# WO Grouping by Recipe + Combined Multi-Part Certificate — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Group sale-order plating lines into one work order (`fp.job`) per distinct plating process, and make the Certificate of Conformance multi-part so a combined WO certifies every part truthfully. **Architecture:** Spec → [docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md](../specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md). Lines whose resolved recipes share an identical *step structure* (and identical masking/bake toggles) collapse onto one `fp.job`. A new `fp.certificate.part` child model holds one row per SO line; `_fp_create_certificates` fills it; the CoC report loops it. The cert multi-part support lands **before** the grouping switch so flipping the grouping is never a compliance regression. **Tech Stack:** Odoo 19 (Python ORM, QWeb PDF reports), modules `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports`. --- ## Testing model (read this first — the env is unusual) These modules **cannot install on the local Community box** (`fusion_plating` needs Enterprise deps; `installed=0` on `modsdev`). So: - **Local per-task gate (always runnable):** - Python: `docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/.py` (Adjust the `/mnt/odoo-modules/fusion_plating` prefix if your bind mount differs; `K:\Github\Odoo-Modules` → `/mnt/odoo-modules`, and the plating modules live under its `fusion_plating/` subdir.) - XML: `docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/.xml'); print('XML OK')"` - **Odoo unit tests** (TransactionCase, committed as real artifacts): run on an **Enterprise env where `fusion_plating` is installed** — `odoo-trial` (VM 316) if present, otherwise a throwaway **entech clone** (do NOT run `--test-enable -u` against prod `admin`). Command shape: ``` odoo -d --test-enable --test-tags /fusion_plating_jobs \ -u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0 ``` - **Live read-only smoke (safe on entech prod):** re-run the recipe-signature audit (Task 8) to confirm SO-30092/30083/30079/30071 collapse to one group each. Read-only — no writes. - **Write-path smoke (clone / odoo-trial only):** create a test SO with same-structure lines, confirm, check one WO + one multi-part cert + render the CoC PDF. Every "run the test" step below shows the command; if the Enterprise test env is not yet available, write + commit the test and run the suite at the Task 8 verification gate. --- ## File structure | File | Module | Responsibility | |------|--------|----------------| | `fusion_plating_certificates/models/fp_certificate_part.py` | certificates | NEW — one row per part on a cert. | | `fusion_plating_certificates/models/fp_certificate.py` | certificates | ADD `part_line_ids` O2M. | | `fusion_plating_certificates/models/__init__.py` | certificates | import new model. | | `fusion_plating_certificates/security/ir.model.access.csv` | certificates | ACL for `fp.certificate.part`. | | `fusion_plating_certificates/views/fp_certificate_views.xml` | certificates | "Parts" notebook page. | | `fusion_plating_certificates/__manifest__.py` | certificates | version bump. | | `fusion_plating_jobs/models/fp_job.py` | jobs | requirement union + part-line build in `_fp_create_certificates`. | | `fusion_plating_jobs/models/sale_order.py` | jobs | grouping signature + key (the switch). | | `fusion_plating_jobs/report/report_fp_job_traveller.xml` | jobs | Item Information loops all parts. | | `fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py` | jobs | backfill one part-line per existing cert. | | `fusion_plating_jobs/__manifest__.py` | jobs | version bump. | | `fusion_plating_jobs/tests/test_wo_recipe_grouping.py` | jobs | NEW — signature + grouping tests. | | `fusion_plating_jobs/tests/test_combined_cert_creation.py` | jobs | NEW — multi-part cert creation tests. | | `fusion_plating_reports/report/report_coc.xml` | reports | parts-table loop. | | `fusion_plating_reports/__manifest__.py` | reports | version bump. | > **Migration location note:** the spec listed the backfill under `fusion_plating_certificates`. It is **moved to `fusion_plating_jobs`** here because the backfill reads `x_fc_job_id` (a jobs-module field) and runs cert helpers — both guaranteed present only after jobs loads (jobs depends on certificates). The `fp.certificate.part` table is created by the certificates upgrade, which Odoo runs first. **Build order:** cert model → cert form → cert creation → CoC report → traveller → **grouping switch (last)** → migration + verify. This way the multi-part cert is ready before any WO ever carries multiple parts. --- ### Task 1: `fp.certificate.part` model + `part_line_ids` + ACL **Files:** - Create: `fusion_plating_certificates/models/fp_certificate_part.py` - Modify: `fusion_plating_certificates/models/fp_certificate.py` (add O2M near the existing `thickness_reading_ids` at line 87) - Modify: `fusion_plating_certificates/models/__init__.py` - Modify: `fusion_plating_certificates/security/ir.model.access.csv` - [ ] **Step 1: Create the model** ```python # fusion_plating_certificates/models/fp_certificate_part.py # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # # One row per part on a Certificate of Conformance. A work order can # cover several parts that share the same plating process (see # fusion_plating_jobs sale_order._fp_line_group_key); the combined CoC # lists each part with its own identity + spec + quantities. from odoo import fields, models class FpCertificatePart(models.Model): _name = 'fp.certificate.part' _description = 'Certificate Part Line' _order = 'certificate_id, sequence, id' certificate_id = fields.Many2one( 'fp.certificate', string='Certificate', required=True, ondelete='cascade', index=True) sequence = fields.Integer(default=10) sale_order_line_id = fields.Many2one( 'sale.order.line', string='Source SO Line', help='The order line this part row was built from (traceability).') part_catalog_id = fields.Many2one('fp.part.catalog', string='Part') part_number = fields.Char(string='Part Number') # snapshot part_name = fields.Char(string='Part Name') # snapshot description = fields.Char(string='Description') # customer-facing snapshot serial = fields.Char(string='Serial Number(s)') # comma-joined snapshot customer_spec_id = fields.Many2one( 'fusion.plating.customer.spec', string='Customer Spec') spec_reference = fields.Char(string='Spec Reference') # snapshot 'CODE Rev X' quantity_shipped = fields.Integer(string='Qty Shipped') nc_quantity = fields.Integer(string='NC Qty') ``` - [ ] **Step 2: Register the import** In `fusion_plating_certificates/models/__init__.py`, add (alphabetical / near the other cert imports): ```python from . import fp_certificate_part ``` - [ ] **Step 3: Add the O2M on `fp.certificate`** In `fusion_plating_certificates/models/fp_certificate.py`, immediately after the `thickness_reading_ids` field (line 87-89): ```python part_line_ids = fields.One2many( 'fp.certificate.part', 'certificate_id', string='Parts', help='One row per part covered by this certificate. Populated at ' 'cert creation from the work order\'s sale-order lines.') ``` - [ ] **Step 4: Add ACL rows** Append to `fusion_plating_certificates/security/ir.model.access.csv` (mirror the existing `fp.certificate` group grants): ```csv access_fp_certificate_part_operator,fp.certificate.part.operator,model_fp_certificate_part,fusion_plating.group_fp_technician,1,1,0,0 access_fp_certificate_part_supervisor,fp.certificate.part.supervisor,model_fp_certificate_part,fusion_plating.group_fp_shop_manager_v2,1,1,1,0 access_fp_certificate_part_manager,fp.certificate.part.manager,model_fp_certificate_part,fusion_plating.group_fp_manager,1,1,1,1 ``` - [ ] **Step 5: Static checks** Run: ``` docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate.py ``` Expected: no output (clean). - [ ] **Step 6: Commit** ```bash git add fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py \ fusion_plating/fusion_plating_certificates/models/fp_certificate.py \ fusion_plating/fusion_plating_certificates/models/__init__.py \ fusion_plating/fusion_plating_certificates/security/ir.model.access.csv git commit -m "feat(fusion_plating_certificates): add fp.certificate.part child model + ACL" ``` --- ### Task 2: "Parts" page on the certificate form **Files:** - Modify: `fusion_plating_certificates/views/fp_certificate_views.xml` (notebook at line 154) - [ ] **Step 1: Add the Parts page as the first notebook page** Insert immediately after `` (line 154), before the existing ``: ```xml ``` - [ ] **Step 2: Static check (XML parse)** Run: ``` docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml'); print('XML OK')" ``` Expected: `XML OK`. - [ ] **Step 3: Commit** ```bash git add fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml git commit -m "feat(fusion_plating_certificates): Parts page on certificate form" ``` --- ### Task 3: `_fp_create_certificates` fills part-lines + requirement union **Files:** - Modify: `fusion_plating_jobs/models/fp_job.py` (`_resolve_required_cert_types` ~line 611; `_fp_create_certificates` build of `vals` before `Cert.create(vals)` at line 2784) - Test: `fusion_plating_jobs/tests/test_combined_cert_creation.py` - [ ] **Step 1: Write the failing test** ```python # fusion_plating_jobs/tests/test_combined_cert_creation.py # -*- coding: utf-8 -*- from odoo.tests.common import TransactionCase class TestCombinedCertCreation(TransactionCase): def setUp(self): super().setUp() self.partner = self.env['res.partner'].create({ 'name': 'CertCust', 'x_fc_send_coc': True, # drives the coc requirement }) self.product = self.env['product.product'].create({'name': 'W'}) self.part_a = self.env['fp.part.catalog'].create({ 'name': 'PartA', 'partner_id': self.partner.id, 'part_number': 'A-1'}) self.part_b = self.env['fp.part.catalog'].create({ 'name': 'PartB', 'partner_id': self.partner.id, 'part_number': 'B-2'}) self.so = self.env['sale.order'].create({ 'partner_id': self.partner.id, 'order_line': [ (0, 0, {'product_id': self.product.id, 'product_uom_qty': 3, 'x_fc_part_catalog_id': self.part_a.id}), (0, 0, {'product_id': self.product.id, 'product_uom_qty': 2, 'x_fc_part_catalog_id': self.part_b.id}), ], }) def test_combined_cert_has_one_line_per_so_line(self): job = self.env['fp.job'].create({ 'partner_id': self.partner.id, 'product_id': self.product.id, 'qty': 5.0, 'sale_order_id': self.so.id, 'part_catalog_id': self.part_a.id, 'sale_order_line_ids': [(6, 0, self.so.order_line.ids)], }) job._fp_create_certificates() cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)]) self.assertEqual(len(cert), 1, 'one combined CoC') self.assertEqual(len(cert.part_line_ids), 2, 'one part-line per SO line') self.assertEqual( set(cert.part_line_ids.mapped('part_number')), {'A-1', 'B-2'}) a = cert.part_line_ids.filtered(lambda p: p.part_number == 'A-1') self.assertEqual(a.quantity_shipped, 3, 'shipped qty from the line') ``` - [ ] **Step 2: Run it (Enterprise test env) — expect FAIL** Run: ``` odoo -d --test-enable \ --test-tags /fusion_plating_jobs:TestCombinedCertCreation \ -u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0 ``` Expected: FAIL — `cert.part_line_ids` is empty (creation doesn't fill it yet). - [ ] **Step 3: Add helper methods on `fp.job`** Add near `_fp_create_certificates` in `fusion_plating_jobs/models/fp_job.py`: ```python def _fp_cert_source_lines(self): """Plating SO lines this job covers (one cert part-line each).""" self.ensure_one() lines = self.sale_order_line_ids if not lines and self.sale_order_id: lines = self.sale_order_id.order_line return lines.filtered( lambda l: not l.display_type and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)) def _fp_format_spec_ref(self, spec): """Format 'CODE Rev X' from a customer spec (or '').""" if not spec: return '' ref = spec.code or '' if 'revision' in spec._fields and spec.revision: ref = (f'{ref} Rev {spec.revision}' if ref else f'Rev {spec.revision}') return ref def _fp_build_cert_part_commands(self): """O2M create commands for fp.certificate.part — one per line.""" self.ensure_one() cmds, seq = [], 10 for sol in self._fp_cert_source_lines(): part = sol.x_fc_part_catalog_id spec = (sol.x_fc_customer_spec_id if 'x_fc_customer_spec_id' in sol._fields else False) serials = '' if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids: serials = ', '.join(sol.x_fc_serial_ids.mapped('name')) desc = (sol.fp_customer_description() if hasattr(sol, 'fp_customer_description') else (sol.name or '')) cmds.append((0, 0, { 'sequence': seq, 'sale_order_line_id': sol.id, 'part_catalog_id': part.id if part else False, 'part_number': (part.part_number if part else '') or '', 'part_name': (part.name if part else '') or '', 'description': desc or '', 'serial': serials, 'customer_spec_id': spec.id if spec else False, 'spec_reference': self._fp_format_spec_ref(spec), 'quantity_shipped': int(sol.product_uom_qty or 0), 'nc_quantity': 0, })) seq += 10 return cmds ``` - [ ] **Step 4: Fill `part_line_ids` in `_fp_create_certificates`** In `_fp_create_certificates`, immediately before `cert = Cert.create(vals)` (line 2784), add: ```python if 'part_line_ids' in Cert._fields: part_cmds = self._fp_build_cert_part_commands() if part_cmds: vals['part_line_ids'] = part_cmds ``` - [ ] **Step 5: Requirement union over all parts** In `_resolve_required_cert_types` (Step 1, ~line 611-642), replace the single-part read with a union across all parts on the job. Change the Step-1 block so `wanted` is the union of each line's part-level requirement (falling back to the partner inherit set computed once): ```python # ---- Step 1 — partner + part baseline (union across all parts) ---- def _partner_inherit_set(): s = set() p = self.partner_id if p: if p.x_fc_send_coc: s.add('coc') if p.x_fc_send_thickness_report: s.add('thickness_report') if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert: s.add('nadcap_cert') if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test: s.add('mill_test') if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific: s.add('customer_specific') return s def _explicit_set(req): return { 'none': set(), 'coc': {'coc'}, 'coc_thickness': {'coc', 'thickness_report'}, }.get(req, {'coc'}) parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id') if not parts and self.part_catalog_id: parts = self.part_catalog_id wanted = set() inherit = None for part in (parts or [False]): req = (part.certificate_requirement if part and 'certificate_requirement' in part._fields else 'inherit') or 'inherit' if req == 'inherit': if inherit is None: inherit = _partner_inherit_set() wanted |= inherit else: wanted |= _explicit_set(req) ``` Leave Step 2 (recipe suppression) and Step 3 (CoC/thickness bundling) unchanged — they already operate on `wanted`. - [ ] **Step 6: Run the test — expect PASS** Run: ``` odoo -d --test-enable \ --test-tags /fusion_plating_jobs:TestCombinedCertCreation \ -u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0 ``` Expected: PASS. - [ ] **Step 7: Static check** Run: ``` docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/fp_job.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py ``` Expected: clean. - [ ] **Step 8: Commit** ```bash git add fusion_plating/fusion_plating_jobs/models/fp_job.py \ fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py git commit -m "feat(fusion_plating_jobs): multi-part cert creation + requirement union" ``` --- ### Task 4: CoC report renders the parts table as a loop **Files:** - Modify: `fusion_plating_reports/report/report_coc.xml` (tbody at lines 297-321) - [ ] **Step 1: Replace the single hard-coded row with a loop + fallback** Replace the `...` block (lines 297-322) with: ```xml


