# 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`](../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.py` — `action_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`: ```python '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): ```python # 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: ```python # -*- 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** ```bash 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`: ```python '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): ```python # 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`: ```python # -*- 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** ```bash 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`: ```python # -*- 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`: ```python from . import test_recipe_cert_suppression ``` - [ ] **Step 3: Run the tests to verify they fail (resolver not yet updated)** Run on entech: ```bash 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`: ```python '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: ```python 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** ```bash 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** ```bash 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: ```python # 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: ```python # 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** ```bash 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** ```bash 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 `` block (line 18). Inside it, AFTER the existing inner `` at lines 30–33 (containing `x_fc_send_packing_slip` / `x_fc_send_bol`), insert: ```xml ``` - [ ] **Step 2: Add manual-attach banner to cert form** Locate `fusion_plating_certificates/views/fp_certificate_views.xml`: ```bash 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 `` element. Insert IMMEDIATELY BEFORE it: ```xml ``` - [ ] **Step 3: Add Certificate Output group to recipe form** Locate the existing recipe form: ```bash 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 `` of that block), gated on `node_type == 'recipe'`: ```xml ``` If the exact location is hard to spot, place the new group immediately INSIDE the form's main `` block (it will render at the bottom; layout polish can be a follow-up). - [ ] **Step 4: Commit** ```bash 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** ```bash 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** ```bash 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** ```bash 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** ```bash 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 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.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.