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

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

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

827 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.