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

@ -9,3 +9,4 @@ from . import res_users
from . import sale_order
from . import sale_order_line
from . import stock
from . import stock_reference

View file

@ -3,7 +3,7 @@
from collections import defaultdict
from odoo import fields, models
from odoo import models, api
from odoo.tools import float_is_zero, float_compare
from odoo.tools.misc import formatLang
@ -14,7 +14,7 @@ class AccountMove(models.Model):
def _stock_account_get_last_step_stock_moves(self):
""" Overridden from stock_account.
Returns the stock moves associated to this invoice."""
rslt = super(AccountMove, self)._stock_account_get_last_step_stock_moves()
rslt = super()._stock_account_get_last_step_stock_moves()
for invoice in self:
if invoice.move_type not in ['out_invoice', 'out_refund']:
continue
@ -32,13 +32,13 @@ class AccountMove(models.Model):
""" Get and prepare data to show a table of invoiced lot on the invoice's report. """
self.ensure_one()
res = super(AccountMove, self)._get_invoiced_lot_values()
res = super()._get_invoiced_lot_values()
if self.state == 'draft' or not self.invoice_date or self.move_type not in ('out_invoice', 'out_refund'):
return res
current_invoice_amls = self.invoice_line_ids.filtered(lambda aml: aml.display_type == 'product' and aml.product_id and aml.product_id.type in ('consu', 'product') and aml.quantity)
all_invoices_amls = current_invoice_amls.sale_line_ids.invoice_lines.filtered(lambda aml: aml.move_id.state == 'posted').sorted(lambda aml: (aml.date, aml.move_name, aml.id))
current_invoice_amls = self.invoice_line_ids.filtered(lambda aml: aml.display_type == 'product' and aml.product_id and aml.product_id.type == 'consu' and aml.quantity)
all_invoices_amls = current_invoice_amls.sale_line_ids.invoice_lines.filtered(lambda aml: aml._filter_aml_lot_valuation()).sorted(lambda aml: (aml.date, aml.move_name, aml.id))
index = all_invoices_amls.ids.index(current_invoice_amls[:1].id) if current_invoice_amls[:1] in all_invoices_amls else 0
previous_amls = all_invoices_amls[:index]
invoiced_qties = current_invoice_amls._get_invoiced_qty_per_product()
@ -62,11 +62,11 @@ class AccountMove(models.Model):
previous_qties_delivered = defaultdict(float)
stock_move_lines = current_invoice_amls.sale_line_ids.move_ids.move_line_ids.filtered(lambda sml: sml.state == 'done' and sml.lot_id).sorted(lambda sml: (sml.date, sml.id))
for sml in stock_move_lines:
if sml.product_id not in invoiced_products or 'customer' not in {sml.location_id.usage, sml.location_dest_id.usage}:
if sml.product_id not in invoiced_products or not sml._should_show_lot_in_invoice():
continue
product = sml.product_id
product_uom = product.uom_id
qty_done = sml.product_uom_id._compute_quantity(sml.qty_done, product_uom)
quantity = sml.product_uom_id._compute_quantity(sml.quantity, product_uom)
# is it a stock return considering the document type (should it be it thought of as positively or negatively?)
is_stock_return = (
@ -75,35 +75,33 @@ class AccountMove(models.Model):
self.move_type == 'out_refund' and (sml.location_id.usage, sml.location_dest_id.usage) == ('internal', 'customer')
)
if is_stock_return:
returned_qty = min(qties_per_lot[sml.lot_id], qty_done)
returned_qty = min(qties_per_lot[sml.lot_id], quantity)
qties_per_lot[sml.lot_id] -= returned_qty
qty_done = returned_qty - qty_done
quantity = returned_qty - quantity
previous_qty_invoiced = previous_qties_invoiced[product]
previous_qty_delivered = previous_qties_delivered[product]
# If we return more than currently delivered (i.e., qty_done < 0), we remove the surplus
# from the previously delivered (and qty_done becomes zero). If it's a delivery, we first
# If we return more than currently delivered (i.e., quantity < 0), we remove the surplus
# from the previously delivered (and quantity becomes zero). If it's a delivery, we first
# try to reach the previous_qty_invoiced
if float_compare(qty_done, 0, precision_rounding=product_uom.rounding) < 0 or \
float_compare(previous_qty_delivered, previous_qty_invoiced, precision_rounding=product_uom.rounding) < 0:
previously_done = qty_done if is_stock_return else min(previous_qty_invoiced - previous_qty_delivered, qty_done)
if product_uom.compare(quantity, 0) < 0 or product_uom.compare(previous_qty_delivered, previous_qty_invoiced) < 0:
previously_done = quantity if is_stock_return else min(previous_qty_invoiced - previous_qty_delivered, quantity)
previous_qties_delivered[product] += previously_done
qty_done -= previously_done
quantity -= previously_done
qties_per_lot[sml.lot_id] += qty_done
qties_per_lot[sml.lot_id] += quantity
for lot, qty in qties_per_lot.items():
# access the lot as a superuser in order to avoid an error
# when a user prints an invoice without having the stock access
lot = lot.sudo()
if float_is_zero(invoiced_qties[lot.product_id], precision_rounding=lot.product_uom_id.rounding) \
or float_compare(qty, 0, precision_rounding=lot.product_uom_id.rounding) <= 0:
if lot.product_uom_id.is_zero(invoiced_qties[lot.product_id]) or lot.product_uom_id.compare(qty, 0) <= 0:
continue
invoiced_lot_qty = min(qty, invoiced_qties[lot.product_id])
invoiced_qties[lot.product_id] -= invoiced_lot_qty
res.append({
'product_name': lot.product_id.display_name,
'quantity': formatLang(self.env, invoiced_lot_qty, dp='Product Unit of Measure'),
'quantity': formatLang(self.env, invoiced_lot_qty, dp='Product Unit'),
'uom_name': lot.product_uom_id.name,
'lot_name': lot.name,
# The lot id is needed by localizations to inherit the method and add custom fields on the invoice's report.
@ -112,6 +110,27 @@ class AccountMove(models.Model):
return res
@api.depends('line_ids.sale_line_ids.order_id.effective_date')
def _compute_delivery_date(self):
# EXTENDS 'account'
super()._compute_delivery_date()
for move in self:
sale_order_effective_date = list(filter(None, move.line_ids.sale_line_ids.order_id.mapped('effective_date')))
effective_date_res = max(sale_order_effective_date) if sale_order_effective_date else False
# if multiple sale order we take the bigger effective_date
if effective_date_res:
move.delivery_date = effective_date_res
@api.depends('line_ids.sale_line_ids.order_id')
def _compute_incoterm_location(self):
super()._compute_incoterm_location()
for move in self:
sale_locations = move.line_ids.sale_line_ids.order_id.mapped('incoterm_location')
incoterm_res = next((incoterm for incoterm in sale_locations if incoterm), False)
# if multiple purchase order we take an incoterm that is not false
if incoterm_res:
move.incoterm_location = incoterm_res
def _get_anglo_saxon_price_ctx(self):
ctx = super()._get_anglo_saxon_price_ctx()
move_is_downpayment = self.invoice_line_ids.filtered(
@ -119,20 +138,39 @@ class AccountMove(models.Model):
)
return dict(ctx, move_is_downpayment=move_is_downpayment)
def _get_protected_vals(self, vals, records):
res = super()._get_protected_vals(vals, records)
# `delivery_date` should be protected on any account.move/account.move.line write
perma_protected = {self._fields['delivery_date']}
if records._name == self._name:
res.append((perma_protected, records))
elif records._name == self.line_ids._name:
res.append((perma_protected, records.move_id))
return res
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
def _get_stock_moves(self):
return super()._get_stock_moves() | self.sale_line_ids.move_ids
def _sale_can_be_reinvoice(self):
self.ensure_one()
return self.move_type != 'entry' and self.display_type != 'cogs' and super(AccountMoveLine, self)._sale_can_be_reinvoice()
return self.move_type != 'entry' and self.display_type != 'cogs' and super()._sale_can_be_reinvoice()
def _stock_account_get_anglo_saxon_price_unit(self):
def _get_cogs_qty(self):
self.ensure_one()
price_unit = super(AccountMoveLine, self)._stock_account_get_anglo_saxon_price_unit()
valuation_account = self.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=self.move_id.fiscal_position_id)['stock_valuation']
posted_cogs_qty = sum(self.sale_line_ids.order_id.invoice_ids.filtered(lambda m: m.move_type == 'out_invoice').line_ids.filtered(
lambda line: line.product_id == self.product_id and line.display_type == 'cogs' and line.account_id == valuation_account
).mapped('quantity'))
return posted_cogs_qty + super()._get_cogs_qty()
so_line = self.sale_line_ids and self.sale_line_ids[-1] or False
if so_line:
price_unit = self._deduce_anglo_saxon_unit_price(so_line.invoice_lines.move_id, so_line.move_ids)
return price_unit
def _get_posted_cogs_value(self):
self.ensure_one()
valuation_account = self.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=self.move_id.fiscal_position_id)['stock_valuation']
posted_cogs_value = - sum(self.sale_line_ids.order_id.invoice_ids.filtered(lambda m: m.move_type == 'out_invoice').line_ids.filtered(
lambda line: line.product_id == self.product_id and line.display_type == 'cogs' and line.account_id == valuation_account
).mapped('balance'))
return posted_cogs_value + super()._get_posted_cogs_value()

