feat(jobs): three-step cert resolver with recipe suppression

Rewrites fp.job._resolve_required_cert_types as a documented three-step
pipeline:

  Step 1 — partner + part flags (extended to read 3 new orphan-type
           partner toggles: x_fc_send_nadcap_cert / x_fc_send_mill_test
           / x_fc_send_customer_specific)
  Step 2 — recipe-level requires_* Booleans STRIP cert types from
           the wanted set (suppress-only — never adds)
  Step 3 — CoC + thickness bundling preserved (thickness collapses
           into CoC PDF as page 2)

Field-existence guards on partner/recipe attribute reads keep the
resolver robust if the certificates / plating module schemas drift.

Recipe is suppress-only per Q1 locked decision: customer/part is the
ceiling, recipe can only remove. Test 3 (test_recipe_cannot_add_certs_
customer_didnt_want) is the explicit regression guard.

Sub: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md
Task: T4. Makes the 5 resolver tests from T3 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-27 02:03:18 -04:00
parent ae02164b78
commit fb6cccc8b1
2 changed files with 77 additions and 25 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.11.0.0',
'version': '19.0.11.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -585,38 +585,90 @@ class FpJob(models.Model):
def _resolve_required_cert_types(self):
"""Set of cert types this job must produce.
Priority: part.certificate_requirement wins; 'inherit' falls
back to partner-level send_coc / send_thickness_report flags.
'none' returns empty (commercial customer, no paperwork).
Unknown requirement codes default to {'coc'} as a safety net.
Three-step resolution (spec 2026-05-27 — see
docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md):
Bundling rule (2026-05-18 — Entech workflow): when a CoC is
wanted AND thickness is wanted, the thickness data is delivered
as page 2 of the CoC PDF (see _fp_merge_thickness_into_pdf),
so we return ONE cert ({'coc'}) instead of two. A standalone
thickness_report cert is only produced when thickness is wanted
WITHOUT a CoC — a rare edge case kept for completeness.
Action_issue's thickness-data gate enforces actual readings or
a Fischerscope PDF on the merged CoC.
Step 1 — Start from partner + part flags. The existing logic,
extended to read 3 new orphan-type partner toggles
(Nadcap / Mill Test / Customer Specific).
Step 2 — Apply recipe suppression. Recipe-level requires_*
Booleans on fusion.plating.process.node REMOVE cert
types from the set but never add them. This is the
"passivation = no thickness even if customer asked"
case. Locked decision Q1: recipe suppresses only.
Step 3 — Bundling rule preserved. When CoC AND thickness are
both in the set, thickness collapses into the CoC PDF
as page 2 (see _fp_merge_thickness_into_pdf). The
returned set holds just {'coc'} in that case.
Field-existence guards on partner / recipe attribute reads
defend against installs where fusion_plating_certificates or
fusion_plating's latest schema bump hasn't landed yet —
matches the defensive pattern used elsewhere in this file.
"""
self.ensure_one()
# ---- Step 1 — partner + part baseline ----
req = (
self.part_catalog_id
and self.part_catalog_id.certificate_requirement
) or 'inherit'
if req == 'inherit':
want_coc = bool(self.partner_id.x_fc_send_coc)
want_thickness = bool(self.partner_id.x_fc_send_thickness_report)
if want_coc:
return {'coc'} # thickness gets merged in
if want_thickness:
return {'thickness_report'}
return set()
return {
'none': set(),
'coc': {'coc'},
'coc_thickness': {'coc'}, # bundled — thickness on page 2
}.get(req, {'coc'})
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 aerospace/defence partner toggles. Field guards
# let this module load even if fusion_plating_certificates
# is at an older version that pre-dates the new fields.
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 = 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 — CoC + thickness bundling ----
# Thickness data is merged as page 2 of the CoC PDF by
# _fp_merge_thickness_into_pdf, so we return ONE cert
# instead of two. action_issue's thickness-data gate enforces
# actual readings or a Fischerscope PDF on the merged CoC.
if 'coc' in wanted and 'thickness_report' in wanted:
wanted.discard('thickness_report')
return wanted
next_milestone_action = fields.Selection(
[