This commit is contained in:
gsinghpal
2026-05-21 03:37:25 -04:00
parent b2f483d67c
commit 1314f4581d
47 changed files with 5730 additions and 177 deletions

View File

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

View File

@@ -1540,6 +1540,23 @@ class FpJob(models.Model):
# qty tracking truly doesn't apply).
skip_qty_gate = self.env.context.get('fp_skip_qty_reconcile')
if not skip_qty_gate and job.qty:
# Smooth the typical "clean close" case so the operator
# doesn't have to manually type qty_done = ordered_qty
# every time. Conditions for safe auto-fill:
# - operator has NOT recorded any scrap or done qty
# (so we're not overriding their explicit entry)
# - the receiving closed with matching qty (parts
# physically came in as expected)
# - no visual-inspection rejects recorded
# When any of those fail, fall through to the gate so
# the operator reconciles by hand. Mirrors the receiving
# `_update_job_qty_received` pattern: server fills the
# obvious default, operator owns the edge cases.
if (not job.qty_done and not job.qty_scrapped
and not (job.qty_visual_inspection_rejects or 0)
and job.qty_received
and abs(job.qty_received - job.qty) < 0.0001):
job.qty_done = job.qty
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
if abs(accounted - job.qty) > 0.0001:
raise UserError(_(

View File

@@ -17,7 +17,7 @@
* onSave → /fp/record_inputs/commit → advance step (optional)
*/
import { Component, onWillStart, useState } from "@odoo/owl";
import { Component, markup, onWillStart, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
@@ -106,7 +106,10 @@ export class FpRecordInputsDialog extends Component {
this.state.jobName = data.job.name;
this.state.recipeRootId = data.recipe_root_id || false;
this.state.userInitials = data.user_initials || "";
this.state.instructionsHtml = data.instructions_html || "";
// `t-out` only renders unescaped HTML when the value is a
// `markup()`-tagged string — otherwise it shows literal tags
// (e.g. `<p>foo</p>`). See CLAUDE.md "OWL `t-out` escapes".
this.state.instructionsHtml = markup(data.instructions_html || "");
this.state.instructionImages = data.instruction_images || [];
const nowDt = this._fpNowForDatetimeLocal();
this.state.rows = data.prompts.map((p) => {

View File

@@ -76,12 +76,21 @@
</div>
</xpath>
<!-- 3. Add a Thickness Report tab right next to the -->
<!-- Certificate PDF tab so operator can preview the -->
<!-- Fischerscope file before merging into the cert. -->
<!-- 3. Thickness Report tab — single place to see/edit
every Fischerscope-related field on the cert.
Reorganized 2026-05-21:
* Status + linked QC at the top (read-only context)
* XDAL 600 metadata (operator/product/etc.) editable
so manager can correct OCR mistakes
* Microscope image preview (auto-extracted from RTF
or manually uploaded — either way editable here)
* Source files (PDF / non-PDF evidence / source name)
* Upload wizard button + help text -->
<xpath expr="//notebook/page[@name='pdf']" position="after">
<page string="Thickness Report (Fischerscope)"
name="thickness_pdf">
<!-- Status + QC link (read-only context) -->
<group>
<field name="x_fc_thickness_status" widget="badge"
readonly="1"
@@ -90,12 +99,33 @@
decoration-success="x_fc_thickness_status == 'merged'"/>
<field name="x_fc_thickness_qc_id" readonly="1"
invisible="not x_fc_thickness_qc_id"/>
<field name="x_fc_thickness_pdf_id" readonly="1"
widget="many2one_binary"
invisible="not x_fc_thickness_pdf_id"/>
</group>
<separator string="Upload Fischerscope Report"/>
<div class="oe_button_box">
<!-- Hints rotate by state -->
<div class="text-muted"
invisible="x_fc_thickness_status != 'none'">
<p>
No Fischerscope thickness data has been
uploaded yet. Click <strong>Upload Thickness
Report</strong> below to drop a `.doc` / `.docx`
/ `.rtf` / `.pdf` file straight from the
XDAL&#160;600. The wizard parses readings +
metadata and fills out the fields on this tab.
</p>
</div>
<div class="text-muted"
invisible="x_fc_thickness_status != 'pending'">
<p>
<i class="fa fa-arrow-up"/>
Click <strong>Issue</strong> in the header to
merge the Fischerscope PDF as page&#160;2 of
the CoC. Readings will render inline in the
body of the cert either way.
</p>
</div>
<!-- Upload wizard CTA -->
<div style="margin: 8px 0;">
<button name="%(fusion_plating_certificates.action_fp_thickness_upload_wizard)d"
type="action"
class="btn-primary"
@@ -103,44 +133,65 @@
context="{'default_certificate_id': id}"
invisible="state != 'draft'"/>
</div>
<div class="text-muted">
<p>
Drop the <code>.docx</code> or <code>.pdf</code>
file straight from the Fischerscope&#160;XDAL&#160;600.
The wizard reads the readings, calibration set,
and operator info, lets you review them, and
attaches the original file to this certificate.
</p>
</div>
<separator string="Attached File"
invisible="not x_fc_local_thickness_pdf"/>
<group invisible="not x_fc_local_thickness_pdf">
<field name="x_fc_local_thickness_pdf"
filename="x_fc_local_thickness_pdf_filename"
readonly="1"/>
<field name="x_fc_local_thickness_pdf_filename"
invisible="1"/>
<separator string="XDAL 600 Measurement Context"/>
<p class="text-muted small">
These values are pulled from the uploaded file
and printed on the CoC's thickness section. Edit
any field here to override what the parser saw.
</p>
<group>
<group>
<field name="x_fc_thickness_equipment"
placeholder="Fischerscope XDAL 600"/>
<field name="x_fc_thickness_operator"
placeholder="Operator initials / name"/>
<field name="x_fc_thickness_datetime"/>
<field name="x_fc_thickness_measuring_time_sec"/>
</group>
<group>
<field name="x_fc_thickness_product"
placeholder="e.g. 2805031 / NiP/Al-alloys 2805030"/>
<field name="x_fc_thickness_application"
placeholder="e.g. 16 / NiP/Al-alloys"/>
<field name="x_fc_thickness_directory"
placeholder="XDAL save directory"/>
<field name="x_fc_thickness_source_filename"
readonly="1"/>
</group>
</group>
<separator string="Microscope Image"/>
<p class="text-muted small">
Auto-extracted from RTF uploads (via libwmf) or
manually uploaded via the wizard. Drop a new
PNG/JPEG here to override.
</p>
<group>
<field name="x_fc_thickness_image_id"
options="{'no_create': True}"/>
</group>
<separator string="Source Files"/>
<group>
<group string="Fischerscope PDF"
invisible="not x_fc_local_thickness_pdf">
<field name="x_fc_local_thickness_pdf"
filename="x_fc_local_thickness_pdf_filename"/>
<field name="x_fc_local_thickness_pdf_filename"
invisible="1"/>
</group>
<group string="Non-PDF Evidence (RTF/DOCX)"
invisible="not x_fc_local_thickness_evidence_id">
<field name="x_fc_local_thickness_evidence_id"
options="{'no_create': True}"/>
</group>
<group string="QC-side Fischerscope PDF"
invisible="not x_fc_thickness_pdf_id">
<field name="x_fc_thickness_pdf_id" readonly="1"
widget="many2one_binary"/>
</group>
</group>
<div class="text-muted"
invisible="x_fc_thickness_status != 'none'">
<p>
No Fischerscope thickness PDF has been
uploaded yet. The CoC will be issued without
an appended thickness report. Either drop the
PDF into the upload field above, OR upload it
on the linked QC check and re-open this cert.
</p>
</div>
<div class="text-muted"
invisible="x_fc_thickness_status != 'pending'">
<p>
<i class="fa fa-arrow-up" title="Action"
aria-label="Action"/>
Click <strong>Issue</strong> in the header
and the Fischerscope PDF will be merged into
page&#160;2 of the CoC.
</p>
</div>
</page>
</xpath>

View File

@@ -21,13 +21,26 @@ Issue button on the cert form, which stays as the fallback path.
import base64
import io
import logging
import os
import re
import shutil
import subprocess
import tempfile
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Minimum pixel-area for an extracted RTF image to be treated as the
# "microscope photo" candidate. Filters out narrow header banners
# (~790x203 = 160k pixels) while keeping standard XDAL exports
# (~1024x768 = 786k). See CLAUDE.md "entech apt is broken" for the
# libwmf install path that makes this possible.
_FP_RTF_IMAGE_MIN_AREA = 200_000
# Fischerscope XDAL 600 reading line, e.g.
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
@@ -38,10 +51,206 @@ _FISCHER_READING_RE = re.compile(
r'\s+P\s+\d+\s*=\s*([\d.]+)\s*%',
re.IGNORECASE,
)
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+)', re.IGNORECASE)
# Capture every {\pict ... \wmetafile8 ...hex...} group in an RTF, in
# document order. The hex blob can be interspersed with whitespace
# (RTF wraps to 80 cols) — the consumer strips it.
_RTF_PICT_WMF_RE = re.compile(
r'\{\\pict'
r'(?:\\[a-zA-Z]+-?\d*\s?)*?'
r'\\wmetafile8'
r'(?:\\[a-zA-Z]+-?\d*\s?)*'
r'\s*([0-9a-fA-F\s]+?)'
r'\}',
re.DOTALL,
)
def _fp_extract_rtf_images(raw_bytes):
"""Pull all WMF picture blocks out of an RTF, unpack to PNG via
libwmf, and return the list of PNG bytes in document order.
XDAL 600 RTF exports embed each picture as a WMF metafile wrapping
the actual raster. ImageMagick on Debian Bookworm doesn't carry a
WMF delegate, so we shell out to `wmf2svg` (from libwmf-bin) — it
writes a thin SVG and a side-file `*-N.png` per raster block. We
keep the PNGs, drop the SVG/WMF temp files.
Returns [] (not raise) on any tooling/parse failure; the cert
issue keeps working even when image extraction can't run.
"""
if not raw_bytes:
return []
try:
text = raw_bytes.decode('latin-1', errors='replace')
except Exception:
return []
blobs = []
for m in _RTF_PICT_WMF_RE.finditer(text):
hex_blob = re.sub(r'\s+', '', m.group(1))
try:
blobs.append(bytes.fromhex(hex_blob))
except ValueError:
continue
if not blobs:
return []
tmpdir = tempfile.mkdtemp(prefix='fp_rtf_wmf_')
pngs = []
try:
for i, wmf in enumerate(blobs):
wmf_path = os.path.join(tmpdir, 'pict%d.wmf' % i)
svg_path = os.path.join(tmpdir, 'pict%d.svg' % i)
with open(wmf_path, 'wb') as fh:
fh.write(wmf)
try:
subprocess.run(
['wmf2svg', '-o', svg_path, wmf_path],
capture_output=True, timeout=20, check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
_logger.warning(
'wmf2svg unavailable or timed out (%s) — skipping '
'RTF image extraction.', e,
)
return []
# wmf2svg writes <basename>-N.png next to the SVG.
for fn in sorted(os.listdir(tmpdir)):
if fn.startswith('pict%d-' % i) and fn.endswith('.png'):
full = os.path.join(tmpdir, fn)
with open(full, 'rb') as fh:
pngs.append(fh.read())
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
return pngs
def _fp_pick_microscope_image(png_bytes_list):
"""Pick the largest-area PNG (by pixel count, not file size) from
the list — that's almost always the microscope photo. Header
banners are wide-but-thin so their pixel area falls below the
threshold. Returns (png_bytes, width, height) or (None, 0, 0)
when no PNG meets the threshold.
"""
try:
from PIL import Image
except ImportError:
# Pillow ships with Odoo; this is defensive.
return (png_bytes_list[0] if png_bytes_list else None, 0, 0)
best = None
best_area = 0
for png in png_bytes_list:
try:
with Image.open(io.BytesIO(png)) as im:
area = im.width * im.height
if area > best_area and area >= _FP_RTF_IMAGE_MIN_AREA:
best = (png, im.width, im.height)
best_area = area
except Exception:
continue
return best or (None, 0, 0)
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_OPERATOR_RE = re.compile(r'Operator:\s*(\S+)', re.IGNORECASE)
_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
_FISCHER_TIME_RE = re.compile(r'Time:\s*([\d:]+\s*[APMapm]*)')
# XDAL 600 header lines — only present on full RTF reports (not on
# the .docx body the upstream parser already handled).
_FISCHER_PRODUCT_RE = re.compile(r'Product:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_DIRECTORY_RE = re.compile(r'Directory:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_APPLICATION_RE = re.compile(r'Application:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_MTIME_RE = re.compile(r'Measuring\s+time\s+(\d+)\s*sec', re.IGNORECASE)
_FISCHER_EQUIPMENT_RE = re.compile(r'(Fischerscope[^\r\n]*XDAL\s*\d+)', re.IGNORECASE)
def _fp_strip_rtf(raw_bytes):
"""Best-effort RTF → plain text. RTF is text-based with control
words prefixed by `\\` and groups wrapped in `{}`. We need to strip
all of those plus the hex-encoded image data so the Fischerscope
reading regex hits clean text.
Not a full parser — meant for the narrow case of XRF/XDAL reports
that have a simple body wrapped around an embedded WMF image.
"""
if not raw_bytes:
return ''
# RTF is ASCII-safe; latin-1 round-trips every byte.
text = raw_bytes.decode('latin-1', errors='replace')
# Drop destination groups entirely — these are the image data,
# font tables, color tables, etc. The pattern `{\* ...}` and other
# nested destinations carry binary-ish hex strings we never want.
text = re.sub(r'\{\\\*[^{}]*\}', ' ', text)
text = re.sub(r'\{\\fonttbl[^{}]*\}', ' ', text)
text = re.sub(r'\{\\colortbl[^{}]*\}', ' ', text)
# Pictures: {\pict ...} contains hex image data. The body is the
# part between `\pict...goal\d+` and the closing brace of the group.
# Easier: nuke anything matching the picture marker through the
# next closing brace at the same depth (single-level approximation
# — works for FedEx/XRF docs that have one image per pict block).
text = re.sub(r'\{\\pict[^{}]*\}', ' ', text)
# Remove control words like \rtf1, \ansicpg1252, \par, \tab,
# \tx2840, etc. (`\` + letters + optional digits + optional space)
text = re.sub(r'\\[A-Za-z]+-?\d*\s?', ' ', text)
# Hex escapes (e.g. \'ae for special chars)
text = re.sub(r"\\'[0-9a-fA-F]{2}", ' ', text)
# Other backslash escapes (`\\`, `\{`, `\}`)
text = re.sub(r'\\[^A-Za-z\s]', ' ', text)
# Strip remaining braces
text = text.replace('{', ' ').replace('}', ' ')
# Collapse runs of whitespace so the Fischerscope regex doesn't
# have to deal with weird spacing artefacts from the strip pass.
text = re.sub(r'[ \t]+', ' ', text)
return text
def _fp_parse_fischerscope_rtf(raw_bytes):
"""Fischerscope XDAL 600 RTF export → same dict shape as the
.docx parser. RTF detection is by magic bytes (`{\\rtf`) — the
XRF software names the file `.doc` for legacy reasons, but the
contents are RTF.
"""
empty = {
'readings': [], 'calibration': '', 'operator': '',
'date_str': '', 'time_str': '',
'product': '', 'directory': '', 'application': '',
'measuring_time_sec': 0, 'equipment': '',
'raw_text': '',
}
if not raw_bytes:
return empty
text = _fp_strip_rtf(raw_bytes)
readings = []
for m in _FISCHER_READING_RE.finditer(text):
try:
readings.append((
float(m.group(2)),
float(m.group(3)),
float(m.group(4)),
))
except ValueError:
continue
def _grab(rx):
m = rx.search(text)
return m.group(1).strip() if m else ''
mtime = 0
m = _FISCHER_MTIME_RE.search(text)
if m:
try:
mtime = int(m.group(1))
except ValueError:
mtime = 0
return {
'readings': readings,
'calibration': _grab(_FISCHER_CALIB_RE),
'operator': _grab(_FISCHER_OPERATOR_RE),
'date_str': _grab(_FISCHER_DATE_RE),
'time_str': _grab(_FISCHER_TIME_RE),
'product': _grab(_FISCHER_PRODUCT_RE),
'directory': _grab(_FISCHER_DIRECTORY_RE),
'application': _grab(_FISCHER_APPLICATION_RE),
'measuring_time_sec': mtime,
'equipment': _grab(_FISCHER_EQUIPMENT_RE),
'raw_text': text,
}
def _fp_parse_fischerscope_docx(raw_bytes):
@@ -227,6 +436,14 @@ class FpCertIssueWizardLine(models.TransientModel):
)
fischer_file = fields.Binary(string='Fischerscope File (PDF or .docx)')
fischer_filename = fields.Char(string='Filename')
# Optional: microscope/coupon image exported separately from the
# XDAL 600. The RTF carries an embedded WMF that the entech host
# can't rasterize (no imagemagick/libwmf — see CLAUDE.md "entech
# apt is in a broken-deps state"), so the operator exports a PNG
# from the XDAL software and uploads it here. Rendered inline on
# the CoC's thickness section when present.
fischer_image_file = fields.Binary(string='Measurement Image (PNG/JPEG)')
fischer_image_filename = fields.Char(string='Image Filename')
parsed_summary = fields.Text(
string='Parsed Summary', readonly=True,
help='Output of the .docx parser. Populated when you attach a '
@@ -274,22 +491,29 @@ class FpCertIssueWizardLine(models.TransientModel):
@api.onchange('fischer_file', 'fischer_filename')
def _onchange_fischer_file(self):
"""Try to parse .docx on upload; prefill the readings + summary."""
"""Parse .docx OR RTF on upload (XDAL 600 names RTF files
`.doc` — detected by magic bytes; see CLAUDE.md "Fischerscope
XDAL 600 `.doc` files are actually RTF"). Prefill the readings
+ summary so the operator can verify before issuing."""
if not self.fischer_file:
return
name = (self.fischer_filename or '').lower()
if not name.endswith('.docx'):
self.parsed_summary = _(
'Non-.docx upload (%s) — file will be attached as '
'evidence. Type readings manually below if needed.'
) % (self.fischer_filename or 'unnamed')
return
try:
raw = base64.b64decode(self.fischer_file)
except Exception:
self.parsed_summary = _('Could not decode the uploaded file.')
return
parsed = _fp_parse_fischerscope_docx(raw)
name = (self.fischer_filename or '').lower()
is_rtf = raw[:5] == b'{\\rtf' or name.endswith('.rtf')
if is_rtf:
parsed = _fp_parse_fischerscope_rtf(raw)
elif name.endswith('.docx'):
parsed = _fp_parse_fischerscope_docx(raw)
else:
self.parsed_summary = _(
'Non-parseable upload (%s) — file will be attached as '
'evidence. Type readings manually below if needed.'
) % (self.fischer_filename or 'unnamed')
return
readings = parsed.get('readings') or []
if readings:
self.reading_line_ids = [(5, 0, 0)] + [
@@ -312,15 +536,70 @@ class FpCertIssueWizardLine(models.TransientModel):
't': parsed.get('time_str') or '',
}
def _write_thickness_metadata_to_cert(self, cert, parsed):
"""Persist the Fischerscope header block (operator, product,
application, equipment, measuring time, date/time, source
filename) onto the cert so the CoC report can render a full
report block instead of a bare readings table.
"""
vals = {}
field_map = (
('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.fischer_filename or ''),
)
for fname, fval in field_map:
if fname in cert._fields and fval:
vals[fname] = fval
# Combine the gauge's date+time and parse to Datetime — try a
# few formats since XDAL exports vary (12h vs 24h, with/without
# seconds). Best-effort: leave the field blank if no format
# matches rather than crashing the cert issue.
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 cert._fields:
from datetime import datetime
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
if vals:
cert.write(vals)
def _apply_to_cert(self):
"""Write this line's data into the cert."""
"""Write this line's data into the cert.
Order matters: operator-uploaded PNG must run LAST so it wins
over any image the RTF auto-extraction picked. Reverse order
(PNG first, then RTF) lets the WMF blow away the explicit
operator choice — exactly the bug we just hit.
"""
self.ensure_one()
cert = self.cert_id.sudo()
if not self.fischer_file:
# Just push manual readings, if any.
self._push_readings_to_cert()
# PNG-only path: still attach the operator's image upload.
self._apply_image_to_cert(cert)
return
name = (self.fischer_filename or 'fischerscope').lower()
calibration = '' # backfilled below if the parser hits
if name.endswith('.pdf'):
# Drop the PDF into the cert-local field — merges into page 2.
cert.write({
@@ -328,23 +607,107 @@ class FpCertIssueWizardLine(models.TransientModel):
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
})
else:
# .doc / .docx / anything else — attach as evidence.
self.env['ir.attachment'].sudo().create({
# .doc / .docx / anything else — attach as evidence AND
# link the attachment to the cert's evidence slot so the
# thickness-required gate recognises it. Without the link,
# the gate would still raise (it checks specific fields,
# not stray attachments) and rolling back the transaction
# would orphan the upload.
att = self.env['ir.attachment'].sudo().create({
'name': self.fischer_filename or 'fischerscope-report',
'type': 'binary',
'datas': self.fischer_file,
'res_model': 'fp.certificate',
'res_id': cert.id,
})
cert.message_post(body=_(
if 'x_fc_local_thickness_evidence_id' in cert._fields:
cert.write({'x_fc_local_thickness_evidence_id': att.id})
# Re-parse the file at apply time so the report-header
# metadata (operator, product, application, etc.) makes it
# onto the cert. Onchange populates reading_line_ids but
# not the cert-level fields. Best-effort: any parse hiccup
# is logged and we still complete the attachment + readings.
try:
raw = base64.b64decode(self.fischer_file)
is_rtf = raw[:5] == b'{\\rtf'
if is_rtf:
parsed = _fp_parse_fischerscope_rtf(raw)
elif name.endswith('.docx'):
parsed = _fp_parse_fischerscope_docx(raw)
else:
parsed = None
if parsed:
self._write_thickness_metadata_to_cert(cert, parsed)
calibration = parsed.get('calibration') or ''
# WMF image extraction is RTF-only (the .docx path
# uses python-docx which already gives PIL-readable
# bitmaps; that flow can be added later if needed).
if is_rtf and 'x_fc_thickness_image_id' in cert._fields:
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.fischer_filename or 'fischerscope')
.rsplit('.', 1)[0]
),
'type': 'binary',
'datas': base64.b64encode(img_bytes),
'mimetype': 'image/png',
'res_model': 'fp.certificate',
'res_id': cert.id,
})
cert.write({
'x_fc_thickness_image_id': img_att.id,
})
_logger.info(
'Cert %s: attached microscope image '
'(%dx%d, %d bytes)',
cert.name, img_w, img_h, len(img_bytes),
)
except Exception as exc:
_logger.warning(
'Cert %s: Fischerscope metadata extraction failed: %s',
cert.name, exc,
)
cert.message_post(body=Markup(_(
'Fischerscope file <b>%s</b> attached via Issue wizard.'
) % (self.fischer_filename or 'unnamed'))
self._push_readings_to_cert()
)) % (self.fischer_filename or 'unnamed'))
self._push_readings_to_cert(calibration=calibration)
# Operator's PNG upload wins over auto-extracted WMF — runs
# last so it overwrites x_fc_thickness_image_id if both paths
# supplied an image.
self._apply_image_to_cert(cert)
def _push_readings_to_cert(self):
def _apply_image_to_cert(self, cert):
"""Attach the operator-uploaded PNG/JPEG and link it to the
cert's image slot so the CoC report can render it inline.
No-op when nothing was uploaded. Mirrors the evidence-file
pattern: file is attached as a regular ir.attachment AND
linked to the dedicated field so the report template can
find it predictably.
"""
self.ensure_one()
if not self.fischer_image_file or \
'x_fc_thickness_image_id' not in cert._fields:
return
att = self.env['ir.attachment'].sudo().create({
'name': self.fischer_image_filename or 'thickness-image.png',
'type': 'binary',
'datas': self.fischer_image_file,
'res_model': 'fp.certificate',
'res_id': cert.id,
})
cert.write({'x_fc_thickness_image_id': att.id})
def _push_readings_to_cert(self, calibration=''):
"""Create fp.thickness.reading rows on the cert from wizard rows.
Skips when no rows. Does not deduplicate against existing
readings — the manager has just told us this is the new data."""
readings — the manager has just told us this is the new data.
Per-reading calibration_std_ref is stamped from the optional
`calibration` arg so the printed CoC's calibration line stays
accurate even when readings are re-pushed from a fresh upload.
"""
self.ensure_one()
Reading = self.env.get('fp.thickness.reading')
if Reading is None or not self.reading_line_ids:
@@ -358,6 +721,8 @@ class FpCertIssueWizardLine(models.TransientModel):
}
if 'reading_number' in Reading._fields:
vals['reading_number'] = r.sequence
if calibration and 'calibration_std_ref' in Reading._fields:
vals['calibration_std_ref'] = calibration
Reading.sudo().create(vals)

View File

@@ -93,6 +93,23 @@
<field name="fischer_filename"
invisible="1"/>
</group>
<group string="Measurement Image (Optional)"
invisible="not needs_thickness">
<field name="fischer_image_file"
filename="fischer_image_filename"
widget="image"
options="{'size': [200, 200]}"/>
<field name="fischer_image_filename"
invisible="1"/>
<div colspan="2" class="text-muted small">
Drop a PNG/JPEG of the coupon
under the XRF probe (export
from the XDAL 600 software's
Image menu). Rendered inline on
the printed CoC so the customer
sees the actual measurement.
</div>
</group>
<div class="alert alert-info"
role="alert"
invisible="not needs_thickness or not parsed_summary">