changes
This commit is contained in:
@@ -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.',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user