changes
This commit is contained in:
@@ -4,3 +4,4 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.6.1.0',
|
||||
'version': '19.0.6.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
@@ -37,6 +37,7 @@ Includes Fischerscope thickness measurement data capture.
|
||||
'views/fp_certificate_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/fp_certificates_menu.xml',
|
||||
'wizards/fp_cert_void_wizard_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -88,6 +88,24 @@ class FpCertificate(models.Model):
|
||||
'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
|
||||
)
|
||||
|
||||
# ----- Inline Fischerscope PDF upload (cert-local) ----------------------
|
||||
# The merge pipeline normally pulls the Fischerscope/XDAL PDF from the
|
||||
# linked QC check. That works when the operator uploaded it via the
|
||||
# tablet, but managers issuing certs after the fact don't want to
|
||||
# navigate to the QC. This pair of fields gives them a direct upload
|
||||
# path on the cert form. When set, _fp_merge_thickness_into_pdf uses
|
||||
# this in preference to the QC-side upload.
|
||||
x_fc_local_thickness_pdf = fields.Binary(
|
||||
string='Fischerscope PDF (Upload Here)',
|
||||
attachment=True,
|
||||
help='Drop the Fischerscope / XDAL 600 XRF export PDF here. '
|
||||
'When the cert is issued it will be appended as page 2 of '
|
||||
'the CoC. Overrides any PDF on the linked QC check.',
|
||||
)
|
||||
x_fc_local_thickness_pdf_filename = fields.Char(
|
||||
string='Fischerscope PDF filename',
|
||||
)
|
||||
|
||||
# ---- Material traceability (T2.3) ----
|
||||
batch_ids = fields.Many2many(
|
||||
'fusion.plating.batch', compute='_compute_batch_ids',
|
||||
@@ -330,6 +348,23 @@ class FpCertificate(models.Model):
|
||||
for rec in self:
|
||||
if rec.state != 'draft':
|
||||
raise UserError(_('Only draft certificates can be issued.'))
|
||||
# Lazy-fill from partner defaults BEFORE running the gates.
|
||||
# Without this, a cert created before partner.x_fc_default_*
|
||||
# was configured would still trip the gate even after sales
|
||||
# set the default. Robust-by-construction: the defaults take
|
||||
# effect retroactively at issue time.
|
||||
if (not rec.contact_partner_id
|
||||
and rec.partner_id
|
||||
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_id):
|
||||
rec.contact_partner_id = (
|
||||
rec.partner_id.x_fc_default_coc_contact_id
|
||||
)
|
||||
if (not rec.certified_by_id
|
||||
and rec.company_id
|
||||
and 'x_fc_owner_user_id' in rec.company_id._fields
|
||||
and rec.company_id.x_fc_owner_user_id):
|
||||
rec.certified_by_id = rec.company_id.x_fc_owner_user_id
|
||||
# Spec reference is what the cert ATTESTS — without it the
|
||||
# cert is just a piece of paper. AS9100 / Nadcap require
|
||||
# naming the spec the work was performed to.
|
||||
@@ -340,24 +375,127 @@ class FpCertificate(models.Model):
|
||||
'(e.g. "AMS 2404", "MIL-C-26074") so the cert '
|
||||
'states which standard the work meets.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Aerospace / Nadcap customers: actual thickness readings
|
||||
# must be on file BEFORE the cert is issued. The flag lives
|
||||
# on the partner so commercial customers aren't blocked.
|
||||
if (rec.partner_id
|
||||
and 'x_fc_strict_thickness_required' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_strict_thickness_required
|
||||
and rec.certificate_type == 'coc'):
|
||||
if not rec.thickness_reading_ids:
|
||||
# Process description (what was done to the parts). Without
|
||||
# it the cert PDF just shows blank process text — customer
|
||||
# has no idea what they paid for. Auto-filled from the
|
||||
# recipe at create time; manager can override before issuing.
|
||||
if not rec.process_description:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Process '
|
||||
'Description is blank.\n\nFill it manually (e.g. '
|
||||
'"ELECTROLESS NICKEL PLATING PER AMS 2404") or '
|
||||
'assign a recipe to the job so it auto-fills.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Signing authority — the human who attests the work. Auto-
|
||||
# filled from per-spec signer_user_id, falling back to
|
||||
# company.x_fc_owner_user_id. If neither is configured, the
|
||||
# manager must pick before issuing.
|
||||
if not rec.certified_by_id:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Certified By '
|
||||
'is not set.\n\nPick the signing authority, or have '
|
||||
'an admin configure the company\'s Certificate Owner '
|
||||
'(Settings > Fusion Plating).'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Customer contact — the named recipient printed on the
|
||||
# cert and emailed when it ships. Auto-filled from
|
||||
# partner.x_fc_default_coc_contact_id when set.
|
||||
if not rec.contact_partner_id:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Customer '
|
||||
'Contact is not set.\n\nPick the recipient contact, '
|
||||
'or configure a Default CoC Contact on customer '
|
||||
'"%(cust)s".'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name if rec.partner_id else '?',
|
||||
})
|
||||
if not (rec.contact_partner_id.email or '').strip():
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email '
|
||||
'to the contact before issuing (the cert is sent '
|
||||
'by email post-issue).'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'c': rec.contact_partner_id.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.
|
||||
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:
|
||||
has_readings = bool(rec.thickness_reading_ids)
|
||||
has_qc_fischer_pdf = bool(
|
||||
rec.x_fc_thickness_pdf_id
|
||||
if 'x_fc_thickness_pdf_id' in rec._fields else False
|
||||
)
|
||||
has_local_pdf = bool(rec.x_fc_local_thickness_pdf)
|
||||
if not (has_readings or has_qc_fischer_pdf or has_local_pdf):
|
||||
type_label = (
|
||||
_('Thickness Report')
|
||||
if rec.certificate_type == 'thickness_report'
|
||||
else _('CoC')
|
||||
)
|
||||
raise UserError(_(
|
||||
'Cannot issue CoC "%(name)s" — customer "%(cust)s" '
|
||||
'requires actual thickness readings on every CoC '
|
||||
'(Nadcap / aerospace).\n\nLog Fischerscope readings '
|
||||
'against the job for SO %(so)s via the Tablet Station '
|
||||
'before issuing.'
|
||||
'Cannot issue %(type)s "%(name)s" — customer '
|
||||
'"%(cust)s" requires thickness data on every '
|
||||
'%(type)s. No readings, no Fischerscope PDF on '
|
||||
'the linked QC, and no local Fischerscope upload '
|
||||
'on this cert.\n\nUse the Issue Certs wizard '
|
||||
'from the work order to upload the Fischerscope '
|
||||
'report, or log readings against the job for '
|
||||
'SO %(so)s via the Tablet Station.'
|
||||
) % {
|
||||
'type': type_label,
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': partner.name if partner else '?',
|
||||
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
|
||||
})
|
||||
# Defensive qty reconciliation — should already be guaranteed
|
||||
# by fp.job.button_mark_done's gate, but re-checked here so
|
||||
# certs created outside the job flow (manual, scripts) still
|
||||
# can't issue with a mismatched job. No bypass — qty integrity
|
||||
# is non-negotiable at issue.
|
||||
job = (rec.x_fc_job_id
|
||||
if 'x_fc_job_id' in rec._fields else False)
|
||||
if job and job.qty_received:
|
||||
rejects = job.qty_visual_inspection_rejects or 0
|
||||
accounted = (
|
||||
(job.qty_done or 0)
|
||||
+ (job.qty_scrapped or 0)
|
||||
+ rejects
|
||||
)
|
||||
if abs(job.qty_received - accounted) > 0.0001:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — job '
|
||||
'%(job)s qty mismatch (received %(r)g vs '
|
||||
'accounted-out %(a)g). Reconcile job '
|
||||
'quantities before issuing.'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name,
|
||||
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
|
||||
'job': job.name,
|
||||
'r': job.qty_received,
|
||||
'a': accounted,
|
||||
})
|
||||
rec.state = 'issued'
|
||||
# Generate the CoC PDF and attach it so action_send_to_customer
|
||||
@@ -445,35 +583,48 @@ class FpCertificate(models.Model):
|
||||
self.ensure_one()
|
||||
if self.certificate_type != 'coc':
|
||||
return None
|
||||
# Find the linked job. fp.certificate has either x_fc_job_id
|
||||
# (preferred — added by fusion_plating_jobs) or job_id (older).
|
||||
job = False
|
||||
if 'x_fc_job_id' in self._fields:
|
||||
job = self.x_fc_job_id
|
||||
if not job and 'job_id' in self._fields:
|
||||
job = self.job_id
|
||||
if not job:
|
||||
return None
|
||||
# Find a passed QC on this job with an uploaded Fischerscope PDF.
|
||||
# Prefer state=passed; fall through to any with a PDF.
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
if QC is None:
|
||||
return None
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('state', '=', 'passed'),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='completed_at desc', limit=1)
|
||||
if not qc:
|
||||
# Resolution order for the source of the Fischerscope bytes:
|
||||
# 1. Cert-local upload (x_fc_local_thickness_pdf) — manager
|
||||
# dropped it directly on the cert form
|
||||
# 2. Linked QC's thickness_report_pdf_id — operator uploaded
|
||||
# via the tablet during inspection
|
||||
# Either path yields the same merged-PDF outcome.
|
||||
fischer_bytes = b''
|
||||
qc = False
|
||||
if self.x_fc_local_thickness_pdf:
|
||||
try:
|
||||
fischer_bytes = _b64.b64decode(
|
||||
self.x_fc_local_thickness_pdf or b''
|
||||
)
|
||||
except Exception:
|
||||
fischer_bytes = b''
|
||||
if not fischer_bytes:
|
||||
# Fall through to the QC-side PDF.
|
||||
job = False
|
||||
if 'x_fc_job_id' in self._fields:
|
||||
job = self.x_fc_job_id
|
||||
if not job and 'job_id' in self._fields:
|
||||
job = self.job_id
|
||||
if not job:
|
||||
return None
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
if QC is None:
|
||||
return None
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('state', '=', 'passed'),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='create_date desc', limit=1)
|
||||
if not qc or not qc.thickness_report_pdf_id:
|
||||
return None
|
||||
fischer_bytes = _b64.b64decode(
|
||||
qc.thickness_report_pdf_id.datas or b''
|
||||
)
|
||||
], order='completed_at desc', limit=1)
|
||||
if not qc:
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='create_date desc', limit=1)
|
||||
if not qc or not qc.thickness_report_pdf_id:
|
||||
return None
|
||||
fischer_bytes = _b64.b64decode(
|
||||
qc.thickness_report_pdf_id.datas or b''
|
||||
)
|
||||
if not fischer_bytes:
|
||||
return None
|
||||
# Merge — pypdf is the modern name; PyPDF2 still works on older
|
||||
@@ -519,9 +670,13 @@ class FpCertificate(models.Model):
|
||||
'CoC-only.', self.name,
|
||||
)
|
||||
return None
|
||||
source = (
|
||||
_('cert upload') if self.x_fc_local_thickness_pdf
|
||||
else _('QC %s') % (qc.name if qc else '?')
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'Fischerscope thickness report from QC %s appended to CoC PDF.'
|
||||
) % qc.name)
|
||||
'Fischerscope thickness report (%s) appended to CoC PDF.'
|
||||
) % source)
|
||||
return merged
|
||||
|
||||
def action_void(self):
|
||||
@@ -533,6 +688,33 @@ class FpCertificate(models.Model):
|
||||
rec.state = 'voided'
|
||||
rec.message_post(body=_('Certificate voided. Reason: %s') % rec.void_reason)
|
||||
|
||||
def action_open_void_wizard(self):
|
||||
"""Open the void-reason wizard. Bound to the Void header button
|
||||
instead of action_void directly so the manager always supplies a
|
||||
written reason (the underlying action_void still blocks on a
|
||||
blank reason as a defensive last-line check)."""
|
||||
self.ensure_one()
|
||||
if self.state != 'issued':
|
||||
raise UserError(_(
|
||||
'Only issued certificates can be voided '
|
||||
'(current state: %s).'
|
||||
) % self.state)
|
||||
Wizard = self.env.get('fp.cert.void.wizard')
|
||||
if Wizard is None:
|
||||
raise UserError(_(
|
||||
'Void wizard not available. Reinstall '
|
||||
'fusion_plating_certificates.'
|
||||
))
|
||||
wiz = Wizard.create({'cert_id': self.id})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Void %s') % self.name,
|
||||
'res_model': Wizard._name,
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_view_traceability(self):
|
||||
"""Show the batches (and their chemistry logs) that produced
|
||||
these parts — auditor's dream, customer's RMA friend."""
|
||||
|
||||
@@ -98,3 +98,18 @@ class ResPartner(models.Model):
|
||||
'AS9100/ISO 9001 boilerplate. Useful for aerospace customers '
|
||||
'who require specific NIST or DFARS language.',
|
||||
)
|
||||
|
||||
# ---- Default CoC contact (cert addressee + email recipient) ----------
|
||||
# The single named contact printed on the CoC and used as the email
|
||||
# default when the cert ships. Sales sets it once per customer.
|
||||
# Falls back to manual selection at action_issue time if blank.
|
||||
x_fc_default_coc_contact_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Default CoC Contact',
|
||||
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
|
||||
tracking=True,
|
||||
help='Default contact the Certificate of Conformance is addressed '
|
||||
'to and emailed to. Pre-fills cert.contact_partner_id when a '
|
||||
'job ships. Leave blank to force the manager to pick at '
|
||||
'issue time. Must be a child contact of this company.',
|
||||
)
|
||||
|
||||
@@ -5,3 +5,5 @@ access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion
|
||||
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_void_wiz_mgr,fp.cert.void.wiz.manager,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_action_issue_gates
|
||||
@@ -0,0 +1,137 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Issuance-gate tests for fp.certificate.action_issue.
|
||||
|
||||
Covers the 2026-05-18 hardening that adds blocking checks for
|
||||
process_description, certified_by_id, contact_partner_id (with email),
|
||||
and qty reconciliation. See
|
||||
docs/superpowers/specs/2026-05-18-cert-creation-and-data-gates-design.md.
|
||||
"""
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestActionIssueGates(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.signer = cls.env['res.users'].create({
|
||||
'name': 'Signer',
|
||||
'login': 'signer_certissue',
|
||||
'email': 'signer@example.com',
|
||||
})
|
||||
cls.contact_with_email = cls.env['res.partner'].create({
|
||||
'name': 'Anne Recipient',
|
||||
'email': 'anne@cust.example',
|
||||
})
|
||||
cls.contact_no_email = cls.env['res.partner'].create({
|
||||
'name': 'Carl NoEmail',
|
||||
})
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'IssueCust',
|
||||
'is_company': True,
|
||||
})
|
||||
cls.contact_with_email.parent_id = cls.partner.id
|
||||
cls.contact_no_email.parent_id = cls.partner.id
|
||||
|
||||
def _make_cert(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'spec_reference': 'AMS 2404',
|
||||
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
|
||||
'certified_by_id': self.signer.id,
|
||||
'contact_partner_id': self.contact_with_email.id,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.certificate'].create(vals)
|
||||
|
||||
# ---- the existing gate still works (spec_reference) ----
|
||||
|
||||
def test_blocks_on_missing_spec_reference(self):
|
||||
cert = self._make_cert(spec_reference=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Spec Reference', str(exc.exception))
|
||||
|
||||
# ---- new gate: process_description ----
|
||||
|
||||
def test_blocks_on_missing_process_description(self):
|
||||
cert = self._make_cert(process_description=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Process Description', str(exc.exception))
|
||||
|
||||
# ---- new gate: certified_by_id ----
|
||||
|
||||
def test_blocks_on_missing_certified_by(self):
|
||||
cert = self._make_cert(certified_by_id=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Certified By', str(exc.exception))
|
||||
|
||||
# ---- new gate: contact_partner_id ----
|
||||
|
||||
def test_blocks_on_missing_contact(self):
|
||||
cert = self._make_cert(contact_partner_id=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Customer Contact', str(exc.exception))
|
||||
|
||||
def test_blocks_on_contact_without_email(self):
|
||||
cert = self._make_cert(contact_partner_id=self.contact_no_email.id)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('no email', str(exc.exception))
|
||||
|
||||
# ---- happy path ----
|
||||
|
||||
def test_passes_when_all_data_present(self):
|
||||
cert = self._make_cert()
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
|
||||
# ---- order: spec_reference still wins (cheapest first) ----
|
||||
|
||||
def test_gate_order_spec_reference_first(self):
|
||||
# Multiple missing → spec_reference message surfaces first.
|
||||
cert = self._make_cert(
|
||||
spec_reference=False,
|
||||
process_description=False,
|
||||
certified_by_id=False,
|
||||
contact_partner_id=False,
|
||||
)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Spec Reference', str(exc.exception))
|
||||
# And NOT the process_description message (gate hit first).
|
||||
self.assertNotIn('Process Description', str(exc.exception))
|
||||
|
||||
# ---- new gate: thickness_report cert needs thickness data ----
|
||||
|
||||
def test_blocks_thickness_report_with_no_data(self):
|
||||
"""A thickness_report cert with zero readings and no Fischerscope
|
||||
PDF is empty paper — must block at issue."""
|
||||
cert = self._make_cert(certificate_type='thickness_report')
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('thickness data', str(exc.exception).lower())
|
||||
|
||||
def test_thickness_report_passes_with_readings(self):
|
||||
cert = self._make_cert(certificate_type='thickness_report')
|
||||
self.env['fp.thickness.reading'].create({
|
||||
'certificate_id': cert.id,
|
||||
'nip_mils': 0.4,
|
||||
})
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
|
||||
def test_coc_does_not_require_thickness_data_by_default(self):
|
||||
"""Commercial CoC (no strict_thickness flag) should still pass
|
||||
even without readings — only thickness_report type is gated."""
|
||||
cert = self._make_cert(certificate_type='coc')
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
@@ -42,7 +42,7 @@
|
||||
<button name="action_issue" string="Issue"
|
||||
type="object" class="btn-primary"
|
||||
invisible="state != 'draft'"/>
|
||||
<button name="action_void" string="Void"
|
||||
<button name="action_open_void_wizard" string="Void"
|
||||
type="object" class="btn-danger"
|
||||
invisible="state != 'issued'"/>
|
||||
<button name="action_send_to_customer" string="Send to Customer"
|
||||
|
||||
@@ -32,6 +32,17 @@
|
||||
<field name="x_fc_send_bol" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Default CoC Contact"/>
|
||||
<p class="text-muted">
|
||||
The named contact this customer's CoC is addressed
|
||||
to and emailed to. Pre-fills cert records when a
|
||||
job ships. Leave blank to force the manager to pick
|
||||
at issue time.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_default_coc_contact_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
<separator string="Cert Statement Override (Sub 12c+)"/>
|
||||
<p class="text-muted">
|
||||
Boilerplate text printed in the "Certification Statement"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_cert_void_wizard
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Void Certificate Wizard.
|
||||
|
||||
Opened from an issued cert's "Void" button. Prompts the manager for a
|
||||
written reason, then calls action_void on the cert with the reason
|
||||
populated. The cert's chatter records the void event with the reason
|
||||
inline via the existing _logger / message_post in action_void.
|
||||
"""
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpCertVoidWizard(models.TransientModel):
|
||||
_name = 'fp.cert.void.wizard'
|
||||
_description = 'Fusion Plating — Void Certificate Wizard'
|
||||
|
||||
cert_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', required=True, readonly=True,
|
||||
)
|
||||
cert_name = fields.Char(related='cert_id.name', readonly=True)
|
||||
partner_id = fields.Many2one(
|
||||
related='cert_id.partner_id', readonly=True,
|
||||
)
|
||||
void_reason = fields.Text(
|
||||
string='Void Reason',
|
||||
help='Why this certificate is being voided. Printed on the '
|
||||
'cert chatter and visible in audit trails. Required for '
|
||||
'AS9100 / Nadcap document control. Validation happens at '
|
||||
'confirm time so the wizard can open empty.',
|
||||
)
|
||||
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
if not (self.void_reason or '').strip():
|
||||
raise UserError(_(
|
||||
'Please enter a void reason before voiding. The reason '
|
||||
'is logged to the cert chatter and printed on the audit '
|
||||
'trail (AS9100 / Nadcap requirement).'
|
||||
))
|
||||
if self.cert_id.state != 'issued':
|
||||
raise UserError(_(
|
||||
'Only issued certificates can be voided '
|
||||
'(current state: %s).'
|
||||
) % self.cert_id.state)
|
||||
# Write the reason FIRST so the cert's action_void gate passes.
|
||||
self.cert_id.void_reason = self.void_reason
|
||||
self.cert_id.action_void()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_cert_void_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.cert.void.wizard.form</field>
|
||||
<field name="model">fp.cert.void.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Void Certificate">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>
|
||||
Void Certificate <field name="cert_name"
|
||||
readonly="1"
|
||||
nolabel="1"
|
||||
class="oe_inline"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
Voiding marks this certificate as no longer
|
||||
valid. The audit trail keeps the record visible
|
||||
but flagged. Required for AS9100 / Nadcap
|
||||
document control.
|
||||
</div>
|
||||
<group>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="void_reason"
|
||||
placeholder="e.g. Customer rejected lot — re-plating required. Replaced by CoC-30041."
|
||||
nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Void Certificate"
|
||||
class="btn-danger"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user