Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-06-03-wo-recipe-grouping-combined-cert.md
gsinghpal e35c120af8 docs(fusion_plating): implementation plan - WO grouping by recipe + combined CoC
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>
2026-06-03 22:01:12 -04:00

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_plating prefix if your bind mount differs; K:\Github\Odoo-Modules/mnt/odoo-modules, and the plating modules live under its fusion_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')"
  • Odoo unit tests (TransactionCase, committed as real artifacts): run on an Enterprise env where fusion_plating is installedodoo-trial (VM 316) if present, otherwise a throwaway entech clone (do NOT run --test-enable -u against prod admin). 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 to fusion_plating_jobs here because the backfill reads x_fc_job_id (a jobs-module field) and runs cert helpers — both guaranteed present only after jobs loads (jobs depends on certificates). The fp.certificate.part table 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 existing thickness_reading_ids at 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_certificates build of vals before Cert.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_ids in _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: avoid on 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_job groups 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.619.0.12.2.0)

  • Modify: fusion_plating_certificates/__manifest__.py (19.0.9.3.019.0.10.0.0)

  • Modify: fusion_plating_reports/__manifest__.py (19.0.11.34.019.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.part fields (Task 1) match the part-line build (Task 3), the report (Task 4), and the migration (Task 7); part_line_ids used 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.