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:
@@ -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.
|
||||||
Reference in New Issue
Block a user