From 8c7bd64e3e77a69a12053da4ae56b7d7bb458177 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Mon, 17 Jan 2022 11:33:16 +0000 Subject: [PATCH] [REF] sale: clean SO & SOL fields & methods * order by logic/dependencies * reduce stacktrace depth during create precomputation by computing the computed fields dependencies before the field itself not during the field computation (if the field was defined after) * guidelines applications * single quotes for tech strings * double quotes for strings shown to user * respect max line length * try to follow consistent order for field attributes * ordering of methods (computes, onchanges, crud, ...) Part-of: odoo/odoo#81510 --- addons/sale/models/sale_order.py | 1414 ++++++++++++----------- addons/sale/models/sale_order_line.py | 1482 +++++++++++++------------ 2 files changed, 1560 insertions(+), 1336 deletions(-) diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index 2689aa29be1..20336e2927c 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -31,12 +31,379 @@ INVOICE_STATUS = [ class SaleOrder(models.Model): - _name = "sale.order" + _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 + _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."), + ] + + #=== FIELDS ===# + + name = fields.Char( + string="Order Reference", + required=True, copy=False, readonly=True, + index='trigram', + states={'draft': [('readonly', False)]}, + default=lambda self: _('New')) + + company_id = fields.Many2one( + comodel_name='res.company', + required=True, index=True, + default=lambda self: self.env.company) + partner_id = fields.Many2one( + comodel_name='res.partner', + string="Customer", + required=True, readonly=False, change_default=True, index=True, + tracking=1, + states=READONLY_FIELD_STATES, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + state = fields.Selection( + 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') + + client_order_ref = fields.Char(string="Customer Reference", copy=False) + create_date = fields.Datetime( # Override of default create_date field from ORM + string="Creation Date", index=True, readonly=True, + help="Date on which sales order is created.") + commitment_date = fields.Datetime( + string="Delivery Date", copy=False, + states=LOCKED_FIELD_STATES, + 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.") + date_order = fields.Datetime( + string="Order Date", + required=True, readonly=False, index=True, copy=False, + states=READONLY_FIELD_STATES, + help="Creation date of draft/sent orders,\nConfirmation date of confirmed orders.", + default=fields.Datetime.now) + origin = fields.Char( + string="Source Document", + help="Reference of the document that generated this sales order request") + reference = fields.Char( + string="Payment Ref.", + help="The payment communication of this sale order.", + copy=False) + + require_signature = fields.Boolean( + string="Online Signature", + compute='_compute_require_signature', + store=True, readonly=False, precompute=True, + states=READONLY_FIELD_STATES, + help="Request a online signature to the customer in order to confirm orders automatically.") + require_payment = fields.Boolean( + string="Online Payment", + compute='_compute_require_payment', + store=True, readonly=False, precompute=True, + states=READONLY_FIELD_STATES, + help="Request an online payment to the customer in order to confirm orders automatically.") + + signature = fields.Image( + string="Signature", + help="Signature received through the portal.", + copy=False, attachment=True, max_width=1024, max_height=1024) + signed_by = fields.Char( + string="Signed By", help="Name of the person that signed the SO.", copy=False) + signed_on = fields.Datetime( + string="Signed On", help="Date of the signature.", copy=False) + + validity_date = fields.Date( + string="Expiration", + compute='_compute_validity_date', + store=True, readonly=False, copy=False, precompute=True, + states=READONLY_FIELD_STATES) + + # Partner-based computes + note = fields.Html( + string="Terms and conditions", + compute='_compute_note', + store=True, readonly=False, precompute=True) + + partner_invoice_id = fields.Many2one( + comodel_name='res.partner', + string="Invoice Address", + compute='_compute_partner_invoice_id', + store=True, readonly=False, required=True, precompute=True, + states=LOCKED_FIELD_STATES, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + partner_shipping_id = fields.Many2one( + comodel_name='res.partner', + string="Delivery Address", + compute='_compute_partner_shipping_id', + store=True, readonly=False, required=True, precompute=True, + states=LOCKED_FIELD_STATES, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) + + fiscal_position_id = fields.Many2one( + comodel_name='account.fiscal.position', + string="Fiscal Position", + compute='_compute_fiscal_position_id', + store=True, readonly=False, precompute=True, 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.", + domain="[('company_id', '=', company_id)]") + payment_term_id = fields.Many2one( + comodel_name='account.payment.term', + string="Payment Terms", + compute='_compute_payment_term_id', + store=True, readonly=False, precompute=True, check_company=True, # Unrequired company + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + pricelist_id = fields.Many2one( + comodel_name='product.pricelist', + string="Pricelist", + compute='_compute_pricelist_id', + store=True, readonly=False, precompute=True, check_company=True, # Unrequired company + states=READONLY_FIELD_STATES, + tracking=1, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", + 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, precompute=True, ondelete="restrict") + currency_rate = fields.Float( + string="Currency Rate", + compute='_compute_currency_rate', + digits=(12, 6), + store=True, precompute=True, + help='The rate of the currency to the currency of rate 1 applicable at the date of the order') + 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") + + user_id = fields.Many2one( + comodel_name='res.users', + string="Salesperson", + compute='_compute_user_id', + store=True, readonly=False, precompute=True, index=True, + tracking=2, + domain=lambda self: "[('groups_id', '=', {}), ('share', '=', False), ('company_ids', '=', company_id)]".format( + self.env.ref("sales_team.group_sale_salesman").id + )) + team_id = fields.Many2one( + comodel_name='crm.team', + string="Sales Team", + compute='_compute_team_id', + store=True, readonly=False, precompute=True, ondelete="set null", + change_default=True, check_company=True, # Unrequired company + tracking=True, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + + # Lines and line based computes + order_line = fields.One2many( + comodel_name='sale.order.line', + inverse_name='order_id', + string="Order Lines", + states=LOCKED_FIELD_STATES, + copy=True, auto_join=True) + + amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, compute='_amount_all', tracking=5) + amount_tax = fields.Monetary(string='Taxes', store=True, compute='_amount_all') + amount_total = fields.Monetary(string='Total', store=True, compute='_amount_all', tracking=4) + + invoice_count = fields.Integer(string="Invoice Count", compute='_get_invoiced') + invoice_ids = fields.Many2many( + comodel_name='account.move', + string="Invoices", + compute='_get_invoiced', + search='_search_invoice_ids', + copy=False) + invoice_status = fields.Selection( + selection=INVOICE_STATUS, + string="Invoice Status", + compute='_get_invoice_status', + store=True) + + # Payment fields + transaction_ids = fields.Many2many( + comodel_name='payment.transaction', + relation='sale_order_transaction_rel', column1='sale_order_id', column2='transaction_id', + string="Transactions", + copy=False, readonly=True) + authorized_transaction_ids = fields.Many2many( + comodel_name='payment.transaction', + string="Authorized Transactions", + compute='_compute_authorized_transaction_ids', + copy=False) + + # UTMs - enforcing the fact that we want to 'set null' when relation is unlinked + campaign_id = fields.Many2one(ondelete='set null') + medium_id = fields.Many2one(ondelete='set null') + source_id = fields.Many2one(ondelete='set null') + + # Followup ? + analytic_account_id = fields.Many2one( + comodel_name='account.analytic.account', + string="Analytic Account", + copy=False, check_company=True, # Unrequired company + states=READONLY_FIELD_STATES, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", + help="The analytic account related to a sales order.") + tag_ids = fields.Many2many( + comodel_name='crm.tag', + relation='sale_order_tag_rel', column1='order_id', column2='tag_id', + string="Tags") + + # Remaining non stored computed fields (hide/make fields readonly, ...) + amount_undiscounted = fields.Float( + string="Amount Before Discount", + compute='_compute_amount_undiscounted', digits=0) + country_code = fields.Char(related='company_id.account_fiscal_country_id.code', string="Country code") + expected_date = fields.Datetime( + string="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.") + is_expired = fields.Boolean(string="Is Expired", compute='_compute_is_expired') + tax_country_id = fields.Many2one( + comodel_name='res.country', + compute='_compute_tax_country_id', + help="Technical field to filter the available taxes depending on the fiscal country and fiscal position.") + tax_totals_json = fields.Char(compute='_compute_tax_totals_json') + terms_type = fields.Selection(related='company_id.terms_type') + type_name = fields.Char(string="Type Name", compute='_compute_type_name') + + #=== COMPUTE METHODS ===# + + @api.depends('company_id') + def _compute_require_signature(self): + for order in self: + order.require_signature = order.company_id.portal_confirmation_sign + + @api.depends('company_id') + def _compute_require_payment(self): + for order in self: + order.require_payment = order.company_id.portal_confirmation_pay + + @api.depends('company_id') + def _compute_validity_date(self): + enabled_feature = bool(self.env['ir.config_parameter'].sudo().get_param('sale.use_quotation_validity_days')) + if not enabled_feature: + self.validity_date = False + return + + today = fields.Date.context_today(self) + for order in self: + days = order.company_id.quotation_validity_days + if days > 0: + order.validity_date = today + timedelta(days) + else: + order.validity_date = False + + @api.depends('partner_id') + def _compute_note(self): + use_invoice_terms = self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') + if not use_invoice_terms: + return + for order in self: + order = order.with_company(order.company_id) + if order.terms_type == 'html' and self.env.company.invoice_terms_html: + baseurl = html_keep_url(order._get_note_url() + '/terms') + order.note = _('Terms & Conditions: %s', baseurl) + elif not is_html_empty(self.env.company.invoice_terms): + order.note = order.with_context(lang=order.partner_id.lang).env.company.invoice_terms + + @api.model + def _get_note_url(self): + return self.env.company.get_base_url() + + @api.depends('partner_id') + def _compute_partner_invoice_id(self): + for order in self: + order.partner_invoice_id = order.partner_id.address_get(['invoice'])['invoice'] if order.partner_id else False + + @api.depends('partner_id') + def _compute_partner_shipping_id(self): + for order in self: + order.partner_shipping_id = order.partner_id.address_get(['delivery'])['delivery'] if order.partner_id else False + + @api.depends('partner_shipping_id', 'partner_id', 'company_id') + def _compute_fiscal_position_id(self): + """ + Trigger the change of fiscal position when the shipping address is modified. + """ + cache = {} + for order in self: + if not order.partner_id: + order.fiscal_position_id = False + continue + key = (order.company_id.id, order.partner_id.id, order.partner_shipping_id.id) + if key not in cache: + cache[key] = self.env['account.fiscal.position'].with_company( + order.company_id + )._get_fiscal_position(order.partner_id, order.partner_shipping_id) + order.fiscal_position_id = cache[key] + + @api.depends('partner_id') + def _compute_payment_term_id(self): + for order in self: + order = order.with_company(order.company_id) + order.payment_term_id = order.partner_id.property_payment_term_id + + @api.depends('partner_id') + def _compute_pricelist_id(self): + for order in self: + if not order.partner_id: + order.pricelist_id = False + continue + order = order.with_company(order.company_id) + order.pricelist_id = order.partner_id.property_product_pricelist + + @api.depends('currency_id', 'date_order', 'company_id') + def _compute_currency_rate(self): + cache = {} + for order in self: + order_date = order.date_order.date() + if not order.company_id: + order.currency_rate = order.currency_id.with_context(date=order_date).rate or 1.0 + continue + elif not order.currency_id: + order.currency_rate = 1.0 + else: + key = (order.company_id.id, order_date, order.currency_id.id) + if key not in cache: + cache[key] = self.env['res.currency']._get_conversion_rate( + from_currency=order.company_id.currency_id, + to_currency=order.currency_id, + company=order.company_id, + date=order_date, + ) + order.currency_rate = cache[key] + + @api.depends('partner_id') + def _compute_user_id(self): + for order in self: + if not order.user_id: + order.user_id = order.partner_id.user_id or order.partner_id.commercial_partner_id.user_id or self.env.user + + @api.depends('partner_id', 'user_id') + def _compute_team_id(self): + cached_teams = {} + for order in self: + default_team_id = (order.team_id or order.partner_id.team_id).id + user_id = order.user_id.id + company_id = order.company_id.id + key = (default_team_id, user_id, company_id) + if key not in cached_teams: + cached_teams[key] = self.env['crm.team'].with_context( + default_team_id=default_team_id + )._get_default_team_id( + user_id=user_id, domain=[('company_id', 'in', [company_id, False])]) + order.team_id = cached_teams[key] + @api.depends('order_line.price_total') def _amount_all(self): """ @@ -64,6 +431,39 @@ class SaleOrder(models.Model): order.invoice_ids = invoices order.invoice_count = len(invoices) + 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)] + elif operator == '=' and not value: + # special case for [('invoice_ids', '=', False)], i.e. "Invoices is not set" + # + # We cannot just search [('order_line.invoice_lines', '=', False)] + # because it returns orders with uninvoiced lines, which is not + # same "Invoices is not set" (some lines may have invoices and some + # doesn't) + # + # A solution is making inverted search first ("orders with invoiced + # lines") and then invert results ("get all other orders") + # + # Domain below returns subset of ('order_line.invoice_lines', '!=', False) + order_ids = self._search([ + ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund')) + ]) + return [('id', 'not in', order_ids)] + return ['&', ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund')), ('order_line.invoice_lines.move_id', operator, value)] + @api.depends('state', 'order_line.invoice_status') def _get_invoice_status(self): """ @@ -101,275 +501,17 @@ class SaleOrder(models.Model): 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.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') - @api.model - def _get_note_url(self): - return self.env.company.get_base_url() - - 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)] - elif operator == '=' and not value: - # special case for [('invoice_ids', '=', False)], i.e. "Invoices is not set" - # - # We cannot just search [('order_line.invoice_lines', '=', False)] - # because it returns orders with uninvoiced lines, which is not - # same "Invoices is not set" (some lines may have invoices and some - # doesn't) - # - # A solution is making inverted search first ("orders with invoiced - # lines") and then invert results ("get all other orders") - # - # Domain below returns subset of ('order_line.invoice_lines', '!=', False) - order_ids = self._search([ - ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund')) - ]) - return [('id', 'not in', order_ids)] - 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='trigram', 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, index=True, - states=READONLY_FIELD_STATES, - copy=False, default=fields.Datetime.now, - help="Creation date of draft/sent orders,\nConfirmation date of confirmed orders.") - validity_date = fields.Date( - string="Expiration", - compute='_compute_validity_date', - store=True, readonly=False, copy=False, precompute=True, - states=READONLY_FIELD_STATES) - is_expired = fields.Boolean(compute='_compute_is_expired', string="Is expired") - require_signature = fields.Boolean( - string="Online Signature", - compute='_compute_require_signature', - store=True, readonly=False, precompute=True, - states=READONLY_FIELD_STATES, - help="Request a online signature to the customer in order to confirm orders automatically.") - require_payment = fields.Boolean( - string="Online Payment", - compute='_compute_require_payment', - store=True, readonly=False, precompute=True, - states=READONLY_FIELD_STATES, - 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, - compute='_compute_user_id', store=True, readonly=False, precompute=True, - domain=lambda self: "[('groups_id', '=', {}), ('share', '=', False), ('company_ids', '=', company_id)]".format( - self.env.ref("sales_team.group_sale_salesman").id - ),) - partner_id = fields.Many2one( - 'res.partner', string='Customer', readonly=False, - states=READONLY_FIELD_STATES, - 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', required=True, - compute='_compute_partner_invoice_id', store=True, readonly=False, precompute=True, - states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) - partner_shipping_id = fields.Many2one( - 'res.partner', string='Delivery Address', required=True, - compute='_compute_partner_shipping_id', store=True, readonly=False, precompute=True, - states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) - - pricelist_id = fields.Many2one( - 'product.pricelist', string='Pricelist', required=False, check_company=True, # Unrequired company - compute='_compute_pricelist_id', store=True, precompute=True, readonly=False, - states=READONLY_FIELD_STATES, - 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, precompute=True, ondelete="restrict") - analytic_account_id = fields.Many2one( - 'account.analytic.account', 'Analytic Account', - copy=False, check_company=True, # Unrequired company - states=READONLY_FIELD_STATES, - 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=LOCKED_FIELD_STATES, copy=True, auto_join=True) - - invoice_count = fields.Integer(string='Invoice Count', compute='_get_invoiced') - invoice_ids = fields.Many2many("account.move", string='Invoices', compute="_get_invoiced", copy=False, search="_search_invoice_ids") - invoice_status = fields.Selection(INVOICE_STATUS, string='Invoice Status', compute='_get_invoice_status', store=True) - - note = fields.Html( - 'Terms and conditions', - compute='_compute_note', store=True, readonly=False, precompute=True) - terms_type = fields.Selection(related='company_id.terms_type') - - amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, compute='_amount_all', tracking=5) - tax_totals_json = fields.Char(compute='_compute_tax_totals_json') - amount_tax = fields.Monetary(string='Taxes', store=True, compute='_amount_all') - amount_total = fields.Monetary(string='Total', store=True, compute='_amount_all', tracking=4) - currency_rate = fields.Float( - string="Currency Rate", - compute='_compute_currency_rate', store=True, digits=(12, 6), precompute=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 - compute='_compute_payment_term_id', store=True, readonly=False, precompute=True, - 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, - compute='_compute_fiscal_position_id', store=True, readonly=False, precompute=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.") - tax_country_id = fields.Many2one( - comodel_name='res.country', - compute='_compute_tax_country_id', - # Avoid access error on fiscal position when reading a sale order with company != user.company_ids - compute_sudo=True, - help="Technical field to filter the available taxes depending on the fiscal country and fiscal position.") - company_id = fields.Many2one('res.company', 'Company', required=True, index=True, default=lambda self: self.env.company) - country_code = fields.Char(related='company_id.account_fiscal_country_id.code', string="Country code") - team_id = fields.Many2one( - 'crm.team', 'Sales Team', - ondelete="set null", tracking=True, - compute='_compute_team_id', store=True, readonly=False, precompute=True, - change_default=True, 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=LOCKED_FIELD_STATES, - 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) - 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') - # UTMs - enforcing the fact that we want to 'set null' when relation is unlinked - campaign_id = fields.Many2one(ondelete='set null') - medium_id = fields.Many2one(ondelete='set null') - source_id = fields.Many2one(ondelete='set null') - - _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): + def _compute_amount_undiscounted(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('company_id') - def _compute_validity_date(self): - enabled_feature = bool(self.env['ir.config_parameter'].sudo().get_param('sale.use_quotation_validity_days')) - if not enabled_feature: - self.validity_date = False - return - - today = fields.Date.context_today(self) - for order in self: - days = order.company_id.quotation_validity_days - if days > 0: - order.validity_date = today + timedelta(days) - else: - order.validity_date = False - - @api.depends('company_id') - def _compute_require_signature(self): - for order in self: - order.require_signature = order.company_id.portal_confirmation_sign - - @api.depends('company_id') - def _compute_require_payment(self): - for order in self: - order.require_payment = order.company_id.portal_confirmation_pay - - @api.depends('currency_id', 'date_order', 'company_id') - def _compute_currency_rate(self): - cache = {} - for order in self: - order_date = order.date_order.date() - if not order.company_id: - order.currency_rate = order.currency_id.with_context(date=order_date).rate or 1.0 - continue - elif not order.currency_id: - order.currency_rate = 1.0 - else: - key = (order.company_id.id, order_date, order.currency_id.id) - if key not in cache: - cache[key] = self.env['res.currency']._get_conversion_rate( - from_currency=order.company_id.currency_id, - to_currency=order.currency_id, - company=order.company_id, - date=order_date, - ) - order.currency_rate = cache[key] - - 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 + 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 + order.amount_undiscounted = total @api.depends('order_line.customer_lead', 'date_order', 'state') def _compute_expected_date(self): @@ -389,6 +531,19 @@ class SaleOrder(models.Model): else: order.expected_date = False + 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('company_id', 'fiscal_position_id') + def _compute_tax_country_id(self): + for record in self: + if record.fiscal_position_id.foreign_vat: + record.tax_country_id = record.fiscal_position_id.country_id + else: + record.tax_country_id = record.company_id.account_fiscal_country_id + @api.depends('order_line.tax_id', 'order_line.price_unit', 'amount_total', 'amount_untaxed') def _compute_tax_totals_json(self): def compute_taxes(order_line): @@ -402,146 +557,33 @@ class SaleOrder(models.Model): tax_totals = account_move._get_tax_totals(order.partner_id, tax_lines_data, order.amount_total, order.amount_untaxed, order.currency_id) order.tax_totals_json = json.dumps(tax_totals) - @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.depends('company_id.account_fiscal_country_id', 'fiscal_position_id.country_id', 'fiscal_position_id.foreign_vat') - def _compute_tax_country_id(self): - for record in self: - if record.fiscal_position_id.foreign_vat: - record.tax_country_id = record.fiscal_position_id.country_id - else: - record.tax_country_id = record.company_id.account_fiscal_country_id - - @api.ondelete(at_uninstall=False) - def _unlink_except_draft_or_cancel(self): + # portal.mixin override + def _compute_access_url(self): + super()._compute_access_url() 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.')) + order.access_url = '/my/orders/%s' % (order.id) - def validate_taxes_on_sales_order(self): - # Override for correct taxcloud computation - # when using coupon and delivery - return True + #=== CONSTRAINT METHODS ===# - 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.depends('partner_shipping_id', 'partner_id', 'company_id') - def _compute_fiscal_position_id(self): - """ - Trigger the change of fiscal position when the shipping address is modified. - """ - cache = {} + @api.constrains('company_id', 'order_line') + def _check_order_line_company_id(self): for order in self: - if not order.partner_id: - order.fiscal_position_id = False - continue - key = (order.company_id.id, order.partner_id.id, order.partner_shipping_id.id) - if key not in cache: - cache[key] = self.env['account.fiscal.position'].with_company( - order.company_id - )._get_fiscal_position(order.partner_id, order.partner_shipping_id) - order.fiscal_position_id = cache[key] + 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('partner_id') - def _compute_partner_invoice_id(self): - for order in self: - order.partner_invoice_id = order.partner_id.address_get(['invoice'])['invoice'] if order.partner_id else False - - @api.depends('partner_id') - def _compute_partner_shipping_id(self): - for order in self: - order.partner_shipping_id = order.partner_id.address_get(['delivery'])['delivery'] if order.partner_id else False - - @api.depends('partner_id') - def _compute_pricelist_id(self): - for order in self.filtered(lambda o: o.partner_id): - order = order.with_company(order.company_id) - order.pricelist_id = order.partner_id.property_product_pricelist - - @api.depends('partner_id') - def _compute_note(self): - use_invoice_terms = self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') - if not use_invoice_terms: - return - for order in self: - order = order.with_company(order.company_id) - if order.terms_type == 'html' and self.env.company.invoice_terms_html: - baseurl = html_keep_url(order._get_note_url() + '/terms') - order.note = _('Terms & Conditions: %s', baseurl) - elif not is_html_empty(self.env.company.invoice_terms): - order.note = order.with_context(lang=order.partner_id.lang).env.company.invoice_terms - - @api.depends('partner_id') - def _compute_payment_term_id(self): - for order in self: - order = order.with_company(order.company_id) - order.payment_term_id = order.partner_id.property_payment_term_id - - @api.depends('partner_id') - def _compute_user_id(self): - for order in self: - if not order.user_id: - order.user_id = order.partner_id.user_id or order.partner_id.commercial_partner_id.user_id or self.env.user - - @api.depends('partner_id', 'user_id') - def _compute_team_id(self): - cached_teams = {} - for order in self: - default_team_id = (order.team_id or order.partner_id.team_id).id - user_id = order.user_id.id - company_id = order.company_id.id - key = (default_team_id, user_id, company_id) - if key not in cached_teams: - cached_teams[key] = self.env['crm.team'].with_context( - default_team_id=default_team_id - )._get_default_team_id( - user_id=user_id, domain=[('company_id', 'in', [company_id, False])]) - order.team_id = cached_teams[key] - - @api.onchange('partner_id') - def _onchange_partner_id_warning(self): - if not self.partner_id: - return - 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 - - if partner.sale_warn == 'block': - self.update({'partner_id': False, 'partner_invoice_id': False, 'partner_shipping_id': False, 'pricelist_id': False}) - return { - 'warning': { - 'title': _("Warning for %s", partner.name), - 'message': partner.sale_warn_msg, - } - } + #=== ONCHANGE METHODS ===# @api.onchange('commitment_date', 'expected_date') def _onchange_commitment_date(self): @@ -555,23 +597,38 @@ class SaleOrder(models.Model): } } + @api.onchange('partner_id') + def _onchange_partner_id_warning(self): + if not self.partner_id: + return + + 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 + + if partner.sale_warn == 'block': + self.partner_id = False + + return { + 'warning': { + 'title': _("Warning for %s", partner.name), + 'message': partner.sale_warn_msg, + } + } + @api.onchange('pricelist_id') def _onchange_pricelist_id_show_update_prices(self): if self.order_line and self.pricelist_id and self._origin.pricelist_id != self.pricelist_id: self.show_update_pricelist = True - def update_prices(self): - self.ensure_one() - lines_to_recompute = self.order_line.filtered(lambda line: not line.display_type) - lines_to_recompute.invalidate_cache(['pricelist_item_id']) - lines_to_recompute._compute_price_unit() - # Special case: we want to overwrite the existing discount on update_prices call - # i.e. to make sure the discount is correctly reset - # if pricelist discount_policy is different than when the price was first computed. - lines_to_recompute.discount = 0.0 - lines_to_recompute._compute_discount() - self.show_update_pricelist = False - self.message_post(body=_("Product prices have been recomputed according to pricelist %s ", self.pricelist_id.display_name)) + #=== CRUD METHODS ===# @api.model_create_multi def create(self, vals_list): @@ -587,61 +644,181 @@ class SaleOrder(models.Model): return super().create(vals_list) - def _compute_field_value(self, field): - if field.name == 'invoice_status' and not self.env.context.get('mail_activity_automation_skip'): - filtered_self = self.filtered(lambda so: so.ids and (so.user_id or so.partner_id.user_id) and so._origin.invoice_status != 'upselling') - super()._compute_field_value(field) - if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'): - return - - upselling_orders = filtered_self.filtered(lambda so: 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) + return super().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): - if self: - self.activity_unlink(['sale.mail_act_sale_upsell']) + @api.ondelete(at_uninstall=False) + def _unlink_except_draft_or_cancel(self): 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)) + 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.')) + + #=== ACTION METHODS ===# + + 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_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, + 'default_email_layout_xmlid': "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, + } + + 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_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_confirm(self): + """ Implementation of additional mechanism 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 _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_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' + } + return self._action_cancel() + + def _action_cancel(self): + 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 update_prices(self): + self.ensure_one() + lines_to_recompute = self.order_line.filtered(lambda line: not line.display_type) + lines_to_recompute.invalidate_cache(['pricelist_item_id']) + lines_to_recompute._compute_price_unit() + # Special case: we want to overwrite the existing discount on update_prices call + # i.e. to make sure the discount is correctly reset + # if pricelist discount_policy is different than when the price was first computed. + lines_to_recompute.discount = 0.0 + lines_to_recompute._compute_discount() + self.show_update_pricelist = False + self.message_post(body=_("Product prices have been recomputed according to pricelist %s ", self.pricelist_id.display_name)) + + # INVOICING # def _prepare_invoice(self): """ @@ -679,13 +856,6 @@ class SaleOrder(models.Model): } 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") @@ -778,8 +948,6 @@ class SaleOrder(models.Model): 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) @@ -888,82 +1056,7 @@ class SaleOrder(models.Model): ) 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' - } - return self._action_cancel() - - def _action_cancel(self): - 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, - 'default_email_layout_xmlid': "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, - } + # MAIL # @api.returns('mail.message', lambda value: value.id) def message_post(self, **kwargs): @@ -971,106 +1064,6 @@ class SaleOrder(models.Model): self.filtered(lambda o: o.state == 'draft').with_context(tracking_disable=True).write({'state': 'sent'}) return super(SaleOrder, self.with_context(mail_post_autofollow=self.env.context.get('mail_post_autofollow', True))).message_post(**kwargs) - def _sms_get_number_fields(self): - """ No phone or mobile field is available on sale model. Instead SMS will - fallback on partner-based computation using ``_sms_get_partner_fields``. """ - return [] - - def _sms_get_partner_fields(self): - return ['partner_id'] - - 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 additional mechanism 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 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_recipients_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 @@ -1129,13 +1122,23 @@ class SaleOrder(models.Model): render_context['subtitle'] = Markup("%s
%s
") % (self.name, amount_txt) return render_context - def preview_sale_order(self): + def _sms_get_number_fields(self): + """ No phone or mobile field is available on sale model. Instead SMS will + fallback on partner-based computation using ``_sms_get_partner_fields``. """ + return [] + + def _sms_get_partner_fields(self): + return ['partner_id'] + + def _track_subtype(self, init_values): self.ensure_one() - return { - 'type': 'ir.actions.act_url', - 'target': 'self', - 'url': self.get_portal_url(), - } + 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()._track_subtype(init_values) + + # PAYMENT # def _force_lines_to_invoice_policy_order(self): for line in self.order_line: @@ -1154,15 +1157,115 @@ class SaleOrder(models.Model): self.ensure_one() return self.transaction_ids._get_last() - def _get_report_base_filename(self): - self.ensure_one() - return '%s %s' % (self.type_name, self.name) + # PORTAL # + + 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 _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') + def _get_report_base_filename(self): + self.ensure_one() + return '%s %s' % (self.type_name, self.name) + + def preview_sale_order(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_url', + 'target': 'self', + 'url': self.get_portal_url(), + } + + #=== CORE METHODS OVERRIDES ===# + + @api.model + def get_empty_list_help(self, help): + self = self.with_context( + empty_list_help_document_name=_("sale order"), + ) + return super().get_empty_list_help(help) + + def _compute_field_value(self, field): + if field.name == 'invoice_status' and not self.env.context.get('mail_activity_automation_skip'): + filtered_self = self.filtered(lambda so: so.ids and (so.user_id or so.partner_id.user_id) and so._origin.invoice_status != 'upselling') + super()._compute_field_value(field) + if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'): + return + + upselling_orders = filtered_self.filtered(lambda so: so.invoice_status == 'upselling') + if not upselling_orders: + return + + upselling_orders._create_upsell_activity() + + 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().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()._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) + + #=== BUSINESS METHODS ===# + + def _create_upsell_activity(self): + if self: + 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_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 + @api.model def _prepare_down_payment_section_line(self, **optional_values): """ @@ -1184,5 +1287,12 @@ class SaleOrder(models.Model): down_payments_section_line.update(optional_values) return down_payments_section_line + #=== HOOKS ===# + def add_option_to_order_with_taxcloud(self): self.ensure_one() + + def validate_taxes_on_sales_order(self): + # Override for correct taxcloud computation + # when using coupon and delivery + return True diff --git a/addons/sale/models/sale_order_line.py b/addons/sale/models/sale_order_line.py index a55bd1a677d..7258b81f147 100644 --- a/addons/sale/models/sale_order_line.py +++ b/addons/sale/models/sale_order_line.py @@ -5,169 +5,16 @@ from datetime import timedelta 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 class SaleOrderLine(models.Model): _name = 'sale.order.line' - _description = 'Sales 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 the 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 - `_compute_qty_to_invoice()` 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 only 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() - if self.state in ['sale', 'done'] and self.order_id.date_order: - order_date = self.order_id.date_order - else: - order_date = 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: - if line.display_type: - line.update({ - 'price_tax': 0.0, - 'price_total': 0.0, - 'price_subtotal': 0.0, - }) - continue - price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) - taxes = line.tax_id.compute_all(price, line.currency_id, line.product_uom_qty, product=line.product_id, partner=line.order_id.partner_shipping_id) - line.update({ - 'price_tax': taxes['total_included'] - taxes['total_excluded'], - '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', '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', 'state') - def _compute_qty_to_invoice(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.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 _compute_qty_invoiced(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._get_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': - qty_invoiced -= invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) - line.qty_invoiced = qty_invoiced - - def _get_invoice_lines(self): - self.ensure_one() - if self._context.get('accrual_entry_date'): - return self.invoice_lines.filtered( - lambda l: l.move_id.invoice_date and l.move_id.invoice_date <= self._context['accrual_entry_date'] - ) - else: - return self.invoice_lines - - @api.depends('price_unit', 'discount') - def _compute_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 _compute_price_reduce_taxinc(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 _compute_price_reduce_taxexcl(self): - for line in self: - line.price_reduce_taxexcl = line.price_subtotal / line.product_uom_qty if line.product_uom_qty else 0.0 - - @api.depends('product_id') - def _compute_tax_id(self): - for line in self: - line = line.with_company(line.company_id) - taxes = line.product_id.taxes_id.filtered(lambda t: t.company_id == line.env.company) - if not line.product_id or not taxes: - # Nothing to map - line.tax_id = False - continue - # If company_id is set, always filter taxes by the company - line.tax_id = line.order_id.fiscal_position_id.map_tax(taxes) - - @api.model_create_multi - def create(self, vals_list): - lines = super().create(vals_list) - for line in lines: - if line.product_id and line.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))", @@ -177,451 +24,240 @@ class SaleOrderLine(models.Model): "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) + # Fields are ordered according by tech & business logics + # and computed fields are defined after their dependencies. + # This reduces execution stacks depth when precomputing fields + # on record creation (and is also a good ordering logic imho) - 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.")) + order_id = fields.Many2one( + comodel_name='sale.order', + string="Order Reference", + required=True, ondelete='cascade', index=True, copy=False) + sequence = fields.Integer(string="Sequence", default=10) - 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) + # Order-related fields + company_id = fields.Many2one( + related='order_id.company_id', + store=True, index=True, precompute=True) + currency_id = fields.Many2one( + related='order_id.currency_id', + depends=['order_id.currency_id'], + store=True, precompute=True) + order_partner_id = fields.Many2one( + related='order_id.partner_id', + string="Customer", + store=True, precompute=True) + salesman_id = fields.Many2one( + related='order_id.user_id', + string="Salesperson", + store=True, precompute=True) + state = fields.Selection( + related='order_id.state', + string="Order Status", + copy=False, store=True, precompute=True) - # Prevent writing on a locked SO. - protected_fields = self._get_protected_fields() - if 'done' in self.mapped('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, - compute='_compute_name', store=True, readonly=False, precompute=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) - price_unit = fields.Float( - 'Unit Price', required=True, digits='Product Price', - compute='_compute_price_unit', store=True, readonly=False, precompute=True) - pricelist_item_id = fields.Many2one( - 'product.pricelist.item', compute='_compute_pricelist_item_id', - help="Tech field caching pricelist rule used for price & discount computation") - - price_reduce = fields.Float( - string='Price Reduce', digits='Product Price', - compute='_compute_price_reduce', store=True, precompute=True) - price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', store=True, precompute=True) - price_tax = fields.Float(compute='_compute_amount', string='Total Tax', store=True, precompute=True) - price_total = fields.Monetary(compute='_compute_amount', string='Total', store=True, precompute=True) - price_reduce_taxinc = fields.Monetary( - compute='_compute_price_reduce_taxinc', string='Price Reduce Tax inc', store=True, precompute=True) - price_reduce_taxexcl = fields.Monetary( - compute='_compute_price_reduce_taxexcl', string='Price Reduce Tax excl', store=True, precompute=True) - - tax_id = fields.Many2many( - 'account.tax', string='Taxes', - compute='_compute_tax_id', store=True, readonly=False, precompute=True, - context={'active_test': False}) - - discount = fields.Float( - string='Discount (%)', digits='Discount', - compute='_compute_discount', store=True, readonly=False, precompute=True) + # Fields specifying custom line logic + display_type = fields.Selection( + selection=[ + ('line_section', "Section"), + ('line_note', "Note"), + ], + help="Technical field for UX purpose.", + default=False) + 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.") + is_expense = fields.Boolean( + string="Is expense", + help="Is true if the sales order line comes from an expense or a vendor bills") + # Generic configuration fields 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 + comodel_name='product.product', + string="Product", + change_default=True, ondelete='restrict', check_company=True, + domain="[('sale_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', company_id)]") 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') - product_uom_qty = fields.Float( - string='Quantity', digits='Product Unit of Measure', required=True, - compute='_compute_product_uom_qty', store=True, readonly=False, precompute=True) - product_uom = fields.Many2one( - 'uom.uom', string='Unit of Measure', - compute='_compute_product_uom', store=True, readonly=False, precompute=True, - domain="[('category_id', '=', product_uom_category_id)]", ondelete="restrict") + string="Product Template", + related='product_id.product_tmpl_id', + domain=[('sale_ok', '=', True)]) product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id') - 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, - compute='_compute_custom_attribute_values', store=True, readonly=False, precompute=True) + product_custom_attribute_value_ids = fields.One2many( + comodel_name='product.attribute.custom.value', inverse_name='sale_order_line_id', + string="Custom Values", + compute='_compute_custom_attribute_values', + store=True, readonly=False, precompute=True, 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', - compute='_compute_no_variant_attribute_values', store=True, readonly=False, precompute=True) + comodel_name='product.template.attribute.value', + string="Extra Values", + compute='_compute_no_variant_attribute_values', + store=True, readonly=False, precompute=True, ondelete='restrict') + + name = fields.Text( + string="Description", + compute='_compute_name', + store=True, readonly=False, required=True, precompute=True) + + product_uom_qty = fields.Float( + string="Quantity", + compute='_compute_product_uom_qty', + digits='Product Unit of Measure', + store=True, readonly=False, required=True, precompute=True) + product_uom = fields.Many2one( + comodel_name='uom.uom', + string="Unit of Measure", + compute='_compute_product_uom', + store=True, readonly=False, precompute=True, ondelete='restrict', + domain="[('category_id', '=', product_uom_category_id)]") + + # Pricing fields + tax_id = fields.Many2many( + comodel_name='account.tax', + string="Taxes", + compute='_compute_tax_id', + store=True, readonly=False, precompute=True, + context={'active_test': False}) + + pricelist_item_id = fields.Many2one( + comodel_name='product.pricelist.item', + compute='_compute_pricelist_item_id', + help="Tech field caching pricelist rule used for price & discount computation") + + price_unit = fields.Float( + string="Unit Price", + compute='_compute_price_unit', + digits='Product Price', + store=True, readonly=False, required=True, precompute=True) + + discount = fields.Float( + string="Discount (%)", + compute='_compute_discount', + digits='Discount', + store=True, readonly=False, precompute=True) + + price_reduce = fields.Float( + string="Price Reduce", + compute='_compute_price_reduce', + digits='Product Price', + store=True, precompute=True) + price_subtotal = fields.Monetary( + string="Subtotal", + compute='_compute_amount', + store=True, precompute=True) + price_tax = fields.Float( + string="Total Tax", + compute='_compute_amount', + store=True, precompute=True) + price_total = fields.Monetary( + string="Total", + compute='_compute_amount', + store=True, precompute=True) + price_reduce_taxexcl = fields.Monetary( + string="Price Reduce Tax excl", + compute='_compute_price_reduce_taxexcl', + store=True, precompute=True) + price_reduce_taxinc = fields.Monetary( + string="Price Reduce Tax incl", + compute='_compute_price_reduce_taxinc', + store=True, precompute=True) + + # Logistics/Delivery fields + product_packaging_id = fields.Many2one( + comodel_name='product.packaging', + string="Packaging", + compute='_compute_product_packaging_id', + store=True, readonly=False, precompute=True, + domain="[('sales', '=', True), ('product_id','=',product_id)]", + check_company=True) + product_packaging_qty = fields.Float( + string="Packaging Quantity", + compute='_compute_product_packaging_qty', + store=True, readonly=False, precompute=True) + + customer_lead = fields.Float( + string="Lead Time", + compute='_compute_customer_lead', + store=True, readonly=False, required=True, precompute=True, + help="Number of days between the order confirmation and the shipping of the products to the customer") qty_delivered_method = fields.Selection( - [('manual', 'Manual'), - ('analytic', 'Analytic From Expenses')], string="Method to update delivered qty", - compute='_compute_qty_delivered_method', store=True, precompute=True, + selection=[ + ('manual', "Manual"), + ('analytic', "Analytic From Expenses"), + ], + string="Method to update delivered qty", + compute='_compute_qty_delivered_method', + store=True, precompute=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', store=True, readonly=False, - digits='Product Unit of Measure') - qty_to_invoice = fields.Float( - compute='_compute_qty_to_invoice', string='To Invoice Quantity', store=True, - digits='Product Unit of Measure') - qty_invoiced = fields.Float( - compute='_compute_qty_invoiced', string='Invoiced Quantity', store=True, - digits='Product Unit of Measure') + string="Delivery Quantity", + compute='_compute_qty_delivered', + digits='Product Unit of Measure', + store=True, readonly=False, copy=False) - untaxed_amount_invoiced = fields.Monetary( - "Untaxed Invoiced Amount", - compute='_compute_untaxed_amount_invoiced', store=True) - untaxed_amount_to_invoice = fields.Monetary( - "Untaxed Amount To Invoice", - compute='_compute_untaxed_amount_to_invoice', store=True) + # Analytic & Invoicing fields + qty_invoiced = fields.Float( + string="Invoiced Quantity", + compute='_compute_qty_invoiced', + digits='Product Unit of Measure', + store=True) + qty_to_invoice = fields.Float( + string="Quantity To Invoice", + compute='_compute_qty_to_invoice', + digits='Product Unit of Measure', + store=True) analytic_tag_ids = fields.Many2many( - 'account.analytic.tag', string='Analytic Tags', + comodel_name='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.") + analytic_line_ids = fields.One2many( + comodel_name='account.analytic.line', inverse_name='so_line', + string="Analytic lines") - # Related stored fields from the order - currency_id = fields.Many2one( - related='order_id.currency_id', depends=['order_id.currency_id'], - store=True, string='Currency', precompute=True) - company_id = fields.Many2one(related='order_id.company_id', string='Company', store=True, index=True, precompute=True) - order_partner_id = fields.Many2one(related='order_id.partner_id', store=True, string='Customer', precompute=True) - salesman_id = fields.Many2one(related='order_id.user_id', store=True, string='Salesperson', precompute=True) - state = fields.Selection( - related='order_id.state', string='Order Status', copy=False, store=True, precompute=True) + invoice_lines = fields.Many2many( + comodel_name='account.move.line', + relation='sale_order_line_invoice_rel', column1='order_line_id', column2='invoice_line_id', + string="Invoice Lines", + copy=False) + invoice_status = fields.Selection( + selection=[ + ('upselling', "Upselling Opportunity"), + ('invoiced', "Fully Invoiced"), + ('to invoice', "To Invoice"), + ('no', "Nothing to Invoice"), + ], + string="Invoice Status", + compute='_compute_invoice_status', + store=True) - customer_lead = fields.Float( - 'Lead Time', required=True, - compute="_compute_customer_lead", store=True, readonly=False, precompute=True, - help="Number of days between the order confirmation and the shipping of the products to the customer") + untaxed_amount_invoiced = fields.Monetary( + string="Untaxed Invoiced Amount", + compute='_compute_untaxed_amount_invoiced', + store=True) + untaxed_amount_to_invoice = fields.Monetary( + string="Untaxed Amount To Invoice", + compute='_compute_untaxed_amount_to_invoice', + store=True) - display_type = fields.Selection([ - ('line_section', "Section"), - ('line_note', "Note")], default=False, help="Technical field for UX purpose.") + # Technical computed fields for UX purposes (hide/make fields readonly, ...) + product_updatable = fields.Boolean( + string="Can Edit Product", + compute='_compute_product_updatable', + help="UX field to know when the product can be modified") + product_uom_readonly = fields.Boolean( + compute='_compute_product_uom_readonly', + help="UX field to know when the UoM can be modified") - product_packaging_id = fields.Many2one( - 'product.packaging', string='Packaging', - compute='_compute_product_packaging_id', store=True, readonly=False, precompute=True, - domain="[('sales', '=', True), ('product_id','=',product_id)]", - check_company=True) - product_packaging_qty = fields.Float( - 'Packaging Quantity', - compute='_compute_product_packaging_qty', store=True, readonly=False, precompute=True) - - # This computed default is necessary - # because the ORM doesn't provide a way to remove a field default on inheritance - # if a default is specified in sale, it disables the compute in sale_stock - # when a SOL is created with a product but without customer_lead values. - def _compute_customer_lead(self): - self.customer_lead = 0.0 - - @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', - '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) - - 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.depends('product_id', 'product_uom_qty', 'product_uom') - def _compute_product_packaging_id(self): - for line in self: - # remove packaging if not match the product - if line.product_packaging_id.product_id != line.product_id: - line.product_packaging_id = False - # Find biggest suitable packaging - if line.product_id and line.product_uom_qty and line.product_uom: - line.product_packaging_id = line.product_id.packaging_ids.filtered( - 'sales')._find_suitable_product_packaging(line.product_uom_qty, line.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.depends('product_packaging_id', 'product_uom', 'product_uom_qty') - def _compute_product_packaging_qty(self): - for line in self: - if not line.product_packaging_id: - line.product_packaging_qty = False - else: - packaging_uom = line.product_packaging_id.product_uom_id - packaging_uom_qty = line.product_uom._compute_quantity(line.product_uom_qty, packaging_uom) - line.product_packaging_qty = float_round( - packaging_uom_qty / line.product_packaging_id.qty, - precision_rounding=packaging_uom.rounding) - - @api.depends('product_id') - def _compute_product_uom(self): - for line in self: - if not line.product_uom or (line.product_id.uom_id.id != line.product_uom.id): - line.product_uom = line.product_id.uom_id - - @api.depends('display_type', 'product_id', 'product_packaging_qty') - def _compute_product_uom_qty(self): - for line in self: - if line.display_type: - line.product_uom_qty = 0.0 - continue - - # Default value = 1.0 - line.product_uom_qty = line.product_uom_qty or 1.0 - if not line.product_packaging_id: - continue - packaging_uom = line.product_packaging_id.product_uom_id - qty_per_packaging = line.product_packaging_id.qty - product_uom_qty = packaging_uom._compute_quantity( - line.product_packaging_qty * qty_per_packaging, line.product_uom) - if float_compare(product_uom_qty, line.product_uom_qty, precision_rounding=line.product_uom.rounding) != 0: - line.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._get_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 - uom_qty_to_consider = line.qty_delivered if line.product_id.invoice_policy == 'delivery' else line.product_uom_qty - price_reduce = line.price_unit * (1 - (line.discount or 0.0) / 100.0) - price_subtotal = price_reduce * uom_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( - price_reduce, - currency=line.currency_id, - quantity=uom_qty_to_consider, - product=line.product_id, - partner=line.order_id.partner_shipping_id)['total_excluded'] - inv_lines = line._get_invoice_lines() - if any(inv_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 inv_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_tag_ids': [(6, 0, self.analytic_tag_ids.ids)], - 'sale_line_ids': [(4, self.id)], - } - if self.order_id.analytic_account_id: - res['analytic_account_id'] = self.order_id.analytic_account_id.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 - coming 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 {} - - @api.depends('product_id') - def _compute_name(self): - for line in self: - if not line.product_id: - continue - line.name = line.with_context(lang=line.order_partner_id.lang)._get_sale_order_line_multiline_description_sale() + #=== COMPUTE METHODS ===# @api.depends('product_id') def _compute_custom_attribute_values(self): @@ -651,21 +287,90 @@ class SaleOrderLine(models.Model): if ptav._origin not in valid_values: line.product_no_variant_attribute_value_ids -= ptav - @api.onchange('product_id') - def _onchange_product_id_warning(self): - if not self.product_id: - return - product = self.product_id - if product.sale_line_warn != 'no-message': - if product.sale_line_warn == 'block': - self.product_id = False + @api.depends('product_id') + def _compute_name(self): + for line in self: + if not line.product_id: + continue + line.name = line.with_context(lang=line.order_partner_id.lang)._get_sale_order_line_multiline_description_sale() - return { - 'warning': { - 'title': _("Warning for %s", product.name), - 'message': product.sale_line_warn_msg, - } - } + def _get_sale_order_line_multiline_description_sale(self): + """ 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). + """ + self.ensure_one() + return self.product_id.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_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_partner_id.lang).display_name + + return name + + @api.depends('display_type', 'product_id', 'product_packaging_qty') + def _compute_product_uom_qty(self): + for line in self: + if line.display_type: + line.product_uom_qty = 0.0 + continue + + # Default value = 1.0 + line.product_uom_qty = line.product_uom_qty or 1.0 + if not line.product_packaging_id: + continue + packaging_uom = line.product_packaging_id.product_uom_id + qty_per_packaging = line.product_packaging_id.qty + product_uom_qty = packaging_uom._compute_quantity( + line.product_packaging_qty * qty_per_packaging, line.product_uom) + if float_compare(product_uom_qty, line.product_uom_qty, precision_rounding=line.product_uom.rounding) != 0: + line.product_uom_qty = product_uom_qty + + @api.depends('product_id') + def _compute_product_uom(self): + for line in self: + if not line.product_uom or (line.product_id.uom_id.id != line.product_uom.id): + line.product_uom = line.product_id.uom_id + + @api.depends('product_id') + def _compute_tax_id(self): + for line in self: + line = line.with_company(line.company_id) + taxes = line.product_id.taxes_id.filtered(lambda t: t.company_id == line.env.company) + if not line.product_id or not taxes: + # Nothing to map + line.tax_id = False + continue + # If company_id is set, always filter taxes by the company + line.tax_id = line.order_id.fiscal_position_id.map_tax(taxes) @api.depends('product_id', 'product_uom', 'product_uom_qty') def _compute_pricelist_item_id(self): @@ -747,40 +452,36 @@ class SaleOrderLine(models.Model): # negative discounts (= surcharge) are included in the display price return max(base_price, final_price) - 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.depends('product_id', 'product_uom', 'product_uom_qty') + def _compute_discount(self): + for line in self: + if not line.product_id or line.display_type: + line.discount = 0.0 - @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 self._search(args, limit=limit, access_rights_uid=name_get_uid) - return super(SaleOrderLine, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) + if not ( + line.product_uom + and line.order_partner_id + and line.order_id.pricelist_id + and line.order_id.pricelist_id.discount_policy == 'without_discount' + ): + continue - def _check_line_unlink(self): - """ - Check whether a line can be deleted or not. + line.discount = 0.0 - 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)) + price, rule_id = line._get_price_rule_id() + new_list_price, currency = line.with_company(line.company_id)._get_real_price_currency( + line.product_id, rule_id, line.product_uom_qty, line.product_uom, date=line.order_id.date_order) - @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.')) + if new_list_price != 0: + if line.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, line.order_id.pricelist_id.currency_id, + line.order_id.company_id or self.env.company, + line.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): + line.discount = discount def _get_real_price_currency(self, product, rule_id, qty, uom=None, date=False): """Retrieve the price before applying the pricelist @@ -832,89 +533,502 @@ class SaleOrderLine(models.Model): return price, target_currency + @api.depends('price_unit', 'discount') + def _compute_price_reduce(self): + for line in self: + line.price_reduce = line.price_unit * (1.0 - line.discount / 100.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: + if line.display_type: + line.update({ + 'price_tax': 0.0, + 'price_total': 0.0, + 'price_subtotal': 0.0, + }) + continue + price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) + taxes = line.tax_id.compute_all(price, line.currency_id, line.product_uom_qty, product=line.product_id, partner=line.order_id.partner_shipping_id) + line.update({ + 'price_tax': taxes['total_included'] - taxes['total_excluded'], + '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('price_subtotal', 'product_uom_qty') + def _compute_price_reduce_taxexcl(self): + for line in self: + line.price_reduce_taxexcl = line.price_subtotal / line.product_uom_qty if line.product_uom_qty else 0.0 + + @api.depends('price_total', 'product_uom_qty') + def _compute_price_reduce_taxinc(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('product_id', 'product_uom_qty', 'product_uom') + def _compute_product_packaging_id(self): + for line in self: + # remove packaging if not match the product + if line.product_packaging_id.product_id != line.product_id: + line.product_packaging_id = False + # Find biggest suitable packaging + if line.product_id and line.product_uom_qty and line.product_uom: + line.product_packaging_id = line.product_id.packaging_ids.filtered( + 'sales')._find_suitable_product_packaging(line.product_uom_qty, line.product_uom) + + @api.depends('product_packaging_id', 'product_uom', 'product_uom_qty') + def _compute_product_packaging_qty(self): + for line in self: + if not line.product_packaging_id: + line.product_packaging_qty = False + else: + packaging_uom = line.product_packaging_id.product_uom_id + packaging_uom_qty = line.product_uom._compute_quantity(line.product_uom_qty, packaging_uom) + line.product_packaging_qty = float_round( + packaging_uom_qty / line.product_packaging_id.qty, + precision_rounding=packaging_uom.rounding) + + # This computed default is necessary to have a clean computation inheritance + # (cf sale_stock) instead of simply removing the default and specifying + # the compute attribute & method in sale_stock. + def _compute_customer_lead(self): + self.customer_lead = 0.0 + + @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', + '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) + + 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.depends('invoice_lines.move_id.state', 'invoice_lines.quantity') + def _compute_qty_invoiced(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._get_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': + qty_invoiced -= invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) + line.qty_invoiced = qty_invoiced + + def _get_invoice_lines(self): + self.ensure_one() + if self._context.get('accrual_entry_date'): + return self.invoice_lines.filtered( + lambda l: l.move_id.invoice_date and l.move_id.invoice_date <= self._context['accrual_entry_date'] + ) + else: + return self.invoice_lines + + # no trigger product_id.invoice_policy to avoid retroactively changing SO + @api.depends('qty_invoiced', 'qty_delivered', 'product_uom_qty', 'state') + def _compute_qty_to_invoice(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.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('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 the 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 + `_compute_qty_to_invoice()` 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 only 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' + + @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._get_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 + uom_qty_to_consider = line.qty_delivered if line.product_id.invoice_policy == 'delivery' else line.product_uom_qty + price_reduce = line.price_unit * (1 - (line.discount or 0.0) / 100.0) + price_subtotal = price_reduce * uom_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( + price_reduce, + currency=line.currency_id, + quantity=uom_qty_to_consider, + product=line.product_id, + partner=line.order_id.partner_shipping_id)['total_excluded'] + inv_lines = line._get_invoice_lines() + if any(inv_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 inv_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 + + @api.depends('product_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 + + @api.depends('state') + def _compute_product_uom_readonly(self): + for line in self: + line.product_uom_readonly = line.state in ['sale', 'done', 'cancel'] + + #=== CONSTRAINT METHODS ===# + + #=== ONCHANGE METHODS ===# + + @api.onchange('product_id') + def _onchange_product_id_warning(self): + if not self.product_id: + return + + product = self.product_id + if product.sale_line_warn != 'no-message': + if product.sale_line_warn == 'block': + self.product_id = False + + return { + 'warning': { + 'title': _("Warning for %s", product.name), + 'message': product.sale_line_warn_msg, + } + } + + @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 + ), + }, + } + + #=== CRUD METHODS ===# + + @api.model_create_multi + def create(self, vals_list): + lines = super().create(vals_list) + for line in lines: + if line.product_id and line.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 + + 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('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')) + ) + + return super().write(values) + def _get_protected_fields(self): return [ 'product_id', 'name', 'price_unit', 'product_uom', 'product_uom_qty', 'tax_id', 'analytic_tag_ids' ] - @api.depends('product_id', 'product_uom', 'product_uom_qty') - def _compute_discount(self): - for line in self: - if not line.product_id or line.display_type: - line.discount = 0.0 + 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) - if not ( - line.product_uom - and line.order_partner_id - and line.order_id.pricelist_id - and line.order_id.pricelist_id.discount_policy == 'without_discount' - ): - continue + def _check_line_unlink(self): + """ + Check whether a line can be deleted or not. - line.discount = 0.0 + 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)) - price, rule_id = line._get_price_rule_id() - new_list_price, currency = line.with_company(line.company_id)._get_real_price_currency( - line.product_id, rule_id, line.product_uom_qty, line.product_uom, date=line.order_id.date_order) + @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.')) - if new_list_price != 0: - if line.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, line.order_id.pricelist_id.currency_id, - line.order_id.company_id or self.env.company, - line.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): - line.discount = discount + #=== BUSINESS METHODS ===# + + def _expected_date(self): + self.ensure_one() + if self.state in ['sale', 'done'] and self.order_id.date_order: + order_date = self.order_id.date_order + else: + order_date = fields.Datetime.now() + return order_date + timedelta(days=self.customer_lead or 0.0) + + 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_tag_ids': [(6, 0, self.analytic_tag_ids.ids)], + 'sale_line_ids': [(4, self.id)], + } + if self.order_id.analytic_account_id: + res['analytic_account_id'] = self.order_id.analytic_account_id.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 + coming 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 {} + + #=== CORE METHODS OVERRIDES ===# + + 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 self._search(args, limit=limit, access_rights_uid=name_get_uid) + return super()._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) + + #=== HOOKS ===# def _is_delivery(self): self.ensure_one() return False - def _get_sale_order_line_multiline_description_sale(self): - """ 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). - """ - self.ensure_one() - return self.product_id.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_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_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