From c7398723344a4b66cdf9e68f5d1c5a4fc14c90bd Mon Sep 17 00:00:00 2001 From: Karl Southern Date: Thu, 7 Jul 2022 16:21:49 +0100 Subject: [PATCH] fix part delivery of non-mrp sale stock, fixes T2163 --- .../models/product_commingled.py | 11 +- product_commingle_mrp/tests/test_mrp.py | 19 +++ product_commingle_sale_stock/models/sale.py | 29 ++-- product_commingle_stock/models/stock_move.py | 48 ++++++- product_commingle_stock/tests/test_stock.py | 127 ++++++++++++++++++ 5 files changed, 218 insertions(+), 16 deletions(-) diff --git a/product_commingle/models/product_commingled.py b/product_commingle/models/product_commingled.py index a7e49be..a760ccd 100644 --- a/product_commingle/models/product_commingled.py +++ b/product_commingle/models/product_commingled.py @@ -19,20 +19,17 @@ class ProductCommingled(models.Model): ), ] - @api.constrains("product_id") - def _check_uom_category(self): + @api.constrains("product_id", "parent_product_id") + def _check_uom(self): for line in self: parent_product = line.parent_product_id lines = line while lines: - if ( - parent_product.uom_id.category_id - != lines.product_id.uom_id.category_id - ): + if parent_product.uom_id != lines.product_id.uom_id: raise ValidationError( _( "You cannot mix and match commingled products with" - " different UoM categories!\nParent: %(parent)s," + " different UoMs!\nParent: %(parent)s," " Child: %(child)s" ) % {"parent": parent_product, "child": lines.product_id} diff --git a/product_commingle_mrp/tests/test_mrp.py b/product_commingle_mrp/tests/test_mrp.py index 1186939..457fd6a 100644 --- a/product_commingle_mrp/tests/test_mrp.py +++ b/product_commingle_mrp/tests/test_mrp.py @@ -73,3 +73,22 @@ def test_simple_stock_move_nested_inside_kit(self): picking_id.action_confirm() self.assertEqual(len(picking_id.move_lines), 1) self.assertEqual(picking_id.move_lines.product_id, self.product_bolta) + + # XXX: Taken directly from sale_mrp/models/sale.py#L112-L115 + + 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, + } + + self.assertEqual( + picking_id.move_lines._compute_kit_quantities( + self.product_bolt_kit, 1, self.bom_equiv_kit, filters + ), + 1.0, + ) diff --git a/product_commingle_sale_stock/models/sale.py b/product_commingle_sale_stock/models/sale.py index eba4466..af8ce85 100644 --- a/product_commingle_sale_stock/models/sale.py +++ b/product_commingle_sale_stock/models/sale.py @@ -6,14 +6,27 @@ class SaleOrderLine(models.Model): def _compute_qty_delivered(self): res = super(SaleOrderLine, self)._compute_qty_delivered() - for order_line in self.filtered(lambda l: l.product_id.commingled_ok): - # TODO: we aren't supporting partial delivery of commingled products - # due to the complications of kits within commingled products, and - # how we go about dealing with those. - if all(m.state == "done" for m in order_line.move_ids): - order_line.qty_delivered = order_line.product_uom_qty - else: - order_line.qty_delivered = 0.0 + + 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, + } + + for order_line in self.filtered( + lambda l: l.product_id.commingled_ok + and l.qty_delivered_method == "stock_move" + ): + done_moves = order_line.mapped("move_ids").filtered( + lambda m: m.state == "done" + ) + order_line.qty_delivered = done_moves._compute_commingled_quantities( + order_line.product_id, filters + ) return res @api.onchange( diff --git a/product_commingle_stock/models/stock_move.py b/product_commingle_stock/models/stock_move.py index 21129a7..e3aba66 100644 --- a/product_commingle_stock/models/stock_move.py +++ b/product_commingle_stock/models/stock_move.py @@ -1,4 +1,5 @@ -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import UserError from odoo.tools import OrderedSet @@ -10,6 +11,11 @@ class StockMove(models.Model): "Commingled Product", ) + product_commingled_id = fields.Many2one( + "product.commingled", + "Commingled Product", + ) + @api.model def _prepare_merge_moves_distinct_fields(self): distinct_fields = super()._prepare_merge_moves_distinct_fields() @@ -87,6 +93,7 @@ def _prepare_commingled_move_values(self, bom_line, line_data, quantity_done): if self.commingled_original_product_id.id else self.product_id.id ), + "product_commingled_id": bom_line.id, } def _generate_move_commingled(self, bom_line, line_data, quantity_done=None): @@ -100,3 +107,42 @@ def _generate_move_commingled(self, bom_line, line_data, quantity_done=None): if self.state == "assigned": vals["state"] = "assigned" return vals + + def _compute_commingled_quantities(self, product_id, filters): + """ + Computes the quantity delivered or received when a commingled item is sold or purchased. + :return: The quantity delivered or received + """ + + if not product_id.commingled_ok: + raise UserError( + _("Product %(product_id)s is not commingled!") + % {"product_id": product_id} + ) + + to_find = self.env["product.commingled"] + todo = product_id.commingled_ids + + # FIXME: This does not support differing UoM, this is technically + # not supported by product_commingle anyway, however, this I believe + # is the only section of code that would fail. + + while todo: + to_find |= product_id.commingled_ids + + todo = ( + product_id.commingled_ids.mapped("product_id") + .filtered(lambda p: p.commingled_ok) + .mapped("commingled_ids") + ) + + candidates = self.filtered(lambda m: m.product_commingled_id in (to_find)) + + incoming_moves = candidates.filtered(filters["incoming_moves"]) + outgoing_moves = candidates.filtered(filters["outgoing_moves"]) + + qty_processed = sum(incoming_moves.mapped("product_qty")) - sum( + outgoing_moves.mapped("product_qty") + ) + + return qty_processed diff --git a/product_commingle_stock/tests/test_stock.py b/product_commingle_stock/tests/test_stock.py index 627a3e3..ad6bb02 100644 --- a/product_commingle_stock/tests/test_stock.py +++ b/product_commingle_stock/tests/test_stock.py @@ -18,6 +18,16 @@ def setUpClass(cls): cls.stock_location = cls.env.ref("stock.stock_location_stock") cls.customer_location = cls.env.ref("stock.stock_location_customers") + cls.outbound_move_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, + } + def test_strict_default(self): picking_id = self.env["stock.picking"].create( { @@ -47,6 +57,28 @@ def test_strict_default(self): picking_id.action_confirm() self.assertEqual(len(picking_id.move_lines), 1) self.assertEqual(picking_id.move_lines.product_id, self.product_bolta) + self.assertEqual( + picking_id.move_lines.product_commingled_id, + self.product_bolt_equiv.commingled_ids.filtered( + lambda c: c.product_id == self.product_bolta + ), + ) + self.assertEqual( + picking_id.move_lines._compute_commingled_quantities( + self.product_bolt_equiv, self.outbound_move_filters + ), + 1, + ) + + done_moves = picking_id.move_lines.filtered(lambda m: m.state == "done") + + self.assertEqual( + done_moves._compute_commingled_quantities( + self.product_bolt_equiv, + self.outbound_move_filters, + ), + 0, + ) def test_strict_reordered(self): self.product_bolt_equiv.commingled_ids.filtered( @@ -82,6 +114,27 @@ def test_strict_reordered(self): picking_id.action_confirm() self.assertEqual(len(picking_id.move_lines), 1) self.assertEqual(picking_id.move_lines.product_id, self.product_boltb) + self.assertEqual( + picking_id.move_lines.product_commingled_id, + self.product_bolt_equiv.commingled_ids.filtered( + lambda c: c.product_id == self.product_boltb + ), + ) + self.assertEqual( + picking_id.move_lines._compute_commingled_quantities( + self.product_bolt_equiv, self.outbound_move_filters + ), + 1, + ) + + self.assertEqual( + picking_id.move_lines.filtered( + lambda m: m.state == "done" + )._compute_commingled_quantities( + self.product_bolt_equiv, self.outbound_move_filters + ), + 0, + ) def test_deplete(self): self.product_bolt_equiv.write({"commingled_policy": "deplete"}) @@ -125,6 +178,28 @@ def test_deplete(self): picking_id.action_confirm() self.assertEqual(len(picking_id.move_lines), 1) self.assertEqual(picking_id.move_lines.product_id, self.product_boltb) + self.assertEqual( + picking_id.move_lines.product_commingled_id, + self.product_bolt_equiv.commingled_ids.filtered( + lambda c: c.product_id == self.product_boltb + ), + ) + + self.assertEqual( + picking_id.move_lines._compute_commingled_quantities( + self.product_bolt_equiv, self.outbound_move_filters + ), + 1, + ) + + self.assertEqual( + picking_id.move_lines.filtered( + lambda m: m.state == "done" + )._compute_commingled_quantities( + self.product_bolt_equiv, self.outbound_move_filters + ), + 0, + ) def test_deplete_prefer_homogenous(self): self.product_bolt_equiv.write( @@ -173,6 +248,27 @@ def test_deplete_prefer_homogenous(self): picking_id.action_confirm() self.assertEqual(len(picking_id.move_lines), 1) self.assertEqual(picking_id.move_lines.product_id, self.product_bolta) + self.assertEqual( + picking_id.move_lines.product_commingled_id, + self.product_bolt_equiv.commingled_ids.filtered( + lambda c: c.product_id == self.product_bolta + ), + ) + self.assertEqual( + picking_id.move_lines._compute_commingled_quantities( + self.product_bolt_equiv, self.outbound_move_filters + ), + 102, + ) + + self.assertEqual( + picking_id.move_lines.filtered( + lambda m: m.state == "done" + )._compute_commingled_quantities( + self.product_bolt_equiv, self.outbound_move_filters + ), + 0, + ) def test_deplete_no_prefer_homogenous(self): self.product_bolt_equiv.write( @@ -220,6 +316,21 @@ def test_deplete_no_prefer_homogenous(self): picking_id.action_confirm() self.assertEqual(len(picking_id.move_lines), 2) + self.assertEqual( + picking_id.move_lines._compute_commingled_quantities( + self.product_bolt_equiv, self.outbound_move_filters + ), + 102, + ) + + self.assertEqual( + picking_id.move_lines.filtered( + lambda m: m.state == "done" + )._compute_commingled_quantities( + self.product_bolt_equiv, self.outbound_move_filters + ), + 0, + ) def test_deplete_split(self): self.product_bolt_equiv.write({"commingled_policy": "deplete"}) @@ -262,3 +373,19 @@ def test_deplete_split(self): picking_id.action_confirm() self.assertEqual(len(picking_id.move_lines), 2) + + self.assertEqual( + picking_id.move_lines._compute_commingled_quantities( + self.product_bolt_equiv, self.outbound_move_filters + ), + 102, + ) + + self.assertEqual( + picking_id.move_lines.filtered( + lambda m: m.state == "done" + )._compute_commingled_quantities( + self.product_bolt_equiv, self.outbound_move_filters + ), + 0, + )