From 46ac48866e47d754242250185b591fffb0ff8959 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Wed, 19 May 2021 13:22:39 +0000 Subject: [PATCH 01/12] [MOV] sale: move/split account files (1) --- addons/sale/models/{account_move.py => account_move_line.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename addons/sale/models/{account_move.py => account_move_line.py} (100%) diff --git a/addons/sale/models/account_move.py b/addons/sale/models/account_move_line.py similarity index 100% rename from addons/sale/models/account_move.py rename to addons/sale/models/account_move_line.py From 15f822fa5aa40c3332908552ab7e0b3ceeb1d5d9 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Wed, 19 May 2021 13:23:09 +0000 Subject: [PATCH 02/12] [MOV] sale: move/split account files (2) --- addons/sale/models/{account_invoice.py => account_move.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename addons/sale/models/{account_invoice.py => account_move.py} (100%) diff --git a/addons/sale/models/account_invoice.py b/addons/sale/models/account_move.py similarity index 100% rename from addons/sale/models/account_invoice.py rename to addons/sale/models/account_move.py From a8076bd513b807b4dc3c91c38c2c444122bb2fcd Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Wed, 19 May 2021 13:19:28 +0000 Subject: [PATCH 03/12] [MOV] sale: move/split account files (3) --- addons/sale/models/account_move.py | 21 ++++++++++++++++++++- addons/sale/models/account_move_line.py | 22 ---------------------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/addons/sale/models/account_move.py b/addons/sale/models/account_move.py index 9ffe13e56d9..e5e9e84379d 100644 --- a/addons/sale/models/account_move.py +++ b/addons/sale/models/account_move.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ - +from odoo.exceptions import UserError class AccountMove(models.Model): _name = 'account.move' @@ -74,6 +74,25 @@ class AccountMove(models.Model): }) return super()._reverse_moves(default_values_list=default_values_list, cancel=cancel) + def action_post(self): + #inherit of the function from account.move to validate a new tax and the priceunit of a downpayment + res = super(AccountMove, self).action_post() + line_ids = self.mapped('line_ids').filtered(lambda line: line.sale_line_ids.is_downpayment) + for line in line_ids: + try: + line.sale_line_ids.tax_id = line.tax_ids + if all(line.tax_ids.mapped('price_include')): + line.sale_line_ids.price_unit = line.price_unit + else: + #To keep positive amount on the sale order and to have the right price for the invoice + #We need the - before our untaxed_amount_to_invoice + line.sale_line_ids.price_unit = -line.sale_line_ids.untaxed_amount_to_invoice + except UserError: + # a UserError here means the SO was locked, which prevents changing the taxes + # just ignore the error - this is a nice to have feature and should not be blocking + pass + return res + def _post(self, soft=True): # OVERRIDE # Auto-reconcile the invoice with payments coming from transactions. diff --git a/addons/sale/models/account_move_line.py b/addons/sale/models/account_move_line.py index caae347ee13..a0565c23326 100644 --- a/addons/sale/models/account_move_line.py +++ b/addons/sale/models/account_move_line.py @@ -6,28 +6,6 @@ from odoo.exceptions import UserError from odoo.tools import float_compare, float_is_zero -class AccountMove(models.Model): - _inherit = 'account.move' - - def action_post(self): - #inherit of the function from account.move to validate a new tax and the priceunit of a downpayment - res = super(AccountMove, self).action_post() - line_ids = self.mapped('line_ids').filtered(lambda line: line.sale_line_ids.is_downpayment) - for line in line_ids: - try: - line.sale_line_ids.tax_id = line.tax_ids - if all(line.tax_ids.mapped('price_include')): - line.sale_line_ids.price_unit = line.price_unit - else: - #To keep positive amount on the sale order and to have the right price for the invoice - #We need the - before our untaxed_amount_to_invoice - line.sale_line_ids.price_unit = -line.sale_line_ids.untaxed_amount_to_invoice - except UserError: - # a UserError here means the SO was locked, which prevents changing the taxes - # just ignore the error - this is a nice to have feature and should not be blocking - pass - return res - class AccountMoveLine(models.Model): _inherit = 'account.move.line' From 03eb6f40fcbe36f7133d0951da8e745fbbb27657 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Tue, 18 May 2021 15:52:19 +0000 Subject: [PATCH 04/12] [MOV] sale: split payment.py (1) --- .../{payment.py => payment_acquirer.py} | 0 addons/sale/models/payment_transaction.py | 170 ++++++++++++++++++ 2 files changed, 170 insertions(+) rename addons/sale/models/{payment.py => payment_acquirer.py} (100%) create mode 100644 addons/sale/models/payment_transaction.py diff --git a/addons/sale/models/payment.py b/addons/sale/models/payment_acquirer.py similarity index 100% rename from addons/sale/models/payment.py rename to addons/sale/models/payment_acquirer.py diff --git a/addons/sale/models/payment_transaction.py b/addons/sale/models/payment_transaction.py new file mode 100644 index 00000000000..4537f1fc6f8 --- /dev/null +++ b/addons/sale/models/payment_transaction.py @@ -0,0 +1,170 @@ +# -*- 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, _, SUPERUSER_ID +from odoo.tools import float_compare + + +_logger = logging.getLogger(__name__) + + +class PaymentAcquirer(models.Model): + _inherit = 'payment.acquirer' + + so_reference_type = fields.Selection(string='Communication', + selection=[ + ('so_name', 'Based on Document Reference'), + ('partner', 'Based on Customer ID')], default='so_name', + help='You can set here the communication type that will appear on sales orders.' + 'The communication will be given to the customer when they choose the payment method.') + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + sale_order_ids = fields.Many2many('sale.order', 'sale_order_transaction_rel', 'transaction_id', 'sale_order_id', + string='Sales Orders', copy=False, readonly=True) + sale_order_ids_nbr = fields.Integer(compute='_compute_sale_order_ids_nbr', string='# of Sales Orders') + + def _compute_sale_order_reference(self, order): + self.ensure_one() + if self.acquirer_id.so_reference_type == 'so_name': + return order.name + else: + # self.acquirer_id.so_reference_type == 'partner' + identification_number = order.partner_id.id + return '%s/%s' % ('CUST', str(identification_number % 97).rjust(2, '0')) + + @api.depends('sale_order_ids') + def _compute_sale_order_ids_nbr(self): + for trans in self: + trans.sale_order_ids_nbr = len(trans.sale_order_ids) + + def _set_pending(self, state_message=None): + """ Override of payment to send the quotations automatically. """ + super(PaymentTransaction, self)._set_pending(state_message=state_message) + + for record in self: + sales_orders = record.sale_order_ids.filtered(lambda so: so.state in ['draft', 'sent']) + sales_orders.filtered(lambda so: so.state == 'draft').with_context(tracking_disable=True).write({'state': 'sent'}) + + if record.acquirer_id.provider == 'transfer': + for so in record.sale_order_ids: + so.reference = record._compute_sale_order_reference(so) + # send order confirmation mail + sales_orders._send_order_confirmation_mail() + + def _check_amount_and_confirm_order(self): + self.ensure_one() + for order in self.sale_order_ids.filtered(lambda so: so.state in ('draft', 'sent')): + if order.currency_id.compare_amounts(self.amount, order.amount_total) == 0: + order.with_context(send_email=True).action_confirm() + else: + _logger.warning( + '<%s> transaction AMOUNT MISMATCH for order %s (ID %s): expected %r, got %r', + self.acquirer_id.provider,order.name, order.id, + order.amount_total, self.amount, + ) + order.message_post( + subject=_("Amount Mismatch (%s)", self.acquirer_id.provider), + body=_("The order was not confirmed despite response from the acquirer (%s): order total is %r but acquirer replied with %r.") % ( + self.acquirer_id.provider, + order.amount_total, + self.amount, + ) + ) + + def _set_authorized(self, state_message=None): + """ Override of payment to confirm the quotations automatically. """ + super()._set_authorized(state_message=state_message) + sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state in ('draft', 'sent')) + for tx in self: + tx._check_amount_and_confirm_order() + + # send order confirmation mail + sales_orders._send_order_confirmation_mail() + + def _log_message_on_linked_documents(self, message): + """ Override of payment to log a message on the sales orders linked to the transaction. + + Note: self.ensure_one() + + :param str message: The message to be logged + :return: None + """ + super()._log_message_on_linked_documents(message) + for order in self.sale_order_ids: + order.message_post(body=message) + + def _reconcile_after_done(self): + """ Override of payment to automatically confirm quotations and generate invoices. """ + sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state in ('draft', 'sent')) + for tx in self: + tx._check_amount_and_confirm_order() + # send order confirmation mail + sales_orders._send_order_confirmation_mail() + # invoice the sale orders if needed + self._invoice_sale_orders() + res = super()._reconcile_after_done() + if self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'): + default_template = self.env['ir.config_parameter'].sudo().get_param('sale.default_email_template') + if default_template: + for trans in self.filtered(lambda t: t.sale_order_ids): + trans = trans.with_company(trans.acquirer_id.company_id).with_context( + mark_invoice_as_sent=True, + company_id=trans.acquirer_id.company_id.id, + ) + for invoice in trans.invoice_ids.with_user(SUPERUSER_ID): + invoice.message_post_with_template(int(default_template), email_layout_xmlid="mail.mail_notification_paynow") + return res + + def _invoice_sale_orders(self): + if self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'): + for trans in self.filtered(lambda t: t.sale_order_ids): + trans = trans.with_company(trans.acquirer_id.company_id)\ + .with_context(company_id=trans.acquirer_id.company_id.id) + trans.sale_order_ids._force_lines_to_invoice_policy_order() + invoices = trans.sale_order_ids._create_invoices() + trans.invoice_ids = [(6, 0, invoices.ids)] + + @api.model + def _compute_reference_prefix(self, provider, separator, **values): + """ Override of payment to compute the reference prefix based on Sales-specific values. + + If the `values` parameter has an entry with 'sale_order_ids' as key and a list of (4, id, O) + or (6, 0, ids) X2M command as value, the prefix is computed based on the sales order name(s) + Otherwise, the computation is delegated to the super method. + + :param str provider: The provider of the acquirer handling the transaction + :param str separator: The custom separator used to separate data references + :param dict values: The transaction values used to compute the reference prefix. It should + have the structure {'sale_order_ids': [(X2M command), ...], ...}. + :return: The computed reference prefix if order ids are found, the one of `super` otherwise + :rtype: str + """ + command_list = values.get('sale_order_ids') + if command_list: + # Extract sales order id(s) from the X2M commands + order_ids = self._fields['sale_order_ids'].convert_to_cache(command_list, self) + orders = self.env['sale.order'].browse(order_ids).exists() + if len(orders) == len(order_ids): # All ids are valid + return separator.join(orders.mapped('name')) + return super()._compute_reference_prefix(provider, separator, **values) + + def action_view_sales_orders(self): + action = { + 'name': _('Sales Order(s)'), + 'type': 'ir.actions.act_window', + 'res_model': 'sale.order', + 'target': 'current', + } + sale_order_ids = self.sale_order_ids.ids + if len(sale_order_ids) == 1: + action['res_id'] = sale_order_ids[0] + action['view_mode'] = 'form' + else: + action['view_mode'] = 'tree,form' + action['domain'] = [('id', 'in', sale_order_ids)] + return action From 8c285ea151979da5dd3af602d1eab585d41fa5f2 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Tue, 18 May 2021 15:54:46 +0000 Subject: [PATCH 05/12] [MOV] sale: split payment.py (2) --- addons/sale/models/payment_acquirer.py | 149 ---------------------- addons/sale/models/payment_transaction.py | 11 -- 2 files changed, 160 deletions(-) diff --git a/addons/sale/models/payment_acquirer.py b/addons/sale/models/payment_acquirer.py index 4537f1fc6f8..2c987b62a40 100644 --- a/addons/sale/models/payment_acquirer.py +++ b/addons/sale/models/payment_acquirer.py @@ -19,152 +19,3 @@ class PaymentAcquirer(models.Model): ('partner', 'Based on Customer ID')], default='so_name', help='You can set here the communication type that will appear on sales orders.' 'The communication will be given to the customer when they choose the payment method.') - - -class PaymentTransaction(models.Model): - _inherit = 'payment.transaction' - - sale_order_ids = fields.Many2many('sale.order', 'sale_order_transaction_rel', 'transaction_id', 'sale_order_id', - string='Sales Orders', copy=False, readonly=True) - sale_order_ids_nbr = fields.Integer(compute='_compute_sale_order_ids_nbr', string='# of Sales Orders') - - def _compute_sale_order_reference(self, order): - self.ensure_one() - if self.acquirer_id.so_reference_type == 'so_name': - return order.name - else: - # self.acquirer_id.so_reference_type == 'partner' - identification_number = order.partner_id.id - return '%s/%s' % ('CUST', str(identification_number % 97).rjust(2, '0')) - - @api.depends('sale_order_ids') - def _compute_sale_order_ids_nbr(self): - for trans in self: - trans.sale_order_ids_nbr = len(trans.sale_order_ids) - - def _set_pending(self, state_message=None): - """ Override of payment to send the quotations automatically. """ - super(PaymentTransaction, self)._set_pending(state_message=state_message) - - for record in self: - sales_orders = record.sale_order_ids.filtered(lambda so: so.state in ['draft', 'sent']) - sales_orders.filtered(lambda so: so.state == 'draft').with_context(tracking_disable=True).write({'state': 'sent'}) - - if record.acquirer_id.provider == 'transfer': - for so in record.sale_order_ids: - so.reference = record._compute_sale_order_reference(so) - # send order confirmation mail - sales_orders._send_order_confirmation_mail() - - def _check_amount_and_confirm_order(self): - self.ensure_one() - for order in self.sale_order_ids.filtered(lambda so: so.state in ('draft', 'sent')): - if order.currency_id.compare_amounts(self.amount, order.amount_total) == 0: - order.with_context(send_email=True).action_confirm() - else: - _logger.warning( - '<%s> transaction AMOUNT MISMATCH for order %s (ID %s): expected %r, got %r', - self.acquirer_id.provider,order.name, order.id, - order.amount_total, self.amount, - ) - order.message_post( - subject=_("Amount Mismatch (%s)", self.acquirer_id.provider), - body=_("The order was not confirmed despite response from the acquirer (%s): order total is %r but acquirer replied with %r.") % ( - self.acquirer_id.provider, - order.amount_total, - self.amount, - ) - ) - - def _set_authorized(self, state_message=None): - """ Override of payment to confirm the quotations automatically. """ - super()._set_authorized(state_message=state_message) - sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state in ('draft', 'sent')) - for tx in self: - tx._check_amount_and_confirm_order() - - # send order confirmation mail - sales_orders._send_order_confirmation_mail() - - def _log_message_on_linked_documents(self, message): - """ Override of payment to log a message on the sales orders linked to the transaction. - - Note: self.ensure_one() - - :param str message: The message to be logged - :return: None - """ - super()._log_message_on_linked_documents(message) - for order in self.sale_order_ids: - order.message_post(body=message) - - def _reconcile_after_done(self): - """ Override of payment to automatically confirm quotations and generate invoices. """ - sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state in ('draft', 'sent')) - for tx in self: - tx._check_amount_and_confirm_order() - # send order confirmation mail - sales_orders._send_order_confirmation_mail() - # invoice the sale orders if needed - self._invoice_sale_orders() - res = super()._reconcile_after_done() - if self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'): - default_template = self.env['ir.config_parameter'].sudo().get_param('sale.default_email_template') - if default_template: - for trans in self.filtered(lambda t: t.sale_order_ids): - trans = trans.with_company(trans.acquirer_id.company_id).with_context( - mark_invoice_as_sent=True, - company_id=trans.acquirer_id.company_id.id, - ) - for invoice in trans.invoice_ids.with_user(SUPERUSER_ID): - invoice.message_post_with_template(int(default_template), email_layout_xmlid="mail.mail_notification_paynow") - return res - - def _invoice_sale_orders(self): - if self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'): - for trans in self.filtered(lambda t: t.sale_order_ids): - trans = trans.with_company(trans.acquirer_id.company_id)\ - .with_context(company_id=trans.acquirer_id.company_id.id) - trans.sale_order_ids._force_lines_to_invoice_policy_order() - invoices = trans.sale_order_ids._create_invoices() - trans.invoice_ids = [(6, 0, invoices.ids)] - - @api.model - def _compute_reference_prefix(self, provider, separator, **values): - """ Override of payment to compute the reference prefix based on Sales-specific values. - - If the `values` parameter has an entry with 'sale_order_ids' as key and a list of (4, id, O) - or (6, 0, ids) X2M command as value, the prefix is computed based on the sales order name(s) - Otherwise, the computation is delegated to the super method. - - :param str provider: The provider of the acquirer handling the transaction - :param str separator: The custom separator used to separate data references - :param dict values: The transaction values used to compute the reference prefix. It should - have the structure {'sale_order_ids': [(X2M command), ...], ...}. - :return: The computed reference prefix if order ids are found, the one of `super` otherwise - :rtype: str - """ - command_list = values.get('sale_order_ids') - if command_list: - # Extract sales order id(s) from the X2M commands - order_ids = self._fields['sale_order_ids'].convert_to_cache(command_list, self) - orders = self.env['sale.order'].browse(order_ids).exists() - if len(orders) == len(order_ids): # All ids are valid - return separator.join(orders.mapped('name')) - return super()._compute_reference_prefix(provider, separator, **values) - - def action_view_sales_orders(self): - action = { - 'name': _('Sales Order(s)'), - 'type': 'ir.actions.act_window', - 'res_model': 'sale.order', - 'target': 'current', - } - sale_order_ids = self.sale_order_ids.ids - if len(sale_order_ids) == 1: - action['res_id'] = sale_order_ids[0] - action['view_mode'] = 'form' - else: - action['view_mode'] = 'tree,form' - action['domain'] = [('id', 'in', sale_order_ids)] - return action diff --git a/addons/sale/models/payment_transaction.py b/addons/sale/models/payment_transaction.py index 4537f1fc6f8..b960d32ee63 100644 --- a/addons/sale/models/payment_transaction.py +++ b/addons/sale/models/payment_transaction.py @@ -10,17 +10,6 @@ from odoo.tools import float_compare _logger = logging.getLogger(__name__) -class PaymentAcquirer(models.Model): - _inherit = 'payment.acquirer' - - so_reference_type = fields.Selection(string='Communication', - selection=[ - ('so_name', 'Based on Document Reference'), - ('partner', 'Based on Customer ID')], default='so_name', - help='You can set here the communication type that will appear on sales orders.' - 'The communication will be given to the customer when they choose the payment method.') - - class PaymentTransaction(models.Model): _inherit = 'payment.transaction' From 77ee991ac80b595c80fed6b2d4581a290e04f89c Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Tue, 18 May 2021 14:52:39 +0000 Subject: [PATCH 06/12] [MOV] sale: rename utm.py --- addons/sale/models/{utm.py => utm_campaign.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename addons/sale/models/{utm.py => utm_campaign.py} (100%) diff --git a/addons/sale/models/utm.py b/addons/sale/models/utm_campaign.py similarity index 100% rename from addons/sale/models/utm.py rename to addons/sale/models/utm_campaign.py From 94cf6625bd5339ce3d19a67ca6a813a3c14b14ff Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Tue, 18 May 2021 15:45:32 +0000 Subject: [PATCH 07/12] [MOV] sale: split sale.py (1) --- addons/sale/models/{sale.py => sale_order.py} | 0 addons/sale/models/sale_order_line.py | 1878 +++++++++++++++++ 2 files changed, 1878 insertions(+) rename addons/sale/models/{sale.py => sale_order.py} (100%) create mode 100644 addons/sale/models/sale_order_line.py diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale_order.py similarity index 100% rename from addons/sale/models/sale.py rename to addons/sale/models/sale_order.py diff --git a/addons/sale/models/sale_order_line.py b/addons/sale/models/sale_order_line.py new file mode 100644 index 00000000000..0aa6cf8e803 --- /dev/null +++ b/addons/sale/models/sale_order_line.py @@ -0,0 +1,1878 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta +from functools import partial +from itertools import groupby + +from odoo import api, fields, models, SUPERUSER_ID, _ +from odoo.exceptions import AccessError, UserError, ValidationError +from odoo.tools.misc import formatLang, get_lang +from odoo.osv import expression +from odoo.tools import float_is_zero, float_compare, float_round + + +from werkzeug.urls import url_encode + + +class SaleOrder(models.Model): + _name = "sale.order" + _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin', 'utm.mixin'] + _description = "Sales Order" + _order = 'date_order desc, id desc' + _check_company_auto = True + + def _default_validity_date(self): + if self.env['ir.config_parameter'].sudo().get_param('sale.use_quotation_validity_days'): + days = self.env.company.quotation_validity_days + if days > 0: + return fields.Date.to_string(datetime.now() + timedelta(days)) + return False + + def _get_default_require_signature(self): + return self.env.company.portal_confirmation_sign + + def _get_default_require_payment(self): + return self.env.company.portal_confirmation_pay + + @api.depends('order_line.price_total') + def _amount_all(self): + """ + Compute the total amounts of the SO. + """ + for order in self: + amount_untaxed = amount_tax = 0.0 + for line in order.order_line: + amount_untaxed += line.price_subtotal + amount_tax += line.price_tax + order.update({ + 'amount_untaxed': amount_untaxed, + 'amount_tax': amount_tax, + 'amount_total': amount_untaxed + amount_tax, + }) + + @api.depends('order_line.invoice_lines') + def _get_invoiced(self): + # The invoice_ids are obtained thanks to the invoice lines of the SO + # lines, and we also search for possible refunds created directly from + # existing invoices. This is necessary since such a refund is not + # directly linked to the SO. + for order in self: + invoices = order.order_line.invoice_lines.move_id.filtered(lambda r: r.move_type in ('out_invoice', 'out_refund')) + order.invoice_ids = invoices + order.invoice_count = len(invoices) + + @api.depends('state', 'order_line.invoice_status') + def _get_invoice_status(self): + """ + Compute the invoice status of a SO. Possible statuses: + - no: if the SO is not in status 'sale' or 'done', we consider that there is nothing to + invoice. This is also the default value if the conditions of no other status is met. + - to invoice: if any SO line is 'to invoice', the whole SO is 'to invoice' + - invoiced: if all SO lines are invoiced, the SO is invoiced. + - upselling: if all SO lines are invoiced or upselling, the status is upselling. + """ + unconfirmed_orders = self.filtered(lambda so: so.state not in ['sale', 'done']) + unconfirmed_orders.invoice_status = 'no' + confirmed_orders = self - unconfirmed_orders + if not confirmed_orders: + return + line_invoice_status_all = [ + (d['order_id'][0], d['invoice_status']) + for d in self.env['sale.order.line'].read_group([ + ('order_id', 'in', confirmed_orders.ids), + ('is_downpayment', '=', False), + ('display_type', '=', False), + ], + ['order_id', 'invoice_status'], + ['order_id', 'invoice_status'], lazy=False)] + for order in confirmed_orders: + line_invoice_status = [d[1] for d in line_invoice_status_all if d[0] == order.id] + if order.state not in ('sale', 'done'): + order.invoice_status = 'no' + elif any(invoice_status == 'to invoice' for invoice_status in line_invoice_status): + order.invoice_status = 'to invoice' + elif line_invoice_status and all(invoice_status == 'invoiced' for invoice_status in line_invoice_status): + order.invoice_status = 'invoiced' + elif line_invoice_status and all(invoice_status in ('invoiced', 'upselling') for invoice_status in line_invoice_status): + order.invoice_status = 'upselling' + else: + order.invoice_status = 'no' + + @api.model + def get_empty_list_help(self, help): + self = self.with_context( + empty_list_help_document_name=_("sale order"), + ) + return super(SaleOrder, self).get_empty_list_help(help) + + @api.model + def _default_note(self): + use_invoice_terms = self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') + if use_invoice_terms and self.env.company.terms_type == "html": + baseurl = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + return _('Terms & Conditions: %s/terms', baseurl) + return use_invoice_terms and self.env.company.invoice_terms or '' + + @api.model + def _get_default_team(self): + return self.env['crm.team']._get_default_team_id() + + @api.onchange('fiscal_position_id') + def _compute_tax_id(self): + """ + Trigger the recompute of the taxes if the fiscal position is changed on the SO. + """ + for order in self: + order.order_line._compute_tax_id() + + def _search_invoice_ids(self, operator, value): + if operator == 'in' and value: + self.env.cr.execute(""" + SELECT array_agg(so.id) + FROM sale_order so + JOIN sale_order_line sol ON sol.order_id = so.id + JOIN sale_order_line_invoice_rel soli_rel ON soli_rel.order_line_id = sol.id + JOIN account_move_line aml ON aml.id = soli_rel.invoice_line_id + JOIN account_move am ON am.id = aml.move_id + WHERE + am.move_type in ('out_invoice', 'out_refund') AND + am.id = ANY(%s) + """, (list(value),)) + so_ids = self.env.cr.fetchone()[0] or [] + return [('id', 'in', so_ids)] + if operator == '=' and not value: + return [('order_line.invoice_lines', '=', False)] + return ['&', ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund')), ('order_line.invoice_lines.move_id', operator, value)] + + name = fields.Char(string='Order Reference', required=True, copy=False, readonly=True, states={'draft': [('readonly', False)]}, index=True, default=lambda self: _('New')) + origin = fields.Char(string='Source Document', help="Reference of the document that generated this sales order request.") + client_order_ref = fields.Char(string='Customer Reference', copy=False) + reference = fields.Char(string='Payment Ref.', copy=False, + help='The payment communication of this sale order.') + state = fields.Selection([ + ('draft', 'Quotation'), + ('sent', 'Quotation Sent'), + ('sale', 'Sales Order'), + ('done', 'Locked'), + ('cancel', 'Cancelled'), + ], string='Status', readonly=True, copy=False, index=True, tracking=3, default='draft') + date_order = fields.Datetime(string='Order Date', required=True, readonly=True, index=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, copy=False, default=fields.Datetime.now, help="Creation date of draft/sent orders,\nConfirmation date of confirmed orders.") + validity_date = fields.Date(string='Expiration', readonly=True, copy=False, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, + default=_default_validity_date) + is_expired = fields.Boolean(compute='_compute_is_expired', string="Is expired") + require_signature = fields.Boolean('Online Signature', default=_get_default_require_signature, readonly=True, + states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, + help='Request a online signature to the customer in order to confirm orders automatically.') + require_payment = fields.Boolean('Online Payment', default=_get_default_require_payment, readonly=True, + states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, + help='Request an online payment to the customer in order to confirm orders automatically.') + create_date = fields.Datetime(string='Creation Date', readonly=True, index=True, help="Date on which sales order is created.") + + user_id = fields.Many2one( + 'res.users', string='Salesperson', index=True, tracking=2, default=lambda self: self.env.user, + domain=lambda self: [('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman').id)]) + partner_id = fields.Many2one( + 'res.partner', string='Customer', readonly=True, + states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, + required=True, change_default=True, index=True, tracking=1, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) + partner_invoice_id = fields.Many2one( + 'res.partner', string='Invoice Address', + readonly=True, required=True, + states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]}, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) + partner_shipping_id = fields.Many2one( + 'res.partner', string='Delivery Address', readonly=True, required=True, + states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]}, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) + + pricelist_id = fields.Many2one( + 'product.pricelist', string='Pricelist', check_company=True, # Unrequired company + required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", tracking=1, + help="If you change the pricelist, only newly added lines will be affected.") + currency_id = fields.Many2one(related='pricelist_id.currency_id', depends=["pricelist_id"], store=True, ondelete="restrict") + analytic_account_id = fields.Many2one( + 'account.analytic.account', 'Analytic Account', + readonly=True, copy=False, check_company=True, # Unrequired company + states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", + help="The analytic account related to a sales order.") + + order_line = fields.One2many('sale.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True, auto_join=True) + + invoice_count = fields.Integer(string='Invoice Count', compute='_get_invoiced', readonly=True) + invoice_ids = fields.Many2many("account.move", string='Invoices', compute="_get_invoiced", readonly=True, copy=False, search="_search_invoice_ids") + invoice_status = fields.Selection([ + ('upselling', 'Upselling Opportunity'), + ('invoiced', 'Fully Invoiced'), + ('to invoice', 'To Invoice'), + ('no', 'Nothing to Invoice') + ], string='Invoice Status', compute='_get_invoice_status', store=True, readonly=True) + + note = fields.Text('Terms and conditions', default=_default_note) + terms_type = fields.Selection(related='company_id.terms_type') + + amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, compute='_amount_all', tracking=5) + amount_by_group = fields.Binary(string="Tax amount by group", compute='_amount_by_group', help="type: [(name, amount, base, formated amount, formated base)]") + amount_tax = fields.Monetary(string='Taxes', store=True, readonly=True, compute='_amount_all') + amount_total = fields.Monetary(string='Total', store=True, readonly=True, compute='_amount_all', tracking=4) + currency_rate = fields.Float("Currency Rate", compute='_compute_currency_rate', compute_sudo=True, store=True, digits=(12, 6), readonly=True, help='The rate of the currency to the currency of rate 1 applicable at the date of the order') + + payment_term_id = fields.Many2one( + 'account.payment.term', string='Payment Terms', check_company=True, # Unrequired company + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) + fiscal_position_id = fields.Many2one( + 'account.fiscal.position', string='Fiscal Position', + domain="[('company_id', '=', company_id)]", check_company=True, + help="Fiscal positions are used to adapt taxes and accounts for particular customers or sales orders/invoices." + "The default value comes from the customer.") + company_id = fields.Many2one('res.company', 'Company', required=True, index=True, default=lambda self: self.env.company) + team_id = fields.Many2one( + 'crm.team', 'Sales Team', + change_default=True, default=_get_default_team, check_company=True, # Unrequired company + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + + signature = fields.Image('Signature', help='Signature received through the portal.', copy=False, attachment=True, max_width=1024, max_height=1024) + signed_by = fields.Char('Signed By', help='Name of the person that signed the SO.', copy=False) + signed_on = fields.Datetime('Signed On', help='Date of the signature.', copy=False) + + commitment_date = fields.Datetime('Delivery Date', copy=False, + states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, + help="This is the delivery date promised to the customer. " + "If set, the delivery order will be scheduled based on " + "this date rather than product lead times.") + expected_date = fields.Datetime("Expected Date", compute='_compute_expected_date', store=False, # Note: can not be stored since depends on today() + help="Delivery date you can promise to the customer, computed from the minimum lead time of the order lines.") + amount_undiscounted = fields.Float('Amount Before Discount', compute='_compute_amount_undiscounted', digits=0) + + type_name = fields.Char('Type Name', compute='_compute_type_name') + + transaction_ids = fields.Many2many('payment.transaction', 'sale_order_transaction_rel', 'sale_order_id', 'transaction_id', + string='Transactions', copy=False, readonly=True) + authorized_transaction_ids = fields.Many2many('payment.transaction', compute='_compute_authorized_transaction_ids', + string='Authorized Transactions', copy=False, readonly=True) + show_update_pricelist = fields.Boolean(string='Has Pricelist Changed', + help="Technical Field, True if the pricelist was changed;\n" + " this will then display a recomputation button") + tag_ids = fields.Many2many('crm.tag', 'sale_order_tag_rel', 'order_id', 'tag_id', string='Tags') + + _sql_constraints = [ + ('date_order_conditional_required', "CHECK( (state IN ('sale', 'done') AND date_order IS NOT NULL) OR state NOT IN ('sale', 'done') )", "A confirmed sales order requires a confirmation date."), + ] + + @api.constrains('company_id', 'order_line') + def _check_order_line_company_id(self): + for order in self: + companies = order.order_line.product_id.company_id + if companies and companies != order.company_id: + bad_products = order.order_line.product_id.filtered(lambda p: p.company_id and p.company_id != order.company_id) + raise ValidationError(_( + "Your quotation contains products from company %(product_company)s whereas your quotation belongs to company %(quote_company)s. \n Please change the company of your quotation or remove the products from other companies (%(bad_products)s).", + product_company=', '.join(companies.mapped('display_name')), + quote_company=order.company_id.display_name, + bad_products=', '.join(bad_products.mapped('display_name')), + )) + + @api.depends('pricelist_id', 'date_order', 'company_id') + def _compute_currency_rate(self): + for order in self: + if not order.company_id: + order.currency_rate = order.currency_id.with_context(date=order.date_order).rate or 1.0 + continue + elif order.company_id.currency_id and order.currency_id: # the following crashes if any one is undefined + order.currency_rate = self.env['res.currency']._get_conversion_rate(order.company_id.currency_id, order.currency_id, order.company_id, order.date_order) + else: + order.currency_rate = 1.0 + + def _compute_access_url(self): + super(SaleOrder, self)._compute_access_url() + for order in self: + order.access_url = '/my/orders/%s' % (order.id) + + def _compute_is_expired(self): + today = fields.Date.today() + for order in self: + order.is_expired = order.state == 'sent' and order.validity_date and order.validity_date < today + + @api.depends('order_line.customer_lead', 'date_order', 'order_line.state') + def _compute_expected_date(self): + """ For service and consumable, we only take the min dates. This method is extended in sale_stock to + take the picking_policy of SO into account. + """ + for order in self: + dates_list = [] + for line in order.order_line.filtered(lambda x: x.state != 'cancel' and not x._is_delivery() and not x.display_type): + dt = line._expected_date() + dates_list.append(dt) + if dates_list: + order.expected_date = fields.Datetime.to_string(min(dates_list)) + else: + order.expected_date = False + + @api.onchange('expected_date') + def _onchange_commitment_date(self): + self.commitment_date = self.expected_date + + @api.depends('transaction_ids') + def _compute_authorized_transaction_ids(self): + for trans in self: + trans.authorized_transaction_ids = trans.transaction_ids.filtered(lambda t: t.state == 'authorized') + + def _compute_amount_undiscounted(self): + for order in self: + total = 0.0 + for line in order.order_line: + total += line.price_subtotal + line.price_unit * ((line.discount or 0.0) / 100.0) * line.product_uom_qty # why is there a discount in a field named amount_undiscounted ?? + order.amount_undiscounted = total + + @api.depends('state') + def _compute_type_name(self): + for record in self: + record.type_name = _('Quotation') if record.state in ('draft', 'sent', 'cancel') else _('Sales Order') + + @api.ondelete(at_uninstall=False) + def _unlink_except_draft_or_cancel(self): + for order in self: + if order.state not in ('draft', 'cancel'): + raise UserError(_('You can not delete a sent quotation or a confirmed sales order. You must first cancel it.')) + + def validate_taxes_on_sales_order(self): + # Override for correct taxcloud computation + # when using coupon and delivery + return True + + def _track_subtype(self, init_values): + self.ensure_one() + if 'state' in init_values and self.state == 'sale': + return self.env.ref('sale.mt_order_confirmed') + elif 'state' in init_values and self.state == 'sent': + return self.env.ref('sale.mt_order_sent') + return super(SaleOrder, self)._track_subtype(init_values) + + @api.onchange('partner_shipping_id', 'partner_id', 'company_id') + def onchange_partner_shipping_id(self): + """ + Trigger the change of fiscal position when the shipping address is modified. + """ + self.fiscal_position_id = self.env['account.fiscal.position'].with_company(self.company_id).get_fiscal_position(self.partner_id.id, self.partner_shipping_id.id) + return {} + + @api.onchange('partner_id') + def onchange_partner_id(self): + """ + Update the following fields when the partner is changed: + - Pricelist + - Payment terms + - Invoice address + - Delivery address + - Sales Team + """ + if not self.partner_id: + self.update({ + 'partner_invoice_id': False, + 'partner_shipping_id': False, + 'fiscal_position_id': False, + }) + return + + self = self.with_company(self.company_id) + + addr = self.partner_id.address_get(['delivery', 'invoice']) + partner_user = self.partner_id.user_id or self.partner_id.commercial_partner_id.user_id + values = { + 'pricelist_id': self.partner_id.property_product_pricelist and self.partner_id.property_product_pricelist.id or False, + 'payment_term_id': self.partner_id.property_payment_term_id and self.partner_id.property_payment_term_id.id or False, + 'partner_invoice_id': addr['invoice'], + 'partner_shipping_id': addr['delivery'], + } + user_id = partner_user.id + if not self.env.context.get('not_self_saleperson'): + user_id = user_id or self.env.uid + if user_id and self.user_id.id != user_id: + values['user_id'] = user_id + + if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms'): + if self.terms_type == 'html' and self.env.company.invoice_terms_html: + values['note'] = _('Terms & Conditions: %s/terms', self.get_base_url()) + elif self.env.company.invoice_terms: + values['note'] = self.with_context(lang=self.partner_id.lang).env.company.invoice_terms + if not self.env.context.get('not_self_saleperson') or not self.team_id: + values['team_id'] = self.env['crm.team'].with_context( + default_team_id=self.partner_id.team_id.id + )._get_default_team_id(domain=['|', ('company_id', '=', self.company_id.id), ('company_id', '=', False)], user_id=user_id) + self.update(values) + + @api.onchange('user_id') + def onchange_user_id(self): + if self.user_id: + self.team_id = self.env['crm.team'].with_context( + default_team_id=self.team_id.id + )._get_default_team_id(user_id=self.user_id.id, domain=None) + + @api.onchange('partner_id') + def onchange_partner_id_warning(self): + if not self.partner_id: + return + warning = {} + title = False + message = False + partner = self.partner_id + + # If partner has no warning, check its company + if partner.sale_warn == 'no-message' and partner.parent_id: + partner = partner.parent_id + + if partner.sale_warn and partner.sale_warn != 'no-message': + # Block if partner only has warning but parent company is blocked + if partner.sale_warn != 'block' and partner.parent_id and partner.parent_id.sale_warn == 'block': + partner = partner.parent_id + title = ("Warning for %s") % partner.name + message = partner.sale_warn_msg + warning = { + 'title': title, + 'message': message, + } + if partner.sale_warn == 'block': + self.update({'partner_id': False, 'partner_invoice_id': False, 'partner_shipping_id': False, 'pricelist_id': False}) + return {'warning': warning} + + if warning: + return {'warning': warning} + + @api.onchange('commitment_date') + def _onchange_commitment_date(self): + """ Warn if the commitment dates is sooner than the expected date """ + if (self.commitment_date and self.expected_date and self.commitment_date < self.expected_date): + return { + 'warning': { + 'title': _('Requested date is too soon.'), + 'message': _("The delivery date is sooner than the expected date." + "You may be unable to honor the delivery date.") + } + } + + @api.onchange('pricelist_id') + def _onchange_pricelist_id(self): + if self.order_line and self.pricelist_id and self._origin.pricelist_id != self.pricelist_id: + self.show_update_pricelist = True + else: + self.show_update_pricelist = False + + def update_prices(self): + self.ensure_one() + lines_to_update = [] + for line in self.order_line.filtered(lambda line: not line.display_type): + product = line.product_id.with_context( + partner=self.partner_id, + quantity=line.product_uom_qty, + date=self.date_order, + pricelist=self.pricelist_id.id, + uom=line.product_uom.id + ) + price_unit = self.env['account.tax']._fix_tax_included_price_company( + line._get_display_price(product), line.product_id.taxes_id, line.tax_id, line.company_id) + if self.pricelist_id.discount_policy == 'without_discount' and price_unit: + discount = max(0, (price_unit - product.price) * 100 / price_unit) + else: + discount = 0 + lines_to_update.append((1, line.id, {'price_unit': price_unit, 'discount': discount})) + self.update({'order_line': lines_to_update}) + self.show_update_pricelist = False + self.message_post(body=_("Product prices have been recomputed according to pricelist %s ", self.pricelist_id.display_name)) + + @api.model + def create(self, vals): + if 'company_id' in vals: + self = self.with_company(vals['company_id']) + if vals.get('name', _('New')) == _('New'): + seq_date = None + if 'date_order' in vals: + seq_date = fields.Datetime.context_timestamp(self, fields.Datetime.to_datetime(vals['date_order'])) + vals['name'] = self.env['ir.sequence'].next_by_code('sale.order', sequence_date=seq_date) or _('New') + + # Makes sure partner_invoice_id', 'partner_shipping_id' and 'pricelist_id' are defined + if any(f not in vals for f in ['partner_invoice_id', 'partner_shipping_id', 'pricelist_id']): + partner = self.env['res.partner'].browse(vals.get('partner_id')) + addr = partner.address_get(['delivery', 'invoice']) + vals['partner_invoice_id'] = vals.setdefault('partner_invoice_id', addr['invoice']) + vals['partner_shipping_id'] = vals.setdefault('partner_shipping_id', addr['delivery']) + vals['pricelist_id'] = vals.setdefault('pricelist_id', partner.property_product_pricelist.id) + result = super(SaleOrder, self).create(vals) + return result + + def _compute_field_value(self, field): + super()._compute_field_value(field) + if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'): + return + + upselling_orders = self.filtered(lambda so: (so.user_id or so.partner_id.user_id) and so.invoice_status == 'upselling') + if not upselling_orders: + return + + upselling_orders._create_upsell_activity() + + def copy_data(self, default=None): + if default is None: + default = {} + if 'order_line' not in default: + default['order_line'] = [(0, 0, line.copy_data()[0]) for line in self.order_line.filtered(lambda l: not l.is_downpayment)] + return super(SaleOrder, self).copy_data(default) + + def name_get(self): + if self._context.get('sale_show_partner_name'): + res = [] + for order in self: + name = order.name + if order.partner_id.name: + name = '%s - %s' % (name, order.partner_id.name) + res.append((order.id, name)) + return res + return super(SaleOrder, self).name_get() + + @api.model + def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): + if self._context.get('sale_show_partner_name'): + if operator == 'ilike' and not (name or '').strip(): + domain = [] + elif operator in ('ilike', 'like', '=', '=like', '=ilike'): + domain = expression.AND([ + args or [], + ['|', ('name', operator, name), ('partner_id.name', operator, name)] + ]) + return self._search(domain, limit=limit, access_rights_uid=name_get_uid) + return super(SaleOrder, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) + + def _create_upsell_activity(self): + self and self.activity_unlink(['sale.mail_act_sale_upsell']) + for order in self: + ref = "%s" + order_ref = ref % (order._name, order.id, order.name) + customer_ref = ref % (order.partner_id._name, order.partner_id.id, order.partner_id.display_name) + order.activity_schedule( + 'sale.mail_act_sale_upsell', + user_id=order.user_id.id or order.partner_id.user_id.id, + note=_("Upsell %(order)s for customer %(customer)s", order=order_ref, customer=customer_ref)) + + def _prepare_invoice(self): + """ + Prepare the dict of values to create the new invoice for a sales order. This method may be + overridden to implement custom invoice generation (making sure to call super() to establish + a clean extension chain). + """ + self.ensure_one() + journal = self.env['account.move'].with_context(default_move_type='out_invoice')._get_default_journal() + if not journal: + raise UserError(_('Please define an accounting sales journal for the company %s (%s).') % (self.company_id.name, self.company_id.id)) + + invoice_vals = { + 'ref': self.client_order_ref or '', + 'move_type': 'out_invoice', + 'narration': self.note, + 'currency_id': self.pricelist_id.currency_id.id, + 'campaign_id': self.campaign_id.id, + 'medium_id': self.medium_id.id, + 'source_id': self.source_id.id, + 'invoice_user_id': self.user_id and self.user_id.id, + 'team_id': self.team_id.id, + 'partner_id': self.partner_invoice_id.id, + 'partner_shipping_id': self.partner_shipping_id.id, + 'fiscal_position_id': (self.fiscal_position_id or self.fiscal_position_id.get_fiscal_position(self.partner_invoice_id.id)).id, + 'partner_bank_id': self.company_id.partner_id.bank_ids[:1].id, + 'journal_id': journal.id, # company comes from the journal + 'invoice_origin': self.name, + 'invoice_payment_term_id': self.payment_term_id.id, + 'payment_reference': self.reference, + 'transaction_ids': [(6, 0, self.transaction_ids.ids)], + 'invoice_line_ids': [], + 'company_id': self.company_id.id, + } + return invoice_vals + + def action_quotation_sent(self): + if self.filtered(lambda so: so.state != 'draft'): + raise UserError(_('Only draft orders can be marked as sent directly.')) + for order in self: + order.message_subscribe(partner_ids=order.partner_id.ids) + self.write({'state': 'sent'}) + + def action_view_invoice(self): + invoices = self.mapped('invoice_ids') + action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_out_invoice_type") + if len(invoices) > 1: + action['domain'] = [('id', 'in', invoices.ids)] + elif len(invoices) == 1: + form_view = [(self.env.ref('account.view_move_form').id, 'form')] + if 'views' in action: + action['views'] = form_view + [(state,view) for state,view in action['views'] if view != 'form'] + else: + action['views'] = form_view + action['res_id'] = invoices.id + else: + action = {'type': 'ir.actions.act_window_close'} + + context = { + 'default_move_type': 'out_invoice', + } + if len(self) == 1: + context.update({ + 'default_partner_id': self.partner_id.id, + 'default_partner_shipping_id': self.partner_shipping_id.id, + 'default_invoice_payment_term_id': self.payment_term_id.id or self.partner_id.property_payment_term_id.id or self.env['account.move'].default_get(['invoice_payment_term_id']).get('invoice_payment_term_id'), + 'default_invoice_origin': self.mapped('name'), + 'default_user_id': self.user_id.id, + }) + action['context'] = context + return action + + def _get_invoice_grouping_keys(self): + return ['company_id', 'partner_id', 'currency_id'] + + @api.model + def _nothing_to_invoice_error(self): + msg = _("""There is nothing to invoice!\n +Reason(s) of this behavior could be: +- You should deliver your products before invoicing them: Click on the "truck" icon (top-right of your screen) and follow instructions. +- You should modify the invoicing policy of your product: Open the product, go to the "Sales tab" and modify invoicing policy from "delivered quantities" to "ordered quantities". + """) + return UserError(msg) + + def _get_invoiceable_lines(self, final=False): + """Return the invoiceable lines for order `self`.""" + down_payment_line_ids = [] + invoiceable_line_ids = [] + pending_section = None + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + + for line in self.order_line: + if line.display_type == 'line_section': + # Only invoice the section if one of its lines is invoiceable + pending_section = line + continue + if line.display_type != 'line_note' and float_is_zero(line.qty_to_invoice, precision_digits=precision): + continue + if line.qty_to_invoice > 0 or (line.qty_to_invoice < 0 and final) or line.display_type == 'line_note': + if line.is_downpayment: + # Keep down payment lines separately, to put them together + # at the end of the invoice, in a specific dedicated section. + down_payment_line_ids.append(line.id) + continue + if pending_section: + invoiceable_line_ids.append(pending_section.id) + pending_section = None + invoiceable_line_ids.append(line.id) + + return self.env['sale.order.line'].browse(invoiceable_line_ids + down_payment_line_ids) + + def _create_invoices(self, grouped=False, final=False, date=None): + """ + Create the invoice associated to the SO. + :param grouped: if True, invoices are grouped by SO id. If False, invoices are grouped by + (partner_invoice_id, currency) + :param final: if True, refunds will be generated if necessary + :returns: list of created invoices + """ + if not self.env['account.move'].check_access_rights('create', False): + try: + self.check_access_rights('write') + self.check_access_rule('write') + except AccessError: + return self.env['account.move'] + + # 1) Create invoices. + invoice_vals_list = [] + invoice_item_sequence = 0 # Incremental sequencing to keep the lines order on the invoice. + for order in self: + order = order.with_company(order.company_id) + current_section_vals = None + down_payments = order.env['sale.order.line'] + + invoice_vals = order._prepare_invoice() + invoiceable_lines = order._get_invoiceable_lines(final) + + if not any(not line.display_type for line in invoiceable_lines): + raise self._nothing_to_invoice_error() + + invoice_line_vals = [] + down_payment_section_added = False + for line in invoiceable_lines: + if not down_payment_section_added and line.is_downpayment: + # Create a dedicated section for the down payments + # (put at the end of the invoiceable_lines) + invoice_line_vals.append( + (0, 0, order._prepare_down_payment_section_line( + sequence=invoice_item_sequence, + )), + ) + dp_section = True + invoice_item_sequence += 1 + invoice_line_vals.append( + (0, 0, line._prepare_invoice_line( + sequence=invoice_item_sequence, + )), + ) + invoice_item_sequence += 1 + + invoice_vals['invoice_line_ids'] += invoice_line_vals + invoice_vals_list.append(invoice_vals) + + if not invoice_vals_list: + raise self._nothing_to_invoice_error() + + # 2) Manage 'grouped' parameter: group by (partner_id, currency_id). + if not grouped: + new_invoice_vals_list = [] + invoice_grouping_keys = self._get_invoice_grouping_keys() + for grouping_keys, invoices in groupby(invoice_vals_list, key=lambda x: [x.get(grouping_key) for grouping_key in invoice_grouping_keys]): + origins = set() + payment_refs = set() + refs = set() + ref_invoice_vals = None + for invoice_vals in invoices: + if not ref_invoice_vals: + ref_invoice_vals = invoice_vals + else: + ref_invoice_vals['invoice_line_ids'] += invoice_vals['invoice_line_ids'] + origins.add(invoice_vals['invoice_origin']) + payment_refs.add(invoice_vals['payment_reference']) + refs.add(invoice_vals['ref']) + ref_invoice_vals.update({ + 'ref': ', '.join(refs)[:2000], + 'invoice_origin': ', '.join(origins), + 'payment_reference': len(payment_refs) == 1 and payment_refs.pop() or False, + }) + new_invoice_vals_list.append(ref_invoice_vals) + invoice_vals_list = new_invoice_vals_list + + # 3) Create invoices. + + # As part of the invoice creation, we make sure the sequence of multiple SO do not interfere + # in a single invoice. Example: + # SO 1: + # - Section A (sequence: 10) + # - Product A (sequence: 11) + # SO 2: + # - Section B (sequence: 10) + # - Product B (sequence: 11) + # + # If SO 1 & 2 are grouped in the same invoice, the result will be: + # - Section A (sequence: 10) + # - Section B (sequence: 10) + # - Product A (sequence: 11) + # - Product B (sequence: 11) + # + # Resequencing should be safe, however we resequence only if there are less invoices than + # orders, meaning a grouping might have been done. This could also mean that only a part + # of the selected SO are invoiceable, but resequencing in this case shouldn't be an issue. + if len(invoice_vals_list) < len(self): + SaleOrderLine = self.env['sale.order.line'] + for invoice in invoice_vals_list: + sequence = 1 + for line in invoice['invoice_line_ids']: + line[2]['sequence'] = SaleOrderLine._get_invoice_line_sequence(new=sequence, old=line[2]['sequence']) + sequence += 1 + + # Manage the creation of invoices in sudo because a salesperson must be able to generate an invoice from a + # sale order without "billing" access rights. However, he should not be able to create an invoice from scratch. + moves = self.env['account.move'].sudo().with_context(default_move_type='out_invoice').create(invoice_vals_list) + + # 4) Some moves might actually be refunds: convert them if the total amount is negative + # We do this after the moves have been created since we need taxes, etc. to know if the total + # is actually negative or not + if final: + moves.sudo().filtered(lambda m: m.amount_total < 0).action_switch_invoice_into_refund_credit_note() + for move in moves: + move.message_post_with_view('mail.message_origin_link', + values={'self': move, 'origin': move.line_ids.mapped('sale_line_ids.order_id')}, + subtype_id=self.env.ref('mail.mt_note').id + ) + return moves + + def action_draft(self): + orders = self.filtered(lambda s: s.state in ['cancel', 'sent']) + return orders.write({ + 'state': 'draft', + 'signature': False, + 'signed_by': False, + 'signed_on': False, + }) + + def action_cancel(self): + cancel_warning = self._show_cancel_wizard() + if cancel_warning: + return { + 'name': _('Cancel Sales Order'), + 'view_mode': 'form', + 'res_model': 'sale.order.cancel', + 'view_id': self.env.ref('sale.sale_order_cancel_view_form').id, + 'type': 'ir.actions.act_window', + 'context': {'default_order_id': self.id}, + 'target': 'new' + } + inv = self.invoice_ids.filtered(lambda inv: inv.state == 'draft') + inv.button_cancel() + return self.write({'state': 'cancel'}) + + def _show_cancel_wizard(self): + for order in self: + if order.invoice_ids.filtered(lambda inv: inv.state == 'draft') and not order._context.get('disable_cancel_warning'): + return True + return False + + def _find_mail_template(self, force_confirmation_template=False): + template_id = False + + if force_confirmation_template or (self.state == 'sale' and not self.env.context.get('proforma', False)): + template_id = int(self.env['ir.config_parameter'].sudo().get_param('sale.default_confirmation_template')) + template_id = self.env['mail.template'].search([('id', '=', template_id)]).id + if not template_id: + template_id = self.env['ir.model.data'].xmlid_to_res_id('sale.mail_template_sale_confirmation', raise_if_not_found=False) + if not template_id: + template_id = self.env['ir.model.data'].xmlid_to_res_id('sale.email_template_edi_sale', raise_if_not_found=False) + + return template_id + + def action_quotation_send(self): + ''' Opens a wizard to compose an email, with relevant mail template loaded by default ''' + self.ensure_one() + template_id = self._find_mail_template() + lang = self.env.context.get('lang') + template = self.env['mail.template'].browse(template_id) + if template.lang: + lang = template._render_lang(self.ids)[self.id] + ctx = { + 'default_model': 'sale.order', + 'default_res_id': self.ids[0], + 'default_use_template': bool(template_id), + 'default_template_id': template_id, + 'default_composition_mode': 'comment', + 'mark_so_as_sent': True, + 'custom_layout': "mail.mail_notification_paynow", + 'proforma': self.env.context.get('proforma', False), + 'force_email': True, + 'model_description': self.with_context(lang=lang).type_name, + } + return { + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'mail.compose.message', + 'views': [(False, 'form')], + 'view_id': False, + 'target': 'new', + 'context': ctx, + } + + @api.returns('mail.message', lambda value: value.id) + def message_post(self, **kwargs): + if self.env.context.get('mark_so_as_sent'): + self.filtered(lambda o: o.state == 'draft').with_context(tracking_disable=True).write({'state': 'sent'}) + return super(SaleOrder, self.with_context(mail_post_autofollow=True)).message_post(**kwargs) + + def _send_order_confirmation_mail(self): + if self.env.su: + # sending mail in sudo was meant for it being sent from superuser + self = self.with_user(SUPERUSER_ID) + template_id = self._find_mail_template(force_confirmation_template=True) + if template_id: + for order in self: + order.with_context(force_send=True).message_post_with_template(template_id, composition_mode='comment', email_layout_xmlid="mail.mail_notification_paynow") + + def action_done(self): + for order in self: + tx = order.sudo().transaction_ids._get_last() + if tx and tx.state == 'pending' and tx.acquirer_id.provider == 'transfer': + tx._set_done() + tx.write({'is_post_processed': True}) + return self.write({'state': 'done'}) + + def action_unlock(self): + self.write({'state': 'sale'}) + + def _action_confirm(self): + """ Implementation of additionnal mecanism of Sales Order confirmation. + This method should be extended when the confirmation should generated + other documents. In this method, the SO are in 'sale' state (not yet 'done'). + """ + # create an analytic account if at least an expense product + for order in self: + if any(expense_policy not in [False, '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 + + def _prepare_confirmation_values(self): + return { + 'state': 'sale', + 'date_order': fields.Datetime.now() + } + + def action_confirm(self): + if self._get_forbidden_state_confirm() & set(self.mapped('state')): + raise UserError(_( + 'It is not allowed to confirm an order in the following states: %s' + ) % (', '.join(self._get_forbidden_state_confirm()))) + + for order in self.filtered(lambda order: order.partner_id not in order.message_partner_ids): + order.message_subscribe([order.partner_id.id]) + self.write(self._prepare_confirmation_values()) + + # Context key 'default_name' is sometimes propagated up to here. + # We don't need it and it creates issues in the creation of linked records. + context = self._context.copy() + context.pop('default_name', None) + + self.with_context(context)._action_confirm() + if self.env.user.has_group('sale.group_auto_done_setting'): + self.action_done() + return True + + def _get_forbidden_state_confirm(self): + return {'done', 'cancel'} + + def _prepare_analytic_account_data(self, prefix=None): + """ + Prepare method for analytic account data + + :param prefix: The prefix of the to-be-created analytic account name + :type prefix: string + :return: dictionary of value for new analytic account creation + """ + name = self.name + if prefix: + name = prefix + ": " + self.name + return { + 'name': name, + 'code': self.client_order_ref, + 'company_id': self.company_id.id, + 'partner_id': self.partner_id.id + } + + def _create_analytic_account(self, prefix=None): + for order in self: + analytic = self.env['account.analytic.account'].create(order._prepare_analytic_account_data(prefix)) + order.analytic_account_id = analytic + + def _amount_by_group(self): + for order in self: + currency = order.currency_id or order.company_id.currency_id + fmt = partial(formatLang, self.with_context(lang=order.partner_id.lang).env, currency_obj=currency) + res = {} + for line in order.order_line: + price_reduce = line.price_unit * (1.0 - line.discount / 100.0) + taxes = line.tax_id.compute_all(price_reduce, quantity=line.product_uom_qty, product=line.product_id, partner=order.partner_shipping_id)['taxes'] + for tax in line.tax_id: + group = tax.tax_group_id + res.setdefault(group, {'amount': 0.0, 'base': 0.0}) + for t in taxes: + if t['id'] == tax.id or t['id'] in tax.children_tax_ids.ids: + res[group]['amount'] += t['amount'] + res[group]['base'] += t['base'] + res = sorted(res.items(), key=lambda l: l[0].sequence) + order.amount_by_group = [( + l[0].name, l[1]['amount'], l[1]['base'], + fmt(l[1]['amount']), fmt(l[1]['base']), + len(res), + ) for l in res] + + def has_to_be_signed(self, include_draft=False): + return (self.state == 'sent' or (self.state == 'draft' and include_draft)) and not self.is_expired and self.require_signature and not self.signature + + def has_to_be_paid(self, include_draft=False): + transaction = self.get_portal_last_transaction() + return (self.state == 'sent' or (self.state == 'draft' and include_draft)) and not self.is_expired and self.require_payment and transaction.state != 'done' and self.amount_total + + def _notify_get_groups(self, msg_vals=None): + """ Give access button to users and portal customer as portal is integrated + in sale. Customer and portal group have probably no right to see + the document so they don't have the access button. """ + groups = super(SaleOrder, self)._notify_get_groups(msg_vals=msg_vals) + + self.ensure_one() + if self.state not in ('draft', 'cancel'): + for group_name, group_method, group_data in groups: + if group_name not in ('customer', 'portal'): + group_data['has_button_access'] = True + + return groups + + def preview_sale_order(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_url', + 'target': 'self', + 'url': self.get_portal_url(), + } + + def _force_lines_to_invoice_policy_order(self): + for line in self.order_line: + if self.state in ['sale', 'done']: + line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced + else: + line.qty_to_invoice = 0 + + def payment_action_capture(self): + self.authorized_transaction_ids._send_capture_request() + + def payment_action_void(self): + self.authorized_transaction_ids._send_void_request() + + def get_portal_last_transaction(self): + self.ensure_one() + return self.transaction_ids._get_last() + + @api.model + def _get_customer_lead(self, product_tmpl_id): + return False + + def _get_report_base_filename(self): + self.ensure_one() + return '%s %s' % (self.type_name, self.name) + + def _get_portal_return_action(self): + """ Return the action used to display orders when returning from customer portal. """ + self.ensure_one() + return self.env.ref('sale.action_quotations_with_onboarding') + + @api.model + def _prepare_down_payment_section_line(self, **optional_values): + """ + Prepare the dict of values to create a new down payment section for a sales order line. + + :param optional_values: any parameter that should be added to the returned down payment section + """ + down_payments_section_line = { + 'display_type': 'line_section', + 'name': _('Down Payments'), + 'product_id': False, + 'product_uom_id': False, + 'quantity': 0, + 'discount': 0, + 'price_unit': 0, + 'account_id': False + } + if optional_values: + down_payments_section_line.update(optional_values) + return down_payments_section_line + + def add_option_to_order_with_taxcloud(self): + self.ensure_one() + + +class SaleOrderLine(models.Model): + _name = 'sale.order.line' + _description = 'Sales Order Line' + _order = 'order_id, sequence, id' + _check_company_auto = True + + @api.depends('state', 'product_uom_qty', 'qty_delivered', 'qty_to_invoice', 'qty_invoiced') + def _compute_invoice_status(self): + """ + Compute the invoice status of a SO line. Possible statuses: + - no: if the SO is not in status 'sale' or 'done', we consider that there is nothing to + invoice. This is also hte default value if the conditions of no other status is met. + - to invoice: we refer to the quantity to invoice of the line. Refer to method + `_get_to_invoice_qty()` for more information on how this quantity is calculated. + - upselling: this is possible only for a product invoiced on ordered quantities for which + we delivered more than expected. The could arise if, for example, a project took more + time than expected but we decided not to invoice the extra cost to the client. This + occurs onyl in state 'sale', so that when a SO is set to done, the upselling opportunity + is removed from the list. + - invoiced: the quantity invoiced is larger or equal to the quantity ordered. + """ + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + for line in self: + if line.state not in ('sale', 'done'): + line.invoice_status = 'no' + elif line.is_downpayment and line.untaxed_amount_to_invoice == 0: + line.invoice_status = 'invoiced' + elif not float_is_zero(line.qty_to_invoice, precision_digits=precision): + line.invoice_status = 'to invoice' + elif line.state == 'sale' and line.product_id.invoice_policy == 'order' and\ + float_compare(line.qty_delivered, line.product_uom_qty, precision_digits=precision) == 1: + line.invoice_status = 'upselling' + elif float_compare(line.qty_invoiced, line.product_uom_qty, precision_digits=precision) >= 0: + line.invoice_status = 'invoiced' + else: + line.invoice_status = 'no' + + def _expected_date(self): + self.ensure_one() + order_date = fields.Datetime.from_string(self.order_id.date_order if self.order_id.date_order and self.order_id.state in ['sale', 'done'] else fields.Datetime.now()) + return order_date + timedelta(days=self.customer_lead or 0.0) + + @api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_id') + def _compute_amount(self): + """ + Compute the amounts of the SO line. + """ + for line in self: + price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) + taxes = line.tax_id.compute_all(price, line.order_id.currency_id, line.product_uom_qty, product=line.product_id, partner=line.order_id.partner_shipping_id) + line.update({ + 'price_tax': sum(t.get('amount', 0.0) for t in taxes.get('taxes', [])), + 'price_total': taxes['total_included'], + 'price_subtotal': taxes['total_excluded'], + }) + if self.env.context.get('import_file', False) and not self.env.user.user_has_groups('account.group_account_manager'): + line.tax_id.invalidate_cache(['invoice_repartition_line_ids'], [line.tax_id.id]) + + @api.depends('product_id', 'order_id.state', 'qty_invoiced', 'qty_delivered') + def _compute_product_updatable(self): + for line in self: + if line.state in ['done', 'cancel'] or (line.state == 'sale' and (line.qty_invoiced > 0 or line.qty_delivered > 0)): + line.product_updatable = False + else: + line.product_updatable = True + + # no trigger product_id.invoice_policy to avoid retroactively changing SO + @api.depends('qty_invoiced', 'qty_delivered', 'product_uom_qty', 'order_id.state') + def _get_to_invoice_qty(self): + """ + Compute the quantity to invoice. If the invoice policy is order, the quantity to invoice is + calculated from the ordered quantity. Otherwise, the quantity delivered is used. + """ + for line in self: + if line.order_id.state in ['sale', 'done']: + if line.product_id.invoice_policy == 'order': + line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced + else: + line.qty_to_invoice = line.qty_delivered - line.qty_invoiced + else: + line.qty_to_invoice = 0 + + @api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity', 'untaxed_amount_to_invoice') + def _get_invoice_qty(self): + """ + Compute the quantity invoiced. If case of a refund, the quantity invoiced is decreased. Note + that this is the case only if the refund is generated from the SO and that is intentional: if + a refund made would automatically decrease the invoiced quantity, then there is a risk of reinvoicing + it automatically, which may not be wanted at all. That's why the refund has to be created from the SO + """ + for line in self: + qty_invoiced = 0.0 + for invoice_line in line.invoice_lines: + if invoice_line.move_id.state != 'cancel': + if invoice_line.move_id.move_type == 'out_invoice': + qty_invoiced += invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) + elif invoice_line.move_id.move_type == 'out_refund': + if not line.is_downpayment or line.untaxed_amount_to_invoice == 0 : + qty_invoiced -= invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) + line.qty_invoiced = qty_invoiced + + @api.depends('price_unit', 'discount') + def _get_price_reduce(self): + for line in self: + line.price_reduce = line.price_unit * (1.0 - line.discount / 100.0) + + @api.depends('price_total', 'product_uom_qty') + def _get_price_reduce_tax(self): + for line in self: + line.price_reduce_taxinc = line.price_total / line.product_uom_qty if line.product_uom_qty else 0.0 + + @api.depends('price_subtotal', 'product_uom_qty') + def _get_price_reduce_notax(self): + for line in self: + line.price_reduce_taxexcl = line.price_subtotal / line.product_uom_qty if line.product_uom_qty else 0.0 + + def _compute_tax_id(self): + for line in self: + line = line.with_company(line.company_id) + fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id.get_fiscal_position(line.order_partner_id.id) + # If company_id is set, always filter taxes by the company + taxes = line.product_id.taxes_id.filtered(lambda t: t.company_id == line.env.company) + line.tax_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_shipping_id) + + @api.model + def _prepare_add_missing_fields(self, values): + """ Deduce missing required fields from the onchange """ + res = {} + onchange_fields = ['name', 'price_unit', 'product_uom', 'tax_id'] + if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields): + line = self.new(values) + line.product_id_change() + for field in onchange_fields: + if field not in values: + res[field] = line._fields[field].convert_to_write(line[field], line) + return res + + @api.model_create_multi + def create(self, vals_list): + for values in vals_list: + if values.get('display_type', self.default_get(['display_type'])['display_type']): + values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0) + + values.update(self._prepare_add_missing_fields(values)) + + lines = super().create(vals_list) + for line in lines: + if line.product_id and line.order_id.state == 'sale': + msg = _("Extra line with %s ") % (line.product_id.display_name,) + line.order_id.message_post(body=msg) + # create an analytic account if at least an expense product + if line.product_id.expense_policy not in [False, 'no'] and not line.order_id.analytic_account_id: + line.order_id._create_analytic_account() + return lines + + _sql_constraints = [ + ('accountable_required_fields', + "CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom IS NOT NULL))", + "Missing required fields on accountable sale order line."), + ('non_accountable_null_fields', + "CHECK(display_type IS NULL OR (product_id IS NULL AND price_unit = 0 AND product_uom_qty = 0 AND product_uom IS NULL AND customer_lead = 0))", + "Forbidden values on non-accountable sale order line"), + ] + + def _update_line_quantity(self, values): + orders = self.mapped('order_id') + for order in orders: + order_lines = self.filtered(lambda x: x.order_id == order) + msg = "" + _("The ordered quantity has been updated.") + "
    " + for line in order_lines: + msg += "
  • %s:
    " % line.product_id.display_name + msg += _( + "Ordered Quantity: %(old_qty)s -> %(new_qty)s", + old_qty=line.product_uom_qty, + new_qty=values["product_uom_qty"] + ) + "
    " + if line.product_id.type in ('consu', 'product'): + msg += _("Delivered Quantity: %s", line.qty_delivered) + "
    " + msg += _("Invoiced Quantity: %s", line.qty_invoiced) + "
    " + msg += "
