Skip to content

Commit 1579695

Browse files
Merge pull request #202 from GlodoUK/19.0-mig-sale_mrp
[MIG] cpq_sale_mrp: Migration to 19.0
2 parents 8e4730a + 8bd8385 commit 1579695

10 files changed

Lines changed: 448 additions & 0 deletions

File tree

cpq_sale_mrp/README.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
------------
2+
cpq_sale_mrp
3+
------------
4+
5+
Glue module between sale_mrp and cpq_mrp.
6+
7+
Known Issues
8+
------------
9+
10+
``_compute_qty_delivered`` and ``_get_qty_procurement`` both work on the ultra basic
11+
assumptions around quantities.
12+
13+
This is partially by design, until we have a better solution.
14+
15+
As products are *highly* configurable and BoMs may change over time, this seems
16+
like the least disruptive solution, for the moment.

cpq_sale_mrp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models

cpq_sale_mrp/__manifest__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "cpq_sale_mrp",
3+
"summary": "Glue module for sale_mrp and cpq_mrp",
4+
"version": "19.0.1.0.0",
5+
"author": "Glo Networks",
6+
"website": "https://github.com/GlodoUK/odoo-addons",
7+
"depends": ["sale_mrp", "cpq_mrp"],
8+
"auto_install": ["sale_mrp", "cpq_mrp"],
9+
"license": "LGPL-3",
10+
}

cpq_sale_mrp/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import account_move_line
2+
from . import sale
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from odoo import models
2+
3+
4+
class AccountMoveLine(models.Model):
5+
_inherit = "account.move.line"
6+
7+
# fmt: off
8+
# ruff: noqa: E501, E741
9+
def _get_cogs_value(self):
10+
price_unit = super()._get_cogs_value()
11+
12+
so_line = self.sale_line_ids and self.sale_line_ids[-1] or False
13+
if so_line and so_line.product_id.cpq_ok:
14+
# Use the CPQ dynamic BoM stored on the stock moves, which captures
15+
# the configuration-specific components generated at order time
16+
bom = (
17+
so_line.move_ids.filtered(lambda m: m.state != "cancel")
18+
.mapped("cpq_bom_id")
19+
.filtered(lambda b: b.type == "phantom")
20+
.with_context(skip_cpq_validate_ptav_ids=True)
21+
)
22+
if bom:
23+
is_line_reversing = self.move_id.move_type == "out_refund"
24+
account_moves = so_line.invoice_lines.move_id.filtered(lambda m: m.state == "posted" and bool(m.reversed_entry_id) == is_line_reversing)
25+
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)
26+
qty_invoiced = sum([x.product_uom_id._compute_quantity(x.quantity, x.product_id.uom_id) for x in posted_invoice_lines])
27+
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)
28+
qty_invoiced -= sum([line.product_uom_id._compute_quantity(line.quantity, line.product_id.uom_id) for line in reversal_cogs])
29+
30+
moves = so_line.move_ids
31+
average_price_unit = 0
32+
# Use the CPQ-aware explosion method to get configuration-specific component quantities
33+
for product, product_dict in bom._get_exploded_qty_dict(so_line.product_id).items():
34+
factor = product_dict.get("qty")
35+
prod_moves = moves.filtered(lambda m, product=product: m.product_id == product)
36+
if not product.is_storable:
37+
continue
38+
product = product.with_company(self.company_id)
39+
average_price_unit += factor * prod_moves._get_price_unit()
40+
price_unit = average_price_unit / bom.product_qty or price_unit
41+
return price_unit
42+
# fmt: on

