diff --git a/fusion_plating/docs/superpowers/plans/2026-06-03-wo-recipe-grouping-combined-cert.md b/fusion_plating/docs/superpowers/plans/2026-06-03-wo-recipe-grouping-combined-cert.md new file mode 100644 index 00000000..624cfb0b --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-06-03-wo-recipe-grouping-combined-cert.md @@ -0,0 +1,869 @@ +# 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.