# Recipe-Level Cert Suppression + Aerospace Cert-Type Parity — Design Spec **Date:** 2026-05-27 **Status:** Design approved, ready for implementation plan **Modules touched:** `fusion_plating`, `fusion_plating_certificates`, `fusion_plating_jobs` ## Goal Two related changes: 1. Let a recipe author flip OFF cert types the recipe physically can never produce (e.g. passivation = no thickness report, commodity ENP = no Nadcap), so a job with that recipe stops auto-spawning those certs even when the customer profile asks for them. 2. Close gaps on three orphan `fp.certificate.certificate_type` Selection values (Nadcap, Mill Test Report, Customer Specific) — none of them have partner toggles, none auto-spawn today. Wire them end-to-end via the same partner / recipe / resolver / auto-spawn path that CoC and Thickness use. ## Locked decisions | Q | Decision | |---|---| | Q1 — Precedence | Recipe SUPPRESSES only. Customer/part is the ceiling; recipe can remove from the set, never add. | | Q2 — Audit scope | Close gaps for the 3 existing orphan cert types (Nadcap, Mill Test, Customer Specific). No new cert types added. | | Q3 — Recipe field shape | Five `requires_*` Booleans on `fusion.plating.process.node`, default True. Matches the existing `requires_signoff` / `requires_rack_assignment` naming pattern. | | Q4 — Part-level expansion | Leave `fp.part.catalog.certificate_requirement` exactly as-is. Partner + recipe is enough; per-part Nadcap override deferred until a real need shows up. | | Q5 — PDF rendering for orphans | Manual attachment. Auto-spawn creates draft `fp.certificate` row, operator uploads the supplier-/regulator-issued PDF, clicks Issue. No new QWeb templates. | ## Section 1 — Data model changes ### On `res.partner` (`fusion_plating_certificates/models/res_partner.py`) Three new Booleans, default **False** (opt-in for aerospace/defence customers): | Field | Label | Default | |---|---|---| | `x_fc_send_nadcap_cert` | Send Nadcap Certificate | False | | `x_fc_send_mill_test` | Send Mill Test Report (MTR) | False | | `x_fc_send_customer_specific` | Send Customer-Specific Cert | False | Grouped under an "Aerospace / Defence" sub-heading inside the existing "Cert + Document Routing" block so commercial-customer profiles stay scannable. ### On `fusion.plating.process.node` (`fusion_plating/models/fp_process_node.py`) Five new Booleans, default **True**: | Field | Label | Default | |---|---|---| | `requires_coc` | Requires CoC | True | | `requires_thickness_report` | Requires Thickness Report | True | | `requires_nadcap_cert` | Requires Nadcap Certificate | True | | `requires_mill_test` | Requires Mill Test Report | True | | `requires_customer_specific` | Requires Customer-Specific Cert | True | Default True = existing recipes produce the same cert set they produce today. Recipe author flips OFF only when a recipe physically never produces that cert (passivation, commodity ENP, etc.). ### What does NOT change - `fp.part.catalog.certificate_requirement` Selection — unchanged. - `fp.certificate.certificate_type` Selection — unchanged (the 3 orphan values are already there). - No new cert types, no new tables, no new ACL rules. ## Section 2 — Resolver logic update Single update point: [`fp.job._resolve_required_cert_types`](../../fusion_plating_jobs/models/fp_job.py) at line 585. ### New algorithm ```python def _resolve_required_cert_types(self): self.ensure_one() # Step 1 — Start from partner + part (today's logic, extended for 3 new types) 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.x_fc_send_coc: wanted.add('coc') if p.x_fc_send_thickness_report: wanted.add('thickness_report') if p.x_fc_send_nadcap_cert: wanted.add('nadcap_cert') if p.x_fc_send_mill_test: wanted.add('mill_test') if 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 — Apply recipe suppression (recipe can only remove) recipe = self.recipe_id if recipe: if not recipe.requires_coc: wanted.discard('coc') if not recipe.requires_thickness_report: wanted.discard('thickness_report') if not recipe.requires_nadcap_cert: wanted.discard('nadcap_cert') if not recipe.requires_mill_test: wanted.discard('mill_test') if not recipe.requires_customer_specific: wanted.discard('customer_specific') # Step 3 — Bundling rule preserved: thickness merges into CoC PDF if 'coc' in wanted and 'thickness_report' in wanted: wanted.discard('thickness_report') return wanted ``` ### Behavioural invariants - Existing customers see zero behaviour change at deploy time. All 3 new partner Booleans default False; all 5 new recipe Booleans default True; resolver result is bit-identical to today's output for every existing job until an author flips a toggle. - Bundling rule for CoC + Thickness is preserved end to end. The Fischerscope-as-page-2 merge keeps working. - Recipe can suppress part-level overrides too — if `part.certificate_requirement='coc'` and `recipe.requires_coc=False`, result is empty set. Same rule everywhere: recipe wins on suppression. ## Section 3 — UI changes ### Partner form (`fusion_plating_certificates/views/res_partner_views.xml`) Three new toggles in the existing "Cert + Document Routing" group, rendered inside a child `` with `string="Aerospace / Defence"` directly below the existing CoC / Thickness fields. All three use `widget="boolean_toggle"` for consistency with the surrounding fields. ### Recipe form (`fusion_plating/views/fp_process_node_views.xml`) New "Certificate Output" group, visible only when `node_type == 'recipe'`. Five toggles + an info banner explaining the precedence: > ℹ A recipe can only SUPPRESS 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). All five use `widget="boolean_toggle"`. ### Cert form (`fusion_plating_certificates/views/fp_certificate_views.xml`) No structural changes. One UX nudge added: a `
` block immediately above the `attachment_id` field, with `invisible="certificate_type not in ('nadcap_cert', 'mill_test', 'customer_specific') or attachment_id"`. Banner text: *"This certificate type expects a PDF you upload from disk (supplier doc / regulator-issued cert). Auto-rendering is not provided."* No menu / action / smart-button changes. ## Section 4 — Auto-spawn behaviour for orphan types ### What works out of the box `fp.job._fp_create_certificates` iterates the set returned by `_resolve_required_cert_types`. Adding `nadcap_cert` / `mill_test` / `customer_specific` to the set means the existing code path creates one draft `fp.certificate` row per type — no per-type branching needed. Idempotency check (`('certificate_type', '=', t)` filter on existing certs) prevents dupes on re-run. ### Three small adjustments 1. **`_fp_render_and_attach_pdf` guard.** Currently always renders the CoC QWeb template. Add at the top: ```python if self.certificate_type != 'coc': return # orphan types are manual-attach only ``` Makes intent explicit and prevents attempting to render a CoC template for a Nadcap cert. 2. **`action_issue` precondition for orphan types.** Raise `UserError("Attach the supplier's PDF before issuing this certificate.")` when `certificate_type in ('nadcap_cert', 'mill_test', 'customer_specific')` AND `attachment_id` is empty. Better than a silent half-issued state. 3. **Email-on-issue path unchanged.** The existing `Send to Customer` button reads `attachment_id` regardless of cert_type; mail template labels read from the Selection field naturally. ### Manager dashboard The cert-pending tile already filters draft certs by `_resolve_required_cert_types`. The 3 new types appear in the same "Draft Certs" tile alongside CoC certs — operator drills in, sees the yellow banner, uploads PDF, clicks Issue. ## Section 5 — Migration, edge cases, testing ### Migration Two `post-migrate.py` scripts, both idempotent: 1. **`fusion_plating_certificates/migrations//post-migrate.py`** — `UPDATE res_partner SET x_fc_send_nadcap_cert = FALSE WHERE x_fc_send_nadcap_cert IS NULL` (same for the other 2). Existing partners get explicit False values so the resolver branches predictably. 2. **`fusion_plating/migrations//post-migrate.py`** — backfill all 5 new `fusion.plating.process.node` Booleans to TRUE on every existing row. Default True = inherit current behaviour for every existing recipe. No data migration on existing `fp.certificate` rows — the 3 orphan types just become reachable. ### Edge cases | Scenario | Resolver behaviour | |---|---| | Customer wants thickness, passivation recipe (no thickness) | `{coc, thickness}` → recipe strips thickness → `{coc}` | | Customer wants Nadcap, commodity recipe (no nadcap) | `{coc, nadcap}` → strip nadcap → `{coc}` | | Customer wants nothing, recipe requires everything | `{}` → recipe can't add → `{}` (suppress-only rule) | | `part.certificate_requirement='none'` | early-exit → `{}` regardless of partner/recipe | | `part.certificate_requirement='coc'`, recipe says no coc | `{coc}` → recipe strips coc → `{}` | | Job has no recipe assigned | resolver skips Step 2; partner+part rules apply | | Operator forgets to attach PDF on Nadcap cert | Issue raises `UserError("Attach the supplier's PDF…")` | | Job re-runs Issue after attach | idempotent — already-issued certs skipped | ### Manager-bypass Existing `fp_skip_cert_gate=True` context flag keeps working — it gates the milestone advance, not the resolver itself. No new bypass needed. ### Tests Five test cases in `fusion_plating_jobs/tests/test_recipe_cert_suppression.py`: 1. `test_recipe_suppresses_thickness` — partner+thickness ON, `recipe.requires_thickness_report=False` → result excludes thickness. 2. `test_recipe_suppresses_nadcap_for_commodity_part` — partner+nadcap ON, `recipe.requires_nadcap_cert=False` → no nadcap. 3. `test_recipe_cannot_add_certs_customer_didnt_want` — partner all OFF, recipe all True → empty set. 4. `test_part_override_coc_recipe_suppresses` — `part.certificate_requirement='coc'`, `recipe.requires_coc=False` → empty set. 5. `test_orphan_cert_issue_blocks_without_attachment` — spawn Nadcap cert, click Issue with no attachment → `UserError`. ### Module version bumps - `fusion_plating_certificates` — partner toggles + cert.action_issue guard + view changes - `fusion_plating` — recipe Booleans + recipe view changes + migration - `fusion_plating_jobs` — resolver update + render guard + Issue precondition + test file ### Smoke runbook on entech (post-deploy, manual) 1. Flip `x_fc_send_nadcap_cert=True` on one test partner → confirm new toggle visible on form. 2. Open a passivation recipe → flip `requires_thickness_report=False` → save. 3. Create an SO + job for that customer/recipe → walk to job done → confirm spawned cert set excludes thickness even though customer toggle is ON. 4. Manually create a Nadcap cert → confirm yellow banner appears → click Issue without attachment → confirm `UserError` → attach PDF → click Issue → confirm cert finalized + emails customer. ## Out of scope (deferred) - Adding new cert types (FAIR / DFARS / CMRT / RoHS) — call this out as a future sub if a customer asks. - Part-level granularity for the 3 orphan types (Q4 = no). - Auto-rendering QWeb templates for orphan types (Q5 = manual attach). - Configurable cert-type master table (Q2 option C — rejected as scope creep). - Per-customer doc library for static Nadcap PDFs (Q5 option C — deferred).