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>
283 lines
12 KiB
Python
283 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
#
|
|
# Phase 3 — parallel job link on fp.certificate.
|
|
# Coexists with bridge_mrp's production_id link.
|
|
#
|
|
# v19.0.6.20.0 — surface the Fischerscope PDF on the cert form so
|
|
# operators can SEE that the thickness report will be (or has been)
|
|
# merged into the CoC. The merge logic itself lives in
|
|
# fusion_plating_certificates/models/fp_certificate.py — this file
|
|
# only adds the human-readable indicators.
|
|
|
|
from odoo import api, fields, models
|
|
|
|
|
|
class FpCertificate(models.Model):
|
|
_inherit = 'fp.certificate'
|
|
|
|
x_fc_job_id = fields.Many2one(
|
|
'fp.job',
|
|
string='Work Order',
|
|
index=True,
|
|
help="Native fp.job link. Coexists with bridge_mrp's production_id.",
|
|
)
|
|
|
|
# ---- Fischerscope thickness-PDF visibility (S19) ---------------------
|
|
# These three fields are computed from the linked job's QC checks so
|
|
# the cert form can show the operator BEFORE issuing whether a
|
|
# Fischerscope report is on file and will be appended as page 2.
|
|
x_fc_thickness_qc_id = fields.Many2one(
|
|
'fusion.plating.quality.check',
|
|
string='Linked QC (Thickness)',
|
|
compute='_compute_fischer_visibility',
|
|
help='Quality check on the linked plating job that has a '
|
|
'Fischerscope / XDAL 600 thickness PDF uploaded. Used to '
|
|
'merge that PDF into the CoC on Issue.',
|
|
)
|
|
x_fc_thickness_pdf_id = fields.Many2one(
|
|
'ir.attachment',
|
|
string='Fischerscope PDF',
|
|
compute='_compute_fischer_visibility',
|
|
help='Thickness report PDF that will be appended as page 2 of '
|
|
'the CoC when the certificate is issued.',
|
|
)
|
|
x_fc_thickness_status = fields.Selection(
|
|
[
|
|
('none', 'No PDF Uploaded'),
|
|
('pending', 'Will Append on Issue'),
|
|
('merged', 'Merged into CoC'),
|
|
],
|
|
string='Thickness Report',
|
|
compute='_compute_fischer_visibility',
|
|
help='none = QC has no Fischerscope upload · '
|
|
'pending = will be appended when Issue is clicked · '
|
|
'merged = already in the issued CoC PDF',
|
|
)
|
|
|
|
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id',
|
|
'x_fc_local_thickness_pdf')
|
|
def _compute_fischer_visibility(self):
|
|
QC = self.env.get('fusion.plating.quality.check')
|
|
empty_qc = self.env['fusion.plating.quality.check'] if QC is not None else None
|
|
empty_att = self.env['ir.attachment']
|
|
for rec in self:
|
|
qc = empty_qc
|
|
pdf = empty_att
|
|
status = 'none'
|
|
# Cert-local upload wins over QC-side PDF (matches the
|
|
# merge resolution order in fp_certificate.py).
|
|
if rec.x_fc_local_thickness_pdf:
|
|
if rec.state == 'issued' and rec.attachment_id:
|
|
status = 'merged'
|
|
else:
|
|
status = 'pending'
|
|
elif QC is not None and rec.x_fc_job_id:
|
|
# Same lookup the merge method uses — passed-first,
|
|
# then any QC with a PDF.
|
|
qc = QC.sudo().search([
|
|
('job_id', '=', rec.x_fc_job_id.id),
|
|
('state', '=', 'passed'),
|
|
('thickness_report_pdf_id', '!=', False),
|
|
], order='completed_at desc', limit=1)
|
|
if not qc:
|
|
qc = QC.sudo().search([
|
|
('job_id', '=', rec.x_fc_job_id.id),
|
|
('thickness_report_pdf_id', '!=', False),
|
|
], order='create_date desc', limit=1)
|
|
if qc and qc.thickness_report_pdf_id:
|
|
pdf = qc.thickness_report_pdf_id
|
|
if rec.state == 'issued' and rec.attachment_id:
|
|
status = 'merged'
|
|
else:
|
|
status = 'pending'
|
|
rec.x_fc_thickness_qc_id = qc or empty_qc
|
|
rec.x_fc_thickness_pdf_id = pdf or empty_att
|
|
rec.x_fc_thickness_status = status
|
|
|
|
def action_view_thickness_qc(self):
|
|
"""Smart-button target — open the linked QC for inspection."""
|
|
self.ensure_one()
|
|
if not self.x_fc_thickness_qc_id:
|
|
return False
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': self.x_fc_thickness_qc_id.name,
|
|
'res_model': 'fusion.plating.quality.check',
|
|
'res_id': self.x_fc_thickness_qc_id.id,
|
|
'view_mode': 'form',
|
|
'target': 'current',
|
|
}
|
|
|
|
def action_open_job(self):
|
|
"""Smart-button target — open the linked plating job."""
|
|
self.ensure_one()
|
|
if not self.x_fc_job_id:
|
|
return False
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': self.x_fc_job_id.name,
|
|
'res_model': 'fp.job',
|
|
'res_id': self.x_fc_job_id.id,
|
|
'view_mode': 'form',
|
|
'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),
|
|
))
|