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>
20 KiB
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_subtreeinfp_part_composer_controller.py). So each line resolves to a distinctfusion.plating.process.noderecord → grouping byrecipe_idmerges 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_idis empty on all of them → not a usable signal.cloned_from_idexists as a field but is empty on all 13 lines → not usable for existing data without a backfill.- 13 existing
fp.certificaterows → 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:
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:
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).
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:
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_typescurrently reads the first part'scertificate_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.partper plating line on the job (_fp_cert_source_lines()=sale_order_line_idsfiltered to lines with a part):
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).
<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:
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_idtofp.thickness.reading; associate readings + page-2 Fischerscope PDF merges per part; render a per-part thickness block under each part row; extend theaction_issuethickness 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.parttable created on install/upgrade. - Post-migrate backfills the 13 existing certs (idempotent).
- Existing jobs/WOs untouched —
_fp_auto_create_job'sif existing: returnguard 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):
pyflakeson every changed.py; lxml parse on changed XML;node --checknot needed (no JS). - Unit (where installable): the grouping helpers are pure functions
of a recipe/line —
_fp_recipe_signaturereturns equal tuples for two structurally-identical recipes and unequal for divergent ones;_fp_line_group_keymerges same-structure lines and splits on differing express overrides. - Live verification (entech via odoo shell, read-only first):
- Re-run the audit signature on SO-30083/30079/30071/30092 → confirm each collapses to 1 group.
- 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. - Confirm
_fp_create_certificatesproduces one CoC with 4 part-lines; render the CoC PDF → 4 part rows, correct per-part part#/serial/spec/qty. - Render an existing (migrated) single-part cert → identical to before.
- 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.