View file

@ -10,9 +10,9 @@ class ProductTemplate(models.Model):
@api.depends('type')
def _compute_expense_policy(self):
super()._compute_expense_policy()
self.filtered(lambda t: t.type == 'product').expense_policy = 'no'
self.filtered(lambda t: t.is_storable).expense_policy = 'no'
@api.depends('type')
def _compute_service_type(self):
super()._compute_service_type()
self.filtered(lambda t: t.type == 'product').service_type = 'manual'
self.filtered(lambda t: t.is_storable).service_type = 'manual'

View file

@ -4,7 +4,7 @@
from odoo import fields, models
class company(models.Model):
class ResCompany(models.Model):
_inherit = 'res.company'
security_lead = fields.Float(

View file

@ -8,7 +8,6 @@ class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
security_lead = fields.Float(related='company_id.security_lead', string="Security Lead Time", readonly=False)
group_display_incoterm = fields.Boolean("Incoterms", implied_group='sale_stock.group_display_incoterm')
use_security_lead = fields.Boolean(
string="Security Lead Time for Sales",
config_parameter='sale_stock.use_security_lead',

View file

@ -4,17 +4,15 @@
from odoo import models, fields
class Users(models.Model):
_inherit = ['res.users']
class ResUsers(models.Model):
_inherit = 'res.users'
property_warehouse_id = fields.Many2one('stock.warehouse', string='Default Warehouse', company_dependent=True, check_company=True)
def _get_default_warehouse_id(self):
if self.property_warehouse_id:
return self.property_warehouse_id
# !!! Any change to the following search domain should probably
# be also applied in sale_stock/models/sale_order.py/_init_column.
return self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1)
return super()._get_default_warehouse_id()
@property
def SELF_READABLE_FIELDS(self):

View file

@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from odoo import api, fields, models, _
from odoo.fields import Command
from odoo.exceptions import UserError
from odoo.tools import float_compare
_logger = logging.getLogger(__name__)
@ -20,14 +21,12 @@ class SaleOrder(models.Model):
picking_policy = fields.Selection([
('direct', 'As soon as possible'),
('one', 'When all products are ready')],
string='Shipping Policy', required=True, readonly=True, default='direct',
states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
string='Shipping Policy', required=True, default='direct',
help="If you deliver all products at once, the delivery order will be scheduled based on the greatest "
"product lead time. Otherwise, it will be based on the shortest.")
warehouse_id = fields.Many2one(
'stock.warehouse', string='Warehouse', required=True,
'stock.warehouse', string='Warehouse',
compute='_compute_warehouse_id', store=True, readonly=False, precompute=True,
states={'sale': [('readonly', True)], 'done': [('readonly', True)], 'cancel': [('readonly', False)]},
check_company=True)
picking_ids = fields.One2many('stock.picking', 'sale_id', string='Transfers')
delivery_count = fields.Integer(string='Delivery Orders', compute='_compute_picking_ids')
@ -36,8 +35,19 @@ class SaleOrder(models.Model):
('started', 'Started'),
('partial', 'Partially Delivered'),
('full', 'Fully Delivered'),
], string='Delivery Status', compute='_compute_delivery_status', store=True)
procurement_group_id = fields.Many2one('procurement.group', 'Procurement Group', copy=False)
], string='Delivery Status', compute='_compute_delivery_status', store=True,
help="Blue: Not Delivered/Started\n\
Orange: Partially Delivered\n\
Green: Fully Delivered")
late_availability = fields.Boolean(
string="Late Availability",
compute='_compute_late_availability',
search='_search_late_availability',
help="True if any related picking has late availability"
)
stock_reference_ids = fields.Many2many(
'stock.reference', 'stock_reference_sale_rel',
'sale_id', 'reference_id', string='References', copy=False)
effective_date = fields.Datetime("Effective Date", compute='_compute_effective_date', store=True, help="Completion date of the first delivery order.")
expected_date = fields.Datetime( help="Delivery date you can promise to the customer, computed from the minimum lead time of "
"the order lines in case of Service products. In case of shipping, the shipping policy of "
@ -63,12 +73,12 @@ class SaleOrder(models.Model):
UPDATE sale_order so
SET warehouse_id = COALESCE(wh.id, %s)
FROM stock_warehouse wh
WHERE so.company_id = wh.company_id and so.warehouse_id IS NULL AND wh.active
WHERE so.company_id = wh.company_id and so.warehouse_id IS NULL and wh.active
"""
params = [default_warehouse.id]
_logger.debug("Initializing column '%s' in table '%s'", column_name, self._table)
self._cr.execute(query, params)
self.env.cr.execute(query, params)
@api.depends('picking_ids.date_done')
def _compute_effective_date(self):
@ -96,24 +106,68 @@ class SaleOrder(models.Model):
def _compute_expected_date(self):
super(SaleOrder, self)._compute_expected_date()
@api.depends('picking_ids.products_availability_state')
def _compute_late_availability(self):
for order in self:
order.late_availability = any(
picking.products_availability_state == 'late' for picking in order.picking_ids
)
def _search_late_availability(self, operator, value):
if operator not in ('=', '!=') or not isinstance(value, bool):
return NotImplemented
sub_query = self.env['stock.picking']._search([
('sale_id', '!=', False), ('products_availability_state', operator, 'late')
])
return [('picking_ids', 'in', sub_query)]
def _select_expected_date(self, expected_dates):
if self.picking_policy == "direct":
return super()._select_expected_date(expected_dates)
return max(expected_dates)
def write(self, values):
@api.constrains('warehouse_id', 'state', 'order_line')
def _check_warehouse(self):
""" Ensure that the warehouse is set in case of storable products """
orders_without_wh = self.filtered(lambda order: order.state not in ('draft', 'cancel') and not order.warehouse_id)
company_ids_with_wh = {
company_id.id for [company_id] in self.env['stock.warehouse']._read_group(
domain=[('company_id', 'in', orders_without_wh.company_id.ids)],
groupby=['company_id'],
)
}
other_company = set()
for order_line in orders_without_wh.order_line:
if order_line.product_id.type != 'consu':
continue
if order_line.route_ids.company_id and order_line.route_ids.company_id != order_line.company_id:
other_company.add(order_line.route_ids.company_id.id)
continue
if order_line.order_id.company_id.id in company_ids_with_wh:
raise UserError(_('You must set a warehouse on your sale order to proceed.'))
self.env['stock.warehouse'].with_company(order_line.order_id.company_id)._warehouse_redirect_warning()
other_company_warehouses = self.env['stock.warehouse'].search([('company_id', 'in', list(other_company))])
if any(c not in other_company_warehouses.company_id.ids for c in other_company):
raise UserError(_("You must have a warehouse for line using a delivery in different company."))
def write(self, vals):
values = vals
if values.get('order_line') and self.state == 'sale':
for order in self:
pre_order_line_qty = {order_line: order_line.product_uom_qty for order_line in order.mapped('order_line') if not order_line.is_expense}
if values.get('partner_shipping_id'):
if values.get('partner_shipping_id') and self.env.context.get('update_delivery_shipping_partner'):
for order in self:
order.picking_ids.partner_id = values.get('partner_shipping_id')
elif values.get('partner_shipping_id'):
new_partner = self.env['res.partner'].browse(values.get('partner_shipping_id'))
for record in self:
picking = record.mapped('picking_ids').filtered(lambda x: x.state not in ('done', 'cancel'))
addresses = (record.partner_shipping_id.display_name, new_partner.display_name)
message = _("""The delivery address has been changed on the Sales Order<br/>
From <strong>"%s"</strong> To <strong>"%s"</strong>,
You should probably update the partner on this document.""") % addresses
From <strong>"%(old_address)s"</strong> to <strong>"%(new_address)s"</strong>,
You should probably update the partner on this document.""",
old_address=record.partner_shipping_id.display_name, new_address=new_partner.display_name)
picking.activity_schedule('mail.mail_activity_data_warning', note=message, user_id=self.env.user.id)
if 'commitment_date' in values:
@ -121,17 +175,20 @@ class SaleOrder(models.Model):
# TODO: Log a note on each down document
deadline_datetime = values.get('commitment_date')
for order in self:
order.order_line.move_ids.date_deadline = deadline_datetime or order.expected_date
moves = order.order_line.move_ids.filtered(
lambda m: m.state not in ('done', 'cancel') and m.location_dest_id.usage == 'customer'
)
moves.date_deadline = deadline_datetime or order.expected_date
res = super(SaleOrder, self).write(values)
res = super().write(values)
if values.get('order_line') and self.state == 'sale':
rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
for order in self:
to_log = {}
order.order_line.fetch(['product_uom_id', 'product_uom_qty', 'display_type', 'is_downpayment'])
for order_line in order.order_line:
if order_line.display_type:
if order_line.display_type or order_line.is_downpayment:
continue
if float_compare(order_line.product_uom_qty, pre_order_line_qty.get(order_line, 0.0), precision_rounding=order_line.product_uom.rounding or rounding) < 0:
if float_compare(order_line.product_uom_qty, pre_order_line_qty.get(order_line, 0.0), precision_rounding=order_line.product_uom_id.rounding) < 0:
to_log[order_line] = (order_line.product_uom_qty, pre_order_line_qty.get(order_line, 0.0))
if to_log:
documents = self.env['stock.picking'].sudo()._log_activity_get_documents(to_log, 'move_ids', 'UP')
@ -166,7 +223,7 @@ class SaleOrder(models.Model):
def _compute_warehouse_id(self):
for order in self:
default_warehouse_id = self.env['ir.default'].with_company(
order.company_id.id).get_model_defaults('sale.order').get('warehouse_id')
order.company_id.id)._get_model_defaults('sale.order').get('warehouse_id')
if order.state in ['draft', 'sent'] or not order.ids:
# Should expect empty
if default_warehouse_id is not None:
@ -184,8 +241,8 @@ class SaleOrder(models.Model):
res['warning'] = {
'title': _('Warning!'),
'message': _(
'Do not forget to change the partner on the following delivery orders: %s'
) % (','.join(pickings.mapped('name')))
'Do not forget to change the partner on the following delivery orders: %s',
','.join(pickings.mapped('name')))
}
return res
@ -232,12 +289,16 @@ class SaleOrder(models.Model):
picking_id = picking_id[0]
else:
picking_id = pickings[0]
action['context'] = dict(self._context, default_partner_id=self.partner_id.id, default_picking_type_id=picking_id.picking_type_id.id, default_origin=self.name, default_group_id=picking_id.group_id.id)
action['context'] = dict(
default_partner_id=self.partner_id.id,
default_picking_type_id=picking_id.picking_type_id.id,
)
return action
def _prepare_invoice(self):
invoice_vals = super(SaleOrder, self)._prepare_invoice()
invoice_vals['invoice_incoterm_id'] = self.incoterm.id
invoice_vals['delivery_date'] = self.effective_date
return invoice_vals
def _log_decrease_ordered_quantity(self, documents, cancel=False):
@ -258,3 +319,18 @@ class SaleOrder(models.Model):
return self.env['ir.qweb']._render('sale_stock.exception_on_so', values)
self.env['stock.picking']._log_activity(_render_note_exception_quantity_so, documents)
def _is_display_stock_in_catalog(self):
return True
# TODO: rename the parameter from reference to references in master for improved readability
def _add_reference(self, reference):
""" link the given references to the list of references. """
self.ensure_one()
self.stock_reference_ids = [Command.link(stock_reference.id) for stock_reference in reference]
# TODO: rename the parameter from reference to references in master for improved readability
def _remove_reference(self, reference):
""" remove the given references from the list of references. """
self.ensure_one()
self.stock_reference_ids = [Command.unlink(stock_reference.id) for stock_reference in reference]

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
)

