[MERGE] forward port branch 12.0 up to 3e4138deaa

closes odoo/odoo#30045
This commit is contained in:
Christophe Simonis
2019-01-09 15:56:53 +00:00
224 changed files with 6215 additions and 1497 deletions
+2
View File
@@ -290,6 +290,8 @@ class AccountAccount(models.Model):
Note that it is disallowed if some lines are partially reconciled.
'''
if not self.ids:
return None
partial_lines_count = self.env['account.move.line'].search_count([
('account_id', 'in', self.ids),
('full_reconcile_id', '=', False),
+6 -3
View File
@@ -1476,9 +1476,11 @@ class AccountInvoice(models.Model):
values = self._prepare_refund(invoice, date_invoice=date_invoice, date=date,
description=description, journal_id=journal_id)
refund_invoice = self.create(values)
invoice_type = {'out_invoice': ('customer invoices credit note'),
'in_invoice': ('vendor bill credit note')}
message = _("This %s has been created from: <a href=# data-oe-model=account.invoice data-oe-id=%d>%s</a><br>Reason: %s") % (invoice_type[invoice.type], invoice.id, invoice.number, description)
if invoice.type == 'out_invoice':
message = _("This customer invoice credit note has been created from: <a href=# data-oe-model=account.invoice data-oe-id=%d>%s</a><br>Reason: %s") % (invoice.id, invoice.number, description)
else:
message = _("This vendor bill credit note has been created from: <a href=# data-oe-model=account.invoice data-oe-id=%d>%s</a><br>Reason: %s") % (invoice.id, invoice.number, description)
refund_invoice.message_post(body=message)
new_invoices += refund_invoice
return new_invoices
@@ -1799,6 +1801,7 @@ class AccountInvoiceLine(models.Model):
else:
price_unit = self.product_id.lst_price
self.price_unit = self.product_id.uom_id._compute_price(price_unit, self.uom_id)
self._set_currency()
if self.product_id.uom_id.category_id.id != self.uom_id.category_id.id:
warning = {
@@ -320,10 +320,10 @@ class account_journal(models.Model):
action_name = 'action_view_bank_statement_tree'
elif self.type == 'sale':
action_name = 'action_invoice_tree1'
self = self.with_context(use_domain=[('type', '=', 'out_invoice')])
self = self.with_context(use_domain=[('type', 'in', ['out_invoice', 'out_refund'])])
elif self.type == 'purchase':
action_name = 'action_vendor_bill_template'
self = self.with_context(use_domain=[('type', '=', 'in_invoice')])
self = self.with_context(use_domain=[('type', 'in', ['in_invoice', 'in_refund'])])
else:
action_name = 'action_move_journal_line'
+2 -2
View File
@@ -1489,7 +1489,7 @@ class AccountPartialReconcile(models.Model):
'analytic_account_id': line.analytic_account_id.id,
'analytic_tag_ids': line.analytic_tag_ids.ids,
'tax_exigible': True,
'amount_currency': self.amount_currency and line.currency_id.round(-line.amount_currency * amount / line.balance) or 0.0,
'amount_currency': line.amount_currency and line.currency_id.round(-line.amount_currency * amount / line.balance) or 0.0,
'currency_id': line.currency_id.id,
'move_id': newly_created_move.id,
'partner_id': line.partner_id.id,
@@ -1504,7 +1504,7 @@ class AccountPartialReconcile(models.Model):
'analytic_tag_ids': line.analytic_tag_ids.ids,
'tax_line_id': line.tax_line_id.id,
'tax_exigible': True,
'amount_currency': self.amount_currency and line.currency_id.round(line.amount_currency * amount / line.balance) or 0.0,
'amount_currency': line.amount_currency and line.currency_id.round(line.amount_currency * amount / line.balance) or 0.0,
'currency_id': line.currency_id.id,
'move_id': newly_created_move.id,
'partner_id': line.partner_id.id,
+9 -7
View File
@@ -164,9 +164,10 @@ class AccountChartTemplate(models.Model):
of accounts had been created for it yet.
"""
self.ensure_one()
if request:
company_id = request.env.user.company_id.id
company = self.env['res.company'].browse(company_id)
# do not use `request.env` here, it can cause deadlocks
if request and request.session.uid:
current_user = self.env['res.users'].browse(request.uid)
company = current_user.company_id
else:
# fallback to company of current user, most likely __system__
# (won't work well for multi-company)
@@ -184,9 +185,10 @@ class AccountChartTemplate(models.Model):
rights.
"""
self.ensure_one()
if request:
company_id = request.env.user.company_id.id
company = self.env['res.company'].browse(company_id)
# do not use `request.env` here, it can cause deadlocks
if request and request.session.uid:
current_user = self.env['res.users'].browse(request.uid)
company = current_user.company_id
else:
# fallback to company of current user, most likely __system__
# (won't work well for multi-company)
@@ -210,7 +212,7 @@ class AccountChartTemplate(models.Model):
prop_values.extend(['account.journal,%s' % (journal_id,) for journal_id in existing_journals.ids])
accounting_props = self.env['ir.property'].search([('value_reference', 'in', prop_values)])
if accounting_props:
accounting_props.unlink()
accounting_props.sudo().unlink()
# delete account, journal, tax, fiscal position and reconciliation model
models_to_delete = ['account.reconcile.model', 'account.fiscal.position', 'account.tax', 'account.move', 'account.journal']
@@ -11,11 +11,13 @@
<record id="group_show_line_subtotals_tax_excluded" model="res.groups">
<field name="name">Tax display B2B</field>
<field name="comment">Show line subtotals without taxes (B2B)</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="group_show_line_subtotals_tax_included" model="res.groups">
<field name="name">Tax display B2C</field>
<field name="comment">Show line subtotals with taxes included (B2C)</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="base.group_public" model="res.groups">
+1
View File
@@ -8,6 +8,7 @@ from . import test_account_validate_account_move
from . import test_account_invoice_rounding
from . import test_bank_statement_reconciliation
from . import test_fiscal_position
from . import test_invoice_onchange
from . import test_reconciliation_widget
from . import test_payment
from . import test_product_id_change
@@ -72,8 +72,15 @@ class TestFiscalPosition(AccountingTestCase):
self.check_compute_fiscal_year(
company,
'2016-03-01',
'2016-02-29',
'2015-03-01',
'2016-02-29',
)
self.check_compute_fiscal_year(
company,
'2016-03-01',
'2016-03-01',
'2017-02-28',
)
@@ -0,0 +1,33 @@
from odoo.addons.account.tests.account_test_classes import AccountingTestCase
from odoo.tests import tagged
from odoo.tests.common import Form
@tagged('post_install', '-at_install')
class TestInvoiceOnchange(AccountingTestCase):
def setUp(self):
super(TestInvoiceOnchange, self).setUp()
self.invoice_line_onchange = self.env['account.invoice.line']._onchange_spec()
self.half_currency = self.env['res.currency'].create({
'name': 'HALF', 'symbol': '$HALF',
'rate_ids': [(0, 0, {'name': '1980-01-01', 'rate': 2})],
})
self.apples_product = self.env['product.product'].create(dict(
self.env['product.product'].default_get(self.env['product.product']._fields),
lst_price=10, name='apples',
))
def test_invoice_currency_onchange(self):
self_ctx = self.env['account.invoice'].with_context(type='out_invoice')
with Form(self_ctx, view='account.invoice_form') as invoice_form:
invoice_form.partner_id = self.env.user.partner_id
with invoice_form.invoice_line_ids.new() as invoice_line_form:
invoice_line_form.product_id = self.apples_product
# Check onchange keep price_unit if currency not changed
self.assertEqual(invoice_line_form.price_unit, 10)
invoice_form.currency_id = self.half_currency
with invoice_form.invoice_line_ids.new() as invoice_line_form:
invoice_line_form.product_id = self.apples_product
# Check onchange gives converted price with custom currency
self.assertEqual(invoice_line_form.price_unit, 20)
+1 -1
View File
@@ -1387,7 +1387,7 @@
<act_window
id="action_move_line_select_tax_audit"
name="Journal Items"
name="Journal Items for Tax Audit"
context="{'search_default_account_id': [active_id]}"
res_model="account.move.line"
src_model="account.account"
@@ -51,7 +51,7 @@ class AccountPayment(models.Model):
@api.onchange('amount','currency_id')
def _onchange_amount(self):
res = super(AccountPayment, self)._onchange_amount()
self.check_amount_in_words = self.currency_id.amount_to_text(self.amount)
self.check_amount_in_words = self.currency_id.amount_to_text(self.amount) if self.currency_id else ''
return res
def _check_communication(self, payment_method_id, communication):
@@ -25,7 +25,13 @@
<!-- Amounts. -->
<ram:SpecifiedLineTradeAgreement>
<t t-set="taxes" t-value="line.invoice_line_tax_ids.compute_all(line.price_unit)"/>
<t t-set="taxes"
t-value="line.invoice_line_tax_ids.compute_all(
line.price_unit,
currency=line.currency_id,
quantity=line.quantity,
product=line.product_id,
partner=record.partner_id)"/>
<ram:GrossPriceProductTradePrice>
<ram:ChargeAmount
t-att-currencyID="currency.name"
@@ -4,5 +4,8 @@
<record id="base.nl" model="res.country">
<field eval="'%(street_name)s %(street_number)s/%(street_number2)s'" name="street_format" />
</record>
<record id="base.mx" model="res.country">
<field eval="'%(street_name)s %(street_number)s/%(street_number2)s'" name="street_format" />
</record>
</data>
</odoo>
@@ -12,6 +12,7 @@ class TestStreetFields(TransactionCase):
self.env.ref('base.be').write({'street_format': '%(street_name)s, %(street_number)s/%(street_number2)s'})
self.env.ref('base.us').write({'street_format': '%(street_number)s/%(street_number2)s %(street_name)s'})
self.env.ref('base.ch').write({'street_format': 'header %(street_name)s, %(street_number)s - %(street_number2)s trailer'})
self.env.ref('base.mx').write({'street_format': '%(street_name)s %(street_number)s/%(street_number2)s'})
def create_and_assert(self, partner_name, country_id, street, street_name, street_number, street_number2):
partner = self.Partner.create({'name': partner_name + '-1', 'street': street, 'country_id': country_id})
@@ -39,6 +40,7 @@ class TestStreetFields(TransactionCase):
self.create_and_assert('Test00', self.env.ref('base.us').id, '40/2b Chaussee de Namur', 'Chaussee de Namur', '40', '2b')
self.create_and_assert('Test01', self.env.ref('base.us').id, '40 Chaussee de Namur', 'Chaussee de Namur', '40', '')
self.create_and_assert('Test02', self.env.ref('base.us').id, 'Chaussee de Namur', 'de Namur', 'Chaussee', '')
self.create_and_assert('Test03', self.env.ref('base.mx').id, 'Av. Miguel Hidalgo y Costilla 601', 'Av.', 'Miguel Hidalgo y Costilla 601', '')
def test_01_header_trailer(self):
self.create_and_assert('Test10', self.env.ref('base.ch').id, 'header Chaussee de Namur, 40 - 2b trailer', 'Chaussee de Namur', '40', '2b')
@@ -19,13 +19,15 @@
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='street']" position="replace">
<field name="street" invisible="1"/>
<field name="street_name" placeholder="Street Name..." attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/>
<div class="o_row">
<label for="street_number" class="oe_edit_only"/>
<div>
<field name="street" class="oe_read_only"/>
</div>
<field name="street_name" placeholder="Street Name..." attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}" class="oe_edit_only"/>
<div class="oe_edit_only o_row">
<label for="street_number"/>
<span> </span>
<field name="street_number" attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/>
<label for="street_number2" class="oe_edit_only"/>
<label for="street_number2"/>
<field name="street_number2" attrs="{'readonly': [('type', '=', 'contact'),('parent_id', '!=', False)]}"/>
</div>
</xpath>
@@ -3,3 +3,4 @@
from . import base_automation
from . import ir_actions
from . import ir_demo
+22
View File
@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrDemo(models.TransientModel):
_inherit = 'ir.demo'
def install_demo(self):
# Prevent the registry to reload while loading demo data, since `create` calls
# `_update_registry`.
self.pool.ready = False
try:
result = super().install_demo()
finally:
self.pool.ready = True
# Reload the registry
self.env['base.automation']._update_registry()
return result
@@ -36,7 +36,7 @@
<div class="content-group">
<div class="mt16" groups="base.group_no_one">
<label for="external_report_layout_id" string="Template" class="col-3 col-lg-3 o_light_label"/>
<field name="external_report_layout_id" class="oe_inline"/>
<field name="external_report_layout_id" domain="[('type','=', 'qweb')]" class="oe_inline"/>
</div>
<div class="mt16">
<button name="change_report_template" string="Change Document Template" type="object" class="oe_link" icon="fa-arrow-right"/>
+1
View File
@@ -21,6 +21,7 @@ this event.
'depends': ['event', 'sale_management'],
'data': [
'views/event_views.xml',
'views/event_views_template.xml',
'views/product_views.xml',
'views/sale_order_views.xml',
'data/event_sale_data.xml',
+1 -1
View File
@@ -86,6 +86,6 @@ class SaleOrderLine(models.Model):
lang=self.order_id.partner_id.lang,
)
return ticket.get_ticket_multiline_description_sale()
return ticket.get_ticket_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants()
else:
return super(SaleOrderLine, self).get_sale_order_line_multiline_description_sale(product)
+13
View File
@@ -0,0 +1,13 @@
odoo.define("event_sale.tour", function (require) {
"use strict";
var tour = require('web_tour.tour');
require('sale.tour');
var step_index = _.findIndex(tour.tours.sale_tour.steps, function (step) {
return (step.id === "form_button_save_clicked");
});
tour.tours.sale_tour.steps.splice(step_index, 0, {
trigger: ".modal-dialog .modal-footer .btn-primary:contains('Save & Close')",
});
});
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend_inherit_event_sale" inherit_id="web.assets_backend" name="Event sale backend assets">
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/event_sale/static/src/js/tour.js"></script>
</xpath>
</template>
</odoo>
+5 -3
View File
@@ -229,9 +229,11 @@ class FleetVehicle(models.Model):
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
domain = args or []
domain = expression.AND([domain, [('name', operator, name)]])
partner_ids = self.env['res.partner']._search([('name', operator, name)], access_rights_uid=name_get_uid)
if partner_ids:
domain = expression.OR([domain, ['|', ('driver_id', 'in', partner_ids), ('driver_id', '=', False)]])
# we don't want to override the domain's filter on driver_id if present
if not any(['driver_id' in element for element in domain]):
partner_ids = self.env['res.partner']._search([('name', operator, name)], access_rights_uid=name_get_uid)
if partner_ids:
domain = expression.OR([domain, ['|', ('driver_id', 'in', partner_ids), ('driver_id', '=', False)]])
rec = self._search(domain, limit=limit, access_rights_uid=name_get_uid)
return self.browse(rec).name_get()
+3 -3
View File
@@ -344,12 +344,12 @@ class Challenge(models.Model):
date_clause = ""
query_params = [line.id]
if start_date:
date_clause += "AND g.start_date = %s"
date_clause += " AND g.start_date = %s"
query_params.append(start_date)
if end_date:
date_clause += "AND g.end_date = %s"
date_clause += " AND g.end_date = %s"
query_params.append(end_date)
query = """SELECT u.id AS user_id
FROM res_users u
LEFT JOIN gamification_goal g
+1
View File
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import main
+12
View File
@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
class HrAttendance(http.Controller):
@http.route('/hr_attendance/kiosk_keepalive', auth='user', type='json')
def kiosk_keepalive(self):
request.httprequest.session.modified = True
return {}
@@ -69,7 +69,7 @@ var KioskMode = AbstractAction.extend({
_callServer: function () {
// Make a call to the database to avoid the auto close of the session
return ajax.rpc("/web/webclient/version_info", {});
return ajax.rpc("/hr_attendance/kiosk_keepalive", {});
},
});
+14 -5
View File
@@ -250,6 +250,12 @@ class HolidaysRequest(models.Model):
('duration_check', "CHECK ( number_of_days >= 0 )", "If you want to change the number of days you should use the 'period' mode"),
]
@api.onchange('holiday_status_id')
def _onchange_holiday_status_id(self):
self.request_unit_half = False
self.request_unit_hours = False
self.request_unit_custom = False
@api.onchange('request_date_from_period', 'request_hour_from', 'request_hour_to',
'request_date_from', 'request_date_to',
'employee_id')
@@ -319,7 +325,7 @@ class HolidaysRequest(models.Model):
@api.onchange('holiday_type')
def _onchange_type(self):
if self.holiday_type == 'employee' and not self.employee_id:
self.employee_id = self.env.user.employee_ids[0].id
self.employee_id = self.env.user.employee_ids[:1].id
self.mode_company_id = False
self.category_id = False
elif self.holiday_type == 'company' and not self.mode_company_id:
@@ -329,7 +335,7 @@ class HolidaysRequest(models.Model):
elif self.holiday_type == 'department' and not self.department_id:
self.employee_id = False
self.mode_company_id = False
self.department_id = self.env.user.employee_ids[0].department_id.id
self.department_id = self.env.user.employee_ids[:1].department_id.id
self.category_id = False
elif self.holiday_type == 'category':
self.employee_id = False
@@ -342,7 +348,7 @@ class HolidaysRequest(models.Model):
if self.employee_id:
self.department_id = self.employee_id.department_id
@api.onchange('date_from', 'date_to')
@api.onchange('date_from', 'date_to', 'employee_id')
def _onchange_leave_dates(self):
if self.date_from and self.date_to:
self.number_of_days = self._get_number_of_days(self.date_from, self.date_to, self.employee_id.id)
@@ -360,8 +366,11 @@ class HolidaysRequest(models.Model):
def _compute_number_of_hours_display(self):
for holiday in self:
calendar = holiday.employee_id.resource_calendar_id or self.env.user.company_id.resource_calendar_id
number_of_hours = calendar.get_work_hours_count(self.date_from, self.date_to)
holiday.number_of_hours_display = number_of_hours or (holiday.number_of_days * HOURS_PER_DAY)
if holiday.date_from and holiday.date_to:
number_of_hours = calendar.get_work_hours_count(holiday.date_from, holiday.date_to)
holiday.number_of_hours_display = number_of_hours or (holiday.number_of_days * HOURS_PER_DAY)
else:
holiday.number_of_hours_display = 0
@api.multi
@api.depends('state', 'employee_id', 'department_id')
+2 -2
View File
@@ -99,7 +99,7 @@ class HolidaysType(models.Model):
@api.multi
@api.depends('validity_start', 'validity_stop')
def _compute_valid(self):
dt = self._context.get('default_date_from') or fields.Datetime.now()
dt = self._context.get('default_date_from') or fields.Date.context_today(self)
for holiday_type in self:
if holiday_type.validity_start and holiday_type.validity_stop:
@@ -110,7 +110,7 @@ class HolidaysType(models.Model):
holiday_type.valid = True
def _search_valid(self, operator, value):
dt = self._context.get('default_date_from') or fields.Datetime.now()
dt = self._context.get('default_date_from') or fields.Date.context_today(self)
signs = ['>=', '<='] if operator == '=' else ['<=', '>=']
+1 -1
View File
@@ -287,7 +287,7 @@
<field name="name">hr.holidays.view.tree</field>
<field name="model">hr.leave</field>
<field name="arch" type="xml">
<tree decoration-danger="state == 'refuse'" decoration-info="state == ' draft'" string="Leave Requests"
<tree decoration-danger="state == 'refuse'" decoration-info="state == 'draft'" string="Leave Requests"
decoration-bf="message_needaction == True">
<field name="employee_id"/>
<field name="department_id"/>
+1 -1
View File
@@ -67,7 +67,7 @@ def jsonrpc(url, method='call', params=None, timeout=15):
e.data = response['error']['data']
raise e
return response.get('result')
except (ValueError, requests.exceptions.ConnectionError, requests.exceptions.MissingSchema, requests.exceptions.Timeout) as e:
except (ValueError, requests.exceptions.ConnectionError, requests.exceptions.MissingSchema, requests.exceptions.Timeout, requests.exceptions.HTTPError) as e:
raise exceptions.AccessError('The url that this service requested returned an error. Please contact the author the app. The url it tried to contact was ' + url)
#----------------------------------------------------------
@@ -1,4 +1,6 @@
.o_history_container{
table-layout: fixed;
width: 200% !important;
> tbody > tr > td {
padding: 0px !important;
}
@@ -7,6 +7,6 @@
<field name="cash_account_code_prefix">570</field>
<field name="transfer_account_code_prefix">580</field>
<field name="currency_id" ref="base.EUR"/>
<field name="spoken_languages" eval="'nl_BE'"/>
<field name="spoken_languages" eval="'nl_BE;nl_NL'"/>
</record>
</odoo>
@@ -9,6 +9,6 @@
<field name="transfer_account_code_prefix">113</field>
<field name="currency_id" ref="base.CAD"/>
<field name="use_anglo_saxon" eval="True"/>
<field name="spoken_languages" eval="'fr_CA'"/>
<field name="spoken_languages" eval="'fr_FR;fr_CA'"/>
</record>
</odoo>
+9
View File
@@ -9,6 +9,9 @@
<record id="tax_group_6" model="account.tax.group">
<field name="name">BTW 6%</field>
</record>
<record id="tax_group_9" model="account.tax.group">
<field name="name">BTW 9%</field>
</record>
<record id="tax_group_21" model="account.tax.group">
<field name="name">BTW 21%</field>
</record>
@@ -18,6 +21,9 @@
<record id="tax_group_6_eu" model="account.tax.group">
<field name="name">BTW 6% EU</field>
</record>
<record id="tax_group_9_eu" model="account.tax.group">
<field name="name">BTW 9% EU</field>
</record>
<record id="tax_group_21_eu" model="account.tax.group">
<field name="name">BTW 21% EU</field>
</record>
@@ -27,6 +33,9 @@
<record id="tax_group_6_niet_eu" model="account.tax.group">
<field name="name">BTW 6% Niet EU</field>
</record>
<record id="tax_group_9_niet_eu" model="account.tax.group">
<field name="name">BTW 9% Niet EU</field>
</record>
<record id="tax_group_21_niet_eu" model="account.tax.group">
<field name="name">BTW 21% Niet EU</field>
</record>
@@ -16,6 +16,11 @@
<field name="tax_src_id" ref="btw_6"/>
<field name="tax_dest_id" ref="btw_X1"/>
</record>
<record id="position_tax_extracom_9" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_non_eu"/>
<field name="tax_src_id" ref="btw_9"/>
<field name="tax_dest_id" ref="btw_X1"/>
</record>
<record id="position_tax_extracom_21" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_non_eu"/>
<field name="tax_src_id" ref="btw_21"/>
@@ -36,6 +41,11 @@
<field name="tax_src_id" ref="btw_6_d"/>
<field name="tax_dest_id" ref="btw_X3"/>
</record>
<record id="position_tax_extracom_d_9" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_non_eu"/>
<field name="tax_src_id" ref="btw_9_d"/>
<field name="tax_dest_id" ref="btw_X3"/>
</record>
<record id="position_tax_extracom_d_21" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_non_eu"/>
<field name="tax_src_id" ref="btw_21_d"/>
@@ -52,6 +62,11 @@
<field name="tax_src_id" ref="btw_6_buy"/>
<field name="tax_dest_id" ref="btw_E1"/>
</record>
<record id="position_tax_extracom_9" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_non_eu"/>
<field name="tax_src_id" ref="btw_9_buy"/>
<field name="tax_dest_id" ref="btw_E1"/>
</record>
<record id="position_tax_extracom_7" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_non_eu"/>
<field name="tax_src_id" ref="btw_21_buy"/>
@@ -67,6 +82,11 @@
<field name="tax_src_id" ref="btw_6_buy_d"/>
<field name="tax_dest_id" ref="btw_E1"/>
</record>
<record id="position_tax_extracom_d_9" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_non_eu"/>
<field name="tax_src_id" ref="btw_9_buy_d"/>
<field name="tax_dest_id" ref="btw_E1"/>
</record>
<record id="position_tax_extracom_d_7" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_non_eu"/>
<field name="tax_src_id" ref="btw_21_buy_d"/>
@@ -89,6 +109,11 @@
<field name="tax_src_id" ref="btw_6"/>
<field name="tax_dest_id" ref="btw_X0_diensten"/>
</record>
<record id="position_tax_intracom_2_9" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_eu"/>
<field name="tax_src_id" ref="btw_9"/>
<field name="tax_dest_id" ref="btw_X0"/>
</record>
<record id="position_tax_intracom_3" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_eu"/>
<field name="tax_src_id" ref="btw_21"/>
@@ -109,6 +134,11 @@
<field name="tax_src_id" ref="btw_6_d"/>
<field name="tax_dest_id" ref="btw_X2"/>
</record>
<record id="position_tax_intracom_d_2_9" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_eu"/>
<field name="tax_src_id" ref="btw_9_d"/>
<field name="tax_dest_id" ref="btw_X2"/>
</record>
<record id="position_tax_intracom_d_3" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_eu"/>
<field name="tax_src_id" ref="btw_21_d"/>
@@ -124,6 +154,11 @@
<field name="tax_src_id" ref="btw_6_buy"/>
<field name="tax_dest_id" ref="btw_I_6"/>
</record>
<record id="position_tax_intracom_9" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_eu"/>
<field name="tax_src_id" ref="btw_9_buy"/>
<field name="tax_dest_id" ref="btw_I_9"/>
</record>
<record id="position_tax_intracom_7" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_eu"/>
<field name="tax_src_id" ref="btw_21_buy"/>
@@ -139,6 +174,11 @@
<field name="tax_src_id" ref="btw_6_buy_d"/>
<field name="tax_dest_id" ref="btw_I_6"/>
</record>
<record id="position_tax_intracom_d_9" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_eu"/>
<field name="tax_src_id" ref="btw_9_buy_d"/>
<field name="tax_dest_id" ref="btw_I_9"/>
</record>
<record id="position_tax_intracom_d_7" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fiscal_position_template_eu"/>
<field name="tax_src_id" ref="btw_21_buy_d"/>
+237 -18
View File
@@ -20,7 +20,7 @@
<record id="btw_6" model="account.tax.template">
<field name="sequence">10</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Verkopen/omzet laag</field>
<field name="name">Verkopen/omzet laag 6%</field>
<field name="description">6% BTW</field>
<field eval="6" name="amount"/>
<field name="amount_type">percent</field>
@@ -30,6 +30,19 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_05'),ref('tag_nl_22')])]"/>
<field name="tax_group_id" ref="tax_group_6"/>
</record>
<record id="btw_9" model="account.tax.template">
<field name="sequence">10</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Verkopen/omzet laag 9%</field>
<field name="description">9% BTW</field>
<field eval="9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_payable_l"/>
<field name="account_id" ref="vat_payable_l"/>
<field name="type_tax_use">sale</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_05'),ref('tag_nl_22')])]"/>
<field name="tax_group_id" ref="tax_group_9"/>
</record>
<record id="btw_21" model="account.tax.template">
<field name="sequence">5</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
@@ -73,7 +86,7 @@
<record id="btw_6_d" model="account.tax.template">
<field name="sequence">10</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Verkopen/omzet laag diensten</field>
<field name="name">Verkopen/omzet laag diensten 6%</field>
<field name="description">6% BTW diensten</field>
<field eval="6" name="amount"/>
<field name="amount_type">percent</field>
@@ -83,6 +96,19 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_05'),ref('tag_nl_22')])]"/>
<field name="tax_group_id" ref="tax_group_6"/>
</record>
<record id="btw_9_d" model="account.tax.template">
<field name="sequence">10</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Verkopen/omzet laag diensten 9%</field>
<field name="description">9% BTW diensten</field>
<field eval="9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_payable_l_d"/>
<field name="account_id" ref="vat_payable_l_d"/>
<field name="type_tax_use">sale</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_05'),ref('tag_nl_22')])]"/>
<field name="tax_group_id" ref="tax_group_9"/>
</record>
<record id="btw_21_d" model="account.tax.template">
<field name="sequence">6</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
@@ -113,7 +139,7 @@
<record id="btw_6_buy" model="account.tax.template">
<field name="sequence">10</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">BTW te vorderen laag (inkopen)</field>
<field name="name">BTW te vorderen laag (inkopen) 6%</field>
<field name="description">6% BTW</field>
<field eval="6" name="amount"/>
<field name="amount_type">percent</field>
@@ -123,10 +149,24 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_6"/>
</record>
<record id="btw_9_buy" model="account.tax.template">
<field name="sequence">10</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">BTW te vorderen laag (inkopen) 9%</field>
<field name="description">9% BTW</field>
<field eval="9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_refund_l"/>
<field name="account_id" ref="vat_refund_l"/>
<field name="type_tax_use">purchase</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_9"/>
</record>
<record id="btw_6_buy_incl" model="account.tax.template">
<field name="sequence">10</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">BTW te vorderen laag (inkopen incl. BTW)</field>
<field name="name">BTW te vorderen laag (inkopen incl. BTW) 6%</field>
<field name="description">6% BTW Incl.</field>
<field eval="6" name="amount"/>
<field name="amount_type">percent</field>
@@ -137,6 +177,20 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_6"/>
</record>
<record id="btw_9_buy_incl" model="account.tax.template">
<field name="sequence">10</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">BTW te vorderen laag (inkopen incl. BTW) 9%</field>
<field name="description">9% BTW Incl.</field>
<field eval="9" name="amount"/>
<field name="amount_type">percent</field>
<field name="price_include">True</field>
<field name="refund_account_id" ref="vat_refund_l"/>
<field name="account_id" ref="vat_refund_l"/>
<field name="type_tax_use">purchase</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_9"/>
</record>
<record id="btw_21_buy" model="account.tax.template">
<field name="sequence">5</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
@@ -181,7 +235,7 @@
<record id="btw_6_buy_d" model="account.tax.template">
<field name="sequence">10</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">BTW te vorderen laag (inkopen) diensten</field>
<field name="name">BTW te vorderen laag (inkopen) diensten 6%</field>
<field name="description">6% BTW diensten</field>
<field eval="6" name="amount"/>
<field name="amount_type">percent</field>
@@ -191,6 +245,19 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_6"/>
</record>
<record id="btw_9_buy_d" model="account.tax.template">
<field name="sequence">10</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">BTW te vorderen laag (inkopen) diensten 9%</field>
<field name="description">9% BTW diensten</field>
<field eval="9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_refund_l_d"/>
<field name="account_id" ref="vat_refund_l_d"/>
<field name="type_tax_use">purchase</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_9"/>
</record>
<record id="btw_21_buy_d" model="account.tax.template">
<field name="sequence">6</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
@@ -231,7 +298,7 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_08')])]"/>
<field name="tax_group_id" ref="tax_group_0"/>
</record>
<record id="btw_ink_0_1" model="account.tax.template">
<record id="btw_ink_0_1" model="account.tax.template">
<field name="sequence">98</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">BTW te vorderen verlegd (inkopen1)</field>
@@ -276,7 +343,7 @@
<record id="btw_I_6_1" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="sequence">21</field>
<field name="name">Inkopen import binnen EU laag(1)</field>
<field name="name">Inkopen import binnen EU laag(1) 6%</field>
<field name="description">Inkopen import binnen EU laag(1)</field>
<field eval="-6" name="amount"/>
<field name="amount_type">percent</field>
@@ -286,10 +353,23 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_30')])]"/>
<field name="tax_group_id" ref="tax_group_6_eu"/>
</record>
<record id="btw_I_9_1" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="sequence">21</field>
<field name="name">Inkopen import binnen EU laag(1) 9%</field>
<field name="description">Inkopen import binnen EU laag(1)</field>
<field eval="-9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_payable_l_eu"/>
<field name="account_id" ref="vat_payable_l_eu"/>
<field name="type_tax_use">none</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_30')])]"/>
<field name="tax_group_id" ref="tax_group_9_eu"/>
</record>
<record id="btw_I_6_2" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="sequence">22</field>
<field name="name">Inkopen import binnen EU laag(2)</field>
<field name="name">Inkopen import binnen EU laag(2) 6%</field>
<field name="description">Inkopen import binnen EU laag(2)</field>
<field eval="6" name="amount"/>
<field name="amount_type">percent</field>
@@ -299,10 +379,23 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_6_eu"/>
</record>
<record id="btw_I_9_2" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="sequence">22</field>
<field name="name">Inkopen import binnen EU laag(2) 9%</field>
<field name="description">Inkopen import binnen EU laag(2)</field>
<field eval="9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_refund_l_eu"/>
<field name="account_id" ref="vat_refund_l_eu"/>
<field name="type_tax_use">none</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_9_eu"/>
</record>
<record id="btw_I_6" model="account.tax.template">
<field name="sequence">20</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import binnen EU laag</field>
<field name="name">Inkopen import binnen EU laag 6%</field>
<field name="description">6% BTW import binnen EU</field>
<field eval="6" name="amount"/>
<field name="amount_type">group</field>
@@ -311,6 +404,18 @@
<field name="children_tax_ids" eval="[(6, 0, [ref('btw_I_6_1'), ref('btw_I_6_2')])]"/>
<field name="tax_group_id" ref="tax_group_6_eu"/>
</record>
<record id="btw_I_9" model="account.tax.template">
<field name="sequence">20</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import binnen EU laag 9%</field>
<field name="description">9% BTW import binnen EU</field>
<field eval="9" name="amount"/>
<field name="amount_type">group</field>
<field name="type_tax_use">purchase</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_17')])]"/>
<field name="children_tax_ids" eval="[(6, 0, [ref('btw_I_9_1'), ref('btw_I_9_2')])]"/>
<field name="tax_group_id" ref="tax_group_9_eu"/>
</record>
<record id="btw_I_21_1" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="sequence">21</field>
@@ -431,7 +536,7 @@
<record id="btw_I_6_d_1" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="sequence">21</field>
<field name="name">Inkopen import binnen EU laag(1) diensten</field>
<field name="name">Inkopen import binnen EU laag(1) diensten 6%</field>
<field name="description">Inkopen import binnen EU laag(1) diensten</field>
<field eval="-6" name="amount"/>
<field name="amount_type">percent</field>
@@ -441,10 +546,23 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_30')])]"/>
<field name="tax_group_id" ref="tax_group_6_eu"/>
</record>
<record id="btw_I_9_d_1" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="sequence">21</field>
<field name="name">Inkopen import binnen EU laag(1) diensten 9%</field>
<field name="description">Inkopen import binnen EU laag(1) diensten</field>
<field eval="-9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_payable_l_d_eu"/>
<field name="account_id" ref="vat_payable_l_d_eu"/>
<field name="type_tax_use">none</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_30')])]"/>
<field name="tax_group_id" ref="tax_group_9_eu"/>
</record>
<record id="btw_I_6_d_2" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="sequence">22</field>
<field name="name">Inkopen import binnen EU laag(2) diensten</field>
<field name="name">Inkopen import binnen EU laag(2) diensten 6%</field>
<field name="description">Inkopen import binnen EU laag(2) diensten</field>
<field eval="6" name="amount"/>
<field name="amount_type">percent</field>
@@ -454,10 +572,23 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_6_eu"/>
</record>
<record id="btw_I_9_d_2" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="sequence">22</field>
<field name="name">Inkopen import binnen EU laag(2) diensten 9%</field>
<field name="description">Inkopen import binnen EU laag(2) diensten</field>
<field eval="9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_refund_l_d_eu"/>
<field name="account_id" ref="vat_refund_l_d_eu"/>
<field name="type_tax_use">none</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_9_eu"/>
</record>
<record id="btw_I_6_d" model="account.tax.template">
<field name="sequence">20</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import binnen EU laag diensten</field>
<field name="name">Inkopen import binnen EU laag diensten 6%</field>
<field name="description">6% BTW import binnen EU diensten</field>
<field eval="6" name="amount"/>
<field name="amount_type">group</field>
@@ -466,6 +597,18 @@
<field name="children_tax_ids" eval="[(6, 0, [ref('btw_I_6_d_1'), ref('btw_I_6_d_2')])]"/>
<field name="tax_group_id" ref="tax_group_6_eu"/>
</record>
<record id="btw_I_9_d" model="account.tax.template">
<field name="sequence">20</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import binnen EU laag diensten 9%</field>
<field name="description">9% BTW import binnen EU diensten</field>
<field eval="9" name="amount"/>
<field name="amount_type">group</field>
<field name="type_tax_use">purchase</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_17')])]"/>
<field name="children_tax_ids" eval="[(6, 0, [ref('btw_I_9_d_1'), ref('btw_I_9_d_2')])]"/>
<field name="tax_group_id" ref="tax_group_9_eu"/>
</record>
<record id="btw_I_21_d_1" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="sequence">21</field>
@@ -547,7 +690,7 @@
<!-- BTW inkoop -->
<record id="btw_E1_1" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag(1)</field>
<field name="name">Inkopen import buiten EU laag(1) 6%</field>
<field name="description">Inkopen import buiten EU laag(1)</field>
<field eval="-6" name="amount"/>
<field name="amount_type">percent</field>
@@ -558,9 +701,22 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_29')])]"/>
<field name="tax_group_id" ref="tax_group_6_niet_eu"/>
</record>
<record id="btw_E1_1_9" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag(1) 9%</field>
<field name="description">Inkopen import buiten EU laag(1)</field>
<field eval="-9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_payable_l_non_eu"/>
<field name="account_id" ref="vat_payable_l_non_eu"/>
<field name="sequence">21</field>
<field name="type_tax_use">none</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_29')])]"/>
<field name="tax_group_id" ref="tax_group_9_niet_eu"/>
</record>
<record id="btw_E1_2" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag(2)</field>
<field name="name">Inkopen import buiten EU laag(2) 6%</field>
<field name="description">Inkopen import buiten EU laag(2)</field>
<field eval="6" name="amount"/>
<field name="amount_type">percent</field>
@@ -571,10 +727,23 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_6_niet_eu"/>
</record>
<record id="btw_E1_2_9" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag(2) 9%</field>
<field name="description">Inkopen import buiten EU laag(2)</field>
<field eval="9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_refund_l_non_eu"/>
<field name="account_id" ref="vat_refund_l_non_eu"/>
<field name="sequence">22</field>
<field name="type_tax_use">none</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_9_niet_eu"/>
</record>
<record id="btw_E1" model="account.tax.template">
<field name="sequence">20</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag</field>
<field name="name">Inkopen import buiten EU laag 6%</field>
<field name="description">BTW import buiten EU laag inkopen</field>
<field eval="6" name="amount"/>
<field name="amount_type">group</field>
@@ -583,6 +752,18 @@
<field name="children_tax_ids" eval="[(6, 0, [ref('btw_E1_1'), ref('btw_E1_2')])]"/>
<field name="tax_group_id" ref="tax_group_6_niet_eu"/>
</record>
<record id="btw_E1_9" model="account.tax.template">
<field name="sequence">20</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag 9%</field>
<field name="description">BTW import buiten EU laag inkopen</field>
<field eval="9" name="amount"/>
<field name="amount_type">group</field>
<field name="type_tax_use">purchase</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_16')])]"/>
<field name="children_tax_ids" eval="[(6, 0, [ref('btw_E1_1_9'), ref('btw_E1_2_9')])]"/>
<field name="tax_group_id" ref="tax_group_9_niet_eu"/>
</record>
<record id="btw_E2_1" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU hoog(1)</field>
@@ -689,7 +870,7 @@
<!-- BTW inkoop diensten -->
<record id="btw_E1_d_1" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag(1) diensten</field>
<field name="name">Inkopen import buiten EU laag(1) diensten 6%</field>
<field name="description">Inkopen import buiten EU laag(1) diensten</field>
<field eval="-6" name="amount"/>
<field name="amount_type">percent</field>
@@ -700,9 +881,22 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_29')])]"/>
<field name="tax_group_id" ref="tax_group_6_niet_eu"/>
</record>
<record id="btw_E1_d_1_9" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag(1) diensten 9%</field>
<field name="description">Inkopen import buiten EU laag(1) diensten</field>
<field eval="-9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_payable_l_d_non_eu"/>
<field name="account_id" ref="vat_payable_l_d_non_eu"/>
<field name="sequence">21</field>
<field name="type_tax_use">none</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_29')])]"/>
<field name="tax_group_id" ref="tax_group_9_niet_eu"/>
</record>
<record id="btw_E1_d_2" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag(2) diensten</field>
<field name="name">Inkopen import buiten EU laag(2) diensten 6%</field>
<field name="description">Inkopen import buiten EU laag(2) diensten</field>
<field eval="6" name="amount"/>
<field name="amount_type">percent</field>
@@ -713,10 +907,23 @@
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_6_niet_eu"/>
</record>
<record id="btw_E1_d_2_9" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag(2) diensten 9%</field>
<field name="description">Inkopen import buiten EU laag(2) diensten</field>
<field eval="9" name="amount"/>
<field name="amount_type">percent</field>
<field name="refund_account_id" ref="vat_refund_l_d_non_eu"/>
<field name="account_id" ref="vat_refund_l_d_non_eu"/>
<field name="sequence">22</field>
<field name="type_tax_use">none</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_33')])]"/>
<field name="tax_group_id" ref="tax_group_9_niet_eu"/>
</record>
<record id="btw_E1_d" model="account.tax.template">
<field name="sequence">20</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag diensten</field>
<field name="name">Inkopen import buiten EU laag diensten 6%</field>
<field name="description">BTW import buiten EU laag inkopen diensten</field>
<field eval="6" name="amount"/>
<field name="amount_type">group</field>
@@ -725,6 +932,18 @@
<field name="children_tax_ids" eval="[(6, 0, [ref('btw_E1_d_1'), ref('btw_E1_d_2')])]"/>
<field name="tax_group_id" ref="tax_group_6_niet_eu"/>
</record>
<record id="btw_E1_d_9" model="account.tax.template">
<field name="sequence">20</field>
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU laag diensten 9%</field>
<field name="description">BTW import buiten EU laag inkopen diensten</field>
<field eval="9" name="amount"/>
<field name="amount_type">group</field>
<field name="type_tax_use">purchase</field>
<field name="tag_ids" eval="[(6,0,[ref('tag_nl_16')])]"/>
<field name="children_tax_ids" eval="[(6, 0, [ref('btw_E1_d_1_9'), ref('btw_E1_d_2_9')])]"/>
<field name="tax_group_id" ref="tax_group_9_niet_eu"/>
</record>
<record id="btw_E2_d_1" model="account.tax.template">
<field name="chart_template_id" ref="l10nnl_chart_template"/>
<field name="name">Inkopen import buiten EU hoog(1) diensten</field>
+3
View File
@@ -9,6 +9,9 @@
<record id="tax_group_10" model="account.tax.group">
<field name="name">MVA 10%</field>
</record>
<record id="tax_group_12" model="account.tax.group">
<field name="name">MVA 12%</field>
</record>
<record id="tax_group_15" model="account.tax.group">
<field name="name">MVA 15%</field>
</record>
+26 -3
View File
@@ -103,7 +103,19 @@
<field name="account_id" ref="chart2704"/>
<field name="refund_account_id" ref="chart2704"/>
<field name="tag_ids" eval="[(6,0,[ref('tag_no_06'),ref('tag_no_15')])]"/>
<field name="tax_group_id" ref="tax_group_10"/>
<field name="tax_group_id" ref="tax_group_10"/>
</record>
<record id="tax3b" model="account.tax.template">
<field name="chart_template_id" ref="no_chart_template"/>
<field name="name">Utgående 12% mva</field>
<field name="description">Utgående 12% mva</field>
<field name="amount">12</field>
<field name="amount_type">percent</field>
<field name="type_tax_use">sale</field>
<field name="account_id" ref="chart2704"/>
<field name="refund_account_id" ref="chart2704"/>
<field name="tag_ids" eval="[(6,0,[ref('tag_no_06'),ref('tag_no_15')])]"/>
<field name="tax_group_id" ref="tax_group_12"/>
</record>
<record id="tax4" model="account.tax.template">
<field name="chart_template_id" ref="no_chart_template"/>
@@ -139,9 +151,20 @@
<field name="account_id" ref="chart2714"/>
<field name="refund_account_id" ref="chart2714"/>
<field name="tag_ids" eval="[(6,0,[ref('tag_no_09'),ref('tag_no_12')])]"/>
<field name="tax_group_id" ref="tax_group_10"/>
<field name="tax_group_id" ref="tax_group_10"/>
</record>
<record id="tax6b" model="account.tax.template">
<field name="chart_template_id" ref="no_chart_template"/>
<field name="name">Inngående 12% mva</field>
<field name="description">Inngående 12% mva</field>
<field name="amount">12</field>
<field name="amount_type">percent</field>
<field name="type_tax_use">purchase</field>
<field name="account_id" ref="chart2714"/>
<field name="refund_account_id" ref="chart2714"/>
<field name="tag_ids" eval="[(6,0,[ref('tag_no_09'),ref('tag_no_12')])]"/>
<field name="tax_group_id" ref="tax_group_12"/>
</record>
<record id="tax7" model="account.tax.template">
<field name="chart_template_id" ref="no_chart_template"/>
<field name="name">Inngående 0% mva</field>
+1 -1
View File
@@ -314,7 +314,7 @@
<div>
<p>
<strong><span t-attf-class="fa #{activity.activity_type_id.icon}"/></strong> <span t-field="activity.activity_type_id.name"/> done
by <span t-field="activity.user_id.name"/><span t-if="activity.summary">:</span>
by <span t-field="activity.env.user.name"/><span t-if="activity.summary">:</span>
<span t-if="activity.summary" t-field="activity.summary"/>
</p>
<div t-field="activity.note" style="margin-left: 8px;"/>
+4 -3
View File
@@ -280,6 +280,7 @@ class Channel(models.Model):
channel_partner = self.mapped('channel_last_seen_partner_ids').filtered(lambda cp: cp.partner_id == self.env.user.partner_id)
if not channel_partner:
return self.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id})]})
return False
@api.multi
def action_unfollow(self):
@@ -852,12 +853,12 @@ class Channel(models.Model):
@api.multi
def channel_join_and_get_info(self):
self.ensure_one()
if self.channel_type == 'channel' and not self.email_send:
added = self.action_follow()
if added and self.channel_type == 'channel' and not self.email_send:
notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (self.id, self.name,)
self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment")
self.action_follow()
if self.moderation_guidelines:
if added and self.moderation_guidelines:
self._send_guidelines(self.env.user.partner_id)
channel_info = self.channel_info('join')[0]
@@ -23,6 +23,10 @@ DebugManager.include({
views: [[false, 'list'], [false, 'form']],
type: 'ir.actions.act_window',
domain: [['res_id', '=', selectedIDs[0]], ['model', '=', this._controller.modelName]],
context: {
default_res_model: this._controller.modelName,
default_res_id: selectedIDs[0],
},
});
},
});
@@ -14,6 +14,10 @@ QUnit.module('Mail DebugManager', {}, function () {
intercepts: {
do_action: function (event) {
assert.deepEqual(event.data.action, {
context: {
default_res_model: "testModel",
default_res_id: 5,
},
res_model: 'mail.message',
name: "Manage Messages",
views: [[false, 'list'], [false, 'form']],
+1 -1
View File
@@ -68,7 +68,7 @@ class MailResendMessage(models.TransientModel):
rdata = []
for pid, cid, active, pshare, ctype, notif, groups in self.env['mail.followers']._get_recipient_data(None, False, pids=to_send.ids):
if pid and notif == 'email' or not notif:
pdata = {'id': pid, 'share': pshare, 'active': active, 'notif': 'email', 'groups': groups}
pdata = {'id': pid, 'share': pshare, 'active': active, 'notif': 'email', 'groups': groups or []}
if not pshare and notif: # has an user and is not shared, is therefore user
rdata.append(dict(pdata, type='user'))
elif pshare and notif: # has an user and is shared, is therefore portal
@@ -12,15 +12,9 @@ var MailBotService = AbstractService.extend({
* @override
*/
start: function () {
var self = this;
this._hasRequest = (window.Notification && window.Notification.permission === "default") || false;
if ('odoobot_initialized' in session && ! session.odoobot_initialized) {
setTimeout(function () {
session.odoobot_initialized = true;
self._rpc({
model: 'mail.channel',
method: 'init_odoobot',
});
}, 2*60*1000);
this._showOdoobotTimeout();
}
},
@@ -60,7 +54,31 @@ var MailBotService = AbstractService.extend({
* @returns {boolean}
*/
isRequestingForNativeNotifications: function () {
return window.Notification && window.Notification.permission === "default";
return this._hasRequest;
},
/**
* Called when user either accepts or refuses push notifications.
*/
removeRequest: function () {
this._hasRequest = false;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_showOdoobotTimeout: function () {
var self = this;
setTimeout(function () {
session.odoobot_initialized = true;
self._rpc({
model: 'mail.channel',
method: 'init_odoobot',
});
}, 2*60*1000);
},
});
@@ -56,6 +56,7 @@ return MessagingMenu.include({
* @param {string} value
*/
_handleResponseNotificationPermission: function (value) {
this.call('mailbot_service', 'removeRequest');
if (value !== 'granted') {
this.call('bus_service', 'sendNotification', _t('Permission denied'),
_t('Odoo will not have the permission to send native notifications on this device.'));
@@ -24,31 +24,44 @@ QUnit.module('MessagingMenu', {
},
};
// Patch mailbot_service so that it does do not do any RPC, and
// the detection of push notifications permissions can be simulated in
// the test cases. By default, shows 'OdooBot has a request'
this.services = _.extend({}, mailTestUtils.getMailServices(), {
mailbot_service: MailBotService
});
// By default, permission are to ask user for push notification ("default").
// Use requestPermissionDef to simulate permission change, e.g. "granted"
this.requestPermissionDef = $.Deferred();
this.ORIGINAL_WINDOW_NOTIFICATION = window.Notification;
window.Notification = {
permission: "default",
requestPermission: function () {
return self.requestPermissionDef;
},
};
// Patch mailbot_service so that it does do not do any RPC
this.isMailbotRequesting = true;
testUtils.mock.patch(MailBotService, {
/**
* @override
*/
start: function () {},
/**
* @override
* @returns {boolean}
*/
isRequestingForNativeNotifications: function () {
return self.isMailbotRequesting;
},
_showOdoobotTimeout: function () {},
});
this.services = _.extend({}, mailTestUtils.getMailServices(), {
mailbot_service: MailBotService
// Patch Bus Service so that it does not play any audio (may raise
// Uncaught rejected Promise due to Chrome autoplay policy: https://goo.gl/xX8pDD)
testUtils.patch(this.services.bus_service, {
/**
* @override
*/
_beep: function () {},
});
},
afterEach: function () {
// unpatch MailBotService
// unpatch MailBotService and BusService
testUtils.mock.unpatch(MailBotService);
testUtils.mock.unpatch(this.services.bus_service);
window.Notification = this.ORIGINAL_WINDOW_NOTIFICATION;
}
});
@@ -79,10 +92,10 @@ QUnit.test('messaging menu widget: rendering with OdooBot has a request', functi
messagingMenu.destroy();
});
QUnit.test('messaging menu widget: rendering without OdooBot has a request', function (assert) {
QUnit.test('messaging menu widget: rendering without OdooBot has a request (denied)', function (assert) {
assert.expect(3);
this.isMailbotRequesting = false;
window.Notification.permission = 'denied';
var messagingMenu = new MessagingMenu();
testUtils.mock.addMockEnvironment(messagingMenu, {
@@ -102,6 +115,60 @@ QUnit.test('messaging menu widget: rendering without OdooBot has a request', fun
messagingMenu.destroy();
});
QUnit.test('messaging menu widget: rendering without OdooBot has a request (accepted)', function (assert) {
assert.expect(3);
window.Notification.permission = 'granted';
var messagingMenu = new MessagingMenu();
testUtils.addMockEnvironment(messagingMenu, {
data: this.data,
services: this.services,
});
messagingMenu.appendTo($('#qunit-fixture'));
assert.containsOnce(messagingMenu, '.o_notification_counter',
"should display a notification counter next to the messaging menu");
assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '0',
"should display a counter of '0' next to the messaging menu");
testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
assert.containsNone(messagingMenu, '.o_preview_info',
"should display no preview in the messaging menu");
messagingMenu.destroy();
});
QUnit.test('messaging menu widget: respond to notification prompt', function (assert) {
assert.expect(4);
var messagingMenu = new MessagingMenu();
testUtils.addMockEnvironment(messagingMenu, {
data: this.data,
services: this.services,
});
messagingMenu.appendTo($('#qunit-fixture'));
assert.containsOnce(messagingMenu, '.o_notification_counter',
"should display a notification counter next to the messaging menu");
assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '1',
"should display a counter of '1' next to the messaging menu");
testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
testUtils.dom.click(messagingMenu.$('.o_preview_info'));
// simulate "default" response, which is equivalent to "Not Now" in Firefox.
this.requestPermissionDef.resolve("default");
assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '0',
"should display a counter of '0' next to the messaging menu");
testUtils.dom.click(messagingMenu.$('.dropdown-toggle'));
assert.containsNone(messagingMenu, '.o_preview_info',
"should display no preview in the messaging menu");
messagingMenu.destroy();
});
});
});
+1 -1
View File
@@ -291,7 +291,7 @@ class MrpBomLine(models.Model):
@api.onchange('parent_product_tmpl_id')
def onchange_parent_product(self):
return {'domain': {'attribute_value_ids': [
('id', 'in', self.parent_product_tmpl_id.mapped('attribute_line_ids.value_ids.id')),
('id', 'in', self.parent_product_tmpl_id._get_valid_product_attribute_values().ids),
('attribute_id.create_variant', '!=', 'no_variant')
]}}
+1 -1
View File
@@ -46,7 +46,7 @@ class MrpProductProduce(models.TransientModel):
product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure')
lot_id = fields.Many2one('stock.production.lot', string='Lot/Serial Number')
produce_line_ids = fields.One2many('mrp.product.produce.line', 'product_produce_id', string='Product to Track')
product_tracking = fields.Selection(related="product_id.tracking", readonly=False)
product_tracking = fields.Selection(related="product_id.tracking", readonly=True)
def action_generate_serial(self):
self.ensure_one()
@@ -56,6 +56,12 @@ var PartnerAutocompleteMixin = {
});
}
odooSuggestions = _.filter(odooSuggestions, function (suggestion) {
return !suggestion.ignored;
});
_.each(odooSuggestions, function(suggestion){
delete suggestion.ignored;
});
return def.resolve(odooSuggestions);
};
@@ -123,6 +123,11 @@ class PaymentWizard(models.TransientModel):
})
if self.payment_method == 'manual':
manual_acquirer = self._get_manual_payment_acquirer(new_env)
if not manual_acquirer:
raise UserError(_(
'No manual payment method could be found for this company. ' +
'Please create one from the Payment Acquirer menu.'
))
manual_acquirer.name = self.manual_name
manual_acquirer.post_msg = self.manual_post_msg
manual_acquirer.website_published = True
@@ -21,3 +21,8 @@ $font-size-sm: (12 / 16) * 1rem;
$btn-padding-y-sm: (1 / 16) * 1rem;
$btn-padding-x-sm: (5 / 16) * 1rem;
// Modals
$modal-lg: $o-modal-lg;
$modal-md: $o-modal-md;
+1 -1
View File
@@ -168,7 +168,7 @@ hr {
.input-group {
flex-flow: row nowrap;
}
.list-group-item:not([class*="list-group-item-"]) {
.list-group-item:not([class*="list-group-item-"]):not(.active) {
color: color-yiq($list-group-bg);
}
+1 -1
View File
@@ -28,7 +28,7 @@
<nav class="navbar navbar-expand-md navbar-light bg-light">
<div class="container">
<a href="/" class="navbar-brand logo">
<span t-field="res_company.logo" t-options="{'widget': 'image'}" t-att-alt="'Logo of %s' % res_company.name" t-att-title="res_company.name"/>
<img t-att-src="'/logo.png?company=%s' % res_company.id" t-att-alt="'Logo of %s' % res_company.name" t-att-title="res_company.name"/>
</a>
<button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#top_menu_collapse">
<span class="navbar-toggler-icon"/>
+10
View File
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- We want to activate product variant by default for easier demoing. -->
<record id="base.group_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('product.group_product_variant'))]"/>
</record>
<record id="product_category_2" model="product.category">
<field name="parent_id" ref="product.product_category_all"/>
@@ -124,26 +128,32 @@
<record id="product_attribute_1" model="product.attribute">
<field name="name">Legs</field>
<field name="sequence">1</field>
</record>
<record id="product_attribute_value_1" model="product.attribute.value">
<field name="name">Steel</field>
<field name="attribute_id" ref="product_attribute_1"/>
<field name="sequence">1</field>
</record>
<record id="product_attribute_value_2" model="product.attribute.value">
<field name="name">Aluminium</field>
<field name="attribute_id" ref="product_attribute_1"/>
<field name="sequence">2</field>
</record>
<record id="product_attribute_2" model="product.attribute">
<field name="name">Color</field>
<field name="sequence">2</field>
</record>
<record id="product_attribute_value_3" model="product.attribute.value">
<field name="name">White</field>
<field name="attribute_id" ref="product_attribute_2"/>
<field name="sequence">1</field>
</record>
<record id="product_attribute_value_4" model="product.attribute.value">
<field name="name">Black</field>
<field name="attribute_id" ref="product_attribute_2"/>
<field name="sequence">2</field>
</record>
<record id="product_attribute_3" model="product.attribute">
+78 -10
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import re
from odoo import api, fields, models, tools, _
@@ -11,6 +12,8 @@ from odoo.addons import decimal_precision as dp
from odoo.tools import float_compare
_logger = logging.getLogger(__name__)
class ProductCategory(models.Model):
_name = "product.category"
@@ -83,13 +86,16 @@ class ProductProduct(models.Model):
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'default_code, name, id'
# price: total price, context dependent (partner, pricelist, quantity)
price = fields.Float(
'Price', compute='_compute_product_price',
digits=dp.get_precision('Product Price'), inverse='_set_product_price')
# price_extra: catalog extra value only, sum of variant extra attributes
price_extra = fields.Float(
'Variant Price Extra', compute='_compute_product_price_extra',
digits=dp.get_precision('Product Price'),
help="This is the sum of the extra price of all attributes")
# lst_price: catalog value + extra, context dependent (uom)
lst_price = fields.Float(
'Public Price', compute='_compute_product_lst_price',
digits=dp.get_precision('Product Price'), inverse='_set_product_lst_price',
@@ -161,8 +167,8 @@ class ProductProduct(models.Model):
pricelist_id_or_name = self._context.get('pricelist')
if pricelist_id_or_name:
pricelist = None
partner = self._context.get('partner', False)
quantity = self._context.get('quantity', 1.0)
partner = self.env.context.get('partner', False)
quantity = self.env.context.get('quantity', 1.0)
# Support context pricelists specified as display_name or ID for compatibility
if isinstance(pricelist_id_or_name, str):
@@ -276,11 +282,29 @@ class ProductProduct(models.Model):
else:
self.product_tmpl_id.image = image
@api.depends('product_tmpl_id', 'attribute_value_ids')
def _compute_product_template_attribute_value_ids(self):
# Fetch and pre-map the values first for performance. It assumes there
# won't be too many values, but there might be a lot of products.
values = self.env['product.template.attribute.value'].search([
('product_tmpl_id', 'in', self.mapped('product_tmpl_id').ids),
('product_attribute_value_id', 'in', self.mapped('attribute_value_ids').ids),
])
values_per_template = {}
for ptav in values:
pt_id = ptav.product_tmpl_id.id
if pt_id not in values_per_template:
values_per_template[pt_id] = {}
values_per_template[pt_id][ptav.product_attribute_value_id.id] = ptav
for product in self:
product.product_template_attribute_value_ids = self.env['product.template.attribute.value']._search([
('product_tmpl_id', '=', product.product_tmpl_id.id),
('product_attribute_value_id', 'in', product.attribute_value_ids.ids)])
product.product_template_attribute_value_ids = self.env['product.template.attribute.value']
for pav in product.attribute_value_ids:
if product.product_tmpl_id.id not in values_per_template or pav.id not in values_per_template[product.product_tmpl_id.id]:
_logger.warning("A matching product.template.attribute.value was not found for the product.attribute.value #%s on the template #%s" % (pav.id, product.product_tmpl_id.id))
else:
product.product_template_attribute_value_ids += values_per_template[product.product_tmpl_id.id][pav.id]
@api.one
def _get_pricelist_items(self):
@@ -387,17 +411,36 @@ class ProductProduct(models.Model):
self.check_access_rule("read")
result = []
# Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields
# Use `load=False` to not call `name_get` for the `product_tmpl_id`
self.sudo().read(['name', 'default_code', 'product_tmpl_id', 'attribute_value_ids'], load=False)
product_template_ids = self.sudo().mapped('product_tmpl_id').ids
if partner_ids:
supplier_info = self.env['product.supplierinfo'].sudo().search([
('product_tmpl_id', 'in', product_template_ids),
('name', 'in', partner_ids),
])
# Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields
# Use `load=False` to not call `name_get` for the `product_tmpl_id` and `product_id`
supplier_info.sudo().read(['product_tmpl_id', 'product_id', 'product_name', 'product_code'], load=False)
supplier_info_by_template = {}
for r in supplier_info:
supplier_info_by_template.setdefault(r.product_tmpl_id, []).append(r)
for product in self.sudo():
# display only the attributes with multiple possible values on the template
variable_attributes = product.attribute_line_ids.filtered(lambda l: len(l.value_ids) > 1).mapped('attribute_id')
variable_attributes = product.attribute_value_ids.filtered(lambda v: len(v.attribute_id.value_ids) > 1).mapped('attribute_id')
variant = product.attribute_value_ids._variant_name(variable_attributes)
name = variant and "%s (%s)" % (product.name, variant) or product.name
sellers = []
if partner_ids:
sellers = [x for x in product.seller_ids if (x.name.id in partner_ids) and (x.product_id == product)]
product_supplier_info = supplier_info_by_template.get(product.product_tmpl_id, [])
sellers = [x for x in product_supplier_info if x.product_id and x.product_id == product]
if not sellers:
sellers = [x for x in product.seller_ids if (x.name.id in partner_ids) and not x.product_id]
sellers = [x for x in product_supplier_info if not x.product_id]
# Filter out sellers based on the company. This is done afterwards for a better
# code readability. At this point, only a few sellers should remain, so it should
# not be a performance issue.
@@ -614,9 +657,34 @@ class ProductProduct(models.Model):
:return: True if the attibutes and values are correct, False instead
"""
self.ensure_one()
values = self.attribute_value_ids.filtered(lambda v: v.attribute_id.create_variant != 'no_variant')
values = self.attribute_value_ids
attributes = values.mapped('attribute_id')
return attributes == valid_attributes and values <= valid_values
if attributes != valid_attributes:
return False
for value in values:
if value not in valid_values:
return False
return True
@api.multi
def _is_variant_possible(self, parent_combination=None):
"""Return whether the variant is possible based on its own combination,
and optionally a parent combination.
See `_is_combination_possible` for more information.
This will always exclude variants for templates that have `no_variant`
attributes because the variant itself will not be the full combination.
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: ẁhether the variant is possible based on its own combination
:rtype: bool
"""
self.ensure_one()
return self.product_tmpl_id._is_combination_possible(self.product_template_attribute_value_ids, parent_combination=parent_combination)
@api.multi
def toggle_active(self):
+58 -10
View File
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.osv import expression
from odoo import api, fields, models, _
from odoo.addons import decimal_precision as dp
from odoo.exceptions import UserError, ValidationError
@@ -11,11 +10,13 @@ from odoo.osv import expression
class ProductAttribute(models.Model):
_name = "product.attribute"
_description = "Product Attribute"
_order = 'sequence, name'
# if you change this _order, keep it in sync with the method
# `_sort_key_attribute_value` in `product.template`
_order = 'sequence, id'
name = fields.Char('Attribute', required=True, translate=True)
value_ids = fields.One2many('product.attribute.value', 'attribute_id', 'Values', copy=True)
sequence = fields.Integer('Sequence', help="Determine the display order")
sequence = fields.Integer('Sequence', help="Determine the display order", index=True)
attribute_line_ids = fields.One2many('product.template.attribute.line', 'attribute_id', 'Lines')
create_variant = fields.Selection([
('no_variant', 'Never'),
@@ -25,15 +26,43 @@ class ProductAttribute(models.Model):
string="Create Variants",
help="Check this if you want to create multiple variants for this attribute.", required=True)
@api.multi
def _without_no_variant_attributes(self):
return self.filtered(lambda pa: pa.create_variant != 'no_variant')
@api.multi
def write(self, vals):
"""Override to make sure attribute type can't be changed if it's used on
a product template.
This is important to prevent because changing the type would make
existing combinations invalid without recomputing them, and recomputing
them might take too long and we don't want to change products without
the user knowing about it."""
if 'create_variant' in vals:
products = self._get_related_product_templates()
if products:
message = ', '.join(products.mapped('name'))
raise UserError(_('You are trying to change the type of an attribute value still referenced on at least one product template: %s') % message)
return super(ProductAttribute, self).write(vals)
@api.multi
def _get_related_product_templates(self):
return self.env['product.template'].with_context(active_test=False).search([
('attribute_line_ids.attribute_id', 'in', self.ids),
])
class ProductAttributeValue(models.Model):
_name = "product.attribute.value"
# if you change this _order, keep it in sync with the method
# `_sort_key_variant` in `product.template'
_order = 'attribute_id, sequence, id'
_description = 'Attribute Value'
name = fields.Char(string='Value', required=True, translate=True)
sequence = fields.Integer(string='Sequence', help="Determine the display order")
attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='cascade', required=True)
sequence = fields.Integer(string='Sequence', help="Determine the display order", index=True)
attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='cascade', required=True, index=True)
_sql_constraints = [
('value_company_uniq', 'unique (name, attribute_id)', 'This attribute value already exists !')
@@ -51,11 +80,21 @@ class ProductAttributeValue(models.Model):
@api.multi
def unlink(self):
linked_products = self.env['product.product'].with_context(active_test=False).search([('attribute_value_ids', 'in', self.ids)])
linked_products = self._get_related_product_templates()
if linked_products:
raise UserError(_('The operation cannot be completed:\nYou are trying to delete an attribute value with a reference on a product variant.'))
return super(ProductAttributeValue, self).unlink()
@api.multi
def _without_no_variant_attributes(self):
return self.filtered(lambda pav: pav.attribute_id.create_variant != 'no_variant')
@api.multi
def _get_related_product_templates(self):
return self.env['product.template'].with_context(active_test=False).search([
('attribute_line_ids.value_ids', 'in', self.ids),
])
class ProductTemplateAttributeLine(models.Model):
"""Attributes available on product.template with their selected values in a m2m.
@@ -64,9 +103,10 @@ class ProductTemplateAttributeLine(models.Model):
_name = "product.template.attribute.line"
_rec_name = 'attribute_id'
_description = 'Product Template Attribute Line'
_order = 'attribute_id, id'
product_tmpl_id = fields.Many2one('product.template', string='Product Template', ondelete='cascade', required=True)
attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='restrict', required=True)
product_tmpl_id = fields.Many2one('product.template', string='Product Template', ondelete='cascade', required=True, index=True)
attribute_id = fields.Many2one('product.attribute', string='Attribute', ondelete='restrict', required=True, index=True)
value_ids = fields.Many2many('product.attribute.value', string='Attribute Values')
product_template_value_ids = fields.Many2many(
'product.template.attribute.value',
@@ -76,7 +116,7 @@ class ProductTemplateAttributeLine(models.Model):
@api.constrains('value_ids', 'attribute_id')
def _check_valid_attribute(self):
if any(line.value_ids > line.attribute_id.value_ids for line in self):
if any(not line.value_ids or line.value_ids > line.attribute_id.value_ids for line in self):
raise ValidationError(_('You cannot use this attribute with the following value.'))
return True
@@ -152,13 +192,17 @@ class ProductTemplateAttributeLine(models.Model):
return self.browse(attribute_ids).name_get()
return super(ProductTemplateAttributeLine, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
@api.multi
def _without_no_variant_attributes(self):
return self.filtered(lambda ptal: ptal.attribute_id.create_variant != 'no_variant')
class ProductTemplateAttributeValue(models.Model):
"""Materialized relationship between attribute values
and product template generated by the product.template.attribute.line"""
_name = "product.template.attribute.value"
_order = 'sequence, attribute_id, id'
_order = 'product_attribute_value_id, id'
_description = 'Product Attribute Value'
name = fields.Char('Value', related="product_attribute_value_id.name")
@@ -192,6 +236,10 @@ class ProductTemplateAttributeValue(models.Model):
return super(ProductTemplateAttributeValue, self).name_get()
return [(value.id, "%s: %s" % (value.attribute_id.name, value.name)) for value in self]
@api.multi
def _without_no_variant_attributes(self):
return self.filtered(lambda ptav: ptav.attribute_id.create_variant != 'no_variant')
class ProductTemplateAttributeExclusion(models.Model):
_name = "product.template.attribute.exclusion"
+421 -87
View File
@@ -2,14 +2,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import itertools
import operator
import psycopg2
from odoo.addons import decimal_precision as dp
from odoo import api, fields, models, tools, _
from odoo.exceptions import ValidationError, RedirectWarning, except_orm
from odoo.exceptions import UserError
from odoo.exceptions import ValidationError, RedirectWarning, UserError
from odoo.osv import expression
class ProductTemplate(models.Model):
@@ -66,13 +64,16 @@ class ProductTemplate(models.Model):
'res.currency', 'Currency', compute='_compute_currency_id')
# price fields
# price: total template price, context dependent (partner, pricelist, quantity)
price = fields.Float(
'Price', compute='_compute_template_price', inverse='_set_template_price',
digits=dp.get_precision('Product Price'))
# list_price: catalog price, user defined
list_price = fields.Float(
'Sale Price', default=1.0,
digits=dp.get_precision('Product Price'),
help="Price at which the product is sold to customers.")
# lst_price: catalog price for template, but including extra for variants
lst_price = fields.Float(
'Public Price', related='list_price', readonly=False,
digits=dp.get_precision('Product Price'))
@@ -177,8 +178,8 @@ class ProductTemplate(models.Model):
pricelist_id_or_name = self._context.get('pricelist')
if pricelist_id_or_name:
pricelist = None
partner = self._context.get('partner')
quantity = self._context.get('quantity', 1.0)
partner = self.env.context.get('partner')
quantity = self.env.context.get('quantity', 1.0)
# Support context pricelists specified as display_name or ID for compatibility
if isinstance(pricelist_id_or_name, str):
@@ -379,6 +380,8 @@ class ProductTemplate(models.Model):
@api.multi
def name_get(self):
# Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields
self.read(['name', 'default_code'])
return [(template.id, '%s%s' % (template.default_code and '[%s] ' % template.default_code or '', template.name))
for template in self]
@@ -455,14 +458,16 @@ class ProductTemplate(models.Model):
def create_variant_ids(self):
Product = self.env["product.product"]
variants_to_create = []
variants_to_activate = []
variants_to_unlink = []
for tmpl_id in self.with_context(active_test=False):
# Handle the variants for each template separately. This will be
# less efficient when called on a lot of products with few variants
# but it is better when there's a lot of variants on one template.
variants_to_create = []
variants_to_activate = self.env['product.product']
variants_to_unlink = self.env['product.product']
# adding an attribute with only one value should not recreate product
# write this attribute on every product to make sure we don't lose them
variant_alone = tmpl_id.attribute_line_ids.filtered(lambda line: line.attribute_id.create_variant == 'always' and len(line.value_ids) == 1).mapped('value_ids')
variant_alone = tmpl_id._get_valid_product_template_attribute_lines().filtered(lambda line: line.attribute_id.create_variant == 'always' and len(line.value_ids) == 1).mapped('value_ids')
for value_id in variant_alone:
updated_products = tmpl_id.product_variant_ids.filtered(lambda product: value_id.attribute_id not in product.mapped('attribute_value_ids.attribute_id'))
updated_products.write({'attribute_value_ids': [(4, value_id.id)]})
@@ -472,15 +477,15 @@ class ProductTemplate(models.Model):
# process.
# Technical note: if there is no attribute, a variant is still created because
# 'not any([])' and 'set([]) not in set([])' are True.
if not any(attrib.create_variant == 'dynamic' for attrib in tmpl_id.mapped('attribute_line_ids.attribute_id')):
# Iterator containing all possible attribute values combination
if not tmpl_id.has_dynamic_attributes():
# Iterator containing all possible `product.attribute.value` combination
# The iterator is used to avoid MemoryError in case of a huge number of combination.
all_variants = itertools.product(*(
line.value_ids.ids for line in tmpl_id.attribute_line_ids if line.value_ids[:1].attribute_id.create_variant != 'no_variant'
line.value_ids.ids for line in tmpl_id._get_valid_product_template_attribute_lines()._without_no_variant_attributes()
))
# Set containing existing attribute values combination
# Set containing existing `product.attribute.value` combination
existing_variants = {
frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant').ids)
frozenset(variant.attribute_value_ids.ids)
for variant in tmpl_id.product_variant_ids
}
# For each possible variant, create if it doesn't exist yet.
@@ -493,107 +498,436 @@ class ProductTemplate(models.Model):
})
if len(variants_to_create) > 1000:
raise UserError(_(
'The number of variants to generate is too high. '
'You should either not generate variants for each combination or generate them on demand from the sales order. '
'To do so, open the form view of attributes and change the mode of *Create Variants*.'))
'The number of variants to generate is too high. '
'You should either not generate variants for each combination or generate them on demand from the sales order. '
'To do so, open the form view of attributes and change the mode of *Create Variants*.'))
# Check existing variants if any needs to be activated or unlinked.
# - if the product is not active and has valid attributes and attribute values, it
# should be activated
# - if the product does not have valid attributes or attribute values, it should be
# deleted
valid_value_ids = tmpl_id.mapped('attribute_line_ids.value_ids').filtered(
lambda v: v.attribute_id.create_variant != 'no_variant'
)
valid_attribute_ids = valid_value_ids.mapped('attribute_id')
valid_value_ids = tmpl_id._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = tmpl_id._get_valid_product_attributes()._without_no_variant_attributes()
for product_id in tmpl_id.product_variant_ids:
if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids):
if not product_id.active:
variants_to_activate.append(product_id)
variants_to_activate += product_id
else:
variants_to_unlink.append(product_id)
variants_to_unlink += product_id
if variants_to_activate:
Product.concat(*variants_to_activate).write({'active': True})
if variants_to_activate:
variants_to_activate.write({'active': True})
# create new products
if variants_to_create:
Product.create(variants_to_create)
# create new products
if variants_to_create:
Product.create(variants_to_create)
# unlink or inactive product
for variant in variants_to_unlink:
# unlink or inactive product
# try in batch first because it is much faster
try:
with self._cr.savepoint(), tools.mute_logger('odoo.sql_db'):
variant.unlink()
# We catch all kind of exception to be sure that the operation doesn't fail.
except (psycopg2.Error, except_orm):
variant.write({'active': False})
pass
variants_to_unlink.unlink()
except Exception:
# fall back to one by one if batch is not possible
for variant in variants_to_unlink:
try:
with self._cr.savepoint(), tools.mute_logger('odoo.sql_db'):
variant.unlink()
# We catch all kind of exception to be sure that the operation doesn't fail.
except Exception:
# Note: this can still fail if something is preventing from archiving.
# This is the case from existing stock reordering rules.
variant.write({'active': False})
return True
def has_dynamic_attributes(self):
return self.attribute_line_ids and \
any(create_variant == 'dynamic'
for create_variant in self.mapped('attribute_line_ids.value_ids.attribute_id.create_variant'))
"""Return whether this `product.template` has at least one dynamic
attribute.
:return: True if at least one dynamic attribute, False otherwise
:rtype: bool
"""
self.ensure_one()
return any(a.create_variant == 'dynamic' for a in self._get_valid_product_attributes())
@api.multi
def _get_valid_product_template_attribute_lines(self):
"""A product template attribute line is considered valid if it has at
least one possible value.
Those with only one value are considered valid, even though they should
not appear on the configurator itself (unless they have an is_custom
value to input), indeed single value attributes can be used to filter
products among others based on that attribute/value.
This method is necessary because it was previously possible to save a
line without any value on it, so the database might not be consistent in
that regard.
:return: all the valid product template attribute lines of this template
:rtype: recordset `product.template.attribute.line`
"""
self.ensure_one()
return self.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
@api.multi
def _get_valid_product_attributes(self):
"""A product attribute is considered valid for a template if it
has at least one possible value set on the template.
See `_get_valid_product_template_attribute_lines`.
:return: all the valid product attributes of this template
:rtype: recordset `product.attribute`
"""
self.ensure_one()
product_attributes = self.env['product.attribute']
for ptal in self._get_valid_product_template_attribute_lines():
product_attributes |= ptal.attribute_id
return product_attributes
@api.multi
def _get_valid_product_attribute_values(self):
"""A product attribute value is considered valid for a template if it is
defined on a product template attribute line.
:return: all the valid product attribute values of this template
:rtype: recordset `product.attribute.value`
"""
self.ensure_one()
return self._get_valid_product_template_attribute_lines().mapped('value_ids')
@api.multi
def _get_possible_variants(self, parent_combination=None):
"""Return the existing variants that are possible.
For dynamic attributes, it will only return the variants that have been
created already. For no_variant attributes, it will return an empty
recordset because the variants themselves are not a full combination.
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: the existing variants that are possible.
:rtype: recordset of `product.product`
"""
self.ensure_one()
return self.product_variant_ids.filtered(lambda p: p._is_variant_possible(parent_combination))
@api.multi
def get_filtered_variants(self, reference_product=None):
"""
Will filter availability (excluded) for the product
combinations (ex: color: white excludes size: large).
Will also filter availability (excluded) for the parent
product if specified (meaning that this product is a an optionnal or
accessory product of the reference_product).
Args:
reference_product (product.product): The reference product that has
the current product as an option or accessory product.
Returns:
The filtered list of product variants
"""
"""deprecated, use _get_possible_variants instead"""
self.ensure_one()
product_template_attribute_values = self.env['product.template.attribute.value'].search([('product_tmpl_id', '=', self.id)])
parent_combination = self.env['product.template.attribute.value']
if reference_product:
# append the reference_product if provided
product_template_attribute_values |= reference_product.product_template_attribute_value_ids
if reference_product._context.get('no_variant_attribute_values'):
parent_combination |= reference_product.product_template_attribute_value_ids
if reference_product.env.context.get('no_variant_attribute_values'):
# Add "no_variant" attribute values' exclusions
# They are kept in the context since they are not linked to this product variant
product_template_attribute_values |= reference_product._context.get('no_variant_attribute_values')
parent_combination |= reference_product.env.context.get('no_variant_attribute_values')
return self._get_possible_variants(parent_combination)
product_variants = self.product_variant_ids
for product_template_attribute_value in product_template_attribute_values:
# CASE 1: The whole product is excluded when no attribute values are selected in the parent product
# returns empty recordset of product.product if so. What is checked is:
# If the product_attribute value doesn't belong to self (i.e. belongs to the reference product)
# and self is the excluded product template on the exclusion lines
# and the exclusions is on the product without specified product attribute values (i.e. the whole product is excluded)
if product_template_attribute_value.product_tmpl_id != self \
and self in product_template_attribute_value.exclude_for.mapped('product_tmpl_id') \
and any(not exclude_for.value_ids
for exclude_for in product_template_attribute_value.exclude_for.filtered(
lambda excluded_product_attribute_value: excluded_product_attribute_value.product_tmpl_id == self)):
return self.env['product.product']
@api.multi
def _get_attribute_exclusions(self, parent_combination=None):
"""Return the list of attribute exclusions of a product.
# CASE 2: Check if some of the product.template.attribute.value of the product are excluded
# for this prodcut. A variant could be excluded:
# - Either by itself (eg: The office chair with iron legs excludes the color white)
# - Or by the reference product (eg: The customizable desk with iron legs excludes the office chair with aluminium legs)
for excluded in product_template_attribute_value.exclude_for.filtered(
lambda excluded_product_attribute_value: excluded_product_attribute_value.product_tmpl_id == self):
product_variants -= product_variants.filtered(
lambda variant:
# 1/ Check the applicability of the exclusion
# i.e: the restriction comes from the parent
# OR the restriction is on a product_attribute_value that this variant has, eg:
# if the office chair with iron legs excludes the color white, we must check
# that this variant has iron legs to check the exclusion
(product_template_attribute_value.product_tmpl_id != self or product_template_attribute_value in variant.product_template_attribute_value_ids) and
# 2/ Check the variant has one of the excluded attribute values
any(attribute_value in excluded.value_ids for attribute_value in variant.product_template_attribute_value_ids))
:param parent_combination: the combination from which
`self` is an optional or accessory product. Indeed exclusions
rules on one product can concern another product.
:type parent_combination: recordset `product.template.attribute.value`
return product_variants
:return: dict of exclusions
- exclusions: from this product itself
- parent_exclusions: from the parent_combination
- archived_combinations: variants that are archived
- existing_combinations: variants that are existing (as opposed to
deleted, because deleted need to be considered impossible if
there are no dynamic attributes).
- has_dynamic_attributes: whether there is a dynamic attribute
- no_variant_product_template_attribute_value_ids: values that are
no_variant
"""
self.ensure_one()
return {
'exclusions': self._get_own_attribute_exclusions(),
'parent_exclusions': self._get_parent_attribute_exclusions(parent_combination),
'archived_combinations': self._get_archived_combinations(),
'has_dynamic_attributes': self.has_dynamic_attributes(),
'existing_combinations': self._get_existing_combinations(),
'no_variant_product_template_attribute_value_ids': self._get_no_variant_product_template_attribute_values(),
}
@api.multi
def _get_own_attribute_exclusions(self):
"""Get exclusions coming from the current template.
Dictionnary, each ptav is a key, and for each of them the value is
an array with the other ptav that they exclude (empty if no exclusion).
"""
self.ensure_one()
product_template_attribute_values = self._get_valid_product_template_attribute_lines().mapped('product_template_value_ids')
return {
ptav.id: [
value_id
for filter_line in ptav.exclude_for.filtered(
lambda filter_line: filter_line.product_tmpl_id == self
) for value_id in filter_line.value_ids.ids
]
for ptav in product_template_attribute_values
}
@api.multi
def _get_parent_attribute_exclusions(self, parent_combination):
"""Get exclusions coming from the parent combination.
Array, each element is a ptav that is excluded because of the parent.
"""
self.ensure_one()
if not parent_combination:
return []
return [
value_id
for filter_line in parent_combination.mapped('exclude_for').filtered(
lambda filter_line: filter_line.product_tmpl_id == self
) for value_id in filter_line.value_ids.ids
]
@api.multi
def _get_archived_combinations(self):
self.ensure_one()
"""Get archived combinations.
Array, each element is an array with ids of an archived combination.
"""
valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
# Search only among those having the right set of attributes.
domain = [('product_tmpl_id', '=', self.id), ('active', '=', False)]
for pa in valid_attribute_ids:
domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
archived_variants = self.env['product.product'].search(domain)
archived_variants = archived_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
return [archived_variant.product_template_attribute_value_ids.ids
for archived_variant in archived_variants]
@api.multi
def _get_existing_combinations(self):
self.ensure_one()
"""Get existing combinations.
Needed because when not using dynamic attributes, the combination is
not ok if it doesn't exist (= if the variant has been deleted).
Array, each element is an array with ids of an existing combination.
"""
valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
# Search only among those having the right set of attributes.
domain = [('product_tmpl_id', '=', self.id), ('active', '=', True)]
for pa in valid_attribute_ids:
domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
existing_variants = self.env['product.product'].search(domain)
existing_variants = existing_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
return [variant.product_template_attribute_value_ids.ids
for variant in existing_variants]
@api.multi
def _get_no_variant_product_template_attribute_values(self):
self.ensure_one()
product_template_attribute_values = self._get_valid_product_template_attribute_lines().mapped('product_template_value_ids')
return product_template_attribute_values.filtered(
lambda v: v.attribute_id.create_variant == 'no_variant'
).ids
@api.multi
def _is_combination_possible(self, combination, parent_combination=None):
"""
The combination is possible if it is not excluded by any rule
coming from the current template, not excluded by any rule from the
parent_combination (if given), and there should not be any archived
variant with the exact same combination.
If the template does not have any dynamic attribute, the combination
is also not possible if the matching variant has been deleted.
Moreover the attributes of the combination must excatly match the
attributes allowed on the template.
:param combination: the combination to check for possibility
:type combination: recordset `product.template.attribute.value`
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: whether the combination is possible
:rtype: bool
"""
self.ensure_one()
if len(combination) != len(self._get_valid_product_template_attribute_lines()):
# number of attribute values passed is different than the
# configuration of attributes on the template
return False
if self._get_valid_product_attributes() != combination.mapped('attribute_id'):
# combination has different attributes than the ones configured on the template
return False
variant = self._get_variant_for_combination(combination)
if not self.has_dynamic_attributes() and not variant:
# the variant has been deleted
return False
exclusions = self._get_own_attribute_exclusions()
if exclusions:
# exclude if the current value is in an exclusion,
# and the value excluding it is also in the combination
for ptav in combination:
for exclusion in exclusions.get(ptav.id):
if exclusion in combination.ids:
return False
parent_exclusions = self._get_parent_attribute_exclusions(parent_combination)
if parent_exclusions:
for exclusion in parent_exclusions:
if exclusion in combination.ids:
return False
filtered_combination = combination._without_no_variant_attributes()
archived_combinations = self._get_archived_combinations()
if archived_combinations and filtered_combination.ids in archived_combinations:
return False
return True
@api.multi
def _get_variant_for_combination(self, combination):
"""Get the variant matching the combination.
All of the values in combination must be present in the variant, and the
variant should not have more attributes. Ignore the attributes that are
not supposed to create variants.
:param combination: recordset of `product.template.attribute.value`
:return: the variant if found, else empty
:rtype: recordset `product.product`
"""
self.ensure_one()
filtered_combination = combination._without_no_variant_attributes()
# If there are a lot of variants on this template, it is much faster to
# build a query than using the existing o2m.
domain = [('product_tmpl_id', '=', self.id)]
for ptav in filtered_combination:
domain = expression.AND([[('attribute_value_ids.id', '=', ptav.product_attribute_value_id.id)], domain])
res = self.env['product.product'].search(domain)
# The domain above is checking for the `product.attribute.value`, but we
# need to make sure it's the same `product.template.attribute.value`.
# Also there should theorically be only 0 or 1 but an existing database
# might not be consistent so we need to make sure to take max 1.
return res.filtered(
lambda v: v.product_template_attribute_value_ids == filtered_combination
)[:1]
@api.multi
def _get_first_possible_combination(self, parent_combination=None, necessary_values=None):
"""
Iterate the attributes and values in order and stop at the first
combination of values that is possible.
When encountering an impossible combination, try to change the
value of latest attributes first.
Ignore attributes that have no values.
Note this method return the same result (empty recordset) if no
combination is possible at all which would be considered a negative
result, or if there are no attribute lines on the template in which
case the "empty combination" is actually a possible combination.
Therefore the result of this method when empty should be tested
with `_is_combination_possible` if it's important to know if the
resulting empty combination is actually possible or not.
:param parent_combination: combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:param necessary_values: values that must be in the returned combination
:type necessary_values: recordset of `product.template.attribute.value`
:return: the first possible combination found, or empty if none possible
:rtype: recordset of `product.template.attribute.value`
"""
self.ensure_one()
if not self.active:
return self.env['product.template.attribute.value']
attribute_lines = self._get_valid_product_template_attribute_lines()
def iterate_attribute_lines(attribute_lines, combination):
"""
:param attribute_lines: recordset of product.template.attribute.line
that are still to iterate
:param combination: recordset of product.template.attribute.value
that have to be tested for possibility
:return: the first possible combination found, or empty
:rtype: recordset of `product.template.attribute.value`
"""
if not attribute_lines:
if self._is_combination_possible(combination, parent_combination):
return combination
else:
return self.env['product.template.attribute.value']
for cur in attribute_lines[0].product_template_value_ids:
res = iterate_attribute_lines(attribute_lines[1:], combination + cur)
if res and all(v in res for v in (necessary_values or [])):
return res
return self.env['product.template.attribute.value']
return iterate_attribute_lines(attribute_lines, self.env['product.template.attribute.value'])
@api.multi
def _get_closest_possible_combination(self, combination):
"""Get the first possible combination that is the closest to the given
combination.
If the given combination is incomplete, try to complete it.
If the given combination is invalid, try to remove values from it before
completing it.
See `_get_first_possible_combination` note about empty result.
:param combination: the values to include if they are possible
:type combination: recordset `product.template.attribute.value`
:return: the first possible combination that is including as much
elements as possible from the given combination.
:rtype: recordset of product.template.attribute.value
"""
while True:
result = self._get_first_possible_combination(necessary_values=combination)
if result or not combination:
return result
combination = combination[:-1]
@api.model
def get_empty_list_help(self, help):
+1
View File
@@ -73,6 +73,7 @@ class TestProductCommon(common.SavepointCase):
'uom_po_id': cls.uom_unit.id,
'attribute_line_ids': [(0, 0, {
'attribute_id': cls.prod_att_1.id,
'value_ids': [(6, 0, [cls.prod_attr1_v1.id, cls.prod_attr1_v2.id])]
})]
})
cls.product_7 = Product.create({
@@ -1,15 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
class TestProductAttributeValueConfig(TransactionCase):
class TestProductAttributeValueSetup(TransactionCase):
def setUp(self):
super(TestProductAttributeValueConfig, self).setUp()
super(TestProductAttributeValueSetup, self).setUp()
self.computer = self.env['product.template'].create({
'name': 'Super Computer'
'name': 'Super Computer',
'price': 2000,
})
self._add_ssd_attribute()
@@ -26,6 +28,150 @@ class TestProductAttributeValueConfig(TransactionCase):
self.computer_case.create_variant_ids()
def _add_ssd_attribute(self):
self.ssd_attribute = self.env['product.attribute'].create({'name': 'Memory', 'sequence': 1})
self.ssd_256 = self.env['product.attribute.value'].create({
'name': '256 GB',
'attribute_id': self.ssd_attribute.id,
'sequence': 1,
})
self.ssd_512 = self.env['product.attribute.value'].create({
'name': '512 GB',
'attribute_id': self.ssd_attribute.id,
'sequence': 2,
})
self._add_ssd_attribute_line()
def _add_ssd_attribute_line(self):
self.computer_ssd_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer.id,
'attribute_id': self.ssd_attribute.id,
'value_ids': [(6, 0, [self.ssd_256.id, self.ssd_512.id])],
})
self.computer_ssd_attribute_lines.product_template_value_ids[0].price_extra = 200
self.computer_ssd_attribute_lines.product_template_value_ids[1].price_extra = 400
def _add_ram_attribute(self):
self.ram_attribute = self.env['product.attribute'].create({'name': 'RAM', 'sequence': 2})
self.ram_8 = self.env['product.attribute.value'].create({
'name': '8 GB',
'attribute_id': self.ram_attribute.id,
'sequence': 1,
})
self.ram_16 = self.env['product.attribute.value'].create({
'name': '16 GB',
'attribute_id': self.ram_attribute.id,
'sequence': 2,
})
self.ram_32 = self.env['product.attribute.value'].create({
'name': '32 GB',
'attribute_id': self.ram_attribute.id,
'sequence': 3,
})
self.computer_ram_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer.id,
'attribute_id': self.ram_attribute.id,
'value_ids': [(6, 0, [self.ram_8.id, self.ram_16.id, self.ram_32.id])],
})
self.computer_ram_attribute_lines.product_template_value_ids[0].price_extra = 20
self.computer_ram_attribute_lines.product_template_value_ids[1].price_extra = 40
self.computer_ram_attribute_lines.product_template_value_ids[2].price_extra = 80
def _add_hdd_attribute(self):
self.hdd_attribute = self.env['product.attribute'].create({'name': 'HDD', 'sequence': 3})
self.hdd_1 = self.env['product.attribute.value'].create({
'name': '1 To',
'attribute_id': self.hdd_attribute.id,
'sequence': 1,
})
self.hdd_2 = self.env['product.attribute.value'].create({
'name': '2 To',
'attribute_id': self.hdd_attribute.id,
'sequence': 2,
})
self.hdd_4 = self.env['product.attribute.value'].create({
'name': '4 To',
'attribute_id': self.hdd_attribute.id,
'sequence': 3,
})
self._add_hdd_attribute_line()
def _add_hdd_attribute_line(self):
self.computer_hdd_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer.id,
'attribute_id': self.hdd_attribute.id,
'value_ids': [(6, 0, [self.hdd_1.id, self.hdd_2.id, self.hdd_4.id])],
})
self.computer_hdd_attribute_lines.product_template_value_ids[0].price_extra = 2
self.computer_hdd_attribute_lines.product_template_value_ids[1].price_extra = 4
self.computer_hdd_attribute_lines.product_template_value_ids[2].price_extra = 8
def _add_ram_exclude_for(self):
self._get_product_value_id(self.computer_ram_attribute_lines, self.ram_16).update({
'exclude_for': [(0, 0, {
'product_tmpl_id': self.computer.id,
'value_ids': [(6, 0, [self._get_product_value_id(self.computer_hdd_attribute_lines, self.hdd_1).id])]
})]
})
def _add_size_attribute(self):
self.size_attribute = self.env['product.attribute'].create({'name': 'Size', 'sequence': 4})
self.size_m = self.env['product.attribute.value'].create({
'name': 'M',
'attribute_id': self.size_attribute.id,
'sequence': 1,
})
self.size_l = self.env['product.attribute.value'].create({
'name': 'L',
'attribute_id': self.size_attribute.id,
'sequence': 2,
})
self.size_xl = self.env['product.attribute.value'].create({
'name': 'XL',
'attribute_id': self.size_attribute.id,
'sequence': 3,
})
self.computer_case_size_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer_case.id,
'attribute_id': self.size_attribute.id,
'value_ids': [(6, 0, [self.size_m.id, self.size_l.id, self.size_xl.id])],
})
def _get_product_value_id(self, product_template_attribute_lines, product_attribute_value):
return product_template_attribute_lines.product_template_value_ids.filtered(
lambda product_value_id: product_value_id.product_attribute_value_id == product_attribute_value)[0]
def _get_product_template_attribute_value(self, product_attribute_value, model=False):
"""
Return the `product.template.attribute.value` matching
`product_attribute_value` for self.
:param: recordset of one product.attribute.value
:return: recordset of one product.template.attribute.value if found
else empty
"""
if not model:
model = self.computer
return model._get_valid_product_template_attribute_lines().filtered(
lambda l: l.attribute_id == product_attribute_value.attribute_id
).product_template_value_ids.filtered(
lambda v: v.product_attribute_value_id == product_attribute_value
)
def _add_exclude(self, m1, m2, product_template=False):
m1.update({
'exclude_for': [(0, 0, {
'product_tmpl_id': (product_template or self.computer).id,
'value_ids': [(6, 0, [m2.id])]
})]
})
@tagged('post_install', '-at_install')
class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
def test_product_template_attribute_values_creation(self):
self.assertEqual(len(self.computer_ssd_attribute_lines.product_template_value_ids), 2,
'Product attribute values (ssd) were not automatically created')
@@ -36,6 +182,35 @@ class TestProductAttributeValueConfig(TransactionCase):
self.assertEqual(len(self.computer_case_size_attribute_lines.product_template_value_ids), 3,
'Product attribute values (size) were not automatically created')
def test_get_variant_for_combination(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
# completely defined variant
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
ok_variant = self.computer._get_variant_for_combination(combination)
self.assertEqual(ok_variant.product_template_attribute_value_ids, combination)
# over defined variant
combination = computer_ssd_256 + computer_ram_8 + computer_ram_16 + computer_hdd_1
variant = self.computer._get_variant_for_combination(combination)
self.assertEqual(len(variant), 0)
# under defined variant
combination = computer_ssd_256 + computer_ram_8
variant = self.computer._get_variant_for_combination(combination)
self.assertEqual(len(variant), 0)
# also test _has_valid_attributes (case ok):
valid_value_ids = self.computer._get_valid_product_attribute_values()._without_no_variant_attributes()
valid_attribute_ids = self.computer._get_valid_product_attributes()._without_no_variant_attributes()
self.assertTrue(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids))
# also test _has_valid_attributes (case not ok):
self.assertFalse(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids - self.hdd_1))
def test_product_filtered_exclude_for(self):
"""
Super Computer has 18 variants total (2 ssd * 3 ram * 3 hdd)
@@ -45,10 +220,18 @@ class TestProductAttributeValueConfig(TransactionCase):
=> There has to be 16 variants left when filtered
"""
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ssd_512 = self._get_product_template_attribute_value(self.ssd_512)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
self.assertEqual(len(self.computer._get_possible_variants()), 18)
self._add_ram_exclude_for()
self.assertEqual(len(self.computer.get_filtered_variants()), 16)
self.assertFalse(self._get_variant_for_attribute_values(self.computer, [self.ssd_256, self.ram_16, self.hdd_1]))
self.assertFalse(self._get_variant_for_attribute_values(self.computer, [self.ssd_512, self.ram_16, self.hdd_1]))
self.assertEqual(len(self.computer._get_possible_variants()), 16)
self.assertTrue(self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)._is_variant_possible())
self.assertFalse(self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_16 + computer_hdd_1)._is_variant_possible())
self.assertFalse(self.computer._get_variant_for_combination(computer_ssd_512 + computer_ram_16 + computer_hdd_1)._is_variant_possible())
def test_children_product_filtered_exclude_for(self):
"""
@@ -59,112 +242,154 @@ class TestProductAttributeValueConfig(TransactionCase):
=> There has to be 2 variants left when filtered
"""
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
computer_size_m = self._get_product_template_attribute_value(self.size_m, self.computer_case)
self._add_exclude(computer_hdd_4, computer_size_m, self.computer_case)
self.assertEqual(len(self.computer_case._get_possible_variants(computer_hdd_4)), 2)
self.assertFalse(self.computer_case._get_variant_for_combination(computer_size_m)._is_variant_possible(computer_hdd_4))
self._add_hdd_excludes_computer_case()
hdd_4_variant = self.computer.product_variant_ids.filtered(
lambda variant: self.hdd_4 in variant.product_template_attribute_value_ids.mapped('product_attribute_value_id'))[0]
self.assertEqual(len(self.computer_case.get_filtered_variants(hdd_4_variant)), 2)
self.assertFalse(self._get_variant_for_attribute_values(self.computer_case, [self.size_m], hdd_4_variant))
def test_is_combination_possible(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
self._add_exclude(computer_ram_16, computer_hdd_1)
def _add_ssd_attribute(self):
self.ssd_attribute = self.env['product.attribute'].create({'name': 'Memory'})
self.ssd_256 = self.env['product.attribute.value'].create({
'name': '256 GB',
'attribute_id': self.ssd_attribute.id
# CASE: basic
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: ram 16 excluding hdd1
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_16 + computer_hdd_1))
# CASE: under defined combination
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_16))
# CASE: no combination, no variant, just return the only variant
mouse = self.env['product.template'].create({'name': 'Mouse'})
self.assertTrue(mouse._is_combination_possible(self.env['product.template.attribute.value']))
# prep work for the last part of the test
color_attribute = self.env['product.attribute'].create({'name': 'Color'})
color_red = self.env['product.attribute.value'].create({
'name': 'Red',
'attribute_id': color_attribute.id,
})
self.ssd_512 = self.env['product.attribute.value'].create({
'name': '512 GB',
'attribute_id': self.ssd_attribute.id
color_green = self.env['product.attribute.value'].create({
'name': 'Green',
'attribute_id': color_attribute.id,
})
self.computer_ssd_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer.id,
'attribute_id': self.ssd_attribute.id,
'value_ids': [(6, 0, [self.ssd_256.id, self.ssd_512.id])]
self.env['product.template.attribute.line'].create({
'product_tmpl_id': mouse.id,
'attribute_id': color_attribute.id,
'value_ids': [(6, 0, [color_red.id, color_green.id])],
})
def _add_ram_attribute(self):
self.ram_attribute = self.env['product.attribute'].create({'name': 'RAM'})
self.ram_8 = self.env['product.attribute.value'].create({
'name': '8 GB',
'attribute_id': self.ram_attribute.id
})
self.ram_16 = self.env['product.attribute.value'].create({
'name': '16 GB',
'attribute_id': self.ram_attribute.id
})
self.ram_32 = self.env['product.attribute.value'].create({
'name': '32 GB',
'attribute_id': self.ram_attribute.id
})
self.computer_ram_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer.id,
'attribute_id': self.ram_attribute.id,
'value_ids': [(6, 0, [self.ram_8.id, self.ram_16.id, self.ram_32.id])]
})
mouse.create_variant_ids()
def _add_hdd_attribute(self):
self.hdd_attribute = self.env['product.attribute'].create({'name': 'HDD'})
self.hdd_1 = self.env['product.attribute.value'].create({
'name': '1 To',
'attribute_id': self.hdd_attribute.id
})
self.hdd_2 = self.env['product.attribute.value'].create({
'name': '2 To',
'attribute_id': self.hdd_attribute.id
})
self.hdd_4 = self.env['product.attribute.value'].create({
'name': '4 To',
'attribute_id': self.hdd_attribute.id
})
self.computer_hdd_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer.id,
'attribute_id': self.hdd_attribute.id,
'value_ids': [(6, 0, [self.hdd_1.id, self.hdd_2.id, self.hdd_4.id])]
})
mouse_color_red = self._get_product_template_attribute_value(color_red, mouse)
mouse_color_green = self._get_product_template_attribute_value(color_green, mouse)
def _add_ram_exclude_for(self):
self.ram_16_excludes_hdd_1 = self.env['product.template.attribute.exclusion'].create({
'product_tmpl_id': self.computer.id,
'value_ids': [(6, 0, [self._get_product_value_id(self.computer_hdd_attribute_lines, self.hdd_1).id])]
})
self._get_product_value_id(self.computer_ram_attribute_lines, self.ram_16).update({
'exclude_for': [(6, 0, [self.ram_16_excludes_hdd_1.id])]
})
self._add_exclude(computer_ssd_256, mouse_color_green, mouse)
def _add_size_attribute(self):
self.size_attribute = self.env['product.attribute'].create({'name': 'Size'})
self.size_m = self.env['product.attribute.value'].create({
'name': 'M',
'attribute_id': self.size_attribute.id
})
self.size_l = self.env['product.attribute.value'].create({
'name': 'L',
'attribute_id': self.size_attribute.id
})
self.size_xl = self.env['product.attribute.value'].create({
'name': 'XL',
'attribute_id': self.size_attribute.id
})
self.computer_case_size_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer_case.id,
'attribute_id': self.size_attribute.id,
'value_ids': [(6, 0, [self.size_m.id, self.size_l.id, self.size_xl.id])]
})
variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
def _add_hdd_excludes_computer_case(self):
self.hdd_4_excludes_computer_case_m = self.env['product.template.attribute.exclusion'].create({
'product_tmpl_id': self.computer_case.id,
'value_ids': [(6, 0, [self._get_product_value_id(self.computer_case_size_attribute_lines, self.size_m).id])]
})
self._get_product_value_id(self.computer_hdd_attribute_lines, self.hdd_4).update({
'exclude_for': [(6, 0, [self.hdd_4_excludes_computer_case_m.id])]
})
# CASE: wrong attributes (mouse_color_red not on computer)
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_16 + mouse_color_red))
def _get_product_value_id(self, product_template_attribute_lines, product_attribute_value):
return product_template_attribute_lines.product_template_value_ids.filtered(
lambda product_value_id: product_value_id.product_attribute_value_id == product_attribute_value)[0]
# CASE: parent ok
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1, mouse_color_red))
self.assertTrue(mouse._is_combination_possible(mouse_color_red, computer_ssd_256 + computer_ram_8 + computer_hdd_1))
def _get_variant_for_attribute_values(self, product, attribute_values, reference_product=None):
return product.get_filtered_variants(reference_product).filtered(
lambda variant:
all(attribute_value in variant.attribute_value_ids for attribute_value in attribute_values))
# CASE: parent exclusion but good direction (parent is directional)
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1, mouse_color_green))
# CASE: parent exclusion and wrong direction (parent is directional)
self.assertFalse(mouse._is_combination_possible(mouse_color_green, computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: deleted combination
variant.unlink()
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
def test_get_first_possible_combination(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ssd_512 = self._get_product_template_attribute_value(self.ssd_512)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_ram_32 = self._get_product_template_attribute_value(self.ram_32)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
self._add_exclude(computer_ram_16, computer_hdd_1)
# ram_8 is allowed with the other attributes
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# Below invalidate cache between every test to force reload the
# o2m attribute_line_ids to order it by the new sequence
# Give priority to ram_16 but it is not allowed by hdd_1 so it should return hhd_2 instead
computer_ram_16.product_attribute_value_id.sequence = -1
self.computer.invalidate_cache() # need o2m to be reordered
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_16 + computer_hdd_2)
# Give priority to ram_32 and is allowed with the rest so it should return it
computer_ram_16.product_attribute_value_id.sequence = 2
computer_ram_32.product_attribute_value_id.sequence = -1
self.computer.invalidate_cache() # need o2m to be reordered
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
# Give priority to ram_16 but now it is not allowing any hdd so it should return ram_8 instead
computer_ram_32.product_attribute_value_id.sequence = 3
computer_ram_16.product_attribute_value_id.sequence = -1
self.computer.invalidate_cache() # need o2m to be reordered
self._add_exclude(computer_ram_16, computer_hdd_2)
self._add_exclude(computer_ram_16, computer_hdd_4)
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# Only the last combination is possible
computer_ram_16.product_attribute_value_id.sequence = 2
self.computer.invalidate_cache() # need o2m to be reordered
self._add_exclude(computer_ram_8, computer_hdd_1)
self._add_exclude(computer_ram_8, computer_hdd_2)
self._add_exclude(computer_ram_8, computer_hdd_4)
self._add_exclude(computer_ram_32, computer_hdd_1)
self._add_exclude(computer_ram_32, computer_hdd_2)
self._add_exclude(computer_ram_32, computer_ssd_256)
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_512 + computer_ram_32 + computer_hdd_4)
# No possible combination
self._add_exclude(computer_ram_32, computer_hdd_4)
self.assertEqual(self.computer._get_first_possible_combination(), self.env['product.template.attribute.value'])
def test_get_closest_possible_combination(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
self._add_exclude(computer_ram_16, computer_hdd_1)
# CASE nothing special
self.assertEqual(self.computer._get_closest_possible_combination(None),
computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# CASE contains computer_hdd_1
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_1),
computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# CASE contains computer_hdd_2
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_2),
computer_ssd_256 + computer_ram_8 + computer_hdd_2)
# CASE contains computer_hdd_2, computer_ram_16
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_2 + computer_ram_16),
computer_ssd_256 + computer_ram_16 + computer_hdd_2)
# CASE invalid combination (excluded):
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_1 + computer_ram_16),
computer_ssd_256 + computer_ram_8 + computer_hdd_1)
# CASE invalid combination (too much):
self.assertEqual(self.computer._get_closest_possible_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_4 + computer_hdd_2),
computer_ssd_256 + computer_ram_8 + computer_hdd_4)
@@ -27,7 +27,7 @@
<group name="values_ids">
<label for="value_ids" string="Attribute Values"/>
<field name="value_ids" widget="one2many_list" nolabel="1">
<tree string="Values" editable="top">
<tree string="Values" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
</tree>
@@ -49,7 +49,7 @@
<field name="name">product.attribute.value.tree</field>
<field name="model">product.attribute.value</field>
<field name="arch" type="xml">
<tree string="Variant Values" editable="top">
<tree string="Variant Values" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="attribute_id"/>
<field name="name"/>
+52 -2
View File
@@ -71,15 +71,65 @@
<group groups="product.group_product_pricelist">
<div name="pricelist_item" groups="product.group_product_pricelist">
<separator string="Pricing"/>
<field name="item_ids" nolabel="1" context="{'default_base':'list_price', 'default_applied_on' :'1_product'}">
<field name="item_ids" nolabel="1" mode="tree,kanban" context="{'default_base':'list_price', 'default_applied_on' :'1_product'}">
<tree string="Pricelist Items" editable="bottom">
<field name="pricelist_id" string="Pricelist" required='1'/>
<field name="fixed_price" string="Price" required='1'/>
<field name="currency_id" invisible="1"/>
<field name="fixed_price" string="Price" required='1' widget='monetary' options="{'currency_field': 'currency_id'}"/>
<field name="min_quantity"/>
<field name="date_start"/>
<field name="date_end"/>
<field name="applied_on" invisible="1"/>
</tree>
<kanban string="Pricelist Items" class="o_kanban_mobile">
<field name="pricelist_id"/>
<field name="currency_id"/>
<field name="fixed_price"/>
<field name="min_quantity"/>
<field name="date_start"/>
<field name="date_end"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_global_click">
<div class="row">
<div class="col-12">
<strong>
<field name='pricelist_id'/>: <field name="fixed_price" widget='monetary' options="{'currency_field': 'currency_id'}"/>
</strong>
</div>
</div>
<div class="row">
<div class="col-12">
<span><t t-esc='record.min_quantity.string'/>: <t t-esc='record.min_quantity.value'/></span>
</div>
</div>
<span t-if='record.date_start.value || record.date_end.value'>
<div class="row">
<div class="col-12">
Valid <span t-if='record.date_start.value'> from <t t-esc='record.date_start.value'/></span><span t-if='record.date_end.value'> until <t t-esc='record.date_end.value'/></span>
</div>
</div>
</span>
</div>
</t>
</templates>
</kanban>
<form string="Pricelist Items">
<h1><field name="name"/></h1>
<group>
<group>
<field name="applied_on" invisible="1"/>
<field name="pricelist_id" string="Pricelist" required='1'/>
<field name="currency_id" invisible="1"/>
<field name="fixed_price" string="Price" required='1' widget='monetary' options="{'currency_field': 'currency_id'}"/>
</group>
<group>
<field name="min_quantity"/>
<field name="date_start"/>
<field name="date_end"/>
</group>
</group>
</form>
</field>
</div>
</group>
+3 -3
View File
@@ -79,10 +79,10 @@ class PurchaseReport(models.Model):
extract(epoch from age(s.date_approve,s.date_order))/(24*60*60)::decimal(16,2) as delay,
extract(epoch from age(l.date_planned,s.date_order))/(24*60*60)::decimal(16,2) as delay_pass,
count(*) as nbr_lines,
sum(l.price_unit / COALESCE(cr.rate, 1.0) * l.product_qty)::decimal(16,2) as price_total,
avg(100.0 * (l.price_unit / COALESCE(cr.rate,1.0) * l.product_qty) / NULLIF(ip.value_float*l.product_qty/u.factor*u2.factor, 0.0))::decimal(16,2) as negociation,
sum(l.price_unit / COALESCE(NULLIF(cr.rate, 0), 1.0) * l.product_qty)::decimal(16,2) as price_total,
avg(100.0 * (l.price_unit / COALESCE(NULLIF(cr.rate, 0),1.0) * l.product_qty) / NULLIF(ip.value_float*l.product_qty/u.factor*u2.factor, 0.0))::decimal(16,2) as negociation,
sum(ip.value_float*l.product_qty/u.factor*u2.factor)::decimal(16,2) as price_standard,
(sum(l.product_qty * l.price_unit / COALESCE(cr.rate, 1.0))/NULLIF(sum(l.product_qty/u.factor*u2.factor),0.0))::decimal(16,2) as price_average,
(sum(l.product_qty * l.price_unit / COALESCE(NULLIF(cr.rate, 0), 1.0))/NULLIF(sum(l.product_qty/u.factor*u2.factor),0.0))::decimal(16,2) as price_average,
partner.country_id as country_id,
partner.commercial_partner_id as commercial_partner_id,
analytic_account.id as account_analytic_id,
+6 -1
View File
@@ -68,7 +68,12 @@
<div class="row">
<div class="col-lg-12">
<h4>
Purchase Order
<t t-if="order.state in ['draft', 'sent']">
Request for Quotation
</t>
<t t-else="1">
Purchase Order
</t>
<span t-esc="order.name"/>
</h4>
</div>
+1 -1
View File
@@ -48,7 +48,7 @@ class StockMove(models.Model):
def _get_upstream_documents_and_responsibles(self, visited):
if self.requisition_line_ids:
return [(requisition_line.requisition_id, requisition_line.requisition_id.user_id, visited) for requisition_line in self.requisition_line_ids if requisition_line.state not in ('done', 'cancel')]
return [(requisition_line.requisition_id, requisition_line.requisition_id.user_id, visited) for requisition_line in self.requisition_line_ids if requisition_line.requisition_id.state not in ('done', 'cancel')]
else:
return super(StockMove, self)._get_upstream_documents_and_responsibles(visited)
+2
View File
@@ -168,6 +168,8 @@ class CustomerPortal(CustomerPortal):
'partner_id': order_sudo.partner_id.id,
'report_type': 'html',
}
if order_sudo.company_id:
values['res_company'] = order_sudo.company_id
if order_sudo.has_to_be_paid():
domain = expression.AND([
+71 -147
View File
@@ -8,146 +8,125 @@ from odoo.http import request
class ProductConfiguratorController(http.Controller):
@http.route(['/product_configurator/configure'], type='json', auth="user", methods=['POST'])
def configure(self, product_id, pricelist_id, **kw):
add_qty = int(kw.get('add_qty', 1))
product_template = request.env['product.template'].browse(int(product_id))
to_currency = product_template.currency_id
pricelist = self._get_pricelist(pricelist_id)
if pricelist:
product_template = product_template.with_context(pricelist=pricelist.id, partner=request.env.user.partner_id)
to_currency = pricelist.currency_id
return request.env['ir.ui.view'].render_template("sale.product_configurator_configure", {
'product': product_template,
# to_currency deprecated, get it from the pricelist or product directly
'to_currency': to_currency,
'pricelist': pricelist,
'add_qty': add_qty,
# get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions
})
@http.route(['/product_configurator/show_optional_products'], type='json', auth="user", methods=['POST'])
def show_optional_products(self, product_id, variant_values, pricelist_id, **kw):
return self._show_optional_products(product_id, variant_values, self._get_pricelist(pricelist_id), False, **kw)
pricelist = self._get_pricelist(pricelist_id)
return self._show_optional_products(product_id, variant_values, pricelist, False, **kw)
@http.route(['/product_configurator/optional_product_items'], type='json', auth="user", methods=['POST'])
def optional_product_items(self, product_id, pricelist_id, **kw):
return self._optional_product_items(product_id, self._get_pricelist(pricelist_id), **kw)
pricelist = self._get_pricelist(pricelist_id)
return self._optional_product_items(product_id, pricelist, **kw)
@http.route(['/product_configurator/get_combination_info'], type='json', auth="user", methods=['POST'])
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
return self._get_combination_info(product_template_id, product_id, combination, add_qty, self._get_pricelist(pricelist_id))
combination = request.env['product.template.attribute.value'].browse(combination)
pricelist = self._get_pricelist(pricelist_id)
return request.env['product.template'].browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
@http.route(['/product_configurator/create_product_variant'], type='json', auth="user", methods=['POST'])
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
return request.env['product.template'].browse(int(product_template_id)).create_product_variant(product_template_attribute_value_ids)
def _optional_product_items(self, product_id, pricelist, **kw):
add_qty = int(kw.get('add_qty', 1))
product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id))
to_currency = product.currency_id
if pricelist:
to_currency = pricelist.currency_id
parent_combination = product.product_template_attribute_value_ids
if product.env.context.get('no_variant_attribute_values'):
# Add "no_variant" attribute values' exclusions
# They are kept in the context since they are not linked to this product variant
parent_combination |= product.env.context.get('no_variant_attribute_values')
return request.env['ir.ui.view'].render_template("sale.optional_product_items", {
# product deprecated, it's not used in the view
'product': product,
# reference_product deprecated, use parent_combination instead
'reference_product': product,
'parent_combination': parent_combination,
'pricelist': pricelist,
# to_currency deprecated, get from pricelist or product
'to_currency': to_currency,
# get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions,
'add_qty': add_qty,
})
def _show_optional_products(self, product_id, variant_values, pricelist, handle_stock, **kw):
product = request.env['product.product'].browse(int(product_id))
from_currency = request.env.user.company_id.currency_id
to_currency = pricelist.currency_id if pricelist else product.currency_id
product = request.env['product.product'].with_context(self._get_product_context(pricelist, **kw)).browse(int(product_id))
combination = request.env['product.template.attribute.value'].browse(variant_values)
has_optional_products = product.optional_product_ids.filtered(lambda p: p._is_add_to_cart_possible(combination))
if not has_optional_products:
return False
add_qty = int(kw.get('add_qty', 1))
to_currency = (pricelist or product).currency_id
company = request.env['res.company'].browse(request.env.context.get('company_id')) or request.env['res.users']._get_company()
date = request.env.context.get('date') or fields.Date.today()
compute_currency = lambda price: from_currency._convert(price, to_currency, company, date)
product = product.with_context(self._get_product_context(pricelist, **kw))
no_variant_attribute_values = request.env['product.template.attribute.value'].browse(variant_values).filtered(
def compute_currency(price):
return product.currency_id._convert(price, to_currency, company, date)
no_variant_attribute_values = combination.filtered(
lambda product_template_attribute_value: product_template_attribute_value.attribute_id.create_variant == 'no_variant'
)
if no_variant_attribute_values:
product = product.with_context(no_variant_attribute_values=no_variant_attribute_values)
has_optional_products = False
for optional_product in product.optional_product_ids:
if optional_product.has_dynamic_attributes() or optional_product.get_filtered_variants(product):
has_optional_products = True
break
if not has_optional_products:
return False
else:
return request.env['ir.ui.view'].render_template("sale.optional_products_modal", {
'product': product,
'reference_product': product,
'variant_values': variant_values,
'pricelist': pricelist,
'compute_currency': compute_currency,
'to_currency': to_currency,
'handle_stock': handle_stock,
'get_attribute_exclusions': self._get_attribute_exclusions,
})
return request.env['ir.ui.view'].render_template("sale.optional_products_modal", {
'product': product,
'combination': combination,
'add_qty': add_qty,
# reference_product deprecated, use combination instead
'reference_product': product,
'variant_values': variant_values,
'pricelist': pricelist,
# compute_currency deprecated, get from pricelist or product
'compute_currency': compute_currency,
# to_currency deprecated, get from pricelist or product
'to_currency': to_currency,
'handle_stock': handle_stock,
# get_attribute_exclusions deprecated, use product method
'get_attribute_exclusions': self._get_attribute_exclusions,
})
def _get_attribute_exclusions(self, product, reference_product=None):
""" list of attribute exclusions of a product
Args:
- product (product.template): The base product template
- reference_product (product.product): The reference product from which 'product' is an optional or accessory product
:return: dict of exclusions
exclusions.exclusions: exclusions within this product
exclusions.parent_exclusions: exclusions coming from the reference_product
"""
product_attribute_values = request.env['product.template.attribute.value'].search([
('product_tmpl_id', '=', product.id),
('product_attribute_value_id', 'in', product.attribute_line_ids.mapped('value_ids').ids),
])
# array of all the excluded value_ids of all the filter lines for this product
mapped_exclusions = {
product_attribute_value.id: [
value_id
for filter_line in product_attribute_value.exclude_for.filtered(
lambda filter_line: filter_line.product_tmpl_id == product
) for value_id in filter_line.value_ids.ids
]
for product_attribute_value in product_attribute_values
}
parent_exclusions = []
"""deprecated, use product method"""
parent_combination = request.env['product.template.attribute.value']
if reference_product:
parent_attribute_value_ids = reference_product.product_template_attribute_value_ids
if parent_attribute_value_ids and reference_product._context.get('no_variant_attribute_values'):
parent_combination |= reference_product.product_template_attribute_value_ids
if reference_product.env.context.get('no_variant_attribute_values'):
# Add "no_variant" attribute values' exclusions
# They are kept in the context since they are not linked to this product variant
parent_attribute_value_ids |= reference_product._context.get('no_variant_attribute_values')
parent_exclusions = [
value_id
for filter_line in parent_attribute_value_ids.mapped('exclude_for').filtered(
lambda filter_line: filter_line.product_tmpl_id == product
) for value_id in filter_line.value_ids.ids]
# Query all archived products for this template
archived_combinations = request.env['product.product'].search(
[('product_tmpl_id', '=', product.id), ('active', '=', False)])
if archived_combinations:
# Old archived variants could have a different set of attributes and are not relevant here
# -> filter them out
attribute_ids = product_attribute_values.mapped('attribute_id')
archived_combinations = archived_combinations.filtered(
lambda product: all(
attribute_id in product.mapped('product_template_attribute_value_ids.attribute_id')
for attribute_id in attribute_ids
)
)
return {
'exclusions': mapped_exclusions,
'parent_exclusions': parent_exclusions,
'archived_combinations': [archived_combination.product_template_attribute_value_ids.ids
for archived_combination in archived_combinations]
}
parent_combination |= reference_product.env.context.get('no_variant_attribute_values')
return product._get_attribute_exclusions(parent_combination)
def _get_product_context(self, pricelist=None, **kw):
"""deprecated, can be removed in master"""
product_context = dict(request.context)
if pricelist:
if not product_context.get('pricelist'):
@@ -157,64 +136,9 @@ class ProductConfiguratorController(http.Controller):
return product_context
def _get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist, **kw):
product_template_attribute_values = request.env['product.template.attribute.value'].browse(combination)
filtered_product_template_attribute_values = product_template_attribute_values.filtered(
lambda product_attribute_value: product_attribute_value.attribute_id.create_variant != 'no_variant'
)
context = {
'quantity': add_qty,
'pricelist': pricelist.id if pricelist else None,
'current_attributes_price_extra': [product_attribute_value.price_extra or 0.0 for product_attribute_value in product_template_attribute_values]
}
"""deprecated, use product method"""
combination = request.env['product.template.attribute.value'].browse(combination)
return request.env['product.template'].browse(product_template_id)._get_combination_info(combination, product_id, add_qty, pricelist)
product_template = request.env['product.template'].with_context(context).browse(product_template_id)
Product = request.env['product.product'].with_context(context)
if product_id and not combination:
product = Product.browse(product_id)
else:
products = Product.search([
('product_tmpl_id', '=', product_template_id)
])
product = products.filtered(
lambda product: all(product_attribute_value in product.product_template_attribute_value_ids
for product_attribute_value in filtered_product_template_attribute_values)
)
product_id = None
list_price = product_template.price_compute('list_price')[product_template.id]
price = product_template.price
if(product):
product = product.with_context(
no_variant_attributes_price_extra=[product_attribute_value.price_extra or 0.0
for product_attribute_value in product_template_attribute_values.filtered(
lambda product_attribute_value: product_attribute_value.attribute_id.create_variant == 'no_variant'
)
]
)
product_id = product.id
list_price = product.price_compute('list_price')[product.id]
price = product.price
display_name = [product_template.name]
if filtered_product_template_attribute_values:
display_name.append(' (')
display_name.append(', '.join(filtered_product_template_attribute_values.mapped('name')))
display_name.append(')')
if pricelist and pricelist.currency_id != product_template.currency_id:
list_price = product_template.currency_id._convert(
list_price, pricelist.currency_id,
request.env.user.company_id, fields.Date.today()
)
return {
'product_id': product_id,
'product_template_id': product_template.id,
'display_name': ''.join(display_name),
'price': price,
'list_price': list_price
}
def _get_pricelist(self, pricelist_id):
return request.env['product.pricelist'].browse(int(pricelist_id)) if pricelist_id and pricelist_id != '0' else None
def _get_pricelist(self, pricelist_id, pricelist_fallback=False):
return request.env['product.pricelist'].browse(int(pricelist_id or 0))
+1
View File
@@ -750,6 +750,7 @@ Thanks!</field>
<field name="name">Custom</field>
<field name="attribute_id" ref="product.product_attribute_1"/>
<field name="is_custom">True</field>
<field name="sequence">3</field>
</record>
<record id="product.product_template_attribute_line_1" model="product.template.attribute.line">
+1 -1
View File
@@ -39,7 +39,7 @@ class AccountAnalyticLine(models.Model):
self.ensure_one()
if self.product_id.expense_policy == 'sales_price':
return self.product_id.with_context(
partner=order.partner_id.id,
partner=order.partner_id,
date_order=order.date_order,
pricelist=order.pricelist_id.id,
uom=self.product_uom_id.id
+8
View File
@@ -56,6 +56,14 @@ class ProductProduct(models.Model):
def _get_invoice_policy(self):
return self.invoice_policy
@api.multi
def _get_combination_info_variant(self, add_qty=1, pricelist=False, parent_combination=False):
"""Return the variant info based on its combination.
See `_get_combination_info` for more information.
"""
self.ensure_one()
return self.product_tmpl_id._get_combination_info(self.product_template_attribute_value_ids, self.id, add_qty, pricelist, parent_combination)
class ProductAttribute(models.Model):
_inherit = "product.attribute"
+210 -10
View File
@@ -1,10 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from odoo import api, fields, models, _
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
from odoo.tools.float_utils import float_round
import json
_logger = logging.getLogger(__name__)
class ProductTemplate(models.Model):
@@ -58,21 +62,88 @@ class ProductTemplate(models.Model):
}
return action
def create_product_variant(self, product_template_attribute_value_ids):
@api.multi
def _create_product_variant(self, combination, log_warning=False):
""" Create if necessary and possible and return the product variant
matching the given combination for this template.
It is possible to create only if the template has dynamic attributes
and the combination itself is possible.
:param combination: the combination for which to get or create variant.
The combination must contain all necessary attributes, including
those of type no_variant. Indeed even though those attributes won't
be included in the variant if newly created, they are needed when
checking if the combination is possible.
:type combination: recordset of `product.template.attribute.value`
:param log_warning: whether a warning should be logged on fail
:type log_warning: bool
:return: the product variant matching the combination or none
:rtype: recordset of `product.product`
"""
self.ensure_one()
attribute_value_ids = \
self.env['product.template.attribute.value'] \
.browse(json.loads(product_template_attribute_value_ids)) \
.mapped('product_attribute_value_id') \
.filtered(lambda attribute_value_id: attribute_value_id.attribute_id.create_variant != 'no_variant')
Product = self.env['product.product']
product_variant = self.env['product.product'].create({
product_variant = self._get_variant_for_combination(combination)
if product_variant:
return product_variant
if not self.has_dynamic_attributes():
if log_warning:
_logger.warning('The user #%s tried to create a variant for the non-dynamic product %s.' % (self.env.user.id, self.id))
return Product
if not self._is_combination_possible(combination):
if log_warning:
_logger.warning('The user #%s tried to create an invalid variant for the product %s.' % (self.env.user.id, self.id))
return Product
attribute_values = combination.mapped('product_attribute_value_id')._without_no_variant_attributes()
return Product.sudo().create({
'product_tmpl_id': self.id,
'attribute_value_ids': [(6, 0, attribute_value_ids.ids)]
'attribute_value_ids': [(6, 0, attribute_values.ids)]
})
return product_variant.id
@api.multi
def create_product_variant(self, product_template_attribute_value_ids):
""" Create if necessary and possible and return the id of the product
variant matching the given combination for this template.
Note AWA: Known "exploit" issues with this method:
- This method could be used by an unauthenticated user to generate a
lot of useless variants. Unfortunately, after discussing the
matter with ODO, there's no easy and user-friendly way to block
that behavior.
We would have to use captcha/server actions to clean/... that
are all not user-friendly/overkill mechanisms.
- This method could be used to try to guess what product variant ids
are created in the system and what product template ids are
configured as "dynamic", but that does not seem like a big deal.
The error messages are identical on purpose to avoid giving too much
information to a potential attacker:
- returning 0 when failing
- returning the variant id whether it already existed or not
:param product_template_attribute_value_ids: the combination for which
to get or create variant
:type product_template_attribute_value_ids: json encoded list of id
of `product.template.attribute.value`
:return: id of the product variant matching the combination or 0
:rtype: int
"""
combination = self.env['product.template.attribute.value'] \
.browse(json.loads(product_template_attribute_value_ids))
return self._create_product_variant(combination, log_warning=True).id or 0
@api.onchange('type')
def _onchange_type(self):
@@ -96,3 +167,132 @@ class ProductTemplate(models.Model):
'template': '/sale/static/xls/product_pricelist_several.xls'
}]
return res
@api.multi
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False):
""" Return info about a given combination.
Note: this method does not take into account whether the combination is
actually possible.
:param combination: recordset of `product.template.attribute.value`
:param product_id: id of a `product.product`. If no `combination`
is set, the method will try to load the variant `product_id` if
it exists instead of finding a variant based on the combination.
If there is no combination, that means we definitely want a
variant and not something that will have no_variant set.
:param add_qty: float with the quantity for which to get the info,
indeed some pricelist rules might depend on it.
:param pricelist: `product.pricelist` the pricelist to use
(can be none, eg. from SO if no partner and no pricelist selected)
:param parent_combination: if no combination and no product_id are
given, it will try to find the first possible combination, taking
into account parent_combination (if set) for the exclusion rules.
:return: dict with product/combination info:
- product_id: the variant id matching the combination (if it exists)
- product_template_id: the current template id
- display_name: the name of the combination
- price: the computed price of the combination, take the catalog
price if no pricelist is given
- list_price: the catalog price of the combination, but this is
not the "real" list_price, it has price_extra included (so
it's actually more closely related to `lst_price`), and it
is converted to the pricelist currency (if given)
- has_discounted_price: True if the pricelist discount policy says
the price does not include the discount and there is actually a
discount applied (price < list_price), else False
"""
self.ensure_one()
quantity = self.env.context.get('quantity', add_qty)
context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False)
product_template = self.with_context(context)
combination = combination or product_template.env['product.template.attribute.value']
if not product_id and not combination:
combination = product_template._get_first_possible_combination(parent_combination)
if product_id and not combination:
product = product_template.env['product.product'].browse(product_id)
else:
product = product_template._get_variant_for_combination(combination)
if product:
# We need to add the price_extra for the attributes that are not
# in the variant, typically those of type no_variant, but it is
# possible that a no_variant attribute is still in a variant if
# the type of the attribute has been changed after creation.
no_variant_attributes_price_extra = [
ptav.price_extra for ptav in combination.filtered(
lambda ptav:
ptav.price_extra and
ptav not in product.product_template_attribute_value_ids
)
]
if no_variant_attributes_price_extra:
product = product.with_context(
no_variant_attributes_price_extra=no_variant_attributes_price_extra
)
list_price = product.price_compute('list_price')[product.id]
price = product.price if pricelist else list_price
else:
product_template = product_template.with_context(current_attributes_price_extra=[v.price_extra or 0.0 for v in combination])
list_price = product_template.price_compute('list_price')[product_template.id]
price = product_template.price if pricelist else list_price
display_name = product_template.name
filtered_combination = combination._without_no_variant_attributes()
if filtered_combination:
display_name = '%s (%s)' % (display_name, ', '.join(filtered_combination.mapped('name')))
if pricelist and pricelist.currency_id != product_template.currency_id:
list_price = product_template.currency_id._convert(
list_price, pricelist.currency_id, product_template.company_id,
fields.Date.today()
)
price_without_discount = list_price if pricelist and pricelist.discount_policy == 'without_discount' else price
has_discounted_price = (pricelist or product_template).currency_id.compare_amounts(price_without_discount, price) == 1
return {
'product_id': product.id,
'product_template_id': product_template.id,
'display_name': display_name,
'price': price,
'list_price': list_price,
'has_discounted_price': has_discounted_price,
}
@api.multi
def _is_add_to_cart_possible(self, parent_combination=None):
"""
It's possible to add to cart (potentially after configuration) if
there is at least one possible combination, or if there is no
`product.template.attribute.line` at all.
:param parent_combination: the combination from which `self` is an
optional or accessory product.
:type parent_combination: recordset `product.template.attribute.value`
:return: True if it's possible to add to cart, else False
:rtype: bool
"""
self.ensure_one()
if not self.active:
return False
combination = self._get_first_possible_combination(parent_combination)
return True if combination else self._is_combination_possible(combination, parent_combination)
+79 -30
View File
@@ -381,11 +381,13 @@ class SaleOrder(models.Model):
if self.env.context.get('mail_activity_automation_skip'):
return super(SaleOrder, self)._write(values)
res = super(SaleOrder, self)._write(values)
if 'invoice_status' in values:
self.activity_unlink(['sale.mail_act_sale_upsell'])
if values['invoice_status'] == 'upselling':
for order in self.filtered('user_id'):
filtered_self = self.search([('id', 'in', self.ids),
('user_id', '!=', False),
('invoice_status', '!=', 'upselling')])
filtered_self.activity_unlink(['sale.mail_act_sale_upsell'])
for order in filtered_self:
order.activity_schedule(
'sale.mail_act_sale_upsell',
user_id=order.user_id.id,
@@ -393,7 +395,7 @@ class SaleOrder(models.Model):
order._name, order.id, order.name,
order.partner_id._name, order.partner_id.id, order.partner_id.display_name))
return res
return super(SaleOrder, self)._write(values)
@api.multi
def copy_data(self, default=None):
@@ -649,9 +651,10 @@ class SaleOrder(models.Model):
self.force_quotation_send()
# create an analytic account if at least an expense product
if any([expense_policy != 'no' for expense_policy in self.order_line.mapped('product_id.expense_policy')]):
if not self.analytic_account_id:
self._create_analytic_account()
for order in self:
if any([expense_policy != 'no' for expense_policy in order.order_line.mapped('product_id.expense_policy')]):
if not order.analytic_account_id:
order._create_analytic_account()
return True
@@ -1110,9 +1113,16 @@ class SaleOrderLine(models.Model):
product_uom_qty = fields.Float(string='Ordered Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0)
product_uom = fields.Many2one('uom.uom', string='Unit of Measure')
product_custom_attribute_value_ids = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string='User entered custom product attribute values')
# M2M holding the values of product.attribute with create_variant field set to 'no_variant'
# It allows keeping track of the extra_price associated to those attribute values and add them to the SO line description
# Note: If the attributes are changed on the template, some or all records
# in `product_no_variant_attribute_value_ids` will be removed here, even
# from existing or locked sales order lines. Thus this field can only be
# relied on initially, but it cannot be used to recompute anything later
# because the result might be different then.
product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string='Product attribute values that do not create variants')
# Non-stored related field to allow portal user to see the image of the product he has ordered
product_image = fields.Binary('Product Image', related="product_id.image", store=False, readonly=False)
@@ -1362,11 +1372,20 @@ class SaleOrderLine(models.Model):
# TO DO: move me in master/saas-16 on sale.order
# awa: don't know if it's still the case since we need the "product_no_variant_attribute_value_ids" field now
# to be able to compute the full price
if self.product_no_variant_attribute_value_ids:
product = product.with_context(no_variant_attributes_price_extra=[
no_variant_attribute_value.price_extra or 0
for no_variant_attribute_value in self.product_no_variant_attribute_value_ids
])
# it is possible that a no_variant attribute is still in a variant if
# the type of the attribute has been changed after creation.
no_variant_attributes_price_extra = [
ptav.price_extra for ptav in self.product_no_variant_attribute_value_ids.filtered(
lambda ptav:
ptav.price_extra and
ptav not in product.product_template_attribute_value_ids
)
]
if no_variant_attributes_price_extra:
product = product.with_context(
no_variant_attributes_price_extra=no_variant_attributes_price_extra
)
if self.order_id.pricelist_id.discount_policy == 'with_discount':
return product.with_context(pricelist=self.order_id.pricelist_id.id).price
@@ -1387,6 +1406,16 @@ class SaleOrderLine(models.Model):
if not self.product_id:
return {'domain': {'product_uom': []}}
# remove the is_custom values that don't belong to this template
for pacv in self.product_custom_attribute_value_ids:
if pacv.attribute_value_id not in self.product_id.product_tmpl_id._get_valid_product_attribute_values():
self.product_custom_attribute_value_ids -= pacv
# remove the no_variant attributes that don't belong to this template
for ptav in self.product_no_variant_attribute_value_ids:
if ptav.product_attribute_value_id not in self.product_id.product_tmpl_id._get_valid_product_attribute_values():
self.product_no_variant_attribute_value_ids -= ptav
vals = {}
domain = {'product_uom': [('category_id', '=', self.product_id.uom_id.category_id.id)]}
if not self.product_uom or (self.product_id.uom_id.id != self.product_uom.id):
@@ -1395,7 +1424,7 @@ class SaleOrderLine(models.Model):
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
partner=self.order_id.partner_id.id,
partner=self.order_id.partner_id,
quantity=vals.get('product_uom_qty') or self.product_uom_qty,
date=self.order_id.date_order,
pricelist=self.order_id.pricelist_id.id,
@@ -1419,20 +1448,6 @@ class SaleOrderLine(models.Model):
name = self.get_sale_order_line_multiline_description_sale(product)
if self.product_custom_attribute_value_ids or self.product_no_variant_attribute_value_ids:
name += '\n'
if self.product_custom_attribute_value_ids:
for product_custom_attribute_value in self.product_custom_attribute_value_ids:
if product_custom_attribute_value.custom_value and product_custom_attribute_value.custom_value.strip():
name += '\n' + product_custom_attribute_value.attribute_value_id.name + ': ' + product_custom_attribute_value.custom_value.strip()
if self.product_no_variant_attribute_value_ids:
for no_variant_attribute_value in self.product_no_variant_attribute_value_ids.filtered(
lambda product_attribute_value: not product_attribute_value.is_custom
):
name += '\n' + no_variant_attribute_value.attribute_id.name + ': ' + no_variant_attribute_value.name
vals.update(name=name)
self._compute_tax_id()
@@ -1451,7 +1466,7 @@ class SaleOrderLine(models.Model):
if self.order_id.pricelist_id and self.order_id.partner_id:
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
partner=self.order_id.partner_id.id,
partner=self.order_id.partner_id,
quantity=self.product_uom_qty,
date=self.order_id.date_order,
pricelist=self.order_id.pricelist_id.id,
@@ -1547,7 +1562,7 @@ class SaleOrderLine(models.Model):
self.discount = 0.0
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
partner=self.order_id.partner_id.id,
partner=self.order_id.partner_id,
quantity=self.product_uom_qty,
date=self.order_id.date_order,
pricelist=self.order_id.pricelist_id.id,
@@ -1581,4 +1596,38 @@ class SaleOrderLine(models.Model):
BUT in event_sale we need to know specifically the sales order line as well as the product to generate the name:
the product is not sufficient because we also need to know the event_id and the event_ticket_id (both which belong to the sale order line).
"""
return product.get_product_multiline_description_sale()
return product.get_product_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants()
def _get_sale_order_line_multiline_description_variants(self):
"""When using no_variant attributes or is_custom values, the product
itself is not sufficient to create the description: we need to add
information about those special attributes and values.
See note about `product_no_variant_attribute_value_ids` above the field
definition: this method is not reliable to recompute the description at
a later time, it should only be used initially.
:return: the description related to special variant attributes/values
:rtype: string
"""
if not self.product_custom_attribute_value_ids and not self.product_no_variant_attribute_value_ids:
return ""
name = "\n"
product_attribute_with_is_custom = self.product_custom_attribute_value_ids.mapped('attribute_value_id.attribute_id')
# display the no_variant attributes, except those that are also
# displayed by a custom (avoid duplicate)
for no_variant_attribute_value in self.product_no_variant_attribute_value_ids.filtered(
lambda ptav: ptav.attribute_id not in product_attribute_with_is_custom
):
name += "\n" + no_variant_attribute_value.attribute_id.name + ': ' + no_variant_attribute_value.name
# display the is_custom values
for pacv in self.product_custom_attribute_value_ids:
name += "\n" + pacv.attribute_value_id.attribute_id.name + \
': ' + pacv.attribute_value_id.name + \
': ' + (pacv.custom_value or '').strip()
return name
@@ -49,21 +49,27 @@ var ProductConfiguratorFormController = FormController.extend({
* @override
*/
_onFieldChanged: function (event) {
this._super.apply(this, arguments);
var self = this;
var product_id = event.data.changes.product_template_id.id;
// check to prevent traceback when emptying the field
if (!product_id) {
return;
}
this.$el.parents('.modal').find('.o_sale_product_configurator_add').removeClass('disabled');
this._rpc({
route: '/product_configurator/configure',
params: {
product_id: event.data.changes.product_template_id.id,
product_id: product_id,
pricelist_id: this.renderer.pricelistId
}
}).then(function (configurator) {
self.renderer.renderConfigurator(configurator);
});
this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
@@ -95,7 +101,7 @@ var ProductConfiguratorFormController = FormController.extend({
false
);
productReady.done(function (productId){
productReady.done(function (productId) {
$modal.find(productSelector.join(', ')).val(productId);
var variantValues = self
@@ -13,6 +13,7 @@ var ProductConfiguratorMixin = {
'click button.js_add_cart_json': 'onClickAddCartJSON',
'change [data-attribute_exclusions]': 'onChangeVariant'
},
isSelectedVariantAllowed: true,
//--------------------------------------------------------------------------
// Public
@@ -22,7 +23,6 @@ var ProductConfiguratorMixin = {
* When a product is added or when the quantity is changed,
* we need to refresh the total price row
* TODO awa: add a container context to avoid global selectors ?
*
*/
computePriceTotal: function () {
if ($('.js_price_total').length){
@@ -56,8 +56,10 @@ var ProductConfiguratorMixin = {
$component = $(ev.currentTarget).closest('form');
} else if ($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){
$component = $(ev.currentTarget).closest('.oe_optional_products_modal');
} else {
} else if ($(ev.currentTarget).closest('.o_product_configurator').length > 0) {
$component = $(ev.currentTarget).closest('.o_product_configurator');
} else {
$component = $(ev.currentTarget);
}
var qty = $component.find('input[name="add_qty"]').val();
@@ -68,10 +70,10 @@ var ProductConfiguratorMixin = {
ajax.jsonRpc(this._getUri('/product_configurator/get_combination_info'), 'call', {
product_template_id: parseInt($parent.find('.product_template_id').val()),
product_id: parseInt($parent.find('.product_id').val()),
product_id: this._getProductId($parent),
combination: combination,
add_qty: parseInt(qty),
pricelist_id: this.pricelistId
pricelist_id: this.pricelistId || false,
}).then(function (combinationData) {
self._onChangeCombination(ev, $parent, combinationData);
});
@@ -291,23 +293,28 @@ var ProductConfiguratorMixin = {
*/
selectOrCreateProduct: function ($container, productId, productTemplateId, useAjax) {
var self = this;
productId = parseInt(productId);
productTemplateId = parseInt(productTemplateId);
var productReady = $.Deferred();
if (productId && productId !== '0'){
if (productId) {
productReady.resolve(productId);
} else {
var params = {
model: 'product.template',
method: 'create_product_variant',
args: [
productTemplateId,
JSON.stringify(self.getSelectedVariantValues($container))
]
product_template_id: productTemplateId,
product_template_attribute_value_ids:
JSON.stringify(self.getSelectedVariantValues($container)),
};
// Note about 12.0 compatibility: this route will not exist if
// updating the code but not restarting the server. (404)
// We don't handle that compatibility because the previous code was
// not working either: it was making an RPC that failed with any
// non-admin user anyway. To use this feature, restart the server.
var route = '/product_configurator/create_product_variant';
if (useAjax) {
productReady = ajax.jsonRpc('/web/dataset/call', 'call', params);
productReady = ajax.jsonRpc(route, 'call', params);
} else {
productReady = this._rpc(params);
productReady = this._rpc({route: route, params: params});
}
}
@@ -335,6 +342,23 @@ var ProductConfiguratorMixin = {
* @param {Array} combination the selected combination of product attribute values
*/
_checkExclusions: function ($parent, combination) {
function areCombinationsEqual(c1, c2) {
return c1.length === c2.length && _.every(c1, function (ptav) {
return c2.indexOf(ptav) > -1;
});
}
function isCombinationInList(c1, list) {
return _.some(list, function (c2) {
return areCombinationsEqual(c1, c2);
});
}
function isPtavInCombination(ptav, combination) {
return combination.indexOf(ptav) > -1;
}
var self = this;
var combinationData = $parent
.find('ul[data-attribute_exclusions]')
@@ -343,43 +367,62 @@ var ProductConfiguratorMixin = {
$parent.find('option, input, label').removeClass('css_not_available');
var disable = false;
// compatibility 12.0
var filteredCombination = combination;
if (combinationData.no_variant_product_template_attribute_value_ids !== undefined) {
var no_variants = combinationData.no_variant_product_template_attribute_value_ids;
filteredCombination = _.filter(combination, function (ptav) {
return !isPtavInCombination(ptav, no_variants);
});
}
// exclusion rules: array of ptav
// for each of them, contains array with the other ptav they exclude
if (combinationData.exclusions) {
_.each(combination, function (combinationValue){
if (combinationData.exclusions &&
combinationData.exclusions.hasOwnProperty(combinationValue)){
// check that the selected combination is in the exclusions
_.each(combinationData.exclusions[combinationValue], function (exclusion) {
if (!disable && combination.indexOf(exclusion) > -1) {
// browse all the currently selected attributes
_.each(combination, function (current_ptav) {
if (combinationData.exclusions.hasOwnProperty(current_ptav)) {
// for each exclusion of the current attribute:
_.each(combinationData.exclusions[current_ptav], function (excluded_ptav) {
// disable if it excludes any other attribute already in the combination
if (isPtavInCombination(excluded_ptav, combination)) {
disable = true;
}
self._disableInput($parent, exclusion);
// disable the excluded input (even when not already selected)
// to give a visual feedback before click
self._disableInput($parent, excluded_ptav);
});
}
});
}
if (combinationData.parent_exclusions){
_.each(combinationData.parent_exclusions, function (exclusion){
if (!disable && combination.indexOf(exclusion) > -1) {
disable = true;
}
self._disableInput($parent, exclusion);
});
// parent exclusions (tell which attributes are excluded from parent)
_.each(combinationData.parent_exclusions, function (ptav) {
if (isPtavInCombination(ptav, combination)) {
disable = true;
}
// disable the excluded input (even when not already selected)
// to give a visual feedback before click
self._disableInput($parent, ptav);
});
// archived variants
if (isCombinationInList(filteredCombination, combinationData.archived_combinations)) {
disable = true;
}
if (combinationData.archived_combinations){
_.each(combinationData.archived_combinations, function (archived_combination){
if (disable) {
return;
}
disable = _.every(archived_combination, function (attribute_value){
return combination.indexOf(attribute_value) > -1;
});
});
// if not using dynamic attributes, exclude variants that are deleted
if (filteredCombination.length && // compatibility 12.0 list view of variants
combinationData.has_dynamic_attributes === false &&
combinationData.existing_combinations !== undefined &&
!isCombinationInList(filteredCombination, combinationData.existing_combinations)
) {
disable = true;
}
this.isSelectedVariantAllowed = !disable;
$parent.toggleClass('css_not_available', disable);
$parent.find("#add_to_cart").toggleClass('disabled', disable);
$parent
@@ -388,6 +431,15 @@ var ProductConfiguratorMixin = {
.toggleClass('disabled', disable);
},
/**
* Extracted to a method to be extendable by other modules
*
* @param {$.Element} $parent
*/
_getProductId: function ($parent) {
return parseInt($parent.find('.product_id').val());
},
/**
* Will disable the input/option that refers to the passed attributeValueId.
* This is used for showing the user that some combinations are not available.
@@ -418,7 +470,11 @@ var ProductConfiguratorMixin = {
var $optional_price = $parent.find(".oe_optional:first .oe_currency_value");
$price.html(self._priceToStr(combination.price));
$default_price.html(self._priceToStr(combination.list_price));
if (combination.list_price - combination.price >= 0.01) {
// compatibility_check to remove in master
// needed for fix in 12.0 in the case of git pull and no server restart
var compatibility_check = combination.list_price - combination.price >= 0.01;
if (combination.has_discounted_price !== undefined ? combination.has_discounted_price : compatibility_check) {
$default_price
.closest('.oe_website_sale')
.addClass("discount");
@@ -428,6 +484,9 @@ var ProductConfiguratorMixin = {
.css('text-decoration', 'line-through');
$default_price.parent().removeClass('d-none');
} else {
$default_price
.closest('.oe_website_sale')
.removeClass("discount");
$optional_price.closest('.oe_optional').addClass('d-none');
$default_price.parent().addClass('d-none');
}
@@ -438,11 +497,16 @@ var ProductConfiguratorMixin = {
'.o_product_configurator'
];
self._updateProductImage(
$parent.closest(rootComponentSelectors.join(', ')),
combination.product_id,
combination.product_template_id
);
// update images only when changing product
if (combination.product_id !== this.last_product_id) {
this.last_product_id = combination.product_id;
self._updateProductImage(
$parent.closest(rootComponentSelectors.join(', ')),
combination.product_id,
combination.product_template_id,
combination.carousel
);
}
$parent
.find('.product_id')
@@ -492,47 +556,20 @@ var ProductConfiguratorMixin = {
* @param {integer} productTemplateId
*/
_updateProductImage: function ($productContainer, productId, productTemplateId) {
var $img;
var model = productId ? 'product.product' : 'product.template';
var modelId = productId || productTemplateId;
var imageSrc = '/web/image?model={0}&id={1}&field=image'
var imageSrc = '/web/image/{0}/{1}/image'
.replace("{0}", model)
.replace("{1}", modelId);
if ($productContainer.find('#o-carousel-product').length) {
$img = $productContainer.find('img.js_variant_img');
$img.attr("src", imageSrc);
$img.parent().attr('data-oe-model', model).attr('data-oe-id', modelId)
.data('oe-model', model).data('oe-id', modelId);
var imagesSelectors = [
'span[data-oe-model^="product."][data-oe-type="image"] img:first',
'img.product_detail_img',
'span.variant_image img'
];
var $thumbnail = $productContainer.find('img.js_variant_img_small');
if ($thumbnail.length !== 0) { // if only one, thumbnails are not displayed
$thumbnail.attr("src", "/web/image/{0}/{1}/image/90x90"
.replace('{0}', model)
.replace('{1}', modelId));
$('.carousel').carousel(0);
}
}
else {
var imagesSelectors = [
'span[data-oe-model^="product."][data-oe-type="image"] img:first',
'img.product_detail_img',
'span.variant_image img'
];
$img = $productContainer.find(imagesSelectors.join(', '));
$img.attr('src', imageSrc);
$img.parent()
.attr('data-oe-model', model)
.attr('data-oe-id', modelId)
.data('oe-model', model)
.data('oe-id', modelId);
}
// reset zooming constructs
$img.filter('[data-zoom-image]').attr('data-zoom-image', $img.attr('src'));
if ($img.data('zoomOdoo') !== undefined) {
$img.data('zoomOdoo').isReady = false;
}
var $img = $productContainer.find(imagesSelectors.join(', '));
$img.attr('src', imageSrc);
},
/**
@@ -553,11 +590,13 @@ var ProductConfiguratorMixin = {
* Website behavior is slightly different from backend so we append
* "_website" to URLs to lead to a different route
*
* TODO this should be overriden in website_sale instead.
*
* @private
* @param {string} uri The uri to adapt
*/
_getUri: function (uri) {
if (this.isWebsite){
if (this.isWebsite) {
return uri + '_website';
} else {
return uri;
@@ -47,7 +47,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
init: function (parent, params) {
var self = this;
this._super(parent, {
var options = _.extend({
size: 'large',
buttons: [{
text: params.okButtonText,
@@ -57,8 +57,10 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
text: params.cancelButtonText,
click: this._onCancelButtonClick
}],
title: params.title
});
technical: !params.isWebsite,
}, params || {});
this._super(parent, options);
this.rootProduct = params.rootProduct;
this.container = parent;
@@ -70,10 +72,6 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
delete this.events['change [data-attribute_exclusions]'];
delete this.events['click button.js_add_cart_json'];
}
this._opened.then(function () {
self.triggerVariantChange(self.$el);
});
},
/**
* @override
@@ -85,7 +83,8 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
var getModalContent = ajax.jsonRpc(uri, 'call', {
product_id: self.rootProduct.product_id,
variant_values: self.rootProduct.variant_values,
pricelist_id: self.pricelistId,
pricelist_id: self.pricelistId || false,
add_qty: self.rootProduct.quantity,
kwargs: {
context: _.extend({
'quantity': self.rootProduct.quantity
@@ -124,6 +123,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
self.$modal.attr('open', true);
self.$modal.removeAttr("aria-hidden");
self.$modal.modal().appendTo(self.container);
self.$modal.focus();
self._opened.resolve();
}
});
@@ -138,10 +138,18 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
*
* @override
*/
start: function (){
this._super.apply(this, arguments);
start: function () {
var def = this._super.apply(this, arguments);
var self = this;
this.$el.find('input[name="add_qty"]').val(this.rootProduct.quantity);
return def.then(function () {
// This has to be triggered to compute the "out of stock" feature
self._opened.then(function () {
self.triggerVariantChange(self.$el);
});
});
},
//--------------------------------------------------------------------------
@@ -357,7 +365,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
ajax.jsonRpc(self._getUri("/product_configurator/optional_product_items"), 'call', {
'product_id': productId,
'pricelist_id': self.pricelistId
'pricelist_id': self.pricelistId || false,
}).then(function (addedItem) {
var $addedItem = $(addedItem);
$modal.find('tr:last').after($addedItem);
@@ -408,7 +416,7 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
this._removeOptionOption($modal, productTemplateId);
$('tr:last').after($parent);
$modal.find('tr:last').after($parent);
},
/**
+2 -2
View File
@@ -46,7 +46,7 @@ tour.register('sale_tour', {
content: _t("Select a product, or create a new one on the fly."),
position: "right",
run: function (actions) {
actions.text("DESK0001", this.$anchor.find("input"));
actions.text("Chair", this.$anchor.find("input"));
},
}, {
trigger: ".ui-menu-item > a",
@@ -58,12 +58,12 @@ tour.register('sale_tour', {
$('.modal-dialog:has(div.o_dialog_warning) footer.modal-footer .btn-primary').trigger('click');
}
},
id: "quotation_product_selected",
}, {
trigger: ".o_form_button_save",
extra_trigger: ".o_sale_order",
content: _t("Once your quotation is ready, you can save, print or send it by email."),
position: "right",
id: "form_button_save_clicked"
}, {
trigger: ".o_sale_print",
extra_trigger: ".o_sale_order.o_form_readonly",
@@ -39,6 +39,13 @@
.css_not_available_msg {
display: block;
}
.js_add,
.oe_price,
.oe_default_price,
.oe_optional {
display: none;
}
}
.css_quantity {
@@ -113,12 +120,7 @@ label.css_attribute_color.css_not_available {
}
.o_total_row {
h5 {
padding-top: 0.4rem;
}
.js_price_total {
font-size: 1.5rem;
}
font-size: 1.2rem;
}
}
@@ -140,10 +142,6 @@ label.css_attribute_color.css_not_available {
background-image: none;
}
.remove-optionnal-item, .add-optionnal-item {
padding-left: 5px;
}
.td-product_name {
word-wrap: break-word;
}
+1
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_sale_product_attribute_value_config
from . import test_sale_to_invoice
from . import test_sale_order
from . import test_sale_pricelist
@@ -0,0 +1,389 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields
from odoo.addons.product.tests.test_product_attribute_value_config import TestProductAttributeValueSetup
from odoo.tests import tagged
class TestSaleProductAttributeValueSetup(TestProductAttributeValueSetup):
def _setup_currency(self, currency_ratio=2):
"""Get or create a currency. This makes the test non-reliant on demo.
With an easy currency rate, for a simple 2 ratio in the following tests.
"""
from_currency = self.computer.currency_id
self._set_or_create_rate_today(from_currency, rate=1)
to_currency = self._get_or_create_currency("my currency", "C")
self._set_or_create_rate_today(to_currency, currency_ratio)
return to_currency
def _set_or_create_rate_today(self, currency, rate):
"""Get or create a currency rate for today. This makes the test
non-reliant on demo data."""
name = fields.Date.today()
currency_id = currency.id
company_id = self.env.user.company_id.id
CurrencyRate = self.env['res.currency.rate']
currency_rate = CurrencyRate.search([
('company_id', '=', company_id),
('currency_id', '=', currency_id),
('name', '=', name),
])
if currency_rate:
currency_rate.rate = rate
else:
CurrencyRate.create({
'company_id': company_id,
'currency_id': currency_id,
'name': name,
'rate': rate,
})
def _get_or_create_currency(self, name, symbol):
"""Get or create a currency based on name. This makes the test
non-reliant on demo data."""
currency = self.env['res.currency'].search([('name', '=', name)])
return currency or currency.create({
'name': name,
'symbol': symbol,
})
@tagged('post_install', '-at_install')
class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
def _setup_pricelist(self, currency_ratio=2):
to_currency = self._setup_currency(currency_ratio)
discount = 10
pricelist = self.env['product.pricelist'].create({
'name': 'test pl',
'currency_id': to_currency.id,
'company_id': self.computer.company_id.id,
})
pricelist_item = self.env['product.pricelist.item'].create({
'min_quantity': 2,
'compute_price': 'percentage',
'percent_price': discount
})
pricelist.item_ids += pricelist_item
return (pricelist, pricelist_item, currency_ratio, 1 - discount / 100)
def test_01_is_combination_possible_archived(self):
"""The goal is to test the possibility of archived combinations.
This test could not be put into product module because there was no
field which had product_id as required and without cascade on delete.
"""
def do_test(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
variant2 = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_2)
# Create a dummy SO to prevent the variant from being deleted by
# create_variant_ids() because the variant is a related field that
# is required on the SO line
so = self.env['sale.order'].create({'partner_id': 1})
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant.id
})
# additional variant to test correct ignoring when mismatch values
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant2.id
})
variant2.active = False
# CASE: 1 not archived, 2 archived
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
# CASE: both archived combination (without no_variant)
variant.active = False
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: not archived (with no_variant)
self.computer_hdd_attribute_lines.unlink()
self.hdd_attribute.create_variant = 'no_variant'
self._add_hdd_attribute_line()
self.computer.create_variant_ids()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: archived combination found (with no_variant)
variant = self.computer._get_variant_for_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_1)
variant.active = False
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
# CASE: archived combination has different attributes (including no_variant)
self.computer_ssd_attribute_lines.unlink()
self.computer.create_variant_ids()
variant4 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1)
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant4.id
})
self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1))
# CASE: archived combination has different attributes (without no_variant)
self.computer_hdd_attribute_lines.unlink()
self.hdd_attribute.create_variant = 'always'
self._add_hdd_attribute_line()
self.computer.create_variant_ids()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
variant5 = self.computer._get_variant_for_combination(computer_ram_8 + computer_hdd_1)
self.env['sale.order.line'].create({
'order_id': so.id,
'name': "test",
'product_id': variant5.id
})
self.assertTrue(variant4 != variant5)
self.assertTrue(self.computer._is_combination_possible(computer_ram_8 + computer_hdd_1))
computer_ssd_256_before = self._get_product_template_attribute_value(self.ssd_256)
do_test(self)
# CASE: add back the removed attribute and try everything again
# It will be the same attribute but the ptal and ptav will be different!
self.computer_ssd_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer.id,
'attribute_id': self.ssd_attribute.id,
'value_ids': [(6, 0, [self.ssd_256.id, self.ssd_512.id])],
})
self.computer.create_variant_ids()
computer_ssd_256_after = self._get_product_template_attribute_value(self.ssd_256)
self.assertTrue(computer_ssd_256_after != computer_ssd_256_before)
do_test(self)
def test_02_get_combination_info(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
# CASE: no pricelist, no currency, with existing combination, with price_extra on attributes
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
computer_variant = self.computer._get_variant_for_combination(combination)
res = self.computer._get_combination_info(combination)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], computer_variant.id)
self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)")
self.assertEqual(res['price'], 2222)
self.assertEqual(res['list_price'], 2222)
# CASE: no combination, product given
res = self.computer._get_combination_info(self.env['product.template.attribute.value'], computer_variant.id)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], computer_variant.id)
# the variant has the same name as the template
self.assertEqual(res['display_name'], "Super Computer")
self.assertEqual(res['price'], 2222)
self.assertEqual(res['list_price'], 2222)
# CASE: using pricelist, quantity rule
pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist()
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], computer_variant.id)
self.assertEqual(res['display_name'], "Super Computer (256 GB, 8 GB, 1 To)")
self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
# CASE: no_variant combination, it's another variant now
self.computer_ssd_attribute_lines.unlink()
self.ssd_attribute.create_variant = 'no_variant'
self._add_ssd_attribute_line()
self.computer.create_variant_ids()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
computer_variant_new = self.computer._get_variant_for_combination(combination)
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], computer_variant_new.id)
self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)")
self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
# CASE: dynamic combination, but the variant already exists
self.computer_hdd_attribute_lines.unlink()
self.hdd_attribute.create_variant = 'dynamic'
self._add_hdd_attribute_line()
self.computer.create_variant_ids()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], computer_variant_new.id)
self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To)")
self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
# CASE: dynamic combination, no variant existing
self._add_keyboard_attribute()
self.computer.create_variant_ids()
combination += self._get_product_template_attribute_value(self.keyboard_excluded)
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], False)
self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)")
self.assertEqual(res['price'], (2222 - 5) * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
# CASE: pricelist set value to 0, no variant
pricelist_item.percent_price = 100
self.computer.invalidate_cache() # need o2m to be refetched
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['product_template_id'], self.computer.id)
self.assertEqual(res['product_id'], False)
self.assertEqual(res['display_name'], "Super Computer (8 GB, 1 To, Excluded)")
self.assertEqual(res['price'], 0)
self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
def test_03_get_combination_info_discount_policy(self):
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
pricelist, pricelist_item, currency_ratio, discount_ratio = self._setup_pricelist()
pricelist.discount_policy = 'with_discount'
# CASE: no discount, setting with_discount
res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist)
self.assertEqual(res['price'], 2222 * currency_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
self.assertEqual(res['has_discounted_price'], False)
# CASE: discount, setting with_discount
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
self.assertEqual(res['has_discounted_price'], False)
# CASE: no discount, setting without_discount
pricelist.discount_policy = 'without_discount'
res = self.computer._get_combination_info(combination, add_qty=1, pricelist=pricelist)
self.assertEqual(res['price'], 2222 * currency_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
self.assertEqual(res['has_discounted_price'], False)
# CASE: discount, setting without_discount
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
self.assertEqual(res['price'], 2222 * currency_ratio * discount_ratio)
self.assertEqual(res['list_price'], 2222 * currency_ratio)
self.assertEqual(res['has_discounted_price'], True)
def test_04_create_product_variant_non_dynamic(self):
"""The goal of this test is to make sure the create_product_variant does
not create variant if the type is not dynamic. It can however return a
variant if it already exists."""
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
self._add_exclude(computer_ram_16, computer_hdd_1)
# CASE: variant is already created, it should return it
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
variant1 = self.computer._get_variant_for_combination(combination)
self.assertEqual(self.computer._create_product_variant(combination), variant1)
# CASE: variant does not exist, but template is non-dynamic, so it
# should not create it
Product = self.env['product.product']
variant1.unlink()
self.assertEqual(self.computer._create_product_variant(combination), Product)
def test_05_create_product_variant_dynamic(self):
"""The goal of this test is to make sure the create_product_variant does
work with dynamic. If the combination is possible, it should create it.
If it's not possible, it should not create it."""
self.computer_hdd_attribute_lines.unlink()
self.computer.create_variant_ids()
self.hdd_attribute.create_variant = 'dynamic'
self._add_hdd_attribute_line()
self.computer.create_variant_ids()
self.computer.invalidate_cache()
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
self._add_exclude(computer_ram_16, computer_hdd_1)
# CASE: variant does not exist, but combination is not possible
# so it should not create it
impossible_combination = computer_ssd_256 + computer_ram_16 + computer_hdd_1
Product = self.env['product.product']
self.assertEqual(self.computer._create_product_variant(impossible_combination), Product)
# CASE: the variant does not exist, and the combination is possible, so
# it should create it
combination = computer_ssd_256 + computer_ram_8 + computer_hdd_1
variant = self.computer._create_product_variant(combination)
self.assertTrue(variant)
# CASE: the variant already exists, so it should return it
self.assertEqual(variant, self.computer._create_product_variant(combination))
def _add_keyboard_attribute(self):
self.keyboard_attribute = self.env['product.attribute'].create({
'name': 'Keyboard',
'sequence': 6,
'create_variant': 'dynamic',
})
self.keyboard_included = self.env['product.attribute.value'].create({
'name': 'Included',
'attribute_id': self.keyboard_attribute.id,
'sequence': 1,
})
self.keyboard_excluded = self.env['product.attribute.value'].create({
'name': 'Excluded',
'attribute_id': self.keyboard_attribute.id,
'sequence': 2,
})
self.computer_keyboard_attribute_lines = self.env['product.template.attribute.line'].create({
'product_tmpl_id': self.computer.id,
'attribute_id': self.keyboard_attribute.id,
'value_ids': [(6, 0, [self.keyboard_included.id, self.keyboard_excluded.id])],
})
self.computer_keyboard_attribute_lines.product_template_value_ids[0].price_extra = 5
self.computer_keyboard_attribute_lines.product_template_value_ids[1].price_extra = -5
+2 -1
View File
@@ -455,6 +455,7 @@
</td>
<td t-attf-class="text-right {{ 'd-none d-sm-table-cell' if report_type == 'html' else '' }}">
<div
t-if="line.discount &gt;= 0"
t-field="line.price_unit"
t-options='{"widget": "monetary", "display_currency": sale_order.pricelist_id.currency_id}'
t-att-style="line.discount and 'text-decoration: line-through' or None"
@@ -465,7 +466,7 @@
</div>
</td>
<td t-if="display_discount" t-attf-class="text-right {{ 'd-none d-sm-table-cell' if report_type == 'html' else '' }}">
<strong t-if="line.discount" class="text-info">
<strong t-if="line.discount &gt; 0" class="text-info">
<t t-esc="((line.discount % 1) and '%s' or '%d') % line.discount"/>% discount
</strong>
</td>
@@ -6,43 +6,64 @@
</main>
</template>
<!-- backend -->
<template id="product_configurator_configure" name="Configure">
<div class="js_product main_product">
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/>
<t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<input type="hidden" class="product_template_id" t-att-value="product.id"/>
<input type="hidden" class="product_id" t-attf-name="product_id" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'"/>
<input type="hidden" class="product_id" t-attf-name="product_id" t-att-value="product_variant.id"/>
<div class="col-lg-12 text-center mt-5">
<div class="col-lg-5 d-inline-block align-top text-left">
<span t-field="product.image" t-options='{"widget": "image", "class": "d-block product_detail_img"}'/>
<img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image' % product_variant.id" class="d-block product_detail_img" alt="Product Image"/>
<img t-else="" t-att-src="'/web/image/product.template/%s/image' % product.id" class="d-block product_detail_img" alt="Product Image"/>
</div>
<div class="col-lg-1 d-inline-block"></div>
<div class="col-lg-5 d-inline-block text-left">
<t t-call="sale.variants" />
<h2 class="oe_price product_id mt-3" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-esc="product.price"
t-options='{
"widget": "monetary",
"display_currency": to_currency
}'/>
<div t-if="product._is_add_to_cart_possible()" class="col-lg-5 d-inline-block text-left">
<t t-if="first_possible_combination" t-call="sale.variants">
<t t-set="parent_combination" t-value="None"/>
</t>
<h2>
<span t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
t-esc="combination_info['list_price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product).currency_id
}'/>
<span class="oe_price product_id mt-3" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-esc="combination_info['price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product).currency_id
}'/>
</h2>
<div class="css_quantity input-group oe_website_spinner">
<div class="input-group-prepend">
<button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
<i class="fa fa-minus"></i>
</button>
</div>
<input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" value="1"/>
<input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" t-att-value="add_qty or 1"/>
<div class="input-group-append">
<button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one">
<i class="fa fa-plus"></i>
</button>
</div>
</div>
<p class="css_not_available_msg bg-danger" style="padding: 15px;">This combination does not exist.</p>
<p class="css_not_available_msg alert alert-warning">This combination does not exist.</p>
</div>
<div t-else="" class="col-lg-5 d-inline-block text-left">
<p class="alert alert-warning">This product has no valid combination.</p>
</div>
</div>
</div>
</template>
<!-- modal: full table, currenclty selected products at top -->
<template id="product_configurator_configure_optional_products">
<table class="table table-striped table-sm">
<thead>
@@ -55,215 +76,237 @@
</thead>
<tbody>
<tr class="js_product in_cart main_product">
<t t-set="combination_info" t-value="product.product_tmpl_id._get_combination_info(combination, product.id, add_qty or 1, pricelist)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<input type="hidden" class="product_template_id" t-att-value="product.product_tmpl_id.id"/>
<input type="hidden" class="product_id" t-att-value="product.id"/>
<input type="hidden" class="product_id" t-att-value="product_variant.id"/>
<td class='td-img'>
<span t-field="product.image_medium" t-options='{"widget": "image" }'/>
<img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image_medium' % product_variant.id" alt="Product Image"/>
<img t-else="" t-att-src="'/web/image/product.template/%s/image_medium' % product.id" alt="Product Image"/>
</td>
<td class='td-product_name'>
<strong t-field="product.display_name"/>
<div class="text-muted small">
<div t-field="product.description_sale"/>
<div class="js_attributes"/>
</div>
<strong t-esc="combination_info['display_name']"/>
<div class="text-muted small">
<div t-field="product.description_sale"/>
<div class="js_attributes"/>
</div>
</td>
<td class="text-center td-qty">
<div class="css_quantity input-group oe_website_spinner">
<div class="input-group-prepend">
<button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
<i class="fa fa-minus"></i>
</button>
<div class="css_quantity input-group oe_website_spinner">
<div class="input-group-prepend">
<button t-attf-href="#" class="btn btn-primary js_add_cart_json d-none d-md-inline-block" aria-label="Remove one" title="Remove one">
<i class="fa fa-minus"></i>
</button>
</div>
<input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" t-att-value="add_qty or 1"/>
<div class="input-group-append">
<button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one">
<i class="fa fa-plus"></i>
</button>
</div>
</div>
<input type="text" class="js_quantity form-control quantity" data-min="1" name="add_qty" value="1"/>
<div class="input-group-append">
<button t-attf-href="#" class="btn btn-primary float_left js_add_cart_json d-none d-md-inline-block" aria-label="Add one" title="Add one">
<i class="fa fa-plus"></i>
</button>
</div>
</div>
</td>
<td class="text-center td-price" name="price">
<ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="{'exclusions: []'}"></ul>
<div class="d-none oe_unchanged_value_ids" t-att-data-unchanged_value_ids="variant_values" ></div>
<div t-attf-class="text-danger oe_default_price oe_striked_price {{ (compute_currency(product.lst_price) - product.price) &lt; 0.01 and 'd-none' }}"
t-field="product.lst_price"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": to_currency
}'/>
<span class="oe_price product_id" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-esc="product.price"
t-options='{
"widget": "monetary",
"display_currency": to_currency
}'/>
<span class="js_raw_price d-none" t-esc="product.price" />
<ul class="d-none js_add_cart_variants" t-att-data-attribute_exclusions="{'exclusions: []'}"></ul>
<div class="d-none oe_unchanged_value_ids" t-att-data-unchanged_value_ids="variant_values" ></div>
<div t-attf-class="text-danger oe_default_price oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
t-esc="combination_info['list_price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product).currency_id
}'
/>
<span class="oe_price product_id" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-esc="combination_info['price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product).currency_id
}'/>
<span class="js_raw_price d-none" t-esc="product.price"/>
</td>
</tr>
<tr class="o_total_row">
<td colspan="2"></td>
<td class="text-right"><h5>Total:</h5></td>
<td class="text-center">
<td colspan="4" class="text-right">
<strong>Total:</strong>
<span class="js_price_total font-weight-bold" style="white-space: nowrap;"
t-att-data-product-id="product.id"
t-esc="product.price"
t-esc="combination_info['price'] * (add_qty or 1)"
t-options='{
"widget": "monetary",
"display_currency": to_currency
}'/>
"widget": "monetary",
"display_currency": (pricelist or product).currency_id
}'/>
</td>
</tr>
<tr t-if="product.optional_product_ids" class="o_select_options"><td colspan="4"><h4>Available Options:</h4></td></tr>
<t t-call="sale.optional_product_items" />
<t t-call="sale.optional_product_items">
<t t-set="parent_combination" t-value="combination"/>
</t>
</tbody>
</table>
</template>
<!-- modal: optional products -->
<template id="optional_product_items">
<t t-foreach="product.optional_product_ids" t-as="product">
<t t-if="len(product.get_filtered_variants(reference_product)) > 0 or product.has_dynamic_attributes()">
<tr class="js_product">
<td class="td-img">
<input type="hidden" class="product_template_id" t-att-value="product.id"/>
<input type="hidden" class="product_id" t-attf-name="optional-product-#{product.id}" t-att-value="product.product_variant_id.id if len(product.product_variant_ids) == 1 else '0'"/>
<input type="hidden" class="product_display_name" t-attf-name="optional-productname-#{product.id}" t-att-value="product.product_variant_id.display_name if len(product.product_variant_ids) == 1 else ''"/>
<span t-field="product.image_small" t-options='{"widget": "image"}' class="variant_image"/>
</td>
<td class='td-product_name' colspan="2">
<div class="float-left">
<strong class="product-name" t-field="product.name"/>
<div class="text-muted small" t-field="product.description_sale"/>
</div>
<div class="float-right">
<t t-call="sale.variants"/>
</div>
</td>
<td class="text-center td-price">
<span class="text-danger oe_default_price oe_optional"
t-field="product.lst_price"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": to_currency
}'/>
<span class="oe_price" style="white-space: nowrap;"
t-field="product.price"
t-options='{
"widget": "monetary",
"display_currency": to_currency
}'/>
<span class="js_raw_price d-none" t-esc="product.price" />
<p class="css_not_available_msg bg-danger" style="padding: 5px;">Option not available</p>
<t t-foreach="product.optional_product_ids" t-as="product">
<t t-set="first_possible_combination" t-value="product._get_first_possible_combination(parent_combination)"/>
<t t-if="product._is_add_to_cart_possible()">
<input type="hidden" class="js_optional_same_quantity" t-attf-name="optional-add-#{product.id}" value="0"/>
<a role="button" href="#" class="js_add"><i class="fa fa-shopping-cart add-optionnal-item"></i></a>
<span class="js_remove d-none">
<span class="optional_product_quantity"><span class="js_item add_qty">1 </span><span class="js_items d-none">5 </span> <span t-field="product.uom_id.sudo().name"/><br/></span>
<a role="button" href="#" class="js_remove"><i class="fa fa-trash-o remove-optionnal-item"></i></a>
</span>
</td>
</tr>
<t t-set="combination_info" t-value="product._get_combination_info(first_possible_combination, add_qty=add_qty or 1, pricelist=pricelist)"/>
<t t-set="product_variant" t-value="product.env['product.product'].browse(combination_info['product_id'])"/>
<tr class="js_product">
<td class="td-img">
<input type="hidden" class="product_template_id" t-att-value="product.id"/>
<input type="hidden" class="product_id" t-attf-name="optional-product-#{product.id}" t-att-value="product_variant.id"/>
<input type="hidden" class="product_display_name" t-attf-name="optional-productname-#{product.id}" t-att-value="combination_info['display_name']"/>
<img t-if="product_variant" t-att-src="'/web/image/product.product/%s/image_medium' % product_variant.id" class="variant_image" alt="Product Image"/>
<img t-else="" t-att-src="'/web/image/product.template/%s/image_medium' % product.id" class="variant_image" alt="Product Image"/>
</td>
<td class='td-product_name' colspan="2">
<div class="float-left">
<strong class="product-name" t-esc="combination_info['display_name']"/>
<div class="text-muted small" t-field="product.description_sale"/>
</div>
<div class="float-right">
<t t-call="sale.variants"/>
</div>
</td>
<td class="text-center td-price">
<div t-attf-class="text-danger oe_default_price oe_optional oe_striked_price {{'' if combination_info['has_discounted_price'] else 'd-none'}}"
t-esc="combination_info['list_price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product).currency_id
}'/>
<div class="oe_price" style="white-space: nowrap;"
t-esc="combination_info['price']"
t-options='{
"widget": "monetary",
"display_currency": (pricelist or product).currency_id
}'/>
<span class="js_raw_price d-none" t-esc="combination_info['price']" />
<p class="css_not_available_msg alert alert-warning">Option not available</p>
<input type="hidden" class="js_optional_same_quantity" t-attf-name="optional-add-#{product.id}" value="0"/>
<a role="button" href="#" class="js_add btn btn-primary btn-sm"><i class="fa fa-shopping-cart add-optionnal-item"></i> Add to cart</a>
<span class="js_remove d-none">
<span class="optional_product_quantity"><span class="js_item add_qty">1 </span><span class="js_items d-none">5 </span> <span t-field="product.uom_id.sudo().name"/><br/></span>
<a role="button" href="#" class="js_remove"><i class="fa fa-trash-o remove-optionnal-item"></i></a>
</span>
</td>
</tr>
</t>
</t>
</t>
</template>
<template id="variants">
<t t-set="attribute_exclusions" t-value="get_attribute_exclusions(product, reference_product)"/>
<ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_exclusions="json.dumps(attribute_exclusions)">
<t t-foreach="product.attribute_line_ids.sorted(key=lambda x: x.attribute_id.sequence)" t-as="variant_id">
<!-- Attributes selection is hidden if there is only one value available and it's not a custom value -->
<li t-att-data-attribute_id="variant_id.attribute_id.id"
t-att-data-attribute_name="variant_id.attribute_id.name"
t-attf-class="variant_attribute #{'d-none' if len(variant_id.product_template_value_ids) == 1 and not variant_id.product_template_value_ids[0].is_custom else ''}">
<t t-set="attribute_exclusions" t-value="product._get_attribute_exclusions(parent_combination)"/>
<ul t-attf-class="list-unstyled js_add_cart_variants #{ul_class}" t-att-data-attribute_exclusions="json.dumps(attribute_exclusions)">
<t t-foreach="product._get_valid_product_template_attribute_lines()" t-as="variant_id">
<!-- Attributes selection is hidden if there is only one value available and it's not a custom value -->
<li t-att-data-attribute_id="variant_id.attribute_id.id"
t-att-data-attribute_name="variant_id.attribute_id.name"
t-attf-class="variant_attribute #{'d-none' if len(variant_id.product_template_value_ids) == 1 and not variant_id.product_template_value_ids[0].is_custom else ''}">
<strong t-field="variant_id.attribute_id.name" class="attribute_name" />
<strong t-field="variant_id.attribute_id.name" class="attribute_name"/>
<t t-if="variant_id.attribute_id.type == 'select'">
<select
t-att-data-attribute_id="variant_id.attribute_id.id"
t-attf-class="form-control js_variant_change #{variant_id.attribute_id.create_variant}"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)">
<t t-foreach="variant_id.product_template_value_ids" t-as="value_id">
<option t-att-value="value_id.id"
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom">
<span t-field="value_id.name"/>
<span t-if="value_id.price_extra">
<t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" class="variant_price_extra" style="white-space: nowrap;" t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": to_currency or website.currency_id
}'/>
</span>
</option>
</t>
</select>
</t>
<t t-if="variant_id.attribute_id.type == 'radio'">
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-unstyled">
<t t-set="inc" t-value="0"/>
<t t-foreach="variant_id.product_template_value_ids" t-as="value_id">
<li class="form-group js_attribute_value" style="margin: 0;">
<label class="col-form-label">
<div>
<input type="radio"
t-attf-class="js_variant_change radio_input #{variant_id.attribute_id.create_variant}"
t-att-checked="'checked' if not inc else None"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
t-att-value="value_id.id"
<t t-if="variant_id.attribute_id.type == 'select'">
<select
t-att-data-attribute_id="variant_id.attribute_id.id"
t-attf-class="form-control js_variant_change #{variant_id.attribute_id.create_variant}"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)">
<t t-foreach="variant_id.product_template_value_ids" t-as="value_id">
<option t-att-value="value_id.id"
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom" />
<div class="radio_input_value">
<span t-field="value_id.name"/>
<span class="badge badge-pill" t-if="value_id.price_extra">
<t t-esc="value_id.price_extra > 0 and '+' or ''"/><span t-field="value_id.price_extra" class="variant_price_extra" style="white-space: nowrap;" t-options='{
t-att-data-is_custom="value_id.is_custom"
t-att-selected="first_possible_combination[variant_id_index] == value_id">
<span t-field="value_id.name"/>
<span t-if="value_id.price_extra" class="badge badge-pill badge-secondary">
<!--
price_extra is displayed as catalog price instead of
price after pricelist because it is impossible to
compute. Indeed, the pricelist rule might depend on the
selected variant, so the price_extra will be different
depending on the selected combination. The price of an
attribute is therefore variable and it's not very
accurate to display it.
-->
<t t-esc="value_id.price_extra > 0 and '+' or '-'"/>
<span t-esc="abs(value_id.price_extra)" class="variant_price_extra" style="white-space: nowrap;"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": to_currency or website.currency_id
"display_currency": (pricelist or product).currency_id
}'/>
</span>
</div>
</div>
</label>
</li>
<t t-set="inc" t-value="inc+1"/>
</t>
</ul>
</t>
</span>
</option>
</t>
</select>
</t>
<t t-if="variant_id.attribute_id.type == 'color'">
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-inline">
<t t-set="inc" t-value="0"/>
<li t-foreach="variant_id.product_template_value_ids" t-as="value_id" class="list-inline-item">
<label t-attf-style="background-color:#{value_id.html_color or value_id.name if not value_id.is_custom else ''}"
t-attf-class="css_attribute_color #{'active' if not inc else ''} #{'custom_value' if value_id.is_custom else ''}">
<input type="radio"
t-attf-class="js_variant_change #{variant_id.attribute_id.create_variant}"
t-att-checked="'checked' if not inc else None"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
t-att-value="value_id.id"
t-att-title="value_id.name"
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom"/>
</label>
<t t-set="inc" t-value="inc+1"/>
</li>
</ul>
</t>
<t t-if="variant_id.attribute_id.type == 'radio'">
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-unstyled">
<t t-foreach="variant_id.product_template_value_ids" t-as="value_id">
<li class="form-group js_attribute_value" style="margin: 0;">
<label class="col-form-label">
<div>
<input type="radio"
t-attf-class="js_variant_change radio_input #{variant_id.attribute_id.create_variant}"
t-att-checked="first_possible_combination[variant_id_index] == value_id"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
t-att-value="value_id.id"
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom" />
<div class="radio_input_value">
<span t-field="value_id.name"/>
<span class="badge badge-pill badge-secondary" t-if="value_id.price_extra">
<!-- see note above about price_extra -->
<t t-esc="value_id.price_extra > 0 and '+' or '-'"/>
<span t-esc="abs(value_id.price_extra)" class="variant_price_extra" style="white-space: nowrap;"
t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": (pricelist or product).currency_id
}'/>
</span>
</div>
</div>
</label>
</li>
</t>
</ul>
</t>
</li>
</t>
</ul>
<t t-if="variant_id.attribute_id.type == 'color'">
<ul t-att-data-attribute_id="variant_id.attribute_id.id" class="list-inline">
<li t-foreach="variant_id.product_template_value_ids" t-as="value_id" class="list-inline-item">
<label t-attf-style="background-color:#{value_id.html_color or value_id.name if not value_id.is_custom else ''}"
t-attf-class="css_attribute_color #{'active' if first_possible_combination[variant_id_index] == value_id else ''} #{'custom_value' if value_id.is_custom else ''}">
<input type="radio"
t-attf-class="js_variant_change #{variant_id.attribute_id.create_variant}"
t-att-checked="first_possible_combination[variant_id_index] == value_id"
t-att-name="'attribute-%s-%s' % (product.id, variant_id.attribute_id.id)"
t-att-value="value_id.id"
t-att-title="value_id.name"
t-att-data-value_id="value_id.product_attribute_value_id.id"
t-att-data-value_name="value_id.name"
t-att-data-attribute_name="value_id.attribute_id.name"
t-att-data-is_custom="value_id.is_custom"/>
</label>
</li>
</ul>
</t>
</li>
</t>
</ul>
</template>
</odoo>
+1
View File
@@ -6,6 +6,7 @@
name="Sales"
web_icon="sale,static/description/icon.png"
active="False"
groups="sales_team.group_sale_salesman,sales_team.group_sale_salesman_all_leads,sales_team.group_sale_manager"
sequence="7"/>
<menuitem id="sale_order_menu"
@@ -10,5 +10,5 @@ class SaleProductConfigurator(models.TransientModel):
product_template_id = fields.Many2one(
'product.template', string="Product",
required=True, domain=[('sale_ok', '=', True), '|', ('attribute_line_ids', '!=', False), ('optional_product_ids', '!=', False)])
required=True, domain=[('sale_ok', '=', True), '|', ('attribute_line_ids.value_ids', '!=', False), ('optional_product_ids', '!=', False)])
pricelist_id = fields.Many2one('product.pricelist', 'Pricelist', readonly=True)
@@ -3,6 +3,7 @@
<record id="group_sale_order_template" model="res.groups">
<field name="name">Quotation Templates</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
</odoo>
@@ -68,7 +68,7 @@ tour.register('sale_product_configurator_advanced_tour', {
trigger: ".o_sale_product_configurator_add",
run: 'click'
}, {
trigger: '.main_product strong:contains("White, Custom, PAV9, PAV5, PAV1")',
trigger: '.main_product strong:contains("Custom, White, PAV9, PAV5, PAV1")',
extra_trigger: '.oe_optional_products_modal',
run: function () {} //check
}, {
@@ -100,7 +100,7 @@ tour.register('sale_product_configurator_advanced_tour', {
extra_trigger: '.oe_optional_products_modal',
run: 'click'
}, {
trigger: 'td.o_data_cell:contains("Customizable Desk (White, Custom, PAV9, PAV5, PAV1)")',
trigger: 'td.o_data_cell:contains("Customizable Desk (Custom, White, PAV9, PAV5, PAV1)")',
extra_trigger: 'div[name="order_line"]',
in_modal: false,
run: function (){} //check
@@ -0,0 +1,100 @@
odoo.define('sale.product_configurator_pricelist_tour', function (require) {
"use strict";
var tour = require('web_tour.tour');
tour.register('sale_product_configurator_pricelist_tour', {
url: "/web",
test: true,
},
[
tour.STEPS.SHOW_APPS_MENU_ITEM,
{
content: "navigate to the sale app",
trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
edition: 'community'
}, {
content: "navigate to the sale app",
trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
edition: 'enterprise'
}, {
content: "create a new order",
trigger: '.o_list_button_add',
extra_trigger: ".o_sale_order"
}, {
content: "search the partner",
trigger: 'div[name="partner_id"] input',
run: 'text Azure'
}, {
content: "select the partner",
trigger: 'ul.ui-autocomplete > li > a:contains(Azure)',
}, {
content: "search the pricelist",
trigger: 'div[name="pricelist_id"] input',
run: 'text Public Pricelist'
}, {
content: "select the pricelist",
trigger: 'ul.ui-autocomplete > li > a:contains(Public Pricelist)',
}, {
content: "wait for pricelist to be set",
trigger: 'div.o_with_button[name="pricelist_id"]',
run: function () {},
}, {
content: "open product configurator",
trigger: 'a:contains("Configure a product")',
}, {
content: "open product selector",
trigger: '.o_product_configurator .o_input_dropdown input',
run: 'click'
}, {
content: "select desk",
trigger: 'li a:contains("Customizable Desk")',
in_modal: false,
extra_trigger: 'ul.ui-autocomplete',
}, {
content: "check price is correct (USD)",
trigger: 'span.oe_currency_value:contains("750.00")',
run: function () {} // check price
}, {
content: "add one more",
trigger: 'button.js_add_cart_json:has(i.fa-plus)',
}, {
content: "check price for 2",
trigger: 'span.oe_currency_value:contains("600.00")',
run: function () {} // check price (pricelist has discount for 2)
}, {
content: "click add",
trigger: '.o_sale_product_configurator_add:not(.disabled)'
}, {
content: "check we are on the add modal",
trigger: '.td-product_name:contains("Customizable Desk (Steel, White)")',
extra_trigger: '.oe_optional_products_modal',
run: 'click'
}, {
content: "add conference chair",
trigger: '.js_product:has(strong:contains(Conference Chair)) .js_add',
extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Conference Chair))',
run: 'click'
}, {
content: "add chair floor protection",
trigger: '.js_product:has(strong:contains(Chair floor protection)) .js_add',
extra_trigger: '.oe_optional_products_modal .js_product:has(strong:contains(Chair floor protection))',
run: 'click'
}, {
content: "verify configurator final price", // tax excluded
trigger: '.o_total_row .oe_currency_value:contains("1,257.00")',
}, {
content: "add to SO",
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
run: 'click'
}, {
content: "verify SO final price excluded",
trigger: 'span[name="amount_untaxed"]:contains("1,257.00")',
}, {
content: "verify SO final price included",
trigger: 'span[name="amount_total"]:contains("1,437.00")',
}
]);
});
@@ -3,6 +3,9 @@ odoo.define('sale.product_configurator_tour', function (require) {
var tour = require('web_tour.tour');
// Note: please keep this test without pricelist for maximum coverage.
// The pricelist is tested on the other tours.
tour.register('sale_product_configurator_tour', {
url: "/web",
test: true,
@@ -15,15 +18,8 @@ tour.register('sale_product_configurator_tour', {
}, {
trigger: ".o_list_button_add",
extra_trigger: ".o_sale_order"
}, {
trigger: ".o_required_modifier[name=partner_id] input",
run: "text Couscous Magique",
}, {
trigger: ".ui-menu-item > a:contains('Couscous Magique')",
auto: true,
}, {
trigger: "a:contains('Configure a product')",
extra_trigger: ".o_field_widget[name=pricelist_id] > .o_external_button", // Wait for pricelist (onchange_partner_id)
}, {
trigger: '.o_product_configurator .o_input_dropdown input',
run: 'click'
@@ -48,7 +44,7 @@ tour.register('sale_product_configurator_tour', {
trigger: '.configurator_container span:contains("Aluminium")',
run: 'click'
}, {
trigger: 'span.oe_currency_value:contains("800")',
trigger: 'span.oe_currency_value:contains("800.40")',
run: function (){} // check updated price
}, {
trigger: 'input[data-value_name="Black"]'
@@ -74,11 +70,12 @@ tour.register('sale_product_configurator_tour', {
}, {
trigger: 'button span:contains(Confirm)',
extra_trigger: '.oe_optional_products_modal',
id: "quotation_product_selected",
run: 'click'
},
// check that 3 products were added to the SO
{
trigger: 'td.o_data_cell:contains("Customizable Desk (White, Aluminium)")',
trigger: 'td.o_data_cell:contains("Customizable Desk (Aluminium, White)")',
extra_trigger: 'div[name="order_line"]',
in_modal: false,
run: function (){}
@@ -93,15 +90,10 @@ tour.register('sale_product_configurator_tour', {
in_modal: false,
run: function (){}
}, {
trigger: '.o_readonly_modifier[name=amount_total]:contains("835")',
trigger: '.o_readonly_modifier[name=amount_total]:contains("0.00")',
in_modal: false,
edition: 'community',
run: function (){}
}, {
trigger: '.o_readonly_modifier[name=amount_total]:contains("837")',
in_modal: false,
edition: 'enterprise',
run: function (){}
}]);
}
]);
});
@@ -1,6 +1,7 @@
import odoo.tests
# Part of Odoo. See LICENSE file for full copyright and licensing details.
@odoo.tests.tagged('post_install', '-at_install')
class TestUi(odoo.tests.HttpCase):
@@ -82,3 +83,43 @@ class TestUi(odoo.tests.HttpCase):
})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_advanced_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_advanced_tour.ready", login="admin")
def test_04_product_configurator_pricelist(self):
"""The goal of this test is to make sure pricelist rules are correctly
applied on the backend product configurator.
Also testing B2C setting: no impact on the backend configurator.
"""
admin = self.env.ref('base.user_admin')
# Activate B2C
self.env.ref('account.group_show_line_subtotals_tax_included').users |= admin
self.env.ref('account.group_show_line_subtotals_tax_excluded').users -= admin
# Active pricelist on SO
self.env.ref('product.group_sale_pricelist').users |= admin
# Add a 15% tax on desk
tax = self.env['account.tax'].create({'name': "Test tax", 'amount': 15})
self.env.ref('product.product_product_4_product_template').taxes_id = tax
# Remove tax from Conference Chair and Chair floor protection
self.env.ref('sale.product_product_1_product_template').taxes_id = None
self.env.ref('product.product_product_11_product_template').taxes_id = None
# Make sure pricelist rule exist
product_template = self.env.ref('product.product_product_4_product_template')
pricelist = self.env.ref('product.list0')
if not pricelist.item_ids.filtered(lambda i: i.product_tmpl_id == product_template and i.price_discount == 20):
self.env['product.pricelist.item'].create({
'base': 'list_price',
'applied_on': '1_product',
'pricelist_id': pricelist.id,
'product_tmpl_id': product_template.id,
'price_discount': 20,
'min_quantity': 2,
'compute_price': 'formula',
})
self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_product_configurator_pricelist_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_product_configurator_pricelist_tour.ready", login="admin")
+2 -1
View File
@@ -4,6 +4,7 @@
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/sale_management/static/tests/product_configurator_test_ui.js"/>
<script type="text/javascript" src="/sale_management/static/tests/product_configurator_advanced_test_ui.js"/>
<script type="text/javascript" src="/sale_management/static/tests/product_configurator_pricelist_test_ui.js"/>
</xpath>
</template>
</odoo>
</odoo>
@@ -36,7 +36,6 @@
<label for="module_sale_quotation_builder"/>
<div class="text-muted">
Design your quotation templates using building blocks<br/>
<!-- TODO SEB don't show this after the module is installed -->
<em attrs="{'invisible': [('module_sale_quotation_builder','=',False)]}">Warning: this option will install the Website app.</em>
</div>
</div>
@@ -13,6 +13,7 @@
'data/sale_order_template_data.xml',
'views/sale_portal_templates.xml',
'views/sale_order_template_views.xml',
'views/res_config_settings_views.xml',
'views/sale_order_views.xml',
],
'installable': True,
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_quotation_builder.res_config_settings_view_form_inherit" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.sale.management.inherit.sale.quotation.builder</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="sale_management.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//label[@for='module_sale_quotation_builder']/following::div/em" position="replace"/>
</field>
</record>
</odoo>
+8 -1
View File
@@ -285,7 +285,14 @@ class SaleOrderLine(models.Model):
@api.onchange('product_uom_qty')
def _onchange_product_uom_qty(self):
if self.state == 'sale' and self.product_id.type in ['product', 'consu'] and self.product_uom_qty < self._origin.product_uom_qty:
# When modifying a one2many, _origin doesn't guarantee that its values will be the ones
# in database. Hence, we need to explicitly read them from there.
if self._origin:
product_uom_qty_origin = self._origin.read(["product_uom_qty"])[0]["product_uom_qty"]
else:
product_uom_qty_origin = 0
if self.state == 'sale' and self.product_id.type in ['product', 'consu'] and self.product_uom_qty < product_uom_qty_origin:
# Do not display this warning if the new quantity is below the delivered
# one; the `write` will raise an `UserError` anyway.
if self.product_uom_qty < self.qty_delivered:
+27 -4
View File
@@ -4,17 +4,26 @@ odoo.define("sale_stock.tour", function (require) {
var tour = require('web_tour.tour');
require('sale.tour');
var quotation_product_selected_step_index = _.findIndex(tour.tours.sale_tour.steps, function (step) {
var quotation_product_selected_step_index = _.findIndex(tour.tours.sale_product_configurator_tour.steps, function (step) {
return (step.id === "quotation_product_selected");
});
tour.tours.sale_tour.steps[quotation_product_selected_step_index].run = function (actions) {
tour.tours.sale_product_configurator_tour.steps[quotation_product_selected_step_index].run = function (actions) {
actions.auto();
};
tour.tours.sale_tour.steps.splice(quotation_product_selected_step_index+1, 0, {
trigger: ".modal-footer .btn-primary",
tour.tours.sale_product_configurator_tour.steps.splice(quotation_product_selected_step_index+1, 0, {
trigger: ".o_dialog_warning + .modal-footer .btn-primary",
auto: true,
run: function (actions) {
actions.auto('.o_dialog_warning + .modal-footer .btn-primary');
}
}, {
trigger: ".o_dialog_warning + .modal-footer .btn-primary",
auto: true,
run: function (actions) {
actions.auto('.o_dialog_warning + .modal-footer .btn-primary');
}
}, {
trigger: "body:not(:has(.o_dialog_warning))",
auto: true,
@@ -25,4 +34,18 @@ odoo.define("sale_stock.tour", function (require) {
}
},
});
// Check if sale_management is installed since sale_stock is adding an extra
// step to add to SO (not enough inventory)
if ('sale.product_configurator_pricelist_tour' in odoo.__DEBUG__.services) {
var steps = tour.tours.sale_product_configurator_pricelist_tour.steps;
for (var k=0; k<steps.length; k++) {
if (steps[k].content === "add to SO") {
steps.splice(k+1, 0, {
content: "click in modal on ok button",
trigger: '.modal-footer button:contains("Ok")',
});
}
}
}
});
+5 -2
View File
@@ -194,7 +194,7 @@ class ProjectTask(models.Model):
self.partner_id = self.sale_line_id.order_partner_id
# set domain on SO: on non billable project, all SOL of customer, otherwise the one from the SO
result = result or {}
domain = [('is_service', '=', True), ('is_expense', '=', False), ('order_partner_id', '=', self.partner_id.id), ('state', 'in', ['sale', 'done'])]
domain = [('is_service', '=', True), ('is_expense', '=', False), ('order_partner_id', 'child_of', self.partner_id.commercial_partner_id.id), ('state', 'in', ['sale', 'done'])]
if self.project_id.sale_order_id:
domain += [('order_id', '=', self.project_id.sale_order_id.id)]
result.setdefault('domain', {})['sale_line_id'] = domain
@@ -203,8 +203,11 @@ class ProjectTask(models.Model):
@api.onchange('partner_id')
def _onchange_partner_id(self):
result = super(ProjectTask, self)._onchange_partner_id()
if self.sale_line_id.order_partner_id != self.partner_id:
result = result or {}
if self.sale_line_id.order_partner_id.commercial_partner_id != self.partner_id.commercial_partner_id:
self.sale_line_id = False
if self.partner_id:
result.setdefault('domain', {})['sale_line_id'] = [('is_service', '=', True), ('is_expense', '=', False), ('order_partner_id', 'child_of', self.partner_id.commercial_partner_id.id), ('state', 'in', ['sale', 'done'])]
return result
@api.multi
+3 -3
View File
@@ -184,12 +184,12 @@ class SnailmailLetter(models.Model):
else:
# adding the web logo from the company for future possible customization
document.update({
'company_logo': letter.company_id.logo_web,
'company_logo': letter.company_id.logo_web.decode('utf-8'),
})
attachment = letter._fetch_attachment()
if attachment:
document.update({
'pdf_bin': route == 'print' and attachment.datas,
'pdf_bin': route == 'print' and attachment.datas.decode('utf-8'),
'pages': route == 'estimate' and self._count_pages_pdf(base64.b64decode(attachment.datas)),
})
else:
@@ -345,7 +345,7 @@ class SnailmailLetter(models.Model):
@api.model
def _snailmail_cron(self):
letters_send = self.search([('state', 'in', ['pending', 'error'])])
letters_send = self.search([('state', '=', 'pending')])
if letters_send:
letters_send._snailmail_print()
limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
@@ -0,0 +1,7 @@
// Change address font-size if needed
document.addEventListener('DOMContentLoaded', function (evt) {
var recipientAddress = document.getElementsByClassName('address row')[0].getElementsByTagName('address')[0];
var height = parseFloat(window.getComputedStyle(recipientAddress, null).getPropertyValue('height'));
var fontSize = parseFloat(window.getComputedStyle(recipientAddress, null).getPropertyValue('font-size'));
recipientAddress.style.fontSize = (85/height) * fontSize + 'px';
});

Some files were not shown because too many files have changed in this diff Show More