From 489312365e55cf97d7a1d3cbcd618fb60633858b Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 10:43:05 -0400 Subject: [PATCH] fix(certificates): honour recipe thickness suppression at cert issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../__manifest__.py | 2 +- .../models/fp_certificate.py | 75 +++++++++++++------ .../fusion_plating_jobs/__manifest__.py | 2 +- .../tests/test_recipe_cert_suppression.py | 45 +++++++++++ .../wizards/fp_cert_issue_wizard.py | 17 +++-- 5 files changed, 108 insertions(+), 33 deletions(-) diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index 55376831..356f2810 100644 --- a/fusion_plating/fusion_plating_certificates/__manifest__.py +++ b/fusion_plating/fusion_plating_certificates/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py index 4da83ead..e177417b 100644 --- a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py +++ b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py @@ -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 diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 3b85656d..c3240cc4 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -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.', diff --git a/fusion_plating/fusion_plating_jobs/tests/test_recipe_cert_suppression.py b/fusion_plating/fusion_plating_jobs/tests/test_recipe_cert_suppression.py index 6e851001..ee4cc4d8 100644 --- a/fusion_plating/fusion_plating_jobs/tests/test_recipe_cert_suppression.py +++ b/fusion_plating/fusion_plating_jobs/tests/test_recipe_cert_suppression.py @@ -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()) diff --git a/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard.py b/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard.py index 342a69e9..6109a726 100644 --- a/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard.py +++ b/fusion_plating/fusion_plating_jobs/wizards/fp_cert_issue_wizard.py @@ -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',