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

@ -6,4 +6,6 @@ from . import mrp_production
from . import sale_order
from . import sale_order_line
from . import stock_move
from . import stock_move_line
from . import stock_rule
from . import mrp_bom

View file

@ -6,8 +6,8 @@ from odoo import models
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
def _stock_account_get_anglo_saxon_price_unit(self):
price_unit = super(AccountMoveLine, self)._stock_account_get_anglo_saxon_price_unit()
def _get_cogs_value(self):
price_unit = super()._get_cogs_value()
so_line = self.sale_line_ids and self.sale_line_ids[-1] or False
if so_line:
@ -22,24 +22,20 @@ class AccountMoveLine(models.Model):
# We then take the first bom of the product.
bom = self.env['mrp.bom']._bom_find(products=so_line.product_id, company_id=so_line.company_id.id, bom_type='phantom')[so_line.product_id]
is_line_reversing = self.move_id.move_type == 'out_refund'
qty_to_invoice = self.product_uom_id._compute_quantity(self.quantity, self.product_id.uom_id)
account_moves = so_line.invoice_lines.move_id.filtered(lambda m: m.state == 'posted' and bool(m.reversed_entry_id) == is_line_reversing)
posted_invoice_lines = account_moves.line_ids.filtered(lambda l: l.display_type == 'cogs' and l.product_id == self.product_id and l.balance > 0)
qty_invoiced = sum([x.product_uom_id._compute_quantity(x.quantity, x.product_id.uom_id) for x in posted_invoice_lines])
reversal_cogs = posted_invoice_lines.move_id.reversal_move_id.line_ids.filtered(lambda l: l.display_type == 'cogs' and l.product_id == self.product_id and l.balance > 0)
reversal_cogs = posted_invoice_lines.move_id.reversal_move_ids.line_ids.filtered(lambda l: l.display_type == 'cogs' and l.product_id == self.product_id and l.balance > 0)
qty_invoiced -= sum([line.product_uom_id._compute_quantity(line.quantity, line.product_id.uom_id) for line in reversal_cogs])
moves = so_line.move_ids
average_price_unit = 0
# Components quantities for 1 'unit' in the product's base uom
components_qty = so_line._get_bom_component_qty(bom)
storable_components = self.env['product.product'].search([('id', 'in', list(components_qty.keys())), ('type', '=', 'product')])
storable_components = self.env['product.product'].search([('id', 'in', list(components_qty.keys())), ('is_storable', '=', True)])
for product in storable_components:
factor = components_qty[product.id]['qty']
prod_moves = moves.filtered(lambda m: m.product_id == product)
prod_qty_invoiced = factor * qty_invoiced
prod_qty_to_invoice = factor * qty_to_invoice
product = product.with_company(self.company_id)
average_price_unit += factor * product._compute_average_price(prod_qty_invoiced, prod_qty_to_invoice, prod_moves, is_returned=is_line_reversing)
average_price_unit += prod_moves._get_price_unit()
price_unit = average_price_unit / bom.product_qty or price_unit
price_unit = self.product_id.uom_id._compute_price(price_unit, self.product_uom_id)
return price_unit

View file