cpq_sale_mrp/models/sale.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from odoo import models
2+
3+
4+
class SaleOrderLine(models.Model):
5+
_inherit = "sale.order.line"
6+
7+
def _get_qty_procurement(self, previous_product_uom_qty=False):
8+
self.ensure_one()
9+
# Specific case when we change the qty on for a CPQ phantom product.
10+
# We don't try to be too smart and keep a simple approach: we compare
11+
# the quantity before and after update, and return the difference.
12+
# We don't take into account what was already sent, or any other
13+
# case.
14+
#
15+
# Since CPQ Dynamic BoMs can be highly configurable, calculating
16+
# differences can be complex and non-sensical in some tested
17+
# circumstances.
18+
#
19+
# We are also out of time to deliver, therefore this needs to suffice
20+
# for the moment.
21+
bom = (
22+
self.env["cpq.dynamic.bom"]
23+
.sudo()
24+
.search(
25+
[
26+
("product_tmpl_id", "=", self.product_id.product_tmpl_id.id),
27+
("type", "=", "phantom"),
28+
]
29+
)
30+
)
31+
if bom and previous_product_uom_qty:
32+
return previous_product_uom_qty.get(self.id, 0.0)
33+
return super()._get_qty_procurement(
34+
previous_product_uom_qty=previous_product_uom_qty
35+
)
36+
37+
def _prepare_qty_delivered(self):
38+
delivered_qties = super()._prepare_qty_delivered()
39+
for line in self:
40+
if line.qty_delivered_method == "stock_move" and line.product_id.cpq_ok:
41+
# In the case of a kit cpq.dynamic.bom, we need to check if all
42+
# components are shipped.
43+
# Since the BOM might have changed, especially on a dynamic BoM,
44+
# we currently do not compute the quantities but verify the move state.
45+
#
46+
# Please see the above notes about my justification for this,
47+
# for the moment.
48+
bom = (
49+
self.env["cpq.dynamic.bom"]
50+
.sudo()
51+
.search(
52+
[
53+
(
54+
"product_tmpl_id",
55+
"=",
56+
line.product_id.product_tmpl_id.id,
57+
),
58+
("type", "=", "phantom"),
59+
]
60+
)
61+
)
62+
if bom:
63+
moves = line.move_ids.filtered(
64+
lambda m: m.picking_id
65+
and m.picking_id.state != "cancel"
66+
and m.state == "done"
67+
)
68+
outgoing_moves = moves.filtered(
69+
lambda m: m.location_dest_id.usage == "customer"
70+
and (
71+
not m.origin_returned_move_id
72+
or (m.origin_returned_move_id and m.to_refund)
73+
)
74+
)
75+
bom_returned = outgoing_moves and all(
76+
moves.filtered(
77+
lambda m, move=move: m.location_dest_id.usage != "customer"
78+
and m.to_refund
79+
and m.origin_returned_move_id.id == move.id
80+
)
81+
for move in outgoing_moves
82+
)
83+
if moves and not bom_returned:
84+
delivered_qties[line] = line.product_uom_qty
85+
else:
86+
delivered_qties[line] = 0.0
87+
return delivered_qties

cpq_sale_mrp/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[build-system]
2+
requires = ["whool"]
3+
build-backend = "whool.buildapi"

cpq_sale_mrp/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import test_get_cogs_value

