Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-27-recipe-cert-toggles-plan.md
gsinghpal e599daf4d9 plan: implementation tasks for recipe cert toggles + aerospace parity
Seven tasks, TDD-style:
  T1 — Partner toggles (3 booleans) + post-migrate backfill
  T2 — Recipe booleans (5 requires_*) + post-migrate backfill
  T3 — Six failing tests in test_recipe_cert_suppression.py
  T4 — Three-step resolver implementation
  T5 — Cert action_issue orphan-attachment gate + render guard
  T6 — UI views (partner separator + cert banner + recipe group)
  T7 — Deploy to entech + smoke runbook

Module version landings:
  fusion_plating_certificates  -> 19.0.12.0.0
  fusion_plating               -> 19.0.22.0.0
  fusion_plating_jobs          -> 19.0.8.1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 01:55:02 -04:00

36 KiB
Raw Blame History

Recipe-Level Cert Suppression + Aerospace Cert-Type Parity — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Spec: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md

Goal: Add recipe-level cert suppression (5 requires_* Booleans on fusion.plating.process.node, default True) + close gaps on 3 orphan cert types (Nadcap / Mill Test / Customer Specific) by adding partner toggles, extending the resolver, and adding a manual-attach workflow for orphan PDFs.

Architecture: Two-layer cert requirement resolution — Step 1 builds the wanted set from partner/part flags (current behaviour, extended for 3 new types); Step 2 strips types the recipe says it can't produce; Step 3 collapses CoC+Thickness into bundled CoC. All flags stored as Booleans on existing models; no new tables; no new cert types. Orphan-type PDFs come from operator upload (supplier doc / regulator cert), not QWeb auto-render.

Tech Stack: Odoo 19, Python 3.11, PostgreSQL, QWeb XML views.

Module version bumps:

  • fusion_plating_certificates: 19.0.11.0.0 → 19.0.12.0.0
  • fusion_plating: 19.0.21.4.0 → 19.0.22.0.0
  • fusion_plating_jobs: 19.0.8.0.0 → 19.0.8.1.0 (logic-only change, minor bump)

Deploy target: entech LXC 111 / pve-worker5, DB admin, native Odoo (not Docker). See CLAUDE.md "Deployment" block for the exact upgrade command.


File Structure

Modified:

  • fusion_plating_certificates/models/res_partner.py — 3 new Booleans
  • fusion_plating_certificates/models/fp_certificate.pyaction_issue orphan-attachment precondition + _fp_render_and_attach_pdf orphan-type early-return
  • fusion_plating_certificates/views/res_partner_views.xml — Aerospace/Defence sub-group with 3 toggles
  • fusion_plating_certificates/views/fp_certificate_views.xml — manual-attach banner
  • fusion_plating_certificates/__manifest__.py — version bump
  • fusion_plating/models/fp_process_node.py — 5 new Booleans (requires_coc, etc.)
  • fusion_plating/views/fp_process_node_views.xml — Certificate Output group
  • fusion_plating/__manifest__.py — version bump
  • fusion_plating_jobs/models/fp_job.py — extend _resolve_required_cert_types
  • fusion_plating_jobs/__manifest__.py — version bump

Created:

  • fusion_plating_certificates/migrations/19.0.12.0.0/post-migrate.py — partner toggle backfill
  • fusion_plating/migrations/19.0.22.0.0/post-migrate.py — recipe Boolean backfill
  • fusion_plating_jobs/tests/test_recipe_cert_suppression.py — 5 new tests

Task 1: Partner toggles — schema + migration

Files:

  • Modify: fusion_plating_certificates/models/res_partner.py (after line 50, near the existing x_fc_strict_thickness_required)

  • Modify: fusion_plating_certificates/__manifest__.py (version + add migration to data list — actually migrations auto-detected, no data entry needed)

  • Create: fusion_plating_certificates/migrations/19.0.12.0.0/post-migrate.py

  • Step 1: Bump module version

Edit fusion_plating_certificates/__manifest__.py:

'version': '19.0.12.0.0',
  • Step 2: Add 3 Boolean fields to res.partner

