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>
36 KiB
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.0fusion_plating: 19.0.21.4.0 → 19.0.22.0.0fusion_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 Booleansfusion_plating_certificates/models/fp_certificate.py—action_issueorphan-attachment precondition +_fp_render_and_attach_pdforphan-type early-returnfusion_plating_certificates/views/res_partner_views.xml— Aerospace/Defence sub-group with 3 togglesfusion_plating_certificates/views/fp_certificate_views.xml— manual-attach bannerfusion_plating_certificates/__manifest__.py— version bumpfusion_plating/models/fp_process_node.py— 5 new Booleans (requires_coc, etc.)fusion_plating/views/fp_process_node_views.xml— Certificate Output groupfusion_plating/__manifest__.py— version bumpfusion_plating_jobs/models/fp_job.py— extend_resolve_required_cert_typesfusion_plating_jobs/__manifest__.py— version bump
Created:
fusion_plating_certificates/migrations/19.0.12.0.0/post-migrate.py— partner toggle backfillfusion_plating/migrations/19.0.22.0.0/post-migrate.py— recipe Boolean backfillfusion_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 existingx_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 existingrequires_transition_format 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 585–619, 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 585–619) 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 inaction_issuearound line 540, and in_fp_render_and_attach_pdfaround 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 insidefp_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 30–33 (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).
- On the recipe form, flip
Requires Thickness Reportto OFF. Save. - Open a test customer's partner form. Make sure
Send Thickness Reportis ON,Send CoCis ON. - Create a new Sale Order for that customer, line uses a part with
certificate_requirement='inherit'and the recipe you just edited. - Confirm the SO. Walk the resulting
fp.jobtostate=done(use odoo shell or the standard flow). - Click the job's "Advance Milestone" / Issue Certs button.
- Open the spawned certs (smart button or
fp.certificatelist filtered byx_fc_job_id). - Verify: exactly ONE cert spawned, of type
coc. Nothickness_reportcert.
Document the cert names in the chatter for audit.
- Step 8: Smoke test 4 — orphan-attachment gate fires
- Manually create an
fp.certificatewithcertificate_type='nadcap_cert', no attachment, partner set, draft state. (Easiest via the model's list view → Create.) - Fill the required fields (spec_reference, process_description, certified_by_id, contact_partner_id with email).
- Click "Issue".
- Expected: A red error dialog reading "Cannot issue Nadcap Certificate '…' — no PDF attached. This certificate type expects a PDF you upload from disk…".
- 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 1–7 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.0fusion_plating: 19.0.22.0.0fusion_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
discardnotadd. 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 wherefusion_plating_certificatesis not loaded — matches the existing defensive pattern in this codebase.