@ -8,12 +8,8 @@ from odoo.exceptions import UserError
class MrpBom(models.Model):
_inherit = 'mrp.bom'
def toggle_active(self):
self.filtered(lambda bom: bom.active)._ensure_bom_is_free()
return super().toggle_active()
def write(self, vals):
if 'phantom' in self.mapped('type') and vals.get('type', 'phantom') != 'phantom':
if not vals.get('active', True) or ('phantom' in self.mapped('type') and vals.get('type', 'phantom') != 'phantom'):
self._ensure_bom_is_free()
return super().write(vals)
@ -24,13 +20,13 @@ class MrpBom(models.Model):
def _ensure_bom_is_free(self):
product_ids = []
for bom in self:
if bom.type != 'phantom':
if not bom.active or bom.type != 'phantom':
continue
product_ids += bom.product_id.ids or bom.product_tmpl_id.product_variant_ids.ids
if not product_ids:
return
lines = self.env['sale.order.line'].sudo().search([
('state', 'in', ('sale', 'done')),
('state', '=', 'sale'),
('invoice_status', 'in', ('no', 'to invoice')),
('product_id', 'in', product_ids),
('move_ids.state', '!=', 'cancel'),

View file

@ -11,15 +11,16 @@ class MrpProduction(models.Model):
"Count of Source SO",
compute='_compute_sale_order_count',
groups='sales_team.group_sale_salesman')
sale_line_id = fields.Many2one('sale.order.line', 'Origin sale order line')
@api.depends('procurement_group_id.mrp_production_ids.move_dest_ids.group_id.sale_id')
@api.depends('reference_ids.sale_ids', 'sale_line_id.order_id')
def _compute_sale_order_count(self):
for production in self:
production.sale_order_count = len(production.procurement_group_id.mrp_production_ids.move_dest_ids.group_id.sale_id)
production.sale_order_count = len(production.reference_ids.sale_ids | production.sale_line_id.order_id)
def action_view_sale_orders(self):
self.ensure_one()
sale_order_ids = self.procurement_group_id.mrp_production_ids.move_dest_ids.group_id.sale_id.ids
sale_order_ids = (self.reference_ids.sale_ids | self.sale_line_id.order_id).ids
action = {
'res_model': 'sale.order',
'type': 'ir.actions.act_window',
@ -33,6 +34,15 @@ class MrpProduction(models.Model):
action.update({
'name': _("Sources Sale Orders of %s", self.name),
'domain': [('id', 'in', sale_order_ids)],
'view_mode': 'tree,form',
'view_mode': 'list,form',
})
return action
def action_confirm(self):
res = super().action_confirm()
for production in self:
if production.sale_line_id:
production.move_finished_ids.filtered(
lambda m: m.product_id == production.product_id
).sale_line_id = production.sale_line_id
return res

View file

@ -17,17 +17,13 @@ class SaleOrder(models.Model):
string='Manufacturing orders associated with this sales order.',
groups='mrp.group_mrp_user')
@api.depends('procurement_group_id.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids')
@api.depends('stock_reference_ids.production_ids')
def _compute_mrp_production_ids(self):
data = self.env['procurement.group'].read_group([('sale_id', 'in', self.ids)], ['ids:array_agg(id)'], ['sale_id'])
mrp_productions = dict()
for item in data:
procurement_groups = self.env['procurement.group'].browse(item['ids'])
mrp_productions[item['sale_id'][0]] = procurement_groups.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids | procurement_groups.mrp_production_ids
for sale in self:
mrp_production_ids = mrp_productions.get(sale.id, self.env['mrp.production'])
sale.mrp_production_count = len(mrp_production_ids)
sale.mrp_production_ids = mrp_production_ids
# We want only manufacturing orders of first level
mos = sale.stock_reference_ids.production_ids
sale.mrp_production_ids = mos.filtered(lambda mo: not mo.production_group_id.parent_ids and mo.state != 'cancel')
sale.mrp_production_count = len(sale.mrp_production_ids)
def action_view_mrp_production(self):
self.ensure_one()
@ -44,6 +40,6 @@ class SaleOrder(models.Model):
action.update({
'name': _("Manufacturing Orders Generated by %s", self.name),
'domain': [('id', 'in', self.mrp_production_ids.ids)],
'view_mode': 'tree,form',
'view_mode': 'list,form',
})
return action

View file

@ -30,20 +30,20 @@ class SaleOrderLine(models.Model):
if components and components != [line.product_id.id]:
line.display_qty_widget = True
def _compute_qty_delivered(self):
super(SaleOrderLine, self)._compute_qty_delivered()
def _prepare_qty_delivered(self):
delivered_qties = super()._prepare_qty_delivered()
for order_line in self:
if order_line.qty_delivered_method == 'stock_move':
boms = order_line.move_ids.filtered(lambda m: m.state != 'cancel').mapped('bom_line_id.bom_id')
boms = order_line.move_ids.filtered(lambda m: m.state != 'cancel').bom_line_id.bom_id
dropship = any(m._is_dropshipped() for m in order_line.move_ids)
if not boms and dropship:
boms = boms._bom_find(order_line.product_id, company_id=order_line.company_id.id, bom_type='phantom')[order_line.product_id]
# We fetch the BoMs of type kits linked to the order_line,
# the we keep only the one related to the finished produst.
# This bom should be the only one since bom_line_id was written on the moves
relevant_bom = boms.filtered(lambda b: b.type == 'phantom' and
(b.product_id == order_line.product_id or
(b.product_tmpl_id == order_line.product_id.product_tmpl_id and not b.product_id)))
if not relevant_bom:
relevant_bom = boms._bom_find(order_line.product_id, company_id=order_line.company_id.id, bom_type='phantom')[order_line.product_id]
if relevant_bom:
# not written on a move coming from a PO: all moves (to customer) must be done
# and the returns must be delivered back to the customer
@ -56,22 +56,26 @@ class SaleOrderLine(models.Model):
if any((m.location_dest_id.usage == 'customer' and m.state != 'done')
or (m.location_dest_id.usage != 'customer'
and m.state == 'done'
and float_compare(m.quantity_done,
sum(sub_m.product_uom._compute_quantity(sub_m.quantity_done, m.product_uom) for sub_m in m.returned_move_ids if sub_m.state == 'done'),
and float_compare(m.quantity,
sum(sub_m.product_uom._compute_quantity(sub_m.quantity, m.product_uom) for sub_m in m.returned_move_ids if sub_m.state == 'done'),
precision_rounding=m.product_uom.rounding) > 0)
for m in moves) or not moves:
order_line.qty_delivered = 0
delivered_qties[order_line] = 0
else:
order_line.qty_delivered = order_line.product_uom_qty
delivered_qties[order_line] = order_line.product_uom_qty
continue
moves = order_line.move_ids.filtered(lambda m: m.state == 'done' and not m.scrapped)
moves = order_line.move_ids.filtered(lambda m: m.state == 'done' and m.location_dest_usage != 'inventory')
filters = {
'incoming_moves': lambda m: m.location_dest_id.usage == 'customer' and (not m.origin_returned_move_id or (m.origin_returned_move_id and m.to_refund)),
'outgoing_moves': lambda m: m.location_dest_id.usage != 'customer' and m.to_refund
# in/out perspective w/ respect to moves is flipped for sale order document
'incoming_moves': lambda m:
m._is_outgoing() and
(not m.origin_returned_move_id or (m.origin_returned_move_id and m.to_refund)),
'outgoing_moves': lambda m:
m._is_incoming() and m.to_refund,
}
order_qty = order_line.product_uom._compute_quantity(order_line.product_uom_qty, relevant_bom.product_uom_id)
order_qty = order_line.product_uom_id._compute_quantity(order_line.product_uom_qty, relevant_bom.product_uom_id)
qty_delivered = moves._compute_kit_quantities(order_line.product_id, order_qty, relevant_bom, filters)
order_line.qty_delivered += relevant_bom.product_uom_id._compute_quantity(qty_delivered, order_line.product_uom)
delivered_qties[order_line] += relevant_bom.product_uom_id._compute_quantity(qty_delivered, order_line.product_uom_id)
# If no relevant BOM is found, fall back on the all-or-nothing policy. This happens
# when the product sold is made only of kits. In this case, the BOM of the stock moves
@ -79,9 +83,10 @@ class SaleOrderLine(models.Model):
elif boms:
# if the move is ingoing, the product **sold** has delivered qty 0
if all(m.state == 'done' and m.location_dest_id.usage == 'customer' for m in order_line.move_ids):
order_line.qty_delivered = order_line.product_uom_qty
delivered_qties[order_line] = order_line.product_uom_qty
else:
order_line.qty_delivered = 0.0
delivered_qties[order_line] = 0.0
return delivered_qties
def compute_uom_qty(self, new_qty, stock_move, rounding=True):
#check if stock move concerns a kit
@ -112,20 +117,53 @@ class SaleOrderLine(models.Model):
components[product] = {'qty': qty, 'uom': to_uom.id}
return components
@api.model
def _get_incoming_outgoing_moves_filter(self):
""" Method to be override: will get incoming moves and outgoing moves.
:return: Dictionary with incoming moves and outgoing moves
:rtype: dict
"""
# The first move created was the one created from the intial rule that started it all.
sorted_moves = self.move_ids.sorted('id')
triggering_rule_ids = []
seen_wh_ids = set()
seen_bom_id = set()
for move in sorted_moves:
if move.bom_line_id.bom_id.id in seen_bom_id:
triggering_rule_ids.append(move.rule_id.id)
elif 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 move.bom_line_id and move.bom_line_id.bom_id.type == 'phantom':
seen_bom_id.add(move.bom_line_id.bom_id.id)
return {
'incoming_moves': lambda m: (
m.state != 'cancel' and m.location_dest_usage != 'inventory'
and m.rule_id.id in triggering_rule_ids
and m.location_final_id.usage == 'customer'
and (not m.origin_returned_move_id or (m.origin_returned_move_id and m.to_refund)
)),
'outgoing_moves': lambda m: (
m.state != 'cancel' and m.location_dest_usage != 'inventory'
and m.location_id.usage == 'customer' and m.to_refund
),
}
def _get_qty_procurement(self, previous_product_uom_qty=False):
self.ensure_one()
# Specific case when we change the qty on a SO for a kit product.
# We don't try to be too smart and keep a simple approach: we use the quantity of entire
# kits that are currently in delivery
bom = self.env['mrp.bom']._bom_find(self.product_id, bom_type='phantom')[self.product_id]
if bom:
moves = self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped)
filters = {
'incoming_moves': lambda m: m.location_dest_id.usage == 'customer' and (not m.origin_returned_move_id or (m.origin_returned_move_id and m.to_refund)),
'outgoing_moves': lambda m: m.location_dest_id.usage != 'customer' and m.to_refund
}
bom = self.env['mrp.bom'].sudo()._bom_find(self.product_id, bom_type='phantom', company_id=self.company_id.id)[self.product_id]
if bom and self.move_ids:
moves = self.move_ids.filtered(lambda r: r.state != 'cancel' and r.location_dest_usage != 'inventory')
filters = self._get_incoming_outgoing_moves_filter()
order_qty = previous_product_uom_qty.get(self.id, 0) if previous_product_uom_qty else self.product_uom_qty
order_qty = self.product_uom._compute_quantity(order_qty, bom.product_uom_id)
order_qty = self.product_uom_id._compute_quantity(order_qty, bom.product_uom_id)
qty = moves._compute_kit_quantities(self.product_id, order_qty, bom, filters)
return bom.product_uom_id._compute_quantity(qty, self.product_uom)
return super(SaleOrderLine, self)._get_qty_procurement(previous_product_uom_qty=previous_product_uom_qty)
return bom.product_uom_id._compute_quantity(qty, self.product_uom_id)
elif bom and previous_product_uom_qty:
return previous_product_uom_qty.get(self.id)
return super()._get_qty_procurement(previous_product_uom_qty=previous_product_uom_qty)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
@ -7,19 +6,11 @@ from odoo import models
class StockMove(models.Model):
_inherit = 'stock.move'
def _prepare_procurement_values(self):
res = super()._prepare_procurement_values()
res['analytic_account_id'] = self.sale_line_id.order_id.analytic_account_id
return res
class StockMoveLine(models.Model):
_inherit = 'stock.move.line'
def _compute_sale_price(self):
kit_lines = self.filtered(lambda move_line: move_line.move_id.bom_line_id.bom_id.type == 'phantom')
for move_line in kit_lines:
unit_price = move_line.product_id.list_price
qty = move_line.product_uom_id._compute_quantity(move_line.qty_done, move_line.product_id.uom_id)
move_line.sale_price = unit_price * qty
super(StockMoveLine, self - kit_lines)._compute_sale_price()
def _get_price_unit(self):
order_line = self.sale_line_id
if order_line and all(move.sale_line_id == order_line for move in self) and any(move.product_id != order_line.product_id for move in self):
product = order_line.product_id.with_company(order_line.company_id)
bom = product.env['mrp.bom']._bom_find(product, company_id=self.company_id.id, bom_type='phantom')[product]
if bom:
return self._get_kit_price_unit(product, bom, order_line.qty_delivered)
return super()._get_price_unit()

View file

@ -0,0 +1,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class StockMoveLine(models.Model):
_inherit = 'stock.move.line'
def _compute_sale_price(self):
kit_lines = self.filtered(lambda move_line: move_line.move_id.bom_line_id.bom_id.type == 'phantom')
for move_line in kit_lines:
unit_price = move_line.product_id.list_price
qty = move_line.product_uom_id._compute_quantity(move_line.quantity, move_line.product_id.uom_id)
move_line.sale_price = unit_price * qty
super(StockMoveLine, self - kit_lines)._compute_sale_price()

View file

@ -0,0 +1,25 @@
from odoo import models
class StockRule(models.Model):
_inherit = 'stock.rule'
def _prepare_mo_vals(self, product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values, bom):
res = super()._prepare_mo_vals(product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values, bom)
if values.get('sale_line_id'):
res['sale_line_id'] = values['sale_line_id']
return res
def _get_stock_move_values(self, product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values):
move_values = super()._get_stock_move_values(product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values)
if (sol_id := values.get('sale_line_id')) is not None and 'product_id' in move_values:
# if the SOL is for a kit
sol = self.env['sale.order.line'].browse(sol_id)
if move_values['product_id'] != sol.product_id.id:
active_moves = sol.move_ids.filtered(lambda m: m.state != 'cancel')
bom_line_id = active_moves.bom_line_id.filtered(
lambda bl: bl.product_id.id == move_values.get('product_id')
)[:1].id
if bom_line_id:
move_values['bom_line_id'] = bom_line_id
return move_values