diff --git a/fusion_plating/docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md b/fusion_plating/docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md new file mode 100644 index 00000000..fe559280 --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md @@ -0,0 +1,425 @@ +# WO Grouping by Recipe + Combined Multi-Part Certificate + +**Date:** 2026-06-03 +**Module(s):** `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports` +**Author:** Gurpreet (Nexa Systems Inc.) +**Status:** Approved — ready for implementation plan + +## Summary + +Today a confirmed sale order with N plating lines creates N work orders +(`fp.job` / "WO-NNN"), even when every line runs the same plating +process. The shop wants **one work order per recipe** — different parts +that go through the same process should ride one traveller and one +physical batch, splitting into separate WOs **only when the process +actually differs**. + +The blocker is the **Certificate of Conformance**: a `fp.job` carries a +single `part_catalog_id` / `customer_spec_id`, and the CoC PDF renders +exactly one part row. Collapsing four parts onto one WO would certify +only the first and silently ship the other three uncertified — the exact +"silent mis-attestation" the 2026-05-13 sticker spec was built to +prevent. + +This spec resolves that by making the **certificate multi-part**: one +combined CoC per WO that lists every part in a table, each with its own +part #, spec, serial, and quantities. The grouping change and the +multi-part cert ship together because neither is safe alone. + +## Audit findings (live entech, db=admin, read-only, 2026-06-03) + +Pulled the real numbers before designing — they overturned the obvious +"group by `recipe_id`" approach. + +| Order | Lines | WOs today | Distinct recipes | WOs after | +|-------|-------|-----------|------------------|-----------| +| SO-30092 | 2 | 2 | 2 (`ENP ALUM BASIC HP`) | **1** | +| SO-30083 | 4 | 4 | 4 (`ENP-STEEL-MP-BASIC`) | **1** | +| SO-30079 | 4 | 4 | 4 (2 parts × 2 lines) | **1** | +| SO-30071 | 3 | 3 | 3 (`ENP-STEEL-MP-BASIC`) | **1** | + +- 23 confirmed SOs total; 4 are multi-plating-line. 13 plating lines + across those 4 orders collapse from **13 WOs → 4 WOs**. +- **Root cause:** every part gets its own *clone* of a base recipe, + renamed `` (the ` — ` is stamped by + `_clone_subtree` in `fp_part_composer_controller.py`). So each line + resolves to a *distinct* `fusion.plating.process.node` record → + grouping by `recipe_id` merges **nothing**. +- The clones are **byte-identical in structure** — 9 (or 11) descendant + nodes, same `node_type` + `kind_id.code` + name in the same order. + Verified across all 4 orders. So merging is **faithful**: every part + follows the identical steps. +- `process_type_id` is **empty** on all of them → not a usable signal. +- `cloned_from_id` exists as a field but is **empty on all 13** lines → + not usable for existing data without a backfill. +- **13 existing `fp.certificate` rows** → migration size. + +**Conclusion:** the only signals that work on real data are *identical +step structure* and *shared base-name prefix*. We group by **identical +step structure** (truthful, naming-independent, no backfill). + +## Locked decisions (from brainstorming, 2026-06-03) + +| Q | Decision | +|---|----------| +| One WO covers many parts — how do certs work? | **One combined cert** listing every part in a table. | +| How much varies between parts in one order? | **Varies by order** → build the full per-part model (handles uniform and per-part-divergent orders). | +| Is "same recipe" one shared record or per-part copies? | **Audited:** per-part clones, structurally identical. Group by structure, not record id. | +| Grouping signal? | **Identical step structure** (recipe structural signature). | +| Two recipes "the same"? | Same `node_type` + `kind_id.code` + name sequence across descendant steps. Numeric targets (thickness/temp/time) are **excluded** — they're per-part attestation data on the cert, not a batch splitter. | + +## Goals / non-goals + +**Goals** +- One WO per distinct plating process; same-process parts share one WO. +- A single combined CoC per WO listing each part's own identity + spec + + quantities. +- No silent loss of any part's certification when parts share a WO. +- Per-part masking/bake differences split the WO (never silently merge). +- Existing WOs and certs keep working unchanged; the 13 existing certs + render identically after migration. + +**Non-goals** +- Re-grouping already-created WOs (only new confirmations regroup). +- Removing the per-part recipe-cloning mechanism (root-cause fix to the + Part Composer — separate, larger, riskier; out of scope). +- Per-part thickness rendering, per-part box stickers, per-part issue + gate → **Phase 2** (see below). +- Per-physical-box serial tracking (unchanged from prior specs). + +## Architecture + +### Phase 1 — compliance-safe MVP + +#### Change 1 — Grouping by recipe structural signature + +File: `fusion_plating_jobs/models/sale_order.py`, method +`_fp_auto_create_job` (the `groups` block around line 439-470). + +Replace the 5-tuple key `(recipe, part, spec, thickness, serial)` with a +**structural signature** key. New helpers on `sale.order`: + +```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 name + (carries the per-part ' — ' suffix) and all numeric targets + (thickness/temp/time/voltage) — those are per-part attestation + data captured on the cert, not a reason to split the batch. + 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 override flags that change physical processing + (masking on/off, bake setpoint/duration, etc.). Lines that differ + here must NOT merge even when the recipe structure matches, or the + shared WO would silently drop one part's masking/bake. + + The exact field set is enumerated from sale.order.line's Express + Orders fields at implementation time (x_fc_masking_enabled + the + bake override fields); all reads are field-guarded. + """ + F = line._fields + bits = [] + for fname in self._FP_EXPRESS_OVERRIDE_FIELDS: + if fname in F: + bits.append((fname, line[fname])) + return tuple(bits) + +def _fp_line_group_key(self, line): + 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)) +``` + +The grouping loop becomes: + +```python +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 downstream of `groups` is unchanged: `ordered_keys` still +sorts by min line sequence, `n_groups` still drives single-vs-suffixed +WO naming (`WO-` vs `WO--NN`), and the per-group job +create loop already sums qty, carries `sale_order_line_ids`, and copies +SO header fields. + +**Representative recipe:** the WO's `recipe_id` is the first line's +recipe in the group. Because every recipe in the group is structurally +identical, step generation (`fp.job.action_confirm` → +`_generate_steps_from_recipe`) produces the correct steps for all parts. + +**Job singular fields:** `part_catalog_id` / `customer_spec_id` keep +pointing at the first line's values (display + back-compat). The +per-part truth lives in `sale_order_line_ids` and the cert part-lines. + +#### Change 2 — `fp.certificate.part` (new child model) + +File: `fusion_plating_certificates/models/fp_certificate_part.py` (new). + +```python +class FpCertificatePart(models.Model): + _name = 'fp.certificate.part' + _description = 'Certificate Part Line' + _order = 'certificate_id, sequence, id' + + certificate_id = fields.Many2one( + 'fp.certificate', required=True, ondelete='cascade', index=True) + sequence = fields.Integer(default=10) + sale_order_line_id = fields.Many2one('sale.order.line') # traceability + part_catalog_id = fields.Many2one('fp.part.catalog') + part_number = fields.Char() # snapshot + part_name = fields.Char() # snapshot of catalog .name + description = fields.Char() # customer-facing description snapshot + serial = fields.Char() # comma-joined serial names snapshot + customer_spec_id = fields.Many2one('fusion.plating.customer.spec') + spec_reference = fields.Char() # snapshot 'CODE Rev X' + quantity_shipped = fields.Integer() + nc_quantity = fields.Integer() + # Phase 2: thickness_reading_ids (inverse certificate_part_id) +``` + +On `fp.certificate`: + +```python +part_line_ids = fields.One2many( + 'fp.certificate.part', 'certificate_id', string='Parts') +``` + +Views: add an editable `part_line_ids` list to the certificate form +(so the issuer can review/adjust before issuing). ACL rows for +`fp.certificate.part` mirror `fp.certificate`'s groups (operator read + +manager write, matching the existing cert ACL). + +#### Change 3 — `_fp_create_certificates` fills part-lines + +File: `fusion_plating_jobs/models/fp_job.py` (method around line 2716). + +- **Requirement union** — `_resolve_required_cert_types` currently reads + the *first* part's `certificate_requirement`. Walk **all** plating + lines on the job; union each part's wanted set (part-level override + else partner inherit). Recipe suppression + CoC/thickness bundling are + unchanged (uniform — one recipe per WO). +- **Cert create** — still one cert per resulting type. Cert-level fields + (po_number, customer_job_no, process_description = base recipe name, + certified_by_id, contact, entech_wo_number, sale_order_id, x_fc_job_id) + unchanged. **Legacy singular fields** (part_number, spec_reference, + quantity_shipped, nc_quantity) keep being set from the **first** line + for back-compat. +- **Part-lines** — build one `fp.certificate.part` per plating line on + the job (`_fp_cert_source_lines()` = `sale_order_line_ids` filtered to + lines with a part): + +```python +seq = 10 +part_cmds = [] +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 + part_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': sol.fp_customer_description() + if hasattr(sol, 'fp_customer_description') else (sol.name or ''), + 'serial': ', '.join(sol.x_fc_serial_ids.mapped('name')) + if 'x_fc_serial_ids' in sol._fields else '', + '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 +vals['part_line_ids'] = part_cmds +``` + +**Per-part quantities:** `quantity_shipped` defaults to the **line** +qty (naturally per-part). `nc_quantity` defaults to **0** — scrap / +visual rejects are tracked at job level only, not per part, so we do not +auto-split them; the issuer edits per-part NC at issue if needed. The +job-level NC total remains on the cert's legacy `nc_quantity` field. + +**Idempotency:** the existing per-type idempotency guard is unchanged; +re-running `_fp_create_certificates` does not duplicate certs or lines. + +#### Change 4 — CoC report renders the parts table as a loop + +File: `fusion_plating_reports/report/report_coc.xml` (tbody at line +297-321). + +```xml + + + + +
+
+
+ + + +
+ + + + + + + + + + ... existing _fp_resolve_part_identity() / _fp_resolve_customer_facing_description() row ... + + +``` + +Process / PO / Customer-Job columns: PO and Customer Job No. are SO-level +(uniform), kept cert-level. The Process column shows each part's own +customer-facing description + spec_reference (per 2026-05-28 policy). +`page-break-inside: avoid` stays on each `` (per CLAUDE.md) so a part +row never splits across a page. + +#### Change 5 — Traveller lists all parts + +File: `fusion_plating_jobs/report/report_fp_job_traveller.xml`. + +The Item Information block today shows one part (`job.part_catalog_id`). +Loop `job.sale_order_line_ids` (plating lines) so the operator sees every +part in the batch with its qty. The routing/process table is unchanged +(one shared recipe). Field reads stay defensively guarded. + +#### Change 6 — Migration backfill + +File: `fusion_plating_certificates/migrations//post-migrate.py`. + +For each existing `fp.certificate` with no `part_line_ids`, create one +part-line from its current singular fields so old certs render +identically: + +```python +for cert in env['fp.certificate'].search([]): + if cert.part_line_ids: + continue + pid = cert._fp_resolve_part_identity() # (number, name, serials) + env['fp.certificate.part'].create({ + 'certificate_id': cert.id, 'sequence': 10, + 'part_catalog_id': (cert.x_fc_job_id.part_catalog_id.id + if cert.x_fc_job_id and cert.x_fc_job_id.part_catalog_id else False), + 'part_number': cert.part_number or (pid[0] or ''), + 'part_name': pid[1] or '', + 'description': cert._fp_resolve_customer_facing_description() or cert.process_description or '', + '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, + }) +``` + +Idempotent (skips certs that already have part-lines). 13 certs → 13 +single-part certs. + +### Phase 2 — per-part refinement (separate plan) + +- **Per-part thickness:** add `certificate_part_id` to + `fp.thickness.reading`; associate readings + page-2 Fischerscope PDF + merges per part; render a per-part thickness block under each part row; + extend the `action_issue` thickness gate to require data on each part + that needs thickness. +- **Per-part box stickers:** today's consolidated "Multiple Line Items" + sticker gains per-part detail / per-part labels. +- **Cert form polish:** richer part-line editing UX. + +Phase 2 is deferred and gets its own spec + plan once Phase 1 is live and +validated on entech. + +## Files touched (Phase 1) + +| # | File | Change | +|---|------|--------| +| 1 | `fusion_plating_jobs/models/sale_order.py` | New `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key`; rewrite the `groups` key; define `_FP_EXPRESS_OVERRIDE_FIELDS`. | +| 2 | `fusion_plating_certificates/models/fp_certificate_part.py` | New model. | +| 3 | `fusion_plating_certificates/models/fp_certificate.py` | `part_line_ids` O2M. | +| 4 | `fusion_plating_certificates/models/__init__.py` | import new model. | +| 5 | `fusion_plating_certificates/security/ir.model.access.csv` | ACL for `fp.certificate.part`. | +| 6 | `fusion_plating_certificates/views/fp_certificate_views.xml` | Part-lines list on the cert form. | +| 7 | `fusion_plating_jobs/models/fp_job.py` | `_resolve_required_cert_types` union over all parts; `_fp_cert_source_lines`; `_fp_format_spec_ref`; part-line build in `_fp_create_certificates`. | +| 8 | `fusion_plating_reports/report/report_coc.xml` | tbody loop over `part_line_ids` + legacy fallback row. | +| 9 | `fusion_plating_jobs/report/report_fp_job_traveller.xml` | Item Information loops all parts. | +| 10 | `fusion_plating_certificates/migrations//post-migrate.py` | Backfill one part-line per existing cert. | +| 11 | `__manifest__.py` × (jobs, certificates, reports) | Version bumps. | + +## Migration + +- New `fp.certificate.part` table created on install/upgrade. +- Post-migrate backfills the 13 existing certs (idempotent). +- Existing jobs/WOs untouched — `_fp_auto_create_job`'s `if existing: + return` guard means only **new** confirmations regroup. +- No re-grouping tool for open orders in Phase 1 (out of scope; can be a + one-off odoo-shell script later if the shop wants it). + +## Testing + +These modules require Enterprise deps and **cannot install on the local +Community box** (`fusion_plating` shows `installed=0` on `modsdev`), so: + +- **Static checks (local):** `pyflakes` on every changed `.py`; lxml + parse on changed XML; `node --check` not needed (no JS). +- **Unit (where installable):** the grouping helpers are pure functions + of a recipe/line — `_fp_recipe_signature` returns equal tuples for two + structurally-identical recipes and unequal for divergent ones; + `_fp_line_group_key` merges same-structure lines and splits on + differing express overrides. +- **Live verification (entech via odoo shell, read-only first):** + 1. Re-run the audit signature on SO-30083/30079/30071/30092 → + confirm each collapses to 1 group. + 2. On a **clone** (or a fresh test SO), confirm SO with 4 same-process + lines → 1 WO carrying 4 `sale_order_line_ids`; SO with 2 different + processes → 2 WOs. + 3. Confirm `_fp_create_certificates` produces one CoC with 4 + part-lines; render the CoC PDF → 4 part rows, correct per-part + part#/serial/spec/qty. + 4. Render an existing (migrated) single-part cert → identical to + before. + 5. A line with masking ON + a line with masking OFF, same recipe → + **2** WOs (express-signature split). + +## Edge cases & open questions + +| Item | Decision | +|------|----------| +| No-recipe lines | Each its own WO (unchanged). | +| Same recipe structure, different express masking/bake | **Split** (express signature in the key). | +| Repeated same part across lines (SO-30079) | One cert part-line **per line** (not per distinct part) — each carries that line's serial/qty. | +| Part with `certificate_requirement='none'` on a WO whose other part needs a CoC | Combined CoC is produced (union) and **lists all shipped parts** — the cert documents the physical shipment. (Confirmed 2026-06-03.) | +| Per-part NC qty | Default 0 (job-level scrap not split per part); editable at issue. (Confirmed 2026-06-03.) | +| Job `part_catalog_id` when multi-part | First line (display/back-compat). | +| WO naming | `WO-` (1 group) / `WO--NN` (N groups) — unchanged. | +| Existing open multi-line SOs already split into WOs | Left as-is; no auto re-group. | + +**Confirmed during review (2026-06-03):** the union-cert "list all +shipped parts even if one part opted out" behaviour, and the "per-part +NC defaults to 0, editable at issue" behaviour are both approved.