1204 lines
55 KiB
Python
1204 lines
55 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 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
|
||
|
|
||
|
|
||
|
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)
|
||
|
|
||
|
# 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', check_company=True, index='btree_not_null',
|
||
|
domain="[('sale_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', company_id)]")
|
||
|
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)
|
||
|
|
||
|
# The price_reduce field should not be used for amounts computations
|
||
|
# because of its digits precision. It will be removed in next version.
|
||
|
price_reduce = fields.Float(
|
||
|
string="Price Reduce",
|
||
|
compute='_compute_price_reduce',
|
||
|
digits='Product Price',
|
||
|
store=True, precompute=True)
|
||
|
price_subtotal = fields.Monetary(
|
||
|
string="Subtotal",
|
||
|
compute='_compute_amount',
|
||
|
store=True, precompute=True)
|
||
|
price_tax = fields.Float(
|
||
|
string="Total Tax",
|
||
|
compute='_compute_amount',
|
||
|
store=True, precompute=True)
|
||
|
price_total = fields.Monetary(
|
||
|
string="Total",
|
||
|
compute='_compute_amount',
|
||
|
store=True, precompute=True)
|
||
|
price_reduce_taxexcl = fields.Monetary(
|
||
|
string="Price Reduce Tax excl",
|
||
|
compute='_compute_price_reduce_taxexcl',
|
||
|
store=True, precompute=True)
|
||
|
price_reduce_taxinc = fields.Monetary(
|
||
|
string="Price Reduce Tax incl",
|
||
|
compute='_compute_price_reduce_taxinc',
|
||
|
store=True, precompute=True)
|
||
|
|
||
|
# Logistics/Delivery fields
|
||
|
product_packaging_id = fields.Many2one(
|
||
|
comodel_name='product.packaging',
|
||
|
string="Packaging",
|
||
|
compute='_compute_product_packaging_id',
|
||
|
store=True, readonly=False, precompute=True,
|
||
|
domain="[('sales', '=', True), ('product_id','=',product_id)]",
|
||
|
check_company=True)
|
||
|
product_packaging_qty = fields.Float(
|
||
|
string="Packaging Quantity",
|
||
|
compute='_compute_product_packaging_qty',
|
||
|
store=True, readonly=False, precompute=True)
|
||
|
|
||
|
customer_lead = fields.Float(
|
||
|
string="Lead Time",
|
||
|
compute='_compute_customer_lead',
|
||
|
store=True, readonly=False, required=True, precompute=True,
|
||
|
help="Number of days between the order confirmation and the shipping of the products to the customer")
|
||
|
|
||
|
qty_delivered_method = fields.Selection(
|
||
|
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 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')
|
||
|
|
||
|
#=== COMPUTE METHODS ===#
|
||
|
|
||
|
@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)
|
||
|
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
|
||
|
|
||
|
# display the no_variant attributes, except those that are also
|
||
|
# displayed by a custom (avoid duplicate description)
|
||
|
for ptav in (no_variant_ptavs - custom_ptavs):
|
||
|
name += "\n" + ptav.display_name
|
||
|
|
||
|
# 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 = taxes_by_product_company[(line.product_id, company)]
|
||
|
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,
|
||
|
line.product_uom_qty or 1.0,
|
||
|
uom=line.product_uom,
|
||
|
date=line._get_order_date(),
|
||
|
)
|
||
|
|
||
|
@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_from_price(
|
||
|
price,
|
||
|
line.currency_id or line.order_id.currency_id,
|
||
|
product_taxes=line.product_id.taxes_id.filtered(
|
||
|
lambda tax: tax.company_id == line.env.company
|
||
|
),
|
||
|
fiscal_position=line.order_id.fiscal_position_id,
|
||
|
)
|
||
|
|
||
|
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()
|
||
|
|
||
|
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()
|
||
|
|
||
|
pricelist_rule = self.pricelist_item_id
|
||
|
order_date = self._get_order_date() or fields.Date.today()
|
||
|
product = self.product_id.with_context(**self._get_product_price_context())
|
||
|
qty = self.product_uom_qty or 1.0
|
||
|
uom = self.product_uom or self.product_id.uom_id
|
||
|
currency = self.currency_id or self.order_id.company_id.currency_id
|
||
|
|
||
|
price = pricelist_rule._compute_price(
|
||
|
product, qty, uom, order_date, currency=currency)
|
||
|
|
||
|
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()
|
||
|
res = {}
|
||
|
|
||
|
# It is possible that a no_variant attribute is still in a variant if
|
||
|
# the type of the attribute has been changed after creation.
|
||
|
no_variant_attributes_price_extra = [
|
||
|
ptav.price_extra for ptav in self.product_no_variant_attribute_value_ids.filtered(
|
||
|
lambda ptav:
|
||
|
ptav.price_extra and
|
||
|
ptav not in self.product_id.product_template_attribute_value_ids
|
||
|
)
|
||
|
]
|
||
|
if no_variant_attributes_price_extra:
|
||
|
res['no_variant_attributes_price_extra'] = tuple(no_variant_attributes_price_extra)
|
||
|
|
||
|
return res
|
||
|
|
||
|
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()
|
||
|
|
||
|
pricelist_rule = self.pricelist_item_id
|
||
|
order_date = self._get_order_date() or fields.Date.today()
|
||
|
product = self.product_id.with_context(**self._get_product_price_context())
|
||
|
qty = self.product_uom_qty or 1.0
|
||
|
uom = self.product_uom
|
||
|
|
||
|
if pricelist_rule:
|
||
|
pricelist_item = pricelist_rule
|
||
|
if pricelist_item.pricelist_id.discount_policy == 'without_discount':
|
||
|
# Find the lowest pricelist rule whose pricelist is configured
|
||
|
# to show the discount to the customer.
|
||
|
while pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id.discount_policy == 'without_discount':
|
||
|
rule_id = pricelist_item.base_pricelist_id._get_product_rule(
|
||
|
product, qty, uom=uom, date=order_date)
|
||
|
pricelist_item = self.env['product.pricelist.item'].browse(rule_id)
|
||
|
|
||
|
pricelist_rule = pricelist_item
|
||
|
|
||
|
price = pricelist_rule._compute_base_price(
|
||
|
product,
|
||
|
qty,
|
||
|
uom,
|
||
|
order_date,
|
||
|
target_currency=self.currency_id,
|
||
|
)
|
||
|
|
||
|
return price
|
||
|
|
||
|
@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
|
||
|
|
||
|
@api.depends('price_unit', 'discount')
|
||
|
def _compute_price_reduce(self):
|
||
|
for line in self:
|
||
|
line.price_reduce = line.price_unit * (1.0 - line.discount / 100.0)
|
||
|
|
||
|
def _convert_to_tax_base_line_dict(self):
|
||
|
""" 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,
|
||
|
)
|
||
|
|
||
|
@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'].with_company(line.company_id)._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):
|
||
|
for line in self:
|
||
|
if not line.product_packaging_id:
|
||
|
line.product_packaging_qty = False
|
||
|
else:
|
||
|
packaging_uom = line.product_packaging_id.product_uom_id
|
||
|
packaging_uom_qty = line.product_uom._compute_quantity(line.product_uom_qty, packaging_uom)
|
||
|
line.product_packaging_qty = float_round(
|
||
|
packaging_uom_qty / line.product_packaging_id.qty,
|
||
|
precision_rounding=packaging_uom.rounding)
|
||
|
|
||
|
# This computed default is necessary to have a clean computation inheritance
|
||
|
# (cf sale_stock) instead of simply removing the default and specifying
|
||
|
# the compute attribute & method in sale_stock.
|
||
|
def _compute_customer_lead(self):
|
||
|
self.customer_lead = 0.0
|
||
|
|
||
|
@api.depends('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 self.invoice_status == 'invoiced' and not invoice_lines:
|
||
|
return ''
|
||
|
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 = {}
|
||
|
|
||
|
# avoid recomputation if no SO lines concerned
|
||
|
if not self:
|
||
|
return result
|
||
|
|
||
|
# group analytic lines by product uom and so line
|
||
|
domain = expression.AND([[('so_line', 'in', self.ids)], additional_domain])
|
||
|
data = self.env['account.analytic.line'].read_group(
|
||
|
domain,
|
||
|
['so_line', 'unit_amount', 'product_uom_id', 'move_line_id:count_distinct'], ['product_uom_id', 'so_line'], lazy=False
|
||
|
)
|
||
|
|
||
|
# convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines
|
||
|
# browse so lines and product uoms here to make them share the same prefetch
|
||
|
lines = self.browse([item['so_line'][0] for item in data])
|
||
|
lines_map = {line.id: line for line in lines}
|
||
|
product_uom_ids = [item['product_uom_id'][0] for item in data if item['product_uom_id']]
|
||
|
product_uom_map = {uom.id: uom for uom in self.env['uom.uom'].browse(product_uom_ids)}
|
||
|
for item in data:
|
||
|
if not item['product_uom_id']:
|
||
|
continue
|
||
|
so_line_id = item['so_line'][0]
|
||
|
so_line = lines_map[so_line_id]
|
||
|
result.setdefault(so_line_id, 0.0)
|
||
|
uom = product_uom_map.get(item['product_uom_id'][0])
|
||
|
# avoid counting unit_amount twice when dealing with multiple analytic lines on the same move line
|
||
|
if item['move_line_id'] == 1 and item['__count'] > 1:
|
||
|
qty = item['unit_amount'] / item['__count']
|
||
|
else:
|
||
|
qty = item['unit_amount']
|
||
|
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 in ['sale', 'done'] 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' or 'done', we consider that there is nothing to
|
||
|
invoice. This is also the default value if the conditions of no other status is met.
|
||
|
- to invoice: we refer to the quantity to invoice of the line. Refer to method
|
||
|
`_compute_qty_to_invoice()` for more information on how this quantity is calculated.
|
||
|
- upselling: this is possible only for a product invoiced on ordered quantities for which
|
||
|
we delivered more than expected. The could arise if, for example, a project took more
|
||
|
time than expected but we decided not to invoice the extra cost to the client. This
|
||
|
occurs only in state 'sale', so that when a SO is set to done, the upselling opportunity
|
||
|
is removed from the list.
|
||
|
- invoiced: the quantity invoiced is larger or equal to the quantity ordered.
|
||
|
"""
|
||
|
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||
|
for line in self:
|
||
|
if line.state not in ('sale', 'done'):
|
||
|
line.invoice_status = 'no'
|
||
|
elif line.is_downpayment and line.untaxed_amount_to_invoice == 0:
|
||
|
line.invoice_status = 'invoiced'
|
||
|
elif not float_is_zero(line.qty_to_invoice, precision_digits=precision):
|
||
|
line.invoice_status = 'to invoice'
|
||
|
elif line.state == 'sale' and line.product_id.invoice_policy == 'order' and\
|
||
|
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' 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('state', 'price_reduce', 'product_id', 'untaxed_amount_invoiced', 'qty_delivered', 'product_uom_qty')
|
||
|
def _compute_untaxed_amount_to_invoice(self):
|
||
|
""" Total of remaining amount to invoice on the sale order line (taxes excl.) as
|
||
|
total_sol - amount already invoiced
|
||
|
where Total_sol depends on the invoice policy of the product.
|
||
|
|
||
|
Note: Draft invoice are ignored on purpose, the 'to invoice' amount should
|
||
|
come only from the SO lines.
|
||
|
"""
|
||
|
for line in self:
|
||
|
amount_to_invoice = 0.0
|
||
|
if line.state in ['sale', 'done']:
|
||
|
# Note: do not use price_subtotal field as it returns zero when the ordered quantity is
|
||
|
# zero. It causes problem for expense line (e.i.: ordered qty = 0, deli qty = 4,
|
||
|
# price_unit = 20 ; subtotal is zero), but when you can invoice the line, you see an
|
||
|
# amount and not zero. Since we compute untaxed amount, we can use directly the price
|
||
|
# reduce (to include discount) without using `compute_all()` method on taxes.
|
||
|
price_subtotal = 0.0
|
||
|
uom_qty_to_consider = line.qty_delivered if line.product_id.invoice_policy == 'delivery' else line.product_uom_qty
|
||
|
price_reduce = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
|
||
|
price_subtotal = price_reduce * uom_qty_to_consider
|
||
|
if len(line.tax_id.filtered(lambda tax: tax.price_include)) > 0:
|
||
|
# As included taxes are not excluded from the computed subtotal, `compute_all()` method
|
||
|
# has to be called to retrieve the subtotal without them.
|
||
|
# `price_reduce_taxexcl` cannot be used as it is computed from `price_subtotal` field. (see upper Note)
|
||
|
price_subtotal = line.tax_id.compute_all(
|
||
|
price_reduce,
|
||
|
currency=line.currency_id,
|
||
|
quantity=uom_qty_to_consider,
|
||
|
product=line.product_id,
|
||
|
partner=line.order_id.partner_shipping_id)['total_excluded']
|
||
|
inv_lines = line._get_invoice_lines()
|
||
|
if any(inv_lines.mapped(lambda l: l.discount != line.discount)):
|
||
|
# In case of re-invoicing with different discount we try to calculate manually the
|
||
|
# remaining amount to invoice
|
||
|
amount = 0
|
||
|
for l in inv_lines:
|
||
|
if len(l.tax_ids.filtered(lambda tax: tax.price_include)) > 0:
|
||
|
amount += l.tax_ids.compute_all(l.currency_id._convert(l.price_unit, line.currency_id, line.company_id, l.date or fields.Date.today(), round=False) * l.quantity)['total_excluded']
|
||
|
else:
|
||
|
amount += l.currency_id._convert(l.price_unit, line.currency_id, line.company_id, l.date or fields.Date.today(), round=False) * l.quantity
|
||
|
|
||
|
amount_to_invoice = max(price_subtotal - amount, 0)
|
||
|
else:
|
||
|
amount_to_invoice = price_subtotal - line.untaxed_amount_invoiced
|
||
|
|
||
|
line.untaxed_amount_to_invoice = amount_to_invoice
|
||
|
|
||
|
@api.depends('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 in ['done', 'cancel'] or (line.state == 'sale' and (line.qty_invoiced > 0 or line.qty_delivered > 0)):
|
||
|
line.product_updatable = False
|
||
|
else:
|
||
|
line.product_updatable = True
|
||
|
|
||
|
@api.depends('state')
|
||
|
def _compute_product_uom_readonly(self):
|
||
|
for line in self:
|
||
|
# line.ids checks whether it's a new record not yet saved
|
||
|
line.product_uom_readonly = line.ids and line.state in ['sale', 'done', 'cancel']
|
||
|
|
||
|
#=== CONSTRAINT METHODS ===#
|
||
|
|
||
|
#=== ONCHANGE METHODS ===#
|
||
|
|
||
|
@api.onchange('product_id')
|
||
|
def _onchange_product_id_warning(self):
|
||
|
if not self.product_id:
|
||
|
return
|
||
|
|
||
|
product = self.product_id
|
||
|
if product.sale_line_warn != 'no-message':
|
||
|
if product.sale_line_warn == 'block':
|
||
|
self.product_id = False
|
||
|
|
||
|
return {
|
||
|
'warning': {
|
||
|
'title': _("Warning for %s", product.name),
|
||
|
'message': product.sale_line_warn_msg,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@api.onchange('product_packaging_id')
|
||
|
def _onchange_product_packaging_id(self):
|
||
|
if self.product_packaging_id and self.product_uom_qty:
|
||
|
newqty = self.product_packaging_id._check_qty(self.product_uom_qty, self.product_uom, "UP")
|
||
|
if float_compare(newqty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0:
|
||
|
return {
|
||
|
'warning': {
|
||
|
'title': _('Warning'),
|
||
|
'message': _(
|
||
|
"This product is packaged by %(pack_size).2f %(pack_name)s. You should sell %(quantity).2f %(unit)s.",
|
||
|
pack_size=self.product_packaging_id.qty,
|
||
|
pack_name=self.product_id.uom_id.name,
|
||
|
quantity=newqty,
|
||
|
unit=self.product_uom.name
|
||
|
),
|
||
|
},
|
||
|
}
|
||
|
|
||
|
#=== CRUD METHODS ===#
|
||
|
def _add_precomputed_values(self, vals_list):
|
||
|
""" In case an editable precomputed field is provided in the create values
|
||
|
without being rounded, we have to 'manually' round it otherwise it won't be,
|
||
|
because those field values are kept 'as is'.
|
||
|
|
||
|
This is a temporary fix until the problem is fixed in the ORM.
|
||
|
"""
|
||
|
for vals in vals_list:
|
||
|
for fname in ('discount', 'product_uom_qty'):
|
||
|
if fname in vals:
|
||
|
vals[fname] = self._fields[fname].convert_to_cache(vals[fname], self)
|
||
|
return super()._add_precomputed_values(vals_list)
|
||
|
|
||
|
@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)
|
||
|
for line in lines:
|
||
|
if line.product_id and line.state == 'sale':
|
||
|
msg = _("Extra line with %s", line.product_id.display_name)
|
||
|
line.order_id.message_post(body=msg)
|
||
|
# create an analytic account if at least an expense product
|
||
|
if line.product_id.expense_policy not in [False, 'no'] and not line.order_id.analytic_account_id:
|
||
|
line.order_id._create_analytic_account()
|
||
|
return lines
|
||
|
|
||
|
def write(self, values):
|
||
|
if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')):
|
||
|
raise UserError(_("You cannot change the type of a sale order line. Instead you should delete the current line and create a new line of the proper type."))
|
||
|
|
||
|
if 'product_uom_qty' in values:
|
||
|
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||
|
self.filtered(
|
||
|
lambda r: r.state == 'sale' and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) != 0)._update_line_quantity(values)
|
||
|
|
||
|
# Prevent writing on a locked SO.
|
||
|
protected_fields = self._get_protected_fields()
|
||
|
if 'done' in self.mapped('state') and any(f in values.keys() for f in protected_fields):
|
||
|
protected_fields_modified = list(set(protected_fields) & set(values.keys()))
|
||
|
|
||
|
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 = "<b>" + _("The ordered quantity has been updated.") + "</b><ul>"
|
||
|
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 += "<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"]
|
||
|
) + "<br/>"
|
||
|
if line.product_id.type in ('consu', 'product'):
|
||
|
msg += _("Delivered Quantity: %s", line.qty_delivered) + "<br/>"
|
||
|
msg += _("Invoiced Quantity: %s", line.qty_invoiced) + "<br/>"
|
||
|
msg += "</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 in ('sale', 'done')
|
||
|
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(_("You can not remove an order line once the sales order is confirmed.\nYou should rather set the quantity to 0."))
|
||
|
|
||
|
#=== BUSINESS METHODS ===#
|
||
|
|
||
|
def _expected_date(self):
|
||
|
self.ensure_one()
|
||
|
if self.state in ['sale', 'done'] and self.order_id.date_order:
|
||
|
order_date = self.order_id.date_order
|
||
|
else:
|
||
|
order_date = fields.Datetime.now()
|
||
|
return order_date + timedelta(days=self.customer_lead or 0.0)
|
||
|
|
||
|
def 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,
|
||
|
}
|
||
|
analytic_account_id = self.order_id.analytic_account_id.id
|
||
|
if self.analytic_distribution and not self.display_type:
|
||
|
res['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 res:
|
||
|
res['analytic_distribution'][analytic_account_id] = res['analytic_distribution'].get(analytic_account_id, 0) + 100
|
||
|
else:
|
||
|
res['analytic_distribution'] = {analytic_account_id: 100}
|
||
|
if optional_values:
|
||
|
res.update(optional_values)
|
||
|
if self.display_type:
|
||
|
res['account_id'] = False
|
||
|
return res
|
||
|
|
||
|
def _prepare_procurement_values(self, group_id=False):
|
||
|
""" Prepare specific key for moves or other components that will be created from a stock rule
|
||
|
coming from a sale order line. This method could be override in order to add other custom key that could
|
||
|
be used in move/po creation.
|
||
|
"""
|
||
|
return {}
|
||
|
|
||
|
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,
|
||
|
})
|
||
|
|
||
|
def _get_downpayment_line_price_unit(self, invoices):
|
||
|
return sum(
|
||
|
l.price_unit if l.move_id.move_type == 'out_invoice' else -l.price_unit
|
||
|
for l in self.invoice_lines
|
||
|
if l.move_id.state == 'posted' and l.move_id not in invoices # don't recompute with the final invoice
|
||
|
)
|
||
|
|
||
|
#=== CORE METHODS OVERRIDES ===#
|
||
|
|
||
|
def name_get(self):
|
||
|
result = []
|
||
|
for so_line in self.sudo():
|
||
|
name = '%s - %s' % (so_line.order_id.name, so_line.name and so_line.name.split('\n')[0] or so_line.product_id.name)
|
||
|
if so_line.order_partner_id.ref:
|
||
|
name = '%s (%s)' % (name, so_line.order_partner_id.ref)
|
||
|
result.append((so_line.id, name))
|
||
|
return result
|
||
|
|
||
|
#=== 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 _sellable_lines_domain(self):
|
||
|
return [('is_downpayment', '=', False)]
|