From e4681a58c6fccc50f02dae68225bf2cc2b3ec2c1 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 13 May 2026 07:57:56 -0400 Subject: [PATCH] fix(jobs): split fp.jobs by thickness + serial on SO confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _fp_auto_create_job grouping key was (recipe, part, coating). Lines that shared all three but differed in thickness (or serial) silently collapsed into one fp.job — the second line's thickness/SN was lost, and any downstream cert printed the first line's values across both batches. Silent mis-attestation = compliance hole. Extended the key tuple to (recipe, part, coating, thickness, serial). Single-line SOs and same-(thickness, SN) multi-line SOs collapse identically to before. Only lines that previously merged when they shouldn't have now split into their own fp.jobs. TDD via test_so_confirm_splits_by_thickness: - seeds the part with default_process_id so both lines hit the `if recipe:` branch (where the bug lived — the no_recipe branch already split correctly per line) - confirms 2 jobs after action_confirm with each carrying its own thickness via the linked SO line Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/models/sale_order.py | 26 ++++--- .../tests/test_fp_job_extensions.py | 76 +++++++++++++++++++ 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 14866dcf..50370cab 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.8.22.10', + 'version': '19.0.8.23.0', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order.py b/fusion_plating/fusion_plating_jobs/models/sale_order.py index e1ff3f2a..477a2f21 100644 --- a/fusion_plating/fusion_plating_jobs/models/sale_order.py +++ b/fusion_plating/fusion_plating_jobs/models/sale_order.py @@ -412,15 +412,13 @@ class SaleOrder(models.Model): _logger.info('SO %s: no plating lines, skipping job creation.', self.name) return - # Group by (recipe, part, coating). Lines that share ALL THREE - # collapse into one WO. Sharing only the recipe is not enough — - # the WO header captures part_id and coating_config_id from - # first_line, and downstream the CoC prints the WO header's - # part_number on the customer-facing cert. Bundling Part A + - # Part B under one WO because they happen to share a recipe - # would put Part A's number on a cert covering both, which is - # a compliance bug (silent mis-attestation). - # No-recipe lines get their own group each. + # Group by (recipe, part, coating, thickness, serial). Lines that + # share ALL FIVE collapse into one WO. Same compliance reasoning + # as part_id + coating_id: bundling lines with different thicknesses + # or different serials under one WO would carry the first line's + # values onto the cert + sticker — silent mis-attestation. Sub 5 + # added thickness_id + serial_id; this extends the grouping logic + # to honour them. No-recipe lines still get their own group each. groups = {} unrecipe_idx = 0 for line in plating_lines: @@ -433,8 +431,16 @@ class SaleOrder(models.Model): 'x_fc_coating_config_id' in line._fields and line.x_fc_coating_config_id.id ) or False + thickness_id = ( + 'x_fc_thickness_id' in line._fields + and line.x_fc_thickness_id.id + ) or False + serial_id = ( + 'x_fc_serial_id' in line._fields + and line.x_fc_serial_id.id + ) or False if recipe: - key = (recipe.id, part_id, coating_id) + key = (recipe.id, part_id, coating_id, thickness_id, serial_id) else: unrecipe_idx += 1 key = ('no_recipe', unrecipe_idx) diff --git a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py index c91a616c..8e5f7139 100644 --- a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py +++ b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py @@ -323,6 +323,82 @@ class TestSoConfirmHook(TransactionCase): else: self.skipTest('x_fc_part_catalog_id field not present') + def test_so_confirm_splits_by_thickness(self): + """Two lines with same recipe+part+coating but DIFFERENT thicknesses + must produce TWO fp.jobs — silent merge was a compliance bug (the + second thickness's CoC would carry the first thickness). + + The bug only manifests when lines hit the `if recipe:` branch in + _fp_auto_create_job — without a resolved recipe, the no_recipe + branch already splits per line. We seed a recipe via + part.default_process_id so both lines resolve to the same recipe + and reach the buggy grouping path. + """ + SOL = self.env['sale.order.line'] + Part = self.env['fp.part.catalog'] + Node = self.env['fusion.plating.process.node'] + Thick = self.env['fp.coating.thickness'] + if 'x_fc_part_catalog_id' not in SOL._fields \ + or 'x_fc_thickness_id' not in SOL._fields \ + or 'default_process_id' not in Part._fields: + self.skipTest('Sub 5 + recipe-on-part fields not present') + + # Two distinct existing thicknesses. Creating them from scratch + # requires a coating_config → process_type chain that's too noisy + # for a unit test; reuse what's seeded. + thicknesses = Thick.search([], limit=2) + if len(thicknesses) < 2: + self.skipTest('need >= 2 fp.coating.thickness records seeded') + thick_a, thick_b = thicknesses[0], thicknesses[1] + + # Any existing top-level recipe works — the test only needs both + # lines to resolve to the SAME recipe so they collide on the key. + recipe = Node.search([('parent_id', '=', False)], limit=1) + if not recipe: + self.skipTest('no fusion.plating.process.node records to anchor a recipe') + + partner_for_part = self.env['res.partner'].create({'name': 'SplitPartner'}) + part = Part.create({ + 'name': 'SplitPart', 'part_number': 'SP-1', + 'partner_id': partner_for_part.id, + 'default_process_id': recipe.id, + }) + + so = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'client_order_ref': 'TEST-PO-SPLIT', + }) + SOL.create({ + 'order_id': so.id, 'product_id': self.product.id, + 'product_uom_qty': 2.0, 'price_unit': 10.0, + 'x_fc_part_catalog_id': part.id, + 'x_fc_thickness_id': thick_a.id, + }) + SOL.create({ + 'order_id': so.id, 'product_id': self.product.id, + 'product_uom_qty': 1.0, 'price_unit': 10.0, + 'x_fc_part_catalog_id': part.id, + 'x_fc_thickness_id': thick_b.id, + }) + so.action_confirm() + + jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)]) + self.assertEqual( + len(jobs), 2, + 'Lines with different thicknesses must spawn separate fp.jobs ' + '(both lines share recipe+part+coating, only thickness differs)', + ) + # Each job's linked SO line should carry its own thickness + thicknesses_on_jobs = set() + for job in jobs: + for line in job.sale_order_line_ids: + if line.x_fc_thickness_id: + thicknesses_on_jobs.add(line.x_fc_thickness_id.id) + self.assertEqual( + thicknesses_on_jobs, {thick_a.id, thick_b.id}, + 'The two distinct thicknesses must each appear on its own job', + ) + class TestJobLifecycleHooks(TransactionCase): def setUp(self):