diff --git a/stock_picking_report_valued_sale_mrp/models/stock_move.py b/stock_picking_report_valued_sale_mrp/models/stock_move.py index 379457cf0..3663c4a04 100644 --- a/stock_picking_report_valued_sale_mrp/models/stock_move.py +++ b/stock_picking_report_valued_sale_mrp/models/stock_move.py @@ -18,14 +18,18 @@ def _get_components_per_kit(self): or sale_line.product_id.ids == sale_line.product_id.get_components() ): return 0 - component_demand = sum( - sale_line.move_ids.filtered( - lambda x: x.product_id == self.product_id - and not x.origin_returned_move_id - and ( - x.state != "cancel" - or (x.state == "cancel" and x.picking_id.backorder_id) - ) - ).mapped("product_uom_qty") + kit_moves = sale_line.move_ids.filtered( + lambda x: x.product_id == self.product_id + and ( + x.state != "cancel" + or (x.state == "cancel" and x.picking_id.backorder_id) + ) + ) + + kit_moves_to_sum = kit_moves.filtered(lambda x: not x.origin_returned_move_id) + kit_moves_to_subtract = kit_moves - kit_moves_to_sum + + component_demand = sum(kit_moves_to_sum.mapped("product_uom_qty")) - sum( + kit_moves_to_subtract.mapped("product_uom_qty") ) return component_demand / sale_line.product_uom_qty diff --git a/stock_picking_report_valued_sale_mrp/tests/test_stock_picking_report_valued_mrp.py b/stock_picking_report_valued_sale_mrp/tests/test_stock_picking_report_valued_mrp.py index b2ae1839f..eaab4e4cc 100644 --- a/stock_picking_report_valued_sale_mrp/tests/test_stock_picking_report_valued_mrp.py +++ b/stock_picking_report_valued_sale_mrp/tests/test_stock_picking_report_valued_mrp.py @@ -45,22 +45,51 @@ def setUpClass(cls): cls.product_2 = cls.product_product.create( {"name": "Product test 2", "type": "product"} ) - order_form = Form(cls.env["sale.order"]) - order_form.partner_id = cls.partner + ( + cls.sale_order_3, + cls.order_line, + cls.order_out_picking, + ) = cls._create_sale_order_with_lines( + cls, + product=cls.product_kit, + quantity=5, + price_unit=29.9, + tax=cls.tax10, + ) + + def _create_sale_order_with_lines(self, product, quantity, price_unit, tax=None): + """Create a sale order with a single line, confirm it, and return + the sale order, filtered order line, and picking. + + Args: + product: product.product to add to the order + quantity: quantity for the order line + price_unit: unit price for the order line + tax: account.tax to add (optional) + + Returns: + Tuple of (sale.order, filtered order_line, stock.picking) + """ + order_form = Form(self.env["sale.order"]) + order_form.partner_id = self.partner + with order_form.order_line.new() as line_form: - line_form.product_id = cls.product_kit - line_form.product_uom_qty = 5 - line_form.price_unit = 29.9 + line_form.product_id = product + line_form.product_uom_qty = quantity + line_form.price_unit = price_unit line_form.tax_id.clear() - line_form.tax_id.add(cls.tax10) - cls.sale_order_3 = order_form.save() - cls.sale_order_3.action_confirm() - # Maybe other modules create additional lines in the create - # method in sale.order model, so let's find the correct line. - cls.order_line = cls.sale_order_3.order_line.filtered( - lambda r: r.product_id == cls.product_kit - ) - cls.order_out_picking = cls.sale_order_3.picking_ids + if tax: + line_form.tax_id.add(tax) + + sale_order = order_form.save() + sale_order.action_confirm() + + # Filter to find the correct line (handles modules that add extra lines) + order_line = sale_order.order_line.filtered(lambda r: r.product_id == product) + # Return the first picking, not the recordset + picking = sale_order.picking_ids[0] if sale_order.picking_ids else None + + return sale_order, order_line, picking def test_01_picking_confirmed(self): for line in self.order_out_picking.move_ids: @@ -73,3 +102,94 @@ def test_01_picking_confirmed(self): self.env["ir.actions.report"]._render_qweb_html( self.env.ref("stock.action_report_delivery"), self.order_out_picking.ids ) + + def _create_sale_order_for_kits(self, qty): + """Create a new sale order for the configured kit product.""" + return self._create_sale_order_with_lines( + product=self.product_kit, + quantity=qty, + price_unit=29.9, + ) + + def test_02_get_components_per_kit_return_redelivery(self): + """_get_components_per_kit() must return correct component-per-kit value + after full return + redelivery, using a clean, fresh SO.""" + + # --------------------------------------------------------- + # Create a fresh SO with quantity = 2 kits + # --------------------------------------------------------- + sale, so_line, picking = self._create_sale_order_for_kits(qty=2) + picking = picking[0] + + # --------------------------------------------------------- + # First delivery + # --------------------------------------------------------- + picking.action_assign() + for line in picking.move_ids: + line.quantity_done = line.product_uom_qty + + picking.button_validate() + + # --------------------------------------------------------- + # Return delivery (Odoo core pattern) + # --------------------------------------------------------- + return_wizard_form = Form( + self.env["stock.return.picking"].with_context( + active_id=picking.id, + active_model="stock.picking", + ) + ) + + return_wiz = return_wizard_form.save() + res = return_wiz.create_returns()["res_id"] + return_picking = self.env["stock.picking"].browse(res) + + return_picking.action_assign() + for line in return_picking.move_ids: + line.quantity_done = line.product_uom_qty + return_picking.button_validate() + + # --------------------------------------------------------- + # Redelivery + # --------------------------------------------------------- + sale._action_cancel() + sale.action_draft() + sale.action_confirm() + redelivery = sale.picking_ids.filtered( + lambda p: p.state not in ("done", "cancel") + ) + self.assertTrue(redelivery) + redelivery = redelivery[0] + + redelivery.action_assign() + for line in redelivery.move_ids: + line.quantity_done = line.product_uom_qty + redelivery.button_validate() + + # --------------------------------------------------------- + # Validate `_get_components_per_kit()` correctly calculates + # BOM component quantities PER KIT (not doubled) + # --------------------------------------------------------- + kit_lines = redelivery.move_line_ids.filtered("phantom_product_id") + self.assertTrue(kit_lines) + expected_per_kit = { + self.product_kit_comp_1.id: 2.0, + self.product_kit_comp_2.id: 4.0, + } + + for sale_line in kit_lines.mapped("sale_line"): + move_lines = kit_lines.filtered(lambda x: x.sale_line == sale_line) + phantom_line = move_lines[:1] + if not phantom_line: + continue + + move = phantom_line.move_id + expected = expected_per_kit[move.product_id.id] + got = move._get_components_per_kit() + + self.assertEqual( + got, + expected, + f"_get_components_per_kit returned {got} but expected {expected} " + f"for component {move.product_id.display_name}", + )