docs(fusion_plating): spec - WO grouping by recipe structure + combined multi-part CoC

Group SO lines into one fp.job per distinct plating process (identical
recipe step structure) instead of one WO per line; make the Certificate
of Conformance multi-part via a new fp.certificate.part child model + CoC
parts-table loop + migration backfill.

Grounded in a read-only entech audit: 13 WOs -> 4 on real orders;
per-part recipe clones are structurally identical (same node_type +
kind_code + name sequence). cloned_from_id/process_type_id are empty on
existing data, so grouping keys off the step structure.

Phase 1 (this spec): grouping + combined cert + report + traveller +
migration. Phase 2 (deferred): per-part thickness + per-part stickers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-03 21:52:50 -04:00
parent 774d21863e
commit e34892f5c0

View File

@@ -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 `<BASE> — <part#>` (the ` — <suffix>` 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 ' — <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-<parent>` vs `WO-<parent>-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
<tbody>
<t t-foreach="doc.part_line_ids" t-as="pl">
<tr>
<td class="text-center" style="line-height: 1.3;">
<div><t t-esc="pl.part_number or '-'"/></div>
<div><t t-esc="pl.part_name or '-'"/></div>
<div><t t-esc="pl.serial or '-'"/></div>
</td>
<td>
<t t-esc="pl.description or doc.process_description or ''"/>
<t t-if="pl.spec_reference"><br/><em t-esc="pl.spec_reference"/></t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</t>
<!-- Defensive fallback: legacy cert with no part-lines (should not
occur post-migration) renders the old single row. -->
<tr t-if="not doc.part_line_ids">
... existing _fp_resolve_part_identity() / _fp_resolve_customer_facing_description() row ...
</tr>
</tbody>
```
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 `<tr>` (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/<new-version>/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/<ver>/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-<parent>` (1 group) / `WO-<parent>-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.