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:
@@ -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 `<group string="Documents to Send on Shipment">` block (line 18). Inside it, AFTER the existing inner `<group>` at lines 30–33 (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 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.
|
||||
Reference in New Issue
Block a user