1271 lines
56 KiB
Python
1271 lines
56 KiB
Python
# -*- 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("<b>%s</b><ul>") % _("The ordered quantity has been updated.")
|
|
for line in order_lines:
|
|
if 'product_id' in values and values['product_id'] != line.product_id.id:
|
|
# tracking is meaningless if the product is changed as well.
|
|
continue
|
|
msg += Markup("<li> %s: <br/>") % 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"]
|
|
) + Markup("<br/>")
|
|
if line.product_id.type in ('consu', 'product'):
|
|
msg += _("Delivered Quantity: %s", line.qty_delivered) + Markup("<br/>")
|
|
msg += _("Invoiced Quantity: %s", line.qty_invoiced) + Markup("<br/>")
|
|
msg += Markup("</ul>")
|
|
order.message_post(body=msg)
|
|
|
|
def _check_line_unlink(self):
|
|
""" Check whether given lines can be deleted or not.
|
|
|
|
* Lines cannot be deleted if the order is confirmed.
|
|
* Down payment lines who have not yet been invoiced bypass that exception.
|
|
* Sections and Notes can always be deleted.
|
|
|
|
:returns: Sales Order Lines that cannot be deleted
|
|
:rtype: `sale.order.line` recordset
|
|
"""
|
|
return self.filtered(
|
|
lambda line:
|
|
line.state == 'sale'
|
|
and (line.invoice_lines or not line.is_downpayment)
|
|
and not line.display_type
|
|
)
|
|
|
|
@api.ondelete(at_uninstall=False)
|
|
def _unlink_except_confirmed(self):
|
|
if self._check_line_unlink():
|
|
raise UserError(_("Once a sales order is confirmed, you can't remove one of its lines (we need to track if something gets invoiced or delivered).\n\
|
|
Set the quantity to 0 instead."))
|
|
|
|
#=== ACTION METHODS ===#
|
|
|
|
def action_add_from_catalog(self):
|
|
order = self.env['sale.order'].browse(self.env.context.get('order_id'))
|
|
return order.action_add_from_catalog()
|
|
|
|
#=== BUSINESS METHODS ===#
|
|
|
|
def _expected_date(self):
|
|
self.ensure_one()
|
|
if self.state == 'sale' 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 compute_uom_qty(self, new_qty, stock_move, rounding=True):
|
|
return self.product_uom._compute_quantity(new_qty, stock_move.product_uom, rounding)
|
|
|
|
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 values to create the new invoice line for a sales order line.
|
|
|
|
:param optional_values: any parameter that should be added to the returned invoice line
|
|
:rtype: dict
|
|
"""
|
|
self.ensure_one()
|
|
res = {
|
|
'display_type': self.display_type or 'product',
|
|
'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': [Command.set(self.tax_id.ids)],
|
|
'sale_line_ids': [Command.link(self.id)],
|
|
'is_downpayment': self.is_downpayment,
|
|
}
|
|
self._set_analytic_distribution(res, **optional_values)
|
|
if optional_values:
|
|
res.update(optional_values)
|
|
if self.display_type:
|
|
res['account_id'] = False
|
|
return res
|
|
|
|
def _set_analytic_distribution(self, inv_line_vals, **optional_values):
|
|
analytic_account_id = self.order_id.analytic_account_id.id
|
|
if self.analytic_distribution and not self.display_type:
|
|
inv_line_vals['analytic_distribution'] = self.analytic_distribution
|
|
if analytic_account_id and not self.display_type:
|
|
analytic_account_id = str(analytic_account_id)
|
|
if 'analytic_distribution' in inv_line_vals:
|
|
inv_line_vals['analytic_distribution'][analytic_account_id] = inv_line_vals['analytic_distribution'].get(analytic_account_id, 0) + 100
|
|
else:
|
|
inv_line_vals['analytic_distribution'] = {analytic_account_id: 100}
|
|
|
|
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 {}
|
|
|
|
def _validate_analytic_distribution(self):
|
|
for line in self.filtered(lambda l: not l.display_type and l.state in ['draft', 'sent']):
|
|
line._validate_distribution(**{
|
|
'product': line.product_id.id,
|
|
'business_domain': 'sale_order',
|
|
'company_id': line.company_id.id,
|
|
})
|
|
|
|
#=== CORE METHODS OVERRIDES ===#
|
|
|
|
def _get_partner_display(self):
|
|
self.ensure_one()
|
|
commercial_partner = self.order_partner_id.commercial_partner_id
|
|
return f'({commercial_partner.ref or commercial_partner.name})'
|
|
|
|
def _additional_name_per_id(self):
|
|
return {
|
|
so_line.id: so_line._get_partner_display()
|
|
for so_line in self
|
|
}
|
|
|
|
#=== HOOKS ===#
|
|
|
|
def _is_delivery(self):
|
|
self.ensure_one()
|
|
return False
|
|
|
|
def _is_not_sellable_line(self):
|
|
# True if the line is a computed line (reward, delivery, ...) that user cannot add manually
|
|
return False
|
|
|
|
def _get_product_catalog_lines_data(self, **kwargs):
|
|
""" Return information about sale order lines in `self`.
|
|
|
|
If `self` is empty, this method returns only the default value(s) needed for the product
|
|
catalog. In this case, the quantity that equals 0.
|
|
|
|
Otherwise, it returns a quantity and a price based on the product of the SOL(s) and whether
|
|
the product is read-only or not.
|
|
|
|
A product is considered read-only if the order is considered read-only (see
|
|
``SaleOrder._is_readonly`` for more details) or if `self` contains multiple records.
|
|
|
|
Note: This method cannot be called with multiple records that have different products linked.
|
|
|
|
:raise odoo.exceptions.ValueError: ``len(self.product_id) != 1``
|
|
:rtype: dict
|
|
:return: A dict with the following structure:
|
|
{
|
|
'quantity': float,
|
|
'price': float,
|
|
'readOnly': bool,
|
|
}
|
|
"""
|
|
if len(self) == 1:
|
|
return {
|
|
'quantity': self.product_uom_qty,
|
|
'price': self.price_unit,
|
|
'readOnly': self.order_id._is_readonly(),
|
|
}
|
|
elif self:
|
|
self.product_id.ensure_one()
|
|
order_line = self[0]
|
|
order = order_line.order_id
|
|
return {
|
|
'readOnly': True,
|
|
'price': order.pricelist_id._get_product_price(
|
|
product=order_line.product_id,
|
|
quantity=1.0,
|
|
currency=order.currency_id,
|
|
date=order.date_order,
|
|
**kwargs,
|
|
),
|
|
'quantity': sum(
|
|
self.mapped(
|
|
lambda line: line.product_uom._compute_quantity(
|
|
qty=line.product_uom_qty,
|
|
to_unit=line.product_id.uom_id,
|
|
)
|
|
)
|
|
),
|
|
}
|
|
else:
|
|
return {
|
|
'quantity': 0,
|
|
# price will be computed in batch with pricelist utils so not given here
|
|
}
|
|
|
|
#=== TOOLING ===#
|
|
|
|
def _convert_to_sol_currency(self, amount, currency):
|
|
"""Convert the given amount from the given currency to the SO(L) currency.
|
|
|
|
:param float amount: the amount to convert
|
|
:param currency: currency in which the given amount is expressed
|
|
:type currency: `res.currency` record
|
|
:returns: converted amount
|
|
:rtype: float
|
|
"""
|
|
self.ensure_one()
|
|
to_currency = self.currency_id or self.order_id.currency_id
|
|
if currency and to_currency and currency != to_currency:
|
|
conversion_date = self.order_id.date_order or fields.Date.context_today(self)
|
|
company = self.company_id or self.order_id.company_id or self.env.company
|
|
return currency._convert(
|
|
from_amount=amount,
|
|
to_currency=to_currency,
|
|
company=company,
|
|
date=conversion_date,
|
|
round=False,
|
|
)
|
|
return amount
|
|
|
|
def has_valued_move_ids(self):
|
|
return self.move_ids
|