Open fusion_plating_certificates/models/res_partner.py, find the existing x_fc_strict_thickness_required field (around line 42), and add immediately after it (before x_fc_receives_certs at line 58):

    # Aerospace / Defence cert toggles (2026-05-27 — sub
    # docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md).
    # Default False — opt-in for aerospace/defence customers only.
    # Resolver _resolve_required_cert_types reads these alongside
    # the existing x_fc_send_coc / x_fc_send_thickness_report.
    x_fc_send_nadcap_cert = fields.Boolean(
        string='Send Nadcap Certificate',
        default=False,
        help='Auto-spawn a Nadcap-type fp.certificate when a job for '
             'this customer reaches awaiting_cert. Operator attaches the '
             'supplier/PRI-issued PDF before clicking Issue — there is '
             'no QWeb auto-render for this type.',
    )
    x_fc_send_mill_test = fields.Boolean(
        string='Send Mill Test Report (MTR)',
        default=False,
        help='Auto-spawn a Mill Test Report cert. Operator attaches the '
             'steel supplier\'s MTR PDF before issuing.',
    )
    x_fc_send_customer_specific = fields.Boolean(
        string='Send Customer-Specific Cert',
        default=False,
        help='Auto-spawn a customer-specific cert. Operator fills the '
             'customer-supplied template PDF and attaches before issuing.',
    )
  • Step 3: Create the partner backfill migration

Create fusion_plating_certificates/migrations/19.0.12.0.0/post-migrate.py with this exact content:

# -*- coding: utf-8 -*-
"""Post-migrate for 19.0.12.0.0 — Aerospace/Defence cert partner toggles.

Backfills NULL → FALSE on the three new Boolean columns. Idempotent;
safe to re-run. Default False = opt-in (only aerospace/defence
customers will see these flipped on).
"""
import logging
_logger = logging.getLogger(__name__)


def migrate(cr, version):
    if not version:
        return
    _logger.info(
        '19.0.12.0.0 post-migrate: backfilling Aerospace/Defence '
        'cert partner toggles to FALSE'
    )
    cr.execute("""
        UPDATE res_partner
           SET x_fc_send_nadcap_cert = FALSE
         WHERE x_fc_send_nadcap_cert IS NULL
    """)
    cr.execute("""
        UPDATE res_partner
           SET x_fc_send_mill_test = FALSE
         WHERE x_fc_send_mill_test IS NULL
    """)
    cr.execute("""
        UPDATE res_partner
           SET x_fc_send_customer_specific = FALSE
         WHERE x_fc_send_customer_specific IS NULL
    """)
  • Step 4: Commit
git add fusion_plating_certificates/models/res_partner.py \
        fusion_plating_certificates/migrations/19.0.12.0.0/post-migrate.py \
        fusion_plating_certificates/__manifest__.py
git commit -m "feat(certificates): partner toggles for Nadcap / MTR / Customer-Specific

Adds three Boolean fields (x_fc_send_nadcap_cert, x_fc_send_mill_test,
x_fc_send_customer_specific) to res.partner, default False. Wires
aerospace/defence customers into the existing cert resolver so the
three orphan fp.certificate.certificate_type values become reachable.

Post-migrate idempotently backfills NULL → FALSE on existing rows."

Task 2: Recipe Booleans — schema + migration

Files:

  • Modify: fusion_plating/models/fp_process_node.py (insert after existing requires_transition_form at line 501)

  • Modify: fusion_plating/__manifest__.py (version)

  • Create: fusion_plating/migrations/19.0.22.0.0/post-migrate.py

  • Step 1: Bump module version

Edit fusion_plating/__manifest__.py:

'version': '19.0.22.0.0',
  • Step 2: Add 5 Boolean fields to fusion.plating.process.node

Open fusion_plating/models/fp_process_node.py, find requires_transition_form at line 501, and add immediately after it (before kind_id at line 511):

    # Certificate Output — recipe-level cert suppression (2026-05-27).
    # Default True for all five so existing recipes keep producing the
    # same cert set they produce today. A recipe author flips OFF only
    # the types the recipe physically never produces (passivation = no
    # thickness; commodity ENP = no nadcap).
    #
    # Precedence (locked decision Q1): recipe SUPPRESSES ONLY. Customer
    # / part flags decide what is requested; recipe can remove from that
    # set but never add. See _resolve_required_cert_types in fp.job.
    requires_coc = fields.Boolean(
        string='Requires CoC',
        default=True,
        help='When False, this recipe never produces a Certificate of '
             'Conformance even if the customer/part requested one.',
    )
    requires_thickness_report = fields.Boolean(
        string='Requires Thickness Report',
        default=True,
        help='When False, this recipe never produces a thickness report. '
             'Use for passivation, chemical conversion, etc. — processes '
             'that physically have no plating thickness to measure.',
    )
    requires_nadcap_cert = fields.Boolean(
        string='Requires Nadcap Certificate',
        default=True,
        help='When False, this recipe never auto-spawns a Nadcap cert. '
             'Use for commodity recipes that the shop does not run '
             'under Nadcap accreditation.',
    )
    requires_mill_test = fields.Boolean(
        string='Requires Mill Test Report',
        default=True,
        help='When False, this recipe never auto-spawns a Mill Test Report '
             'cert.',
    )
    requires_customer_specific = fields.Boolean(
        string='Requires Customer-Specific Cert',
        default=True,
        help='When False, this recipe never auto-spawns a Customer-Specific '
             'cert.',
    )
  • Step 3: Create the recipe backfill migration