" + order.message_post(body=msg) + + def write(self, values): + if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')): + raise UserError(_("You cannot change the type of a sale order line. Instead you should delete the current line and create a new line of the proper type.")) + + if 'product_uom_qty' in values: + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + self.filtered( + lambda r: r.state == 'sale' and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) != 0)._update_line_quantity(values) + + # Prevent writing on a locked SO. + protected_fields = self._get_protected_fields() + if 'done' in self.mapped('order_id.state') and any(f in values.keys() for f in protected_fields): + protected_fields_modified = list(set(protected_fields) & set(values.keys())) + fields = self.env['ir.model.fields'].search([ + ('name', 'in', protected_fields_modified), ('model', '=', self._name) + ]) + raise UserError( + _('It is forbidden to modify the following fields in a locked order:\n%s') + % '\n'.join(fields.mapped('field_description')) + ) + + 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) + name = fields.Text(string='Description', required=True) + sequence = fields.Integer(string='Sequence', default=10) + + invoice_lines = fields.Many2many('account.move.line', 'sale_order_line_invoice_rel', 'order_line_id', 'invoice_line_id', string='Invoice Lines', copy=False) + invoice_status = fields.Selection([ + ('upselling', 'Upselling Opportunity'), + ('invoiced', 'Fully Invoiced'), + ('to invoice', 'To Invoice'), + ('no', 'Nothing to Invoice') + ], string='Invoice Status', compute='_compute_invoice_status', store=True, readonly=True, default='no') + price_unit = fields.Float('Unit Price', required=True, digits='Product Price', default=0.0) + + price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', readonly=True, store=True) + price_tax = fields.Float(compute='_compute_amount', string='Total Tax', readonly=True, store=True) + price_total = fields.Monetary(compute='_compute_amount', string='Total', readonly=True, store=True) + + price_reduce = fields.Float(compute='_get_price_reduce', string='Price Reduce', digits='Product Price', readonly=True, store=True) + tax_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) + price_reduce_taxinc = fields.Monetary(compute='_get_price_reduce_tax', string='Price Reduce Tax inc', readonly=True, store=True) + price_reduce_taxexcl = fields.Monetary(compute='_get_price_reduce_notax', string='Price Reduce Tax excl', readonly=True, store=True) + + discount = fields.Float(string='Discount (%)', digits='Discount', default=0.0) + + product_id = fields.Many2one( + 'product.product', string='Product', domain="[('sale_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", + change_default=True, ondelete='restrict', check_company=True) # Unrequired company + product_template_id = fields.Many2one( + 'product.template', string='Product Template', + related="product_id.product_tmpl_id", domain=[('sale_ok', '=', True)]) + product_updatable = fields.Boolean(compute='_compute_product_updatable', string='Can Edit Product', readonly=True, default=True) + product_uom_qty = fields.Float(string='Quantity', digits='Product Unit of Measure', required=True, default=1.0) + product_uom = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]", ondelete="restrict") + product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', readonly=True) + product_uom_readonly = fields.Boolean(compute='_compute_product_uom_readonly') + product_custom_attribute_value_ids = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string="Custom Values", copy=True) + + # 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 + product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string="Extra Values", ondelete='restrict') + + qty_delivered_method = fields.Selection([ + ('manual', 'Manual'), + ('analytic', 'Analytic From Expenses') + ], string="Method to update delivered qty", compute='_compute_qty_delivered_method', compute_sudo=True, store=True, readonly=True, + help="According to product configuration, the delivered quantity can be automatically computed by mechanism :\n" + " - Manual: the quantity is set manually on the line\n" + " - Analytic From expenses: the quantity is the quantity sum from posted expenses\n" + " - Timesheet: the quantity is the sum of hours recorded on tasks linked to this sale line\n" + " - Stock Moves: the quantity comes from confirmed pickings\n") + qty_delivered = fields.Float('Delivered Quantity', copy=False, compute='_compute_qty_delivered', inverse='_inverse_qty_delivered', compute_sudo=True, store=True, digits='Product Unit of Measure', default=0.0) + qty_delivered_manual = fields.Float('Delivered Manually', copy=False, digits='Product Unit of Measure', default=0.0) + qty_to_invoice = fields.Float( + compute='_get_to_invoice_qty', string='To Invoice Quantity', store=True, readonly=True, + digits='Product Unit of Measure') + qty_invoiced = fields.Float( + compute='_get_invoice_qty', string='Invoiced Quantity', store=True, readonly=True, + compute_sudo=True, + digits='Product Unit of Measure') + + untaxed_amount_invoiced = fields.Monetary("Untaxed Invoiced Amount", compute='_compute_untaxed_amount_invoiced', compute_sudo=True, store=True) + untaxed_amount_to_invoice = fields.Monetary("Untaxed Amount To Invoice", compute='_compute_untaxed_amount_to_invoice', compute_sudo=True, store=True) + + salesman_id = fields.Many2one(related='order_id.user_id', store=True, string='Salesperson', readonly=True) + currency_id = fields.Many2one(related='order_id.currency_id', depends=['order_id.currency_id'], store=True, string='Currency', readonly=True) + company_id = fields.Many2one(related='order_id.company_id', string='Company', store=True, readonly=True, index=True) + order_partner_id = fields.Many2one(related='order_id.partner_id', store=True, string='Customer', readonly=False) + analytic_tag_ids = fields.Many2many( + 'account.analytic.tag', string='Analytic Tags', + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + analytic_line_ids = fields.One2many('account.analytic.line', 'so_line', string="Analytic lines") + is_expense = fields.Boolean('Is expense', help="Is true if the sales order line comes from an expense or a vendor bills") + is_downpayment = fields.Boolean( + string="Is a down payment", help="Down payments are made when creating invoices from a sales order." + " They are not copied when duplicating a sales order.") + + state = fields.Selection( + related='order_id.state', string='Order Status', readonly=True, copy=False, store=True, default='draft') + + customer_lead = fields.Float( + 'Lead Time', required=True, default=0.0, + help="Number of days between the order confirmation and the shipping of the products to the customer") + + display_type = fields.Selection([ + ('line_section', "Section"), + ('line_note', "Note")], default=False, help="Technical field for UX purpose.") + + product_packaging_id = fields.Many2one('product.packaging', string='Packaging', default=False, domain="[('sales', '=', True), ('product_id','=',product_id)]", check_company=True) + product_packaging_qty = fields.Float('Packaging Quantity') + + @api.depends('state') + def _compute_product_uom_readonly(self): + for line in self: + line.product_uom_readonly = line.state in ['sale', 'done', 'cancel'] + + @api.depends('state', 'is_expense') + def _compute_qty_delivered_method(self): + """ Sale module compute delivered qty for product [('type', 'in', ['consu']), ('service_type', '=', 'manual')] + - consu + expense_policy : analytic (sum of analytic unit_amount) + - consu + no expense_policy : manual (set manually on SOL) + - service (+ service_type='manual', the only available option) : manual + + This is true when only sale is installed: sale_stock redifine the behavior for 'consu' type, + and sale_timesheet implements the behavior of 'service' + service_type=timesheet. + """ + for line in self: + if line.is_expense: + line.qty_delivered_method = 'analytic' + else: # service and consu + line.qty_delivered_method = 'manual' + + @api.depends('qty_delivered_method', 'qty_delivered_manual', 'analytic_line_ids.so_line', 'analytic_line_ids.unit_amount', 'analytic_line_ids.product_uom_id') + def _compute_qty_delivered(self): + """ This method compute the delivered quantity of the SO lines: it covers the case provide by sale module, aka + expense/vendor bills (sum of unit_amount of AAL), and manual case. + This method should be overridden to provide other way to automatically compute delivered qty. Overrides should + take their concerned so lines, compute and set the `qty_delivered` field, and call super with the remaining + records. + """ + # compute for analytic lines + lines_by_analytic = self.filtered(lambda sol: sol.qty_delivered_method == 'analytic') + mapping = lines_by_analytic._get_delivered_quantity_by_analytic([('amount', '<=', 0.0)]) + for so_line in lines_by_analytic: + so_line.qty_delivered = mapping.get(so_line.id or so_line._origin.id, 0.0) + # compute for manual lines + for line in self: + if line.qty_delivered_method == 'manual': + line.qty_delivered = line.qty_delivered_manual or 0.0 + + def _get_delivered_quantity_by_analytic(self, additional_domain): + """ Compute and write the delivered quantity of current SO lines, based on their related + analytic lines. + :param additional_domain: domain to restrict AAL to include in computation (required since timesheet is an AAL with a project ...) + """ + result = {} + + # avoid recomputation if no SO lines concerned + if not self: + return result + + # group analytic lines by product uom and so line + domain = expression.AND([[('so_line', 'in', self.ids)], additional_domain]) + data = self.env['account.analytic.line'].read_group( + domain, + ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False + ) + + # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines + # browse so lines and product uoms here to make them share the same prefetch + lines = self.browse([item['so_line'][0] for item in data]) + lines_map = {line.id: line for line in lines} + product_uom_ids = [item['product_uom_id'][0] for item in data if item['product_uom_id']] + product_uom_map = {uom.id: uom for uom in self.env['uom.uom'].browse(product_uom_ids)} + for item in data: + if not item['product_uom_id']: + continue + so_line_id = item['so_line'][0] + so_line = lines_map[so_line_id] + result.setdefault(so_line_id, 0.0) + uom = product_uom_map.get(item['product_uom_id'][0]) + if so_line.product_uom.category_id == uom.category_id: + qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom, rounding_method='HALF-UP') + else: + qty = item['unit_amount'] + result[so_line_id] += qty + + return result + + @api.onchange('qty_delivered') + def _inverse_qty_delivered(self): + """ When writing on qty_delivered, if the value should be modify manually (`qty_delivered_method` = 'manual' only), + then we put the value in `qty_delivered_manual`. Otherwise, `qty_delivered_manual` should be False since the + delivered qty is automatically compute by other mecanisms. + """ + for line in self: + if line.qty_delivered_method == 'manual': + line.qty_delivered_manual = line.qty_delivered + else: + line.qty_delivered_manual = 0.0 + + @api.onchange('product_id', 'product_uom_qty', 'product_uom') + def _onchange_suggest_packaging(self): + # remove packaging if not match the product + if self.product_packaging_id.product_id != self.product_id: + self.product_packaging_id = False + # suggest biggest suitable packaging + if self.product_id and self.product_uom_qty and self.product_uom: + self.product_packaging_id = self.product_id.packaging_ids.filtered('sales')._find_suitable_product_packaging(self.product_uom_qty, self.product_uom) + + @api.onchange('product_packaging_id') + def _onchange_product_packaging_id(self): + if self.product_packaging_id and self.product_uom_qty: + newqty = self.product_packaging_id._check_qty(self.product_uom_qty, self.product_uom, "UP") + if float_compare(newqty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0: + return { + 'warning': { + 'title': _('Warning'), + 'message': _( + "This product is packaged by %(pack_size).2f %(pack_name)s. You should sell %(quantity).2f %(unit)s.", + pack_size=self.product_packaging_id.qty, + pack_name=self.product_id.uom_id.name, + quantity=newqty, + unit=self.product_uom.name + ), + }, + } + + @api.onchange('product_packaging_id', 'product_uom', 'product_uom_qty') + def _onchange_update_product_packaging_qty(self): + if not self.product_packaging_id: + self.product_packaging_qty = False + else: + packaging_uom = self.product_packaging_id.product_uom_id + packaging_uom_qty = self.product_uom._compute_quantity(self.product_uom_qty, packaging_uom) + self.product_packaging_qty = float_round(packaging_uom_qty / self.product_packaging_id.qty, precision_rounding=packaging_uom.rounding) + + @api.onchange('product_packaging_qty') + def _onchange_product_packaging_qty(self): + if self.product_packaging_id: + packaging_uom = self.product_packaging_id.product_uom_id + qty_per_packaging = self.product_packaging_id.qty + product_uom_qty = packaging_uom._compute_quantity(self.product_packaging_qty * qty_per_packaging, self.product_uom) + if float_compare(product_uom_qty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0: + self.product_uom_qty = product_uom_qty + + @api.depends('invoice_lines', 'invoice_lines.price_total', 'invoice_lines.move_id.state', 'invoice_lines.move_id.move_type') + def _compute_untaxed_amount_invoiced(self): + """ Compute the untaxed amount already invoiced from the sale order line, taking the refund attached + the so line into account. This amount is computed as + SUM(inv_line.price_subtotal) - SUM(ref_line.price_subtotal) + where + `inv_line` is a customer invoice line linked to the SO line + `ref_line` is a customer credit note (refund) line linked to the SO line + """ + for line in self: + amount_invoiced = 0.0 + for invoice_line in line.invoice_lines: + if invoice_line.move_id.state == 'posted': + invoice_date = invoice_line.move_id.invoice_date or fields.Date.today() + if invoice_line.move_id.move_type == 'out_invoice': + amount_invoiced += invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date) + elif invoice_line.move_id.move_type == 'out_refund': + amount_invoiced -= invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date) + line.untaxed_amount_invoiced = amount_invoiced + + @api.depends('state', 'price_reduce', 'product_id', 'untaxed_amount_invoiced', 'qty_delivered', 'product_uom_qty') + def _compute_untaxed_amount_to_invoice(self): + """ Total of remaining amount to invoice on the sale order line (taxes excl.) as + total_sol - amount already invoiced + where Total_sol depends on the invoice policy of the product. + + Note: Draft invoice are ignored on purpose, the 'to invoice' amount should + come only from the SO lines. + """ + for line in self: + amount_to_invoice = 0.0 + if line.state in ['sale', 'done']: + # Note: do not use price_subtotal field as it returns zero when the ordered quantity is + # zero. It causes problem for expense line (e.i.: ordered qty = 0, deli qty = 4, + # price_unit = 20 ; subtotal is zero), but when you can invoice the line, you see an + # amount and not zero. Since we compute untaxed amount, we can use directly the price + # reduce (to include discount) without using `compute_all()` method on taxes. + price_subtotal = 0.0 + umo_qty_to_consider = line.qty_delivered if line.product_id.invoice_policy == 'delivery' else line.product_uom_qty + price_subtotal = line.price_reduce * umo_qty_to_consider + if len(line.tax_id.filtered(lambda tax: tax.price_include)) > 0: + # As included taxes are not excluded from the computed subtotal, `compute_all()` method + # has to be called to retrieve the subtotal without them. + # `price_reduce_taxexcl` cannot be used as it is computed from `price_subtotal` field. (see upper Note) + price_subtotal = line.tax_id.compute_all( + line.price_reduce, + currency=line.order_id.currency_id, + quantity=umo_qty_to_consider, + product=line.product_id, + partner=line.order_id.partner_shipping_id)['total_excluded'] + + if any(line.invoice_lines.mapped(lambda l: l.discount != line.discount)): + # In case of re-invoicing with different discount we try to calculate manually the + # remaining amount to invoice + amount = 0 + for l in line.invoice_lines: + if len(l.tax_ids.filtered(lambda tax: tax.price_include)) > 0: + amount += l.tax_ids.compute_all(l.currency_id._convert(l.price_unit, line.currency_id, line.company_id, l.date or fields.Date.today(), round=False) * l.quantity)['total_excluded'] + else: + amount += l.currency_id._convert(l.price_unit, line.currency_id, line.company_id, l.date or fields.Date.today(), round=False) * l.quantity + + amount_to_invoice = max(price_subtotal - amount, 0) + else: + amount_to_invoice = price_subtotal - line.untaxed_amount_invoiced + + line.untaxed_amount_to_invoice = amount_to_invoice + + def _get_invoice_line_sequence(self, new=0, old=0): + """ + Method intended to be overridden in third-party module if we want to prevent the resequencing + of invoice lines. + + :param int new: the new line sequence + :param int old: the old line sequence + + :return: the sequence of the SO line, by default the new one. + """ + return new or old + + def _prepare_invoice_line(self, **optional_values): + """ + Prepare the dict of values to create the new invoice line for a sales order line. + + :param qty: float quantity to invoice + :param optional_values: any parameter that should be added to the returned invoice line + """ + self.ensure_one() + res = { + 'display_type': self.display_type, + 'sequence': self.sequence, + 'name': self.name, + 'product_id': self.product_id.id, + 'product_uom_id': self.product_uom.id, + 'quantity': self.qty_to_invoice, + 'discount': self.discount, + 'price_unit': self.price_unit, + 'tax_ids': [(6, 0, self.tax_id.ids)], + 'analytic_account_id': self.order_id.analytic_account_id.id, + 'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)], + 'sale_line_ids': [(4, self.id)], + } + if optional_values: + res.update(optional_values) + if self.display_type: + res['account_id'] = False + return res + + def _prepare_procurement_values(self, group_id=False): + """ Prepare specific key for moves or other components that will be created from a stock rule + comming from a sale order line. This method could be override in order to add other custom key that could + be used in move/po creation. + """ + return {} + + def _get_display_price(self, product): + # 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 + + # 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=tuple(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, uom=self.product_uom.id).price + product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id) + + final_price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(product or self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) + base_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) + if currency != self.order_id.pricelist_id.currency_id: + base_price = currency._convert( + base_price, self.order_id.pricelist_id.currency_id, + self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) + # negative discounts (= surcharge) are included in the display price + return max(base_price, final_price) + + @api.onchange('product_id') + def product_id_change(self): + if not self.product_id: + return + valid_values = self.product_id.product_tmpl_id.valid_product_template_attribute_line_ids.product_template_value_ids + # remove the is_custom values that don't belong to this template + for pacv in self.product_custom_attribute_value_ids: + if pacv.custom_product_template_attribute_value_id not in valid_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._origin not in valid_values: + self.product_no_variant_attribute_value_ids -= ptav + + vals = {} + if not self.product_uom or (self.product_id.uom_id.id != self.product_uom.id): + vals['product_uom'] = self.product_id.uom_id + vals['product_uom_qty'] = self.product_uom_qty or 1.0 + + product = self.product_id.with_context( + lang=get_lang(self.env, self.order_id.partner_id.lang).code, + 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, + uom=self.product_uom.id + ) + + vals.update(name=self.get_sale_order_line_multiline_description_sale(product)) + + self._compute_tax_id() + + if self.order_id.pricelist_id and self.order_id.partner_id: + vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id) + self.update(vals) + + title = False + message = False + result = {} + warning = {} + if product.sale_line_warn != 'no-message': + title = _("Warning for %s", product.name) + message = product.sale_line_warn_msg + warning['title'] = title + warning['message'] = message + result = {'warning': warning} + if product.sale_line_warn == 'block': + self.product_id = False + + return result + + @api.onchange('product_uom', 'product_uom_qty') + def product_uom_change(self): + if not self.product_uom or not self.product_id: + self.price_unit = 0.0 + return + 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, + quantity=self.product_uom_qty, + date=self.order_id.date_order, + pricelist=self.order_id.pricelist_id.id, + uom=self.product_uom.id, + fiscal_position=self.env.context.get('fiscal_position') + ) + self.price_unit = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id) + + def name_get(self): + result = [] + for so_line in self.sudo(): + name = '%s - %s' % (so_line.order_id.name, so_line.name and so_line.name.split('\n')[0] or so_line.product_id.name) + if so_line.order_partner_id.ref: + name = '%s (%s)' % (name, so_line.order_partner_id.ref) + result.append((so_line.id, name)) + return result + + @api.model + def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): + if operator in ('ilike', 'like', '=', '=like', '=ilike'): + args = expression.AND([ + args or [], + ['|', ('order_id.name', operator, name), ('name', operator, name)] + ]) + return super(SaleOrderLine, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) + + def _check_line_unlink(self): + """ + Check wether a line can be deleted or not. + + Lines cannot be deleted if the order is confirmed; downpayment + lines who have not yet been invoiced bypass that exception. + :rtype: recordset sale.order.line + :returns: set of lines that cannot be deleted + """ + return self.filtered(lambda line: line.state in ('sale', 'done') and (line.invoice_lines or not line.is_downpayment)) + + @api.ondelete(at_uninstall=False) + def _unlink_except_confirmed(self): + if self._check_line_unlink(): + raise UserError(_('You can not remove an order line once the sales order is confirmed.\nYou should rather set the quantity to 0.')) + + def _get_real_price_currency(self, product, rule_id, qty, uom, pricelist_id): + """Retrieve the price before applying the pricelist + :param obj product: object of current product record + :parem float qty: total quentity of product + :param tuple price_and_rule: tuple(price, suitable_rule) coming from pricelist computation + :param obj uom: unit of measure of current order line + :param integer pricelist_id: pricelist id of sales order""" + PricelistItem = self.env['product.pricelist.item'] + field_name = 'lst_price' + currency_id = None + product_currency = product.currency_id + if rule_id: + pricelist_item = PricelistItem.browse(rule_id) + if pricelist_item.pricelist_id.discount_policy == 'without_discount': + while pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id and pricelist_item.base_pricelist_id.discount_policy == 'without_discount': + price, rule_id = pricelist_item.base_pricelist_id.with_context(uom=uom.id).get_product_price_rule(product, qty, self.order_id.partner_id) + pricelist_item = PricelistItem.browse(rule_id) + + if pricelist_item.base == 'standard_price': + field_name = 'standard_price' + product_currency = product.cost_currency_id + elif pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id: + field_name = 'price' + product = product.with_context(pricelist=pricelist_item.base_pricelist_id.id) + product_currency = pricelist_item.base_pricelist_id.currency_id + currency_id = pricelist_item.pricelist_id.currency_id + + if not currency_id: + currency_id = product_currency + cur_factor = 1.0 + else: + if currency_id.id == product_currency.id: + cur_factor = 1.0 + else: + cur_factor = currency_id._get_conversion_rate(product_currency, currency_id, self.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) + + product_uom = self.env.context.get('uom') or product.uom_id.id + if uom and uom.id != product_uom: + # the unit price is in a different uom + uom_factor = uom._compute_price(1.0, product.uom_id) + else: + uom_factor = 1.0 + + return product[field_name] * uom_factor * cur_factor, currency_id + + def _get_protected_fields(self): + return [ + 'product_id', 'name', 'price_unit', 'product_uom', 'product_uom_qty', + 'tax_id', 'analytic_tag_ids' + ] + + def _onchange_product_id_set_customer_lead(self): + pass + + @api.onchange('product_id', 'price_unit', 'product_uom', 'product_uom_qty', 'tax_id') + def _onchange_discount(self): + if not (self.product_id and self.product_uom and + self.order_id.partner_id and self.order_id.pricelist_id and + self.order_id.pricelist_id.discount_policy == 'without_discount' and + self.env.user.has_group('product.group_discount_per_so_line')): + return + + self.discount = 0.0 + product = self.product_id.with_context( + lang=self.order_id.partner_id.lang, + partner=self.order_id.partner_id, + quantity=self.product_uom_qty, + date=self.order_id.date_order, + pricelist=self.order_id.pricelist_id.id, + uom=self.product_uom.id, + fiscal_position=self.env.context.get('fiscal_position') + ) + + product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id) + + price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) + new_list_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) + + if new_list_price != 0: + if self.order_id.pricelist_id.currency_id != currency: + # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency + new_list_price = currency._convert( + new_list_price, self.order_id.pricelist_id.currency_id, + self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) + discount = (new_list_price - price) / new_list_price * 100 + if (discount > 0 and new_list_price > 0) or (discount < 0 and new_list_price < 0): + self.discount = discount + + def _is_delivery(self): + self.ensure_one() + return False + + def get_sale_order_line_multiline_description_sale(self, product): + """ Compute a default multiline description for this sales order line. + + In most cases the product description is enough but sometimes we need to append information that only + exists on the sale order line itself. + e.g: + - custom attributes and attributes that don't create variants, both introduced by the "product configurator" + - 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() + 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. + + :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" + + custom_ptavs = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id + no_variant_ptavs = self.product_no_variant_attribute_value_ids._origin + + # display the no_variant attributes, except those that are also + # displayed by a custom (avoid duplicate description) + for ptav in (no_variant_ptavs - custom_ptavs): + name += "\n" + ptav.with_context(lang=self.order_id.partner_id.lang).display_name + + # Sort the values according to _order settings, because it doesn't work for virtual records in onchange + custom_values = sorted(self.product_custom_attribute_value_ids, key=lambda r: (r.custom_product_template_attribute_value_id.id, r.id)) + # display the is_custom values + for pacv in custom_values: + name += "\n" + pacv.with_context(lang=self.order_id.partner_id.lang).display_name + + return name + + def _is_not_sellable_line(self): + # True if the line is a computed line (reward, delivery, ...) that user cannot add manually + return False From 611f6447d9ce6446fc562cfa7152293cf92c3864 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Tue, 18 May 2021 14:54:46 +0000 Subject: [PATCH 08/12] [MOV] sale: split sale.py (2) --- addons/sale/models/sale_order.py | 818 ------------------- addons/sale/models/sale_order_line.py | 1045 ------------------------- 2 files changed, 1863 deletions(-) diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index 0aa6cf8e803..4553b9430f1 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -1058,821 +1058,3 @@ Reason(s) of this behavior could be: def add_option_to_order_with_taxcloud(self): self.ensure_one() - - -class SaleOrderLine(models.Model): - _name = 'sale.order.line' - _description = 'Sales Order Line' - _order = 'order_id, sequence, id' - _check_company_auto = True - - @api.depends('state', 'product_uom_qty', 'qty_delivered', 'qty_to_invoice', 'qty_invoiced') - def _compute_invoice_status(self): - """ - Compute the invoice status of a SO line. Possible statuses: - - no: if the SO is not in status 'sale' or 'done', we consider that there is nothing to - invoice. This is also hte default value if the conditions of no other status is met. - - to invoice: we refer to the quantity to invoice of the line. Refer to method - `_get_to_invoice_qty()` for more information on how this quantity is calculated. - - upselling: this is possible only for a product invoiced on ordered quantities for which - we delivered more than expected. The could arise if, for example, a project took more - time than expected but we decided not to invoice the extra cost to the client. This - occurs onyl in state 'sale', so that when a SO is set to done, the upselling opportunity - is removed from the list. - - invoiced: the quantity invoiced is larger or equal to the quantity ordered. - """ - precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') - for line in self: - if line.state not in ('sale', 'done'): - line.invoice_status = 'no' - elif line.is_downpayment and line.untaxed_amount_to_invoice == 0: - line.invoice_status = 'invoiced' - elif not float_is_zero(line.qty_to_invoice, precision_digits=precision): - line.invoice_status = 'to invoice' - elif line.state == 'sale' and line.product_id.invoice_policy == 'order' and\ - float_compare(line.qty_delivered, line.product_uom_qty, precision_digits=precision) == 1: - line.invoice_status = 'upselling' - elif float_compare(line.qty_invoiced, line.product_uom_qty, precision_digits=precision) >= 0: - line.invoice_status = 'invoiced' - else: - line.invoice_status = 'no' - - def _expected_date(self): - self.ensure_one() - order_date = fields.Datetime.from_string(self.order_id.date_order if self.order_id.date_order and self.order_id.state in ['sale', 'done'] else fields.Datetime.now()) - return order_date + timedelta(days=self.customer_lead or 0.0) - - @api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_id') - def _compute_amount(self): - """ - Compute the amounts of the SO line. - """ - for line in self: - price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) - taxes = line.tax_id.compute_all(price, line.order_id.currency_id, line.product_uom_qty, product=line.product_id, partner=line.order_id.partner_shipping_id) - line.update({ - 'price_tax': sum(t.get('amount', 0.0) for t in taxes.get('taxes', [])), - 'price_total': taxes['total_included'], - 'price_subtotal': taxes['total_excluded'], - }) - if self.env.context.get('import_file', False) and not self.env.user.user_has_groups('account.group_account_manager'): - line.tax_id.invalidate_cache(['invoice_repartition_line_ids'], [line.tax_id.id]) - - @api.depends('product_id', 'order_id.state', 'qty_invoiced', 'qty_delivered') - def _compute_product_updatable(self): - for line in self: - if line.state in ['done', 'cancel'] or (line.state == 'sale' and (line.qty_invoiced > 0 or line.qty_delivered > 0)): - line.product_updatable = False - else: - line.product_updatable = True - - # no trigger product_id.invoice_policy to avoid retroactively changing SO - @api.depends('qty_invoiced', 'qty_delivered', 'product_uom_qty', 'order_id.state') - def _get_to_invoice_qty(self): - """ - Compute the quantity to invoice. If the invoice policy is order, the quantity to invoice is - calculated from the ordered quantity. Otherwise, the quantity delivered is used. - """ - for line in self: - if line.order_id.state in ['sale', 'done']: - if line.product_id.invoice_policy == 'order': - line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced - else: - line.qty_to_invoice = line.qty_delivered - line.qty_invoiced - else: - line.qty_to_invoice = 0 - - @api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity', 'untaxed_amount_to_invoice') - def _get_invoice_qty(self): - """ - Compute the quantity invoiced. If case of a refund, the quantity invoiced is decreased. Note - that this is the case only if the refund is generated from the SO and that is intentional: if - a refund made would automatically decrease the invoiced quantity, then there is a risk of reinvoicing - it automatically, which may not be wanted at all. That's why the refund has to be created from the SO - """ - for line in self: - qty_invoiced = 0.0 - for invoice_line in line.invoice_lines: - if invoice_line.move_id.state != 'cancel': - if invoice_line.move_id.move_type == 'out_invoice': - qty_invoiced += invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) - elif invoice_line.move_id.move_type == 'out_refund': - if not line.is_downpayment or line.untaxed_amount_to_invoice == 0 : - qty_invoiced -= invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) - line.qty_invoiced = qty_invoiced - - @api.depends('price_unit', 'discount') - def _get_price_reduce(self): - for line in self: - line.price_reduce = line.price_unit * (1.0 - line.discount / 100.0) - - @api.depends('price_total', 'product_uom_qty') - def _get_price_reduce_tax(self): - for line in self: - line.price_reduce_taxinc = line.price_total / line.product_uom_qty if line.product_uom_qty else 0.0 - - @api.depends('price_subtotal', 'product_uom_qty') - def _get_price_reduce_notax(self): - for line in self: - line.price_reduce_taxexcl = line.price_subtotal / line.product_uom_qty if line.product_uom_qty else 0.0 - - def _compute_tax_id(self): - for line in self: - line = line.with_company(line.company_id) - fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id.get_fiscal_position(line.order_partner_id.id) - # If company_id is set, always filter taxes by the company - taxes = line.product_id.taxes_id.filtered(lambda t: t.company_id == line.env.company) - line.tax_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_shipping_id) - - @api.model - def _prepare_add_missing_fields(self, values): - """ Deduce missing required fields from the onchange """ - res = {} - onchange_fields = ['name', 'price_unit', 'product_uom', 'tax_id'] - if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields): - line = self.new(values) - line.product_id_change() - for field in onchange_fields: - if field not in values: - res[field] = line._fields[field].convert_to_write(line[field], line) - return res - - @api.model_create_multi - def create(self, vals_list): - for values in vals_list: - if values.get('display_type', self.default_get(['display_type'])['display_type']): - values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0) - - values.update(self._prepare_add_missing_fields(values)) - - lines = super().create(vals_list) - for line in lines: - if line.product_id and line.order_id.state == 'sale': - msg = _("Extra line with %s ") % (line.product_id.display_name,) - line.order_id.message_post(body=msg) - # create an analytic account if at least an expense product - if line.product_id.expense_policy not in [False, 'no'] and not line.order_id.analytic_account_id: - line.order_id._create_analytic_account() - return lines - - _sql_constraints = [ - ('accountable_required_fields', - "CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom IS NOT NULL))", - "Missing required fields on accountable sale order line."), - ('non_accountable_null_fields', - "CHECK(display_type IS NULL OR (product_id IS NULL AND price_unit = 0 AND product_uom_qty = 0 AND product_uom IS NULL AND customer_lead = 0))", - "Forbidden values on non-accountable sale order line"), - ] - - def _update_line_quantity(self, values): - orders = self.mapped('order_id') - for order in orders: - order_lines = self.filtered(lambda x: x.order_id == order) - msg = "" + _("The ordered quantity has been updated.") + "
    " - for line in order_lines: - msg += "
  • %s:
    " % line.product_id.display_name - msg += _( - "Ordered Quantity: %(old_qty)s -> %(new_qty)s", - old_qty=line.product_uom_qty, - new_qty=values["product_uom_qty"] - ) + "
    " - if line.product_id.type in ('consu', 'product'): - msg += _("Delivered Quantity: %s", line.qty_delivered) + "
    " - msg += _("Invoiced Quantity: %s", line.qty_invoiced) + "
    " - msg += "
