diff --git a/fusion_plating/docs/superpowers/plans/2026-05-27-recipe-cert-toggles-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-27-recipe-cert-toggles-plan.md new file mode 100644 index 00000000..88daeeb3 --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-27-recipe-cert-toggles-plan.md @@ -0,0 +1,826 @@ +# 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.