View file

@ -3,7 +3,7 @@
from collections import defaultdict
from odoo import api, fields, models, _
from odoo import api, fields, models, Command
from odoo.tools.sql import column_exists, create_column
@ -16,9 +16,76 @@ class StockMove(models.Model):
_inherit = "stock.move"
sale_line_id = fields.Many2one('sale.order.line', 'Sale Line', index='btree_not_null')
@api.depends('sale_line_id', 'sale_line_id.product_uom_id')
def _compute_packaging_uom_id(self):
super()._compute_packaging_uom_id()
for move in self:
if move.sale_line_id:
move.packaging_uom_id = move.sale_line_id.product_uom_id
@api.depends('sale_line_id')
def _compute_description_picking(self):
super()._compute_description_picking()
for move in self:
if move.sale_line_id and not move.description_picking_manual:
sale_line_id = move.sale_line_id.with_context(lang=move.sale_line_id.order_id.partner_id.lang)
if move.description_picking == move.product_id.display_name:
move.description_picking = ''
move.description_picking = (sale_line_id._get_sale_order_line_multiline_description_variants() + '\n' + move.description_picking).strip()
def _action_synch_order(self):
sale_order_lines_vals = []
for move in self:
sale_order = move.picking_id.sale_id
# Creates new SO line only when pickings linked to a sale order and
# for moves with qty. done and not already linked to a SO line.
if not sale_order or move.sale_line_id or not move.picked or not (
(move.location_dest_id.usage in ['customer', 'transit'] and not move.move_dest_ids)
or (move.location_id.usage == 'customer' and move.to_refund)
):
continue
product = move.product_id
if line := sale_order.order_line.filtered(lambda l: l.product_id == product):
move.sale_line_id = line[:1]
continue
quantity = move.quantity
if move.location_id.usage in ['customer', 'transit']:
quantity *= -1
so_line_vals = {
'move_ids': [(4, move.id, 0)],
'name': product.display_name,
'order_id': sale_order.id,
'product_id': product.id,
'product_uom_qty': 0,
'qty_delivered': quantity,
'product_uom_id': move.product_uom.id,
}
so_line = sale_order.order_line.filtered(lambda sol: sol.product_id == product)
if product.invoice_policy == 'delivery':
# Check if there is already a SO line for this product to get
# back its unit price (in case it was manually updated).
so_line = sale_order.order_line.filtered(lambda sol: sol.product_id == product)
if so_line:
so_line_vals['price_unit'] = so_line[0].price_unit
elif product.invoice_policy == 'order':
# No unit price if the product is invoiced on the ordered qty.
so_line_vals['price_unit'] = 0
# New lines should be added at the bottom of the SO (higher sequence number)
if not so_line:
so_line_vals['sequence'] = max(sale_order.order_line.mapped('sequence')) + len(sale_order_lines_vals) + 1
sale_order_lines_vals.append(so_line_vals)
if sale_order_lines_vals:
self.env['sale.order.line'].with_context(skip_procurement=True).create(sale_order_lines_vals)
return super()._action_synch_order()
@api.model
def _prepare_merge_moves_distinct_fields(self):
distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields()
distinct_fields = super()._prepare_merge_moves_distinct_fields()
distinct_fields.append('sale_line_id')
return distinct_fields
@ -34,7 +101,12 @@ class StockMove(models.Model):
def _get_source_document(self):
res = super()._get_source_document()
return self.sudo().sale_line_id.order_id or res
return self.sale_line_id.order_id or res
def _get_sale_order_lines(self):
""" Return all possible sale order lines for one stock move. """
self.ensure_one()
return (self + self.browse(self._rollup_move_origs() | self._rollup_move_dests())).sale_line_id
def _assign_picking_post_process(self, new=False):
super(StockMove, self)._assign_picking_post_process(new=new)
@ -42,18 +114,57 @@ class StockMove(models.Model):
picking_id = self.mapped('picking_id')
sale_order_ids = self.mapped('sale_line_id.order_id')
for sale_order_id in sale_order_ids:
picking_id.message_post_with_view(
picking_id.message_post_with_source(
'mail.message_origin_link',
values={'self': picking_id, 'origin': sale_order_id},
subtype_id=self.env.ref('mail.mt_note').id)
render_values={'self': picking_id, 'origin': sale_order_id},
subtype_xmlid='mail.mt_note',
)
def _get_all_related_sm(self, product):
return super()._get_all_related_sm(product) | self.filtered(lambda m: m.sale_line_id.product_id == product)
class ProcurementGroup(models.Model):
_inherit = 'procurement.group'
def write(self, vals):
res = super().write(vals)
if 'product_id' in vals:
for move in self:
if move.sale_line_id and move.product_id != move.sale_line_id.product_id:
move.sale_line_id = False
return res
sale_id = fields.Many2one('sale.order', 'Sale Order')
def _prepare_procurement_values(self):
res = super()._prepare_procurement_values()
# to pass sale_line_id fom SO to MO in mto
if self.sale_line_id:
res['sale_line_id'] = self.sale_line_id.id
return res
def _reassign_sale_lines(self, sale_order):
current_order = self.sale_line_id.order_id
if len(current_order) <= 1 and current_order != sale_order:
ids_to_reset = set()
if not sale_order:
ids_to_reset.update(self.ids)
else:
line_ids_by_product = dict(self.env['sale.order.line']._read_group(
domain=[('order_id', '=', sale_order.id), ('product_id', 'in', self.product_id.ids)],
aggregates=['id:array_agg'],
groupby=['product_id']
))
for move in self:
if line_id := line_ids_by_product.get(move.product_id, [])[:1]:
move.sale_line_id = line_id[0]
else:
ids_to_reset.add(move.id)
if ids_to_reset:
self.env['stock.move'].browse(ids_to_reset).sale_line_id = False
class StockMoveLine(models.Model):
_inherit = "stock.move.line"
def _should_show_lot_in_invoice(self):
return 'customer' in {self.location_id.usage, self.location_dest_id.usage} or self.env.ref('stock.stock_location_inter_company') in (self.location_id, self.location_dest_id)
class StockRule(models.Model):
@ -68,7 +179,43 @@ class StockRule(models.Model):
class StockPicking(models.Model):
_inherit = 'stock.picking'
sale_id = fields.Many2one(related="group_id.sale_id", string="Sales Order", store=True, readonly=False, index='btree_not_null')
sale_id = fields.Many2one('sale.order', compute="_compute_sale_id", inverse="_set_sale_id", string="Sales Order", store=True, index='btree_not_null')
@api.depends('reference_ids.sale_ids', 'move_ids.sale_line_id.order_id')
def _compute_sale_id(self):
for picking in self:
# picking and move should have a link to the SO to see the picking on the stat button.
# This will filter the move chain to the delivery moves only.
sales_order = picking.move_ids.sale_line_id.order_id
picking.sale_id = sales_order[0] if sales_order else False
@api.depends('move_ids.sale_line_id')
def _compute_move_type(self):
super()._compute_move_type()
for picking in self:
sale_orders = picking.move_ids.sale_line_id.order_id
if sale_orders:
if any(so.picking_policy == "direct" for so in sale_orders):
picking.move_type = "direct"
else:
picking.move_type = "one"
def _set_sale_id(self):
if self.reference_ids:
if self.sale_id:
self.reference_ids.sale_ids = [Command.link(self.sale_id.id)]
else:
sale_order = self.move_ids.sale_line_id.order_id
if len(sale_order) == 1:
self.reference_ids.sale_ids = [Command.unlink(sale_order.id)]
else:
if self.sale_id:
reference = self.env['stock.reference'].create({
'sale_ids': [Command.link(self.sale_id.id)],
'name': self.sale_id.name,
})
self._add_reference(reference)
self.move_ids._reassign_sale_lines(self.sale_id)
def _auto_init(self):
"""
@ -86,30 +233,41 @@ class StockPicking(models.Model):
res = super()._action_done()
sale_order_lines_vals = []
for move in self.move_ids:
sale_order = move.picking_id.sale_id
ref_sale = move.picking_id.reference_ids.sale_ids
sale_order = ref_sale and ref_sale[0] or move.sale_line_id.order_id
# Creates new SO line only when pickings linked to a sale order and
# for moves with qty. done and not already linked to a SO line.
if not sale_order or move.location_dest_id.usage != 'customer' or move.sale_line_id or not move.quantity_done:
if not sale_order or move.sale_line_id or not move.picked or not (
(move.location_dest_id.usage in ['customer', 'transit'] and not move.move_dest_ids)
or (move.location_id.usage == 'customer' and move.to_refund)
):
continue
product = move.product_id
quantity = move.quantity
if move.location_id.usage in ['customer', 'transit']:
quantity *= -1
so_line_vals = {
'move_ids': [(4, move.id, 0)],
'name': product.display_name,
'order_id': sale_order.id,
'product_id': product.id,
'product_uom_qty': 0,
'qty_delivered': move.quantity_done,
'product_uom': move.product_uom.id,
'qty_delivered': quantity,
'product_uom_id': move.product_uom.id,
}
so_line = sale_order.order_line.filtered(lambda sol: sol.product_id == product)
if product.invoice_policy == 'delivery':
# Check if there is already a SO line for this product to get
# back its unit price (in case it was manually updated).
so_line = sale_order.order_line.filtered(lambda sol: sol.product_id == product)
if so_line:
so_line_vals['price_unit'] = so_line[0].price_unit
elif product.invoice_policy == 'order':
# No unit price if the product is invoiced on the ordered qty.
so_line_vals['price_unit'] = 0
# New lines should be added at the bottom of the SO (higher sequence number)
if not so_line:
so_line_vals['sequence'] = max(sale_order.order_line.mapped('sequence')) + len(sale_order_lines_vals) + 1
sale_order_lines_vals.append(so_line_vals)
if sale_order_lines_vals:
@ -154,6 +312,11 @@ class StockPicking(models.Model):
return super(StockPicking, self)._log_less_quantities_than_expected(moves)
def _can_return(self):
self.ensure_one()
return super()._can_return() or self.sale_id
class StockLot(models.Model):
_inherit = 'stock.lot'
@ -162,18 +325,25 @@ class StockLot(models.Model):
@api.depends('name')
def _compute_sale_order_ids(self):
sale_orders = defaultdict(lambda: self.env['sale.order'])
for move_line in self.env['stock.move.line'].search([('lot_id', 'in', self.ids), ('state', '=', 'done')]):
move = move_line.move_id
if move.picking_id.location_dest_id.usage == 'customer' and move.sale_line_id.order_id:
sale_orders[move_line.lot_id.id] |= move.sale_line_id.order_id
sale_orders = defaultdict(set)
move_lines = self.env['stock.move.line'].search([
('lot_id', 'in', self.ids),
('state', '=', 'done'),
('move_id.sale_line_id.order_id', '!=', False),
('move_id.picking_id.location_dest_id.usage', 'in', ('customer', 'transit')),
])
for ml in move_lines:
so = ml.move_id.sale_line_id.order_id
if so.with_user(self.env.user).has_access('read'):
sale_orders[ml.lot_id.id].add(so.id)
for lot in self:
lot.sale_order_ids = sale_orders[lot.id]
lot.sale_order_count = len(lot.sale_order_ids)
so_ids = sale_orders.get(lot.id, set())
lot.sale_order_ids = [Command.set(list(so_ids))]
lot.sale_order_count = len(so_ids)
def action_view_so(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_orders")
action['domain'] = [('id', 'in', self.mapped('sale_order_ids.id'))]
action['context'] = dict(self._context, create=False)
action['context'] = dict(self.env.context, create=False)
return action

View file

@ -0,0 +1,9 @@
from odoo import fields, models
class StockReference(models.Model):
_inherit = 'stock.reference'
sale_ids = fields.Many2many(
'sale.order', 'stock_reference_sale_rel', 'reference_id',
'sale_id', string="Sales")