mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-27 20:52:07 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -9,3 +9,4 @@ from . import res_users
|
|||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
from . import stock
|
||||
from . import stock_reference
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
from odoo import fields, models
|
||||
|
||||
|
||||
class company(models.Model):
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
security_lead = fields.Float(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue