19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
from collections import defaultdict
from odoo import api, fields, models, _
from odoo.fields import Domain
from odoo.tools import float_compare
from odoo.exceptions import UserError
@ -13,37 +13,68 @@ class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')])
route_id = fields.Many2one('stock.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict', check_company=True)
route_ids = fields.Many2many('stock.route', string='Routes', domain=[('sale_selectable', '=', True)], ondelete='restrict')
move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves')
virtual_available_at_date = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
virtual_available_at_date = fields.Float(compute='_compute_qty_at_date', digits='Product Unit')
scheduled_date = fields.Datetime(compute='_compute_qty_at_date')
forecast_expected_date = fields.Datetime(compute='_compute_qty_at_date')
free_qty_today = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
free_qty_today = fields.Float(compute='_compute_qty_at_date', digits='Product Unit')
qty_available_today = fields.Float(compute='_compute_qty_at_date')
warehouse_id = fields.Many2one(related='order_id.warehouse_id')
qty_to_deliver = fields.Float(compute='_compute_qty_to_deliver', digits='Product Unit of Measure')
warehouse_id = fields.Many2one('stock.warehouse', compute='_compute_warehouse_id', store=True)
qty_to_deliver = fields.Float(compute='_compute_qty_to_deliver', digits='Product Unit')
is_mto = fields.Boolean(compute='_compute_is_mto')
display_qty_widget = fields.Boolean(compute='_compute_qty_to_deliver')
is_storable = fields.Boolean(related='product_id.is_storable')
customer_lead = fields.Float(
compute='_compute_customer_lead', store=True, readonly=False, precompute=True,
inverse='_inverse_customer_lead')
@api.depends('product_type', 'product_uom_qty', 'qty_delivered', 'state', 'move_ids', 'product_uom')
@api.depends('route_ids', 'order_id.warehouse_id', 'product_id')
def _compute_warehouse_id(self):
for line in self:
line.warehouse_id = line.order_id.warehouse_id
if line.route_ids:
domain = [
('location_dest_id', 'in', line.order_id.partner_shipping_id.property_stock_customer.ids),
('action', '!=', 'push'),
]
# prefer rules on the route itself even if they pull from a different warehouse than the SO's
rules = sorted(
self.env['stock.rule'].search(
domain=Domain.AND([[('route_id', 'in', line.route_ids.ids)], domain]),
order='route_sequence, sequence'
),
# if there are multiple rules on the route, prefer those that pull from the SO's warehouse
# or those that are not warehouse specific
key=lambda rule: 0 if rule.location_src_id.warehouse_id in (False, line.order_id.warehouse_id) else 1
)
if rules:
line.warehouse_id = rules[0].location_src_id.warehouse_id
@api.depends('is_storable', 'product_uom_qty', 'qty_delivered', 'state', 'move_ids', 'product_uom_id')
def _compute_qty_to_deliver(self):
"""Compute the visibility of the inventory widget."""
for line in self:
line.qty_to_deliver = line.product_uom_qty - line.qty_delivered
if line.state in ('draft', 'sent', 'sale') and line.product_type == 'product' and line.product_uom and line.qty_to_deliver > 0:
if line.state == 'sale' and not line.move_ids:
if line.state in ('draft', 'sent', 'sale') and line.is_storable and line.product_uom_id and line.qty_to_deliver > 0:
if line.state == 'sale' and all(m.state in ['done', 'cancel'] for m in line.move_ids):
line.display_qty_widget = False
else:
line.display_qty_widget = True
else:
line.display_qty_widget = False
def _read_qties(self, date, wh):
return self.mapped('product_id').with_context(to_date=date, warehouse_id=wh).read([
'qty_available',
'free_qty',
'virtual_available',
])
@api.depends(
'product_id', 'customer_lead', 'product_uom_qty', 'product_uom', 'order_id.commitment_date',
'move_ids', 'move_ids.forecast_expected_date', 'move_ids.forecast_availability')
'product_id', 'customer_lead', 'product_uom_qty', 'product_uom_id', 'order_id.commitment_date',
'move_ids', 'move_ids.forecast_expected_date', 'move_ids.forecast_availability',
'warehouse_id')
def _compute_qty_at_date(self):
""" Compute the quantity forecasted of product at delivery date. There are
two cases:
@ -79,8 +110,8 @@ class SaleOrderLine(models.Model):
line.qty_available_today = 0
line.free_qty_today = 0
for move in moves:
line.qty_available_today += move.product_uom._compute_quantity(move.reserved_availability, line.product_uom)
line.free_qty_today += move.product_id.uom_id._compute_quantity(move.forecast_availability, line.product_uom)
line.qty_available_today += move.product_uom._compute_quantity(move.quantity, line.product_uom_id)
line.free_qty_today += move.product_id.uom_id._compute_quantity(move.forecast_availability, line.product_uom_id)
line.scheduled_date = line.order_id.commitment_date or line._expected_date()
line.virtual_available_at_date = False
treated |= line
@ -95,11 +126,7 @@ class SaleOrderLine(models.Model):
grouped_lines[(line.warehouse_id.id, line.order_id.commitment_date or line._expected_date())] |= line
for (warehouse, scheduled_date), lines in grouped_lines.items():
product_qties = lines.mapped('product_id').with_context(to_date=scheduled_date, warehouse=warehouse).read([
'qty_available',
'free_qty',
'virtual_available',
])
product_qties = lines._read_qties(scheduled_date, warehouse)
qties_per_product = {
product['id']: (product['qty_available'], product['free_qty'], product['virtual_available'])
for product in product_qties
@ -112,11 +139,11 @@ class SaleOrderLine(models.Model):
line.virtual_available_at_date = virtual_available_at_date - qty_processed_per_product[line.product_id.id]
line.forecast_expected_date = False
product_qty = line.product_uom_qty
if line.product_uom and line.product_id.uom_id and line.product_uom != line.product_id.uom_id:
line.qty_available_today = line.product_id.uom_id._compute_quantity(line.qty_available_today, line.product_uom)
line.free_qty_today = line.product_id.uom_id._compute_quantity(line.free_qty_today, line.product_uom)
line.virtual_available_at_date = line.product_id.uom_id._compute_quantity(line.virtual_available_at_date, line.product_uom)
product_qty = line.product_uom._compute_quantity(product_qty, line.product_id.uom_id)
if line.product_uom_id and line.product_id.uom_id and line.product_uom_id != line.product_id.uom_id:
line.qty_available_today = line.product_id.uom_id._compute_quantity(line.qty_available_today, line.product_uom_id)
line.free_qty_today = line.product_id.uom_id._compute_quantity(line.free_qty_today, line.product_uom_id)
line.virtual_available_at_date = line.product_id.uom_id._compute_quantity(line.virtual_available_at_date, line.product_uom_id)
product_qty = line.product_uom_id._compute_quantity(product_qty, line.product_id.uom_id)
qty_processed_per_product[line.product_id.id] += product_qty
treated |= lines
remaining = (self - treated)
@ -126,24 +153,24 @@ class SaleOrderLine(models.Model):
remaining.free_qty_today = False
remaining.qty_available_today = False
@api.depends('product_id', 'route_id', 'order_id.warehouse_id', 'product_id.route_ids')
@api.depends('product_id', 'route_ids', 'warehouse_id', 'product_id.route_ids')
def _compute_is_mto(self):
""" Verify the route of the product based on the warehouse
set 'is_available' at True if the product availability in stock does
not need to be verified, which is the case in MTO, Cross-Dock or Drop-Shipping
not need to be verified, which is the case in MTO, Drop-Shipping
"""
self.is_mto = False
for line in self:
if not line.display_qty_widget:
continue
product = line.product_id
product_routes = line.route_id or (product.route_ids + product.categ_id.total_route_ids)
product_routes = line.route_ids or (product.route_ids + product.categ_id.total_route_ids)
# Check MTO
mto_route = line.order_id.warehouse_id.mto_pull_id.route_id
mto_route = line.warehouse_id.mto_pull_id.route_id
if not mto_route:
try:
mto_route = self.env['stock.warehouse']._find_global_route('stock.route_warehouse0_mto', _('Make To Order'))
mto_route = self.env['stock.warehouse']._find_or_create_global_route('stock.route_warehouse0_mto', _('Replenish on Order (MTO)'), create=False)
except UserError:
# if route MTO not found in ir_model_data, we treat the product as in MTS
pass
@ -155,20 +182,22 @@ class SaleOrderLine(models.Model):
@api.depends('product_id')
def _compute_qty_delivered_method(self):
""" Stock module compute delivered qty for product [('type', 'in', ['consu', 'product'])]
""" Stock module compute delivered qty for product [('type', '=', 'consu')]
For SO line coming from expense, no picking should be generate: we don't manage stock for
those lines, even if the product is a storable.
"""
super(SaleOrderLine, self)._compute_qty_delivered_method()
for line in self:
if not line.is_expense and line.product_id.type in ['consu', 'product']:
if not line.is_expense and line.product_id.type == 'consu':
line.qty_delivered_method = 'stock_move'
@api.depends('move_ids.state', 'move_ids.scrapped', 'move_ids.quantity_done', 'move_ids.product_uom')
@api.depends('move_ids.state', 'move_ids.location_dest_usage', 'move_ids.quantity', 'move_ids.product_uom')
def _compute_qty_delivered(self):
super(SaleOrderLine, self)._compute_qty_delivered()
def _prepare_qty_delivered(self):
delivered_qties = super()._prepare_qty_delivered()
for line in self: # TODO: maybe one day, this should be done in SQL for performance sake
if line.qty_delivered_method == 'stock_move':
qty = 0.0
@ -176,36 +205,14 @@ class SaleOrderLine(models.Model):
for move in outgoing_moves:
if move.state != 'done':
continue
qty += move.product_uom._compute_quantity(move.quantity_done, line.product_uom, rounding_method='HALF-UP')
qty += move.product_uom._compute_quantity(move.quantity, line.product_uom_id, rounding_method='HALF-UP')
for move in incoming_moves:
if move.state != 'done':
continue
qty -= move.product_uom._compute_quantity(move.quantity_done, line.product_uom, rounding_method='HALF-UP')
line.qty_delivered = qty
qty -= move.product_uom._compute_quantity(move.quantity, line.product_uom_id, rounding_method='HALF-UP')
delivered_qties[line] = qty
return delivered_qties
@api.model_create_multi
def create(self, vals_list):
lines = super(SaleOrderLine, self).create(vals_list)
lines.filtered(lambda line: line.state == 'sale')._action_launch_stock_rule()
return lines
def write(self, values):
lines = self.env['sale.order.line']
if 'product_uom_qty' in values:
lines = self.filtered(lambda r: r.state == 'sale' and not r.is_expense)
if 'product_packaging_id' in values:
self.move_ids.filtered(
lambda m: m.state not in ['cancel', 'done']
).product_packaging_id = values['product_packaging_id']
previous_product_uom_qty = {line.id: line.product_uom_qty for line in lines}
res = super(SaleOrderLine, self).write(values)
if lines:
lines._action_launch_stock_rule(previous_product_uom_qty)
return res
@api.depends('order_id.state')
def _compute_invoice_status(self):
def check_moves_state(moves):
# All moves states are either 'done' or 'cancel', and there is at least one 'done'
@ -215,20 +222,39 @@ class SaleOrderLine(models.Model):
return False
at_least_one_done = at_least_one_done or move.state == 'done'
return at_least_one_done
super(SaleOrderLine, self)._compute_invoice_status()
super()._compute_invoice_status()
for line in self:
# We handle the following specific situation: a physical product is partially delivered,
# but we would like to set its invoice status to 'Fully Invoiced'. The use case is for
# products sold by weight, where the delivered quantity rarely matches exactly the
# quantity ordered.
if line.order_id.state == 'done'\
and line.invoice_status == 'no'\
and line.product_id.type in ['consu', 'product']\
and line.product_id.invoice_policy == 'delivery'\
and line.move_ids \
and check_moves_state(line.move_ids):
if (
line.state == 'sale'
and line.invoice_status == 'no'
and line.product_id.type in ['consu', 'product']
and line.product_id.invoice_policy == 'delivery'
and line.move_ids
and check_moves_state(line.move_ids)
):
line.invoice_status = 'invoiced'
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
lines.filtered(lambda line: line.state == 'sale')._action_launch_stock_rule()
return lines
def write(self, vals):
lines = self.env['sale.order.line']
if 'product_uom_qty' in vals:
lines = self.filtered(lambda r: r.state == 'sale' and not r.is_expense)
previous_product_uom_qty = {line.id: line.product_uom_qty for line in lines}
res = super().write(vals)
if lines:
lines._action_launch_stock_rule(previous_product_uom_qty=previous_product_uom_qty)
return res
@api.depends('move_ids')
def _compute_product_updatable(self):
super()._compute_product_updatable()
@ -248,117 +274,134 @@ class SaleOrderLine(models.Model):
# Propagate deadline on related stock move
line.move_ids.date_deadline = line.order_id.date_order + timedelta(days=line.customer_lead or 0.0)
def _prepare_procurement_values(self, group_id=False):
def _prepare_procurement_values(self):
""" 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.
"""
values = super(SaleOrderLine, self)._prepare_procurement_values(group_id)
values = super()._prepare_procurement_values()
self.ensure_one()
# Use the delivery date if there is else use date_order and lead time
date_deadline = self.order_id.commitment_date or self._expected_date()
date_planned = date_deadline - timedelta(days=self.order_id.company_id.security_lead)
values.update({
'group_id': group_id,
'origin': self.order_id.name,
'reference_ids': self.order_id.stock_reference_ids,
'sale_line_id': self.id,
'date_planned': date_planned,
'date_deadline': date_deadline,
'route_ids': self.route_id,
'warehouse_id': self.order_id.warehouse_id or False,
'route_ids': self.route_ids,
'warehouse_id': self.warehouse_id,
'partner_id': self.order_id.partner_shipping_id.id,
'product_description_variants': self.with_context(lang=self.order_id.partner_id.lang)._get_sale_order_line_multiline_description_variants(),
'location_final_id': self._get_location_final(),
'product_description_variants': self.with_context(lang=self.order_id.partner_id.lang)._get_sale_order_line_multiline_description_variants().strip(),
'company_id': self.order_id.company_id,
'product_packaging_id': self.product_packaging_id,
'sequence': self.sequence,
'never_product_template_attribute_value_ids': self.product_no_variant_attribute_value_ids,
'packaging_uom_id': self.product_uom_id,
})
return values
def _get_location_final(self):
# Can be overriden for inter-company transactions.
self.ensure_one()
return self.order_id.partner_shipping_id.property_stock_customer
def _get_qty_procurement(self, previous_product_uom_qty=False):
self.ensure_one()
qty = 0.0
outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves()
outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves(strict=False)
for move in outgoing_moves:
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
qty_to_compute = move.quantity if move.state == 'done' else move.product_uom_qty
qty += move.product_uom._compute_quantity(qty_to_compute, self.product_uom_id, rounding_method='HALF-UP')
for move in incoming_moves:
qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
qty_to_compute = move.quantity if move.state == 'done' else move.product_uom_qty
qty -= move.product_uom._compute_quantity(qty_to_compute, self.product_uom_id, rounding_method='HALF-UP')
return qty
def _get_outgoing_incoming_moves(self):
def _get_outgoing_incoming_moves(self, strict=True):
""" Return the outgoing and incoming moves of the sale order line.
@param strict: If True, only consider the moves that are strictly delivered to the customer (old behavior).
If False, consider the moves that were created through the initial rule of the delivery route,
to support the new push mechanism.
"""
outgoing_moves_ids = set()
incoming_moves_ids = set()
moves = self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id)
if self._context.get('accrual_entry_date'):
moves = moves.filtered(lambda r: fields.Date.context_today(r, r.date) <= self._context['accrual_entry_date'])
moves = self.move_ids.filtered(lambda r: r.state != 'cancel' and r.location_dest_usage != 'inventory' and self.product_id == r.product_id)
if moves and not strict:
# The first move created was the one created from the intial rule that started it all.
sorted_moves = moves.sorted('id')
triggering_rule_ids = []
seen_wh_ids = set()
for move in sorted_moves:
if move.warehouse_id.id not in seen_wh_ids:
triggering_rule_ids.append(move.rule_id.id)
seen_wh_ids.add(move.warehouse_id.id)
if self.env.context.get('accrual_entry_date'):
accrual_date = fields.Date.from_string(self.env.context['accrual_entry_date'])
moves = moves.filtered(lambda r: fields.Date.context_today(r, r.date) <= accrual_date)
for move in moves:
if move.location_dest_id.usage == "customer":
if not move._is_dropshipped_returned() and (
(strict and move.location_dest_id._is_outgoing()) or (
not strict and move.rule_id.id in triggering_rule_ids and
(move.location_final_id or move.location_dest_id)._is_outgoing()
)):
if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund):
outgoing_moves_ids.add(move.id)
elif move.location_dest_id.usage != "customer" and move.to_refund:
elif move.to_refund and (
(strict and move._is_incoming() or move.location_id._is_outgoing()) or (
not strict and move.rule_id.id in triggering_rule_ids and
(move.location_final_id or move.location_dest_id).usage == 'internal'
)):
incoming_moves_ids.add(move.id)
return self.env['stock.move'].browse(outgoing_moves_ids), self.env['stock.move'].browse(incoming_moves_ids)
def _get_procurement_group(self):
return self.order_id.procurement_group_id
def _prepare_procurement_group_vals(self):
def _prepare_reference_vals(self):
return {
'name': self.order_id.name,
'move_type': self.order_id.picking_policy,
'sale_id': self.order_id.id,
'partner_id': self.order_id.partner_shipping_id.id,
'sale_ids': [(4, self.order_id.id)],
}
def _action_launch_stock_rule(self, previous_product_uom_qty=False):
def _create_procurements(self, product_qty, procurement_uom, values):
self.ensure_one()
return [self.env['stock.rule'].Procurement(
self.product_id, product_qty, procurement_uom, self._get_location_final(),
self.product_id.display_name, self.order_id.name, self.order_id.company_id, values)]
def _action_launch_stock_rule(self, *, previous_product_uom_qty=False):
"""
Launch procurement group run method with required/custom fields generated by a
sale order line. procurement group will launch '_run_pull', '_run_buy' or '_run_manufacture'
Launch procurement run method with required/custom fields generated by a
sale order line. procurement will launch '_run_pull', '_run_buy' or '_run_manufacture'
depending on the sale order line product rule.
"""
if self._context.get("skip_procurement"):
if self.env.context.get("skip_procurement"):
return True
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
precision = self.env['decimal.precision'].precision_get('Product Unit')
procurements = []
for line in self:
line = line.with_company(line.company_id)
if line.state != 'sale' or not line.product_id.type in ('consu', 'product'):
if line.state != 'sale' or line.order_id.locked or line.product_id.type != 'consu':
continue
qty = line._get_qty_procurement(previous_product_uom_qty)
if float_compare(qty, line.product_uom_qty, precision_digits=precision) == 0:
continue
group_id = line._get_procurement_group()
if not group_id:
group_id = self.env['procurement.group'].create(line._prepare_procurement_group_vals())
line.order_id.procurement_group_id = group_id
else:
# In case the procurement group is already created and the order was
# cancelled, we need to update certain values of the group.
updated_vals = {}
if group_id.partner_id != line.order_id.partner_shipping_id:
updated_vals.update({'partner_id': line.order_id.partner_shipping_id.id})
if group_id.move_type != line.order_id.picking_policy:
updated_vals.update({'move_type': line.order_id.picking_policy})
if updated_vals:
group_id.write(updated_vals)
references = line.order_id.stock_reference_ids
if not references:
self.env['stock.reference'].create(line._prepare_reference_vals())
values = line._prepare_procurement_values(group_id=group_id)
values = line._prepare_procurement_values()
product_qty = line.product_uom_qty - qty
line_uom = line.product_uom
line_uom = line.product_uom_id
quant_uom = line.product_id.uom_id
product_qty, procurement_uom = line_uom._adjust_uom_quantities(product_qty, quant_uom)
procurements.append(self.env['procurement.group'].Procurement(
line.product_id, product_qty, procurement_uom,
line.order_id.partner_shipping_id.property_stock_customer,
line.product_id.display_name, line.order_id.name, line.order_id.company_id, values))
procurements += line._create_procurements(product_qty, procurement_uom, values)
if procurements:
procurement_group = self.env['procurement.group']
if self.env.context.get('import_file'):
procurement_group = procurement_group.with_context(import_file=False)
procurement_group.run(procurements)
self.env['stock.rule'].run(procurements)
# This next block is currently needed only because the scheduler trigger is done by picking confirmation rather than stock.move confirmation
orders = self.mapped('order_id')
@ -370,8 +413,45 @@ class SaleOrderLine(models.Model):
return True
def _update_line_quantity(self, values):
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu'])
precision = self.env['decimal.precision'].precision_get('Product Unit')
line_products = self.filtered(lambda l: l.product_id.type == 'consu')
if line_products.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(line_products.mapped('qty_delivered')), precision_digits=precision) == -1:
raise UserError(_('The ordered quantity of a sale order line cannot be decreased below the amount already delivered. Instead, create a return in your inventory.'))
super(SaleOrderLine, self)._update_line_quantity(values)
#=== HOOKS ===#
# FIXME VFE this hook is supported on the order, not the order line
def _get_action_add_from_catalog_extra_context(self, order):
extra_context = super()._get_action_add_from_catalog_extra_context(order)
extra_context.update(warehouse_id=order.warehouse_id.id)
return extra_context
def _get_product_catalog_lines_data(self, **kwargs):
""" Override of `sale` to add the delivered quantity.
:rtype: dict
:return: A dict with the following structure:
{
'deliveredQty': float,
'quantity': float,
'price': float,
'readOnly': bool,
}
"""
res = super()._get_product_catalog_lines_data(**kwargs)
res['deliveredQty'] = sum(
self.mapped(
lambda line: line.product_uom_id._compute_quantity(
qty=line.qty_delivered,
to_unit=line.product_id.uom_id,
)
)
)
return res
def has_valued_move_ids(self):
return (
any(move.state not in ('cancel', 'draft') for move in self.move_ids)
or super().has_valued_move_ids() # TODO: remove in master
)