Create fusion_plating/migrations/19.0.22.0.0/post-migrate.py:

# -*- coding: utf-8 -*-
"""Post-migrate for 19.0.22.0.0 — Recipe-level cert suppression Booleans.

Backfills NULL → TRUE on the five new requires_* columns on
fusion.plating.process.node. Default TRUE = inherit current behaviour
for every existing recipe (zero migration surprises). Idempotent.
"""
import logging
_logger = logging.getLogger(__name__)


def migrate(cr, version):
    if not version:
        return
    _logger.info(
        '19.0.22.0.0 post-migrate: backfilling recipe cert-suppression '
        'requires_* Booleans to TRUE on existing process nodes'
    )
    for col in (
        'requires_coc',
        'requires_thickness_report',
        'requires_nadcap_cert',
        'requires_mill_test',
        'requires_customer_specific',
    ):
        cr.execute(f"""
            UPDATE fusion_plating_process_node
               SET {col} = TRUE
             WHERE {col} IS NULL
        """)
  • Step 4: Commit
git add fusion_plating/models/fp_process_node.py \
        fusion_plating/migrations/19.0.22.0.0/post-migrate.py \
        fusion_plating/__manifest__.py
git commit -m "feat(plating): recipe-level cert suppression Booleans

Adds five requires_* Booleans on fusion.plating.process.node
(requires_coc, requires_thickness_report, requires_nadcap_cert,
requires_mill_test, requires_customer_specific), default True.

Default True = existing recipes keep producing the same cert set.
Author flips OFF only for recipes that physically never produce that
cert (passivation = no thickness, commodity ENP = no nadcap).

Post-migrate backfills NULL → TRUE on existing nodes."

Task 3: Resolver update — write the failing tests first

Files:

  • Create: fusion_plating_jobs/tests/test_recipe_cert_suppression.py

  • Modify: fusion_plating_jobs/tests/__init__.py (add new test import)

  • Step 1: Create the test file with 5 failing tests

Create fusion_plating_jobs/tests/test_recipe_cert_suppression.py:

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""Recipe-level cert suppression tests (spec 2026-05-27).

