plan: implementation tasks for recipe cert toggles + aerospace parity

Seven tasks, TDD-style:
  T1 — Partner toggles (3 booleans) + post-migrate backfill
  T2 — Recipe booleans (5 requires_*) + post-migrate backfill
  T3 — Six failing tests in test_recipe_cert_suppression.py
  T4 — Three-step resolver implementation
  T5 — Cert action_issue orphan-attachment gate + render guard
  T6 — UI views (partner separator + cert banner + recipe group)
  T7 — Deploy to entech + smoke runbook

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-27 01:55:02 -04:00
parent e09913af5a
commit e599daf4d9

View File

@@ -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 585619, the existing `_resolve_required_cert_types`)
- Modify: `fusion_plating_jobs/__manifest__.py` (version)
- [ ] **Step 1: Bump module version**
Edit `fusion_plating_jobs/__manifest__.py`:
```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 585619) 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 `<group string="Documents to Send on Shipment">` block (line 18). Inside it, AFTER the existing inner `<group>` at lines 3033 (containing `x_fc_send_packing_slip` / `x_fc_send_bol`), insert:
```xml
<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`:
```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 `<field name="attachment_id" ...>` element. Insert IMMEDIATELY BEFORE it:
```xml
<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:
```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 `</group>` of that block), gated on `node_type == 'recipe'`:
```xml
<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**
```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 17 are all checked. Total commits: 6 (one per implementation task, plus Task 7 has none). Module versions land at:
- `fusion_plating_certificates`: 19.0.12.0.0
- `fusion_plating`: 19.0.22.0.0
- `fusion_plating_jobs`: 19.0.8.1.0
## Self-review notes
- **Spec coverage** — every section of the spec maps to a task:
- Spec §1 (data model) → Tasks 1 + 2
- Spec §2 (resolver) → Task 4
- Spec §3 (UI) → Task 6
- Spec §4 (auto-spawn for orphans) → Task 5
- Spec §5 (migration / edge cases / testing) → Tasks 1, 2, 3 (tests), 7 (smoke)
- **Suppress-only precedence** is enforced by Step 2 of the resolver using `discard` not `add`. Test 3 (`test_recipe_cannot_add_certs_customer_didnt_want`) is the explicit regression guard.
- **Bundling rule** preserved in Step 3 of the resolver. Test 5 verifies thickness is absent from a 5-toggle ON scenario.
- **Field-existence guards** (`'x_fc_send_nadcap_cert' in p._fields`) on the resolver protect against installs where `fusion_plating_certificates` is not loaded — matches the existing defensive pattern in this codebase.