fix(plating): parse Fischerscope .doc/.docx/RTF dropped on the cert form

The cert form's x_fc_local_thickness_pdf field only stored the upload; only
the Issue Certs wizard parsed it. Add create/write hooks on the jobs-side
fp.certificate that, when a NON-PDF is written to that field, run the wizard's
parser: readings -> thickness_reading_ids, header metadata -> x_fc_thickness_*,
microscope image (RTF) -> x_fc_thickness_image_id, then relocate the source to
x_fc_local_thickness_evidence_id and clear the PDF field (mirrors the wizard's
non-PDF end state). Real PDFs pass through untouched for the page-2 merge.
Re-entry guarded via the fp_skip_thickness_parse context flag. Bump jobs
19.0.11.3.0.

Deployed + verified on entech: CoC-30065 (.doc) back-filled to 3 readings +
metadata (operator BK) + extracted microscope image, renders inline (242KB);
PDF cert CoC-30040-02 correctly left untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-28 23:01:02 -04:00
parent 6a5364e053
commit cd0c08f348
2 changed files with 158 additions and 1 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.11.2.0', 'version': '19.0.11.3.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',

View File

@@ -123,3 +123,160 @@ class FpCertificate(models.Model):
'view_mode': 'form', 'view_mode': 'form',
'target': 'current', 'target': 'current',
} }
# ---- Parse-on-upload for the cert-form Fischerscope field (2026-05-28)
# The Issue Certs wizard parses .doc/.docx/RTF Fischerscope exports into
# readings + metadata + microscope image. Dropping the same file straight
# onto the cert form's x_fc_local_thickness_pdf field did nothing — it
# just stored the bytes. These hooks give the form the SAME behaviour as
# the wizard: on save, a non-PDF upload is parsed and relocated to the
# evidence field (a real PDF is left in place to merge as page 2).
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
for rec in recs:
if (rec.x_fc_local_thickness_pdf
and not self.env.context.get('fp_skip_thickness_parse')):
rec._fp_parse_local_thickness_upload()
return recs
def write(self, vals):
res = super().write(vals)
if (vals.get('x_fc_local_thickness_pdf')
and not self.env.context.get('fp_skip_thickness_parse')):
for rec in self:
rec._fp_parse_local_thickness_upload()
return res
def _fp_parse_local_thickness_upload(self):
"""Parse a Fischerscope .doc/.docx/RTF dropped on the cert form's
x_fc_local_thickness_pdf field, exactly like the Issue Certs wizard:
extract readings → thickness_reading_ids, header metadata →
x_fc_thickness_* fields, microscope image → x_fc_thickness_image_id,
then relocate the non-PDF source to x_fc_local_thickness_evidence_id
and clear the PDF field (so the page-2 merge doesn't choke on it).
A real PDF is left in place — it merges as page 2 of the CoC on
Issue and carries no parseable readings. Unknown non-PDF types are
left untouched.
"""
import base64
from datetime import datetime
self.ensure_one()
if not self.x_fc_local_thickness_pdf:
return
try:
raw = base64.b64decode(self.x_fc_local_thickness_pdf)
except Exception:
return
# Real PDF → leave it (merges as page 2). XDAL 600 names RTF files
# ".doc"; detect by magic bytes, not extension (see CLAUDE.md).
if raw[:4] == b'%PDF':
return
name = (self.x_fc_local_thickness_pdf_filename or '').lower()
is_rtf = raw[:5] == b'{\\rtf'
is_docx = name.endswith('.docx')
if not (is_rtf or is_docx):
return # unknown non-PDF — don't guess
from ..wizards.fp_cert_issue_wizard import (
_fp_parse_fischerscope_rtf, _fp_parse_fischerscope_docx,
_fp_extract_rtf_images, _fp_pick_microscope_image,
)
parsed = (_fp_parse_fischerscope_rtf(raw) if is_rtf
else _fp_parse_fischerscope_docx(raw))
vals = {}
for fname, fval in (
('x_fc_thickness_operator', parsed.get('operator')),
('x_fc_thickness_product', parsed.get('product')),
('x_fc_thickness_directory', parsed.get('directory')),
('x_fc_thickness_application', parsed.get('application')),
('x_fc_thickness_measuring_time_sec',
parsed.get('measuring_time_sec') or 0),
('x_fc_thickness_equipment',
parsed.get('equipment') or 'Fischerscope XDAL 600'),
('x_fc_thickness_source_filename',
self.x_fc_local_thickness_pdf_filename or ''),
):
if fname in self._fields and fval:
vals[fname] = fval
date_str = (parsed.get('date_str') or '').strip()
time_str = (parsed.get('time_str') or '').strip()
if date_str and 'x_fc_thickness_datetime' in self._fields:
combined = ('%s %s' % (date_str, time_str)).strip()
for fmt in (
'%m/%d/%Y %I:%M:%S %p', '%m/%d/%Y %I:%M %p',
'%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M', '%m/%d/%Y',
):
try:
vals['x_fc_thickness_datetime'] = datetime.strptime(
combined, fmt,
)
break
except ValueError:
continue
# Readings — replace any existing set with the freshly-parsed rows
# (the uploaded report is authoritative for this cert).
readings = parsed.get('readings') or []
Reading = self.env.get('fp.thickness.reading')
if readings and Reading is not None:
calibration = parsed.get('calibration') or ''
cmds = [(5, 0, 0)]
for i, (nip, ni, p) in enumerate(readings):
rvals = {'nip_mils': nip, 'ni_percent': ni, 'p_percent': p}
if 'reading_number' in Reading._fields:
rvals['reading_number'] = i + 1
if calibration and 'calibration_std_ref' in Reading._fields:
rvals['calibration_std_ref'] = calibration
cmds.append((0, 0, rvals))
vals['thickness_reading_ids'] = cmds
# Relocate the non-PDF source to the evidence slot + clear the PDF
# field (mirrors the wizard's non-PDF end state).
att = self.env['ir.attachment'].sudo().create({
'name': self.x_fc_local_thickness_pdf_filename or 'fischerscope-report',
'type': 'binary',
'datas': self.x_fc_local_thickness_pdf,
'res_model': self._name,
'res_id': self.id,
})
if 'x_fc_local_thickness_evidence_id' in self._fields:
vals['x_fc_local_thickness_evidence_id'] = att.id
vals['x_fc_local_thickness_pdf'] = False
vals['x_fc_local_thickness_pdf_filename'] = False
# Microscope image (RTF only — .docx images need a different path).
if is_rtf and 'x_fc_thickness_image_id' in self._fields:
try:
pngs = _fp_extract_rtf_images(raw)
img_bytes, img_w, img_h = _fp_pick_microscope_image(pngs)
if img_bytes:
img_att = self.env['ir.attachment'].sudo().create({
'name': '%s-microscope.png' % (
(self.x_fc_local_thickness_pdf_filename
or 'fischerscope').rsplit('.', 1)[0]
),
'type': 'binary',
'datas': base64.b64encode(img_bytes),
'mimetype': 'image/png',
'res_model': self._name,
'res_id': self.id,
})
vals['x_fc_thickness_image_id'] = img_att.id
except Exception:
pass
self.with_context(fp_skip_thickness_parse=True).write(vals)
from markupsafe import Markup
from odoo import _
self.message_post(body=Markup(_(
'Fischerscope file <b>%s</b> parsed from the cert form: '
'%d reading(s) extracted.'
)) % (
self.x_fc_thickness_source_filename or name or 'unnamed',
len(readings),
))