From 28e5e7f9ded488657caf9ce0b0de21700ad4d245 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Wed, 3 Jun 2026 22:43:47 -0400 Subject: [PATCH] feat(fusion_plating_jobs): group WOs by recipe step structure Replace the old 5-tuple (recipe.id, part, spec, thickness, serial) grouping key with a structural signature so multiple parts that share the same recipe step tree (ENP clones) collapse onto one combined work order. Add three helpers: _fp_recipe_signature, _fp_line_express_signature, _fp_line_group_key. Add TransactionCase test covering merge, non-merge, and masking-split cases. Co-Authored-By: Claude Sonnet 4.6 --- .../fusion_plating_jobs/models/sale_order.py | 95 +++++++++++----- .../fusion_plating_jobs/tests/__init__.py | 1 + .../tests/test_wo_recipe_grouping.py | 101 ++++++++++++++++++ 3 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order.py b/fusion_plating/fusion_plating_jobs/models/sale_order.py index 1454738c..67561488 100644 --- a/fusion_plating/fusion_plating_jobs/models/sale_order.py +++ b/fusion_plating/fusion_plating_jobs/models/sale_order.py @@ -395,6 +395,66 @@ class SaleOrder(models.Model): return part.recipe_id return Node + def _fp_recipe_signature(self, recipe): + """Hashable structural signature of a recipe's step tree. + + Two recipes with the same signature have identical processing + steps and can share one work order. Excludes the recipe ROOT + (its name carries the per-part ' — ' suffix) and all + numeric targets — those are per-part attestation data on the + cert, not a batch splitter. Returns None for a missing recipe. + """ + if not recipe: + return None + Node = self.env['fusion.plating.process.node'] + kids = Node.search( + [('id', 'child_of', recipe.id), + ('node_type', 'in', ('sub_process', 'operation', 'step'))], + order='parent_path, sequence') + return tuple( + (k.node_type, + (k.kind_id.code if k.kind_id else '') or '', + (k.name or '').strip().lower()) + for k in kids) + + def _fp_line_express_signature(self, line): + """Per-line Express toggles that change which steps exist: + masking on/off and bake present/absent. Lines differing here + must not merge (the shared WO would silently drop one part's + masking or bake step). Free-text bake instructions are NOT in + the signature — both-present lines merge and the bake step + carries the last applied line's text (known Phase-1 limit). + When the Express fields are absent on a line's module, masking + defaults to True and bake to False, so a non-Express line groups + as masking-on / no-bake. + """ + F = line._fields + masking = bool(line.x_fc_masking_enabled) if 'x_fc_masking_enabled' in F else True + has_bake = bool((line.x_fc_bake_instructions or '').strip()) \ + if 'x_fc_bake_instructions' in F else False + return (masking, has_bake) + + def _fp_line_group_key(self, line, sig_cache=None): + """WO grouping key. Lines with the same key ride one work order. + + `sig_cache` (optional) memoises recipe-id -> signature so a + multi-line SO doesn't re-search the same recipe tree per line. + """ + recipe = self._fp_resolve_recipe_for_line(line) + if not recipe: + return ('no_recipe', line.id) # never merges + if sig_cache is None: + sig = self._fp_recipe_signature(recipe) + else: + if recipe.id not in sig_cache: + sig_cache[recipe.id] = self._fp_recipe_signature(recipe) + sig = sig_cache[recipe.id] + if not sig: + # A recipe with no step nodes has no structure to share — + # don't let empty-tree shells silently merge into one WO. + return ('no_recipe', line.id) + return ('recipe', sig, self._fp_line_express_signature(line)) + def _fp_auto_create_job(self): """Create fp.job(s) from the SO's plating lines. @@ -436,37 +496,14 @@ class SaleOrder(models.Model): _logger.info('SO %s: no plating lines, skipping job creation.', self.name) return - # Group by (recipe, part, spec, thickness, serial). Lines that - # share ALL FIVE collapse into one WO. Bundling lines with - # different specs / thicknesses / serials under one WO would - # carry the first line's values onto the cert + sticker — - # silent mis-attestation. No-recipe lines still get their own - # group each. + # Group by recipe structural signature (+ per-line masking/bake + # toggles). Lines whose recipes have identical steps collapse onto + # one WO; no-recipe lines stay separate. See spec + # 2026-06-03-wo-grouping-by-recipe-combined-cert-design.md. groups = {} - unrecipe_idx = 0 + _sig_cache = {} for line in plating_lines: - recipe = self._fp_resolve_recipe_for_line(line) - part_id = ( - 'x_fc_part_catalog_id' in line._fields - and line.x_fc_part_catalog_id.id - ) or False - spec_id = ( - 'x_fc_customer_spec_id' in line._fields - and line.x_fc_customer_spec_id.id - ) or False - thickness_key = ( - 'x_fc_thickness_range' in line._fields - and (line.x_fc_thickness_range or '').strip() - ) 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, spec_id, thickness_key, serial_id) - else: - unrecipe_idx += 1 - key = ('no_recipe', unrecipe_idx) + key = self._fp_line_group_key(line, sig_cache=_sig_cache) groups[key] = groups.get(key, self.env['sale.order.line']) | line # Order groups by min line sequence so dash-suffixes mirror SO diff --git a/fusion_plating/fusion_plating_jobs/tests/__init__.py b/fusion_plating/fusion_plating_jobs/tests/__init__.py index 404fccef..4c06d108 100644 --- a/fusion_plating/fusion_plating_jobs/tests/__init__.py +++ b/fusion_plating/fusion_plating_jobs/tests/__init__.py @@ -11,3 +11,4 @@ from . import test_post_shop_states from . import test_recipe_cert_suppression from . import test_order_ship_state from . import test_combined_cert_creation +from . import test_wo_recipe_grouping diff --git a/fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py b/fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py new file mode 100644 index 00000000..19360345 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase + + +class TestWoRecipeGrouping(TransactionCase): + def setUp(self): + super().setUp() + self.SO = self.env['sale.order'] + self.Node = self.env['fusion.plating.process.node'] + # kind_id is required on process.node; reuse any seeded kind so + # node creation doesn't depend on the default lookup resolving. + self.kind = self.env['fp.step.kind'].search([], limit=1) + + def _node_vals(self, name, node_type): + v = {'name': name, 'node_type': node_type} + if self.kind: + v['kind_id'] = self.kind.id + return v + + def _recipe(self, name, step_names): + root = self.Node.create(self._node_vals(name, 'recipe')) + seq = 10 + for sn in step_names: + v = self._node_vals(sn, 'step') + v.update({'parent_id': root.id, 'sequence': seq}) + self.Node.create(v) + seq += 10 + return root + + def test_identical_structure_same_signature(self): + r1 = self._recipe('ENP — PART-A', ['Soak Clean', 'Rinse', 'E-Nickel']) + r2 = self._recipe('ENP — PART-B', ['Soak Clean', 'Rinse', 'E-Nickel']) + self.assertEqual( + self.SO._fp_recipe_signature(r1), + self.SO._fp_recipe_signature(r2), + 'clones with identical steps share a signature') + + def test_different_structure_different_signature(self): + r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse', 'E-Nickel']) + r2 = self._recipe('CHROME — B', ['Etch', 'Plate']) + self.assertNotEqual( + self.SO._fp_recipe_signature(r1), + self.SO._fp_recipe_signature(r2)) + + def test_so_groups_same_structure_into_one_wo(self): + partner = self.env['res.partner'].create({'name': 'G'}) + product = self.env['product.product'].create({'name': 'P'}) + pa = self.env['fp.part.catalog'].create({ + 'name': 'A', 'partner_id': partner.id, 'part_number': 'A'}) + pb = self.env['fp.part.catalog'].create({ + 'name': 'B', 'partner_id': partner.id, 'part_number': 'B'}) + pc = self.env['fp.part.catalog'].create({ + 'name': 'C', 'partner_id': partner.id, 'part_number': 'C'}) + r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse']) + r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) # same structure + r3 = self._recipe('CHROME — C', ['Etch', 'Plate']) # different + so = self.env['sale.order'].create({ + 'partner_id': partner.id, + 'order_line': [ + (0, 0, {'product_id': product.id, 'product_uom_qty': 1, + 'x_fc_part_catalog_id': pa.id, + 'x_fc_process_variant_id': r1.id}), + (0, 0, {'product_id': product.id, 'product_uom_qty': 1, + 'x_fc_part_catalog_id': pb.id, + 'x_fc_process_variant_id': r2.id}), + (0, 0, {'product_id': product.id, 'product_uom_qty': 1, + 'x_fc_part_catalog_id': pc.id, + 'x_fc_process_variant_id': r3.id}), + ], + }) + so._fp_auto_create_job() + jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)]) + self.assertEqual(len(jobs), 2, 'A+B merge, C separate') + sizes = sorted(len(j.sale_order_line_ids) for j in jobs) + self.assertEqual(sizes, [1, 2]) + + def test_masking_toggle_splits_same_structure(self): + partner = self.env['res.partner'].create({'name': 'M'}) + product = self.env['product.product'].create({'name': 'P'}) + pa = self.env['fp.part.catalog'].create({ + 'name': 'A', 'partner_id': partner.id, 'part_number': 'A'}) + pb = self.env['fp.part.catalog'].create({ + 'name': 'B', 'partner_id': partner.id, 'part_number': 'B'}) + r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse']) + r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) + so = self.env['sale.order'].create({ + 'partner_id': partner.id, + 'order_line': [ + (0, 0, {'product_id': product.id, 'product_uom_qty': 1, + 'x_fc_part_catalog_id': pa.id, + 'x_fc_process_variant_id': r1.id, + 'x_fc_masking_enabled': True}), + (0, 0, {'product_id': product.id, 'product_uom_qty': 1, + 'x_fc_part_catalog_id': pb.id, + 'x_fc_process_variant_id': r2.id, + 'x_fc_masking_enabled': False}), + ], + }) + so._fp_auto_create_job() + jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)]) + self.assertEqual(len(jobs), 2, 'masking on vs off must not merge')