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:
gsinghpal
2026-05-19 01:05:16 -04:00
parent d77cc252bb
commit 8831176ec4
14 changed files with 1103 additions and 4 deletions

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import fp_cert_void_wizard
from . import fp_thickness_upload_wizard

View File

@@ -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.',
)

View File

@@ -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>