from collections import defaultdict from datetime import timedelta from markupsafe import Markup from odoo import api, fields, models from odoo.exceptions import UserError, ValidationError from odoo.fields import Command, Domain from odoo.tools import float_compare, float_is_zero, format_date, groupby from odoo.tools.translate import _ 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 _accountable_required_fields = models.Constraint( 'CHECK(display_type IS NOT NULL OR is_downpayment OR (product_id IS NOT NULL AND product_uom_id IS NOT NULL))', 'Missing required fields on accountable sale order line.', ) _non_accountable_null_fields = models.Constraint( 'CHECK(display_type IS NULL OR (product_id IS NULL AND price_unit = 0 AND product_uom_qty = 0 AND product_uom_id 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_subsection', "Subsection"), ('line_note', "Note"), ], default=False) is_configurable_product = fields.Boolean( string="Is the product configurable?", related='product_template_id.has_configurable_attributes', depends=['product_template_id']) 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=lambda self: self._domain_product_id(), check_company=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. # magic way to make sure the domain integrates the check_company _domain_product_id logics # despite not being a check_company=True field domain=lambda self: self._fields['product_id']._description_domain(self.env), ) product_template_attribute_value_ids = fields.Many2many( related='product_id.product_template_attribute_value_ids', 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') is_product_archived = fields.Boolean(compute="_compute_is_product_archived") name = fields.Text( string="Description", compute='_compute_name', store=True, readonly=False, required=True, precompute=True) translated_product_name = fields.Text(compute='_compute_translated_product_name') product_uom_qty = fields.Float( string="Quantity", compute='_compute_product_uom_qty', digits='Product Unit', default=1.0, store=True, readonly=False, required=True, precompute=True) product_uom_id = fields.Many2one( comodel_name='uom.uom', string="Unit", compute='_compute_product_uom_id', domain='[("id", "in", allowed_uom_ids)]', store=True, readonly=False, precompute=True, ondelete='restrict') allowed_uom_ids = fields.Many2many('uom.uom', compute='_compute_allowed_uom_ids') linked_line_id = fields.Many2one( string="Linked Order Line", comodel_name='sale.order.line', ondelete='cascade', domain="[('order_id', '=', order_id)]", copy=False, index=True, ) linked_line_ids = fields.One2many( string="Linked Order Lines", comodel_name='sale.order.line', inverse_name='linked_line_id', ) categ_id = fields.Many2one(related='product_id.categ_id') # Uniquely identifies this sale order line before the record is saved in the DB, i.e. before the # record has an `id`. virtual_id = fields.Char() # Links this sale order line to another sale order line, via its `virtual_id`. linked_virtual_id = fields.Char() # Local storage of this sale order line's selected combo items, iff this is a combo product # line. selected_combo_items = fields.Char(store=False) combo_item_id = fields.Many2one(comodel_name='product.combo.item') # Pricing fields tax_ids = fields.Many2many( comodel_name='account.tax', string="Taxes", compute='_compute_tax_ids', store=True, readonly=False, precompute=True, context={'active_test': False, 'hide_original_tax_ids': True}, check_company=True, domain="[('type_tax_use', '=', 'sale'), ('country_id', '=', tax_country_id)]", ) # 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', min_display_digits='Product Price', store=True, readonly=False, required=True, precompute=True) technical_price_unit = fields.Float() 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) 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', default=0.0, digits='Product Unit', store=True, readonly=False, copy=False) # Analytic & Invoicing fields qty_invoiced = fields.Float( string="Invoiced Quantity", compute='_compute_qty_invoiced', digits='Product Unit', store=True) qty_invoiced_posted = fields.Float( string="Invoiced Quantity (posted)", compute='_compute_qty_invoiced_posted', digits='Product Unit') qty_to_invoice = fields.Float( string="Quantity To Invoice", compute='_compute_qty_to_invoice', digits='Product Unit', 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) amount_invoiced = fields.Monetary( string="Invoiced Amount", compute='_compute_amount_invoiced', compute_sudo=True, # ensure same access as `untaxed_amount_invoiced` ) untaxed_amount_to_invoice = fields.Monetary( string="Untaxed Amount To Invoice", compute='_compute_untaxed_amount_to_invoice', store=True) amount_to_invoice = fields.Monetary( string="Un-invoiced Balance", compute='_compute_amount_to_invoice', compute_sudo=True, # ensure same access as `untaxed_amount_to_invoice` ) amount_to_invoice_at_date = fields.Float(string='Amount', compute='_compute_amount_to_invoice_at_date') # Same than `qty_delivered` and `qty_invoiced` but non-stored and depending of the context. qty_delivered_at_date = fields.Float( string="Delivered", compute='_compute_qty_delivered_at_date', digits='Product Unit') qty_invoiced_at_date = fields.Float( string="Invoiced", compute='_compute_qty_invoiced_at_date', digits='Product Unit') # Technical field holding custom data for the taxes computation engine. extra_tax_data = fields.Json() # Technical computed fields for UX purposes (hide/make fields readonly, ...) product_type = fields.Selection(related='product_id.type', depends=['product_id']) service_tracking = fields.Selection(related='product_id.service_tracking', 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) company_price_include = fields.Selection(related="company_id.account_price_include") sale_line_warn_msg = fields.Text(compute='_compute_sale_line_warn_msg') # Section-related fields parent_id = fields.Many2one( string="Parent Section Line", comodel_name='sale.order.line', compute='_compute_parent_id', ) # The section or subsection this line belongs to. collapse_prices = fields.Boolean( string="Collapse Prices", copy=True, default=False, ) # Whether this section's lines' prices will be hidden in reports and in the portal. collapse_composition = fields.Boolean( string="Collapse Composition", copy=True, default=False, ) # Whether this section's lines will be hidden in reports and in the portal. #=== 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(): if so_line.order_partner_id.lang: so_line = so_line.with_context(lang=so_line.order_id._get_lang()) if (product := so_line.product_id).display_name: default_name = so_line._get_sale_order_line_multiline_description_sale() if so_line.name == default_name: description = product.display_name else: parts = (so_line.name or "").split('\n', 2) description = parts[1] if len(parts) > 1 and parts[1] else product.display_name else: description = (so_line.name or "").split('\n', 1)[0] name = f"{so_line.order_id.name} - {description}" additional_name = name_per_id.get(so_line.id) if additional_name: name = f'{name} {additional_name}' so_line.display_name = name def _domain_product_id(self): return [('sale_ok', '=', True)] @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_is_product_archived(self): for line in self: line.is_product_archived = line.product_id and not line.product_id.active @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', 'linked_line_id', 'linked_line_ids') def _compute_name(self): for line in self: if not line.product_id and not line.is_downpayment: continue lang = line.order_id._get_lang() if lang != self.env.lang: line = line.with_context(lang=lang) if line.product_id: line.name = line._get_sale_order_line_multiline_description_sale() continue if line.is_downpayment: line.name = line._get_downpayment_description() 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() description = ( self.product_id.get_product_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants() ) if self.linked_line_id and not self.combo_item_id: description += "\n" + _( "Option for: %s", self.linked_line_id.product_id.with_context(display_default_code=False).display_name ) return description 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 """ no_variant_ptavs = self.product_no_variant_attribute_value_ids._origin.filtered( # Only describe the attributes where a choice was made by the customer lambda ptav: ptav.display_type == 'multi' or ptav.attribute_line_id.value_count > 1 ) if not self.product_custom_attribute_value_ids and not no_variant_ptavs: return "" name = "" custom_ptavs = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id 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 def _get_downpayment_description(self): self.ensure_one() if self.display_type: return _("Down Payments") dp_state = self._get_downpayment_state() name = _("Down Payment") if dp_state == 'draft': name = _( "Down Payment: %(date)s (Draft)", date=format_date(self.env, self.create_date.date()), ) elif dp_state == 'cancel': name = _("Down Payment (Cancelled)") else: invoice = self._get_invoice_lines().filtered( lambda aml: aml.quantity >= 0 ).move_id.filtered(lambda move: move.move_type == 'out_invoice') if len(invoice) == 1 and invoice.payment_reference and invoice.invoice_date: name = _( "Down Payment (ref: %(reference)s on %(date)s)", reference=invoice.payment_reference, date=format_date(self.env, invoice.invoice_date), ) return name @api.depends('product_id') def _compute_translated_product_name(self): for line in self: line.translated_product_name = line.product_id.with_context( lang=line.order_id._get_lang(), ).display_name @api.depends('display_type', 'product_id') def _compute_product_uom_qty(self): for line in self: if line.display_type: line.product_uom_qty = 0.0 @api.depends('product_id') def _compute_product_uom_id(self): for line in self: if not line.product_uom_id or (line.product_id.uom_id.id != line.product_uom_id.id): line.product_uom_id = line.product_id.uom_id @api.depends('product_id.sale_line_warn_msg') def _compute_sale_line_warn_msg(self): has_warning_group = self.env.user.has_group('sale.group_warning_sale') for line in self: line.sale_line_warn_msg = line.product_id.sale_line_warn_msg if has_warning_group else "" @api.depends('product_id', 'product_id.uom_id', 'product_id.uom_ids') def _compute_allowed_uom_ids(self): for line in self: line.allowed_uom_ids = line.product_id.uom_id | line.product_id.uom_ids @api.depends('product_id', 'company_id') def _compute_tax_ids(self): lines_by_company = defaultdict(lambda: self.env['sale.order.line']) cached_taxes = {} for line in self: if line.product_type == 'combo': line.tax_ids = False continue lines_by_company[line.company_id] += line for company, lines in lines_by_company.items(): for line in lines.with_company(company): taxes = None if line.product_id: taxes = line.product_id.taxes_id._filter_taxes_by_company(company) if not line.product_id or not taxes: # Nothing to map line.tax_ids = 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_ids = 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_id', '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( # No need for the price context, we're not considering the price here product=line.product_id, **line._get_pricelist_kwargs(), ) @api.depends('product_id', 'product_uom_id', 'product_uom_qty') def _compute_price_unit(self): def has_manual_price(line): # `line.currency_id` can be False for NewId records currency = ( line.currency_id or line.company_id.currency_id or line.env.company.currency_id ) return currency.compare_amounts(line.technical_price_unit, line.price_unit) force_recompute = self.env.context.get('force_price_recomputation') for line in self: # Don't compute the price for deleted lines or lines for which the # price unit doesn't come from the product. if not line.order_id or line.is_downpayment or line._is_global_discount(): continue # check if the price has been manually set or there is already invoiced amount. # if so, the price shouldn't change as it might have been manually edited. if ( (not force_recompute and has_manual_price(line)) or line.qty_invoiced > 0 or (line.product_id.expense_policy == 'cost' and line.is_expense) ): continue line = line.with_context(sale_write_from_compute=True) if not line.product_uom_id or not line.product_id: line.price_unit = 0.0 line.technical_price_unit = 0.0 else: line._reset_price_unit() def _reset_price_unit(self): self.ensure_one() line = self.with_company(self.company_id) price = line._get_display_price() product_taxes = line.product_id.taxes_id._filter_taxes_by_company(line.company_id) price_unit = line.product_id._get_tax_included_unit_price_from_price( price, product_taxes=product_taxes, fiscal_position=line.order_id.fiscal_position_id, ) line.update({ 'price_unit': price_unit, 'technical_price_unit': price_unit, }) def _get_order_date(self): self.ensure_one() return self.order_id.date_order 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() if self.product_type == 'combo': return 0 # The display price of a combo line should always be 0. if self.combo_item_id: return self._get_combo_item_display_price() return self._get_display_price_ignore_combo() def _get_display_price_ignore_combo(self): """ This helper method allows to compute the display price of a SOL, while ignoring combo logic. I.e. this method returns the display price of a SOL as if it were neither a combo line nor a combo item line. """ self.ensure_one() pricelist_price = self._get_pricelist_price() if not self.pricelist_item_id._show_discount(): # 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() return self.pricelist_item_id._compute_price( product=self.product_id.with_context(**self._get_product_price_context()), **self._get_pricelist_kwargs(), ) def _get_pricelist_kwargs(self): return { 'quantity': self.product_uom_qty or 1.0, 'uom': self.product_uom_id, 'date': self._get_order_date(), 'currency': self.currency_id, } 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.id, 'quantity': self.product_uom_qty, 'date': self._get_order_date(), } 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()), **self._get_pricelist_kwargs() ) def _get_combo_item_display_price(self): """ Compute the display price of this SOL's combo item. A combo item's price is a fraction of its combo product's price (i.e. the product of type `combo` which is referenced in this SOL's linked line). It is independent of the combo item's product (i.e. the product referenced in this SOL). The combo's `base_price` will be used to prorate the price of this combo with respect to the other combos in the combo product. Note: this method will throw if this SOL has no combo item or no linked combo product. """ self.ensure_one() # Compute the combo product's price. combo_line = self._get_linked_line() combo_product_price = combo_line._get_display_price_ignore_combo() # Compute the combos' base prices. combo_base_prices = { combo_id: combo_id.currency_id._convert( from_amount=combo_id.base_price, to_currency=self.currency_id, company=self.company_id, date=self.order_id.date_order, ) for combo_id in combo_line.product_template_id.sudo().combo_ids } total_combo_base_price = sum(combo_base_prices.values()) # Compute the prorated combo prices. combo_prices = { combo_id: self.currency_id.round( # Don't divide by total_combo_base_price if it's 0. This will make the prorating # wrong, but the delta will be fixed by combo_price_delta below. base_price * combo_product_price / (total_combo_base_price or 1) ) for (combo_id, base_price) in combo_base_prices.items() } # Compute the delta between the combo product's price and the sum of its combo prices. # Ideally, this should be 0, but division in python isn't perfect, so we may need to adjust # the combo prices to make the delta 0. combo_price_delta = combo_product_price - sum(combo_prices.values()) if combo_price_delta: combo_prices[combo_line.product_template_id.sudo().combo_ids[-1]] += combo_price_delta # Add the extra price of this combo item, as well as the extra prices of any `no_variant` # attributes to the combo price. return ( combo_prices[self.combo_item_id.combo_id] + self.combo_item_id.extra_price + self.product_id._get_no_variant_attributes_price_extra( self.product_no_variant_attribute_value_ids ) ) @api.depends('product_id', 'product_uom_id', 'product_uom_qty') def _compute_discount(self): discount_enabled = self.env['product.pricelist.item']._is_discount_feature_enabled() 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 discount_enabled): continue if line.combo_item_id: line.discount = line._get_linked_line().discount continue line.discount = 0.0 if not line.pricelist_item_id._show_discount(): # 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 _prepare_base_line_for_taxes_computation(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() company = self.order_id.company_id or self.env.company base_values = { 'tax_ids': self.tax_ids, 'quantity': self.product_uom_qty, 'partner_id': self.order_id.partner_id, 'currency_id': self.order_id.currency_id or company.currency_id, 'rate': self.order_id.currency_rate, 'name': self.name, } if self._is_global_discount(): base_values['special_type'] = 'global_discount' elif self.is_downpayment: base_values['special_type'] = 'down_payment' base_values.update(kwargs) return self.env['account.tax']._prepare_base_line_for_taxes_computation(self, **base_values) def _is_global_discount(self): self.ensure_one() return self.extra_tax_data and self.extra_tax_data.get('computation_key', '').startswith('global_discount,') @api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_ids') def _compute_amount(self): AccountTax = self.env['account.tax'] for line in self: company = line.company_id or self.env.company base_line = line._prepare_base_line_for_taxes_computation() AccountTax._add_tax_details_in_base_line(base_line, company) AccountTax._round_base_lines_tax_details([base_line], company) line.price_subtotal = base_line['tax_details']['total_excluded_currency'] line.price_total = base_line['tax_details']['total_included_currency'] line.price_tax = line.price_total - line.price_subtotal @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 # 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. """ delivered_qties = self._prepare_qty_delivered() for so_line in self: if not so_line.qty_delivered or so_line in delivered_qties: so_line.qty_delivered = delivered_qties[so_line] @api.depends('qty_delivered') @api.depends_context('accrual_entry_date') def _compute_qty_delivered_at_date(self): if not self._date_in_the_past(): # Avoid useless compute if we don't look in the past. for line in self: line.qty_delivered_at_date = line.qty_delivered return delivered_qties = self._prepare_qty_delivered() for line in self: line.qty_delivered_at_date = delivered_qties[line] def _prepare_qty_delivered(self): # compute for analytic lines delivered_qties = defaultdict(float) 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: delivered_qties[so_line] = mapping.get(so_line.id or so_line._origin.id, 0.0) return delivered_qties 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 return 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 = Domain.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 qty = uom._compute_quantity(qty, so_line.product_uom_id, 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 """ invoiced_quantities = self._prepare_qty_invoiced() for line in self: line.qty_invoiced = invoiced_quantities[line] @api.depends('qty_invoiced') @api.depends_context('accrual_entry_date') def _compute_qty_invoiced_at_date(self): if not self._date_in_the_past(): # Avoid useless compute if we don't look in the past. for line in self: line.qty_invoiced_at_date = line.qty_invoiced return invoiced_quantities = self._prepare_qty_invoiced() for line in self: line.qty_invoiced_at_date = invoiced_quantities[line] def _prepare_qty_invoiced(self): invoiced_qties = defaultdict(float) for line in self: for invoice_line in line._get_invoice_lines(): if invoice_line.move_id.state != 'cancel' or invoice_line.move_id.payment_state == 'invoicing_legacy': invoice_qty = invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom_id) if invoice_line.move_id.move_type == 'out_invoice': invoiced_qties[line] += invoice_qty elif invoice_line.move_id.move_type == 'out_refund': invoiced_qties[line] -= invoice_qty return invoiced_qties @api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity') def _compute_qty_invoiced_posted(self): """ This method is almost identical to '_compute_qty_invoiced()'. The only difference lies in the fact that for accounting purposes, we only want the quantities of the posted invoices. We need a dedicated computation because the triggers are different and could lead to incorrect values for 'qty_invoiced' when computed together. """ for line in self: qty_invoiced_posted = 0.0 for invoice_line in line._get_invoice_lines(): if invoice_line.move_id.state == 'posted' or invoice_line.move_id.payment_state == 'invoicing_legacy': qty_unsigned = invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom_id) qty_signed = qty_unsigned * -invoice_line.move_id.direction_sign qty_invoiced_posted += qty_signed line.qty_invoiced_posted = qty_invoiced_posted def _get_invoice_lines(self): self.ensure_one() if self.env.context.get('accrual_entry_date'): accrual_date = fields.Date.from_string(self.env.context['accrual_entry_date']) return self.invoice_lines.filtered( lambda l: l.move_id.invoice_date and l.move_id.invoice_date <= accrual_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 combo product lines, compute the value if a linked combo item line gets recomputed, and set `qty_to_invoice` only if at least one of its combo item lines is invoiceable. """ combo_lines = set() for line in self: if line.state == 'sale' and not line.display_type: if line.product_id.type == 'combo': combo_lines.add(line) elif 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 if line.combo_item_id and line.linked_line_id: combo_lines.add(line.linked_line_id) else: line.qty_to_invoice = 0 for combo_line in combo_lines: if any( line.combo_item_id and line.qty_to_invoice for line in combo_line.linked_line_ids ): combo_line.qty_to_invoice = combo_line.product_uom_qty - combo_line.qty_invoiced else: combo_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') 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' def _can_be_invoiced_alone(self): """ Whether a given line is meaningful to invoice alone. It is generally meaningless/confusing or even wrong to invoice some specific SOlines (delivery, discounts, rewards, ...) without others, unless they are the only left to invoice in the SO. """ self.ensure_one() return self.product_id.id != self.company_id.sale_discount_product_id.id def _is_discount_line(self): self.ensure_one() return self.product_id in self.company_id.sale_discount_product_id @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' or invoice_line.move_id.payment_state == 'invoicing_legacy': 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('invoice_lines', 'invoice_lines.price_total', 'invoice_lines.move_id.state') def _compute_amount_invoiced(self): for line in self: amount_invoiced = 0.0 for invoice_line in line._get_invoice_lines(): invoice = invoice_line.move_id if invoice.state == 'posted' or invoice_line.move_id.payment_state == 'invoicing_legacy': invoice_date = invoice.invoice_date or fields.Date.context_today(self) amount_invoiced_unsigned = invoice_line.currency_id._convert(invoice_line.price_total, line.currency_id, line.company_id, invoice_date) amount_invoiced += amount_invoiced_unsigned * -invoice.direction_sign line.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_ids.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_ids.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('discount', 'price_total', 'product_uom_qty', 'qty_delivered', 'qty_invoiced_posted') def _compute_amount_to_invoice(self): for line in self: if line.product_uom_qty: uom_qty_to_consider = line.qty_delivered if line.product_id.invoice_policy == 'delivery' else line.product_uom_qty qty_to_invoice = uom_qty_to_consider - line.qty_invoiced_posted unit_price_total = line.price_total / line.product_uom_qty line.amount_to_invoice = unit_price_total * qty_to_invoice else: line.amount_to_invoice = 0.0 @api.depends('price_unit', 'qty_invoiced_at_date', 'qty_delivered_at_date') @api.depends_context('accrual_entry_date') def _compute_amount_to_invoice_at_date(self): for line in self: line.amount_to_invoice_at_date = (line.qty_delivered_at_date - line.qty_invoiced_at_date) * line.price_unit @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): self.product_updatable = True for line in self: if ( line.is_downpayment or line.state == 'cancel' or line.state == 'sale' and ( line.order_id.locked or line.qty_invoiced > 0 or line.qty_delivered > 0 ) ): line.product_updatable = False @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'] def _compute_parent_id(self): sale_order_lines = set(self) for order, lines in self.grouped('order_id').items(): if not order: lines.parent_id = False continue last_section = False last_sub = False for line in order.order_line.sorted('sequence'): if line.display_type == 'line_section': last_section = line if line in sale_order_lines: line.parent_id = False last_sub = False elif line.display_type == 'line_subsection': if line in sale_order_lines: line.parent_id = last_section last_sub = line elif line in sale_order_lines: line.parent_id = last_sub or last_section #=== CONSTRAINT METHODS ===# @api.constrains('combo_item_id') def _check_combo_item_id(self): """ `combo_item_id` should never be set manually. This constraint mainly serves to avoid programming errors. """ for line in self: linked_line = line._get_linked_line() allowed_combo_items = linked_line.product_template_id.combo_ids.combo_item_ids if line.combo_item_id and line.combo_item_id not in allowed_combo_items: raise ValidationError(_( "A sale order line's combo item must be among its linked line's available" " combo items." )) if line.combo_item_id and line.combo_item_id.product_id != line.product_id: raise ValidationError(_( "A sale order line's product must match its combo item's product." )) # === ONCHANGE METHODS ===# @api.onchange('product_id') def _onchange_product_id(self): if not self.product_id: return self._reset_price_unit() #=== 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 if 'technical_price_unit' in vals and 'price_unit' not in vals: # price_unit field was set as readonly in the view (but technical_price_unit not) # the field is not sent by the client and expected to be recomputed, but isn't # because technical_price_unit is set. vals.pop('technical_price_unit') lines = super().create(vals_list) for line in lines: linked_line = line._get_linked_line() if linked_line: line.linked_line_id = linked_line 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) return lines def _add_precomputed_values(self, vals_list): super()._add_precomputed_values(vals_list) for vals in vals_list: if 'price_unit' in vals and 'technical_price_unit' not in vals: vals['technical_price_unit'] = vals['price_unit'] def write(self, vals): values = vals if 'display_type' in values: new_type = values.get('display_type') invalid_lines = self.filtered( lambda line: line.display_type != new_type and not (line.display_type == 'line_subsection' and new_type == 'line_section') ) if invalid_lines: 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_id' in values and any( sol.product_id.id != values['product_id'] and not sol.product_updatable for sol in self ): raise UserError(_("You cannot modify the product of this order line.")) if 'product_uom_qty' in values: precision = self.env['decimal.precision'].precision_get('Product Unit') 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) if ( 'technical_price_unit' in values and 'price_unit' not in values and not self.env.context.get('sale_write_from_compute') ): # price_unit field was set as readonly in the view (but technical_price_unit not) # the field is not sent by the client and expected to be recomputed, but isn't # because technical_price_unit is set. values.pop('technical_price_unit') # 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'))) ) return super().write(values) 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_id', 'product_uom_qty', 'tax_ids', 'analytic_distribution', 'discount' ] 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