8 bite-sized tasks (cert part-line model -> form -> creation -> CoC report -> traveller -> grouping switch -> migration -> verify). Cert multi-part support lands before the grouping flip so it is never a compliance regression. Tests are committed TransactionCase artifacts run on an Enterprise env (local Community cannot install fusion_plating); plus a read-only entech signature smoke. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
40 KiB
WO Grouping by Recipe + Combined Multi-Part Certificate — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Group sale-order plating lines into one work order (fp.job) per distinct plating process, and make the Certificate of Conformance multi-part so a combined WO certifies every part truthfully.
Architecture: Spec → docs/superpowers/specs/2026-06-03-wo-grouping-by-recipe-combined-cert-design.md. Lines whose resolved recipes share an identical step structure (and identical masking/bake toggles) collapse onto one fp.job. A new fp.certificate.part child model holds one row per SO line; _fp_create_certificates fills it; the CoC report loops it. The cert multi-part support lands before the grouping switch so flipping the grouping is never a compliance regression.
Tech Stack: Odoo 19 (Python ORM, QWeb PDF reports), modules fusion_plating_jobs, fusion_plating_certificates, fusion_plating_reports.
Testing model (read this first — the env is unusual)
These modules cannot install on the local Community box (fusion_plating needs Enterprise deps; installed=0 on modsdev). So:
- Local per-task gate (always runnable):
- Python:
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/<path>.py(Adjust the/mnt/odoo-modules/fusion_platingprefix if your bind mount differs;K:\Github\Odoo-Modules→/mnt/odoo-modules, and the plating modules live under itsfusion_plating/subdir.) - XML:
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/<path>.xml'); print('XML OK')"
- Python:
- Odoo unit tests (TransactionCase, committed as real artifacts): run on an Enterprise env where
fusion_platingis installed —odoo-trial(VM 316) if present, otherwise a throwaway entech clone (do NOT run--test-enable -uagainst prodadmin). Command shape:odoo -d <enterprise_test_db> --test-enable --test-tags /fusion_plating_jobs \ -u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0 - Live read-only smoke (safe on entech prod): re-run the recipe-signature audit (Task 8) to confirm SO-30092/30083/30079/30071 collapse to one group each. Read-only — no writes.
- Write-path smoke (clone / odoo-trial only): create a test SO with same-structure lines, confirm, check one WO + one multi-part cert + render the CoC PDF.
Every "run the test" step below shows the command; if the Enterprise test env is not yet available, write + commit the test and run the suite at the Task 8 verification gate.
File structure
| File | Module | Responsibility |
|---|---|---|
fusion_plating_certificates/models/fp_certificate_part.py |
certificates | NEW — one row per part on a cert. |
fusion_plating_certificates/models/fp_certificate.py |
certificates | ADD part_line_ids O2M. |
fusion_plating_certificates/models/__init__.py |
certificates | import new model. |
fusion_plating_certificates/security/ir.model.access.csv |
certificates | ACL for fp.certificate.part. |
fusion_plating_certificates/views/fp_certificate_views.xml |
certificates | "Parts" notebook page. |
fusion_plating_certificates/__manifest__.py |
certificates | version bump. |
fusion_plating_jobs/models/fp_job.py |
jobs | requirement union + part-line build in _fp_create_certificates. |
fusion_plating_jobs/models/sale_order.py |
jobs | grouping signature + key (the switch). |
fusion_plating_jobs/report/report_fp_job_traveller.xml |
jobs | Item Information loops all parts. |
fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py |
jobs | backfill one part-line per existing cert. |
fusion_plating_jobs/__manifest__.py |
jobs | version bump. |
fusion_plating_jobs/tests/test_wo_recipe_grouping.py |
jobs | NEW — signature + grouping tests. |
fusion_plating_jobs/tests/test_combined_cert_creation.py |
jobs | NEW — multi-part cert creation tests. |
fusion_plating_reports/report/report_coc.xml |
reports | parts-table loop. |
fusion_plating_reports/__manifest__.py |
reports | version bump. |
Migration location note: the spec listed the backfill under
fusion_plating_certificates. It is moved tofusion_plating_jobshere because the backfill readsx_fc_job_id(a jobs-module field) and runs cert helpers — both guaranteed present only after jobs loads (jobs depends on certificates). Thefp.certificate.parttable is created by the certificates upgrade, which Odoo runs first.
Build order: cert model → cert form → cert creation → CoC report → traveller → grouping switch (last) → migration + verify. This way the multi-part cert is ready before any WO ever carries multiple parts.
Task 1: fp.certificate.part model + part_line_ids + ACL
Files:
-
Create:
fusion_plating_certificates/models/fp_certificate_part.py -
Modify:
fusion_plating_certificates/models/fp_certificate.py(add O2M near the existingthickness_reading_idsat line 87) -
Modify:
fusion_plating_certificates/models/__init__.py -
Modify:
fusion_plating_certificates/security/ir.model.access.csv -
Step 1: Create the model
# fusion_plating_certificates/models/fp_certificate_part.py
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# One row per part on a Certificate of Conformance. A work order can
# cover several parts that share the same plating process (see
# fusion_plating_jobs sale_order._fp_line_group_key); the combined CoC
# lists each part with its own identity + spec + quantities.
from odoo import fields, models
class FpCertificatePart(models.Model):
_name = 'fp.certificate.part'
_description = 'Certificate Part Line'
_order = 'certificate_id, sequence, id'
certificate_id = fields.Many2one(
'fp.certificate', string='Certificate',
required=True, ondelete='cascade', index=True)
sequence = fields.Integer(default=10)
sale_order_line_id = fields.Many2one(
'sale.order.line', string='Source SO Line',
help='The order line this part row was built from (traceability).')
part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
part_number = fields.Char(string='Part Number') # snapshot
part_name = fields.Char(string='Part Name') # snapshot
description = fields.Char(string='Description') # customer-facing snapshot
serial = fields.Char(string='Serial Number(s)') # comma-joined snapshot
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec', string='Customer Spec')
spec_reference = fields.Char(string='Spec Reference') # snapshot 'CODE Rev X'
quantity_shipped = fields.Integer(string='Qty Shipped')
nc_quantity = fields.Integer(string='NC Qty')
- Step 2: Register the import
In fusion_plating_certificates/models/__init__.py, add (alphabetical / near the other cert imports):
from . import fp_certificate_part
- Step 3: Add the O2M on
fp.certificate
In fusion_plating_certificates/models/fp_certificate.py, immediately after the thickness_reading_ids field (line 87-89):
part_line_ids = fields.One2many(
'fp.certificate.part', 'certificate_id', string='Parts',
help='One row per part covered by this certificate. Populated at '
'cert creation from the work order\'s sale-order lines.')
- Step 4: Add ACL rows
Append to fusion_plating_certificates/security/ir.model.access.csv (mirror the existing fp.certificate group grants):
access_fp_certificate_part_operator,fp.certificate.part.operator,model_fp_certificate_part,fusion_plating.group_fp_technician,1,1,0,0
access_fp_certificate_part_supervisor,fp.certificate.part.supervisor,model_fp_certificate_part,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
access_fp_certificate_part_manager,fp.certificate.part.manager,model_fp_certificate_part,fusion_plating.group_fp_manager,1,1,1,1
- Step 5: Static checks
Run:
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py /mnt/odoo-modules/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
Expected: no output (clean).
- Step 6: Commit
git add fusion_plating/fusion_plating_certificates/models/fp_certificate_part.py \
fusion_plating/fusion_plating_certificates/models/fp_certificate.py \
fusion_plating/fusion_plating_certificates/models/__init__.py \
fusion_plating/fusion_plating_certificates/security/ir.model.access.csv
git commit -m "feat(fusion_plating_certificates): add fp.certificate.part child model + ACL"
Task 2: "Parts" page on the certificate form
Files:
-
Modify:
fusion_plating_certificates/views/fp_certificate_views.xml(notebook at line 154) -
Step 1: Add the Parts page as the first notebook page
Insert immediately after <notebook> (line 154), before the existing <page string="Thickness Readings" ...>:
<page string="Parts" name="parts">
<field name="part_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="part_number"/>
<field name="part_name"/>
<field name="description"/>
<field name="serial"/>
<field name="customer_spec_id"/>
<field name="spec_reference"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
</list>
</field>
</page>
- Step 2: Static check (XML parse)
Run:
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml'); print('XML OK')"
Expected: XML OK.
- Step 3: Commit
git add fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
git commit -m "feat(fusion_plating_certificates): Parts page on certificate form"
Task 3: _fp_create_certificates fills part-lines + requirement union
Files:
-
Modify:
fusion_plating_jobs/models/fp_job.py(_resolve_required_cert_types~line 611;_fp_create_certificatesbuild ofvalsbeforeCert.create(vals)at line 2784) -
Test:
fusion_plating_jobs/tests/test_combined_cert_creation.py -
Step 1: Write the failing test
# fusion_plating_jobs/tests/test_combined_cert_creation.py
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestCombinedCertCreation(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'CertCust',
'x_fc_send_coc': True, # drives the coc requirement
})
self.product = self.env['product.product'].create({'name': 'W'})
self.part_a = self.env['fp.part.catalog'].create({
'name': 'PartA', 'partner_id': self.partner.id, 'part_number': 'A-1'})
self.part_b = self.env['fp.part.catalog'].create({
'name': 'PartB', 'partner_id': self.partner.id, 'part_number': 'B-2'})
self.so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 3,
'x_fc_part_catalog_id': self.part_a.id}),
(0, 0, {'product_id': self.product.id, 'product_uom_qty': 2,
'x_fc_part_catalog_id': self.part_b.id}),
],
})
def test_combined_cert_has_one_line_per_so_line(self):
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5.0,
'sale_order_id': self.so.id,
'part_catalog_id': self.part_a.id,
'sale_order_line_ids': [(6, 0, self.so.order_line.ids)],
})
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([('x_fc_job_id', '=', job.id)])
self.assertEqual(len(cert), 1, 'one combined CoC')
self.assertEqual(len(cert.part_line_ids), 2, 'one part-line per SO line')
self.assertEqual(
set(cert.part_line_ids.mapped('part_number')), {'A-1', 'B-2'})
a = cert.part_line_ids.filtered(lambda p: p.part_number == 'A-1')
self.assertEqual(a.quantity_shipped, 3, 'shipped qty from the line')
- Step 2: Run it (Enterprise test env) — expect FAIL
Run:
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestCombinedCertCreation \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
Expected: FAIL — cert.part_line_ids is empty (creation doesn't fill it yet).
- Step 3: Add helper methods on
fp.job
Add near _fp_create_certificates in fusion_plating_jobs/models/fp_job.py:
def _fp_cert_source_lines(self):
"""Plating SO lines this job covers (one cert part-line each)."""
self.ensure_one()
lines = self.sale_order_line_ids
if not lines and self.sale_order_id:
lines = self.sale_order_id.order_line
return lines.filtered(
lambda l: not l.display_type
and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id))
def _fp_format_spec_ref(self, spec):
"""Format 'CODE Rev X' from a customer spec (or '')."""
if not spec:
return ''
ref = spec.code or ''
if 'revision' in spec._fields and spec.revision:
ref = (f'{ref} Rev {spec.revision}' if ref
else f'Rev {spec.revision}')
return ref
def _fp_build_cert_part_commands(self):
"""O2M create commands for fp.certificate.part — one per line."""
self.ensure_one()
cmds, seq = [], 10
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)
serials = ''
if 'x_fc_serial_ids' in sol._fields and sol.x_fc_serial_ids:
serials = ', '.join(sol.x_fc_serial_ids.mapped('name'))
desc = (sol.fp_customer_description()
if hasattr(sol, 'fp_customer_description')
else (sol.name or ''))
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': desc or '',
'serial': serials,
'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
return cmds
- Step 4: Fill
part_line_idsin_fp_create_certificates
In _fp_create_certificates, immediately before cert = Cert.create(vals) (line 2784), add:
if 'part_line_ids' in Cert._fields:
part_cmds = self._fp_build_cert_part_commands()
if part_cmds:
vals['part_line_ids'] = part_cmds
- Step 5: Requirement union over all parts
In _resolve_required_cert_types (Step 1, ~line 611-642), replace the single-part read with a union across all parts on the job. Change the Step-1 block so wanted is the union of each line's part-level requirement (falling back to the partner inherit set computed once):
# ---- Step 1 — partner + part baseline (union across all parts) ----
def _partner_inherit_set():
s = set()
p = self.partner_id
if p:
if p.x_fc_send_coc:
s.add('coc')
if p.x_fc_send_thickness_report:
s.add('thickness_report')
if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert:
s.add('nadcap_cert')
if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test:
s.add('mill_test')
if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific:
s.add('customer_specific')
return s
def _explicit_set(req):
return {
'none': set(), 'coc': {'coc'},
'coc_thickness': {'coc', 'thickness_report'},
}.get(req, {'coc'})
parts = self._fp_cert_source_lines().mapped('x_fc_part_catalog_id')
if not parts and self.part_catalog_id:
parts = self.part_catalog_id
wanted = set()
inherit = None
for part in (parts or [False]):
req = (part.certificate_requirement
if part and 'certificate_requirement' in part._fields
else 'inherit') or 'inherit'
if req == 'inherit':
if inherit is None:
inherit = _partner_inherit_set()
wanted |= inherit
else:
wanted |= _explicit_set(req)
Leave Step 2 (recipe suppression) and Step 3 (CoC/thickness bundling) unchanged — they already operate on wanted.
- Step 6: Run the test — expect PASS
Run:
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestCombinedCertCreation \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
Expected: PASS.
- Step 7: Static check
Run:
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/fp_job.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py
Expected: clean.
- Step 8: Commit
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
fusion_plating/fusion_plating_jobs/tests/test_combined_cert_creation.py
git commit -m "feat(fusion_plating_jobs): multi-part cert creation + requirement union"
Task 4: CoC report renders the parts table as a loop
Files:
-
Modify:
fusion_plating_reports/report/report_coc.xml(tbody at lines 297-321) -
Step 1: Replace the single hard-coded row with a loop + fallback
Replace the <tbody>...</tbody> block (lines 297-322) with:
<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>
<tr t-if="not doc.part_line_ids">
<td class="text-center" style="line-height: 1.3;">
<t t-set="pid" t-value="doc._fp_resolve_part_identity()"/>
<div><t t-esc="pid[0] or '-'"/></div>
<div><t t-esc="pid[1] or '-'"/></div>
<div><t t-esc="pid[2] or '-'"/></div>
</td>
<td>
<t t-set="cust_desc" t-value="doc._fp_resolve_customer_facing_description()"/>
<t t-esc="cust_desc or doc.process_description or ''"/>
<t t-if="doc.spec_reference">
<br/><em t-esc="doc.spec_reference"/>
</t>
</td>
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<td class="text-center"><t t-esc="doc.quantity_shipped or 0"/></td>
<td class="text-center"><t t-esc="doc.nc_quantity or 0"/></td>
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
</tr>
</tbody>
Keep
page-break-inside: avoidon the parent table (line 271-272) unchanged. Each part row is short; the table-level rule already prevents mid-row splits for the typical 1-4 part case.
- Step 2: Static check (XML parse)
Run:
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_reports/report/report_coc.xml'); print('XML OK')"
Expected: XML OK.
- Step 3: Commit
git add fusion_plating/fusion_plating_reports/report/report_coc.xml
git commit -m "feat(fusion_plating_reports): CoC parts table loops part_line_ids"
Task 5: Traveller lists every part in the batch
Files:
-
Modify:
fusion_plating_jobs/report/report_fp_job_traveller.xml(Item Information block, ~lines 116-160) -
Step 1: Loop the plating lines in the Item Information cell
The Item Information <td> currently renders job.part_catalog_id once (singular). Wrap the per-part rows in a loop over the job's plating lines, falling back to the singular part when no lines are linked. Replace the singular part-number / revision / material / name reads (lines ~127-157) with:
<t t-set="trav_lines"
t-value="job.sale_order_line_ids.filtered(lambda l: not l.display_type and ('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)) if 'sale_order_line_ids' in job._fields else job.browse([])"/>
<t t-if="not trav_lines and 'part_catalog_id' in job._fields and job.part_catalog_id">
<t t-set="trav_parts" t-value="[job.part_catalog_id]"/>
</t>
<t t-else="">
<t t-set="trav_parts" t-value="trav_lines.mapped('x_fc_part_catalog_id')"/>
</t>
<t t-foreach="trav_parts" t-as="tp">
<div style="margin-bottom: 2px;">
<strong t-esc="tp.part_number or '—'"/>
<t t-if="'revision' in tp._fields and tp.revision">
<span> Rev <t t-esc="tp.revision"/></span>
</t>
<t t-if="'base_material' in tp._fields and tp.base_material">
<span> · <t t-esc="tp.base_material"/></span>
</t>
<span> · <t t-esc="tp.name or '—'"/></span>
</div>
</t>
This preserves the existing field reads (
part_number,revision,base_material,name) but emits one line per part. The routing/process table below (one shared recipe) is unchanged. Verify the surrounding<td>/column structure still balances after the edit — keep the edit inside the existing Item Information cell.
- Step 2: Static check (XML parse)
Run:
docker exec odoo-modsdev-app python3 -c "import lxml.etree as e; e.parse('/mnt/odoo-modules/fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml'); print('XML OK')"
Expected: XML OK.
- Step 3: Commit
git add fusion_plating/fusion_plating_jobs/report/report_fp_job_traveller.xml
git commit -m "feat(fusion_plating_jobs): traveller lists all parts in the batch"
Task 6: Grouping by recipe structural signature (the switch)
Files:
-
Modify:
fusion_plating_jobs/models/sale_order.py(_fp_auto_create_jobgroups block, lines 439-470) -
Test:
fusion_plating_jobs/tests/test_wo_recipe_grouping.py -
Step 1: Write the failing tests
# fusion_plating_jobs/tests/test_wo_recipe_grouping.py
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestWoRecipeGrouping(TransactionCase):
def setUp(self):
super().setUp()
self.SO = self.env['sale.order']
self.Node = self.env['fusion.plating.process.node']
def _recipe(self, name, step_names):
root = self.Node.create({'name': name, 'node_type': 'recipe'})
seq = 10
for sn in step_names:
self.Node.create({
'name': sn, 'node_type': 'step',
'parent_id': root.id, 'sequence': seq})
seq += 10
return root
def test_identical_structure_same_signature(self):
r1 = self._recipe('ENP — PART-A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('ENP — PART-B', ['Soak Clean', 'Rinse', 'E-Nickel'])
self.assertEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2),
'clones with identical steps share a signature')
def test_different_structure_different_signature(self):
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse', 'E-Nickel'])
r2 = self._recipe('CHROME — B', ['Etch', 'Plate'])
self.assertNotEqual(
self.SO._fp_recipe_signature(r1),
self.SO._fp_recipe_signature(r2))
def test_so_groups_same_structure_into_one_wo(self):
partner = self.env['res.partner'].create({'name': 'G'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
pc = self.env['fp.part.catalog'].create({
'name': 'C', 'partner_id': partner.id, 'part_number': 'C'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse']) # same structure
r3 = self._recipe('CHROME — C', ['Etch', 'Plate']) # different
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pc.id,
'x_fc_process_variant_id': r3.id}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'A+B merge, C separate')
sizes = sorted(len(j.sale_order_line_ids) for j in jobs)
self.assertEqual(sizes, [1, 2])
def test_masking_toggle_splits_same_structure(self):
partner = self.env['res.partner'].create({'name': 'M'})
product = self.env['product.product'].create({'name': 'P'})
pa = self.env['fp.part.catalog'].create({
'name': 'A', 'partner_id': partner.id, 'part_number': 'A'})
pb = self.env['fp.part.catalog'].create({
'name': 'B', 'partner_id': partner.id, 'part_number': 'B'})
r1 = self._recipe('ENP — A', ['Soak Clean', 'Rinse'])
r2 = self._recipe('ENP — B', ['Soak Clean', 'Rinse'])
so = self.env['sale.order'].create({
'partner_id': partner.id,
'order_line': [
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pa.id,
'x_fc_process_variant_id': r1.id,
'x_fc_masking_enabled': True}),
(0, 0, {'product_id': product.id, 'product_uom_qty': 1,
'x_fc_part_catalog_id': pb.id,
'x_fc_process_variant_id': r2.id,
'x_fc_masking_enabled': False}),
],
})
so._fp_auto_create_job()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 2, 'masking on vs off must not merge')
- Step 2: Run them — expect FAIL
Run:
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestWoRecipeGrouping \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
Expected: FAIL — _fp_recipe_signature does not exist yet.
- Step 3: Add the signature helpers on
sale.order
In fusion_plating_jobs/models/sale_order.py, add these methods (near _fp_resolve_recipe_for_line):
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
(its name carries the per-part ' — <part#>' suffix) and all
numeric targets — those are per-part attestation data on the
cert, not a batch splitter. 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 toggles that change which steps exist:
masking on/off and bake present/absent. Lines differing here
must not merge (the shared WO would silently drop one part's
masking or bake step). Free-text bake instructions are NOT in
the signature — both-present lines merge and the bake step
carries the last applied line's text (known Phase-1 limit)."""
F = line._fields
masking = bool(line.x_fc_masking_enabled) if 'x_fc_masking_enabled' in F else True
has_bake = bool((line.x_fc_bake_instructions or '').strip()) \
if 'x_fc_bake_instructions' in F else False
return (masking, has_bake)
def _fp_line_group_key(self, line):
"""WO grouping key. Lines with the same key ride one work order."""
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))
- Step 4: Replace the grouping loop
In _fp_auto_create_job, replace the groups-building block (lines 445-470, the unrecipe_idx/5-tuple-key logic) with:
# Group by recipe structural signature (+ per-line masking/bake
# toggles). Lines whose recipes have identical steps collapse onto
# one WO; no-recipe lines stay separate. See spec
# 2026-06-03-wo-grouping-by-recipe-combined-cert-design.md.
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 after (the ordered_keys = sorted(...) block at line 473 onward) is unchanged — it still derives n_groups, names WOs WO-<parent> / WO-<parent>-NN, and builds one job per group carrying sale_order_line_ids.
- Step 5: Run the tests — expect PASS
Run:
odoo -d <enterprise_test_db> --test-enable \
--test-tags /fusion_plating_jobs:TestWoRecipeGrouping \
-u fusion_plating_jobs --stop-after-init --http-port=0 --gevent-port=0
Expected: PASS (4 tests).
- Step 6: Static check
Run:
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/models/sale_order.py /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py
Expected: clean.
- Step 7: Commit
git add fusion_plating/fusion_plating_jobs/models/sale_order.py \
fusion_plating/fusion_plating_jobs/tests/test_wo_recipe_grouping.py
git commit -m "feat(fusion_plating_jobs): group WOs by recipe step structure"
Task 7: Migration backfill + version bumps
Files:
-
Create:
fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py -
Modify:
fusion_plating_jobs/__manifest__.py(19.0.12.1.6→19.0.12.2.0) -
Modify:
fusion_plating_certificates/__manifest__.py(19.0.9.3.0→19.0.10.0.0) -
Modify:
fusion_plating_reports/__manifest__.py(19.0.11.34.0→19.0.11.35.0) -
Step 1: Write the backfill migration
# fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py
# -*- coding: utf-8 -*-
# Backfill one fp.certificate.part per existing certificate from its
# legacy singular fields, so pre-existing certs render identically under
# the new multi-part CoC. Lives in fusion_plating_jobs (not certificates)
# because it reads x_fc_job_id, a jobs-module field; the part-line table
# itself is created by the certificates upgrade, which runs first.
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
if 'fp.certificate.part' not in env:
return
certs = env['fp.certificate'].search([])
made = 0
for cert in certs:
if cert.part_line_ids:
continue
try:
pid = cert._fp_resolve_part_identity() # (number, name, serials)
except Exception:
pid = ('', '', '')
job = cert.x_fc_job_id if 'x_fc_job_id' in cert._fields else False
part = job.part_catalog_id if (job and 'part_catalog_id' in job._fields) else False
try:
desc = cert._fp_resolve_customer_facing_description() or cert.process_description or ''
except Exception:
desc = cert.process_description or ''
env['fp.certificate.part'].create({
'certificate_id': cert.id, 'sequence': 10,
'part_catalog_id': part.id if part else False,
'part_number': cert.part_number or (pid[0] or ''),
'part_name': pid[1] or '',
'description': desc,
'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,
})
made += 1
_logger.info('fp.certificate.part backfill: created %s part-line(s)', made)
- Step 2: Bump versions
fusion_plating_jobs/__manifest__.py: 'version': '19.0.12.1.6', → 'version': '19.0.12.2.0',
fusion_plating_certificates/__manifest__.py: 'version': '19.0.9.3.0', → 'version': '19.0.10.0.0',
fusion_plating_reports/__manifest__.py: 'version': '19.0.11.34.0', → 'version': '19.0.11.35.0',
- Step 3: Static check
Run:
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/odoo-modules/fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py
Expected: clean.
- Step 4: Commit
git add fusion_plating/fusion_plating_jobs/migrations/19.0.12.2.0/post-migrate.py \
fusion_plating/fusion_plating_jobs/__manifest__.py \
fusion_plating/fusion_plating_certificates/__manifest__.py \
fusion_plating/fusion_plating_reports/__manifest__.py
git commit -m "feat(fusion_plating): cert backfill migration + version bumps"
Task 8: Verification (Enterprise env + read-only entech smoke)
Files: none (verification only).
- Step 1: Full suite on the Enterprise test env
Run:
odoo -d <enterprise_test_db> --test-enable --test-tags /fusion_plating_jobs \
-u fusion_plating_jobs,fusion_plating_certificates,fusion_plating_reports \
--stop-after-init --http-port=0 --gevent-port=0
Expected: exit 0; the new grouping + cert tests pass; no regressions in existing fusion_plating_jobs tests.
- Step 2: Read-only signature re-run on entech (prod-safe)
Confirm the four real orders collapse. In odoo shell -d admin on entech (read-only — no commit):
SO = env['sale.order']
for name in ('SO-30092', 'SO-30083', 'SO-30079', 'SO-30071'):
so = SO.search([('name', '=', name)], limit=1)
if not so:
continue
lines = so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)
keys = {SO._fp_line_group_key(l) for l in lines}
print(name, 'lines=%d' % len(lines), 'groups=%d' % len(keys))
# Expect: each prints groups=1
- Step 3: Write-path smoke (clone / odoo-trial — NOT prod)
On a non-prod Enterprise DB: create an SO with 3 lines (2 sharing a structurally-identical recipe, 1 different) for a partner with x_fc_send_coc=True; confirm it; verify (a) 2 fp.job records, (b) the merged job has 2 sale_order_line_ids, (c) closing the merged job produces one CoC with 2 part_line_ids, (d) the rendered CoC PDF shows 2 part rows, (e) a migrated legacy single-part cert still renders one row.
- Step 4: Mark plan complete
All boxes checked, suite green, entech smoke shows groups=1 for the four orders → ready to deploy (entech upgrade of the three modules, per the standard deploy recipe in CLAUDE.md).
Self-review (completed by plan author)
- Spec coverage: grouping signature (Task 6) ✓; combined cert + per-part lines (Tasks 1-3) ✓; CoC report loop (Task 4) ✓; traveller (Task 5) ✓; migration backfill (Task 7) ✓; requirement union (Task 3) ✓; locked decisions (NC=0 editable, union lists all parts, masking/bake split) encoded in Tasks 3 & 6 ✓. Phase 2 (per-part thickness, per-part stickers) intentionally out of scope.
- Placeholder scan: no TBD/TODO; every code step shows complete code;
<enterprise_test_db>is an explicit env parameter (documented in the Testing model), not a code placeholder. - Type/name consistency:
_fp_recipe_signature/_fp_line_express_signature/_fp_line_group_key(Task 6) match their uses;fp.certificate.partfields (Task 1) match the part-line build (Task 3), the report (Task 4), and the migration (Task 7);part_line_idsused consistently across Tasks 1-4 & 7. - Known limitation (documented in code): two same-structure lines that both have bake instructions but different text merge; the shared bake step carries the last applied line's text. Acceptable for Phase 1.