Initial commit: Odoo 18.0-20251222 extra-addons
This commit is contained in:
4
sale_mrp_bom/tests/__init__.py
Executable file
4
sale_mrp_bom/tests/__init__.py
Executable file
@@ -0,0 +1,4 @@
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import test_sale_mrp_bom
|
||||
from . import test_sale_mrp_bom_multi_line
|
||||
142
sale_mrp_bom/tests/test_sale_mrp_bom.py
Executable file
142
sale_mrp_bom/tests/test_sale_mrp_bom.py
Executable file
@@ -0,0 +1,142 @@
|
||||
# Copyright 2020 Akretion Renato Lima <renato.lima@akretion.com.br>
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestSaleMrpLink(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env.ref("base.res_partner_2")
|
||||
cls.warehouse = cls.env.ref("stock.warehouse0")
|
||||
route_manufacture = cls.warehouse.manufacture_pull_id.route_id.id
|
||||
route_mto = cls.warehouse.mto_pull_id.route_id.id
|
||||
cls.product_a = cls._create_product(
|
||||
"Product A", route_ids=[(6, 0, [route_manufacture, route_mto])]
|
||||
)
|
||||
cls.product_b = cls._create_product(
|
||||
"Product B", route_ids=[(6, 0, [route_manufacture, route_mto])]
|
||||
)
|
||||
cls.component_a = cls._create_product("Component A", route_ids=[])
|
||||
cls.component_b = cls._create_product("Component B", route_ids=[])
|
||||
|
||||
@classmethod
|
||||
def _create_product(cls, name, route_ids):
|
||||
return cls.env["product.product"].create(
|
||||
{"name": name, "type": "consu", "route_ids": route_ids}
|
||||
)
|
||||
|
||||
def _prepare_bom_lines(self):
|
||||
# Create BOMs
|
||||
bom_a_v1 = self._create_bom(self.product_a.product_tmpl_id)
|
||||
self._create_bom_line(bom_a_v1, self.component_a, 1)
|
||||
bom_a_v2 = self._create_bom(self.product_a.product_tmpl_id)
|
||||
self._create_bom_line(bom_a_v2, self.component_a, 2)
|
||||
|
||||
bom_b_v1 = self._create_bom(self.product_b.product_tmpl_id)
|
||||
self._create_bom_line(bom_b_v1, self.component_b, 1)
|
||||
bom_b_v2 = self._create_bom(self.product_b.product_tmpl_id)
|
||||
self._create_bom_line(bom_b_v2, self.component_b, 2)
|
||||
|
||||
bom_a, bom_b = bom_a_v2, bom_b_v2
|
||||
self.boms = {
|
||||
self.product_a.id: bom_a,
|
||||
self.product_b.id: bom_b,
|
||||
}
|
||||
return bom_a, bom_b
|
||||
|
||||
def _prepare_so(self):
|
||||
bom_a_v2, bom_b_v2 = self._prepare_bom_lines()
|
||||
|
||||
# Create Sale Order
|
||||
so = self._create_sale_order(self.partner, "SO1")
|
||||
self._create_sale_order_line(so, self.product_a, 1, 10.0, bom_a_v2)
|
||||
self._create_sale_order_line(so, self.product_b, 1, 10.0, bom_b_v2)
|
||||
so.action_confirm()
|
||||
return so, bom_a_v2, bom_b_v2
|
||||
|
||||
def _create_bom(self, template):
|
||||
return self.env["mrp.bom"].create(
|
||||
[{"product_tmpl_id": template.id, "type": "normal"}]
|
||||
)
|
||||
|
||||
def _create_bom_line(self, bom, product, qty):
|
||||
self.env["mrp.bom.line"].create(
|
||||
[{"bom_id": bom.id, "product_id": product.id, "product_qty": qty}]
|
||||
)
|
||||
|
||||
def _create_sale_order(self, partner, client_ref):
|
||||
return self.env["sale.order"].create(
|
||||
[{"partner_id": partner.id, "client_order_ref": client_ref}]
|
||||
)
|
||||
|
||||
def _create_sale_order_line(self, sale_order, product, qty, price, bom):
|
||||
self.env["sale.order.line"].create(
|
||||
[
|
||||
{
|
||||
"order_id": sale_order.id,
|
||||
"product_id": product.id,
|
||||
"price_unit": price,
|
||||
"product_uom_qty": qty,
|
||||
"bom_id": bom.id,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
def test_define_bom_in_sale_line(self):
|
||||
"""Check manufactured order is created with BOM defined in Sale."""
|
||||
previous_mos = self.env["mrp.production"].search([])
|
||||
_so, _bom_a, _bom_b = self._prepare_so()
|
||||
|
||||
# Check manufacture order
|
||||
mos = self.env["mrp.production"].search([]) - previous_mos
|
||||
for mo in mos:
|
||||
self.assertEqual(mo.bom_id, self.boms.get(mo.product_id.id))
|
||||
|
||||
def test_pick_a_pack_confirm(self):
|
||||
so, bom_a, bom_b = self._prepare_so()
|
||||
picking, boms = so.picking_ids[0], (bom_a, bom_b)
|
||||
|
||||
for i, line in enumerate(picking.move_ids):
|
||||
values = line._prepare_procurement_values()
|
||||
self.assertEqual(values["bom_id"], boms[i])
|
||||
|
||||
def test_mismatch_product_variant_ids(self):
|
||||
so, bom_a, bom_b = self._prepare_so()
|
||||
line_a = so.order_line[0]
|
||||
self.assertEqual(line_a.bom_id, bom_a)
|
||||
with self.assertRaises(ValidationError):
|
||||
line_a.write({"bom_id": bom_b})
|
||||
|
||||
def test_accept_bom_with_no_variant(self):
|
||||
# make variants for template of product A
|
||||
product_tmpl_a = self.product_a.product_tmpl_id
|
||||
prod_att_color = self.env["product.attribute"].create({"name": "Color"})
|
||||
product_attr_val_red, product_attr_val_green = self.env[
|
||||
"product.attribute.value"
|
||||
].create(
|
||||
[
|
||||
{"name": "red", "attribute_id": prod_att_color.id, "sequence": 1},
|
||||
{"name": "blue", "attribute_id": prod_att_color.id, "sequence": 2},
|
||||
]
|
||||
)
|
||||
product_tmpl_a.attribute_line_ids = [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"attribute_id": prod_att_color.id,
|
||||
"value_ids": [
|
||||
(6, 0, [product_attr_val_red.id, product_attr_val_green.id])
|
||||
],
|
||||
},
|
||||
)
|
||||
]
|
||||
product_a = product_tmpl_a.product_variant_ids[0]
|
||||
bom_no_variant = self._create_bom(product_tmpl_a)
|
||||
so = self._create_sale_order(self.partner, "SO2")
|
||||
self._create_sale_order_line(so, product_a, 2, 25, bom_no_variant)
|
||||
line_a = so.order_line[0]
|
||||
line_a.bom_id = bom_no_variant
|
||||
211
sale_mrp_bom/tests/test_sale_mrp_bom_multi_line.py
Executable file
211
sale_mrp_bom/tests/test_sale_mrp_bom_multi_line.py
Executable file
@@ -0,0 +1,211 @@
|
||||
# Copyright 2025 360ERP (https://www.360erp.com)
|
||||
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestSalePhantomBomProcurementMultiLine(TransactionCase):
|
||||
"""
|
||||
Tests Phantom BoM explosion for Kits selected on SO Lines.
|
||||
Focuses on a scenario with multiple lines for the same kit product,
|
||||
each specifying a different phantom BoM referencing the same component,
|
||||
to ensure component quantities are correctly exploded and aggregated
|
||||
within the resulting Stock Picking moves.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.env = cls.env(
|
||||
context=dict(
|
||||
cls.env.context,
|
||||
mail_create_nolog=True,
|
||||
mail_create_nosubscribe=True,
|
||||
mail_notrack=True,
|
||||
no_reset_password=True,
|
||||
tracking_disable=True,
|
||||
)
|
||||
)
|
||||
cls.company = cls.env.company
|
||||
|
||||
# Required groups
|
||||
cls.env.user.groups_id += cls.env.ref("stock.group_adv_location")
|
||||
cls.env.user.groups_id += cls.env.ref("sale_mrp_bom.sale_mrp_bom_group")
|
||||
|
||||
# Ensure MTO Route is Active
|
||||
cls.mto_route = cls.env.ref(
|
||||
"stock.route_warehouse0_mto", raise_if_not_found=True
|
||||
)
|
||||
if not cls.mto_route.active:
|
||||
cls.mto_route.action_unarchive()
|
||||
|
||||
# Products
|
||||
cls.product_mtokit = cls.env["product.product"].create(
|
||||
[
|
||||
{
|
||||
"name": "MTOKIT",
|
||||
"type": "consu",
|
||||
"route_ids": [(6, 0, [cls.mto_route.id])],
|
||||
"categ_id": cls.env.ref("product.product_category_all").id,
|
||||
}
|
||||
]
|
||||
)
|
||||
cls.product_mtocomp = cls.env["product.product"].create(
|
||||
[
|
||||
{
|
||||
"name": "MTOCOMP",
|
||||
"type": "consu",
|
||||
"route_ids": [],
|
||||
"categ_id": cls.env.ref("product.product_category_all").id,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
# BoMs (Phantom)
|
||||
cls.bom_kit1 = cls.env["mrp.bom"].create(
|
||||
[
|
||||
{
|
||||
"product_tmpl_id": cls.product_mtokit.product_tmpl_id.id,
|
||||
"product_qty": 1.0,
|
||||
"type": "phantom",
|
||||
"code": "KIT1",
|
||||
}
|
||||
]
|
||||
)
|
||||
# BoM Line 1: 1 x MTOCOMP
|
||||
cls.env["mrp.bom.line"].create(
|
||||
[
|
||||
{
|
||||
"bom_id": cls.bom_kit1.id,
|
||||
"product_id": cls.product_mtocomp.id,
|
||||
"product_qty": 1,
|
||||
}
|
||||
]
|
||||
)
|
||||
cls.bom_kit2 = cls.env["mrp.bom"].create(
|
||||
[
|
||||
{
|
||||
"product_tmpl_id": cls.product_mtokit.product_tmpl_id.id,
|
||||
"product_qty": 1.0,
|
||||
"type": "phantom",
|
||||
"code": "KIT2",
|
||||
}
|
||||
]
|
||||
)
|
||||
# BoM Line 2: 2 x MTOCOMP
|
||||
cls.env["mrp.bom.line"].create(
|
||||
[
|
||||
{
|
||||
"bom_id": cls.bom_kit2.id,
|
||||
"product_id": cls.product_mtocomp.id,
|
||||
"product_qty": 2,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
cls.partner = cls.env.ref("base.res_partner_2") # Customer
|
||||
cls.warehouse = cls.env.ref("stock.warehouse0")
|
||||
|
||||
def _create_sale_order(self, partner):
|
||||
return self.env["sale.order"].create(
|
||||
[
|
||||
{
|
||||
"partner_id": partner.id,
|
||||
"partner_invoice_id": partner.id,
|
||||
"partner_shipping_id": partner.id,
|
||||
"warehouse_id": self.warehouse.id,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
def _create_sale_order_line(self, sale_order, product, qty, bom):
|
||||
sol = self.env["sale.order.line"].create(
|
||||
[
|
||||
{
|
||||
"order_id": sale_order.id,
|
||||
"product_id": product.id,
|
||||
"product_uom_qty": qty,
|
||||
"bom_id": bom.id,
|
||||
"product_uom": product.uom_id.id,
|
||||
"price_unit": 1,
|
||||
}
|
||||
]
|
||||
)
|
||||
return sol
|
||||
|
||||
def test_phantom_bom_explosion_multi_line_same_component(self):
|
||||
"""
|
||||
Test SO with 2 lines for MTOKIT (phantom): Line 1 uses KIT1 (1 comp),
|
||||
Line 2 uses KIT2 (2 comps).
|
||||
Verify that the resulting delivery picking moves contain lines for MTO_COMP
|
||||
with correctly aggregated quantities based on the phantom BoM explosion.
|
||||
"""
|
||||
# Create SO
|
||||
so = self._create_sale_order(self.partner)
|
||||
qty_line1 = 5
|
||||
qty_line2 = 3
|
||||
|
||||
# Line 1: 5 x MTOKIT using KIT1 (-> 5 * 1 = 5 MTO_COMP)
|
||||
self._create_sale_order_line(so, self.product_mtokit, qty_line1, self.bom_kit1)
|
||||
# Line 2: 3 x MTOKIT using KIT2 (-> 3 * 2 = 6 MTO_COMP)
|
||||
self._create_sale_order_line(so, self.product_mtokit, qty_line2, self.bom_kit2)
|
||||
|
||||
# Confirm the Sale Order - This triggers the delivery order creation
|
||||
# and the phantom BoM explosion for the delivery moves.
|
||||
so.action_confirm()
|
||||
|
||||
# Find the picking associated with the Sale Order
|
||||
pickings = so.picking_ids
|
||||
self.assertEqual(
|
||||
len(pickings),
|
||||
1,
|
||||
f"Expected one picking for {so.name}, found {len(pickings)}",
|
||||
)
|
||||
picking = pickings[0]
|
||||
|
||||
# Find stock moves within this picking
|
||||
moves = picking.move_ids
|
||||
|
||||
# Verify no moves for the kit itself (it's phantom)
|
||||
kit_product_moves = moves.filtered(
|
||||
lambda m: m.product_id == self.product_mtokit
|
||||
)
|
||||
self.assertFalse(
|
||||
kit_product_moves,
|
||||
"No stock move line should be created for the parent kit product (MTOKIT).",
|
||||
)
|
||||
|
||||
# Find the moves specifically for the component within this picking
|
||||
comp_product_moves = moves.filtered(
|
||||
lambda m: m.product_id == self.product_mtocomp
|
||||
)
|
||||
self.assertTrue(
|
||||
comp_product_moves,
|
||||
f"Stock move lines for {self.product_mtocomp.name} should be created.",
|
||||
)
|
||||
|
||||
# Calculate expected *total* component quantity based on BoMs
|
||||
expected_comp_qty_line1 = (
|
||||
qty_line1
|
||||
* self.bom_kit1.bom_line_ids.filtered(
|
||||
lambda bl: bl.product_id == self.product_mtocomp
|
||||
).product_qty
|
||||
)
|
||||
expected_comp_qty_line2 = (
|
||||
qty_line2
|
||||
* self.bom_kit2.bom_line_ids.filtered(
|
||||
lambda bl: bl.product_id == self.product_mtocomp
|
||||
).product_qty
|
||||
)
|
||||
expected_total_qty = (
|
||||
expected_comp_qty_line1 + expected_comp_qty_line2
|
||||
) # Should be 5 * 1 + 3 * 2 = 11
|
||||
|
||||
# Verify the total quantity demanded in the generated stock moves
|
||||
# Check the initial demand planned for the picking
|
||||
actual_total_move_qty = sum(comp_product_moves.mapped("product_uom_qty"))
|
||||
self.assertEqual(
|
||||
actual_total_move_qty,
|
||||
expected_total_qty,
|
||||
f"MTO_COMP: expected {expected_total_qty}, got {actual_total_move_qty}.",
|
||||
)
|
||||
Reference in New Issue
Block a user