``` > Keep `page-break-inside: avoid` on the parent table (line 271-272) unchanged. Each part row is short; the table-level rule already prevents mid-row splits for the typical 1-4 part case. - [ ] **Step 2: Static check (XML parse)** Run: ``` docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_reports/report/report_coc.xml'); print('XML OK')" ``` Expected: `XML OK`. - [ ] **Step 3: Commit** ```bash git add fusion_plating/fusion_plating_reports/report/report_coc.xml git commit -m "feat(fusion_plating_reports): CoC parts table loops part_line_ids" ``` --- ### Task 5: Traveller lists every part in the batch **Files:** - Modify: `fusion_plating_jobs/report/report_fp_job_traveller.xml` (Item Information block, ~lines 116-160) - [ ] **Step 1: Loop the plating lines in the Item Information cell** The Item Information `` currently renders `job.part_catalog_id` once (singular). Wrap the per-part rows in a loop over the job's plating lines, falling back to the singular part when no lines are linked. Replace the singular part-number / revision / material / name reads (lines ~127-157) with: ```xml
Rev · ·
``` > This preserves the existing field reads (`part_number`, `revision`, `base_material`, `name`) but emits one line per part. The routing/process table below (one shared recipe) is unchanged. Verify the surrounding ``/column structure still balances after the edit — keep the edit inside the existing Item Information cell. - [ ] **Step 2: Static check (XML parse)** Run: ``` docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml'); print('XML OK')" ``` Expected: `XML OK`. - [ ] **Step 3: Commit** ```bash git add fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml git commit -m "feat(fusion_plating_jobs): traveller lists all parts in the batch" ``` --- ### Task 6: Grouping by recipe structural signature (the switch) **Files:** - Modify: `fusion_plating_jobs/models/sale_order.py` (`_fp_auto_create_job` groups block, lines 439-470) - Test: `fusion_plating_jobs/tests/test_wo_recipe_grouping.py` - [ ] **Step 1: Write the failing tests** ```python # fusion_plating_jobs/tests/test_wo_recipe_grouping.py # -*- 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'] def _recipe(self, name, step_names): root = self.Node.create({'name': name, 'node_type': 'recipe'}) seq = 10 for sn in step_names: self.Node.create({ 'name': sn, 'node_type': 'step', 'parent_id': root.id, 'sequence': seq}) 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') ``` - [ ] **Step 2: Run them — expect FAIL** Run: ``` odoo -d --test-enable \ --test-tags /fusion_plating_jobs:TestWoRecipeGrouping \ -u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0 ``` Expected: FAIL — `_fp_recipe_signature` does not exist yet. - [ ] **Step 3: Add the signature helpers on `sale.order`** In `fusion_plating_jobs/models/sale_order.py`, add these methods (near `_fp_resolve_recipe_for_line`): ```python 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).""" 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): """WO grouping key. Lines with the same key ride one work order.""" recipe = self._fp_resolve_recipe_for_line(line) if not recipe: return ('no_recipe', line.id) # never merges return ('recipe', self._fp_recipe_signature(recipe), self._fp_line_express_signature(line)) ``` - [ ] **Step 4: Replace the grouping loop** In `_fp_auto_create_job`, replace the `groups`-building block (lines 445-470, the `unrecipe_idx`/5-tuple-key logic) with: ```python # 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 = {} for line in plating_lines: key = self._fp_line_group_key(line) groups[key] = groups.get(key, self.env['sale.order.line']) | line ``` Everything after (the `ordered_keys = sorted(...)` block at line 473 onward) is unchanged — it still derives `n_groups`, names WOs `WO-` / `WO--NN`, and builds one job per group carrying `sale_order_line_ids`. - [ ] **Step 5: Run the tests — expect PASS** Run: ``` odoo -d --test-enable \ --test-tags /fusion_plating_jobs:TestWoRecipeGrouping \ -u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0 ``` Expected: PASS (4 tests). - [ ] **Step 6: Static check** Run: ``` docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/sale_order.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py ``` Expected: clean. - [ ] **Step 7: Commit** ```bash git add fusion_plating/fusion_plating_jobs/models/sale_order.py \ fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py git commit -m "feat(fusion_plating_jobs): group WOs by recipe step structure" ``` --- ### Task 7: Migration backfill + version bumps **Files:** - Create: `fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py` - Modify: `fusion_plating_jobs/__manifest__.py` (`19.0.12.1.6` → `19.0.12.2.0`) - Modify: `fusion_plating_certificates/__manifest__.py` (`19.0.9.3.0` → `19.0.10.0.0`) - Modify: `fusion_plating_reports/__manifest__.py` (`19.0.11.34.0` → `19.0.11.35.0`) - [ ] **Step 1: Write the backfill migration** ```python # fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py # -*- coding: utf-8 -*- # Backfill one fp.certificate.part per existing certificate from its # legacy singular fields, so pre-existing certs render identically under # the new multi-part CoC. Lives in fusion_plating_jobs (not certificates) # because it reads x_fc_job_id, a jobs-module field; the part-line table # itself is created by the certificates upgrade, which runs first. import logging from odoo import api, SUPERUSER_ID _logger = logging.getLogger(__name__) def migrate(cr, version): env = api.Environment(cr, SUPERUSER_ID, {}) if 'fp.certificate.part' not in env: return certs = env['fp.certificate'].search([]) made = 0 for cert in certs: if cert.part_line_ids: continue try: pid = cert._fp_resolve_part_identity() # (number, name, serials) except Exception: pid = ('', '', '') job = cert.x_fc_job_id if 'x_fc_job_id' in cert._fields else False part = job.part_catalog_id if (job and 'part_catalog_id' in job._fields) else False try: desc = cert._fp_resolve_customer_facing_description() or cert.process_description or '' except Exception: desc = cert.process_description or '' env['fp.certificate.part'].create({ 'certificate_id': cert.id, 'sequence': 10, 'part_catalog_id': part.id if part else False, 'part_number': cert.part_number or (pid[0] or ''), 'part_name': pid[1] or '', 'description': desc, 'serial': pid[2] or '', 'customer_spec_id': cert.customer_spec_id.id if cert.customer_spec_id else False, 'spec_reference': cert.spec_reference or '', 'quantity_shipped': cert.quantity_shipped or 0, 'nc_quantity': cert.nc_quantity or 0, }) made += 1 _logger.info('fp.certificate.part backfill: created %s part-line(s)', made) ``` - [ ] **Step 2: Bump versions** `fusion_plating_jobs/__manifest__.py`: `'version': '19.0.12.1.6',` → `'version': '19.0.12.2.0',` `fusion_plating_certificates/__manifest__.py`: `'version': '19.0.9.3.0',` → `'version': '19.0.10.0.0',` `fusion_plating_reports/__manifest__.py`: `'version': '19.0.11.34.0',` → `'version': '19.0.11.35.0',` - [ ] **Step 3: Static check** Run: ``` docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py ``` Expected: clean. - [ ] **Step 4: Commit** ```bash git add fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py \ fusion_plating/fusion_plating_jobs/__manifest__.py \ fusion_plating/fusion_plating_certificates/__manifest__.py \ fusion_plating/fusion_plating_reports/__manifest__.py git commit -m "feat(fusion_plating): cert backfill migration + version bumps" ``` --- ### Task 8: Verification (Enterprise env + read-only entech smoke) **Files:** none (verification only). - [ ] **Step 1: Full suite on the Enterprise test env** Run: ``` odoo -d --test-enable --test-tags /fusion_plating_jobs \ -u fusion_plating_jobs,fusion_plating_certificates,fusion_plating_reports \ --stop-after-init --http-port=0 --gevent-port=0 ``` Expected: exit 0; the new grouping + cert tests pass; no regressions in existing `fusion_plating_jobs` tests. - [ ] **Step 2: Read-only signature re-run on entech (prod-safe)** Confirm the four real orders collapse. In `odoo shell -d admin` on entech (read-only — no commit): ```python SO = env['sale.order'] for name in ('SO-30092', 'SO-30083', 'SO-30079', 'SO-30071'): so = SO.search([('name', '=', name)], limit=1) if not so: continue lines = so.order_line.filtered(lambda l: l.x_fc_part_catalog_id) keys = {SO._fp_line_group_key(l) for l in lines} print(name, 'lines=%d' % len(lines), 'groups=%d' % len(keys)) # Expect: each prints groups=1 ``` - [ ] **Step 3: Write-path smoke (clone / odoo-trial — NOT prod)** On a non-prod Enterprise DB: create an SO with 3 lines (2 sharing a structurally-identical recipe, 1 different) for a partner with `x_fc_send_coc=True`; confirm it; verify (a) **2** `fp.job` records, (b) the merged job has 2 `sale_order_line_ids`, (c) closing the merged job produces **one** CoC with **2** `part_line_ids`, (d) the rendered CoC PDF shows 2 part rows, (e) a migrated legacy single-part cert still renders one row. - [ ] **Step 4: Mark plan complete** All boxes checked, suite green, entech smoke shows `groups=1` for the four orders → ready to deploy (entech upgrade of the three modules, per the standard deploy recipe in CLAUDE.md). --- ## Self-review (completed by plan author) - **Spec coverage:** grouping signature (Task 6) ✓; combined cert + per-part lines (Tasks 1-3) ✓; CoC report loop (Task 4) ✓; traveller (Task 5) ✓; migration backfill (Task 7) ✓; requirement union (Task 3) ✓; locked decisions (NC=0 editable, union lists all parts, masking/bake split) encoded in Tasks 3 & 6 ✓. Phase 2 (per-part thickness, per-part stickers) intentionally out of scope. - **Placeholder scan:** no TBD/TODO; every code step shows complete code; `` is an explicit env parameter (documented in the Testing model), not a code placeholder. - **Type/name consistency:** `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key` (Task 6) match their uses; `fp.certificate.part` fields (Task 1) match the part-line build (Task 3), the report (Task 4), and the migration (Task 7); `part_line_ids` used consistently across Tasks 1-4 & 7. - **Known limitation (documented in code):** two same-structure lines that both have bake instructions but different text merge; the shared bake step carries the last applied line's text. Acceptable for Phase 1.