cpq_sale_mrp/tests/common.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
from odoo.fields import Command
2+
from odoo.tests.common import TransactionCase
3+
4+
5+
class TestCpqSaleMrpCommon(TransactionCase):
6+
@classmethod
7+
def setUpClass(cls):
8+
super().setUpClass()
9+
10+
cls.env.company.anglo_saxon_accounting = True
11+
12+
cls.categ_real_time = cls.env["product.category"].create(
13+
{
14+
"name": "CPQ Real Time",
15+
"property_valuation": "real_time",
16+
}
17+
)
18+
19+
cls.uom_meter = cls.env.ref("uom.product_uom_meter")
20+
21+
cls.uom_unit = cls.env.ref("uom.product_uom_unit")
22+
23+
cls.attr_cable_length = cls.env["product.attribute"].create(
24+
{"name": "Cable Length"}
25+
)
26+
cls.attr_val_custom_length = cls.env["product.attribute.value"].create(
27+
{
28+
"name": "Custom Length",
29+
"attribute_id": cls.attr_cable_length.id,
30+
"is_custom": True,
31+
"cpq_custom_type": "float",
32+
}
33+
)
34+
35+
cls.attr_cable_color = cls.env["product.attribute"].create(
36+
{"name": "Cable Color"}
37+
)
38+
cls.attr_val_black = cls.env["product.attribute.value"].create(
39+
{
40+
"name": "Black",
41+
"attribute_id": cls.attr_cable_color.id,
42+
}
43+
)
44+
cls.attr_val_grey = cls.env["product.attribute.value"].create(
45+
{
46+
"name": "Grey",
47+
"attribute_id": cls.attr_cable_color.id,
48+
}
49+
)
50+
51+
cls.bulk_cable = cls.env["product.product"].create(
52+
{
53+
"name": "Bulk Cat6 Cable",
54+
"is_storable": True,
55+
"standard_price": 2.0,
56+
"uom_id": cls.uom_meter.id,
57+
"categ_id": cls.categ_real_time.id,
58+
}
59+
)
60+
cls.rj45 = cls.env["product.product"].create(
61+
{
62+
"name": "RJ45 Connector",
63+
"is_storable": True,
64+
"standard_price": 0.0,
65+
"uom_id": cls.uom_unit.id,
66+
"categ_id": cls.categ_real_time.id,
67+
}
68+
)
69+
cls.boot = cls.env["product.product"].create(
70+
{
71+
"name": "Strain Relief Boot",
72+
"is_storable": True,
73+
"standard_price": 10.0,
74+
"uom_id": cls.uom_unit.id,
75+
"categ_id": cls.categ_real_time.id,
76+
}
77+
)
78+
cls.tie = cls.env["product.product"].create(
79+
{
80+
"name": "Velcro Tie",
81+
"is_storable": True,
82+
"standard_price": 5.0,
83+
"uom_id": cls.uom_unit.id,
84+
"categ_id": cls.categ_real_time.id,
85+
}
86+
)
87+
88+
# The CPQ kit
89+
cls.cable_kit_tmpl = cls.env["product.template"].create(
90+
{
91+
"name": "Configured Cable Loom Kit",
92+
"cpq_ok": True,
93+
"is_storable": True,
94+
"categ_id": cls.categ_real_time.id,
95+
"uom_id": cls.uom_unit.id,
96+
"attribute_line_ids": [
97+
Command.create(
98+
{
99+
"attribute_id": cls.attr_cable_length.id,
100+
"value_ids": [Command.set([cls.attr_val_custom_length.id])],
101+
}
102+
),
103+
Command.create(
104+
{
105+
"attribute_id": cls.attr_cable_color.id,
106+
"value_ids": [
107+
Command.set(
108+
[cls.attr_val_black.id, cls.attr_val_grey.id]
109+
)
110+
],
111+
}
112+
),
113+
],
114+
}
115+
)
116+
117+
# Resolve PTAVs for the kit template.
118+
cls.ptav_custom_length = cls.env["product.template.attribute.value"].search(
119+
[
120+
("product_tmpl_id", "=", cls.cable_kit_tmpl.id),
121+
(
122+
"product_attribute_value_id",
123+
"=",
124+
cls.attr_val_custom_length.id,
125+
),
126+
],
127+
limit=1,
128+
)
129+
cls.ptav_black = cls.env["product.template.attribute.value"].search(
130+
[
131+
("product_tmpl_id", "=", cls.cable_kit_tmpl.id),
132+
("product_attribute_value_id", "=", cls.attr_val_black.id),
133+
],
134+
limit=1,
135+
)
136+
137+
cls.cable_kit_dyn_bom = cls.env["cpq.dynamic.bom"].create(
138+
{
139+
"code": "TEST CABLE KIT DYN",
140+
"type": "phantom",
141+
"product_tmpl_id": cls.cable_kit_tmpl.id,
142+
"product_uom_id": cls.cable_kit_tmpl.uom_id.id,
143+
"product_qty": 1.0,
144+
"bom_line_ids": [
145+
Command.create(
146+
{
147+
"component_type": "variant",
148+
"component_product_id": cls.bulk_cable.id,
149+
"quantity_type": "ptav_custom_id",
150+
"quantity_ptav_custom_id": cls.ptav_custom_length.id,
151+
"condition_type": "always",
152+
"uom_id": cls.uom_meter.id,
153+
}
154+
),
155+
Command.create(
156+
{
157+
"component_type": "variant",
158+
"component_product_id": cls.rj45.id,
159+
"quantity_type": "fixed",
160+
"quantity_fixed": 2.0,
161+
"condition_type": "always",
162+
"uom_id": cls.uom_unit.id,
163+
}
164+
),
165+
Command.create(
166+
{
167+
"component_type": "variant",
168+
"component_product_id": cls.boot.id,
169+
"quantity_type": "fixed",
170+
"quantity_fixed": 2.0,
171+
"condition_type": "always",
172+
"uom_id": cls.uom_unit.id,
173+
}
174+
),
175+
Command.create(
176+
{
177+
"component_type": "variant",
178+
"component_product_id": cls.tie.id,
179+
"quantity_type": "fixed",
180+
"quantity_fixed": 2.0,
181+
"condition_type": "always",
182+
"uom_id": cls.uom_unit.id,
183+
}
184+
),
185+
],
186+
}
187+
)
188+
189+
cls.partner = cls.env["res.partner"].create({"name": "CPQ Test Customer"})

0 commit comments

Comments
 (0)