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,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_action_issue_gates
|
||||
from . import test_fischerscope_parser
|
||||
from . import test_thickness_upload_wizard
|
||||
|
||||
@@ -32,6 +32,12 @@ class TestActionIssueGates(TransactionCase):
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'IssueCust',
|
||||
'is_company': True,
|
||||
# Default for x_fc_send_thickness_report is True, which would
|
||||
# add a thickness-data gate to every issue test in this class.
|
||||
# These tests are scoped to the OTHER gates (spec_ref,
|
||||
# process_description, certified_by, contact). Turn off the
|
||||
# thickness flag so we're testing one gate at a time.
|
||||
'x_fc_send_thickness_report': False,
|
||||
})
|
||||
cls.contact_with_email.parent_id = cls.partner.id
|
||||
cls.contact_no_email.parent_id = cls.partner.id
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Unit tests for the Fischerscope thickness-report parser.
|
||||
# Pure-Python tests — no Odoo DB needed. Builds synthetic .docx files
|
||||
# matching the real XDAL 600 export layout and verifies extraction.
|
||||
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
# Lazy import inside methods to avoid the circular-import trap that
|
||||
# fires during test-module discovery (the package __init__ pulls in
|
||||
# `lib`; if tests/__init__ also resolves `..lib` at top-level, Python
|
||||
# sees a partially-initialised parent package).
|
||||
|
||||
|
||||
class TestFischerscopeParser(TransactionCase):
|
||||
"""Round-trip tests against the parser. We build a known-shape .docx
|
||||
in memory, parse it back, and assert the structure matches what the
|
||||
real Fischerscope XDAL 600 produces (see screenshot 2026-05-19)."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
try:
|
||||
import docx # python-docx — required for tests
|
||||
cls.docx = docx
|
||||
except ImportError:
|
||||
cls.docx = None
|
||||
# Resolve the parser by absolute path at first use — relative
|
||||
# `from ..lib import` at module top trips the test loader's
|
||||
# partially-initialised-package check.
|
||||
from odoo.addons.fusion_plating_certificates.lib import (
|
||||
fischerscope_parser as _fp,
|
||||
)
|
||||
cls.fischerscope_parser = _fp
|
||||
|
||||
def _make_sample_docx(self, with_image=False):
|
||||
"""Build a .docx that matches the screenshot layout."""
|
||||
if not self.docx:
|
||||
self.skipTest('python-docx not available')
|
||||
doc = self.docx.Document()
|
||||
doc.add_paragraph('Fischerscope® XDAL 600')
|
||||
doc.add_paragraph('Product: 2805031 / NiP/Al-alloys 2805030')
|
||||
doc.add_paragraph('Directory: NiP products for flat samples')
|
||||
doc.add_paragraph('Application: 16 / NiP/Al-alloys')
|
||||
doc.add_paragraph('')
|
||||
doc.add_paragraph('Calibr. Std. Set NiP/Al STD SET SN 100174568')
|
||||
doc.add_paragraph('n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %')
|
||||
doc.add_paragraph('n= 2 NiP 1= 0.5049 mils Ni 1 = 93.179 % P 1 = 6.8209 %')
|
||||
doc.add_paragraph('n= 3 NiP 1= 0.5134 mils Ni 1 = 92.273 % P 1 = 7.7266 %')
|
||||
doc.add_paragraph('')
|
||||
doc.add_paragraph(' NiP 1 mils Ni 1 % P 1 %')
|
||||
doc.add_paragraph('Mean 0.5689 92.258 7.7415')
|
||||
doc.add_paragraph('Standard Deviation 0.1037 0.9282 0.9282')
|
||||
doc.add_paragraph('CoV (%) 18.22 1.01 11.99')
|
||||
doc.add_paragraph('Range 0.1836 1.8562 1.8562')
|
||||
doc.add_paragraph('Number of readings 3 3 3')
|
||||
doc.add_paragraph('Measuring time 120 sec')
|
||||
doc.add_paragraph('Operator: BK 4755 1')
|
||||
doc.add_paragraph('Date: 5/15/2026 Time: 12:24:46 PM')
|
||||
|
||||
if with_image:
|
||||
# Embed a tiny valid 1x1 PNG so the image-extraction path
|
||||
# is exercised. Bytes from
|
||||
# https://github.com/mathiasbynens/small/blob/master/png-transparent.png
|
||||
png = bytes.fromhex(
|
||||
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4'
|
||||
'890000000a49444154789c63000100000500010d0a2db40000000049454e44ae'
|
||||
'426082'
|
||||
)
|
||||
img_buf = io.BytesIO(png)
|
||||
doc.add_picture(img_buf)
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
# ---- happy path: full Fischerscope export ----------------------------
|
||||
def test_parse_extracts_three_readings(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.docx', self._make_sample_docx(),
|
||||
)
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(len(result['readings']), 3)
|
||||
self.assertEqual(result['readings'][0], {
|
||||
'reading_number': 1,
|
||||
'nip_mils': 0.6885,
|
||||
'ni_percent': 91.323,
|
||||
'p_percent': 8.6771,
|
||||
})
|
||||
self.assertEqual(result['readings'][2]['reading_number'], 3)
|
||||
|
||||
def test_parse_extracts_metadata(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.docx', self._make_sample_docx(),
|
||||
)
|
||||
meta = result['metadata']
|
||||
self.assertIn('Fischerscope', (meta.get('equipment_model') or ''))
|
||||
self.assertIn('XDAL 600', (meta.get('equipment_model') or ''))
|
||||
self.assertEqual(meta.get('product_ref'),
|
||||
'2805031 / NiP/Al-alloys 2805030')
|
||||
self.assertEqual(meta.get('calibration_std_ref'),
|
||||
'NiP/Al STD SET SN 100174568')
|
||||
self.assertEqual(meta.get('measuring_time_seconds'), 120)
|
||||
self.assertEqual(meta.get('operator_name'), 'BK')
|
||||
self.assertEqual(meta.get('reading_datetime'),
|
||||
datetime(2026, 5, 15, 12, 24, 46))
|
||||
|
||||
def test_parse_extracts_image_when_present(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.docx', self._make_sample_docx(with_image=True),
|
||||
)
|
||||
self.assertIsNotNone(result['image'])
|
||||
self.assertGreater(len(result['image']), 50)
|
||||
# python-docx writes the relationship type to image; mime is content_type.
|
||||
self.assertTrue((result.get('image_mime') or '').startswith('image/'))
|
||||
|
||||
def test_parse_handles_no_image(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.docx', self._make_sample_docx(with_image=False),
|
||||
)
|
||||
self.assertIsNone(result['image'])
|
||||
|
||||
# ---- fallback / error paths -----------------------------------------
|
||||
def test_parse_unknown_extension(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.csv', b'irrelevant',
|
||||
)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(result['readings'], [])
|
||||
self.assertTrue(result['errors'])
|
||||
self.assertIn('Unsupported', result['errors'][0])
|
||||
|
||||
def test_parse_legacy_doc_extension(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.doc', b'%PDF',
|
||||
)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertIn('.doc', result['errors'][0])
|
||||
|
||||
def test_parse_corrupt_docx(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.docx', b'not a real docx file',
|
||||
)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(result['readings'], [])
|
||||
self.assertTrue(result['errors'])
|
||||
|
||||
def test_parse_empty_docx_no_readings(self):
|
||||
if not self.docx:
|
||||
self.skipTest('python-docx not available')
|
||||
doc = self.docx.Document()
|
||||
doc.add_paragraph('Just a blank report')
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'blank.docx', buf.getvalue(),
|
||||
)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(result['readings'], [])
|
||||
# raw_text should still be populated for debug
|
||||
self.assertIn('blank report', result['raw_text'])
|
||||
|
||||
# ---- robustness: variation in spacing / channel digits --------------
|
||||
def test_parse_tolerates_whitespace_variation(self):
|
||||
if not self.docx:
|
||||
self.skipTest('python-docx not available')
|
||||
doc = self.docx.Document()
|
||||
doc.add_paragraph('Calibr. Std. Set TESTSTD SN 999')
|
||||
# Tighter spacing, no channel digit (some exports omit "1")
|
||||
doc.add_paragraph('n=1 NiP= 0.50 mils Ni = 92.0 % P = 8.0 %')
|
||||
# Looser spacing, channel digit "1"
|
||||
doc.add_paragraph('n = 2 NiP 1 = 0.55 mils Ni 1 = 91.5 % P 1 = 8.5 %')
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'variant.docx', buf.getvalue(),
|
||||
)
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(len(result['readings']), 2)
|
||||
self.assertAlmostEqual(result['readings'][0]['nip_mils'], 0.50)
|
||||
self.assertAlmostEqual(result['readings'][1]['nip_mils'], 0.55)
|
||||
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# End-to-end tests for the thickness-upload wizard.
|
||||
|
||||
import base64
|
||||
import io
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestThicknessUploadWizard(TransactionCase):
|
||||
"""Walk the wizard from upload → parse → save and verify the side
|
||||
effects on the certificate."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
try:
|
||||
import docx
|
||||
cls.docx = docx
|
||||
except ImportError:
|
||||
cls.docx = None
|
||||
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'WizardCust',
|
||||
'email': 'wizardcust@example.com',
|
||||
})
|
||||
cls.cert = cls.env['fp.certificate'].create({
|
||||
'partner_id': cls.partner.id,
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
def _sample_docx_bytes(self):
|
||||
if not self.docx:
|
||||
self.skipTest('python-docx not available')
|
||||
doc = self.docx.Document()
|
||||
doc.add_paragraph('Fischerscope® XDAL 600')
|
||||
doc.add_paragraph('Product: 2805031 / NiP/Al-alloys 2805030')
|
||||
doc.add_paragraph('Calibr. Std. Set NiP/Al STD SET SN 100174568')
|
||||
doc.add_paragraph('n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %')
|
||||
doc.add_paragraph('n= 2 NiP 1= 0.5049 mils Ni 1 = 93.179 % P 1 = 6.8209 %')
|
||||
doc.add_paragraph('n= 3 NiP 1= 0.5134 mils Ni 1 = 92.273 % P 1 = 7.7266 %')
|
||||
doc.add_paragraph('Measuring time 120 sec')
|
||||
doc.add_paragraph('Operator: BK')
|
||||
doc.add_paragraph('Date: 5/15/2026 Time: 12:24:46 PM')
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
def _make_wizard(self, file_bytes, filename='fischer.docx'):
|
||||
return self.env['fp.thickness.upload.wizard'].create({
|
||||
'certificate_id': self.cert.id,
|
||||
'file_data': base64.b64encode(file_bytes),
|
||||
'file_name': filename,
|
||||
})
|
||||
|
||||
# ---- parse step ------------------------------------------------------
|
||||
def test_action_parse_populates_review_state(self):
|
||||
wiz = self._make_wizard(self._sample_docx_bytes())
|
||||
wiz.action_parse()
|
||||
self.assertEqual(wiz.state, 'review')
|
||||
self.assertEqual(wiz.reading_count, 3)
|
||||
self.assertEqual(len(wiz.reading_line_ids), 3)
|
||||
# Spot-check the second row carries the values we expect.
|
||||
line_2 = wiz.reading_line_ids.filtered(lambda l: l.reading_number == 2)
|
||||
self.assertEqual(len(line_2), 1)
|
||||
self.assertAlmostEqual(line_2.nip_mils, 0.5049, places=4)
|
||||
|
||||
def test_action_parse_unparseable_goes_to_manual_state(self):
|
||||
wiz = self._make_wizard(b'not a docx', filename='garbage.docx')
|
||||
wiz.action_parse()
|
||||
self.assertEqual(wiz.state, 'manual')
|
||||
self.assertEqual(wiz.reading_count, 0)
|
||||
self.assertFalse(wiz.reading_line_ids)
|
||||
|
||||
def test_action_parse_extracts_metadata(self):
|
||||
wiz = self._make_wizard(self._sample_docx_bytes())
|
||||
wiz.action_parse()
|
||||
self.assertIn('Fischerscope', wiz.parsed_equipment_model or '')
|
||||
self.assertEqual(wiz.parsed_calibration_std_ref,
|
||||
'NiP/Al STD SET SN 100174568')
|
||||
self.assertEqual(wiz.parsed_measuring_time_seconds, 120)
|
||||
self.assertEqual(wiz.parsed_operator_name, 'BK')
|
||||
|
||||
# ---- save step -------------------------------------------------------
|
||||
def test_action_save_creates_thickness_readings(self):
|
||||
wiz = self._make_wizard(self._sample_docx_bytes())
|
||||
wiz.action_parse()
|
||||
wiz.action_save()
|
||||
readings = self.env['fp.thickness.reading'].search([
|
||||
('certificate_id', '=', self.cert.id),
|
||||
])
|
||||
self.assertEqual(len(readings), 3)
|
||||
# Same metadata on every row (decision 2026-05-19).
|
||||
for r in readings:
|
||||
self.assertEqual(r.calibration_std_ref,
|
||||
'NiP/Al STD SET SN 100174568')
|
||||
self.assertIn('Fischerscope', r.equipment_model or '')
|
||||
self.assertEqual(r.measuring_time_seconds, 120)
|
||||
|
||||
def test_action_save_attaches_original_file(self):
|
||||
wiz = self._make_wizard(
|
||||
self._sample_docx_bytes(), filename='fischer-WO-30040.docx',
|
||||
)
|
||||
wiz.action_parse()
|
||||
wiz.action_save()
|
||||
self.cert.invalidate_recordset(
|
||||
['x_fc_local_thickness_pdf', 'x_fc_local_thickness_pdf_filename'],
|
||||
)
|
||||
self.assertTrue(self.cert.x_fc_local_thickness_pdf)
|
||||
self.assertEqual(
|
||||
self.cert.x_fc_local_thickness_pdf_filename, 'fischer-WO-30040.docx',
|
||||
)
|
||||
|
||||
def test_action_save_posts_chatter(self):
|
||||
wiz = self._make_wizard(self._sample_docx_bytes())
|
||||
wiz.action_parse()
|
||||
before = len(self.cert.message_ids)
|
||||
wiz.action_save()
|
||||
after = len(self.cert.message_ids)
|
||||
self.assertGreater(after, before)
|
||||
last = self.cert.message_ids[0]
|
||||
self.assertIn('thickness', (last.body or '').lower())
|
||||
|
||||
def test_action_save_blocks_on_non_draft_cert(self):
|
||||
# Force the cert into 'voided' so action_save's gate fires.
|
||||
self.cert.state = 'voided'
|
||||
wiz = self._make_wizard(self._sample_docx_bytes())
|
||||
wiz.action_parse()
|
||||
with self.assertRaises(UserError):
|
||||
wiz.action_save()
|
||||
|
||||
def test_action_save_manual_fallback_still_attaches_file(self):
|
||||
"""When parse fails (state=manual), Save must still attach the
|
||||
original file so the merge path / audit trail are populated."""
|
||||
wiz = self._make_wizard(b'unparseable')
|
||||
wiz.action_parse()
|
||||
self.assertEqual(wiz.state, 'manual')
|
||||
wiz.action_save()
|
||||
self.cert.invalidate_recordset(['x_fc_local_thickness_pdf'])
|
||||
self.assertTrue(self.cert.x_fc_local_thickness_pdf)
|
||||
# No readings should have been created.
|
||||
n = self.env['fp.thickness.reading'].search_count([
|
||||
('certificate_id', '=', self.cert.id),
|
||||
])
|
||||
self.assertEqual(n, 0)
|
||||
Reference in New Issue
Block a user