From ac11b7e00dea13a0d20bf0c39e544d12c0a1e9fe Mon Sep 17 00:00:00 2001
From: "Adrien Widart (awt)" <awt@odoo.com>
Date: Tue, 15 Nov 2022 15:08:40 +0000
Subject: [PATCH] [FIX] mrp: include unscrapped SN when checking uniqueness

It is not possible to consume a component tracked by serial that comes
back from a scrap location

To reproduce the issue:
1. In Settings, enable "Multi Routes"
2. Create two storable products P_compo, P_finished
    - P_compo is tracked by serial number
3. Update the on-hand qty of P_compo:
    - 1 x P_compo with serial SN
4. Process a manufacturing order MO:
    - Product: P_finished
    - Compo: 1 x P_compo with SN
5. Unbuild P_finished
    - It brings SN back to stock
5. Scrap one P_compo with SN
6. Unscrap it (thanks to an internal transfer)
7. Repeat step 4

Error: a user error is raised: "The serial number SN used for component
P_compo has already been consumed"

When checking the SN uniqueness of a component, we don't consider the
case where a product came back from a srap location

OPW-3055252

closes odoo/odoo#105843

Signed-off-by: William Henrotin (whe) <whe@odoo.com>
---
 addons/mrp/models/mrp_production.py   |  8 ++-
 addons/mrp/tests/test_traceability.py | 87 +++++++++++++++++++++++++++
 2 files changed, 94 insertions(+), 1 deletion(-)

diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py
index c3c1b83d7d96..97f25d82f111 100644
--- a/addons/mrp/models/mrp_production.py
+++ b/addons/mrp/models/mrp_production.py
@@ -1795,8 +1795,14 @@ class MrpProduction(models.Model):
                         ('state', '=', 'done'),
                         ('location_dest_id.scrap_location', '=', True)
                     ])
+                    unremoved = self.env['stock.move.line'].search_count([
+                        ('lot_id', '=', move_line.lot_id.id),
+                        ('state', '=', 'done'),
+                        ('location_id.scrap_location', '=', True),
+                        ('location_dest_id.scrap_location', '=', False),
+                    ])
                     # Either removed or unbuild
-                    if not ((duplicates_returned or removed) and duplicates - duplicates_returned - removed == 0):
+                    if not ((duplicates_returned or removed) and duplicates - duplicates_returned - removed + unremoved == 0):
                         raise UserError(message)
                 # Check presence of same sn in current production
                 duplicates = co_prod_move_lines.filtered(lambda ml: ml.qty_done and ml.lot_id == move_line.lot_id) - move_line
diff --git a/addons/mrp/tests/test_traceability.py b/addons/mrp/tests/test_traceability.py
index 9cae9394f436..40b97f2677cf 100644
--- a/addons/mrp/tests/test_traceability.py
+++ b/addons/mrp/tests/test_traceability.py
@@ -497,3 +497,90 @@ class TestTraceability(TestMrpCommon):
 
         mo.button_mark_done()
         self.assertEqual(mo.state, 'done')
+
+    def test_unbuild_scrap_and_unscrap_tracked_component(self):
+        """
+        Suppose a tracked-by-SN component C. There is one C in stock with SN01.
+        Build a product P that uses C with SN, unbuild P, scrap SN, unscrap SN
+        and rebuild a product with SN in the components
+        """
+        warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1)
+        stock_location = warehouse.lot_stock_id
+
+        component = self.bom_4.bom_line_ids.product_id
+        component.write({
+            'type': 'product',
+            'tracking': 'serial',
+        })
+        serial_number = self.env['stock.production.lot'].create({
+            'product_id': component.id,
+            'name': 'Super Serial',
+            'company_id': self.env.company.id,
+        })
+        self.env['stock.quant']._update_available_quantity(component, stock_location, 1, lot_id=serial_number)
+
+        # produce 1
+        mo_form = Form(self.env['mrp.production'])
+        mo_form.bom_id = self.bom_4
+        mo = mo_form.save()
+        mo.action_confirm()
+        mo.action_assign()
+        self.assertEqual(mo.move_raw_ids.move_line_ids.lot_id, serial_number)
+
+        with Form(mo) as mo_form:
+            mo_form.qty_producing = 1
+        mo.move_raw_ids.move_line_ids.qty_done = 1
+        mo.button_mark_done()
+
+        # unbuild
+        action = mo.button_unbuild()
+        wizard = Form(self.env[action['res_model']].with_context(action['context'])).save()
+        wizard.action_validate()
+
+        # scrap the component
+        scrap = self.env['stock.scrap'].create({
+            'product_id': component.id,
+            'product_uom_id': component.uom_id.id,
+            'scrap_qty': 1,
+            'lot_id': serial_number.id,
+        })
+        scrap_location = scrap.scrap_location_id
+        scrap.do_scrap()
+
+        # unscrap the component
+        internal_move = self.env['stock.move'].create({
+            'name': component.name,
+            'location_id': scrap_location.id,
+            'location_dest_id': stock_location.id,
+            'product_id': component.id,
+            'product_uom': component.uom_id.id,
+            'product_uom_qty': 1.0,
+            'move_line_ids': [(0, 0, {
+                'product_id': component.id,
+                'location_id': scrap_location.id,
+                'location_dest_id': stock_location.id,
+                'product_uom_id': component.uom_id.id,
+                'qty_done': 1.0,
+                'lot_id': serial_number.id,
+            })],
+        })
+        internal_move._action_confirm()
+        internal_move._action_done()
+
+        # produce one with the unscrapped component
+        mo_form = Form(self.env['mrp.production'])
+        mo_form.bom_id = self.bom_4
+        mo = mo_form.save()
+        mo.action_confirm()
+        mo.action_assign()
+        self.assertEqual(mo.move_raw_ids.move_line_ids.lot_id, serial_number)
+
+        with Form(mo) as mo_form:
+            mo_form.qty_producing = 1
+        mo.move_raw_ids.move_line_ids.qty_done = 1
+        mo.button_mark_done()
+
+        self.assertRecordValues((mo.move_finished_ids + mo.move_raw_ids).move_line_ids, [
+            {'product_id': self.bom_4.product_id.id, 'lot_id': False, 'qty_done': 1},
+            {'product_id': component.id, 'lot_id': serial_number.id, 'qty_done': 1},
+        ])
-- 
GitLab