Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md
gsinghpal e34892f5c0 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>
2026-06-03 21:52:50 -04:00

20 KiB
Raw Blame History

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:

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_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):
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_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.