chore(plating): de-dash shipped code + intake-neutral customer emails
Replace em-dashes and en-dashes with hyphens across 789 shipped source files (py/xml/js/scss) so the delivered module reads as human-written; em-dashes had become a recognizable AI-generated tell. Internal .md dev notes are excluded. The WO-sticker mojibake strippers keep their dash search targets (now written — / –). No logic changes: comments and display strings only; validated with py_compile + lxml parse. Rewrite the 7 customer notification emails to be intake-neutral (ship-in / drop-off / pickup) and repair-aware, and fix the Shipped email documents line (packing slip vs bill of lading; certificate only when issued). Subjects use a hyphen separator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ class FpCertificate(models.Model):
|
||||
formats. Auto-created when reports are generated.
|
||||
"""
|
||||
_name = 'fp.certificate'
|
||||
_description = 'Fusion Plating — Certificate'
|
||||
_description = 'Fusion Plating - Certificate'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
||||
_order = 'issue_date desc, id desc'
|
||||
|
||||
@@ -39,7 +39,7 @@ class FpCertificate(models.Model):
|
||||
domain="[('customer_rank', '>', 0)]",
|
||||
)
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
|
||||
# Phase 6 (Sub 11) — production_id retired (MRP module gone).
|
||||
# Phase 6 (Sub 11) - production_id retired (MRP module gone).
|
||||
# Certificates link via sale_order_id + portal_job_id natively.
|
||||
portal_job_id = fields.Many2one('fusion.plating.portal.job', string='Portal Job')
|
||||
part_number = fields.Char(string='Part Number', help='Denormalized for fast search.')
|
||||
@@ -52,7 +52,7 @@ class FpCertificate(models.Model):
|
||||
quantity_shipped = fields.Integer(string='Qty Shipped')
|
||||
nc_quantity = fields.Integer(
|
||||
string='NC Qty',
|
||||
help='Non-conforming quantity — parts that failed inspection / rework.',
|
||||
help='Non-conforming quantity - parts that failed inspection / rework.',
|
||||
)
|
||||
customer_job_no = fields.Char(
|
||||
string='Customer Job No.',
|
||||
@@ -69,7 +69,7 @@ class FpCertificate(models.Model):
|
||||
'job ships. The first is the primary (its name, email and '
|
||||
'phone print on the CoC); ALL of them are emailed when the '
|
||||
'cert is sent to the customer. (Renamed from the single '
|
||||
'contact_partner_id — see migration 19.0.10.3.0.)',
|
||||
'contact_partner_id - see migration 19.0.10.3.0.)',
|
||||
)
|
||||
issued_by_id = fields.Many2one(
|
||||
'res.users', string='Issued By', default=lambda self: self.env.user,
|
||||
@@ -78,7 +78,7 @@ class FpCertificate(models.Model):
|
||||
'res.users', string='Certified By', help='Signing authority (e.g. Quality Manager).',
|
||||
)
|
||||
|
||||
# ===== Sub 12c — chronological CoC opt-in ===============================
|
||||
# ===== Sub 12c - chronological CoC opt-in ===============================
|
||||
body_style = fields.Selection(
|
||||
[
|
||||
('classic', 'Classic (recipe-order)'),
|
||||
@@ -113,7 +113,7 @@ class FpCertificate(models.Model):
|
||||
string='Fischerscope PDF filename',
|
||||
)
|
||||
|
||||
# Non-PDF Fischerscope uploads (.doc / .docx / .xlsx / images) — the
|
||||
# Non-PDF Fischerscope uploads (.doc / .docx / .xlsx / images) - the
|
||||
# Issue Certs wizard stashes them here so the thickness-required gate
|
||||
# can still pass. Unlike `x_fc_local_thickness_pdf`, this attachment
|
||||
# is NOT merged into the CoC PDF as page 2 (we can't rasterize .doc
|
||||
@@ -129,7 +129,7 @@ class FpCertificate(models.Model):
|
||||
'the CoC PDF.',
|
||||
)
|
||||
|
||||
# Report-level Fischerscope metadata — populated by the Issue Certs
|
||||
# Report-level Fischerscope metadata - populated by the Issue Certs
|
||||
# wizard when parsing an RTF/.docx upload. Rendered on the CoC so
|
||||
# the printed cert shows the same context an auditor would see on
|
||||
# the original XDAL 600 export (equipment, operator, calibration,
|
||||
@@ -171,11 +171,11 @@ class FpCertificate(models.Model):
|
||||
'parsed from.',
|
||||
)
|
||||
# Two paths populate this field, with operator upload winning:
|
||||
# 1. RTF auto-extraction — Issue Certs wizard runs libwmf
|
||||
# 1. RTF auto-extraction - Issue Certs wizard runs libwmf
|
||||
# (wmf2svg) on the embedded WMF blocks and picks the
|
||||
# largest raster (header banners filtered by area threshold).
|
||||
# 2. Manual PNG/JPEG upload via the wizard's "Measurement
|
||||
# Image" field — operator override path when the
|
||||
# Image" field - operator override path when the
|
||||
# auto-extracted image is wrong, missing, or low-quality.
|
||||
# See _apply_to_cert and _apply_image_to_cert in the wizard.
|
||||
x_fc_thickness_image_id = fields.Many2one(
|
||||
@@ -203,7 +203,7 @@ class FpCertificate(models.Model):
|
||||
|
||||
@api.depends('sale_order_id')
|
||||
def _compute_batch_ids(self):
|
||||
# Phase 6 (Sub 11) — walks fp.job via SO instead of mrp.production.
|
||||
# Phase 6 (Sub 11) - walks fp.job via SO instead of mrp.production.
|
||||
Batch = self.env.get('fusion.plating.batch')
|
||||
Bath = self.env['fusion.plating.bath']
|
||||
Job = self.env.get('fp.job')
|
||||
@@ -235,7 +235,7 @@ class FpCertificate(models.Model):
|
||||
string='Age (hours)',
|
||||
compute='_compute_x_fc_age_hours',
|
||||
help='Hours since the cert was created. Drives the Quality '
|
||||
'Dashboard age chip and overdue filter. Non-stored — '
|
||||
'Dashboard age chip and overdue filter. Non-stored - '
|
||||
'always fresh on read. Spec 2026-05-25.',
|
||||
)
|
||||
void_reason = fields.Text(string='Void Reason')
|
||||
@@ -340,7 +340,7 @@ class FpCertificate(models.Model):
|
||||
sigma = 0.0
|
||||
rec.std_dev_mils = round(sigma, 4)
|
||||
|
||||
# Cpk — requires spec limits + non-zero sigma
|
||||
# Cpk - requires spec limits + non-zero sigma
|
||||
usl = rec.spec_max_mils or 0.0
|
||||
lsl = rec.spec_min_mils or 0.0
|
||||
if n < 5 or sigma == 0 or (usl == 0 and lsl == 0):
|
||||
@@ -404,7 +404,7 @@ class FpCertificate(models.Model):
|
||||
def create(self, vals_list):
|
||||
SaleOrder = self.env['sale.order']
|
||||
for vals in vals_list:
|
||||
# Spec-limit auto-fill — sources thickness range from the
|
||||
# Spec-limit auto-fill - sources thickness range from the
|
||||
# recipe (Phase A moved the thickness fields onto the
|
||||
# recipe root). Falls back gracefully when the SO has no
|
||||
# recipe-bearing line.
|
||||
@@ -449,12 +449,12 @@ class FpCertificate(models.Model):
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -543,7 +543,7 @@ class FpCertificate(models.Model):
|
||||
# the common case and this fill is what locks the signer at
|
||||
# issue. fp.certificate doesn't declare company_id directly,
|
||||
# so resolve the company from the SO (then env.company) the
|
||||
# same way the CoC report does — otherwise this fill is
|
||||
# same way the CoC report does - otherwise this fill is
|
||||
# skipped and the "Certified By is not set" gate below would
|
||||
# block issuance.
|
||||
if not rec.certified_by_id:
|
||||
@@ -566,33 +566,33 @@ class FpCertificate(models.Model):
|
||||
# when an estimator chooses to fill it, but it no longer
|
||||
# blocks issuance.
|
||||
# Process description (what was done to the parts). Without
|
||||
# it the cert PDF just shows blank process text — customer
|
||||
# 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 '
|
||||
'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-
|
||||
# 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 '
|
||||
'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
|
||||
# Customer contact - the named recipient printed on the
|
||||
# cert and emailed when it ships. Auto-filled from the FIRST
|
||||
# of partner.x_fc_default_coc_contact_ids when set.
|
||||
if not rec.contact_partner_ids:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Customer '
|
||||
'Cannot issue certificate "%(name)s" - Customer '
|
||||
'Contact is not set.\n\nPick the recipient contact(s), '
|
||||
'or configure Default CoC Contacts on customer '
|
||||
'"%(cust)s".'
|
||||
@@ -602,7 +602,7 @@ class FpCertificate(models.Model):
|
||||
})
|
||||
if not (rec.contact_partner_ids[:1].email or '').strip():
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — primary contact '
|
||||
'Cannot issue certificate "%(name)s" - primary contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email to the '
|
||||
'contact before issuing (the cert is sent by email '
|
||||
'post-issue).'
|
||||
@@ -611,7 +611,7 @@ class FpCertificate(models.Model):
|
||||
'c': rec.contact_partner_ids[:1].name,
|
||||
})
|
||||
# Orphan cert types (Nadcap / Mill Test / Customer-Specific)
|
||||
# are manual-attach only — operator uploads supplier doc /
|
||||
# are manual-attach only - operator uploads supplier doc /
|
||||
# regulator-issued cert / filled customer template. Block
|
||||
# issuance until an attachment is present so the customer
|
||||
# doesn't receive an empty PDF. See spec 2026-05-27.
|
||||
@@ -622,7 +622,7 @@ class FpCertificate(models.Model):
|
||||
rec._fields['certificate_type'].selection
|
||||
).get(rec.certificate_type, rec.certificate_type)
|
||||
raise UserError(_(
|
||||
'Cannot issue %(type)s "%(name)s" — no PDF attached.'
|
||||
'Cannot issue %(type)s "%(name)s" - no PDF attached.'
|
||||
'\n\nThis certificate type expects a PDF you upload '
|
||||
'from disk (supplier doc / regulator-issued cert / '
|
||||
'filled customer template). Upload the PDF to the '
|
||||
@@ -631,14 +631,14 @@ class FpCertificate(models.Model):
|
||||
'type': type_label,
|
||||
'name': rec.name or rec.display_name,
|
||||
})
|
||||
# Thickness data requirement — _fp_needs_thickness_data is the
|
||||
# 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 —
|
||||
# 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).
|
||||
# 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
|
||||
@@ -660,7 +660,7 @@ class FpCertificate(models.Model):
|
||||
else _('CoC')
|
||||
)
|
||||
raise UserError(_(
|
||||
'Cannot issue %(type)s "%(name)s" — customer '
|
||||
'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 '
|
||||
@@ -674,10 +674,10 @@ class FpCertificate(models.Model):
|
||||
'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
|
||||
# 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
|
||||
# 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)
|
||||
@@ -690,7 +690,7 @@ class FpCertificate(models.Model):
|
||||
)
|
||||
if abs(job.qty_received - accounted) > 0.0001:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — job '
|
||||
'Cannot issue certificate "%(name)s" - job '
|
||||
'%(job)s qty mismatch (received %(r)g vs '
|
||||
'accounted-out %(a)g). Reconcile job '
|
||||
'quantities before issuing.'
|
||||
@@ -752,7 +752,7 @@ class FpCertificate(models.Model):
|
||||
if was_issued:
|
||||
for rec in self:
|
||||
if not was_issued.get(rec.id):
|
||||
continue # wasn't issued — no regress
|
||||
continue # wasn't issued - no regress
|
||||
if ('x_fc_job_id' in rec._fields and rec.x_fc_job_id
|
||||
and hasattr(rec.x_fc_job_id,
|
||||
'_fp_check_regress_after_cert_void')):
|
||||
@@ -788,8 +788,8 @@ class FpCertificate(models.Model):
|
||||
mandatory; the customer-facing description (what the estimator
|
||||
actually typed on the order) now carries the descriptive text.
|
||||
|
||||
Resolution — first non-empty wins:
|
||||
1. The order line matching this cert's job/part — cleaned via
|
||||
Resolution - first non-empty wins:
|
||||
1. The order line matching this cert's job/part - cleaned via
|
||||
sale.order.line.fp_customer_description() (strips the
|
||||
"[code] product" prefix Odoo re-prepends).
|
||||
2. Any product line on the linked sale order.
|
||||
@@ -880,7 +880,7 @@ class FpCertificate(models.Model):
|
||||
the supporting evidence inline with the cert.
|
||||
|
||||
Tries the EN-language CoC report first, falls back to the
|
||||
generic action_report_coc. Idempotent — skips if attachment_id
|
||||
generic action_report_coc. Idempotent - skips if attachment_id
|
||||
is already set. PDF merge is best-effort: corrupt Fischerscope
|
||||
upload or missing pypdf falls back to CoC-only with a warning.
|
||||
"""
|
||||
@@ -888,7 +888,7 @@ class FpCertificate(models.Model):
|
||||
import io
|
||||
self.ensure_one()
|
||||
# Orphan cert types (Nadcap / Mill Test / Customer-Specific) are
|
||||
# manual-attach only — never render a CoC QWeb template for them.
|
||||
# manual-attach only - never render a CoC QWeb template for them.
|
||||
# action_issue's precondition gate already enforces the
|
||||
# attachment requirement, so by the time we get here the operator
|
||||
# has uploaded the right PDF. See spec 2026-05-27 §4.
|
||||
@@ -950,9 +950,9 @@ class FpCertificate(models.Model):
|
||||
if self.certificate_type != 'coc':
|
||||
return None
|
||||
# Resolution order for the source of the Fischerscope bytes:
|
||||
# 1. Cert-local upload (x_fc_local_thickness_pdf) — manager
|
||||
# 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
|
||||
# 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''
|
||||
@@ -993,7 +993,7 @@ class FpCertificate(models.Model):
|
||||
)
|
||||
if not fischer_bytes:
|
||||
return None
|
||||
# Merge — pypdf is the modern name; PyPDF2 still works on older
|
||||
# Merge - pypdf is the modern name; PyPDF2 still works on older
|
||||
# Odoo bundles. Either is fine.
|
||||
try:
|
||||
from pypdf import PdfWriter
|
||||
@@ -1013,7 +1013,7 @@ class FpCertificate(models.Model):
|
||||
return None
|
||||
try:
|
||||
if use_append:
|
||||
# pypdf 3.x — PdfWriter.append() handles bytes/streams
|
||||
# pypdf 3.x - PdfWriter.append() handles bytes/streams
|
||||
writer = writer_cls()
|
||||
writer.append(io.BytesIO(coc_pdf_bytes))
|
||||
writer.append(io.BytesIO(fischer_bytes))
|
||||
@@ -1021,7 +1021,7 @@ class FpCertificate(models.Model):
|
||||
writer.write(out)
|
||||
merged = out.getvalue()
|
||||
else:
|
||||
# PyPDF2 — PdfMerger.append + write
|
||||
# PyPDF2 - PdfMerger.append + write
|
||||
merger = writer_cls()
|
||||
merger.append(io.BytesIO(coc_pdf_bytes))
|
||||
merger.append(io.BytesIO(fischer_bytes))
|
||||
@@ -1031,7 +1031,7 @@ class FpCertificate(models.Model):
|
||||
merged = out.getvalue()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'PDF merge failed for cert %s — Fischerscope PDF may '
|
||||
'PDF merge failed for cert %s - Fischerscope PDF may '
|
||||
'be corrupt / encrypted / malformed. Falling back to '
|
||||
'CoC-only.', self.name,
|
||||
)
|
||||
@@ -1048,7 +1048,7 @@ class FpCertificate(models.Model):
|
||||
def action_reset_to_draft(self):
|
||||
"""Move an issued/voided cert back to draft so the manager can
|
||||
correct typos in the thickness metadata, swap the microscope
|
||||
image, re-pick the void reason, etc. — then re-Issue.
|
||||
image, re-pick the void reason, etc. - then re-Issue.
|
||||
|
||||
Wipes the existing `attachment_id` so the next render picks up
|
||||
whatever was changed. The original PDF stays around as a
|
||||
@@ -1109,11 +1109,11 @@ class FpCertificate(models.Model):
|
||||
|
||||
def action_view_traceability(self):
|
||||
"""Show the batches (and their chemistry logs) that produced
|
||||
these parts — auditor's dream, customer's RMA friend."""
|
||||
these parts - auditor's dream, customer's RMA friend."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Traceability — %s') % self.name,
|
||||
'name': _('Traceability - %s') % self.name,
|
||||
'res_model': 'fusion.plating.batch',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', self.batch_ids.ids)],
|
||||
|
||||
Reference in New Issue
Block a user