fix(certificates): honour recipe thickness suppression at cert issue
The recipe-cert-toggles feature (fb6cccc8) taught
fp.job._resolve_required_cert_types to suppress thickness for recipes
with requires_thickness_report=False (passivation, chemical conversion,
anodize seal-only). But the actual thickness-data ENFORCEMENT never got
the memo: both fp.certificate.action_issue's hard gate AND the
Issue-Certs wizard's readiness hint re-derived 'needs thickness' from
partner flags only and ignored the recipe. Result: a passivation CoC for
a thickness/strict customer could never be issued — the gate demanded
Fischerscope data the process physically cannot produce.
Consolidate the partner-flag + recipe-suppression logic into one
fp.certificate._fp_needs_thickness_data() helper and route both the gate
and the wizard through it, so the cert-type resolver and the issue-time
gate can never drift again. Add regression tests: passivation recipe
suppresses the issue gate even for strict-thickness customers; a normal
recipe still enforces (control, guards aerospace).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.9.3.0',
|
||||
'version': '19.0.9.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -436,6 +436,47 @@ class FpCertificate(models.Model):
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
def _fp_needs_thickness_data(self):
|
||||
"""True when this cert MUST carry thickness data to be issued.
|
||||
|
||||
Single source of truth shared by action_issue (hard gate) and the
|
||||
Issue-Certs wizard (readiness hint) so the two can never drift.
|
||||
|
||||
Partner side — the ceiling: a CoC needs thickness when the customer
|
||||
is strict-thickness (aerospace / Nadcap) or opts into the
|
||||
thickness-on-CoC bundle; a thickness_report cert always needs it.
|
||||
|
||||
Recipe side — suppress-only: a recipe whose requires_thickness_report
|
||||
is False (passivation, chemical conversion, anodize seal-only — no
|
||||
plating thickness physically exists) REMOVES the requirement even
|
||||
when the customer asked. This mirrors Step 2 of
|
||||
fp.job._resolve_required_cert_types so the cert-type resolver and
|
||||
this issue-time gate agree. Without it, a passivation CoC for a
|
||||
thickness customer can never be issued (the gate demands Fischerscope
|
||||
data the process cannot produce). Field-existence guards keep this
|
||||
safe when fusion_plating_jobs / fusion_plating are at an older
|
||||
schema or not installed.
|
||||
"""
|
||||
self.ensure_one()
|
||||
partner = self.partner_id
|
||||
needs = (
|
||||
self.certificate_type == 'thickness_report'
|
||||
or (self.certificate_type == 'coc' and partner and (
|
||||
('x_fc_strict_thickness_required' in partner._fields
|
||||
and partner.x_fc_strict_thickness_required)
|
||||
or ('x_fc_send_thickness_report' in partner._fields
|
||||
and partner.x_fc_send_thickness_report)
|
||||
))
|
||||
)
|
||||
if not needs:
|
||||
return False
|
||||
job = self.x_fc_job_id if 'x_fc_job_id' in self._fields else False
|
||||
recipe = job.recipe_id if job else False
|
||||
if (recipe and 'requires_thickness_report' in recipe._fields
|
||||
and not recipe.requires_thickness_report):
|
||||
return False
|
||||
return True
|
||||
|
||||
# ----- State actions ----------------------------------------------------
|
||||
def action_issue(self):
|
||||
# ===== ACL guard (spec 2026-05-25 §ACL changes) ===============
|
||||
@@ -580,30 +621,18 @@ class FpCertificate(models.Model):
|
||||
'type': type_label,
|
||||
'name': rec.name or rec.display_name,
|
||||
})
|
||||
# Thickness data requirement — unified gate covering both
|
||||
# cert types. A customer needs thickness data on the cert
|
||||
# when ANY of these is true:
|
||||
# 1. cert type is thickness_report (the cert IS the data)
|
||||
# 2. partner.x_fc_strict_thickness_required (aerospace /
|
||||
# Nadcap — always strict)
|
||||
# 3. partner.x_fc_send_thickness_report (the bundling
|
||||
# rule — CoC carries thickness as page 2 by default
|
||||
# for these customers; see CLAUDE.md "CoC + thickness
|
||||
# = ONE cert (page 2 merge)")
|
||||
# Acceptable data: logged readings on the cert OR a
|
||||
# Fischerscope PDF on the linked QC OR a cert-local
|
||||
# Fischerscope upload. Any one is enough.
|
||||
# Thickness data requirement — _fp_needs_thickness_data is the
|
||||
# single source of truth (shared with the Issue-Certs wizard).
|
||||
# A customer needs thickness data when the cert is a
|
||||
# thickness_report, or it's a CoC and the partner is
|
||||
# strict-thickness / opts into the thickness-on-CoC bundle —
|
||||
# UNLESS the job's recipe suppresses thickness
|
||||
# (requires_thickness_report=False: passivation, chemical
|
||||
# conversion, anodize seal-only — no plating thickness exists).
|
||||
# Acceptable data: logged readings on the cert OR a Fischerscope
|
||||
# PDF on the linked QC OR a cert-local Fischerscope upload.
|
||||
partner = rec.partner_id
|
||||
needs_thickness = (
|
||||
rec.certificate_type == 'thickness_report'
|
||||
or (rec.certificate_type == 'coc' and partner and (
|
||||
('x_fc_strict_thickness_required' in partner._fields
|
||||
and partner.x_fc_strict_thickness_required)
|
||||
or ('x_fc_send_thickness_report' in partner._fields
|
||||
and partner.x_fc_send_thickness_report)
|
||||
))
|
||||
)
|
||||
if needs_thickness:
|
||||
if rec._fp_needs_thickness_data():
|
||||
has_readings = bool(rec.thickness_reading_ids)
|
||||
has_qc_fischer_pdf = bool(
|
||||
rec.x_fc_thickness_pdf_id
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.12.1.6',
|
||||
'version': '19.0.12.1.7',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -141,3 +141,48 @@ class TestRecipeCertSuppression(TransactionCase):
|
||||
cert.with_context(
|
||||
fp_skip_cert_authority_gate=True
|
||||
).action_issue()
|
||||
|
||||
# ---- Test 7: passivation recipe also suppresses the ISSUE-TIME gate ----
|
||||
def test_passivation_recipe_suppresses_thickness_issue_gate(self):
|
||||
"""A passivation recipe (requires_thickness_report=False) must drop
|
||||
the thickness-data requirement at issue time too — even for a
|
||||
strict-thickness customer. Regression: the recipe-suppression
|
||||
feature updated _resolve_required_cert_types but NOT the
|
||||
action_issue / wizard thickness gate, so passivation CoCs could
|
||||
never be issued (gate demanded Fischerscope data the process
|
||||
cannot produce)."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
self.partner.x_fc_strict_thickness_required = True
|
||||
self.recipe.requires_thickness_report = False
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
cert = self.env['fp.certificate'].create({
|
||||
'name': 'TEST-COC-PASSIVATION',
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'x_fc_job_id': job.id,
|
||||
})
|
||||
# Recipe suppresses thickness -> the shared gate must NOT demand it.
|
||||
self.assertFalse(cert._fp_needs_thickness_data())
|
||||
|
||||
# ---- Test 8: normal recipe still enforces thickness (control) ----
|
||||
def test_normal_recipe_keeps_thickness_issue_gate(self):
|
||||
"""Control for Test 7: a recipe that allows thickness
|
||||
(requires_thickness_report default True) still demands thickness
|
||||
data on the CoC for a thickness customer. Guards against
|
||||
over-suppression weakening real aerospace enforcement."""
|
||||
self.partner.x_fc_send_coc = True
|
||||
self.partner.x_fc_send_thickness_report = True
|
||||
# self.recipe.requires_thickness_report stays default True.
|
||||
part = self._make_part()
|
||||
job = self._make_job(part_catalog_id=part.id)
|
||||
cert = self.env['fp.certificate'].create({
|
||||
'name': 'TEST-COC-NORMAL',
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'partner_id': self.partner.id,
|
||||
'x_fc_job_id': job.id,
|
||||
})
|
||||
self.assertTrue(cert._fp_needs_thickness_data())
|
||||
|
||||
@@ -461,17 +461,18 @@ class FpCertIssueWizardLine(models.TransientModel):
|
||||
|
||||
@api.depends('cert_id.certificate_type',
|
||||
'cert_id.partner_id.x_fc_send_thickness_report',
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required')
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required',
|
||||
'cert_id.x_fc_job_id.recipe_id.requires_thickness_report')
|
||||
def _compute_needs_thickness(self):
|
||||
# Delegate to fp.certificate._fp_needs_thickness_data — the single
|
||||
# source of truth shared with the action_issue hard gate — so the
|
||||
# wizard's readiness hint and the gate can never drift. Honours
|
||||
# recipe-level thickness suppression (passivation = no thickness
|
||||
# even if the customer asked).
|
||||
for ln in self:
|
||||
cert = ln.cert_id
|
||||
partner = cert.partner_id
|
||||
ln.needs_thickness = (
|
||||
cert.certificate_type == 'thickness_report'
|
||||
or (cert.certificate_type == 'coc' and partner and (
|
||||
partner.x_fc_strict_thickness_required
|
||||
or partner.x_fc_send_thickness_report
|
||||
))
|
||||
ln.cert_id._fp_needs_thickness_data()
|
||||
if ln.cert_id else False
|
||||
)
|
||||
|
||||
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
|
||||
|
||||
Reference in New Issue
Block a user