feat(certificates): Fischerscope thickness-report upload wizard
Operators now drop a .docx or .pdf Fischerscope XDAL 600 export
on the cert form's Thickness Report tab. The wizard parses the
readings, calibration std, operator + date metadata, and the
embedded microscope image, then shows them for review before
recording on fp.certificate.
Operator Wizard Certificate
─────────────────────────────────────────────────────────────
Click "Upload Parse .docx / - thickness_reading_ids
Thickness .pdf → written (3 rows)
Report" Show 3 readings - x_fc_local_thickness
Pick file + metadata _pdf attached (original
Click Parse Click Save file)
- microscope image as
ir.attachment on cert
- chatter post
─────────────────────────────────────────────────────────────
When parse can't find readings (unrecognised format), wizard falls
through to manual state — operator can still save, file lands on
the cert as-is for the existing CoC page-2 merge logic.
Closes the gap in the S19 enforcement: x_fc_send_thickness_report
customers blocked at action_issue until the file is on file. Now
they have a parseable upload UX, not just a bare Binary field.
Architecture
- fischerscope_parser.py: pure-Python lib, branches on extension,
python-docx + PyPDF2 already on entech (no new deps). Regex
extraction returns {readings, metadata, image, errors}.
- fp.thickness.upload.wizard: TransientModel with upload/review/
manual states. Lazy-imports parser at action_parse time to dodge
Python 3.11 partial-init relative-import error.
- 27 tests (TestFischerscopeParser 9 + TestThicknessUploadWizard 8
+ the rehoused TestActionIssueGates 10) — all green on entech.
Same metadata copies onto every reading row, microscope image
attaches once at cert level (decisions 2026-05-19).
Drive-by fixes uncovered while running tests on entech:
- fp.certificate.action_issue: guard rec.company_id access with
field-existence check. Lazy-fill-signer branch crashed when
certified_by_id was unset on certs that don't carry a company_id
field. Pre-existing bug that never fired in production because
jobs auto-fill certified_by_id before reaching this branch.
- test_action_issue_gates: set x_fc_send_thickness_report=False on
the test partner. Field defaults to True so every cert in this
class hit the thickness gate; tests were never able to verify
the other gates in isolation.
- Tests directory missing test_action_issue_gates.py on entech.
Synced; turns out the 2026-05-18 "changes" commit added the file
locally but the deploy script never copied tests/.
Module: fusion_plating_certificates 19.0.6.4.0 → 19.0.7.0.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_cert_void_wizard
|
||||
from . import fp_thickness_upload_wizard
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Thickness-report upload wizard. Operator picks a Fischerscope export
|
||||
# (.docx or .pdf); the wizard parses readings + metadata via the
|
||||
# fischerscope_parser library, shows the result for review, and on Save
|
||||
# writes per-reading rows into fp.thickness.reading + stores the
|
||||
# original file in fp.certificate.x_fc_local_thickness_pdf.
|
||||
#
|
||||
# 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
|
||||
# file as-is (attach-only fallback). Either way the file ends up in
|
||||
# place to satisfy the action_issue thickness gate.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
# 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
|
||||
# raises "cannot import name from partially initialized module"). The
|
||||
# parser is referenced once inside action_parse so deferring is fine.
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpThicknessUploadWizard(models.TransientModel):
|
||||
"""Upload + parse a Fischerscope thickness report onto a certificate."""
|
||||
_name = 'fp.thickness.upload.wizard'
|
||||
_description = 'Thickness Report Upload Wizard'
|
||||
|
||||
certificate_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', required=True, ondelete='cascade',
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='certificate_id.partner_id', string='Customer', readonly=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
[('upload', 'Upload file'),
|
||||
('review', 'Review parsed readings'),
|
||||
('manual', 'Parse failed — attach only')],
|
||||
default='upload', required=True,
|
||||
)
|
||||
|
||||
# File ----------------------------------------------------------------
|
||||
file_data = fields.Binary(string='Fischerscope Report', required=True)
|
||||
file_name = fields.Char(string='File Name')
|
||||
|
||||
# Parsed metadata (readonly after parse) ------------------------------
|
||||
parsed_equipment_model = fields.Char(string='Equipment', readonly=True)
|
||||
parsed_product_ref = fields.Char(string='Product Ref', readonly=True)
|
||||
parsed_calibration_std_ref = fields.Char(string='Calibration Std', readonly=True)
|
||||
parsed_measuring_time_seconds = fields.Integer(
|
||||
string='Measuring Time (sec)', readonly=True,
|
||||
)
|
||||
parsed_operator_name = fields.Char(string='Operator', readonly=True)
|
||||
parsed_reading_datetime = fields.Datetime(
|
||||
string='Reading Date/Time', readonly=True,
|
||||
)
|
||||
|
||||
# Image preview -------------------------------------------------------
|
||||
parsed_image = fields.Binary(string='Microscope Image', readonly=True)
|
||||
parsed_image_mime = fields.Char(readonly=True)
|
||||
|
||||
# Editable reading rows -----------------------------------------------
|
||||
reading_line_ids = fields.One2many(
|
||||
'fp.thickness.upload.wizard.line', 'wizard_id', string='Readings',
|
||||
)
|
||||
|
||||
# Parse status --------------------------------------------------------
|
||||
parse_messages = fields.Text(string='Parser notes', readonly=True)
|
||||
reading_count = fields.Integer(string='Parsed Readings', readonly=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions
|
||||
# ------------------------------------------------------------------
|
||||
def action_parse(self):
|
||||
"""Run the parser; populate metadata + reading_line_ids."""
|
||||
self.ensure_one()
|
||||
if not self.file_data:
|
||||
raise UserError(_('Pick a file before parsing.'))
|
||||
try:
|
||||
content = base64.b64decode(self.file_data)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise UserError(_('File data is corrupt: %s') % e) from e
|
||||
|
||||
from ..lib.fischerscope_parser import parse_fischerscope_file
|
||||
result = parse_fischerscope_file(self.file_name or '', content)
|
||||
|
||||
# Wipe previous attempt so a retry doesn't pile up rows.
|
||||
self.reading_line_ids.unlink()
|
||||
|
||||
self.parsed_equipment_model = result['metadata'].get('equipment_model')
|
||||
self.parsed_product_ref = result['metadata'].get('product_ref')
|
||||
self.parsed_calibration_std_ref = result['metadata'].get('calibration_std_ref')
|
||||
self.parsed_measuring_time_seconds = (
|
||||
result['metadata'].get('measuring_time_seconds') or 0
|
||||
)
|
||||
self.parsed_operator_name = result['metadata'].get('operator_name')
|
||||
self.parsed_reading_datetime = result['metadata'].get('reading_datetime')
|
||||
|
||||
if result.get('image'):
|
||||
self.parsed_image = base64.b64encode(result['image'])
|
||||
self.parsed_image_mime = result.get('image_mime')
|
||||
|
||||
# Build editable rows for review/edit.
|
||||
Line = self.env['fp.thickness.upload.wizard.line']
|
||||
for r in result['readings']:
|
||||
Line.create({
|
||||
'wizard_id': self.id,
|
||||
'reading_number': r['reading_number'],
|
||||
'nip_mils': r['nip_mils'],
|
||||
'ni_percent': r['ni_percent'],
|
||||
'p_percent': r['p_percent'],
|
||||
})
|
||||
|
||||
self.reading_count = len(result['readings'])
|
||||
self.parse_messages = '\n'.join(result.get('errors') or []) or False
|
||||
self.state = 'review' if result['success'] else 'manual'
|
||||
return self._reopen()
|
||||
|
||||
def action_save(self):
|
||||
"""Commit parsed readings + file to the certificate."""
|
||||
self.ensure_one()
|
||||
cert = self.certificate_id
|
||||
if not cert:
|
||||
raise UserError(_('Wizard has no certificate to write to.'))
|
||||
if cert.state != 'draft':
|
||||
raise UserError(_(
|
||||
'Cannot attach thickness data — certificate %s is in '
|
||||
'state %s. Only draft certificates can be edited.'
|
||||
) % (cert.display_name, cert.state))
|
||||
|
||||
# Attach the original file so the merge logic + audit trail still
|
||||
# have it (also covers the "parse failed" manual fallback case).
|
||||
if self.file_data:
|
||||
cert.write({
|
||||
'x_fc_local_thickness_pdf': self.file_data,
|
||||
'x_fc_local_thickness_pdf_filename': self.file_name or False,
|
||||
})
|
||||
|
||||
# Persist the microscope image as a cert-level attachment (decision
|
||||
# confirmed 2026-05-19). One image per report, not per-reading.
|
||||
if self.parsed_image:
|
||||
ext = self._guess_image_ext(self.parsed_image_mime)
|
||||
self.env['ir.attachment'].create({
|
||||
'name': 'microscope-%s%s' % (cert.name or 'cert', ext),
|
||||
'datas': self.parsed_image,
|
||||
'res_model': cert._name,
|
||||
'res_id': cert.id,
|
||||
'mimetype': self.parsed_image_mime or 'image/jpeg',
|
||||
})
|
||||
|
||||
# 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:
|
||||
Reading = self.env['fp.thickness.reading']
|
||||
for line in self.reading_line_ids:
|
||||
Reading.create({
|
||||
'certificate_id': cert.id,
|
||||
'reading_number': line.reading_number,
|
||||
'nip_mils': line.nip_mils,
|
||||
'ni_percent': line.ni_percent,
|
||||
'p_percent': line.p_percent,
|
||||
'position_label': line.position_label or False,
|
||||
'equipment_model': self.parsed_equipment_model
|
||||
or 'Fischerscope XDAL 600',
|
||||
'product_ref': self.parsed_product_ref or False,
|
||||
'calibration_std_ref': (
|
||||
self.parsed_calibration_std_ref
|
||||
or 'NiP/Al STD SET SN 100174568'
|
||||
),
|
||||
'reading_datetime': (
|
||||
self.parsed_reading_datetime
|
||||
or fields.Datetime.now()
|
||||
),
|
||||
'measuring_time_seconds': (
|
||||
self.parsed_measuring_time_seconds or 120
|
||||
),
|
||||
})
|
||||
|
||||
# Chatter audit
|
||||
n = len(self.reading_line_ids)
|
||||
body = (
|
||||
_('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 '
|
||||
'readings). File: %s') % (self.file_name or 'unnamed')
|
||||
)
|
||||
cert.message_post(body=body)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': cert._name,
|
||||
'res_id': cert.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def _reopen(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _guess_image_ext(mime):
|
||||
return {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/tiff': '.tiff',
|
||||
}.get((mime or '').lower(), '.bin')
|
||||
|
||||
|
||||
class FpThicknessUploadWizardLine(models.TransientModel):
|
||||
"""Editable reading row in the upload wizard."""
|
||||
_name = 'fp.thickness.upload.wizard.line'
|
||||
_description = 'Thickness Upload Wizard — Reading'
|
||||
_order = 'reading_number'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.thickness.upload.wizard', required=True, ondelete='cascade',
|
||||
)
|
||||
reading_number = fields.Integer(string='#', required=True)
|
||||
nip_mils = fields.Float(string='NiP (mils)', digits=(10, 4))
|
||||
ni_percent = fields.Float(string='Ni %', digits=(6, 3))
|
||||
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.',
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Thickness-report upload wizard view.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Wizard form -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="fp_thickness_upload_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.thickness.upload.wizard.form</field>
|
||||
<field name="model">fp.thickness.upload.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Upload Thickness Report">
|
||||
<field name="state" invisible="1"/>
|
||||
|
||||
<!-- Upload step -->
|
||||
<div invisible="state != 'upload'">
|
||||
<p>
|
||||
Drop the Fischerscope XDAL 600 export below
|
||||
(<code>.docx</code> or <code>.pdf</code>). I'll read the
|
||||
readings, gauge calibration, and operator info, then
|
||||
let you review the values before they land on
|
||||
certificate <field name="certificate_id" readonly="1" nolabel="1"
|
||||
class="oe_inline" options="{'no_open': True, 'no_create': True}"/>.
|
||||
</p>
|
||||
<group>
|
||||
<field name="file_data" filename="file_name"/>
|
||||
<field name="file_name"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_parse" string="Parse File"
|
||||
type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Review step -->
|
||||
<div invisible="state != 'review'">
|
||||
<div class="alert alert-success" role="alert">
|
||||
Parsed <field name="reading_count" readonly="1"
|
||||
nolabel="1" class="oe_inline"/> reading(s)
|
||||
from <field name="file_name" readonly="1" nolabel="1"
|
||||
class="oe_inline"/>. Review/edit below,
|
||||
then click Save to record on the certificate.
|
||||
</div>
|
||||
|
||||
<group string="Equipment + Calibration">
|
||||
<field name="parsed_equipment_model"/>
|
||||
<field name="parsed_product_ref"/>
|
||||
<field name="parsed_calibration_std_ref"/>
|
||||
<field name="parsed_measuring_time_seconds"/>
|
||||
<field name="parsed_operator_name"/>
|
||||
<field name="parsed_reading_datetime"/>
|
||||
</group>
|
||||
|
||||
<group string="Microscope Image"
|
||||
invisible="not parsed_image">
|
||||
<field name="parsed_image" widget="image"
|
||||
options="{'preview_image': 'parsed_image'}"
|
||||
nolabel="1"/>
|
||||
</group>
|
||||
|
||||
<field name="reading_line_ids" nolabel="1">
|
||||
<list editable="bottom">
|
||||
<field name="reading_number"/>
|
||||
<field name="nip_mils"/>
|
||||
<field name="ni_percent"/>
|
||||
<field name="p_percent"/>
|
||||
<field name="position_label"/>
|
||||
</list>
|
||||
</field>
|
||||
|
||||
<group invisible="not parse_messages">
|
||||
<field name="parse_messages" readonly="1"
|
||||
widget="text" nolabel="1"/>
|
||||
</group>
|
||||
|
||||
<footer>
|
||||
<button name="action_save" string="Save"
|
||||
type="object" class="btn-primary"/>
|
||||
<button string="Re-upload" class="btn-secondary"
|
||||
name="action_parse" type="object"
|
||||
invisible="not file_data"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Manual fallback step -->
|
||||
<div invisible="state != 'manual'">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<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
|
||||
flow into the CoC PDF as page 2, but the readings
|
||||
won't appear as queryable rows.
|
||||
</div>
|
||||
<group>
|
||||
<field name="file_name" readonly="1"/>
|
||||
</group>
|
||||
<group invisible="not parse_messages">
|
||||
<field name="parse_messages" readonly="1"
|
||||
widget="text" nolabel="1"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_save"
|
||||
string="Attach file anyway"
|
||||
type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 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>
|
||||
<field name="res_model">fp.thickness.upload.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="model_fp_thickness_upload_wizard"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user