Verifies _resolve_required_cert_types respects the five recipe-level
requires_* Booleans (suppress-only precedence) and the orphan-cert
action_issue gate raises on missing attachment.
"""
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError


class TestRecipeCertSuppression(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.partner = cls.env['res.partner'].create({
            'name': 'AeroCustomer',
            'is_company': True,
        })
        cls.product = cls.env['product.product'].create({'name': 'AeroPart'})
        # Recipe = top-level fusion.plating.process.node with node_type='recipe'
        cls.recipe = cls.env['fusion.plating.process.node'].create({
            'name': 'TestRecipe',
            'node_type': 'recipe',
        })

    _part_seq = 0

    def _make_part(self, **kw):
        # Bump per-test counter so multiple parts in the same test don't
        # collide on the (partner_id, part_number) uniqueness constraint.
        type(self)._part_seq += 1
        vals = {
            'name': 'PartA',
            'part_number': 'PN-CERT-%03d' % self._part_seq,
            'partner_id': self.partner.id,
            'certificate_requirement': 'inherit',
        }
        vals.update(kw)
        return self.env['fp.part.catalog'].create(vals)

    def _make_job(self, **kw):
        vals = {
            'partner_id': self.partner.id,
            'product_id': self.product.id,
            'qty': 1.0,
            'recipe_id': self.recipe.id,
        }
        vals.update(kw)
        return self.env['fp.job'].create(vals)

    # ---- Test 1: recipe suppresses thickness ----
    def test_recipe_suppresses_thickness(self):
        """Customer wants thickness, recipe says no thickness → none."""
        self.partner.x_fc_send_coc = True
        self.partner.x_fc_send_thickness_report = True
        self.recipe.requires_thickness_report = False
        part = self._make_part()
        job = self._make_job(part_catalog_id=part.id)
        result = job._resolve_required_cert_types()
        # CoC stays, thickness is suppressed. Bundling rule moot.
        self.assertEqual(result, {'coc'})

    # ---- Test 2: recipe suppresses nadcap on commodity part ----
    def test_recipe_suppresses_nadcap_for_commodity_part(self):
        """Customer wants nadcap, commodity recipe says no nadcap → none."""
        self.partner.x_fc_send_coc = True
        self.partner.x_fc_send_nadcap_cert = True
        self.recipe.requires_nadcap_cert = False
        part = self._make_part()
        job = self._make_job(part_catalog_id=part.id)
        result = job._resolve_required_cert_types()
        self.assertEqual(result, {'coc'})
        self.assertNotIn('nadcap_cert', result)

    # ---- Test 3: recipe cannot ADD what customer didn't want ----
    def test_recipe_cannot_add_certs_customer_didnt_want(self):
        """Partner all OFF, recipe all True → empty (suppress-only)."""
        self.partner.x_fc_send_coc = False
        self.partner.x_fc_send_thickness_report = False
        self.partner.x_fc_send_nadcap_cert = False
        self.partner.x_fc_send_mill_test = False
        self.partner.x_fc_send_customer_specific = False
        # All recipe requires_* default to True
        part = self._make_part()
        job = self._make_job(part_catalog_id=part.id)
        self.assertEqual(job._resolve_required_cert_types(), set())

    # ---- Test 4: recipe can suppress part-level override ----
    def test_part_override_coc_recipe_suppresses(self):
        """Part says coc, recipe says no coc → empty (recipe wins)."""
        self.recipe.requires_coc = False
        part = self._make_part(certificate_requirement='coc')
        job = self._make_job(part_catalog_id=part.id)
        self.assertEqual(job._resolve_required_cert_types(), set())

    # ---- Test 5: all 3 orphan types propagate when customer wants them ----
    def test_all_orphan_types_propagate(self):
        """All 5 partner toggles ON, recipe default → 4-element set
        (thickness collapses into CoC via bundling)."""
        self.partner.x_fc_send_coc = True
        self.partner.x_fc_send_thickness_report = True
        self.partner.x_fc_send_nadcap_cert = True
        self.partner.x_fc_send_mill_test = True
        self.partner.x_fc_send_customer_specific = True
        part = self._make_part()
        job = self._make_job(part_catalog_id=part.id)
        result = job._resolve_required_cert_types()
        # thickness merges into CoC PDF (bundling rule preserved)
        self.assertEqual(
            result,
            {'coc', 'nadcap_cert', 'mill_test', 'customer_specific'},
        )
        self.assertNotIn('thickness_report', result)

    # ---- Test 6: orphan cert blocks Issue without attachment ----
    def test_orphan_cert_issue_blocks_without_attachment(self):
        """Spawn a Nadcap cert with no attachment → action_issue raises."""
        # Give the partner an email so the existing email-on-contact gate
        # doesn't fire first (we want to verify the NEW gate, not an
        # unrelated existing one).
        self.partner.email = 'qa@aerocustomer.test'
        cert = self.env['fp.certificate'].create({
            'name': 'TEST-NADCAP-001',
            'certificate_type': 'nadcap_cert',
            'state': 'draft',
            'partner_id': self.partner.id,
            'contact_partner_id': self.partner.id,
            'spec_reference': 'AMS 2404',
            'process_description': 'TEST PROCESS',
            # certified_by_id defaults to env.user if signer not set,
            # but action_issue's gate at line ~524 requires it set.
            'certified_by_id': self.env.user.id,
        })
        # Bypass the QM-only authority gate (test runs as admin which has
        # all groups but we make the intent explicit).
        with self.assertRaisesRegex(UserError, 'no PDF attached'):
            cert.with_context(
                fp_skip_cert_authority_gate=True
            ).action_issue()
  • Step 2: Register the new test file

Append to fusion_plating_jobs/tests/__init__.py:

from . import test_recipe_cert_suppression
  • Step 3: Run the tests to verify they fail (resolver not yet updated)

Run on entech:

ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -i fusion_plating_jobs --test-tags /fusion_plating_jobs:TestRecipeCertSuppression --stop-after-init --no-http\" 2>&1 | tail -30 && systemctl start odoo'"

Expected: 5 tests FAIL. Either field-doesn't-exist (x_fc_send_nadcap_cert) or assertion mismatch (resolver doesn't read recipe.requires_*).

This is the RED phase of TDD.


Task 4: Resolver update — implement, make tests pass

Files:

  • Modify: fusion_plating_jobs/models/fp_job.py (lines 585619, the existing _resolve_required_cert_types)

  • Modify: fusion_plating_jobs/__manifest__.py (version)

  • Step 1: Bump module version

Edit fusion_plating_jobs/__manifest__.py:

'version': '19.0.8.1.0',
  • Step 2: Rewrite _resolve_required_cert_types

In fusion_plating_jobs/models/fp_job.py, replace the existing method body (lines 585619) with this exact code:

    def _resolve_required_cert_types(self):
        """Set of cert types this job must produce.

        Three-step resolution (spec 2026-05-27):
          Step 1 — Start from partner + part flags (today's logic, now
                   extended to read 3 new orphan-type partner toggles).
          Step 2 — Apply recipe suppression. Recipe-level requires_*
                   Booleans on fusion.plating.process.node can REMOVE
                   cert types from the set but never add them (Q1 locked
                   decision: recipe suppresses only).
          Step 3 — Bundling rule: CoC + thickness_report collapse into
                   one {'coc'} cert with thickness merged as page 2 of
                   the CoC PDF (see _fp_merge_thickness_into_pdf).
        """
        self.ensure_one()
        # Step 1 — partner + part
        req = (
            self.part_catalog_id
            and self.part_catalog_id.certificate_requirement
        ) or 'inherit'
        if req == 'inherit':
            wanted = set()
            p = self.partner_id
            if p:
                if p.x_fc_send_coc:                   wanted.add('coc')
                if p.x_fc_send_thickness_report:      wanted.add('thickness_report')
                # Three new aerospace/defence toggles. Field-existence
                # guards in case fusion_plating_certificates is not
                # installed (defensive — same pattern as elsewhere).
                if 'x_fc_send_nadcap_cert' in p._fields and p.x_fc_send_nadcap_cert:
                    wanted.add('nadcap_cert')
                if 'x_fc_send_mill_test' in p._fields and p.x_fc_send_mill_test:
                    wanted.add('mill_test')
                if 'x_fc_send_customer_specific' in p._fields and p.x_fc_send_customer_specific:
                    wanted.add('customer_specific')
        else:
            wanted = {
                'none':          set(),
                'coc':           {'coc'},
                'coc_thickness': {'coc', 'thickness_report'},
            }.get(req, {'coc'})

        # Step 2 — Recipe suppression. Suppress-only: recipe can remove
        # types the customer/part wanted, never add new ones.
        recipe = self.recipe_id
        if recipe:
            if 'requires_coc' in recipe._fields and not recipe.requires_coc:
                wanted.discard('coc')
            if 'requires_thickness_report' in recipe._fields and not recipe.requires_thickness_report:
                wanted.discard('thickness_report')
            if 'requires_nadcap_cert' in recipe._fields and not recipe.requires_nadcap_cert:
                wanted.discard('nadcap_cert')
            if 'requires_mill_test' in recipe._fields and not recipe.requires_mill_test:
                wanted.discard('mill_test')
            if 'requires_customer_specific' in recipe._fields and not recipe.requires_customer_specific:
                wanted.discard('customer_specific')

        # Step 3 — Bundling: CoC + thickness collapse into {'coc'} with
        # thickness merged as page 2 of the CoC PDF.
        if 'coc' in wanted and 'thickness_report' in wanted:
            wanted.discard('thickness_report')

        return wanted
  • Step 3: Run the tests to verify they pass now
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_certificates,fusion_plating_jobs --test-tags /fusion_plating_jobs:TestRecipeCertSuppression --stop-after-init --no-http\" 2>&1 | tail -30 && systemctl start odoo'"

Expected: 5 tests passed.

If FAIL: read the test output, check whether the partner/recipe fields landed (Task 1 + Task 2 deployed), and re-verify the resolver code matches exactly.

  • Step 4: Commit
git add fusion_plating_jobs/models/fp_job.py \
        fusion_plating_jobs/tests/test_recipe_cert_suppression.py \
        fusion_plating_jobs/tests/__init__.py \
        fusion_plating_jobs/__manifest__.py
git commit -m "feat(jobs): three-step cert resolver with recipe suppression

Rewrites fp.job._resolve_required_cert_types as a three-step pipeline:
  Step 1 — partner + part flags (extended for 3 new orphan types)
  Step 2 — recipe-level requires_* booleans STRIP cert types
  Step 3 — CoC + thickness bundling preserved

Recipe is suppress-only per design: it can remove cert types from the
set but never add them. Customer/part decides what is requested.

Adds 5 test cases in tests/test_recipe_cert_suppression.py covering
recipe suppress, no-add semantics, part-override interaction, and
all-five-types propagation through bundling."

Task 5: Cert orphan-attachment gate + render guard

Files:

  • Modify: fusion_plating_certificates/models/fp_certificate.py (insert in action_issue around line 540, and in _fp_render_and_attach_pdf around line 731)

  • Step 1: Add orphan-attachment precondition to action_issue

In fusion_plating_certificates/models/fp_certificate.py, find action_issue (starts at line 440). Inside the for rec in self loop, AFTER the email-on-contact check (around line 553, after the block that ends 'c': rec.contact_partner_id.name,) and BEFORE the existing thickness gate (around line 554, the comment # Thickness data requirement — unified gate covering both), insert:

            # Orphan cert types (Nadcap / Mill Test / Customer Specific)
            # are manual-attach only — operator uploads supplier doc /
            # regulator-issued cert / filled customer template. Block
            # issuance until an attachment is present. Saves operators
            # from an empty-PDF half-issued state.
            ORPHAN_TYPES = ('nadcap_cert', 'mill_test', 'customer_specific')
            if rec.certificate_type in ORPHAN_TYPES and not rec.attachment_id:
                type_label = dict(
                    rec._fields['certificate_type'].selection
                ).get(rec.certificate_type, rec.certificate_type)
                raise UserError(_(
                    'Cannot issue %(type)s "%(name)s" — no PDF attached.\n\n'
                    'This certificate type expects a PDF you upload from '
                    'disk (supplier doc / regulator-issued cert / filled '
                    'customer template). Upload the PDF to the Attachment '
                    'field on this cert before clicking Issue.'
                ) % {
                    'type': type_label,
                    'name': rec.name or rec.display_name,
                })
  • Step 2: Add early-return guard to _fp_render_and_attach_pdf

In the same file, find _fp_render_and_attach_pdf (starts at line 715). Immediately after self.ensure_one() (line 731), and BEFORE the existing if self.attachment_id: return self.attachment_id (line 732), insert:

        # Orphan cert types are manual-attach only — don't attempt to
        # render a CoC QWeb template for them. action_issue's
        # precondition gate already enforces the attachment requirement.
        if self.certificate_type != 'coc':
            return self.attachment_id or False
  • Step 3: Re-run the Task 3 test suite to verify Test 6 passes
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_certificates --test-tags /fusion_plating_jobs:TestRecipeCertSuppression --stop-after-init --no-http\" 2>&1 | tail -30 && systemctl start odoo'"

Expected: all 6 tests pass — Test 6 (test_orphan_cert_issue_blocks_without_attachment) is now green because both gates landed in Steps 1+2.

  • Step 4: Commit
git add fusion_plating_certificates/models/fp_certificate.py
git commit -m "feat(certificates): orphan-cert attachment gate + render guard

Block fp.certificate.action_issue on Nadcap / Mill Test / Customer
Specific certs when attachment_id is empty. These three cert types
are manual-attach only (supplier doc / regulator cert / customer
template) — operator uploads the PDF before issuing.

_fp_render_and_attach_pdf gets an early-return guard so a future
caller never renders a CoC QWeb template for an orphan cert."

Task 6: UI views — partner + recipe + cert banner

Files:

  • Modify: fusion_plating_certificates/views/res_partner_views.xml (insert child group after line 33 inside fp_document_prefs_group)

  • Modify: fusion_plating_certificates/views/fp_certificate_views.xml (find attachment_id field, insert banner above it)

  • Modify: fusion_plating/views/fp_process_node_views.xml (locate the recipe form, add Certificate Output group)

  • Step 1: Add Aerospace/Defence sub-group to partner form

In fusion_plating_certificates/views/res_partner_views.xml, find the existing <group string="Documents to Send on Shipment"> block (line 18). Inside it, AFTER the existing inner <group> at lines 3033 (containing x_fc_send_packing_slip / x_fc_send_bol), insert:

                        <separator string="Aerospace / Defence" colspan="2"/>
                        <group>
                            <field name="x_fc_send_nadcap_cert"
                                   widget="boolean_toggle"/>
                            <field name="x_fc_send_mill_test"
                                   widget="boolean_toggle"/>
                            <field name="x_fc_send_customer_specific"
                                   widget="boolean_toggle"/>
                        </group>
  • Step 2: Add manual-attach banner to cert form

Locate fusion_plating_certificates/views/fp_certificate_views.xml:

grep -n "attachment_id" /Users/gurpreet/Github/Odoo-Modules/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml | head -3

Open the file. Find the first <field name="attachment_id" ...> element. Insert IMMEDIATELY BEFORE it:

                        <div class="alert alert-warning" role="alert"
                             invisible="certificate_type not in ('nadcap_cert', 'mill_test', 'customer_specific') or attachment_id">
                            <i class="fa fa-info-circle me-2"/>
                            This certificate type expects a PDF you upload from
                            disk (supplier doc / regulator-issued cert / filled
                            customer template). Auto-rendering is not provided.
                        </div>
  • Step 3: Add Certificate Output group to recipe form

Locate the existing recipe form:

grep -n "requires_signoff\|requires_transition_form\|node_type" /Users/gurpreet/Github/Odoo-Modules/fusion_plating/fusion_plating/views/fp_process_node_views.xml | head -10

Find where requires_signoff or requires_transition_form appears in the recipe-level form (likely under a "Step Settings" or "Authoring" group). Add a new group AFTER the existing requires_* group (look for the closing </group> of that block), gated on node_type == 'recipe':

                    <group string="Certificate Output"
                           invisible="node_type != 'recipe'">
                        <div class="alert alert-info" role="alert" colspan="2">
                            <i class="fa fa-info-circle me-2"/>
                            A recipe can only <strong>SUPPRESS</strong> certs the
                            customer requested. Turn a toggle OFF for recipes
                            that physically never produce that cert (e.g.
                            passivation = thickness off; commodity ENP =
                            nadcap off).
                        </div>
                        <field name="requires_coc" widget="boolean_toggle"/>
                        <field name="requires_thickness_report"
                               widget="boolean_toggle"/>
                        <field name="requires_nadcap_cert"
                               widget="boolean_toggle"/>
                        <field name="requires_mill_test"
                               widget="boolean_toggle"/>
                        <field name="requires_customer_specific"
                               widget="boolean_toggle"/>
                    </group>

If the exact location is hard to spot, place the new group immediately INSIDE the form's main <sheet> block (it will render at the bottom; layout polish can be a follow-up).

  • Step 4: Commit
git add fusion_plating_certificates/views/res_partner_views.xml \
        fusion_plating_certificates/views/fp_certificate_views.xml \
        fusion_plating/views/fp_process_node_views.xml
git commit -m "feat(views): partner Aerospace/Defence group + recipe cert output

Partner form gains a separator-grouped 'Aerospace / Defence' block
with the three new send_* toggles. Cert form shows a manual-attach
banner when an orphan-type cert has no attachment. Recipe form gets
a 'Certificate Output' group with the five requires_* toggles +
an info banner explaining the suppress-only precedence."

Task 7: Deploy to entech + smoke test

Files: None modified — this task is deploy + manual verification.

  • Step 1: Tar up the three modules and ship to entech
cd /Users/gurpreet/Github/Odoo-Modules/fusion_plating
tar czf /tmp/fp_cert_toggles.tgz \
  fusion_plating_certificates/ \
  fusion_plating/ \
  fusion_plating_jobs/
cat /tmp/fp_cert_toggles.tgz | base64 | ssh pve-worker5 \
  "pct exec 111 -- bash -c 'base64 -d > /tmp/fp_cert_toggles.tgz && cd /mnt/extra-addons/custom && tar xzf /tmp/fp_cert_toggles.tgz && echo OK extracted'"

Expected: OK extracted.

  • Step 2: Upgrade all three modules in one command
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_certificates,fusion_plating_jobs --stop-after-init --no-http\" 2>&1 | tail -25 && systemctl start odoo'"

Expected: clean shutdown, no ERROR in the tail, ends with Initiating shutdown. Migration log lines for 19.0.12.0.0 and 19.0.22.0.0 should appear.

  • Step 3: Bust the asset cache
ssh pve-worker5 'pct exec 111 -- su - postgres -c "psql -d admin -c \"DELETE FROM ir_attachment WHERE url LIKE '"'"'/web/assets/%'"'"';\""'

Expected: DELETE N where N is between 1 and 8.

  • Step 4: Verify service is back up
ssh pve-worker5 "pct exec 111 -- systemctl is-active odoo" && \
  ssh pve-worker5 "pct exec 111 -- curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8069/web/login"

Expected: active then 200.

  • Step 5: Smoke test 1 — partner toggles visible

In a browser, log into entech (https://enplating.com admin or local). Open any customer's partner form → Plating Documents tab. Confirm under "Documents to Send on Shipment" a new "Aerospace / Defence" separator appears with three toggles: Send Nadcap Certificate, Send Mill Test Report (MTR), Send Customer-Specific Cert. All three should default to OFF.

  • Step 6: Smoke test 2 — recipe toggles visible

Open Plating → Operations → Process Recipes → pick any recipe (e.g. ENP-ALUM-BASIC). On the recipe-level form, confirm a "Certificate Output" group appears with five toggles, all default ON, plus the blue info banner explaining suppress-only precedence.

  • Step 7: Smoke test 3 — recipe suppresses thickness end-to-end

Pick a recipe like "Passivation" (or any recipe — open it).

  1. On the recipe form, flip Requires Thickness Report to OFF. Save.
  2. Open a test customer's partner form. Make sure Send Thickness Report is ON, Send CoC is ON.
  3. Create a new Sale Order for that customer, line uses a part with certificate_requirement='inherit' and the recipe you just edited.
  4. Confirm the SO. Walk the resulting fp.job to state=done (use odoo shell or the standard flow).
  5. Click the job's "Advance Milestone" / Issue Certs button.
  6. Open the spawned certs (smart button or fp.certificate list filtered by x_fc_job_id).
  7. Verify: exactly ONE cert spawned, of type coc. No thickness_report cert.

Document the cert names in the chatter for audit.

  • Step 8: Smoke test 4 — orphan-attachment gate fires
  1. Manually create an fp.certificate with certificate_type='nadcap_cert', no attachment, partner set, draft state. (Easiest via the model's list view → Create.)
  2. Fill the required fields (spec_reference, process_description, certified_by_id, contact_partner_id with email).
  3. Click "Issue".
  4. Expected: A red error dialog reading "Cannot issue Nadcap Certificate '…' — no PDF attached. This certificate type expects a PDF you upload from disk…".
  5. Upload any PDF as the attachment, click Issue again → cert finalizes (state → issued).
  • Step 9: Commit smoke results to chatter / log

No git commit needed for smoke. If anything failed, fix in place and re-deploy (jump back to Step 1).


Done — feature complete

Feature complete when Tasks 17 are all checked. Total commits: 6 (one per implementation task, plus Task 7 has none). Module versions land at:

  • fusion_plating_certificates: 19.0.12.0.0
  • fusion_plating: 19.0.22.0.0
  • fusion_plating_jobs: 19.0.8.1.0

Self-review notes

  • Spec coverage — every section of the spec maps to a task:
    • Spec §1 (data model) → Tasks 1 + 2
    • Spec §2 (resolver) → Task 4
    • Spec §3 (UI) → Task 6
    • Spec §4 (auto-spawn for orphans) → Task 5
    • Spec §5 (migration / edge cases / testing) → Tasks 1, 2, 3 (tests), 7 (smoke)
  • Suppress-only precedence is enforced by Step 2 of the resolver using discard not add. Test 3 (test_recipe_cannot_add_certs_customer_didnt_want) is the explicit regression guard.
  • Bundling rule preserved in Step 3 of the resolver. Test 5 verifies thickness is absent from a 5-toggle ON scenario.
  • Field-existence guards ('x_fc_send_nadcap_cert' in p._fields) on the resolver protect against installs where fusion_plating_certificates is not loaded — matches the existing defensive pattern in this codebase.