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:
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
# Note: `lib/` is NOT eagerly imported here — Python's relative-import
|
||||
# Note: `lib/` is NOT eagerly imported here - Python's relative-import
|
||||
# machinery would otherwise re-enter this package mid-init when the
|
||||
# wizard module does `from ..lib.fischerscope_parser import …`, raising
|
||||
# "cannot import name X from partially initialized module" on Python
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'name': 'Fusion Plating - Certificates',
|
||||
'version': '19.0.10.3.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
Fusion Plating — Certificates
|
||||
Fusion Plating - Certificates
|
||||
===============================
|
||||
|
||||
Unified certificate registry tracking all quality documents issued to customers.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Parser libraries for fusion_plating_certificates.
|
||||
# Pure-Python modules, no Odoo imports — safe to unit-test in isolation.
|
||||
# Pure-Python modules, no Odoo imports - safe to unit-test in isolation.
|
||||
from . import fischerscope_parser # noqa: F401
|
||||
|
||||
@@ -20,7 +20,7 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regexes — derived from the real Fischerscope XDAL 600 export layout.
|
||||
# Regexes - derived from the real Fischerscope XDAL 600 export layout.
|
||||
# Sample line:
|
||||
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
|
||||
# Spaces vary; allow flexible whitespace + optional channel digit after NiP/Ni/P.
|
||||
@@ -37,7 +37,7 @@ _READING_RE = re.compile(
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
# Equipment model — first non-blank line that contains "Fischerscope" or
|
||||
# Equipment model - first non-blank line that contains "Fischerscope" or
|
||||
# similar gauge identifier. Captures everything up to end of line.
|
||||
_EQUIPMENT_RE = re.compile(
|
||||
r'(Fischerscope[^\n\r]*)',
|
||||
@@ -113,7 +113,7 @@ def parse_fischerscope_file(filename, content_bytes):
|
||||
'errors': [str], # non-fatal warnings encountered
|
||||
}
|
||||
|
||||
Never raises on parse failure — returns success=False with readings=[].
|
||||
Never raises on parse failure - returns success=False with readings=[].
|
||||
Raises only on unrecoverable I/O (e.g. corrupted file bytes).
|
||||
"""
|
||||
name = (filename or '').lower()
|
||||
@@ -125,7 +125,7 @@ def parse_fischerscope_file(filename, content_bytes):
|
||||
return _failed_result(
|
||||
raw_text='',
|
||||
error=(
|
||||
'Legacy .doc format not supported — re-export from the '
|
||||
'Legacy .doc format not supported - re-export from the '
|
||||
'gauge as .docx or .pdf. (python-docx reads .docx only; '
|
||||
'old binary .doc needs LibreOffice conversion which '
|
||||
"isn't installed.)"
|
||||
@@ -149,7 +149,7 @@ def _parse_docx(content_bytes):
|
||||
except ImportError:
|
||||
return _failed_result(
|
||||
raw_text='',
|
||||
error='python-docx not installed — cannot parse .docx files.',
|
||||
error='python-docx not installed - cannot parse .docx files.',
|
||||
)
|
||||
try:
|
||||
doc = docx.Document(io.BytesIO(content_bytes))
|
||||
@@ -157,7 +157,7 @@ def _parse_docx(content_bytes):
|
||||
return _failed_result(raw_text='', error='Could not open .docx: %s' % e)
|
||||
|
||||
# Build the raw text by walking paragraphs AND tables. Fischerscope
|
||||
# exports vary — sometimes the readings are in a table, sometimes
|
||||
# exports vary - sometimes the readings are in a table, sometimes
|
||||
# in justified paragraphs. Joining everything gives the regex a
|
||||
# stable target.
|
||||
parts = []
|
||||
@@ -197,7 +197,7 @@ def _parse_pdf(content_bytes):
|
||||
except ImportError:
|
||||
return _failed_result(
|
||||
raw_text='',
|
||||
error='PyPDF2 not installed — cannot parse .pdf files.',
|
||||
error='PyPDF2 not installed - cannot parse .pdf files.',
|
||||
)
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(content_bytes))
|
||||
@@ -214,7 +214,7 @@ def _parse_pdf(content_bytes):
|
||||
|
||||
# PDF image extraction is unreliable across PDF producers. Best-
|
||||
# effort: walk page resources looking for /XObject /Image entries.
|
||||
# If anything fails, drop image silently — the operator still has
|
||||
# If anything fails, drop image silently - the operator still has
|
||||
# the original file attached.
|
||||
image_bytes = None
|
||||
image_mime = None
|
||||
|
||||
@@ -32,7 +32,7 @@ def migrate(cr, version):
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
_logger.warning(
|
||||
'fp_default_coc_contact_rel missing — skipping CoC contact '
|
||||
'fp_default_coc_contact_rel missing - skipping CoC contact '
|
||||
'migration (rel table not created yet).')
|
||||
return
|
||||
# Copy the single value into the M2m (skip rows already present so a
|
||||
|
||||
@@ -30,7 +30,7 @@ def migrate(cr, version):
|
||||
""")
|
||||
if not cr.fetchone():
|
||||
_logger.warning(
|
||||
'fp_certificate_contact_partner_rel missing — skipping cert '
|
||||
'fp_certificate_contact_partner_rel missing - skipping cert '
|
||||
'customer-contact migration (rel table not created yet).')
|
||||
return
|
||||
cr.execute("""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Phase 6 (Sub 11) — drop legacy MRP columns from certificate tables.
|
||||
# Phase 6 (Sub 11) - drop legacy MRP columns from certificate tables.
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Post-migrate for 19.0.9.0.0 — Aerospace/Defence cert partner toggles.
|
||||
"""Post-migrate for 19.0.9.0.0 - Aerospace/Defence cert partner toggles.
|
||||
|
||||
Backfills NULL -> FALSE on the three new Boolean columns added to
|
||||
res.partner (x_fc_send_nadcap_cert, x_fc_send_mill_test,
|
||||
@@ -11,7 +11,7 @@ flipped on by the admin).
|
||||
|
||||
Without this backfill, existing partner rows would read NULL on
|
||||
the new columns and the resolver's `if p.x_fc_send_nadcap_cert`
|
||||
check would short-circuit cleanly anyway — but explicit FALSE makes
|
||||
check would short-circuit cleanly anyway - but explicit FALSE makes
|
||||
SQL queries / reports cleaner and matches the existing partner-flag
|
||||
storage convention.
|
||||
"""
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -43,7 +43,7 @@ class FusionPlatingDelivery(models.Model):
|
||||
draft_certs = Cert.search(dom)
|
||||
if draft_certs:
|
||||
raise UserError(_(
|
||||
'Cannot mark delivery %(d)s shipped — job '
|
||||
'Cannot mark delivery %(d)s shipped - job '
|
||||
'%(j)s still has %(n)d draft '
|
||||
'certificate(s) (%(types)s). Issue them '
|
||||
'first, or pass fp_skip_cert_gate=True '
|
||||
|
||||
@@ -14,13 +14,13 @@ class FpThicknessReading(models.Model):
|
||||
Data is manually entered for now; future: CSV import from equipment.
|
||||
"""
|
||||
_name = 'fp.thickness.reading'
|
||||
_description = 'Fusion Plating — Thickness Reading'
|
||||
_description = 'Fusion Plating - Thickness Reading'
|
||||
_order = 'reading_number'
|
||||
|
||||
certificate_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', ondelete='cascade',
|
||||
)
|
||||
# Phase 6 (Sub 11) — production_id retired (MRP module gone).
|
||||
# Phase 6 (Sub 11) - production_id retired (MRP module gone).
|
||||
# Thickness readings link via certificate_id and quality_check_id.
|
||||
reading_number = fields.Integer(
|
||||
string='Reading #', default=1, help='Sequence number (n=1, n=2, n=3).',
|
||||
@@ -73,5 +73,5 @@ class FpThicknessReading(models.Model):
|
||||
if rec.nip_mils:
|
||||
label = '%s (%.4f mils)' % (label, rec.nip_mils)
|
||||
if ctx:
|
||||
label = '%s — %s' % (label, ctx)
|
||||
label = '%s - %s' % (label, ctx)
|
||||
rec.display_name = label
|
||||
|
||||
@@ -15,7 +15,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
x_fc_owner_user_id = fields.Many2one(
|
||||
related='company_id.x_fc_owner_user_id', readonly=False,
|
||||
)
|
||||
# x_fc_coc_signature_override was retired 2026-05-17 — cert
|
||||
# x_fc_coc_signature_override was retired 2026-05-17 - cert
|
||||
# signatures now come from the certifier user's Plating Signature
|
||||
# only. Field stays on res.company (no migration) but is no longer
|
||||
# exposed in settings.
|
||||
|
||||
@@ -49,9 +49,9 @@ class ResPartner(models.Model):
|
||||
'for commercial customers.',
|
||||
)
|
||||
|
||||
# Aerospace / Defence cert toggles (2026-05-27 — sub
|
||||
# Aerospace / Defence cert toggles (2026-05-27 - sub
|
||||
# docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md).
|
||||
# Default False — opt-in for aerospace/defence customers only.
|
||||
# Default False - opt-in for aerospace/defence customers only.
|
||||
# Resolver _resolve_required_cert_types reads these alongside the
|
||||
# existing x_fc_send_coc / x_fc_send_thickness_report. The three
|
||||
# cert types (nadcap_cert / mill_test / customer_specific) are
|
||||
@@ -61,7 +61,7 @@ class ResPartner(models.Model):
|
||||
default=False, tracking=True,
|
||||
help='Auto-spawn a Nadcap-type fp.certificate when a job for '
|
||||
'this customer reaches awaiting_cert. Operator attaches '
|
||||
'the supplier/PRI-issued PDF before clicking Issue — '
|
||||
'the supplier/PRI-issued PDF before clicking Issue - '
|
||||
'there is no QWeb auto-render for this type.',
|
||||
)
|
||||
x_fc_send_mill_test = fields.Boolean(
|
||||
@@ -78,10 +78,10 @@ class ResPartner(models.Model):
|
||||
'issuing.',
|
||||
)
|
||||
|
||||
# ---- Sub 6 — Per-contact communication routing -----------------------
|
||||
# ---- Sub 6 - Per-contact communication routing -----------------------
|
||||
# These five flags live on CHILD contacts under a company partner.
|
||||
# When every contact under a company leaves them blank, the resolver
|
||||
# falls back to the company's own `email` — matching the pre-Sub-6
|
||||
# falls back to the company's own `email` - matching the pre-Sub-6
|
||||
# behaviour exactly. Admins opt in to per-contact routing by ticking
|
||||
# the relevant flag on each contact row.
|
||||
x_fc_receives_certs = fields.Boolean(
|
||||
@@ -117,7 +117,7 @@ class ResPartner(models.Model):
|
||||
'visibility into everything the shop sends out.',
|
||||
)
|
||||
|
||||
# ---- Sub 12c+ — Per-customer cert statement override ----------------
|
||||
# ---- Sub 12c+ - Per-customer cert statement override ----------------
|
||||
x_fc_cert_statement = fields.Text(
|
||||
string='Cert Statement Override',
|
||||
help='Override boilerplate text printed in the Certificate of '
|
||||
@@ -134,7 +134,7 @@ class ResPartner(models.Model):
|
||||
# when a job ships); the rest are CC'd when the CoC is emailed. Sales
|
||||
# sets the list once per customer. Falls back to manual selection at
|
||||
# action_issue time if blank. Self-referential M2m (renamed from the old
|
||||
# single Many2one x_fc_default_coc_contact_id — see migration 19.0.10.2.0).
|
||||
# single Many2one x_fc_default_coc_contact_id - see migration 19.0.10.2.0).
|
||||
x_fc_default_coc_contact_ids = fields.Many2many(
|
||||
'res.partner',
|
||||
relation='fp_default_coc_contact_rel',
|
||||
|
||||
@@ -58,7 +58,7 @@ class TestActionIssueGates(TransactionCase):
|
||||
# ---- spec_reference is OPTIONAL (client request 2026-05-28) ----
|
||||
|
||||
def test_no_block_on_missing_spec_reference(self):
|
||||
"""Spec Reference no longer gates issuance — the customer-facing
|
||||
"""Spec Reference no longer gates issuance - the customer-facing
|
||||
description now serves as the cert's spec/certificate info. A
|
||||
cert with everything else present must issue even with a blank
|
||||
spec_reference."""
|
||||
@@ -124,7 +124,7 @@ class TestActionIssueGates(TransactionCase):
|
||||
|
||||
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."""
|
||||
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()
|
||||
@@ -141,7 +141,7 @@ class TestActionIssueGates(TransactionCase):
|
||||
|
||||
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."""
|
||||
even without readings - only thickness_report type is gated."""
|
||||
cert = self._make_cert(certificate_type='coc')
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Unit tests for the Fischerscope thickness-report parser.
|
||||
# Pure-Python tests — no Odoo DB needed. Builds synthetic .docx files
|
||||
# Pure-Python tests - no Odoo DB needed. Builds synthetic .docx files
|
||||
# matching the real XDAL 600 export layout and verifies extraction.
|
||||
|
||||
import io
|
||||
@@ -26,11 +26,11 @@ class TestFischerscopeParser(TransactionCase):
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
try:
|
||||
import docx # python-docx — required for tests
|
||||
import docx # python-docx - required for tests
|
||||
cls.docx = docx
|
||||
except ImportError:
|
||||
cls.docx = None
|
||||
# Resolve the parser by absolute path at first use — relative
|
||||
# Resolve the parser by absolute path at first use - relative
|
||||
# `from ..lib import` at module top trips the test loader's
|
||||
# partially-initialised-package check.
|
||||
from odoo.addons.fusion_plating_certificates.lib import (
|
||||
|
||||
@@ -39,13 +39,13 @@
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<!-- Phase D5 — Nadcap-cert restriction enforced at MODEL
|
||||
<!-- Phase D5 - Nadcap-cert restriction enforced at MODEL
|
||||
layer via ir.rule (rule_fp_certificate_nadcap_qm_only
|
||||
in fp_cert_security.xml). Single Issue button visible
|
||||
to all Manager+ when state=draft. Manager clicking
|
||||
Issue on a Nadcap cert gets AccessError from the rule.
|
||||
(Strategy B with user_has_groups() inside invisible=
|
||||
was rejected by Odoo 19 view validator — see CLAUDE.md
|
||||
was rejected by Odoo 19 view validator - see CLAUDE.md
|
||||
rule 13f.) -->
|
||||
<button name="action_issue" string="Issue"
|
||||
type="object" class="btn-primary"
|
||||
@@ -91,7 +91,7 @@
|
||||
<field name="name" readonly="1"/>
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Main info — collapsed from 3 separate groups
|
||||
<!-- Main info - collapsed from 3 separate groups
|
||||
into 1 to eliminate the dead rows that
|
||||
appeared when one sub-group ran shorter than
|
||||
the other. Left column is identity / signer /
|
||||
@@ -125,11 +125,11 @@
|
||||
<field name="mean_nip_mils" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- SPC rebalanced — spec/min/max on the left,
|
||||
<!-- SPC rebalanced - spec/min/max on the left,
|
||||
derived stats on the right; trend_explanation
|
||||
spans both columns so the long message doesn't
|
||||
get cropped. -->
|
||||
<group string="SPC — Statistical Process Control">
|
||||
<group string="SPC - Statistical Process Control">
|
||||
<group>
|
||||
<field name="spec_min_mils"/>
|
||||
<field name="spec_max_mils"/>
|
||||
@@ -169,7 +169,7 @@
|
||||
</page>
|
||||
<page string="Certificate PDF" name="pdf">
|
||||
<!-- Manual-attach banner for orphan cert types
|
||||
(Nadcap / Mill Test / Customer-Specific) —
|
||||
(Nadcap / Mill Test / Customer-Specific) -
|
||||
spec 2026-05-27. action_issue refuses to
|
||||
finalize these types without a PDF. -->
|
||||
<div class="alert alert-warning" role="alert"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<field name="domain">[('certificate_type', '=', 'thickness_report')]</field>
|
||||
</record>
|
||||
|
||||
<!-- Phase 1 — re-parented under Plating → Quality. Certificates are
|
||||
<!-- Phase 1 - re-parented under Plating → Quality. Certificates are
|
||||
a quality output, not a separate top-level concern. -->
|
||||
<menuitem id="menu_fp_certificates"
|
||||
name="Certificates"
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Sub 6 — Per-contact routing flags on the contact's own form
|
||||
<!-- Sub 6 - Per-contact routing flags on the contact's own form
|
||||
(opens when editing a child contact row). Applied to every
|
||||
res.partner form so delivery-location partners and their child
|
||||
contacts both surface the same flags. -->
|
||||
|
||||
@@ -14,7 +14,7 @@ from odoo.exceptions import UserError
|
||||
|
||||
class FpCertVoidWizard(models.TransientModel):
|
||||
_name = 'fp.cert.void.wizard'
|
||||
_description = 'Fusion Plating — Void Certificate Wizard'
|
||||
_description = 'Fusion Plating - Void Certificate Wizard'
|
||||
|
||||
cert_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', required=True, readonly=True,
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</group>
|
||||
<group>
|
||||
<field name="void_reason"
|
||||
placeholder="e.g. Customer rejected lot — re-plating required. Replaced by CoC-30041."
|
||||
placeholder="e.g. Customer rejected lot - re-plating required. Replaced by CoC-30041."
|
||||
nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# When the parser extracts ≥1 reading, the wizard enters "review" state
|
||||
# and the editable reading table is shown. When 0 readings are found,
|
||||
# the wizard enters "manual" state — the operator can still save the
|
||||
# the wizard enters "manual" state - the operator can still save the
|
||||
# file as-is (attach-only fallback). Either way the file ends up in
|
||||
# place to satisfy the action_issue thickness gate.
|
||||
|
||||
@@ -20,7 +20,7 @@ import logging
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
# Lazy parser import — `from ..lib.fischerscope_parser import …` at
|
||||
# Lazy parser import - `from ..lib.fischerscope_parser import …` at
|
||||
# module top fails on Python 3.11+ because the parent package
|
||||
# `fusion_plating_certificates` is still mid-init when wizards/__init__
|
||||
# imports this file (relative traversal into a partially-loaded parent
|
||||
@@ -44,7 +44,7 @@ class FpThicknessUploadWizard(models.TransientModel):
|
||||
state = fields.Selection(
|
||||
[('upload', 'Upload file'),
|
||||
('review', 'Review parsed readings'),
|
||||
('manual', 'Parse failed — attach only')],
|
||||
('manual', 'Parse failed - attach only')],
|
||||
default='upload', required=True,
|
||||
)
|
||||
|
||||
@@ -133,7 +133,7 @@ class FpThicknessUploadWizard(models.TransientModel):
|
||||
raise UserError(_('Wizard has no certificate to write to.'))
|
||||
if cert.state != 'draft':
|
||||
raise UserError(_(
|
||||
'Cannot attach thickness data — certificate %s is in '
|
||||
'Cannot attach thickness data - certificate %s is in '
|
||||
'state %s. Only draft certificates can be edited.'
|
||||
) % (cert.display_name, cert.state))
|
||||
|
||||
@@ -157,7 +157,7 @@ class FpThicknessUploadWizard(models.TransientModel):
|
||||
'mimetype': self.parsed_image_mime or 'image/jpeg',
|
||||
})
|
||||
|
||||
# Write reading rows — same metadata copied onto every row
|
||||
# Write reading rows - same metadata copied onto every row
|
||||
# (decision confirmed 2026-05-19, so each row is fully self-
|
||||
# describing for downstream queries / reports).
|
||||
if self.reading_line_ids:
|
||||
@@ -189,7 +189,7 @@ class FpThicknessUploadWizard(models.TransientModel):
|
||||
# Chatter audit
|
||||
n = len(self.reading_line_ids)
|
||||
body = (
|
||||
_('Fischerscope thickness report uploaded — %d reading(s) '
|
||||
_('Fischerscope thickness report uploaded - %d reading(s) '
|
||||
'parsed from %s.') % (n, self.file_name or 'file')
|
||||
if n else
|
||||
_('Fischerscope thickness file attached (parse returned no '
|
||||
@@ -228,7 +228,7 @@ class FpThicknessUploadWizard(models.TransientModel):
|
||||
class FpThicknessUploadWizardLine(models.TransientModel):
|
||||
"""Editable reading row in the upload wizard."""
|
||||
_name = 'fp.thickness.upload.wizard.line'
|
||||
_description = 'Thickness Upload Wizard — Reading'
|
||||
_description = 'Thickness Upload Wizard - Reading'
|
||||
_order = 'reading_number'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
@@ -240,5 +240,5 @@ class FpThicknessUploadWizardLine(models.TransientModel):
|
||||
p_percent = fields.Float(string='P %', digits=(6, 4))
|
||||
position_label = fields.Char(
|
||||
string='Position',
|
||||
help='Optional — where on the part this reading was taken.',
|
||||
help='Optional - where on the part this reading was taken.',
|
||||
)
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<strong>Couldn't parse readings.</strong>
|
||||
The file format didn't match what we recognise
|
||||
(Fischerscope XDAL 600 export). You can still save it
|
||||
as-is — the file will attach to the certificate and
|
||||
as-is - the file will attach to the certificate and
|
||||
flow into the CoC PDF as page 2, but the readings
|
||||
won't appear as queryable rows.
|
||||
</div>
|
||||
@@ -122,7 +122,7 @@
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Window action — opened from the cert form button -->
|
||||
<!-- Window action - opened from the cert form button -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="action_fp_thickness_upload_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Upload Thickness Report</field>
|
||||
|
||||
Reference in New Issue
Block a user