# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from collections import defaultdict from datetime import timedelta from markupsafe import Markup from odoo import api, fields, models, _ from odoo.exceptions import UserError from odoo.fields import Command from odoo.osv import expression from odoo.tools import float_is_zero, float_compare, float_round, format_date, groupby class SaleOrderLine(models.Model): _name = 'sale.order.line' _inherit = 'analytic.mixin' _description = "Sales Order Line" _rec_names_search = ['name', 'order_id.name'] _order = 'order_id, sequence, id' _check_company_auto = True _sql_constraints = [ ('accountable_required_fields', "CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom IS NOT NULL))", "Missing required fields on accountable sale order line."), ('non_accountable_null_fields', "CHECK(display_type IS NULL OR (product_id IS NULL AND price_unit = 0 AND product_uom_qty = 0 AND product_uom IS NULL AND customer_lead = 0))", "Forbidden values on non-accountable sale order line"), ] # 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) 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) # 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, index=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) tax_country_id = fields.Many2one(related='order_id.tax_country_id') # Fields specifying custom line logic display_type = fields.Selection( selection=[ ('line_section', "Section"), ('line_note', "Note"), ], 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( comodel_name='product.product', string="Product", change_default=True, ondelete='restrict', index='btree_not_null', domain="[('sale_ok', '=', True)]") product_template_id = fields.Many2one( string="Product Template", comodel_name='product.template', compute='_compute_product_template_id', readonly=False, search='_search_product_template_id', # previously related='product_id.product_tmpl_id' # not anymore since the field must be considered editable for product configurator logic # without modifying the related product_id when updated. domain=[('sale_ok', '=', True)]) product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', depends=['product_id']) 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( 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', default=1.0, 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}, check_company=True) # Tech field caching pricelist rule used for price & discount computation pricelist_item_id = fields.Many2one( comodel_name='product.pricelist.item', compute='_compute_pricelist_item_id') 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_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( 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( string="Delivery Quantity", compute='_compute_qty_delivered', digits='Product Unit of Measure', store=True, readonly=False, copy=False) # 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_line_ids = fields.One2many( comodel_name='account.analytic.line', inverse_name='so_line', string="Analytic lines") 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) 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) # Technical computed fields for UX purposes (hide/make fields readonly, ...) product_type = fields.Selection(related='product_id.detailed_type', depends=['product_id']) product_updatable = fields.Boolean( string="Can Edit Product", compute='_compute_product_updatable') product_uom_readonly = fields.Boolean( compute='_compute_product_uom_readonly') tax_calculation_rounding_method = fields.Selection( related='company_id.tax_calculation_rounding_method', string='Tax calculation rounding method', readonly=True) #=== COMPUTE METHODS ===# @api.depends('order_partner_id', 'order_id', 'product_id') def _compute_display_name(self): name_per_id = self._additional_name_per_id() for so_line in self.sudo(): name = '{} - {}'.format(so_line.order_id.name, so_line.name and so_line.name.split('\n')[0] or so_line.product_id.name) additional_name = name_per_id.get(so_line.id) if additional_name: name = f'{name} {additional_name}' so_line.display_name = name @api.depends('product_id') def _compute_product_template_id(self): for line in self: line.product_template_id = line.product_id.product_tmpl_id def _search_product_template_id(self, operator, value): return [('product_id.product_tmpl_id', operator, value)] @api.depends('product_id') def _compute_custom_attribute_values(self): for line in self: if not line.product_id: line.product_custom_attribute_value_ids = False continue if not line.product_custom_attribute_value_ids: continue valid_values = line.product_id.product_tmpl_id.valid_product_template_attribute_line_ids.product_template_value_ids # remove the is_custom values that don't belong to this template for pacv in line.product_custom_attribute_value_ids: if pacv.custom_product_template_attribute_value_id not in valid_values: line.product_custom_attribute_value_ids -= pacv @api.depends('product_id') def _compute_no_variant_attribute_values(self): for line in self: if not line.product_id: line.product_no_variant_attribute_value_ids = False continue if not line.product_no_variant_attribute_value_ids: continue valid_values = line.product_id.product_tmpl_id.valid_product_template_attribute_line_ids.product_template_value_ids # remove the no_variant attributes that don't belong to this template for ptav in line.product_no_variant_attribute_value_ids: if ptav._origin not in valid_values: line.product_no_variant_attribute_value_ids -= ptav @api.depends('product_id') def _compute_name(self): for line in self: if not line.product_id: continue lang = line.order_id._get_lang() if lang != self.env.lang: line = line.with_context(lang=lang) name = line._get_sale_order_line_multiline_description_sale() if line.is_downpayment and not line.display_type: context = {'lang': lang} dp_state = line._get_downpayment_state() if dp_state == 'draft': name = _("%(line_description)s (Draft)", line_description=name) elif dp_state == 'cancel': name = _("%(line_description)s (Canceled)", line_description=name) else: invoice = line._get_invoice_lines().move_id if len(invoice) == 1 and invoice.payment_reference and invoice.invoice_date: name = _( "%(line_description)s (ref: %(reference)s on %(date)s)", line_description=name, reference=invoice.payment_reference, date=format_date(line.env, invoice.invoice_date), ) del context line.name = name 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 multi_ptavs = no_variant_ptavs.filtered(lambda ptav: ptav.display_type == 'multi').sorted() # display the no_variant attributes, except those that are also # displayed by a custom (avoid duplicate description) for ptav in (no_variant_ptavs - multi_ptavs - custom_ptavs): name += "\n" + ptav.display_name # display the selected values per attribute on a single for a multi checkbox for pta, ptavs in groupby(multi_ptavs, lambda ptav: ptav.attribute_id): name += "\n" + _( "%(attribute)s: %(values)s", attribute=pta.name, values=", ".join(ptav.name for ptav in ptavs) ) # Sort the values according to _order settings, because it doesn't work for virtual records in onchange sorted_custom_ptav = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id.sorted() for patv in sorted_custom_ptav: pacv = self.product_custom_attribute_value_ids.filtered(lambda pcav: pcav.custom_product_template_attribute_value_id == patv) name += "\n" + pacv.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 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', 'company_id') def _compute_tax_id(self): taxes_by_product_company = defaultdict(lambda: self.env['account.tax']) lines_by_company = defaultdict(lambda: self.env['sale.order.line']) cached_taxes = {} for line in self: lines_by_company[line.company_id] += line for product in self.product_id: for tax in product.taxes_id: taxes_by_product_company[(product, tax.company_id)] += tax for company, lines in lines_by_company.items(): for line in lines.with_company(company): taxes, comp = None, company while not taxes and comp: taxes = taxes_by_product_company[(line.product_id, comp)] comp = comp.parent_id if not line.product_id or not taxes: # Nothing to map line.tax_id = False continue fiscal_position = line.order_id.fiscal_position_id cache_key = (fiscal_position.id, company.id, tuple(taxes.ids)) cache_key += line._get_custom_compute_tax_cache_key() if cache_key in cached_taxes: result = cached_taxes[cache_key] else: result = fiscal_position.map_tax(taxes) cached_taxes[cache_key] = result # If company_id is set, always filter taxes by the company line.tax_id = result def _get_custom_compute_tax_cache_key(self): """Hook method to be able to set/get cached taxes while computing them""" return tuple() @api.depends('product_id', 'product_uom', 'product_uom_qty') def _compute_pricelist_item_id(self): for line in self: if not line.product_id or line.display_type or not line.order_id.pricelist_id: line.pricelist_item_id = False else: line.pricelist_item_id = line.order_id.pricelist_id._get_product_rule( line.product_id, quantity=line.product_uom_qty or 1.0, uom=line.product_uom, date=line.order_id.date_order, ) @api.depends('product_id', 'product_uom', 'product_uom_qty') def _compute_price_unit(self): for line in self: # check if there is already invoiced amount. if so, the price shouldn't change as it might have been # manually edited if line.qty_invoiced > 0 or (line.product_id.expense_policy == 'cost' and line.is_expense): continue if not line.product_uom or not line.product_id: line.price_unit = 0.0 else: line = line.with_company(line.company_id) price = line._get_display_price() line.price_unit = line.product_id._get_tax_included_unit_price( line.company_id or line.env.company, line.order_id.currency_id, line.order_id.date_order, 'sale', fiscal_position=line.order_id.fiscal_position_id, product_price_unit=price, product_currency=line.currency_id ) def _get_display_price(self): """Compute the displayed unit price for a given line. Overridden in custom flows: * where the price is not specified by the pricelist * where the discount is not specified by the pricelist Note: self.ensure_one() """ self.ensure_one() pricelist_price = self._get_pricelist_price() if self.order_id.pricelist_id.discount_policy == 'with_discount': return pricelist_price if not self.pricelist_item_id: # No pricelist rule found => no discount from pricelist return pricelist_price base_price = self._get_pricelist_price_before_discount() # negative discounts (= surcharge) are included in the display price return max(base_price, pricelist_price) def _get_pricelist_price(self): """Compute the price given by the pricelist for the given line information. :return: the product sales price in the order currency (without taxes) :rtype: float """ self.ensure_one() self.product_id.ensure_one() price = self.pricelist_item_id._compute_price( product=self.product_id.with_context(**self._get_product_price_context()), quantity=self.product_uom_qty or 1.0, uom=self.product_uom, date=self.order_id.date_order, currency=self.currency_id, ) return price def _get_product_price_context(self): """Gives the context for product price computation. :return: additional context to consider extra prices from attributes in the base product price. :rtype: dict """ self.ensure_one() return self.product_id._get_product_price_context( self.product_no_variant_attribute_value_ids, ) def _get_pricelist_price_context(self): """DO NOT USE in new code, this contextual logic should be dropped or heavily refactored soon""" self.ensure_one() return { 'pricelist': self.order_id.pricelist_id.id, 'uom': self.product_uom.id, 'quantity': self.product_uom_qty, 'date': self.order_id.date_order, } def _get_pricelist_price_before_discount(self): """Compute the price used as base for the pricelist price computation. :return: the product sales price in the order currency (without taxes) :rtype: float """ self.ensure_one() self.product_id.ensure_one() return self.pricelist_item_id._compute_price_before_discount( product=self.product_id.with_context(**self._get_product_price_context()), quantity=self.product_uom_qty or 1.0, uom=self.product_uom, date=self.order_id.date_order, currency=self.currency_id, ) @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 if not ( line.order_id.pricelist_id and line.order_id.pricelist_id.discount_policy == 'without_discount' ): continue line.discount = 0.0 if not line.pricelist_item_id: # No pricelist rule was found for the product # therefore, the pricelist didn't apply any discount/change # to the existing sales price. continue line = line.with_company(line.company_id) pricelist_price = line._get_pricelist_price() base_price = line._get_pricelist_price_before_discount() if base_price != 0: # Avoid division by zero discount = (base_price - pricelist_price) / base_price * 100 if (discount > 0 and base_price > 0) or (discount < 0 and base_price < 0): # only show negative discounts if price is negative # otherwise it's a surcharge which shouldn't be shown to the customer line.discount = discount def _convert_to_tax_base_line_dict(self, **kwargs): """ Convert the current record to a dictionary in order to use the generic taxes computation method defined on account.tax. :return: A python dictionary. """ self.ensure_one() return self.env['account.tax']._convert_to_tax_base_line_dict( self, partner=self.order_id.partner_id, currency=self.order_id.currency_id, product=self.product_id, taxes=self.tax_id, price_unit=self.price_unit, quantity=self.product_uom_qty, discount=self.discount, price_subtotal=self.price_subtotal, **kwargs, ) @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: tax_results = self.env['account.tax']._compute_taxes([ line._convert_to_tax_base_line_dict() ]) totals = list(tax_results['totals'].values())[0] amount_untaxed = totals['amount_untaxed'] amount_tax = totals['amount_tax'] line.update({ 'price_subtotal': amount_untaxed, 'price_tax': amount_tax, 'price_total': amount_untaxed + amount_tax, }) @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 # suggest biggest suitable packaging matching the SO's company if line.product_id and line.product_uom_qty and line.product_uom: suggested_packaging = line.product_id.packaging_ids\ .filtered(lambda p: p.sales and (p.product_id.company_id <= p.company_id <= line.company_id))\ ._find_suitable_product_packaging(line.product_uom_qty, line.product_uom) line.product_packaging_id = suggested_packaging or line.product_packaging_id @api.depends('product_packaging_id', 'product_uom', 'product_uom_qty') def _compute_product_packaging_qty(self): self.product_packaging_qty = 0 for line in self: if not line.product_packaging_id: continue line.product_packaging_qty = line.product_packaging_id._compute_qty(line.product_uom_qty, line.product_uom) # 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('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_downpayment_state(self): self.ensure_one() if self.display_type: return '' invoice_lines = self._get_invoice_lines() if all(line.parent_state == 'draft' for line in invoice_lines): return 'draft' if all(line.parent_state == 'cancel' for line in invoice_lines): return 'cancel' return '' 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 = defaultdict(float) # 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, ['product_uom_id', 'so_line'], ['unit_amount:sum', 'move_line_id:count_distinct', '__count'], ) # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines for uom, so_line, unit_amount_sum, move_line_id_count_distinct, count in data: if not uom: continue # avoid counting unit_amount twice when dealing with multiple analytic lines on the same move line if move_line_id_count_distinct == 1 and count > 1: qty = unit_amount_sum / count else: qty = unit_amount_sum if so_line.product_uom.category_id == uom.category_id: qty = uom._compute_quantity(qty, so_line.product_uom, rounding_method='HALF-UP') 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' or invoice_line.move_id.payment_state == 'invoicing_legacy': 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 == 'sale' and not line.display_type: 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', 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', 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 != 'sale': 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\ line.product_uom_qty >= 0.0 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', 'product_id', 'untaxed_amount_invoiced', 'qty_delivered', 'product_uom_qty', 'price_unit') 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 == 'sale': # 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('order_id.partner_id', 'product_id') def _compute_analytic_distribution(self): for line in self: if not line.display_type: distribution = line.env['account.analytic.distribution.model']._get_distribution({ "product_id": line.product_id.id, "product_categ_id": line.product_id.categ_id.id, "partner_id": line.order_id.partner_id.id, "partner_category_id": line.order_id.partner_id.category_id.ids, "company_id": line.company_id.id, }) line.analytic_distribution = distribution or line.analytic_distribution @api.depends('product_id', 'state', 'qty_invoiced', 'qty_delivered') def _compute_product_updatable(self): for line in self: if line.state == 'cancel': line.product_updatable = False elif line.state == 'sale' and ( line.order_id.locked or 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.ids checks whether it's a new record not yet saved line.product_uom_readonly = line.ids and line.state in ['sale', '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): for vals in vals_list: if vals.get('display_type') or self.default_get(['display_type']).get('display_type'): vals['product_uom_qty'] = 0.0 lines = super().create(vals_list) if self.env.context.get('sale_no_log_for_new_lines'): return lines 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 any(self.order_id.mapped('locked')) and any(f in values.keys() for f in protected_fields): protected_fields_modified = list(set(protected_fields) & set(values.keys())) if 'name' in protected_fields_modified and all(self.mapped('is_downpayment')): protected_fields_modified.remove('name') fields = self.env['ir.model.fields'].sudo().search([ ('name', 'in', protected_fields_modified), ('model', '=', self._name) ]) if fields: raise UserError( _('It is forbidden to modify the following fields in a locked order:\n%s', '\n'.join(fields.mapped('field_description'))) ) result = super().write(values) # Don't recompute the package_id if we are setting the quantity of the items and the quantity of packages if 'product_uom_qty' in values and 'product_packaging_qty' in values and 'product_packaging_id' not in values: self.env.remove_to_compute(self._fields['product_packaging_id'], self) return result def _get_protected_fields(self): """ Give the fields that should not be modified on a locked SO. :returns: list of field names :rtype: list """ return [ 'product_id', 'name', 'price_unit', 'product_uom', 'product_uom_qty', 'tax_id', 'analytic_distribution' ] 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 = Markup("%s