This commit is contained in:
gsinghpal
2026-05-18 22:33:23 -04:00
parent 25f568f225
commit 091f98e1f9
76 changed files with 4521 additions and 220 deletions

View File

@@ -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."""

View File

@@ -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.',
)