" - order.message_post(body=msg) - - def write(self, values): - if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')): - raise UserError(_("You cannot change the type of a sale order line. Instead you should delete the current line and create a new line of the proper type.")) - - if 'product_uom_qty' in values: - precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') - self.filtered( - lambda r: r.state == 'sale' and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) != 0)._update_line_quantity(values) - - # Prevent writing on a locked SO. - protected_fields = self._get_protected_fields() - if 'done' in self.mapped('order_id.state') and any(f in values.keys() for f in protected_fields): - protected_fields_modified = list(set(protected_fields) & set(values.keys())) - fields = self.env['ir.model.fields'].search([ - ('name', 'in', protected_fields_modified), ('model', '=', self._name) - ]) - raise UserError( - _('It is forbidden to modify the following fields in a locked order:\n%s') - % '\n'.join(fields.mapped('field_description')) - ) - - 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) - name = fields.Text(string='Description', required=True) - sequence = fields.Integer(string='Sequence', default=10) - - invoice_lines = fields.Many2many('account.move.line', 'sale_order_line_invoice_rel', 'order_line_id', 'invoice_line_id', string='Invoice Lines', copy=False) - invoice_status = fields.Selection([ - ('upselling', 'Upselling Opportunity'), - ('invoiced', 'Fully Invoiced'), - ('to invoice', 'To Invoice'), - ('no', 'Nothing to Invoice') - ], string='Invoice Status', compute='_compute_invoice_status', store=True, readonly=True, default='no') - price_unit = fields.Float('Unit Price', required=True, digits='Product Price', default=0.0) - - price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', readonly=True, store=True) - price_tax = fields.Float(compute='_compute_amount', string='Total Tax', readonly=True, store=True) - price_total = fields.Monetary(compute='_compute_amount', string='Total', readonly=True, store=True) - - price_reduce = fields.Float(compute='_get_price_reduce', string='Price Reduce', digits='Product Price', readonly=True, store=True) - tax_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) - price_reduce_taxinc = fields.Monetary(compute='_get_price_reduce_tax', string='Price Reduce Tax inc', readonly=True, store=True) - price_reduce_taxexcl = fields.Monetary(compute='_get_price_reduce_notax', string='Price Reduce Tax excl', readonly=True, store=True) - - discount = fields.Float(string='Discount (%)', digits='Discount', default=0.0) - - product_id = fields.Many2one( - 'product.product', string='Product', domain="[('sale_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", - change_default=True, ondelete='restrict', check_company=True) # Unrequired company - product_template_id = fields.Many2one( - 'product.template', string='Product Template', - related="product_id.product_tmpl_id", domain=[('sale_ok', '=', True)]) - product_updatable = fields.Boolean(compute='_compute_product_updatable', string='Can Edit Product', readonly=True, default=True) - product_uom_qty = fields.Float(string='Quantity', digits='Product Unit of Measure', required=True, default=1.0) - product_uom = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]", ondelete="restrict") - product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', readonly=True) - product_uom_readonly = fields.Boolean(compute='_compute_product_uom_readonly') - product_custom_attribute_value_ids = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string="Custom Values", copy=True) - - # 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 - product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string="Extra Values", ondelete='restrict') - - qty_delivered_method = fields.Selection([ - ('manual', 'Manual'), - ('analytic', 'Analytic From Expenses') - ], string="Method to update delivered qty", compute='_compute_qty_delivered_method', compute_sudo=True, store=True, readonly=True, - help="According to product configuration, the delivered quantity can be automatically computed by mechanism :\n" - " - Manual: the quantity is set manually on the line\n" - " - Analytic From expenses: the quantity is the quantity sum from posted expenses\n" - " - Timesheet: the quantity is the sum of hours recorded on tasks linked to this sale line\n" - " - Stock Moves: the quantity comes from confirmed pickings\n") - qty_delivered = fields.Float('Delivered Quantity', copy=False, compute='_compute_qty_delivered', inverse='_inverse_qty_delivered', compute_sudo=True, store=True, digits='Product Unit of Measure', default=0.0) - qty_delivered_manual = fields.Float('Delivered Manually', copy=False, digits='Product Unit of Measure', default=0.0) - qty_to_invoice = fields.Float( - compute='_get_to_invoice_qty', string='To Invoice Quantity', store=True, readonly=True, - digits='Product Unit of Measure') - qty_invoiced = fields.Float( - compute='_get_invoice_qty', string='Invoiced Quantity', store=True, readonly=True, - compute_sudo=True, - digits='Product Unit of Measure') - - untaxed_amount_invoiced = fields.Monetary("Untaxed Invoiced Amount", compute='_compute_untaxed_amount_invoiced', compute_sudo=True, store=True) - untaxed_amount_to_invoice = fields.Monetary("Untaxed Amount To Invoice", compute='_compute_untaxed_amount_to_invoice', compute_sudo=True, store=True) - - salesman_id = fields.Many2one(related='order_id.user_id', store=True, string='Salesperson', readonly=True) - currency_id = fields.Many2one(related='order_id.currency_id', depends=['order_id.currency_id'], store=True, string='Currency', readonly=True) - company_id = fields.Many2one(related='order_id.company_id', string='Company', store=True, readonly=True, index=True) - order_partner_id = fields.Many2one(related='order_id.partner_id', store=True, string='Customer', readonly=False) - analytic_tag_ids = fields.Many2many( - 'account.analytic.tag', string='Analytic Tags', - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") - analytic_line_ids = fields.One2many('account.analytic.line', 'so_line', string="Analytic lines") - is_expense = fields.Boolean('Is expense', help="Is true if the sales order line comes from an expense or a vendor bills") - is_downpayment = fields.Boolean( - string="Is a down payment", help="Down payments are made when creating invoices from a sales order." - " They are not copied when duplicating a sales order.") - - state = fields.Selection( - related='order_id.state', string='Order Status', readonly=True, copy=False, store=True, default='draft') - - customer_lead = fields.Float( - 'Lead Time', required=True, default=0.0, - help="Number of days between the order confirmation and the shipping of the products to the customer") - - display_type = fields.Selection([ - ('line_section', "Section"), - ('line_note', "Note")], default=False, help="Technical field for UX purpose.") - - product_packaging_id = fields.Many2one('product.packaging', string='Packaging', default=False, domain="[('sales', '=', True), ('product_id','=',product_id)]", check_company=True) - product_packaging_qty = fields.Float('Packaging Quantity') - - @api.depends('state') - def _compute_product_uom_readonly(self): - for line in self: - line.product_uom_readonly = line.state in ['sale', 'done', 'cancel'] - - @api.depends('state', 'is_expense') - def _compute_qty_delivered_method(self): - """ Sale module compute delivered qty for product [('type', 'in', ['consu']), ('service_type', '=', 'manual')] - - consu + expense_policy : analytic (sum of analytic unit_amount) - - consu + no expense_policy : manual (set manually on SOL) - - service (+ service_type='manual', the only available option) : manual - - This is true when only sale is installed: sale_stock redifine the behavior for 'consu' type, - and sale_timesheet implements the behavior of 'service' + service_type=timesheet. - """ - for line in self: - if line.is_expense: - line.qty_delivered_method = 'analytic' - else: # service and consu - line.qty_delivered_method = 'manual' - - @api.depends('qty_delivered_method', 'qty_delivered_manual', 'analytic_line_ids.so_line', 'analytic_line_ids.unit_amount', 'analytic_line_ids.product_uom_id') - def _compute_qty_delivered(self): - """ This method compute the delivered quantity of the SO lines: it covers the case provide by sale module, aka - expense/vendor bills (sum of unit_amount of AAL), and manual case. - This method should be overridden to provide other way to automatically compute delivered qty. Overrides should - take their concerned so lines, compute and set the `qty_delivered` field, and call super with the remaining - records. - """ - # compute for analytic lines - lines_by_analytic = self.filtered(lambda sol: sol.qty_delivered_method == 'analytic') - mapping = lines_by_analytic._get_delivered_quantity_by_analytic([('amount', '<=', 0.0)]) - for so_line in lines_by_analytic: - so_line.qty_delivered = mapping.get(so_line.id or so_line._origin.id, 0.0) - # compute for manual lines - for line in self: - if line.qty_delivered_method == 'manual': - line.qty_delivered = line.qty_delivered_manual or 0.0 - - def _get_delivered_quantity_by_analytic(self, additional_domain): - """ Compute and write the delivered quantity of current SO lines, based on their related - analytic lines. - :param additional_domain: domain to restrict AAL to include in computation (required since timesheet is an AAL with a project ...) - """ - result = {} - - # avoid recomputation if no SO lines concerned - if not self: - return result - - # group analytic lines by product uom and so line - domain = expression.AND([[('so_line', 'in', self.ids)], additional_domain]) - data = self.env['account.analytic.line'].read_group( - domain, - ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False - ) - - # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines - # browse so lines and product uoms here to make them share the same prefetch - lines = self.browse([item['so_line'][0] for item in data]) - lines_map = {line.id: line for line in lines} - product_uom_ids = [item['product_uom_id'][0] for item in data if item['product_uom_id']] - product_uom_map = {uom.id: uom for uom in self.env['uom.uom'].browse(product_uom_ids)} - for item in data: - if not item['product_uom_id']: - continue - so_line_id = item['so_line'][0] - so_line = lines_map[so_line_id] - result.setdefault(so_line_id, 0.0) - uom = product_uom_map.get(item['product_uom_id'][0]) - if so_line.product_uom.category_id == uom.category_id: - qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom, rounding_method='HALF-UP') - else: - qty = item['unit_amount'] - result[so_line_id] += qty - - return result - - @api.onchange('qty_delivered') - def _inverse_qty_delivered(self): - """ When writing on qty_delivered, if the value should be modify manually (`qty_delivered_method` = 'manual' only), - then we put the value in `qty_delivered_manual`. Otherwise, `qty_delivered_manual` should be False since the - delivered qty is automatically compute by other mecanisms. - """ - for line in self: - if line.qty_delivered_method == 'manual': - line.qty_delivered_manual = line.qty_delivered - else: - line.qty_delivered_manual = 0.0 - - @api.onchange('product_id', 'product_uom_qty', 'product_uom') - def _onchange_suggest_packaging(self): - # remove packaging if not match the product - if self.product_packaging_id.product_id != self.product_id: - self.product_packaging_id = False - # suggest biggest suitable packaging - if self.product_id and self.product_uom_qty and self.product_uom: - self.product_packaging_id = self.product_id.packaging_ids.filtered('sales')._find_suitable_product_packaging(self.product_uom_qty, self.product_uom) - - @api.onchange('product_packaging_id') - def _onchange_product_packaging_id(self): - if self.product_packaging_id and self.product_uom_qty: - newqty = self.product_packaging_id._check_qty(self.product_uom_qty, self.product_uom, "UP") - if float_compare(newqty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0: - return { - 'warning': { - 'title': _('Warning'), - 'message': _( - "This product is packaged by %(pack_size).2f %(pack_name)s. You should sell %(quantity).2f %(unit)s.", - pack_size=self.product_packaging_id.qty, - pack_name=self.product_id.uom_id.name, - quantity=newqty, - unit=self.product_uom.name - ), - }, - } - - @api.onchange('product_packaging_id', 'product_uom', 'product_uom_qty') - def _onchange_update_product_packaging_qty(self): - if not self.product_packaging_id: - self.product_packaging_qty = False - else: - packaging_uom = self.product_packaging_id.product_uom_id - packaging_uom_qty = self.product_uom._compute_quantity(self.product_uom_qty, packaging_uom) - self.product_packaging_qty = float_round(packaging_uom_qty / self.product_packaging_id.qty, precision_rounding=packaging_uom.rounding) - - @api.onchange('product_packaging_qty') - def _onchange_product_packaging_qty(self): - if self.product_packaging_id: - packaging_uom = self.product_packaging_id.product_uom_id - qty_per_packaging = self.product_packaging_id.qty - product_uom_qty = packaging_uom._compute_quantity(self.product_packaging_qty * qty_per_packaging, self.product_uom) - if float_compare(product_uom_qty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0: - self.product_uom_qty = product_uom_qty - - @api.depends('invoice_lines', 'invoice_lines.price_total', 'invoice_lines.move_id.state', 'invoice_lines.move_id.move_type') - def _compute_untaxed_amount_invoiced(self): - """ Compute the untaxed amount already invoiced from the sale order line, taking the refund attached - the so line into account. This amount is computed as - SUM(inv_line.price_subtotal) - SUM(ref_line.price_subtotal) - where - `inv_line` is a customer invoice line linked to the SO line - `ref_line` is a customer credit note (refund) line linked to the SO line - """ - for line in self: - amount_invoiced = 0.0 - for invoice_line in line.invoice_lines: - if invoice_line.move_id.state == 'posted': - invoice_date = invoice_line.move_id.invoice_date or fields.Date.today() - if invoice_line.move_id.move_type == 'out_invoice': - amount_invoiced += invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date) - elif invoice_line.move_id.move_type == 'out_refund': - amount_invoiced -= invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date) - line.untaxed_amount_invoiced = amount_invoiced - - @api.depends('state', 'price_reduce', 'product_id', 'untaxed_amount_invoiced', 'qty_delivered', 'product_uom_qty') - def _compute_untaxed_amount_to_invoice(self): - """ Total of remaining amount to invoice on the sale order line (taxes excl.) as - total_sol - amount already invoiced - where Total_sol depends on the invoice policy of the product. - - Note: Draft invoice are ignored on purpose, the 'to invoice' amount should - come only from the SO lines. - """ - for line in self: - amount_to_invoice = 0.0 - if line.state in ['sale', 'done']: - # Note: do not use price_subtotal field as it returns zero when the ordered quantity is - # zero. It causes problem for expense line (e.i.: ordered qty = 0, deli qty = 4, - # price_unit = 20 ; subtotal is zero), but when you can invoice the line, you see an - # amount and not zero. Since we compute untaxed amount, we can use directly the price - # reduce (to include discount) without using `compute_all()` method on taxes. - price_subtotal = 0.0 - umo_qty_to_consider = line.qty_delivered if line.product_id.invoice_policy == 'delivery' else line.product_uom_qty - price_subtotal = line.price_reduce * umo_qty_to_consider - if len(line.tax_id.filtered(lambda tax: tax.price_include)) > 0: - # As included taxes are not excluded from the computed subtotal, `compute_all()` method - # has to be called to retrieve the subtotal without them. - # `price_reduce_taxexcl` cannot be used as it is computed from `price_subtotal` field. (see upper Note) - price_subtotal = line.tax_id.compute_all( - line.price_reduce, - currency=line.order_id.currency_id, - quantity=umo_qty_to_consider, - product=line.product_id, - partner=line.order_id.partner_shipping_id)['total_excluded'] - - if any(line.invoice_lines.mapped(lambda l: l.discount != line.discount)): - # In case of re-invoicing with different discount we try to calculate manually the - # remaining amount to invoice - amount = 0 - for l in line.invoice_lines: - if len(l.tax_ids.filtered(lambda tax: tax.price_include)) > 0: - amount += l.tax_ids.compute_all(l.currency_id._convert(l.price_unit, line.currency_id, line.company_id, l.date or fields.Date.today(), round=False) * l.quantity)['total_excluded'] - else: - amount += l.currency_id._convert(l.price_unit, line.currency_id, line.company_id, l.date or fields.Date.today(), round=False) * l.quantity - - amount_to_invoice = max(price_subtotal - amount, 0) - else: - amount_to_invoice = price_subtotal - line.untaxed_amount_invoiced - - line.untaxed_amount_to_invoice = amount_to_invoice - - def _get_invoice_line_sequence(self, new=0, old=0): - """ - Method intended to be overridden in third-party module if we want to prevent the resequencing - of invoice lines. - - :param int new: the new line sequence - :param int old: the old line sequence - - :return: the sequence of the SO line, by default the new one. - """ - return new or old - - def _prepare_invoice_line(self, **optional_values): - """ - Prepare the dict of values to create the new invoice line for a sales order line. - - :param qty: float quantity to invoice - :param optional_values: any parameter that should be added to the returned invoice line - """ - self.ensure_one() - res = { - 'display_type': self.display_type, - 'sequence': self.sequence, - 'name': self.name, - 'product_id': self.product_id.id, - 'product_uom_id': self.product_uom.id, - 'quantity': self.qty_to_invoice, - 'discount': self.discount, - 'price_unit': self.price_unit, - 'tax_ids': [(6, 0, self.tax_id.ids)], - 'analytic_account_id': self.order_id.analytic_account_id.id, - 'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)], - 'sale_line_ids': [(4, self.id)], - } - if optional_values: - res.update(optional_values) - if self.display_type: - res['account_id'] = False - return res - - def _prepare_procurement_values(self, group_id=False): - """ Prepare specific key for moves or other components that will be created from a stock rule - comming from a sale order line. This method could be override in order to add other custom key that could - be used in move/po creation. - """ - return {} - - def _get_display_price(self, product): - # 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 - - # 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=tuple(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, uom=self.product_uom.id).price - product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id) - - final_price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(product or self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) - base_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) - if currency != self.order_id.pricelist_id.currency_id: - base_price = currency._convert( - base_price, self.order_id.pricelist_id.currency_id, - self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) - # negative discounts (= surcharge) are included in the display price - return max(base_price, final_price) - - @api.onchange('product_id') - def product_id_change(self): - if not self.product_id: - return - valid_values = self.product_id.product_tmpl_id.valid_product_template_attribute_line_ids.product_template_value_ids - # remove the is_custom values that don't belong to this template - for pacv in self.product_custom_attribute_value_ids: - if pacv.custom_product_template_attribute_value_id not in valid_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._origin not in valid_values: - self.product_no_variant_attribute_value_ids -= ptav - - vals = {} - if not self.product_uom or (self.product_id.uom_id.id != self.product_uom.id): - vals['product_uom'] = self.product_id.uom_id - vals['product_uom_qty'] = self.product_uom_qty or 1.0 - - product = self.product_id.with_context( - lang=get_lang(self.env, self.order_id.partner_id.lang).code, - 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, - uom=self.product_uom.id - ) - - vals.update(name=self.get_sale_order_line_multiline_description_sale(product)) - - self._compute_tax_id() - - if self.order_id.pricelist_id and self.order_id.partner_id: - vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id) - self.update(vals) - - title = False - message = False - result = {} - warning = {} - if product.sale_line_warn != 'no-message': - title = _("Warning for %s", product.name) - message = product.sale_line_warn_msg - warning['title'] = title - warning['message'] = message - result = {'warning': warning} - if product.sale_line_warn == 'block': - self.product_id = False - - return result - - @api.onchange('product_uom', 'product_uom_qty') - def product_uom_change(self): - if not self.product_uom or not self.product_id: - self.price_unit = 0.0 - return - 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, - quantity=self.product_uom_qty, - date=self.order_id.date_order, - pricelist=self.order_id.pricelist_id.id, - uom=self.product_uom.id, - fiscal_position=self.env.context.get('fiscal_position') - ) - self.price_unit = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id) - - def name_get(self): - result = [] - for so_line in self.sudo(): - name = '%s - %s' % (so_line.order_id.name, so_line.name and so_line.name.split('\n')[0] or so_line.product_id.name) - if so_line.order_partner_id.ref: - name = '%s (%s)' % (name, so_line.order_partner_id.ref) - result.append((so_line.id, name)) - return result - - @api.model - def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): - if operator in ('ilike', 'like', '=', '=like', '=ilike'): - args = expression.AND([ - args or [], - ['|', ('order_id.name', operator, name), ('name', operator, name)] - ]) - return super(SaleOrderLine, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) - - def _check_line_unlink(self): - """ - Check wether a line can be deleted or not. - - Lines cannot be deleted if the order is confirmed; downpayment - lines who have not yet been invoiced bypass that exception. - :rtype: recordset sale.order.line - :returns: set of lines that cannot be deleted - """ - return self.filtered(lambda line: line.state in ('sale', 'done') and (line.invoice_lines or not line.is_downpayment)) - - @api.ondelete(at_uninstall=False) - def _unlink_except_confirmed(self): - if self._check_line_unlink(): - raise UserError(_('You can not remove an order line once the sales order is confirmed.\nYou should rather set the quantity to 0.')) - - def _get_real_price_currency(self, product, rule_id, qty, uom, pricelist_id): - """Retrieve the price before applying the pricelist - :param obj product: object of current product record - :parem float qty: total quentity of product - :param tuple price_and_rule: tuple(price, suitable_rule) coming from pricelist computation - :param obj uom: unit of measure of current order line - :param integer pricelist_id: pricelist id of sales order""" - PricelistItem = self.env['product.pricelist.item'] - field_name = 'lst_price' - currency_id = None - product_currency = product.currency_id - if rule_id: - pricelist_item = PricelistItem.browse(rule_id) - if pricelist_item.pricelist_id.discount_policy == 'without_discount': - while pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id and pricelist_item.base_pricelist_id.discount_policy == 'without_discount': - price, rule_id = pricelist_item.base_pricelist_id.with_context(uom=uom.id).get_product_price_rule(product, qty, self.order_id.partner_id) - pricelist_item = PricelistItem.browse(rule_id) - - if pricelist_item.base == 'standard_price': - field_name = 'standard_price' - product_currency = product.cost_currency_id - elif pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id: - field_name = 'price' - product = product.with_context(pricelist=pricelist_item.base_pricelist_id.id) - product_currency = pricelist_item.base_pricelist_id.currency_id - currency_id = pricelist_item.pricelist_id.currency_id - - if not currency_id: - currency_id = product_currency - cur_factor = 1.0 - else: - if currency_id.id == product_currency.id: - cur_factor = 1.0 - else: - cur_factor = currency_id._get_conversion_rate(product_currency, currency_id, self.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) - - product_uom = self.env.context.get('uom') or product.uom_id.id - if uom and uom.id != product_uom: - # the unit price is in a different uom - uom_factor = uom._compute_price(1.0, product.uom_id) - else: - uom_factor = 1.0 - - return product[field_name] * uom_factor * cur_factor, currency_id - - def _get_protected_fields(self): - return [ - 'product_id', 'name', 'price_unit', 'product_uom', 'product_uom_qty', - 'tax_id', 'analytic_tag_ids' - ] - - def _onchange_product_id_set_customer_lead(self): - pass - - @api.onchange('product_id', 'price_unit', 'product_uom', 'product_uom_qty', 'tax_id') - def _onchange_discount(self): - if not (self.product_id and self.product_uom and - self.order_id.partner_id and self.order_id.pricelist_id and - self.order_id.pricelist_id.discount_policy == 'without_discount' and - self.env.user.has_group('product.group_discount_per_so_line')): - return - - self.discount = 0.0 - product = self.product_id.with_context( - lang=self.order_id.partner_id.lang, - partner=self.order_id.partner_id, - quantity=self.product_uom_qty, - date=self.order_id.date_order, - pricelist=self.order_id.pricelist_id.id, - uom=self.product_uom.id, - fiscal_position=self.env.context.get('fiscal_position') - ) - - product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id) - - price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) - new_list_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) - - if new_list_price != 0: - if self.order_id.pricelist_id.currency_id != currency: - # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency - new_list_price = currency._convert( - new_list_price, self.order_id.pricelist_id.currency_id, - self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) - discount = (new_list_price - price) / new_list_price * 100 - if (discount > 0 and new_list_price > 0) or (discount < 0 and new_list_price < 0): - self.discount = discount - - def _is_delivery(self): - self.ensure_one() - return False - - def get_sale_order_line_multiline_description_sale(self, product): - """ Compute a default multiline description for this sales order line. - - In most cases the product description is enough but sometimes we need to append information that only - exists on the sale order line itself. - e.g: - - custom attributes and attributes that don't create variants, both introduced by the "product configurator" - - 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() + 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. - - :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" - - custom_ptavs = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id - no_variant_ptavs = self.product_no_variant_attribute_value_ids._origin - - # display the no_variant attributes, except those that are also - # displayed by a custom (avoid duplicate description) - for ptav in (no_variant_ptavs - custom_ptavs): - name += "\n" + ptav.with_context(lang=self.order_id.partner_id.lang).display_name - - # Sort the values according to _order settings, because it doesn't work for virtual records in onchange - custom_values = sorted(self.product_custom_attribute_value_ids, key=lambda r: (r.custom_product_template_attribute_value_id.id, r.id)) - # display the is_custom values - for pacv in custom_values: - name += "\n" + pacv.with_context(lang=self.order_id.partner_id.lang).display_name - - return name - - def _is_not_sellable_line(self): - # True if the line is a computed line (reward, delivery, ...) that user cannot add manually - return False diff --git a/addons/sale/models/sale_order_line.py b/addons/sale/models/sale_order_line.py index 0aa6cf8e803..16b3c49dd39 100644 --- a/addons/sale/models/sale_order_line.py +++ b/addons/sale/models/sale_order_line.py @@ -15,1051 +15,6 @@ from odoo.tools import float_is_zero, float_compare, float_round from werkzeug.urls import url_encode -class SaleOrder(models.Model): - _name = "sale.order" - _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin', 'utm.mixin'] - _description = "Sales Order" - _order = 'date_order desc, id desc' - _check_company_auto = True - - def _default_validity_date(self): - if self.env['ir.config_parameter'].sudo().get_param('sale.use_quotation_validity_days'): - days = self.env.company.quotation_validity_days - if days > 0: - return fields.Date.to_string(datetime.now() + timedelta(days)) - return False - - def _get_default_require_signature(self): - return self.env.company.portal_confirmation_sign - - def _get_default_require_payment(self): - return self.env.company.portal_confirmation_pay - - @api.depends('order_line.price_total') - def _amount_all(self): - """ - Compute the total amounts of the SO. - """ - for order in self: - amount_untaxed = amount_tax = 0.0 - for line in order.order_line: - amount_untaxed += line.price_subtotal - amount_tax += line.price_tax - order.update({ - 'amount_untaxed': amount_untaxed, - 'amount_tax': amount_tax, - 'amount_total': amount_untaxed + amount_tax, - }) - - @api.depends('order_line.invoice_lines') - def _get_invoiced(self): - # The invoice_ids are obtained thanks to the invoice lines of the SO - # lines, and we also search for possible refunds created directly from - # existing invoices. This is necessary since such a refund is not - # directly linked to the SO. - for order in self: - invoices = order.order_line.invoice_lines.move_id.filtered(lambda r: r.move_type in ('out_invoice', 'out_refund')) - order.invoice_ids = invoices - order.invoice_count = len(invoices) - - @api.depends('state', 'order_line.invoice_status') - def _get_invoice_status(self): - """ - Compute the invoice status of a SO. Possible statuses: - - no: if the SO is not in status 'sale' or 'done', we consider that there is nothing to - invoice. This is also the default value if the conditions of no other status is met. - - to invoice: if any SO line is 'to invoice', the whole SO is 'to invoice' - - invoiced: if all SO lines are invoiced, the SO is invoiced. - - upselling: if all SO lines are invoiced or upselling, the status is upselling. - """ - unconfirmed_orders = self.filtered(lambda so: so.state not in ['sale', 'done']) - unconfirmed_orders.invoice_status = 'no' - confirmed_orders = self - unconfirmed_orders - if not confirmed_orders: - return - line_invoice_status_all = [ - (d['order_id'][0], d['invoice_status']) - for d in self.env['sale.order.line'].read_group([ - ('order_id', 'in', confirmed_orders.ids), - ('is_downpayment', '=', False), - ('display_type', '=', False), - ], - ['order_id', 'invoice_status'], - ['order_id', 'invoice_status'], lazy=False)] - for order in confirmed_orders: - line_invoice_status = [d[1] for d in line_invoice_status_all if d[0] == order.id] - if order.state not in ('sale', 'done'): - order.invoice_status = 'no' - elif any(invoice_status == 'to invoice' for invoice_status in line_invoice_status): - order.invoice_status = 'to invoice' - elif line_invoice_status and all(invoice_status == 'invoiced' for invoice_status in line_invoice_status): - order.invoice_status = 'invoiced' - elif line_invoice_status and all(invoice_status in ('invoiced', 'upselling') for invoice_status in line_invoice_status): - order.invoice_status = 'upselling' - else: - order.invoice_status = 'no' - - @api.model - def get_empty_list_help(self, help): - self = self.with_context( - empty_list_help_document_name=_("sale order"), - ) - return super(SaleOrder, self).get_empty_list_help(help) - - @api.model - def _default_note(self): - use_invoice_terms = self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') - if use_invoice_terms and self.env.company.terms_type == "html": - baseurl = self.env['ir.config_parameter'].sudo().get_param('web.base.url') - return _('Terms & Conditions: %s/terms', baseurl) - return use_invoice_terms and self.env.company.invoice_terms or '' - - @api.model - def _get_default_team(self): - return self.env['crm.team']._get_default_team_id() - - @api.onchange('fiscal_position_id') - def _compute_tax_id(self): - """ - Trigger the recompute of the taxes if the fiscal position is changed on the SO. - """ - for order in self: - order.order_line._compute_tax_id() - - def _search_invoice_ids(self, operator, value): - if operator == 'in' and value: - self.env.cr.execute(""" - SELECT array_agg(so.id) - FROM sale_order so - JOIN sale_order_line sol ON sol.order_id = so.id - JOIN sale_order_line_invoice_rel soli_rel ON soli_rel.order_line_id = sol.id - JOIN account_move_line aml ON aml.id = soli_rel.invoice_line_id - JOIN account_move am ON am.id = aml.move_id - WHERE - am.move_type in ('out_invoice', 'out_refund') AND - am.id = ANY(%s) - """, (list(value),)) - so_ids = self.env.cr.fetchone()[0] or [] - return [('id', 'in', so_ids)] - if operator == '=' and not value: - return [('order_line.invoice_lines', '=', False)] - return ['&', ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund')), ('order_line.invoice_lines.move_id', operator, value)] - - name = fields.Char(string='Order Reference', required=True, copy=False, readonly=True, states={'draft': [('readonly', False)]}, index=True, default=lambda self: _('New')) - origin = fields.Char(string='Source Document', help="Reference of the document that generated this sales order request.") - client_order_ref = fields.Char(string='Customer Reference', copy=False) - reference = fields.Char(string='Payment Ref.', copy=False, - help='The payment communication of this sale order.') - state = fields.Selection([ - ('draft', 'Quotation'), - ('sent', 'Quotation Sent'), - ('sale', 'Sales Order'), - ('done', 'Locked'), - ('cancel', 'Cancelled'), - ], string='Status', readonly=True, copy=False, index=True, tracking=3, default='draft') - date_order = fields.Datetime(string='Order Date', required=True, readonly=True, index=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, copy=False, default=fields.Datetime.now, help="Creation date of draft/sent orders,\nConfirmation date of confirmed orders.") - validity_date = fields.Date(string='Expiration', readonly=True, copy=False, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, - default=_default_validity_date) - is_expired = fields.Boolean(compute='_compute_is_expired', string="Is expired") - require_signature = fields.Boolean('Online Signature', default=_get_default_require_signature, readonly=True, - states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, - help='Request a online signature to the customer in order to confirm orders automatically.') - require_payment = fields.Boolean('Online Payment', default=_get_default_require_payment, readonly=True, - states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, - help='Request an online payment to the customer in order to confirm orders automatically.') - create_date = fields.Datetime(string='Creation Date', readonly=True, index=True, help="Date on which sales order is created.") - - user_id = fields.Many2one( - 'res.users', string='Salesperson', index=True, tracking=2, default=lambda self: self.env.user, - domain=lambda self: [('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman').id)]) - partner_id = fields.Many2one( - 'res.partner', string='Customer', readonly=True, - states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, - required=True, change_default=True, index=True, tracking=1, - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) - partner_invoice_id = fields.Many2one( - 'res.partner', string='Invoice Address', - readonly=True, required=True, - states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]}, - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) - partner_shipping_id = fields.Many2one( - 'res.partner', string='Delivery Address', readonly=True, required=True, - states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]}, - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) - - pricelist_id = fields.Many2one( - 'product.pricelist', string='Pricelist', check_company=True, # Unrequired company - required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", tracking=1, - help="If you change the pricelist, only newly added lines will be affected.") - currency_id = fields.Many2one(related='pricelist_id.currency_id', depends=["pricelist_id"], store=True, ondelete="restrict") - analytic_account_id = fields.Many2one( - 'account.analytic.account', 'Analytic Account', - readonly=True, copy=False, check_company=True, # Unrequired company - states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", - help="The analytic account related to a sales order.") - - order_line = fields.One2many('sale.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True, auto_join=True) - - invoice_count = fields.Integer(string='Invoice Count', compute='_get_invoiced', readonly=True) - invoice_ids = fields.Many2many("account.move", string='Invoices', compute="_get_invoiced", readonly=True, copy=False, search="_search_invoice_ids") - invoice_status = fields.Selection([ - ('upselling', 'Upselling Opportunity'), - ('invoiced', 'Fully Invoiced'), - ('to invoice', 'To Invoice'), - ('no', 'Nothing to Invoice') - ], string='Invoice Status', compute='_get_invoice_status', store=True, readonly=True) - - note = fields.Text('Terms and conditions', default=_default_note) - terms_type = fields.Selection(related='company_id.terms_type') - - amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, compute='_amount_all', tracking=5) - amount_by_group = fields.Binary(string="Tax amount by group", compute='_amount_by_group', help="type: [(name, amount, base, formated amount, formated base)]") - amount_tax = fields.Monetary(string='Taxes', store=True, readonly=True, compute='_amount_all') - amount_total = fields.Monetary(string='Total', store=True, readonly=True, compute='_amount_all', tracking=4) - currency_rate = fields.Float("Currency Rate", compute='_compute_currency_rate', compute_sudo=True, store=True, digits=(12, 6), readonly=True, help='The rate of the currency to the currency of rate 1 applicable at the date of the order') - - payment_term_id = fields.Many2one( - 'account.payment.term', string='Payment Terms', check_company=True, # Unrequired company - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) - fiscal_position_id = fields.Many2one( - 'account.fiscal.position', string='Fiscal Position', - domain="[('company_id', '=', company_id)]", check_company=True, - help="Fiscal positions are used to adapt taxes and accounts for particular customers or sales orders/invoices." - "The default value comes from the customer.") - company_id = fields.Many2one('res.company', 'Company', required=True, index=True, default=lambda self: self.env.company) - team_id = fields.Many2one( - 'crm.team', 'Sales Team', - change_default=True, default=_get_default_team, check_company=True, # Unrequired company - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") - - signature = fields.Image('Signature', help='Signature received through the portal.', copy=False, attachment=True, max_width=1024, max_height=1024) - signed_by = fields.Char('Signed By', help='Name of the person that signed the SO.', copy=False) - signed_on = fields.Datetime('Signed On', help='Date of the signature.', copy=False) - - commitment_date = fields.Datetime('Delivery Date', copy=False, - states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, - help="This is the delivery date promised to the customer. " - "If set, the delivery order will be scheduled based on " - "this date rather than product lead times.") - expected_date = fields.Datetime("Expected Date", compute='_compute_expected_date', store=False, # Note: can not be stored since depends on today() - help="Delivery date you can promise to the customer, computed from the minimum lead time of the order lines.") - amount_undiscounted = fields.Float('Amount Before Discount', compute='_compute_amount_undiscounted', digits=0) - - type_name = fields.Char('Type Name', compute='_compute_type_name') - - transaction_ids = fields.Many2many('payment.transaction', 'sale_order_transaction_rel', 'sale_order_id', 'transaction_id', - string='Transactions', copy=False, readonly=True) - authorized_transaction_ids = fields.Many2many('payment.transaction', compute='_compute_authorized_transaction_ids', - string='Authorized Transactions', copy=False, readonly=True) - show_update_pricelist = fields.Boolean(string='Has Pricelist Changed', - help="Technical Field, True if the pricelist was changed;\n" - " this will then display a recomputation button") - tag_ids = fields.Many2many('crm.tag', 'sale_order_tag_rel', 'order_id', 'tag_id', string='Tags') - - _sql_constraints = [ - ('date_order_conditional_required', "CHECK( (state IN ('sale', 'done') AND date_order IS NOT NULL) OR state NOT IN ('sale', 'done') )", "A confirmed sales order requires a confirmation date."), - ] - - @api.constrains('company_id', 'order_line') - def _check_order_line_company_id(self): - for order in self: - companies = order.order_line.product_id.company_id - if companies and companies != order.company_id: - bad_products = order.order_line.product_id.filtered(lambda p: p.company_id and p.company_id != order.company_id) - raise ValidationError(_( - "Your quotation contains products from company %(product_company)s whereas your quotation belongs to company %(quote_company)s. \n Please change the company of your quotation or remove the products from other companies (%(bad_products)s).", - product_company=', '.join(companies.mapped('display_name')), - quote_company=order.company_id.display_name, - bad_products=', '.join(bad_products.mapped('display_name')), - )) - - @api.depends('pricelist_id', 'date_order', 'company_id') - def _compute_currency_rate(self): - for order in self: - if not order.company_id: - order.currency_rate = order.currency_id.with_context(date=order.date_order).rate or 1.0 - continue - elif order.company_id.currency_id and order.currency_id: # the following crashes if any one is undefined - order.currency_rate = self.env['res.currency']._get_conversion_rate(order.company_id.currency_id, order.currency_id, order.company_id, order.date_order) - else: - order.currency_rate = 1.0 - - def _compute_access_url(self): - super(SaleOrder, self)._compute_access_url() - for order in self: - order.access_url = '/my/orders/%s' % (order.id) - - def _compute_is_expired(self): - today = fields.Date.today() - for order in self: - order.is_expired = order.state == 'sent' and order.validity_date and order.validity_date < today - - @api.depends('order_line.customer_lead', 'date_order', 'order_line.state') - def _compute_expected_date(self): - """ For service and consumable, we only take the min dates. This method is extended in sale_stock to - take the picking_policy of SO into account. - """ - for order in self: - dates_list = [] - for line in order.order_line.filtered(lambda x: x.state != 'cancel' and not x._is_delivery() and not x.display_type): - dt = line._expected_date() - dates_list.append(dt) - if dates_list: - order.expected_date = fields.Datetime.to_string(min(dates_list)) - else: - order.expected_date = False - - @api.onchange('expected_date') - def _onchange_commitment_date(self): - self.commitment_date = self.expected_date - - @api.depends('transaction_ids') - def _compute_authorized_transaction_ids(self): - for trans in self: - trans.authorized_transaction_ids = trans.transaction_ids.filtered(lambda t: t.state == 'authorized') - - def _compute_amount_undiscounted(self): - for order in self: - total = 0.0 - for line in order.order_line: - total += line.price_subtotal + line.price_unit * ((line.discount or 0.0) / 100.0) * line.product_uom_qty # why is there a discount in a field named amount_undiscounted ?? - order.amount_undiscounted = total - - @api.depends('state') - def _compute_type_name(self): - for record in self: - record.type_name = _('Quotation') if record.state in ('draft', 'sent', 'cancel') else _('Sales Order') - - @api.ondelete(at_uninstall=False) - def _unlink_except_draft_or_cancel(self): - for order in self: - if order.state not in ('draft', 'cancel'): - raise UserError(_('You can not delete a sent quotation or a confirmed sales order. You must first cancel it.')) - - def validate_taxes_on_sales_order(self): - # Override for correct taxcloud computation - # when using coupon and delivery - return True - - def _track_subtype(self, init_values): - self.ensure_one() - if 'state' in init_values and self.state == 'sale': - return self.env.ref('sale.mt_order_confirmed') - elif 'state' in init_values and self.state == 'sent': - return self.env.ref('sale.mt_order_sent') - return super(SaleOrder, self)._track_subtype(init_values) - - @api.onchange('partner_shipping_id', 'partner_id', 'company_id') - def onchange_partner_shipping_id(self): - """ - Trigger the change of fiscal position when the shipping address is modified. - """ - self.fiscal_position_id = self.env['account.fiscal.position'].with_company(self.company_id).get_fiscal_position(self.partner_id.id, self.partner_shipping_id.id) - return {} - - @api.onchange('partner_id') - def onchange_partner_id(self): - """ - Update the following fields when the partner is changed: - - Pricelist - - Payment terms - - Invoice address - - Delivery address - - Sales Team - """ - if not self.partner_id: - self.update({ - 'partner_invoice_id': False, - 'partner_shipping_id': False, - 'fiscal_position_id': False, - }) - return - - self = self.with_company(self.company_id) - - addr = self.partner_id.address_get(['delivery', 'invoice']) - partner_user = self.partner_id.user_id or self.partner_id.commercial_partner_id.user_id - values = { - 'pricelist_id': self.partner_id.property_product_pricelist and self.partner_id.property_product_pricelist.id or False, - 'payment_term_id': self.partner_id.property_payment_term_id and self.partner_id.property_payment_term_id.id or False, - 'partner_invoice_id': addr['invoice'], - 'partner_shipping_id': addr['delivery'], - } - user_id = partner_user.id - if not self.env.context.get('not_self_saleperson'): - user_id = user_id or self.env.uid - if user_id and self.user_id.id != user_id: - values['user_id'] = user_id - - if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms'): - if self.terms_type == 'html' and self.env.company.invoice_terms_html: - values['note'] = _('Terms & Conditions: %s/terms', self.get_base_url()) - elif self.env.company.invoice_terms: - values['note'] = self.with_context(lang=self.partner_id.lang).env.company.invoice_terms - if not self.env.context.get('not_self_saleperson') or not self.team_id: - values['team_id'] = self.env['crm.team'].with_context( - default_team_id=self.partner_id.team_id.id - )._get_default_team_id(domain=['|', ('company_id', '=', self.company_id.id), ('company_id', '=', False)], user_id=user_id) - self.update(values) - - @api.onchange('user_id') - def onchange_user_id(self): - if self.user_id: - self.team_id = self.env['crm.team'].with_context( - default_team_id=self.team_id.id - )._get_default_team_id(user_id=self.user_id.id, domain=None) - - @api.onchange('partner_id') - def onchange_partner_id_warning(self): - if not self.partner_id: - return - warning = {} - title = False - message = False - partner = self.partner_id - - # If partner has no warning, check its company - if partner.sale_warn == 'no-message' and partner.parent_id: - partner = partner.parent_id - - if partner.sale_warn and partner.sale_warn != 'no-message': - # Block if partner only has warning but parent company is blocked - if partner.sale_warn != 'block' and partner.parent_id and partner.parent_id.sale_warn == 'block': - partner = partner.parent_id - title = ("Warning for %s") % partner.name - message = partner.sale_warn_msg - warning = { - 'title': title, - 'message': message, - } - if partner.sale_warn == 'block': - self.update({'partner_id': False, 'partner_invoice_id': False, 'partner_shipping_id': False, 'pricelist_id': False}) - return {'warning': warning} - - if warning: - return {'warning': warning} - - @api.onchange('commitment_date') - def _onchange_commitment_date(self): - """ Warn if the commitment dates is sooner than the expected date """ - if (self.commitment_date and self.expected_date and self.commitment_date < self.expected_date): - return { - 'warning': { - 'title': _('Requested date is too soon.'), - 'message': _("The delivery date is sooner than the expected date." - "You may be unable to honor the delivery date.") - } - } - - @api.onchange('pricelist_id') - def _onchange_pricelist_id(self): - if self.order_line and self.pricelist_id and self._origin.pricelist_id != self.pricelist_id: - self.show_update_pricelist = True - else: - self.show_update_pricelist = False - - def update_prices(self): - self.ensure_one() - lines_to_update = [] - for line in self.order_line.filtered(lambda line: not line.display_type): - product = line.product_id.with_context( - partner=self.partner_id, - quantity=line.product_uom_qty, - date=self.date_order, - pricelist=self.pricelist_id.id, - uom=line.product_uom.id - ) - price_unit = self.env['account.tax']._fix_tax_included_price_company( - line._get_display_price(product), line.product_id.taxes_id, line.tax_id, line.company_id) - if self.pricelist_id.discount_policy == 'without_discount' and price_unit: - discount = max(0, (price_unit - product.price) * 100 / price_unit) - else: - discount = 0 - lines_to_update.append((1, line.id, {'price_unit': price_unit, 'discount': discount})) - self.update({'order_line': lines_to_update}) - self.show_update_pricelist = False - self.message_post(body=_("Product prices have been recomputed according to pricelist %s ", self.pricelist_id.display_name)) - - @api.model - def create(self, vals): - if 'company_id' in vals: - self = self.with_company(vals['company_id']) - if vals.get('name', _('New')) == _('New'): - seq_date = None - if 'date_order' in vals: - seq_date = fields.Datetime.context_timestamp(self, fields.Datetime.to_datetime(vals['date_order'])) - vals['name'] = self.env['ir.sequence'].next_by_code('sale.order', sequence_date=seq_date) or _('New') - - # Makes sure partner_invoice_id', 'partner_shipping_id' and 'pricelist_id' are defined - if any(f not in vals for f in ['partner_invoice_id', 'partner_shipping_id', 'pricelist_id']): - partner = self.env['res.partner'].browse(vals.get('partner_id')) - addr = partner.address_get(['delivery', 'invoice']) - vals['partner_invoice_id'] = vals.setdefault('partner_invoice_id', addr['invoice']) - vals['partner_shipping_id'] = vals.setdefault('partner_shipping_id', addr['delivery']) - vals['pricelist_id'] = vals.setdefault('pricelist_id', partner.property_product_pricelist.id) - result = super(SaleOrder, self).create(vals) - return result - - def _compute_field_value(self, field): - super()._compute_field_value(field) - if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'): - return - - upselling_orders = self.filtered(lambda so: (so.user_id or so.partner_id.user_id) and so.invoice_status == 'upselling') - if not upselling_orders: - return - - upselling_orders._create_upsell_activity() - - def copy_data(self, default=None): - if default is None: - default = {} - if 'order_line' not in default: - default['order_line'] = [(0, 0, line.copy_data()[0]) for line in self.order_line.filtered(lambda l: not l.is_downpayment)] - return super(SaleOrder, self).copy_data(default) - - def name_get(self): - if self._context.get('sale_show_partner_name'): - res = [] - for order in self: - name = order.name - if order.partner_id.name: - name = '%s - %s' % (name, order.partner_id.name) - res.append((order.id, name)) - return res - return super(SaleOrder, self).name_get() - - @api.model - def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): - if self._context.get('sale_show_partner_name'): - if operator == 'ilike' and not (name or '').strip(): - domain = [] - elif operator in ('ilike', 'like', '=', '=like', '=ilike'): - domain = expression.AND([ - args or [], - ['|', ('name', operator, name), ('partner_id.name', operator, name)] - ]) - return self._search(domain, limit=limit, access_rights_uid=name_get_uid) - return super(SaleOrder, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) - - def _create_upsell_activity(self): - self and self.activity_unlink(['sale.mail_act_sale_upsell']) - for order in self: - ref = "%s" - order_ref = ref % (order._name, order.id, order.name) - customer_ref = ref % (order.partner_id._name, order.partner_id.id, order.partner_id.display_name) - order.activity_schedule( - 'sale.mail_act_sale_upsell', - user_id=order.user_id.id or order.partner_id.user_id.id, - note=_("Upsell %(order)s for customer %(customer)s", order=order_ref, customer=customer_ref)) - - def _prepare_invoice(self): - """ - Prepare the dict of values to create the new invoice for a sales order. This method may be - overridden to implement custom invoice generation (making sure to call super() to establish - a clean extension chain). - """ - self.ensure_one() - journal = self.env['account.move'].with_context(default_move_type='out_invoice')._get_default_journal() - if not journal: - raise UserError(_('Please define an accounting sales journal for the company %s (%s).') % (self.company_id.name, self.company_id.id)) - - invoice_vals = { - 'ref': self.client_order_ref or '', - 'move_type': 'out_invoice', - 'narration': self.note, - 'currency_id': self.pricelist_id.currency_id.id, - 'campaign_id': self.campaign_id.id, - 'medium_id': self.medium_id.id, - 'source_id': self.source_id.id, - 'invoice_user_id': self.user_id and self.user_id.id, - 'team_id': self.team_id.id, - 'partner_id': self.partner_invoice_id.id, - 'partner_shipping_id': self.partner_shipping_id.id, - 'fiscal_position_id': (self.fiscal_position_id or self.fiscal_position_id.get_fiscal_position(self.partner_invoice_id.id)).id, - 'partner_bank_id': self.company_id.partner_id.bank_ids[:1].id, - 'journal_id': journal.id, # company comes from the journal - 'invoice_origin': self.name, - 'invoice_payment_term_id': self.payment_term_id.id, - 'payment_reference': self.reference, - 'transaction_ids': [(6, 0, self.transaction_ids.ids)], - 'invoice_line_ids': [], - 'company_id': self.company_id.id, - } - return invoice_vals - - def action_quotation_sent(self): - if self.filtered(lambda so: so.state != 'draft'): - raise UserError(_('Only draft orders can be marked as sent directly.')) - for order in self: - order.message_subscribe(partner_ids=order.partner_id.ids) - self.write({'state': 'sent'}) - - def action_view_invoice(self): - invoices = self.mapped('invoice_ids') - action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_out_invoice_type") - if len(invoices) > 1: - action['domain'] = [('id', 'in', invoices.ids)] - elif len(invoices) == 1: - form_view = [(self.env.ref('account.view_move_form').id, 'form')] - if 'views' in action: - action['views'] = form_view + [(state,view) for state,view in action['views'] if view != 'form'] - else: - action['views'] = form_view - action['res_id'] = invoices.id - else: - action = {'type': 'ir.actions.act_window_close'} - - context = { - 'default_move_type': 'out_invoice', - } - if len(self) == 1: - context.update({ - 'default_partner_id': self.partner_id.id, - 'default_partner_shipping_id': self.partner_shipping_id.id, - 'default_invoice_payment_term_id': self.payment_term_id.id or self.partner_id.property_payment_term_id.id or self.env['account.move'].default_get(['invoice_payment_term_id']).get('invoice_payment_term_id'), - 'default_invoice_origin': self.mapped('name'), - 'default_user_id': self.user_id.id, - }) - action['context'] = context - return action - - def _get_invoice_grouping_keys(self): - return ['company_id', 'partner_id', 'currency_id'] - - @api.model - def _nothing_to_invoice_error(self): - msg = _("""There is nothing to invoice!\n -Reason(s) of this behavior could be: -- You should deliver your products before invoicing them: Click on the "truck" icon (top-right of your screen) and follow instructions. -- You should modify the invoicing policy of your product: Open the product, go to the "Sales tab" and modify invoicing policy from "delivered quantities" to "ordered quantities". - """) - return UserError(msg) - - def _get_invoiceable_lines(self, final=False): - """Return the invoiceable lines for order `self`.""" - down_payment_line_ids = [] - invoiceable_line_ids = [] - pending_section = None - precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') - - for line in self.order_line: - if line.display_type == 'line_section': - # Only invoice the section if one of its lines is invoiceable - pending_section = line - continue - if line.display_type != 'line_note' and float_is_zero(line.qty_to_invoice, precision_digits=precision): - continue - if line.qty_to_invoice > 0 or (line.qty_to_invoice < 0 and final) or line.display_type == 'line_note': - if line.is_downpayment: - # Keep down payment lines separately, to put them together - # at the end of the invoice, in a specific dedicated section. - down_payment_line_ids.append(line.id) - continue - if pending_section: - invoiceable_line_ids.append(pending_section.id) - pending_section = None - invoiceable_line_ids.append(line.id) - - return self.env['sale.order.line'].browse(invoiceable_line_ids + down_payment_line_ids) - - def _create_invoices(self, grouped=False, final=False, date=None): - """ - Create the invoice associated to the SO. - :param grouped: if True, invoices are grouped by SO id. If False, invoices are grouped by - (partner_invoice_id, currency) - :param final: if True, refunds will be generated if necessary - :returns: list of created invoices - """ - if not self.env['account.move'].check_access_rights('create', False): - try: - self.check_access_rights('write') - self.check_access_rule('write') - except AccessError: - return self.env['account.move'] - - # 1) Create invoices. - invoice_vals_list = [] - invoice_item_sequence = 0 # Incremental sequencing to keep the lines order on the invoice. - for order in self: - order = order.with_company(order.company_id) - current_section_vals = None - down_payments = order.env['sale.order.line'] - - invoice_vals = order._prepare_invoice() - invoiceable_lines = order._get_invoiceable_lines(final) - - if not any(not line.display_type for line in invoiceable_lines): - raise self._nothing_to_invoice_error() - - invoice_line_vals = [] - down_payment_section_added = False - for line in invoiceable_lines: - if not down_payment_section_added and line.is_downpayment: - # Create a dedicated section for the down payments - # (put at the end of the invoiceable_lines) - invoice_line_vals.append( - (0, 0, order._prepare_down_payment_section_line( - sequence=invoice_item_sequence, - )), - ) - dp_section = True - invoice_item_sequence += 1 - invoice_line_vals.append( - (0, 0, line._prepare_invoice_line( - sequence=invoice_item_sequence, - )), - ) - invoice_item_sequence += 1 - - invoice_vals['invoice_line_ids'] += invoice_line_vals - invoice_vals_list.append(invoice_vals) - - if not invoice_vals_list: - raise self._nothing_to_invoice_error() - - # 2) Manage 'grouped' parameter: group by (partner_id, currency_id). - if not grouped: - new_invoice_vals_list = [] - invoice_grouping_keys = self._get_invoice_grouping_keys() - for grouping_keys, invoices in groupby(invoice_vals_list, key=lambda x: [x.get(grouping_key) for grouping_key in invoice_grouping_keys]): - origins = set() - payment_refs = set() - refs = set() - ref_invoice_vals = None - for invoice_vals in invoices: - if not ref_invoice_vals: - ref_invoice_vals = invoice_vals - else: - ref_invoice_vals['invoice_line_ids'] += invoice_vals['invoice_line_ids'] - origins.add(invoice_vals['invoice_origin']) - payment_refs.add(invoice_vals['payment_reference']) - refs.add(invoice_vals['ref']) - ref_invoice_vals.update({ - 'ref': ', '.join(refs)[:2000], - 'invoice_origin': ', '.join(origins), - 'payment_reference': len(payment_refs) == 1 and payment_refs.pop() or False, - }) - new_invoice_vals_list.append(ref_invoice_vals) - invoice_vals_list = new_invoice_vals_list - - # 3) Create invoices. - - # As part of the invoice creation, we make sure the sequence of multiple SO do not interfere - # in a single invoice. Example: - # SO 1: - # - Section A (sequence: 10) - # - Product A (sequence: 11) - # SO 2: - # - Section B (sequence: 10) - # - Product B (sequence: 11) - # - # If SO 1 & 2 are grouped in the same invoice, the result will be: - # - Section A (sequence: 10) - # - Section B (sequence: 10) - # - Product A (sequence: 11) - # - Product B (sequence: 11) - # - # Resequencing should be safe, however we resequence only if there are less invoices than - # orders, meaning a grouping might have been done. This could also mean that only a part - # of the selected SO are invoiceable, but resequencing in this case shouldn't be an issue. - if len(invoice_vals_list) < len(self): - SaleOrderLine = self.env['sale.order.line'] - for invoice in invoice_vals_list: - sequence = 1 - for line in invoice['invoice_line_ids']: - line[2]['sequence'] = SaleOrderLine._get_invoice_line_sequence(new=sequence, old=line[2]['sequence']) - sequence += 1 - - # Manage the creation of invoices in sudo because a salesperson must be able to generate an invoice from a - # sale order without "billing" access rights. However, he should not be able to create an invoice from scratch. - moves = self.env['account.move'].sudo().with_context(default_move_type='out_invoice').create(invoice_vals_list) - - # 4) Some moves might actually be refunds: convert them if the total amount is negative - # We do this after the moves have been created since we need taxes, etc. to know if the total - # is actually negative or not - if final: - moves.sudo().filtered(lambda m: m.amount_total < 0).action_switch_invoice_into_refund_credit_note() - for move in moves: - move.message_post_with_view('mail.message_origin_link', - values={'self': move, 'origin': move.line_ids.mapped('sale_line_ids.order_id')}, - subtype_id=self.env.ref('mail.mt_note').id - ) - return moves - - def action_draft(self): - orders = self.filtered(lambda s: s.state in ['cancel', 'sent']) - return orders.write({ - 'state': 'draft', - 'signature': False, - 'signed_by': False, - 'signed_on': False, - }) - - def action_cancel(self): - cancel_warning = self._show_cancel_wizard() - if cancel_warning: - return { - 'name': _('Cancel Sales Order'), - 'view_mode': 'form', - 'res_model': 'sale.order.cancel', - 'view_id': self.env.ref('sale.sale_order_cancel_view_form').id, - 'type': 'ir.actions.act_window', - 'context': {'default_order_id': self.id}, - 'target': 'new' - } - inv = self.invoice_ids.filtered(lambda inv: inv.state == 'draft') - inv.button_cancel() - return self.write({'state': 'cancel'}) - - def _show_cancel_wizard(self): - for order in self: - if order.invoice_ids.filtered(lambda inv: inv.state == 'draft') and not order._context.get('disable_cancel_warning'): - return True - return False - - def _find_mail_template(self, force_confirmation_template=False): - template_id = False - - if force_confirmation_template or (self.state == 'sale' and not self.env.context.get('proforma', False)): - template_id = int(self.env['ir.config_parameter'].sudo().get_param('sale.default_confirmation_template')) - template_id = self.env['mail.template'].search([('id', '=', template_id)]).id - if not template_id: - template_id = self.env['ir.model.data'].xmlid_to_res_id('sale.mail_template_sale_confirmation', raise_if_not_found=False) - if not template_id: - template_id = self.env['ir.model.data'].xmlid_to_res_id('sale.email_template_edi_sale', raise_if_not_found=False) - - return template_id - - def action_quotation_send(self): - ''' Opens a wizard to compose an email, with relevant mail template loaded by default ''' - self.ensure_one() - template_id = self._find_mail_template() - lang = self.env.context.get('lang') - template = self.env['mail.template'].browse(template_id) - if template.lang: - lang = template._render_lang(self.ids)[self.id] - ctx = { - 'default_model': 'sale.order', - 'default_res_id': self.ids[0], - 'default_use_template': bool(template_id), - 'default_template_id': template_id, - 'default_composition_mode': 'comment', - 'mark_so_as_sent': True, - 'custom_layout': "mail.mail_notification_paynow", - 'proforma': self.env.context.get('proforma', False), - 'force_email': True, - 'model_description': self.with_context(lang=lang).type_name, - } - return { - 'type': 'ir.actions.act_window', - 'view_mode': 'form', - 'res_model': 'mail.compose.message', - 'views': [(False, 'form')], - 'view_id': False, - 'target': 'new', - 'context': ctx, - } - - @api.returns('mail.message', lambda value: value.id) - def message_post(self, **kwargs): - if self.env.context.get('mark_so_as_sent'): - self.filtered(lambda o: o.state == 'draft').with_context(tracking_disable=True).write({'state': 'sent'}) - return super(SaleOrder, self.with_context(mail_post_autofollow=True)).message_post(**kwargs) - - def _send_order_confirmation_mail(self): - if self.env.su: - # sending mail in sudo was meant for it being sent from superuser - self = self.with_user(SUPERUSER_ID) - template_id = self._find_mail_template(force_confirmation_template=True) - if template_id: - for order in self: - order.with_context(force_send=True).message_post_with_template(template_id, composition_mode='comment', email_layout_xmlid="mail.mail_notification_paynow") - - def action_done(self): - for order in self: - tx = order.sudo().transaction_ids._get_last() - if tx and tx.state == 'pending' and tx.acquirer_id.provider == 'transfer': - tx._set_done() - tx.write({'is_post_processed': True}) - return self.write({'state': 'done'}) - - def action_unlock(self): - self.write({'state': 'sale'}) - - def _action_confirm(self): - """ Implementation of additionnal mecanism of Sales Order confirmation. - This method should be extended when the confirmation should generated - other documents. In this method, the SO are in 'sale' state (not yet 'done'). - """ - # create an analytic account if at least an expense product - for order in self: - if any(expense_policy not in [False, '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 - - def _prepare_confirmation_values(self): - return { - 'state': 'sale', - 'date_order': fields.Datetime.now() - } - - def action_confirm(self): - if self._get_forbidden_state_confirm() & set(self.mapped('state')): - raise UserError(_( - 'It is not allowed to confirm an order in the following states: %s' - ) % (', '.join(self._get_forbidden_state_confirm()))) - - for order in self.filtered(lambda order: order.partner_id not in order.message_partner_ids): - order.message_subscribe([order.partner_id.id]) - self.write(self._prepare_confirmation_values()) - - # Context key 'default_name' is sometimes propagated up to here. - # We don't need it and it creates issues in the creation of linked records. - context = self._context.copy() - context.pop('default_name', None) - - self.with_context(context)._action_confirm() - if self.env.user.has_group('sale.group_auto_done_setting'): - self.action_done() - return True - - def _get_forbidden_state_confirm(self): - return {'done', 'cancel'} - - def _prepare_analytic_account_data(self, prefix=None): - """ - Prepare method for analytic account data - - :param prefix: The prefix of the to-be-created analytic account name - :type prefix: string - :return: dictionary of value for new analytic account creation - """ - name = self.name - if prefix: - name = prefix + ": " + self.name - return { - 'name': name, - 'code': self.client_order_ref, - 'company_id': self.company_id.id, - 'partner_id': self.partner_id.id - } - - def _create_analytic_account(self, prefix=None): - for order in self: - analytic = self.env['account.analytic.account'].create(order._prepare_analytic_account_data(prefix)) - order.analytic_account_id = analytic - - def _amount_by_group(self): - for order in self: - currency = order.currency_id or order.company_id.currency_id - fmt = partial(formatLang, self.with_context(lang=order.partner_id.lang).env, currency_obj=currency) - res = {} - for line in order.order_line: - price_reduce = line.price_unit * (1.0 - line.discount / 100.0) - taxes = line.tax_id.compute_all(price_reduce, quantity=line.product_uom_qty, product=line.product_id, partner=order.partner_shipping_id)['taxes'] - for tax in line.tax_id: - group = tax.tax_group_id - res.setdefault(group, {'amount': 0.0, 'base': 0.0}) - for t in taxes: - if t['id'] == tax.id or t['id'] in tax.children_tax_ids.ids: - res[group]['amount'] += t['amount'] - res[group]['base'] += t['base'] - res = sorted(res.items(), key=lambda l: l[0].sequence) - order.amount_by_group = [( - l[0].name, l[1]['amount'], l[1]['base'], - fmt(l[1]['amount']), fmt(l[1]['base']), - len(res), - ) for l in res] - - def has_to_be_signed(self, include_draft=False): - return (self.state == 'sent' or (self.state == 'draft' and include_draft)) and not self.is_expired and self.require_signature and not self.signature - - def has_to_be_paid(self, include_draft=False): - transaction = self.get_portal_last_transaction() - return (self.state == 'sent' or (self.state == 'draft' and include_draft)) and not self.is_expired and self.require_payment and transaction.state != 'done' and self.amount_total - - def _notify_get_groups(self, msg_vals=None): - """ Give access button to users and portal customer as portal is integrated - in sale. Customer and portal group have probably no right to see - the document so they don't have the access button. """ - groups = super(SaleOrder, self)._notify_get_groups(msg_vals=msg_vals) - - self.ensure_one() - if self.state not in ('draft', 'cancel'): - for group_name, group_method, group_data in groups: - if group_name not in ('customer', 'portal'): - group_data['has_button_access'] = True - - return groups - - def preview_sale_order(self): - self.ensure_one() - return { - 'type': 'ir.actions.act_url', - 'target': 'self', - 'url': self.get_portal_url(), - } - - def _force_lines_to_invoice_policy_order(self): - for line in self.order_line: - if self.state in ['sale', 'done']: - line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced - else: - line.qty_to_invoice = 0 - - def payment_action_capture(self): - self.authorized_transaction_ids._send_capture_request() - - def payment_action_void(self): - self.authorized_transaction_ids._send_void_request() - - def get_portal_last_transaction(self): - self.ensure_one() - return self.transaction_ids._get_last() - - @api.model - def _get_customer_lead(self, product_tmpl_id): - return False - - def _get_report_base_filename(self): - self.ensure_one() - return '%s %s' % (self.type_name, self.name) - - def _get_portal_return_action(self): - """ Return the action used to display orders when returning from customer portal. """ - self.ensure_one() - return self.env.ref('sale.action_quotations_with_onboarding') - - @api.model - def _prepare_down_payment_section_line(self, **optional_values): - """ - Prepare the dict of values to create a new down payment section for a sales order line. - - :param optional_values: any parameter that should be added to the returned down payment section - """ - down_payments_section_line = { - 'display_type': 'line_section', - 'name': _('Down Payments'), - 'product_id': False, - 'product_uom_id': False, - 'quantity': 0, - 'discount': 0, - 'price_unit': 0, - 'account_id': False - } - if optional_values: - down_payments_section_line.update(optional_values) - return down_payments_section_line - - def add_option_to_order_with_taxcloud(self): - self.ensure_one() - - class SaleOrderLine(models.Model): _name = 'sale.order.line' _description = 'Sales Order Line' From 78203b054368040d63a4a99c99078759bf636111 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Tue, 18 May 2021 14:56:18 +0000 Subject: [PATCH 09/12] [IMP] sale: style checks (unused imports, ...) --- addons/sale/models/account_move_line.py | 2 +- addons/sale/models/payment_acquirer.py | 8 +------- addons/sale/models/product_product.py | 2 +- addons/sale/models/res_company.py | 2 +- addons/sale/models/sale_order.py | 7 ++----- addons/sale/models/sale_order_line.py | 17 ++++++----------- addons/sale/models/sales_team.py | 2 +- addons/sale/models/utm_campaign.py | 2 +- 8 files changed, 14 insertions(+), 28 deletions(-) diff --git a/addons/sale/models/account_move_line.py b/addons/sale/models/account_move_line.py index a0565c23326..c24b4285ea4 100644 --- a/addons/sale/models/account_move_line.py +++ b/addons/sale/models/account_move_line.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models, _ +from odoo import fields, models, _ from odoo.exceptions import UserError from odoo.tools import float_compare, float_is_zero diff --git a/addons/sale/models/payment_acquirer.py b/addons/sale/models/payment_acquirer.py index 2c987b62a40..e9cc1d9f2ca 100644 --- a/addons/sale/models/payment_acquirer.py +++ b/addons/sale/models/payment_acquirer.py @@ -1,13 +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, _, SUPERUSER_ID -from odoo.tools import float_compare - - -_logger = logging.getLogger(__name__) +from odoo import fields, models class PaymentAcquirer(models.Model): diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py index 1e0292ddf44..5b0a9e082fc 100644 --- a/addons/sale/models/product_product.py +++ b/addons/sale/models/product_product.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import timedelta, time -from odoo import api, fields, models +from odoo import fields, models from odoo.tools.float_utils import float_round diff --git a/addons/sale/models/res_company.py b/addons/sale/models/res_company.py index e5f70e2eb98..bb6492ef179 100644 --- a/addons/sale/models/res_company.py +++ b/addons/sale/models/res_company.py @@ -4,7 +4,7 @@ import base64 from odoo import api, fields, models, _ from odoo.modules.module import get_module_resource -from odoo.modules.module import get_resource_path + class ResCompany(models.Model): _inherit = "res.company" diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index 4553b9430f1..f11e1dfcaad 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -7,12 +7,9 @@ from itertools import groupby from odoo import api, fields, models, SUPERUSER_ID, _ from odoo.exceptions import AccessError, UserError, ValidationError -from odoo.tools.misc import formatLang, get_lang +from odoo.tools.misc import formatLang from odoo.osv import expression -from odoo.tools import float_is_zero, float_compare, float_round - - -from werkzeug.urls import url_encode +from odoo.tools import float_is_zero class SaleOrder(models.Model): diff --git a/addons/sale/models/sale_order_line.py b/addons/sale/models/sale_order_line.py index 16b3c49dd39..41d68abf60c 100644 --- a/addons/sale/models/sale_order_line.py +++ b/addons/sale/models/sale_order_line.py @@ -1,20 +1,15 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import datetime, timedelta -from functools import partial -from itertools import groupby +from datetime import timedelta -from odoo import api, fields, models, SUPERUSER_ID, _ -from odoo.exceptions import AccessError, UserError, ValidationError -from odoo.tools.misc import formatLang, get_lang +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools.misc import get_lang from odoo.osv import expression from odoo.tools import float_is_zero, float_compare, float_round -from werkzeug.urls import url_encode - - class SaleOrderLine(models.Model): _name = 'sale.order.line' _description = 'Sales Order Line' @@ -112,7 +107,7 @@ class SaleOrderLine(models.Model): if invoice_line.move_id.move_type == 'out_invoice': qty_invoiced += invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) elif invoice_line.move_id.move_type == 'out_refund': - if not line.is_downpayment or line.untaxed_amount_to_invoice == 0 : + if not line.is_downpayment or line.untaxed_amount_to_invoice == 0: qty_invoiced -= invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) line.qty_invoiced = qty_invoiced @@ -710,7 +705,7 @@ class SaleOrderLine(models.Model): pricelist_item = PricelistItem.browse(rule_id) if pricelist_item.pricelist_id.discount_policy == 'without_discount': while pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id and pricelist_item.base_pricelist_id.discount_policy == 'without_discount': - price, rule_id = pricelist_item.base_pricelist_id.with_context(uom=uom.id).get_product_price_rule(product, qty, self.order_id.partner_id) + _price, rule_id = pricelist_item.base_pricelist_id.with_context(uom=uom.id).get_product_price_rule(product, qty, self.order_id.partner_id) pricelist_item = PricelistItem.browse(rule_id) if pricelist_item.base == 'standard_price': diff --git a/addons/sale/models/sales_team.py b/addons/sale/models/sales_team.py index b73828684cc..3dacbc8f9c2 100644 --- a/addons/sale/models/sales_team.py +++ b/addons/sale/models/sales_team.py @@ -3,7 +3,7 @@ from datetime import date -from odoo import api, fields, models, _ +from odoo import fields, models, _ class CrmTeam(models.Model): diff --git a/addons/sale/models/utm_campaign.py b/addons/sale/models/utm_campaign.py index 1eec837aa90..902856e8788 100644 --- a/addons/sale/models/utm_campaign.py +++ b/addons/sale/models/utm_campaign.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import fields, models, api, SUPERUSER_ID +from odoo import fields, models class UtmCampaign(models.Model): _inherit = 'utm.campaign' From a9236aca16b51296d915c7c31d19b13d53438480 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Tue, 18 May 2021 16:05:31 +0000 Subject: [PATCH 10/12] [IMP] sale: updated __init__.py --- addons/sale/models/__init__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/addons/sale/models/__init__.py b/addons/sale/models/__init__.py index 92e9953d654..5d17ab53fe9 100644 --- a/addons/sale/models/__init__.py +++ b/addons/sale/models/__init__.py @@ -2,15 +2,17 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import analytic -from . import account_invoice from . import account_move +from . import account_move_line +from . import payment_acquirer +from . import payment_transaction from . import product_product from . import product_template from . import res_company -from . import res_partner -from . import sale from . import res_config_settings +from . import res_partner +from . import sale_order +from . import sale_order_line from . import sales_team -from . import payment -from . import utm +from . import utm_campaign from . import mail_compose_message From f437f4a03d70c7481f3aba25b92de30a19bf4281 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Tue, 18 May 2021 16:08:08 +0000 Subject: [PATCH 11/12] [MOV] sale: wizard to wizard directory --- addons/sale/models/__init__.py | 1 - addons/sale/wizard/__init__.py | 1 + addons/sale/{models => wizard}/mail_compose_message.py | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename addons/sale/{models => wizard}/mail_compose_message.py (100%) diff --git a/addons/sale/models/__init__.py b/addons/sale/models/__init__.py index 5d17ab53fe9..84b111f1fae 100644 --- a/addons/sale/models/__init__.py +++ b/addons/sale/models/__init__.py @@ -15,4 +15,3 @@ from . import sale_order from . import sale_order_line from . import sales_team from . import utm_campaign -from . import mail_compose_message diff --git a/addons/sale/wizard/__init__.py b/addons/sale/wizard/__init__.py index b0b5d57194d..40ded784140 100644 --- a/addons/sale/wizard/__init__.py +++ b/addons/sale/wizard/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import mail_compose_message from . import payment_acquirer_onboarding_wizard from . import sale_make_invoice_advance from . import sale_order_cancel diff --git a/addons/sale/models/mail_compose_message.py b/addons/sale/wizard/mail_compose_message.py similarity index 100% rename from addons/sale/models/mail_compose_message.py rename to addons/sale/wizard/mail_compose_message.py From fbc3102b56ff3bd6f20902cb08285045be9ae5ba Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Wed, 19 May 2021 13:15:02 +0000 Subject: [PATCH 12/12] [FIX] sale: update test according to the file moves --- addons/sale/tests/test_payment_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sale/tests/test_payment_flow.py b/addons/sale/tests/test_payment_flow.py index 406b1fe7456..54693c6380d 100644 --- a/addons/sale/tests/test_payment_flow.py +++ b/addons/sale/tests/test_payment_flow.py @@ -117,7 +117,7 @@ class TestSalePayment(PaymentCommon, PaymentHttpCommon): self.assertEqual(tx_sudo.reference, self.reference) tx_sudo._set_done() - with mute_logger('odoo.addons.sale.models.payment'): + with mute_logger('odoo.addons.sale.models.payment_transaction'): tx_sudo._finalize_post_processing() self.assertEqual(self.order.state, 'draft') # Only a partial amount was paid