[MERGE] forward port branch 12.0 up to 3e4138deaa
closes odoo/odoo#30045
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -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", {});
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 ['<=', '>=']
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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,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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;"/>
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
]}}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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
|
||||
@@ -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 >= 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 > 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) < 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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
@@ -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:
|
||||
|
||||
@@ -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")',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user