diff options
52 files changed, 790 insertions, 136 deletions
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 0c24a6e2844..d4637bad007 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -70,6 +70,12 @@ class AccountInvoice(models.Model): self.amount_total_signed = self.amount_total * sign self.amount_untaxed_signed = amount_untaxed_signed * sign + def _compute_sign_taxes(self): + for invoice in self: + sign = invoice.type in ['in_refund', 'out_refund'] and -1 or 1 + invoice.amount_untaxed_invoice_signed = invoice.amount_untaxed * sign + invoice.amount_tax_signed = invoice.amount_tax * sign + @api.onchange('amount_total') def _onchange_amount_total(self): for inv in self: @@ -324,8 +330,12 @@ class AccountInvoice(models.Model): store=True, readonly=True, compute='_compute_amount', track_visibility='always') amount_untaxed_signed = fields.Monetary(string='Untaxed Amount in Company Currency', currency_field='company_currency_id', store=True, readonly=True, compute='_compute_amount') + amount_untaxed_invoice_signed = fields.Monetary(string='Untaxed Amount in Invoice Currency', currency_field='currency_id', + readonly=True, compute='_compute_sign_taxes') amount_tax = fields.Monetary(string='Tax', store=True, readonly=True, compute='_compute_amount') + amount_tax_signed = fields.Monetary(string='Tax in Invoice Currency', currency_field='currency_id', + readonly=True, compute='_compute_sign_taxes') amount_total = fields.Monetary(string='Total', store=True, readonly=True, compute='_compute_amount') amount_total_signed = fields.Monetary(string='Total in Invoice Currency', currency_field='currency_id', @@ -1751,14 +1761,14 @@ class AccountInvoiceLine(models.Model): def _set_taxes(self): """ Used in on_change to set taxes and price""" self.ensure_one() - if self.invoice_id.type in ('out_invoice', 'out_refund'): - taxes = self.product_id.taxes_id or self.account_id.tax_ids or self.invoice_id.company_id.account_sale_tax_id - else: - taxes = self.product_id.supplier_taxes_id or self.account_id.tax_ids or self.invoice_id.company_id.account_purchase_tax_id # Keep only taxes of the company company_id = self.company_id or self.env.user.company_id - taxes = taxes.filtered(lambda r: r.company_id == company_id) + + if self.invoice_id.type in ('out_invoice', 'out_refund'): + taxes = self.product_id.taxes_id.filtered(lambda r: r.company_id == company_id) or self.account_id.tax_ids or self.invoice_id.company_id.account_sale_tax_id + else: + taxes = self.product_id.supplier_taxes_id.filtered(lambda r: r.company_id == company_id) or self.account_id.tax_ids or self.invoice_id.company_id.account_purchase_tax_id self.invoice_line_tax_ids = fp_taxes = self.invoice_id.fiscal_position_id.map_tax(taxes, self.product_id, self.invoice_id.partner_id) diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 672aa72afdd..983bd9014ea 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -985,7 +985,7 @@ class AccountMoveLine(models.Model): partner_id = self.env['res.partner']._find_accounting_partner(self[0].partner_id).id company_currency = self[0].account_id.company_id.currency_id - writeoff_currency = self[0].currency_id or company_currency + writeoff_currency = self[0].account_id.currency_id or company_currency line_to_reconcile = self.env['account.move.line'] # Iterate and create one writeoff by journal writeoff_moves = self.env['account.move'] diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index 539e9b64a8f..2248020d5e7 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -831,3 +831,17 @@ class account_payment(models.Model): }) return vals + + def _get_invoice_payment_amount(self, inv): + """ + Computes the amount covered by the current payment in the given invoice. + + :param inv: an invoice object + :returns: the amount covered by the payment in the invoice + """ + self.ensure_one() + return sum([ + data['amount'] + for data in inv._get_payments_vals() + if data['account_payment_id'] == self.id + ]) diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index 7455ef8a325..84a023b6dd9 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -275,6 +275,7 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { 'field_changed': '_onFieldChanged', }), _avoidFieldUpdate: {}, + MV_LINE_DEBOUNCE: 200, /** * create partner_id field in editable mode @@ -287,6 +288,11 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { this.model = model; this._initialState = state; + if (this.MV_LINE_DEBOUNCE) { + this._onSelectMoveLine = _.debounce(this._onSelectMoveLine, this.MV_LINE_DEBOUNCE, true); + } else { + this._onSelectMoveLine = this._onSelectMoveLine; + } }, /** * @override diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index b8200c149d9..ffd59877fe6 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -584,6 +584,7 @@ odoo.define('account.reconciliation_tests', function (require) { "use strict"; var ReconciliationClientAction = require('account.ReconciliationClientAction'); +var ReconciliationRenderer = require('account.ReconciliationRenderer'); var demoData = require('account.reconciliation_tests.data'); var testUtils = require('web.test_utils'); @@ -591,7 +592,13 @@ var testUtils = require('web.test_utils'); QUnit.module('account', { beforeEach: function () { this.params = demoData.getParams(); - } + testUtils.patch(ReconciliationRenderer.LineRenderer, { + MV_LINE_DEBOUNCE: 0, + }); + }, + afterEach: function () { + testUtils.unpatch(ReconciliationRenderer.LineRenderer); + }, }, function () { QUnit.module('Reconciliation'); diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index cb6c3ea93da..40886710ced 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -163,8 +163,8 @@ <field name="user_id"/> <field name="date_due"/> <field name="origin"/> - <field name="amount_untaxed" string="Tax Excluded" sum="Total"/> - <field name="amount_tax" sum="Total"/> + <field name="amount_untaxed_invoice_signed" string="Tax Excluded" sum="Total"/> + <field name="amount_tax_signed" string="Tax" sum="Total"/> <field name="amount_total_signed" string="Total" sum="Total"/> <field name="residual_signed" string="Amount Due" sum="Amount Due"/> <field name="currency_id" invisible="1"/> diff --git a/addons/account/views/report_payment_receipt_templates.xml b/addons/account/views/report_payment_receipt_templates.xml index 871ae39479f..c0e11a2ef7f 100644 --- a/addons/account/views/report_payment_receipt_templates.xml +++ b/addons/account/views/report_payment_receipt_templates.xml @@ -46,7 +46,7 @@ <td><span t-field="inv.date_invoice"/></td> <td><span t-field="inv.number"/></td> <td class="text-right"><span t-field="inv.amount_total"/></td> - <td class="text-right"><span t-esc="inv.amount_total - inv.residual" t-options="{'widget': 'monetary', 'display_currency': inv.currency_id}"/></td> + <td class="text-right"><span t-esc="o._get_invoice_payment_amount(inv)" t-options="{'widget': 'monetary', 'display_currency': inv.currency_id}"/></td> <td class="text-right"><span t-field="inv.residual"/></td> </tr> </tbody> diff --git a/addons/account_cancel/views/account_views.xml b/addons/account_cancel/views/account_views.xml index e3dd3821294..18f308bd9c5 100644 --- a/addons/account_cancel/views/account_views.xml +++ b/addons/account_cancel/views/account_views.xml @@ -17,7 +17,7 @@ <field name="inherit_id" ref="account.invoice_form"/> <field name="arch" type="xml"> <xpath expr="//button[@name='action_invoice_draft']" position="before"> - <button name="action_invoice_cancel" type="object" states="draft,open,paid" string="Cancel" groups="account.group_account_invoice"/> + <button name="action_invoice_cancel" type="object" states="draft,open,in_payment,paid" string="Cancel" groups="account.group_account_invoice"/> </xpath> </field> </record> @@ -28,7 +28,7 @@ <field name="inherit_id" ref="account.invoice_supplier_form"/> <field name="arch" type="xml"> <xpath expr="//button[@name='action_invoice_draft']" position="before"> - <button name="action_invoice_cancel" type="object" states="draft,open,paid" string="Cancel" groups="account.group_account_invoice"/> + <button name="action_invoice_cancel" type="object" states="draft,open,in_payment,paid" string="Cancel" groups="account.group_account_invoice"/> </xpath> </field> </record> diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index 876e48698d0..4d76e598d43 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -409,9 +409,8 @@ class AlarmManager(models.AbstractModel): all_meetings = self.get_next_potential_limit_alarm('notification', partner_id=partner.id) time_limit = 3600 * 24 # return alarms of the next 24 hours - for event_id in all_meetings: - max_delta = all_meetings[event_id]['max_duration'] - meeting = self.env['calendar.event'].browse(event_id) + for meeting in self.env['calendar.event'].search([('id', 'in', list(all_meetings))]): # cannot browse + max_delta = all_meetings[meeting.id]['max_duration'] if meeting.recurrency: b_found = False last_found = False diff --git a/addons/l10n_ch/models/mail_template.py b/addons/l10n_ch/models/mail_template.py index e3a1deca1dd..7ec29d4d508 100644 --- a/addons/l10n_ch/models/mail_template.py +++ b/addons/l10n_ch/models/mail_template.py @@ -36,6 +36,9 @@ class MailTemplate(models.Model): pdf = self.env.ref('l10n_ch.l10n_ch_isr_report').render_qweb_pdf([res_id])[0] pdf = base64.b64encode(pdf) - attachments_list = multi_mode and rslt[res_id]['attachments'] or rslt['attachments'] - attachments_list.append((report_name, pdf)) + attachments_list = multi_mode and rslt[res_id].get('attachments', False) or rslt.get('attachments', False) + if attachments_list: + attachments_list.append((report_name, pdf)) + else: + rslt[res_id]['attachments'] = [(report_name, pdf)] return rslt diff --git a/addons/l10n_in_hr_payroll/views/l10n_in_hr_payroll_view.xml b/addons/l10n_in_hr_payroll/views/l10n_in_hr_payroll_view.xml index 56ba9b00694..175b8e2fe9a 100644 --- a/addons/l10n_in_hr_payroll/views/l10n_in_hr_payroll_view.xml +++ b/addons/l10n_in_hr_payroll/views/l10n_in_hr_payroll_view.xml @@ -3,10 +3,10 @@ <record id="hr_contract_form_in_inherit" model="ir.ui.view"> <field name="name">hr.contract.form.in.inherit</field> <field name="model">hr.contract</field> - <field name="inherit_id" ref="hr_payroll.hr_contract_form_inherit"/> + <field name="inherit_id" ref="hr_contract.hr_contract_view_form"/> <field name="arch" type="xml"> <data> - <xpath expr="//field[@name='struct_id']" position="after"> + <xpath expr="//group[@name='duration_group']" position="after"> <group col="2" colspan="2" name="right_column"> <separator colspan="2" string="Allowance"/> <field name="driver_salay"/> diff --git a/addons/mrp/wizard/mrp_product_produce.py b/addons/mrp/wizard/mrp_product_produce.py index d29fc0c140d..dfda97a8ca0 100644 --- a/addons/mrp/wizard/mrp_product_produce.py +++ b/addons/mrp/wizard/mrp_product_produce.py @@ -181,7 +181,7 @@ class MrpProductProduce(models.TransientModel): 'product_id': move.product_id.id, }) - self.produce_line_ids = [(0, 0, x) for x in lines] + self.produce_line_ids = [(5,)] + [(0, 0, x) for x in lines] class MrpProductProduceLine(models.TransientModel): _name = "mrp.product.produce.line" diff --git a/addons/mrp_byproduct/models/mrp_production.py b/addons/mrp_byproduct/models/mrp_production.py index 08902f14b56..6903ff8bd7d 100644 --- a/addons/mrp_byproduct/models/mrp_production.py +++ b/addons/mrp_byproduct/models/mrp_production.py @@ -61,13 +61,14 @@ class MrpProductProduce(models.TransientModel): for by_product_move in by_product_moves: rounding = by_product_move.product_uom.rounding quantity = float_round(self.product_qty * by_product_move.unit_factor, precision_rounding=rounding) + location_dest_id = by_product_move.location_dest_id.get_putaway_strategy(by_product_move.product_id).id or by_product_move.location_dest_id.id values = { 'move_id': by_product_move.id, 'product_id': by_product_move.product_id.id, 'production_id': self.production_id.id, 'product_uom_id': by_product_move.product_uom.id, 'location_id': by_product_move.location_id.id, - 'location_dest_id': by_product_move.location_dest_id.id, + 'location_dest_id': location_dest_id, } if by_product_move.product_id.tracking == 'lot': values.update({ diff --git a/addons/payment_authorize/controllers/main.py b/addons/payment_authorize/controllers/main.py index 70d641564a6..cf6877218db 100644 --- a/addons/payment_authorize/controllers/main.py +++ b/addons/payment_authorize/controllers/main.py @@ -55,14 +55,14 @@ class AuthorizeController(http.Controller): except ValidationError as e: message = e.args[0] if isinstance(message, dict) and 'missing_fields' in message: - msg = _("The transaction cannot be processed because some contact details are missing or invalid: ") - message = msg + ', '.join(message['missing_fields']) + '. ' if request.env.user._is_public(): - message += _("Please sign in to complete your profile.") + message = _("Please sign in to complete the payment.") # update message if portal mode = b2b if request.env['ir.config_parameter'].sudo().get_param('auth_signup.allow_uninvited', 'False').lower() == 'false': - message += _("If you don't have any account, please ask your salesperson to update your profile. ") + message += _(" If you don't have any account, ask your salesperson to grant you a portal access. ") else: + msg = _("The transaction cannot be processed because some contact details are missing or invalid: ") + message = msg + ', '.join(message['missing_fields']) + '. ' message += _("Please complete your profile. ") return { diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 329b72c1789..ea6b5b89758 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -754,7 +754,7 @@ class PosOrder(models.Model): if to_invoice: pos_order.action_pos_order_invoice() - pos_order.invoice_id.sudo().action_invoice_open() + pos_order.invoice_id.sudo().with_context(force_company=self.env.user.company_id.id).action_invoice_open() pos_order.account_move = pos_order.invoice_id.move_id return order_ids diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index 7306eb629c8..47c0e6cf9ca 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -151,6 +151,61 @@ var ScreenWidget = PosBaseWidget.extend({ } } }, + /** + * Handles the error response from the server when we push + * an invoiceable order + * Displays appropriates warnings and errors and + * proposes subsequent actions + * + * @private + * @param {PosModel} order: the order to consider, defaults to current order + * @param {Boolean} refresh_screens: whether or not displayed screens should refresh + * @param {Object} error: the error provided by Ajax + */ + _handleFailedPushForInvoice: function (order, refresh_screen, error) { + var self = this; + order = order || this.pos.get_order(); + this.invoicing = false; + order.finalized = false; + if (error.message === 'Missing Customer') { + this.gui.show_popup('confirm',{ + 'title': _t('Please select the Customer'), + 'body': _t('You need to select the customer before you can invoice an order.'), + confirm: function(){ + self.gui.show_screen('clientlist', null, refresh_screen); + }, + }); + } else if (error.message === 'Backend Invoice') { + this.gui.show_popup('confirm',{ + 'title': _t('Please print the invoice from the backend'), + 'body': _t('The order has been synchronized earlier. Please make the invoice from the backend for the order: ') + error.data.order.name, + confirm: function () { + this.gui.show_screen('receipt', null, refresh_screen); + }, + cancel: function () { + this.gui.show_screen('receipt', null, refresh_screen); + }, + }); + } else if (error.code < 0) { // XmlHttpRequest Errors + this.gui.show_popup('error',{ + 'title': _t('The order could not be sent'), + 'body': _t('Check your internet connection and try again.'), + cancel: function () { + this.gui.show_screen('receipt', {button_print_invoice: true}, refresh_screen); // refresh if necessary + }, + }); + } else if (error.code === 200) { // OpenERP Server Errors + this.gui.show_popup('error-traceback',{ + 'title': error.data.message || _t("Server Error"), + 'body': error.data.debug || _t('The server encountered an error while receiving your order.'), + }); + } else { // ??? + this.gui.show_popup('error',{ + 'title': _t("Unknown Error"), + 'body': _t("The order could not be sent to the server due to an unknown error"), + }); + } + }, }); /*--------------------------------------*\ @@ -1520,7 +1575,9 @@ var ReceiptScreenWidget = ScreenWidget.extend({ return this.pos.config.iface_print_auto && !this.pos.get_order()._printed; }, should_close_immediately: function() { - return this.pos.config.iface_print_via_proxy && this.pos.config.iface_print_skip_screen; + var order = this.pos.get_order(); + var invoiced_finalized = order.is_to_invoice() ? order.finalized : true; + return this.pos.config.iface_print_via_proxy && this.pos.config.iface_print_skip_screen && invoiced_finalized; }, lock_screen: function(locked) { this._locked = locked; @@ -1624,9 +1681,32 @@ var ReceiptScreenWidget = ScreenWidget.extend({ self.print(); } }); + var button_print_invoice = this.$('.button.print_invoice'); + button_print_invoice.click(function () { + var order = self.pos.get_order(); + var invoiced = self.pos.push_and_invoice_order(order); + self.invoicing = true; + + invoiced.fail(self._handleFailedPushForInvoice.bind(self, order, true)); // refresh + + invoiced.done(function(){ + self.invoicing = false; + self.gui.show_screen('receipt', {button_print_invoice: false}, true); // refresh + }); + }); + }, render_change: function() { + var self = this; this.$('.change-value').html(this.format_currency(this.pos.get_order().get_change())); + var order = this.pos.get_order(); + var order_screen_params = order.get_screen_data('params'); + var button_print_invoice = this.$('.button.print_invoice'); + if (order_screen_params && order_screen_params.button_print_invoice) { + button_print_invoice.show(); + } else { + button_print_invoice.hide(); + } }, render_receipt: function() { this.$('.pos-receipt-container').html(QWeb.render('PosTicket', this.get_receipt_render_env())); @@ -2051,45 +2131,7 @@ var PaymentScreenWidget = ScreenWidget.extend({ var invoiced = this.pos.push_and_invoice_order(order); this.invoicing = true; - invoiced.fail(function(error){ - self.invoicing = false; - order.finalized = false; - if (error.message === 'Missing Customer') { - self.gui.show_popup('confirm',{ - 'title': _t('Please select the Customer'), - 'body': _t('You need to select the customer before you can invoice an order.'), - confirm: function(){ - self.gui.show_screen('clientlist'); - }, - }); - } else if (error.message === 'Backend Invoice') { - self.gui.show_popup('confirm',{ - 'title': _t('Please print the invoice from the backend'), - 'body': _t('The order has been synchronized earlier. Please make the invoice from the backend for the order: ') + error.data.order.name, - confirm: function () { - this.gui.show_screen('receipt'); - }, - cancel: function () { - this.gui.show_screen('receipt'); - }, - }); - } else if (error.code < 0) { // XmlHttpRequest Errors - self.gui.show_popup('error',{ - 'title': _t('The order could not be sent'), - 'body': _t('Check your internet connection and try again.'), - }); - } else if (error.code === 200) { // OpenERP Server Errors - self.gui.show_popup('error-traceback',{ - 'title': error.data.message || _t("Server Error"), - 'body': error.data.debug || _t('The server encountered an error while receiving your order.'), - }); - } else { // ??? - self.gui.show_popup('error',{ - 'title': _t("Unknown Error"), - 'body': _t("The order could not be sent to the server due to an unknown error"), - }); - } - }); + invoiced.fail(this._handleFailedPushForInvoice.bind(this, order, false)); invoiced.done(function(){ self.invoicing = false; diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml index 4827706756c..26d315631a8 100644 --- a/addons/point_of_sale/static/src/xml/pos.xml +++ b/addons/point_of_sale/static/src/xml/pos.xml @@ -719,6 +719,9 @@ </span> </div> <div class="centered-content touch-scrollable"> + <div class="button print_invoice"> + <i class='fa fa-print'></i> Print Invoice + </div> <div class="button print"> <i class='fa fa-print'></i> Print Receipt </div> diff --git a/addons/portal/controllers/mail.py b/addons/portal/controllers/mail.py index dcccbb61fa1..dc83f05a3d2 100644 --- a/addons/portal/controllers/mail.py +++ b/addons/portal/controllers/mail.py @@ -112,7 +112,7 @@ class PortalChatter(http.Controller): raise Forbidden() # Non-employee see only messages with not internal subtype (aka, no internal logs) if not request.env['res.users'].has_group('base.group_user'): - domain = expression.AND([['&', ('subtype_id', '!=', False), ('subtype_id.internal', '=', False)], domain]) + domain = expression.AND([['&', '&', ('subtype_id', '!=', False), ('subtype_id.internal', '=', False), ('website_published', '=', True)], domain]) Message = request.env['mail.message'].sudo() return { 'messages': Message.search(domain, limit=limit, offset=offset).portal_message_format(), diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 08b1aa26a8c..3c842b793ba 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -380,6 +380,9 @@ class ProductTemplate(models.Model): related_vals['volume'] = vals['volume'] if vals.get('weight'): related_vals['weight'] = vals['weight'] + # Please do forward port + if vals.get('packaging_ids'): + related_vals['packaging_ids'] = vals['packaging_ids'] if related_vals: template.write(related_vals) diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 27b6d4b21f9..0ed941374b5 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -461,7 +461,6 @@ class Task(models.Model): date_assign = fields.Datetime(string='Assigning Date', index=True, copy=False, readonly=True) date_deadline = fields.Date(string='Deadline', index=True, copy=False, track_visibility='onchange') date_last_stage_update = fields.Datetime(string='Last Stage Update', - default=fields.Datetime.now, index=True, copy=False, readonly=True) @@ -679,9 +678,10 @@ class Task(models.Model): # user_id change: update date_assign if vals.get('user_id'): vals['date_assign'] = fields.Datetime.now() - # Stage change: Update date_end if folded stage + # Stage change: Update date_end if folded stage and date_last_stage_update if vals.get('stage_id'): vals.update(self.update_date_end(vals['stage_id'])) + vals['date_last_stage_update'] = fields.Datetime.now() task = super(Task, self.with_context(context)).create(vals) return task diff --git a/addons/purchase_stock/models/account_invoice.py b/addons/purchase_stock/models/account_invoice.py index 048853d4c64..78cd9ba2707 100644 --- a/addons/purchase_stock/models/account_invoice.py +++ b/addons/purchase_stock/models/account_invoice.py @@ -48,7 +48,6 @@ class AccountInvoice(models.Model): inv.currency_id, company=inv.company_id, date=fields.Date.today(), round=False, ) - line_quantity = line['quantity'] if i_line.product_id.cost_method != 'standard' and i_line.purchase_line_id: po_currency = i_line.purchase_id.currency_id @@ -86,7 +85,6 @@ class AccountInvoice(models.Model): # in Stock Move, price unit is in company_currency valuation_price_unit = valuation_price_unit_total / valuation_total_qty valuation_price_unit = i_line.product_id.uom_id._compute_price(valuation_price_unit, i_line.uom_id) - line_quantity = valuation_total_qty elif i_line.product_id.cost_method == 'fifo': # In this condition, we have a real price-valuated product which has not yet been received @@ -95,7 +93,7 @@ class AccountInvoice(models.Model): company=po_company, date=inv.date or inv.date_invoice, round=False, ) - interim_account_price = valuation_price_unit * line_quantity + interim_account_price = valuation_price_unit * line['quantity'] invoice_cur_prec = inv.currency_id.decimal_places if float_compare(valuation_price_unit, i_line.price_unit, precision_digits=invoice_cur_prec) != 0 and float_compare(line['price_unit'], i_line.price_unit, precision_digits=invoice_cur_prec) == 0: @@ -126,7 +124,7 @@ class AccountInvoice(models.Model): 'type': 'src', 'name': i_line.name[:64], 'price_unit': price_unit_val_dif, - 'quantity': line_quantity, + 'quantity': line['quantity'], 'price': inv.currency_id.round(price_val_dif), 'account_id': acc, 'product_id': line['product_id'], diff --git a/addons/purchase_stock/tests/test_stockvaluation.py b/addons/purchase_stock/tests/test_stockvaluation.py index e15b8a2063b..708fb05aa17 100644 --- a/addons/purchase_stock/tests/test_stockvaluation.py +++ b/addons/purchase_stock/tests/test_stockvaluation.py @@ -877,3 +877,244 @@ class TestStockValuationWithCOA(AccountingTestCase): exchange_stock_line = reconciled_lines.filtered(lambda l: l.journal_id == exhange_diff_journal) self.assertEqual(exchange_stock_line.amount_currency, 0.00) self.assertAlmostEqual(exchange_stock_line.balance, 27.86) + + def test_average_realtime_with_two_delivery_anglo_saxon_valuation_multicurrency_different_dates(self): + """ + The PO and invoice are in the same foreign currency. + The deliveries occur at different times and rates + The invoice is created at an even different date + This should create a price difference entry. + """ + company = self.env.user.company_id + company.anglo_saxon_accounting = True + + date_po = '2019-01-01' + date_delivery = '2019-01-08' + date_delivery1 = '2019-01-10' + date_invoice = '2019-01-16' + date_invoice1 = '2019-01-20' + + product_avg = self.product1.product_tmpl_id.copy({ + 'valuation': 'real_time', + 'purchase_method': 'purchase', + 'cost_method': 'average', + 'name': 'AVG', + 'standard_price': 60, + 'property_account_creditor_price_difference': self.price_diff_account.id + }).product_variant_id + product_avg.invoice_policy = 'order' + + # SetUp currency and rates + self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", (self.usd_currency.id, company.id)) + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.0, + 'currency_id': self.usd_currency.id, + 'company_id': company.id, + }) + self.env['res.currency.rate'].create({ + 'name': date_po, + 'rate': 1.5, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_delivery, + 'rate': 0.7, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + self.env['res.currency.rate'].create({ + 'name': date_delivery1, + 'rate': 0.8, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + self.env['res.currency.rate'].create({ + 'name': date_invoice, + 'rate': 2, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + self.env['res.currency.rate'].create({ + 'name': date_invoice1, + 'rate': 2.2, + 'currency_id': self.eur_currency.id, + 'company_id': company.id, + }) + + # To allow testing validation of PO + def _today(*args, **kwargs): + return date_po + # To allow testing validation of Delivery + delivery_now = date_delivery + def _now(*args, **kwargs): + return delivery_now + ' 01:00:00' + + patchers = [ + patch('odoo.fields.Date.context_today', _today), + patch('odoo.fields.Datetime.now', _now), + ] + + for p in patchers: + p.start() + + # Proceed + po = self.env['purchase.order'].create({ + 'currency_id': self.eur_currency.id, + 'partner_id': self.partner_id.id, + 'order_line': [ + (0, 0, { + 'name': product_avg.name, + 'product_id': product_avg.id, + 'product_qty': 10.0, + 'product_uom': product_avg.uom_po_id.id, + 'price_unit': 30.0, + 'date_planned': date_po, + }) + ], + }) + po.button_confirm() + + line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg) + + picking = po.picking_ids + (picking.move_lines + .filtered(lambda l: l.purchase_line_id == line_product_avg) + .write({'quantity_done': 5.0})) + + picking.button_validate() + picking.action_done() # Create Backorder + + inv = self.env['account.invoice'].create({ + 'type': 'in_invoice', + 'date_invoice': date_invoice, + 'currency_id': self.eur_currency.id, + 'purchase_id': po.id, + 'partner_id': self.partner_id.id, + 'invoice_line_ids': [ + (0, 0, { + 'name': product_avg.name, + 'price_subtotal': 100.0, + 'price_unit': 20.0, + 'product_id': product_avg.id, + 'purchase_id': po.id, + 'purchase_line_id': line_product_avg.id, + 'quantity': 5.0, + 'account_id': self.stock_input_account.id, + }) + ] + }) + + inv.action_invoice_open() + + backorder_picking = self.env['stock.picking'].search([('backorder_id', '=', picking.id)]) + delivery_now = date_delivery1 + (backorder_picking.move_lines + .filtered(lambda l: l.purchase_line_id == line_product_avg) + .write({'quantity_done': 5.0})) + backorder_picking.button_validate() + + inv1 = self.env['account.invoice'].create({ + 'type': 'in_invoice', + 'date_invoice': date_invoice1, + 'currency_id': self.eur_currency.id, + 'purchase_id': po.id, + 'partner_id': self.partner_id.id, + 'invoice_line_ids': [ + (0, 0, { + 'name': product_avg.name, + 'price_subtotal': 200.0, + 'price_unit': 40.0, + 'product_id': product_avg.id, + 'purchase_id': po.id, + 'purchase_line_id': line_product_avg.id, + 'quantity': 5.0, + 'account_id': self.stock_input_account.id, + }) + ] + }) + + inv1.action_invoice_open() + + for p in patchers: + p.stop() + + ########################## + # Invoice 0 # + ########################## + move_lines = inv.move_id.line_ids + self.assertEqual(len(move_lines), 3) + + # PAYABLE CHECK + payable_line = move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEqual(payable_line.amount_currency, -100.0) + self.assertAlmostEqual(payable_line.balance, -50.00) + + # # PRODUCTS CHECKS + + # DELIVERY DIFFERENCE (AVERAGE) + stock_line = move_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertEqual(stock_line.journal_id, inv.journal_id) + self.assertEqual(stock_line.amount_currency, 150.00) + self.assertAlmostEqual(stock_line.balance, 75.00) + + price_diff_line = move_lines.filtered(lambda l: l.account_id == self.price_diff_account) + self.assertEqual(price_diff_line.amount_currency, -50.00) + self.assertAlmostEqual(price_diff_line.balance, -25.00) + + full_reconcile = stock_line.full_reconcile_id + self.assertTrue(full_reconcile.exists()) + + reconciled_lines = full_reconcile.reconciled_line_ids - stock_line + self.assertEqual(len(reconciled_lines), 2) + + stock_journal_line = reconciled_lines.filtered(lambda l: l.journal_id == self.stock_journal) + self.assertEqual(stock_journal_line.amount_currency, -150) + self.assertAlmostEqual(stock_journal_line.balance, -214.29) + + exhange_diff_journal = company.currency_exchange_journal_id.exists() + exchange_stock_line = reconciled_lines.filtered(lambda l: l.journal_id == exhange_diff_journal) + self.assertEqual(exchange_stock_line.amount_currency, 0.00) + self.assertAlmostEqual(exchange_stock_line.balance, 139.29) + + ########################## + # Invoice 1 # + ########################## + move_lines = inv1.move_id.line_ids + self.assertEqual(len(move_lines), 3) + + # PAYABLE CHECK + payable_line = move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEqual(payable_line.amount_currency, -200.0) + self.assertAlmostEqual(payable_line.balance, -90.91) + + # # PRODUCTS CHECKS + + # DELIVERY DIFFERENCE (AVERAGE) + stock_line = move_lines.filtered(lambda l: l.account_id == self.stock_input_account) + self.assertEqual(stock_line.journal_id, inv.journal_id) + self.assertEqual(stock_line.amount_currency, 150.00) + self.assertAlmostEqual(stock_line.balance, 68.18) + + price_diff_line = move_lines.filtered(lambda l: l.account_id == self.price_diff_account) + self.assertEqual(price_diff_line.amount_currency, 50.00) + self.assertAlmostEqual(price_diff_line.balance, 22.73) + + full_reconcile = stock_line.full_reconcile_id + self.assertTrue(full_reconcile.exists()) + + reconciled_lines = full_reconcile.reconciled_line_ids - stock_line + self.assertEqual(len(reconciled_lines), 2) + + stock_journal_line = reconciled_lines.filtered(lambda l: l.journal_id == self.stock_journal) + self.assertEqual(stock_journal_line.amount_currency, -150) + self.assertAlmostEqual(stock_journal_line.balance, -187.5) + + exhange_diff_journal = company.currency_exchange_journal_id.exists() + exchange_stock_line = reconciled_lines.filtered(lambda l: l.journal_id == exhange_diff_journal) + self.assertEqual(exchange_stock_line.amount_currency, 0.00) + self.assertAlmostEqual(exchange_stock_line.balance, 119.32) diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 44bfa04dcc8..d2d3b8b1e8d 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -1099,7 +1099,7 @@ class SaleOrderLine(models.Model): result = super(SaleOrderLine, self).write(values) return result - order_id = fields.Many2one('sale.order', string='Order Reference', required=True, ondelete='cascade', index=True, copy=False, readonly=True) + order_id = fields.Many2one('sale.order', string='Order Reference', required=True, ondelete='cascade', index=True, copy=False) name = fields.Text(string='Description', required=True) sequence = fields.Integer(string='Sequence', default=10) diff --git a/addons/sale/views/sale_portal_templates.xml b/addons/sale/views/sale_portal_templates.xml index d6afd43f68a..192a22fc918 100644 --- a/addons/sale/views/sale_portal_templates.xml +++ b/addons/sale/views/sale_portal_templates.xml @@ -127,7 +127,7 @@ <t t-set="classes" t-value="'col-lg-auto d-print-none'"/> <t t-set="title"> - <h2 class="mb-0"><b t-field="sale_order.amount_total"/> </h2> + <h2 class="mb-0"><b t-field="sale_order.amount_total" data-id="total_amount"/> </h2> </t> <t t-set="entries"> <ul class="list-group list-group-flush flex-wrap flex-row flex-lg-column"> @@ -167,14 +167,15 @@ <b t-field="sale_order.amount_undiscounted" t-options='{"widget": "monetary", "display_currency": sale_order.pricelist_id.currency_id}' style="text-decoration: line-through" - class="d-block mt-1"/> + class="d-block mt-1" + data-id="amount_undiscounted" /> </small> <t t-if="sale_order.amount_untaxed == sale_order.amount_total"> - <h4 t-field="sale_order.amount_total" class="text-success"/> + <h4 t-field="sale_order.amount_total" class="text-success" data-id="total_amount"/> </t> <t t-else=""> - <h4 t-field="sale_order.amount_untaxed" class="text-success mb-0"/> - <small>(<span t-field="sale_order.amount_total"/> Incl. tax)</small> + <h4 t-field="sale_order.amount_untaxed" class="text-success mb-0" data-id="total_untaxed"/> + <small>(<span t-field="sale_order.amount_total" data-id="total_amount"/> Incl. tax)</small> </t> </li> </t> @@ -374,7 +375,7 @@ </t> </div> <div class="mb-3"> - <strong>Date:</strong> <span t-field="sale_order.create_date" t-options='{"widget": "date"}'/> + <strong>Date:</strong> <span t-field="sale_order.date_order" t-options='{"widget": "date"}'/> </div> <div class="mb-3" t-if="sale_order.validity_date"> <strong>Expiration Date:</strong> <span t-field="sale_order.validity_date" t-options='{"widget": "date"}'/> @@ -514,6 +515,7 @@ <div id="total" class="row" name="total" style="page-break-inside: avoid;"> <div t-attf-class="#{'col-4' if report_type != 'html' else 'col-sm-7 col-md-5'} ml-auto"> + <!-- Should be replaced in master by t-call="sale.sale_order_portal_content_totals_table" --> <table class="table table-sm"> <tr class="border-black" style="border-bottom:1px solid #dddddd;"> <td><strong>Subtotal</strong></td> @@ -577,4 +579,47 @@ </div> </template> + <template id="sale_order_portal_content_totals_table"> + <table class="table table-sm"> + <tr class="border-black"> + <td><strong>Subtotal</strong></td> + <td class="text-right"> + <span + data-id="total_untaxed" + t-field="sale_order.amount_untaxed" + t-options='{"widget": "monetary","display_currency": sale_order.pricelist_id.currency_id}' + /> + </td> + </tr> + <t t-foreach="sale_order.amount_by_group" t-as="amount_by_group"> + <tr> + <t t-if="amount_by_group[3] == 1 and sale_order.amount_untaxed == amount_by_group[2]"> + <td> + <span t-esc="amount_by_group[0]"/> + <span>&nbsp;<span>on</span>&nbsp;<t t-esc="amount_by_group[2]" t-options='{"widget": "monetary", "display_currency": sale_order.pricelist_id.currency_id}'/></span> + </td> + <td class="text-right"> + <span t-esc="amount_by_group[1]" + t-options='{"widget": "monetary", "display_currency": sale_order.pricelist_id.currency_id}'/> + </td> + </t> + <t t-else =""> + <td> + <span t-esc="amount_by_group[0]"/> + </td> + <td class="text-right"> + <span t-esc="amount_by_group[1]" + t-options='{"widget": "monetary", "display_currency": sale_order.pricelist_id.currency_id}'/> + </td> + </t> + </tr> + </t> + <tr class="border-black"> + <td><strong>Total</strong></td> + <td class="text-right"> + <span data-id="total_amount" t-field="sale_order.amount_total" t-options='{"widget": "monetary", "display_currency": sale_order.pricelist_id.currency_id}'/> + </td> + </tr> + </table> + </template> </odoo> diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index d0f81c38a8a..309bf4b6dc4 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -910,7 +910,7 @@ </div> <group> <group> - <field name="order_id"/> + <field name="order_id" readonly="1"/> <field name="product_id" readonly="1"/> <field name="name" readonly="1"/> <field name="product_uom_qty" readonly="1"/> diff --git a/addons/sale_management/controllers/portal.py b/addons/sale_management/controllers/portal.py index 1796beb7223..eb4e945b8d3 100644 --- a/addons/sale_management/controllers/portal.py +++ b/addons/sale_management/controllers/portal.py @@ -47,14 +47,21 @@ class CustomerPortal(CustomerPortal): currency = order_sudo.currency_id format_price = partial(formatLang, request.env, digits=currency.decimal_places) - return { + results = { 'order_line_product_uom_qty': str(quantity), 'order_line_price_total': format_price(order_line.price_total), 'order_line_price_subtotal': format_price(order_line.price_subtotal), 'order_amount_total': format_price(order_sudo.amount_total), 'order_amount_untaxed': format_price(order_sudo.amount_untaxed), 'order_amount_tax': format_price(order_sudo.amount_tax), + 'order_amount_undiscounted': format_price(order_sudo.amount_undiscounted), } + try: + results['order_totals_table'] = request.env['ir.ui.view'].render_template('sale.sale_order_portal_content_totals_table', {'sale_order': order_sudo}) + except ValueError: + pass + + return results @http.route(["/my/orders/<int:order_id>/add_option/<int:option_id>"], type='http', auth="public", website=True) def add(self, order_id, option_id, access_token=None, **post): diff --git a/addons/sale_management/static/src/js/sale_management.js b/addons/sale_management/static/src/js/sale_management.js index 88b956e0e6e..98484d40f03 100644 --- a/addons/sale_management/static/src/js/sale_management.js +++ b/addons/sale_management/static/src/js/sale_management.js @@ -39,7 +39,7 @@ if (!$('.o_portal_sale_sidebar').length) { var order_id = href.match(/my\/orders\/([0-9]+)/); var line_id = href.match(/update_line\/([0-9]+)/); var params = { - 'line_id': line_id[1], + 'line_id': parseInt(line_id[1]), 'remove': this.$el.is('[href*="remove"]'), 'unlink': this.$el.is('[href*="unlink"]'), 'input_quantity': quantity >= 0 ? quantity : false, @@ -49,6 +49,7 @@ if (!$('.o_portal_sale_sidebar').length) { params.access_token = token; } + order_id = parseInt(order_id[1]) this._callUpdateLineRoute(order_id, params).then(this._updateOrderValues.bind(this)); return false; }, @@ -66,19 +67,20 @@ if (!$('.o_portal_sale_sidebar').length) { * when the quantity of a product has changed * * @private + * @param {integer} order_id * @param {Object} params * @return {Deferred} */ _callUpdateLineRoute: function (order_id, params) { var def = new $.Deferred(); - var url = "/my/orders/" + parseInt(order_id[1]) + "/update_line_dict"; + var url = "/my/orders/" + order_id + "/update_line_dict"; ajax.jsonRpc(url, 'call', params) .then(def.resolve.bind(def)) .fail(function () { // Compatibility: the server may not have been restarted // So the real route may not exist delete params.input_quantity; - ajax.jsonRpc("/quote/update_line", 'call', params) + ajax.jsonRpc("/my/orders/" + order_id + "/update_line", 'call', params) .fail(def.reject.bind(def)) .then(function (data) { // Data is an array, convert it to a dict @@ -108,6 +110,8 @@ if (!$('.o_portal_sale_sidebar').length) { var orderAmountTotal = data.order_amount_total; var orderAmountUntaxed = data.order_amount_untaxed; var orderAmountTax = data.order_amount_tax; + var orderAmountUndiscounted = data.order_amount_undiscounted; + var orderTotalsTable = $(data.order_totals_table); var lineProductUomQty = data.order_line_product_uom_qty; var linePriceTotal = data.order_line_price_total; @@ -129,6 +133,13 @@ if (!$('.o_portal_sale_sidebar').length) { if (orderAmountTotal !== undefined) { this.elems.$orderAmountTotal.text(orderAmountTotal); } + + if (orderAmountUndiscounted !== undefined) { + this.elems.$orderAmountUndiscounted.text(orderAmountUndiscounted); + } + if (orderTotalsTable) { + this.elems.$orderTotalsTable.find('table').replaceWith(orderTotalsTable); + } }, /** * Locate in the DOM the elements to update @@ -147,12 +158,13 @@ if (!$('.o_portal_sale_sidebar').length) { $linePriceTotal = $linePriceSubTotal = $parentTr.find('.oe_currency_value').last(); } - var $orderAmountUntaxed = $('[data-id="total_untaxed"]>span'); - var $orderAmountTotal = $('[data-id="total_amount"]>span'); + var $orderAmountUntaxed = $('[data-id="total_untaxed"]').find('span, b'); + var $orderAmountTotal = $('[data-id="total_amount"]').find('span, b'); + var $orderAmountUndiscounted = $('[data-id="amount_undiscounted"]').find('span, b'); if (!$orderAmountUntaxed.length) { $orderAmountUntaxed = $orderAmountTotal.eq(1); - $orderAmountTotal = $orderAmountTotal.eq(0).add($orderAmountTotal.eq(3)); + $orderAmountTotal = $orderAmountTotal.eq(0).add($orderAmountTotal.eq(2)); } return { @@ -161,6 +173,8 @@ if (!$('.o_portal_sale_sidebar').length) { $linePriceTotal: $linePriceTotal, $orderAmountUntaxed: $orderAmountUntaxed, $orderAmountTotal: $orderAmountTotal, + $orderTotalsTable: $('#total'), + $orderAmountUndiscounted: $orderAmountUndiscounted, } } }); diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index bfdc17f7d27..81895718a96 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -57,11 +57,15 @@ class AccountAnalyticLine(models.Model): @api.multi def write(self, values): # prevent to update invoiced timesheets if one line is of type delivery + self._check_can_write(values) + result = super(AccountAnalyticLine, self).write(values) + return result + + @api.multi + def _check_can_write(self, values): if self.sudo().filtered(lambda aal: aal.so_line.product_id.invoice_policy == "delivery") and self.filtered(lambda timesheet: timesheet.timesheet_invoice_id): if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'project_id', 'task_id', 'so_line', 'amount', 'date']]): raise UserError(_('You can not modify already invoiced timesheets (linked to a Sales order items invoiced on Time and material).')) - result = super(AccountAnalyticLine, self).write(values) - return result @api.model def _timesheet_preprocess(self, values): diff --git a/addons/sale_timesheet/report/project_profitability_report_analysis.py b/addons/sale_timesheet/report/project_profitability_report_analysis.py index 2ec12acea09..2dc397495aa 100644 --- a/addons/sale_timesheet/report/project_profitability_report_analysis.py +++ b/addons/sale_timesheet/report/project_profitability_report_analysis.py @@ -68,7 +68,7 @@ class ProfitabilityAnalysis(models.Model): ELSE 0.0 END AS amount_untaxed_to_invoice, CASE - WHEN SOL.qty_delivered_method IN ('timesheet', 'manual') THEN (SOL.untaxed_amount_invoiced / CASE COALESCE(S.currency_rate, 0) WHEN 0 THEN 1.0 ELSE S.currency_rate END) + WHEN SOL.qty_delivered_method IN ('timesheet', 'manual') THEN (COALESCE(SOL.untaxed_amount_invoiced, COST_SUMMARY.downpayment_invoiced) / CASE COALESCE(S.currency_rate, 0) WHEN 0 THEN 1.0 ELSE S.currency_rate END) ELSE 0.0 END AS amount_untaxed_invoiced, COST_SUMMARY.timesheet_unit_amount AS timesheet_unit_amount, @@ -83,7 +83,8 @@ class ProfitabilityAnalysis(models.Model): sale_line_id, SUM(timesheet_unit_amount) AS timesheet_unit_amount, SUM(timesheet_cost) AS timesheet_cost, - SUM(expense_cost) AS expense_cost + SUM(expense_cost) AS expense_cost, + SUM(downpayment_invoiced) AS downpayment_invoiced FROM ( SELECT P.id AS project_id, @@ -91,7 +92,8 @@ class ProfitabilityAnalysis(models.Model): TS.so_line AS sale_line_id, SUM(TS.unit_amount) AS timesheet_unit_amount, SUM(TS.amount) AS timesheet_cost, - 0.0 AS expense_cost + 0.0 AS expense_cost, + 0.0 AS downpayment_invoiced FROM account_analytic_line TS, project_project P WHERE TS.project_id IS NOT NULL AND P.id = TS.project_id AND P.active = 't' AND P.allow_timesheets = 't' GROUP BY P.id, TS.so_line @@ -104,7 +106,8 @@ class ProfitabilityAnalysis(models.Model): AAL.so_line AS sale_line_id, 0.0 AS timesheet_unit_amount, 0.0 AS timesheet_cost, - SUM(AAL.amount) AS expense_cost + SUM(AAL.amount) AS expense_cost, + 0.0 AS downpayment_invoiced FROM project_project P LEFT JOIN account_analytic_account AA ON P.analytic_account_id = AA.id LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id @@ -116,10 +119,47 @@ class ProfitabilityAnalysis(models.Model): SELECT P.id AS project_id, P.analytic_account_id AS analytic_account_id, + MY_SOLS.id AS sale_line_id, + 0.0 AS timesheet_unit_amount, + 0.0 AS timesheet_cost, + 0.0 AS expense_cost, + CASE WHEN MY_SOLS.invoice_status = 'invoiced' THEN MY_SOLS.price_reduce ELSE 0.0 END AS downpayment_invoiced + FROM project_project P + LEFT JOIN sale_order_line MY_SOL ON P.sale_line_id = MY_SOL.id + LEFT JOIN sale_order MY_S ON MY_SOL.order_id = MY_S.id + LEFT JOIN sale_order_line MY_SOLS ON MY_SOLS.order_id = MY_S.id + WHERE MY_SOLS.is_downpayment = 't' + GROUP BY P.id, MY_SOLS.id + + UNION + + SELECT + P.id AS project_id, + P.analytic_account_id AS analytic_account_id, + OLIS.id AS sale_line_id, + 0.0 AS timesheet_unit_amount, + 0.0 AS timesheet_cost, + OLIS.price_reduce AS expense_cost, + 0.0 AS downpayment_invoiced + FROM project_project P + LEFT JOIN account_analytic_account ANAC ON P.analytic_account_id = ANAC.id + LEFT JOIN account_analytic_line ANLI ON ANAC.id = ANLI.account_id + LEFT JOIN sale_order_line OLI ON P.sale_line_id = OLI.id + LEFT JOIN sale_order ORD ON OLI.order_id = ORD.id + LEFT JOIN sale_order_line OLIS ON ORD.id = OLIS.order_id + WHERE OLIS.product_id = ANLI.product_id AND OLIS.is_downpayment = 't' AND ANLI.amount < 0.0 AND ANLI.project_id IS NULL AND P.active = 't' AND P.allow_timesheets = 't' + GROUP BY P.id, OLIS.id + + UNION + + SELECT + P.id AS project_id, + P.analytic_account_id AS analytic_account_id, SOL.id AS sale_line_id, 0.0 AS timesheet_unit_amount, 0.0 AS timesheet_cost, - 0.0 AS expense_cost + 0.0 AS expense_cost, + 0.0 AS downpayment_invoiced FROM sale_order_line SOL INNER JOIN project_project P ON SOL.project_id = P.id WHERE P.active = 't' AND P.allow_timesheets = 't' @@ -132,7 +172,8 @@ class ProfitabilityAnalysis(models.Model): SOL.id AS sale_line_id, 0.0 AS timesheet_unit_amount, 0.0 AS timesheet_cost, - 0.0 AS expense_cost + 0.0 AS expense_cost, + 0.0 AS downpayment_invoiced FROM sale_order_line SOL INNER JOIN project_task T ON SOL.task_id = T.id INNER JOIN project_project P ON P.id = T.project_id diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 69f5aaf5e70..907c58dc4cf 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -1115,3 +1115,12 @@ class Picking(models.Model): action['domain'] = [('id', 'in', packages.ids)] action['context'] = {'picking_id': self.id} return action + + def action_picking_move_tree(self): + action = self.env.ref('stock.stock_move_action').read()[0] + action['views'] = [ + (self.env.ref('stock.view_picking_move_tree').id, 'tree'), + ] + action['context'] = self.env.context + action['domain'] = [('picking_id', 'in', self.ids)] + return action diff --git a/addons/stock/views/stock_move_views.xml b/addons/stock/views/stock_move_views.xml index adb5c50923b..44b5802f497 100644 --- a/addons/stock/views/stock_move_views.xml +++ b/addons/stock/views/stock_move_views.xml @@ -66,6 +66,38 @@ </field> </record> + <record id="view_picking_move_tree" model="ir.ui.view"> + <field name="name">stock.picking.move.tree</field> + <field name="model">stock.move</field> + <field eval="50" name="priority"/> + <field name="arch" type="xml"> + <tree decoration-danger="state != 'done' and quantity_done > reserved_availability and show_reserved_availability" decoration-muted="scrapped == True or state == 'cancel' or (state == 'done' and is_locked == True)" string="Stock Moves" editable="bottom"> + <field name="name" invisible="1"/> + <field name="date_expected" invisible="1"/> + <field name="state" invisible="1" readonly="0"/> + <field name="picking_type_id" invisible="1"/> + <field name="location_id" invisible="1"/> + <field name="location_dest_id" invisible="1"/> + <field name="scrapped" invisible="1"/> + <field name="picking_code" invisible="1"/> + <field name="product_type" invisible="1"/> + <field name="show_details_visible" invisible="1"/> + <field name="show_reserved_availability" invisible="1"/> + <field name="show_operations" invisible="1" readonly="1"/> + <field name="additional" invisible="1"/> + <field name="has_move_lines" invisible="1"/> + <field name="is_locked" invisible="1"/> + <field name="product_id" required="1" attrs="{'readonly': ['|', '&', ('state', '!=', 'draft'), ('additional', '=', False), ('has_move_lines', '=', True)]}"/> + <field name="is_initial_demand_editable" invisible="1"/> + <field name="is_quantity_done_editable" invisible="1"/> + <field name="product_uom_qty" string="Initial Demand" attrs="{'readonly': [('is_initial_demand_editable', '=', False)]}"/> + <field name="reserved_availability" string="Reserved"/> + <field name="quantity_done" string="Done" attrs="{'readonly': [('is_quantity_done_editable', '=', False)]}"/> + <field name="product_uom" attrs="{'readonly': [('state', '!=', 'draft'), ('additional', '=', False)]}" options="{'no_open': True, 'no_create': True}" string="Unit of Measure" groups="uom.group_uom"/> + </tree> + </field> + </record> + <record id="view_move_kandan" model="ir.ui.view"> <field name="name">stock.move.kanban</field> <field name="model">stock.move</field> diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index c87a786e5e3..e33636cd294 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -241,6 +241,19 @@ class="oe_stat_button" icon="fa-cubes" attrs="{'invisible': [('has_packages', '=', False)]}"/> <button name="%(action_stock_report)d" icon="fa-arrow-up" class="oe_stat_button" string="Traceability Report" type="action" attrs="{'invisible': ['|', ('state', '!=', 'done'), ('has_tracking', '=', False)]}" groups="stock.group_production_lot"/> + <!-- Use the following button to avoid onchange on one2many --> + <button name="action_picking_move_tree" + class="oe_stat_button" + icon="fa-arrows-v" + type="object" + help="List view of operations" + groups="base.group_no_one" + attrs="{'invisible': ['|', '&', ('show_operations', '=', True), '|', ('is_locked', '=', True), ('state', '=', 'done'), '&', ('state', '=', 'done'), ('is_locked', '=', True)]}" + context="{'picking_type_code': picking_type_code, 'default_picking_id': id, 'form_view_ref':'stock.view_move_picking_form', 'address_in_id': partner_id, 'default_picking_type_id': picking_type_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}"> + <div class="o_form_field o_stat_info"> + <span class="o_stat_text">Operations</span> + </div> + </button> </div> <h1 class="d-none d-md-block"> <field name="name" attrs="{'invisible': [('name','=','/')]}" readonly="1"/> diff --git a/addons/web/static/src/js/tools/debug_manager.js b/addons/web/static/src/js/tools/debug_manager.js index f09d8745f04..7c5fd390871 100644 --- a/addons/web/static/src/js/tools/debug_manager.js +++ b/addons/web/static/src/js/tools/debug_manager.js @@ -433,6 +433,7 @@ DebugManager.include({ var fields = state.fields; var fieldsInfo = state.fieldsInfo.form; var fieldNamesInView = state.getFieldNames(); + var fieldNamesOnlyOnView = ['message_attachment_count']; var fieldsValues = state.data; var modifierDatas = {}; _.each(fieldNamesInView, function (fieldName) { @@ -441,6 +442,7 @@ DebugManager.include({ }); }); this.fields = _.chain(fieldNamesInView) + .difference(fieldNamesOnlyOnView) .map(function (fieldName) { var modifierData = modifierDatas[fieldName]; var invisibleOrReadOnly; diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js index 2b771ba9772..782f31a1bd2 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -60,10 +60,11 @@ return AbstractModel.extend({ end.add(-1, 'days'); } + var isDateEvent = this.fields[this.mapping.date_start].type === 'date'; // An "allDay" event without the "all_day" option is not considered // as a 24h day. It's just a part of the day (by default: 7h-19h). if (event.allDay) { - if (!this.mapping.all_day) { + if (!this.mapping.all_day && !isDateEvent) { if (event.r_start) { start.hours(event.r_start.hours()) .minutes(event.r_start.minutes()) diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index ced6670844e..01c97f4d723 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -2333,6 +2333,67 @@ QUnit.module('Views', { calendar.destroy(); }); + QUnit.test('timzeone does not affect calendar with date field', function (assert) { + assert.expect(8); + + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar date_start="start_date" mode="month">'+ + '<field name="name"/>'+ + '<field name="start_date"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "create") { + assert.strictEqual(args.args[0].start_date, "2016-12-20 00:00:00"); + } + if (args.method === "write") { + assert.step(args.args[1].start_date); + } + return this._super(route, args); + }, + session: { + getTZOffset: function () { + return 120; // 2 hours timezone + }, + }, + }); + + // Create event (on 20 december) + var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)'); + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell, "mouseup"); + var $input = $('.modal-body input:first'); + $input.val("An event").trigger('input'); + $('.modal button.btn:contains(Create)').trigger('click'); + + assert.strictEqual(calendar.$('.o_field_start_date').text().trim(), "12/20/2016") + + // Move event to another day (on 27 november) + testUtils.dragAndDrop( + calendar.$('.fc-event').first(), + calendar.$('.fc-day-top').first() + ); + assert.verifySteps(["2016-11-27 00:00:00"]); + assert.strictEqual(calendar.$('.o_field_start_date').text().trim(), "11/27/2016") + + // Move event to last day (on 7 january) + testUtils.dragAndDrop( + calendar.$('.fc-event').first(), + calendar.$('.fc-day-top').last() + ); + assert.verifySteps(["2016-11-27 00:00:00", "2017-01-07 00:00:00"]); + assert.strictEqual(calendar.$('.o_field_start_date').text().trim(), "01/07/2017") + + calendar.destroy(); + }); + QUnit.test('form_view_id attribute works (for creating events)', function (assert) { assert.expect(1); diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index 651e71f63a5..672c28febff 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -811,7 +811,7 @@ var SnippetsMenu = Widget.extend({ && (float_next === 'left' || float_next === 'right')) { zone.remove(); } else if (disp_prev !== null && disp_next !== null - && disp_prev !== 'block' && disp_next !== 'block') { + && disp_prev.indexOf('inline') >= 0 && disp_next.indexOf('inline') >= 0) { zone.remove(); } }); diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js index b3e5f2898f3..948aae75c60 100644 --- a/addons/website/static/src/js/content/snippets.animation.js +++ b/addons/website/static/src/js/content/snippets.animation.js @@ -1034,7 +1034,7 @@ registry.facebookPage = Animation.extend({ if (!params.href) { return def; } - params.width = utils.confine(this.$el.width(), 180, 500); + params.width = utils.confine(Math.floor(this.$el.width()), 180, 500); var src = $.param.querystring('https://www.facebook.com/plugins/page.php', params); this.$iframe = $('<iframe/>', { diff --git a/addons/website_blog/static/src/scss/primary_variables.scss b/addons/website_blog/static/src/scss/primary_variables.scss index f5cda7678dc..c8b936b59f1 100644 --- a/addons/website_blog/static/src/scss/primary_variables.scss +++ b/addons/website_blog/static/src/scss/primary_variables.scss @@ -1,5 +1,3 @@ -$s-latest-posts-big-picture-figure-bg: null; - @mixin s-latest-posts-js-get-posts-hook {}; @mixin s-latest-posts-figure-hook {}; diff --git a/addons/website_blog/static/src/scss/website_blog.scss b/addons/website_blog/static/src/scss/website_blog.scss index f16afd40bf2..f54dc52b832 100644 --- a/addons/website_blog/static/src/scss/website_blog.scss +++ b/addons/website_blog/static/src/scss/website_blog.scss @@ -262,13 +262,10 @@ } } -$s-latest-posts-big-picture-figure-bg: $white !default; - .s_latest_posts_big_picture { .s_latest_posts_post { position: relative; height: 250px; - background: $s-latest-posts-big-picture-figure-bg; text-align: center; cursor: pointer; transition: all 300ms; @@ -281,9 +278,11 @@ $s-latest-posts-big-picture-figure-bg: $white !default; position: relative; display: block; width: 100%; + max-width: 100% !important; height: 100%; padding: 0; opacity: 0.8; + overflow: hidden; > .o_blog_cover_component { @include o-position-absolute(0, 0, 0, 0); @@ -293,15 +292,17 @@ $s-latest-posts-big-picture-figure-bg: $white !default; background-position: center; } } - > div, > div > a { - @include o-position-absolute(0, 0, 0, 0); - padding: 2em; - color: $white; - text-transform: uppercase; - font-size: 1.25em; - backface-visibility: hidden; - &::after, &::before { - pointer-events: none; + > div:not(.o_blog_cover_container):not(.js-loading) { + &, & > a { + @include o-position-absolute(0, $grid-gutter-width/2, 0, $grid-gutter-width/2); + padding: 2em; + color: $white; + text-transform: uppercase; + font-size: 1.25em; + backface-visibility: hidden; + &::after, &::before { + pointer-events: none; + } } > a { @@ -328,7 +329,9 @@ $s-latest-posts-big-picture-figure-bg: $white !default; @include s-latest-posts-figure-hook; } - @include s-latest-posts-js-get-posts-hook; + .js_get_posts { + @include s-latest-posts-js-get-posts-hook; + } } .s_latest_posts, .s_latest_posts_big_picture { diff --git a/addons/website_blog/views/website_blog_templates.xml b/addons/website_blog/views/website_blog_templates.xml index 817387db989..6623de92ebc 100644 --- a/addons/website_blog/views/website_blog_templates.xml +++ b/addons/website_blog/views/website_blog_templates.xml @@ -589,10 +589,10 @@ </template> <template id="s_latest_posts" name="Latest Posts - List"> - <section class="s_latest_posts"> + <section class="s_latest_posts pt16 pb16"> <div class="container"> <h2 class="text-center">Latest Posts</h2> - <div class="row js_get_posts" + <div class="row s_col_no_bgcolor js_get_posts" data-loading="true" data-template="website_blog.s_latest_posts_list_template"/> </div> @@ -600,10 +600,10 @@ </template> <template id="s_latest_posts_big_picture" name="Latest Posts - Big Images"> - <section class="s_latest_posts_big_picture"> + <section class="s_latest_posts_big_picture pt16 pb16"> <div class="container-fluid"> <h2 class="text-center">Latest Posts</h2> - <div class="row js_get_posts" + <div class="row s_col_no_bgcolor js_get_posts" data-loading="true" data-template="website_blog.s_latest_posts_big_picture_template"/> </div> diff --git a/addons/website_forum/controllers/main.py b/addons/website_forum/controllers/main.py index cf3352276c0..8accbcd16eb 100644 --- a/addons/website_forum/controllers/main.py +++ b/addons/website_forum/controllers/main.py @@ -21,12 +21,8 @@ class WebsiteForum(http.Controller): _user_per_page = 30 def _get_notifications(self): - badge_subtype = request.env.ref('gamification.mt_badge_granted') - if badge_subtype: - msg = request.env['mail.message'].search([('subtype_id', '=', badge_subtype.id), ('needaction', '=', True)]) - else: - msg = list() - return msg + """Deprecated""" + return list() def _prepare_forum_values(self, forum=None, **kwargs): values = { diff --git a/addons/website_hr_recruitment/models/hr_recruitment.py b/addons/website_hr_recruitment/models/hr_recruitment.py index 8606b1826c1..8239fc1e355 100644 --- a/addons/website_hr_recruitment/models/hr_recruitment.py +++ b/addons/website_hr_recruitment/models/hr_recruitment.py @@ -45,7 +45,7 @@ class Job(models.Model): default_description = self.env["ir.model.data"].xmlid_to_object("website_hr_recruitment.default_website_description") return (default_description.render() if default_description else "") - website_description = fields.Html('Website description', translate=html_translate, sanitize_attributes=False, default=_get_default_website_description) + website_description = fields.Html('Website description', translate=html_translate, sanitize_attributes=False, default=_get_default_website_description, prefetch=False) @api.multi def _compute_website_url(self): diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index ae79e8a141f..3411fb3e3b9 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -969,6 +969,9 @@ class WebsiteSale(ProductConfiguratorController): if not order or (order.amount_total and not tx): return request.redirect('/shop') + if order and not order.amount_total and not tx: + return request.redirect(order.get_portal_url()) + # clean context and session, then redirect to the confirmation page request.website.sale_reset() if tx and tx.state == 'draft': diff --git a/addons/website_sale/static/src/scss/website_sale.scss b/addons/website_sale/static/src/scss/website_sale.scss index dde7488cd96..4b5db309760 100644 --- a/addons/website_sale/static/src/scss/website_sale.scss +++ b/addons/website_sale/static/src/scss/website_sale.scss @@ -358,6 +358,10 @@ &.oe_list { height: 200px; + .oe_subdescription { + max-height: 40px; + } + section { background: rgba(100, 100, 100, 0.2); } diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 190c930357d..9bf57dfc385 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -1365,7 +1365,7 @@ <div class="js_payment mt-3" t-if="not website_sale_order.amount_total" id="payment_method"> <form target="_self" action="/shop/payment/validate" method="post" class="float-right"> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()" /> - <a role="button" class="btn btn-primary a-submit"> + <a role="button" class="btn btn-primary a-submit" href="#"> <span t-if="order.amount_total > 0">Pay Now <span class="fa fa-chevron-right"></span></span> <span t-if="order.amount_total == 0">Confirm Order <span class="fa fa-chevron-right"></span></span> </a> diff --git a/odoo/addons/base/models/ir_qweb_fields.py b/odoo/addons/base/models/ir_qweb_fields.py index 09d83b1ea57..06d21c69aaf 100644 --- a/odoo/addons/base/models/ir_qweb_fields.py +++ b/odoo/addons/base/models/ir_qweb_fields.py @@ -479,6 +479,10 @@ class FloatTimeConverter(models.AbstractModel): def value_to_html(self, value, options): sign = math.copysign(1.0, value) hours, minutes = divmod(abs(value) * 60, 60) + minutes = round(minutes) + if minutes == 60: + minutes = 0 + hours += 1 return '%02d:%02d' % (sign * hours, minutes) diff --git a/odoo/addons/test_inherits/ir.model.access.csv b/odoo/addons/test_inherits/ir.model.access.csv index 22ecf366e31..09aeec489d8 100644 --- a/odoo/addons/test_inherits/ir.model.access.csv +++ b/odoo/addons/test_inherits/ir.model.access.csv @@ -2,3 +2,5 @@ access_test_unit,access_test_unit,model_test_unit,,1,1,1,1 access_test_box,access_test_box,model_test_box,,1,1,1,1 access_test_pallet,access_test_pallet,model_test_pallet,,1,1,1,1 +access_test_another_unit,access_test_another_unit,model_test_another_unit,,1,1,1,1 +access_test_another_box,access_test_another_box,model_test_another_box,,1,1,1,1 diff --git a/odoo/addons/test_inherits/models.py b/odoo/addons/test_inherits/models.py index e31ebcdfff0..efa965c2653 100644 --- a/odoo/addons/test_inherits/models.py +++ b/odoo/addons/test_inherits/models.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from odoo import models, fields, api +from odoo.exceptions import ValidationError # We just create a new model @@ -38,3 +39,28 @@ class Pallet(models.Model): box_id = fields.Many2one('test.box', 'Box', required=True, ondelete='cascade') field_in_pallet = fields.Char('Field2') + + +# Another model for another test suite +class AnotherUnit(models.Model): + _name = 'test.another_unit' + _description = 'Another Test Unit' + + val1 = fields.Integer('Value 1', required=True) + + +# We want to _inherits from the parent model, add a field and check +# the new field is always equals to the first one +class AnotherBox(models.Model): + _name = 'test.another_box' + _inherits = {'test.another_unit': 'another_unit_id'} + _description = 'Another Test Box' + + another_unit_id = fields.Many2one('test.another_unit', 'Another Unit', + required=True, ondelete='cascade') + val2 = fields.Integer('Value 2', required=True) + + @api.constrains('val1', 'val2') + def _check(self): + if self.val1 != self.val2: + raise ValidationError("The two values must be equals") diff --git a/odoo/addons/test_inherits/tests/test_inherits.py b/odoo/addons/test_inherits/tests/test_inherits.py index 85396de5441..3328f23e2ed 100644 --- a/odoo/addons/test_inherits/tests/test_inherits.py +++ b/odoo/addons/test_inherits/tests/test_inherits.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from odoo.tests import common +from odoo.exceptions import ValidationError class test_inherits(common.TransactionCase): @@ -37,3 +38,37 @@ class test_inherits(common.TransactionCase): field = IrModelData.search([('name', '=', 'field_test_box__name')]) self.assertEqual(len(field), 1) self.assertEqual(field.module, 'test_inherits') + + def test_constraint_inherits(self): + """Validate constraints on inherits when the parent is not updated""" + Model = self.env['test.another_box'] + + with self.assertRaises(ValidationError): + another_box = Model.create({'val1': 1, 'val2': 2}) + another_box = Model.create({'val1': 1, 'val2': 1}) + + with self.assertRaises(ValidationError): + another_box.write({'val2': 2}) + another_box.write({'val1': 2, 'val2': 2}) + + def test_constraint_inherits_parent_change(self): + """Validate constraints on inherits when parent is updated too""" + UnitModel = self.env['test.another_unit'] + BoxModel = self.env['test.another_box'] + + unit1 = UnitModel.create({'val1': 1}) + box = BoxModel.create({'another_unit_id': unit1.id, 'val2': 1}) + + unit2 = UnitModel.create({'val1': 2}) + box.write({'another_unit_id': unit2.id, 'val2': 2}) + + unit3 = UnitModel.create({'val1': 3}) + box.write({'another_unit_id': unit3.id, 'val1': 4, 'val2': 4}) + + unit4 = UnitModel.create({'val1': 5}) + with self.assertRaises(ValidationError): + box.write({'another_unit_id': unit4.id, 'val2': 6}) + + unit5 = UnitModel.create({'val1': 7}) + with self.assertRaises(ValidationError): + box.write({'another_unit_id': unit5.id, 'val1': 8, 'val2': 7}) diff --git a/odoo/addons/test_new_api/tests/test_one2many.py b/odoo/addons/test_new_api/tests/test_one2many.py index 62de71c07fc..5c810c3835f 100644 --- a/odoo/addons/test_new_api/tests/test_one2many.py +++ b/odoo/addons/test_new_api/tests/test_one2many.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from openerp.tests.common import TransactionCase +from odoo.tests.common import TransactionCase class One2manyCase(TransactionCase): @@ -51,6 +51,8 @@ class One2manyCase(TransactionCase): self.multi.lines = self.multi.lines[:-1] self.assertEqual(len(self.multi.lines), 9) self.assertIn("hello", self.multi.lines.mapped('name')) + if not self.multi.id: + return # Invalidate the cache and check again; this crashes if the value # of self.multi.lines in cache contains new records self.multi.invalidate_cache() @@ -89,11 +91,26 @@ class One2manyCase(TransactionCase): self.multi.lines = [(0, 0, {"name": str(name)})] self.operations() + def test_rpcstyle_one_by_one_on_new(self): + self.multi = self.env["test_new_api.multi"].new({ + "name": "What is up?" + }) + for name in range(10): + self.multi.lines = [(0, 0, {"name": str(name)})] + self.operations() + def test_rpcstyle_single(self): """Check lines created with RPC style and added in one step""" self.multi.lines = [(0, 0, {'name': str(name)}) for name in range(10)] self.operations() + def test_rpcstyle_single_on_new(self): + self.multi = self.env["test_new_api.multi"].new({ + "name": "What is up?" + }) + self.multi.lines = [(0, 0, {'name': str(name)}) for name in range(10)] + self.operations() + def test_many2one_integer(self): """Test several models one2many with same inverse Integer field""" # utility function to convert records to tuples with id,name diff --git a/odoo/fields.py b/odoo/fields.py index 50b6f4a0552..f168f315328 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -2233,8 +2233,8 @@ class _RelationalMulti(_Relational): elif isinstance(value, (list, tuple)): # value is a list/tuple of commands, dicts or record ids comodel = record.env[self.comodel_name] - # determine the value ids; by convention empty on new records - ids = OrderedSet(record[self.name].ids if record.id else ()) + # determine the value ids + ids = OrderedSet(record[self.name]._ids) # modify ids with the commands for command in value: if isinstance(command, (tuple, list)): diff --git a/odoo/models.py b/odoo/models.py index 677d2b33911..6b18a0c2157 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -2393,6 +2393,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): cr = self._cr update_custom_fields = self._context.get('update_custom_fields', False) must_create_table = not tools.table_exists(cr, self._table) + parent_path_compute = False if self._auto: if must_create_table: @@ -2401,6 +2402,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if self._parent_store: if not tools.column_exists(cr, self._table, 'parent_path'): self._create_parent_columns() + parent_path_compute = True self._check_removed_columns(log=False) @@ -2429,6 +2431,9 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if must_create_table: self._execute_sql() + if parent_path_compute: + self._parent_store_compute() + @api.model_cr def init(self): """ This method is called after :meth:`~._auto_init`, and may be @@ -3288,14 +3293,12 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): self._name, ', '.join(sorted(unknown_names))) with self.env.protecting(protected_fields, self): - # write stored fields with (low-level) method _write - if store_vals or inverse_vals or inherited_vals: - # if log_access is enabled, this updates 'write_date' and - # 'write_uid' and check access rules, even when old_vals is - # empty - self._write(store_vals) + # update references to parents + ref_store_vals = {k: store_vals[k] for k in self._inherits.values() if k in store_vals} + if ref_store_vals: + self._write(ref_store_vals) - # update parent records (after possibly updating parent fields) + # update parent records cr = self.env.cr for model_name, parent_vals in inherited_vals.items(): parent_name = self._inherits[model_name] @@ -3308,6 +3311,13 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): self.env[model_name].browse(parent_ids).write(parent_vals) + # write stored fields with (low-level) method _write + if store_vals or inverse_vals or inherited_vals: + # if log_access is enabled, this updates 'write_date' and + # 'write_uid' and check access rules, even when old_vals is + # empty + self._write(store_vals) + if inverse_vals: self.check_field_access_rights('write', list(inverse_vals)) |
