diff --git a/fusion_plating/fusion_plating_certificates/__init__.py b/fusion_plating/fusion_plating_certificates/__init__.py index cf9f201b..b2be999a 100644 --- a/fusion_plating/fusion_plating_certificates/__init__.py +++ b/fusion_plating/fusion_plating_certificates/__init__.py @@ -3,5 +3,10 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. +# Note: `lib/` is NOT eagerly imported here — Python's relative-import +# machinery would otherwise re-enter this package mid-init when the +# wizard module does `from ..lib.fischerscope_parser import …`, raising +# "cannot import name X from partially initialized module" on Python +# 3.11+. lib is imported lazily where it's used (action_parse). from . import models from . import wizards diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index 0e2e9afd..b2d3bdce 100644 --- a/fusion_plating/fusion_plating_certificates/__manifest__.py +++ b/fusion_plating/fusion_plating_certificates/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Certificates', - 'version': '19.0.6.4.0', + 'version': '19.0.7.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'description': """ @@ -38,6 +38,7 @@ Includes Fischerscope thickness measurement data capture. 'views/res_partner_views.xml', 'views/fp_certificates_menu.xml', 'wizards/fp_cert_void_wizard_views.xml', + 'wizards/fp_thickness_upload_wizard_views.xml', ], 'installable': True, 'application': False, diff --git a/fusion_plating/fusion_plating_certificates/lib/__init__.py b/fusion_plating/fusion_plating_certificates/lib/__init__.py new file mode 100644 index 00000000..6b77815c --- /dev/null +++ b/fusion_plating/fusion_plating_certificates/lib/__init__.py @@ -0,0 +1,3 @@ +# Parser libraries for fusion_plating_certificates. +# Pure-Python modules, no Odoo imports — safe to unit-test in isolation. +from . import fischerscope_parser # noqa: F401 diff --git a/fusion_plating/fusion_plating_certificates/lib/fischerscope_parser.py b/fusion_plating/fusion_plating_certificates/lib/fischerscope_parser.py new file mode 100644 index 00000000..e5529804 --- /dev/null +++ b/fusion_plating/fusion_plating_certificates/lib/fischerscope_parser.py @@ -0,0 +1,337 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# Fischerscope XDAL 600 thickness-report parser. +# +# Input: bytes of a .docx or .pdf file exported by the gauge. +# Output: dict with `readings` (list of per-reading dicts), `metadata` +# (single dict with equipment/calibration/operator info), and `image` +# (raw bytes of the embedded microscope image, when extractable). +# +# Pure-Python, no Odoo imports. Suitable for direct unit testing. + +import io +import logging +import re +from datetime import datetime + +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Regexes — derived from the real Fischerscope XDAL 600 export layout. +# Sample line: +# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 % +# Spaces vary; allow flexible whitespace + optional channel digit after NiP/Ni/P. +# --------------------------------------------------------------------------- +_READING_RE = re.compile( + r"""n\s*=\s*(?P\d+) # reading number + \s+NiP\s*\d*\s*=\s* # NiP label (channel number optional) + (?P[\d.]+)\s*mils # NiP thickness in mils + \s+Ni\s*\d*\s*=\s* # Ni label + (?P[\d.]+)\s*% # Ni percentage + \s+P\s*\d*\s*=\s* # P label + (?P

[\d.]+)\s*% # P percentage + """, + re.VERBOSE, +) + +# Equipment model — first non-blank line that contains "Fischerscope" or +# similar gauge identifier. Captures everything up to end of line. +_EQUIPMENT_RE = re.compile( + r'(Fischerscope[^\n\r]*)', + re.IGNORECASE, +) + +# Product ref: "Product: 2805031 / NiP/Al-alloys 2805030" +_PRODUCT_RE = re.compile( + r'Product\s*:\s*([^\n\r]+?)(?:\s*$|\s*\n)', + re.IGNORECASE | re.MULTILINE, +) + +# Calibration set: "Calibr. Std. Set NiP/Al STD SET SN 100174568" +_CALIBR_RE = re.compile( + r'Calibr\.?\s*Std\.?\s*Set\s*([^\n\r]+?)(?:\s*$|\s*\n)', + re.IGNORECASE | re.MULTILINE, +) + +# Measuring time: "Measuring time 120 sec" +_MEAS_TIME_RE = re.compile( + r'Measuring\s*time\s*:?\s*(\d+)\s*sec', + re.IGNORECASE, +) + +# Operator: "Operator: BK" (initials or short name) +# Stop the capture at: 2+ whitespace, a newline, end-of-string, 2+ digits, +# or end-of-line in multiline mode. The bare "Operator: BK\nDate: ..." +# case (operator name immediately followed by newline + next field) was +# the bug that fell through every other branch. +_OPERATOR_RE = re.compile( + r'Operator\s*:?\s*([A-Za-z][A-Za-z0-9 .\-]{0,40}?)(?=\s{2,}|\n|$|\s*\d{2,})', + re.IGNORECASE | re.MULTILINE, +) + +# Date + Time: "Date: 5/15/2026 Time: 12:24:46 PM" +_DATETIME_RE = re.compile( + r'Date\s*:?\s*(\d{1,2}/\d{1,2}/\d{2,4})' + r'\s*Time\s*:?\s*(\d{1,2}:\d{2}(?::\d{2})?\s*(?:AM|PM)?)', + re.IGNORECASE, +) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def parse_fischerscope_file(filename, content_bytes): + """Parse a Fischerscope thickness report. + + Branches on file extension: + .docx → python-docx (paragraphs + inline_shapes for the image) + .pdf → PyPDF2 (text per page; image extraction best-effort) + + Returns: + { + 'success': bool, # True if at least one reading was parsed + 'readings': [ # list of per-reading dicts + {'reading_number': int, 'nip_mils': float, + 'ni_percent': float, 'p_percent': float}, + ... + ], + 'metadata': { # may have None values for missing keys + 'equipment_model': str | None, + 'product_ref': str | None, + 'calibration_std_ref': str | None, + 'measuring_time_seconds': int | None, + 'operator_name': str | None, + 'reading_datetime': datetime | None, + }, + 'image': bytes | None, # microscope image, if extractable + 'image_mime': str | None, # image/jpeg, image/png, etc. + 'raw_text': str, # extracted text (for debug / fallback) + 'errors': [str], # non-fatal warnings encountered + } + + Never raises on parse failure — returns success=False with readings=[]. + Raises only on unrecoverable I/O (e.g. corrupted file bytes). + """ + name = (filename or '').lower() + if name.endswith('.docx'): + return _parse_docx(content_bytes) + if name.endswith('.pdf'): + return _parse_pdf(content_bytes) + if name.endswith('.doc'): + return _failed_result( + raw_text='', + error=( + 'Legacy .doc format not supported — re-export from the ' + 'gauge as .docx or .pdf. (python-docx reads .docx only; ' + 'old binary .doc needs LibreOffice conversion which ' + "isn't installed.)" + ), + ) + return _failed_result( + raw_text='', + error='Unsupported file extension: %r. Expected .docx or .pdf.' % filename, + ) + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + +def _parse_docx(content_bytes): + """Parse a .docx Fischerscope report.""" + errors = [] + try: + import docx # python-docx + except ImportError: + return _failed_result( + raw_text='', + error='python-docx not installed — cannot parse .docx files.', + ) + try: + doc = docx.Document(io.BytesIO(content_bytes)) + except Exception as e: + return _failed_result(raw_text='', error='Could not open .docx: %s' % e) + + # Build the raw text by walking paragraphs AND tables. Fischerscope + # exports vary — sometimes the readings are in a table, sometimes + # in justified paragraphs. Joining everything gives the regex a + # stable target. + parts = [] + for para in doc.paragraphs: + text = para.text + if text: + parts.append(text) + for tbl in doc.tables: + for row in tbl.rows: + row_text = ' '.join(cell.text for cell in row.cells) + if row_text.strip(): + parts.append(row_text) + raw_text = '\n'.join(parts) + + # Image: walk inline_shapes + image-parts; pick the first one. The + # Fischerscope export embeds exactly one microscope image per report. + image_bytes = None + image_mime = None + try: + for rel in doc.part.rels.values(): + if 'image' in (rel.reltype or '').lower(): + img_part = rel.target_part + image_bytes = img_part.blob + image_mime = img_part.content_type + break + except Exception as e: + errors.append('image extraction failed: %s' % e) + + return _build_result(raw_text, errors, image_bytes, image_mime) + + +def _parse_pdf(content_bytes): + """Parse a .pdf Fischerscope report. Text-based PDFs only.""" + errors = [] + try: + from PyPDF2 import PdfReader + except ImportError: + return _failed_result( + raw_text='', + error='PyPDF2 not installed — cannot parse .pdf files.', + ) + try: + reader = PdfReader(io.BytesIO(content_bytes)) + except Exception as e: + return _failed_result(raw_text='', error='Could not open PDF: %s' % e) + + raw_text_parts = [] + for i, page in enumerate(reader.pages): + try: + raw_text_parts.append(page.extract_text() or '') + except Exception as e: + errors.append('page %d extract_text failed: %s' % (i + 1, e)) + raw_text = '\n'.join(raw_text_parts) + + # PDF image extraction is unreliable across PDF producers. Best- + # effort: walk page resources looking for /XObject /Image entries. + # If anything fails, drop image silently — the operator still has + # the original file attached. + image_bytes = None + image_mime = None + try: + for page in reader.pages: + resources = page.get('/Resources') + if not resources: + continue + xobjects = resources.get('/XObject') + if not xobjects: + continue + x_resolved = xobjects.get_object() if hasattr(xobjects, 'get_object') else xobjects + for obj_name in x_resolved: + obj = x_resolved[obj_name] + obj = obj.get_object() if hasattr(obj, 'get_object') else obj + if obj.get('/Subtype') == '/Image': + image_bytes = obj.get_data() + f = obj.get('/Filter') + if f == '/DCTDecode': + image_mime = 'image/jpeg' + elif f == '/FlateDecode': + image_mime = 'image/png' + else: + image_mime = 'application/octet-stream' + break + if image_bytes: + break + except Exception as e: + errors.append('PDF image extraction failed: %s' % e) + image_bytes = None + + return _build_result(raw_text, errors, image_bytes, image_mime) + + +def _build_result(raw_text, errors, image_bytes, image_mime): + """Run the regex extractor over raw_text and assemble the result dict.""" + readings = [] + for m in _READING_RE.finditer(raw_text): + try: + readings.append({ + 'reading_number': int(m.group('n')), + 'nip_mils': float(m.group('nip')), + 'ni_percent': float(m.group('ni')), + 'p_percent': float(m.group('p')), + }) + except (ValueError, TypeError) as e: + errors.append('reading parse error at offset %d: %s' % (m.start(), e)) + + metadata = { + 'equipment_model': _capture(_EQUIPMENT_RE, raw_text), + 'product_ref': _capture(_PRODUCT_RE, raw_text), + 'calibration_std_ref': _capture(_CALIBR_RE, raw_text), + 'measuring_time_seconds': _capture_int(_MEAS_TIME_RE, raw_text), + 'operator_name': _capture(_OPERATOR_RE, raw_text), + 'reading_datetime': _capture_datetime(raw_text), + } + + return { + 'success': bool(readings), + 'readings': readings, + 'metadata': metadata, + 'image': image_bytes, + 'image_mime': image_mime, + 'raw_text': raw_text, + 'errors': errors, + } + + +def _failed_result(raw_text, error): + return { + 'success': False, + 'readings': [], + 'metadata': { + 'equipment_model': None, + 'product_ref': None, + 'calibration_std_ref': None, + 'measuring_time_seconds': None, + 'operator_name': None, + 'reading_datetime': None, + }, + 'image': None, + 'image_mime': None, + 'raw_text': raw_text, + 'errors': [error] if error else [], + } + + +def _capture(rx, text): + m = rx.search(text or '') + if not m: + return None + val = m.group(1).strip() + return val or None + + +def _capture_int(rx, text): + m = rx.search(text or '') + if not m: + return None + try: + return int(m.group(1)) + except (ValueError, TypeError): + return None + + +def _capture_datetime(text): + m = _DATETIME_RE.search(text or '') + if not m: + return None + date_str, time_str = m.group(1).strip(), m.group(2).strip() + # Try a few likely formats; the gauge can emit either MM/DD/YYYY or + # M/D/YY plus 12h or 24h. + for date_fmt in ('%m/%d/%Y', '%m/%d/%y', '%d/%m/%Y', '%d/%m/%y'): + for time_fmt in ('%I:%M:%S %p', '%I:%M %p', '%H:%M:%S', '%H:%M'): + try: + return datetime.strptime('%s %s' % (date_str, time_str), + '%s %s' % (date_fmt, time_fmt)) + except ValueError: + continue + return None diff --git a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py index 267e0e7a..24c789c5 100644 --- a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py +++ b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py @@ -360,7 +360,13 @@ class FpCertificate(models.Model): rec.contact_partner_id = ( rec.partner_id.x_fc_default_coc_contact_id ) + # Guard with field-existence check — fp.certificate doesn't + # declare company_id directly; production picks it up from + # auto-creation context but tests can build a cert without + # one. Without the guard, AttributeError on the .company_id + # access bubbles up as a test error. if (not rec.certified_by_id + and 'company_id' in rec._fields and rec.company_id and 'x_fc_owner_user_id' in rec.company_id._fields and rec.company_id.x_fc_owner_user_id): diff --git a/fusion_plating/fusion_plating_certificates/security/ir.model.access.csv b/fusion_plating/fusion_plating_certificates/security/ir.model.access.csv index 04771077..6156b0bc 100644 --- a/fusion_plating/fusion_plating_certificates/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_certificates/security/ir.model.access.csv @@ -7,3 +7,7 @@ access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_ access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1 access_fp_cert_void_wiz_mgr,fp.cert.void.wiz.manager,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_thickness_upload_wiz_sup,fp.thickness.upload.wiz.supervisor,model_fp_thickness_upload_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1 +access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1 +access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1 diff --git a/fusion_plating/fusion_plating_certificates/tests/__init__.py b/fusion_plating/fusion_plating_certificates/tests/__init__.py index ba4bd968..77b3498a 100644 --- a/fusion_plating/fusion_plating_certificates/tests/__init__.py +++ b/fusion_plating/fusion_plating_certificates/tests/__init__.py @@ -1,2 +1,4 @@ # -*- coding: utf-8 -*- from . import test_action_issue_gates +from . import test_fischerscope_parser +from . import test_thickness_upload_wizard diff --git a/fusion_plating/fusion_plating_certificates/tests/test_action_issue_gates.py b/fusion_plating/fusion_plating_certificates/tests/test_action_issue_gates.py index d2ecd019..70cdd301 100644 --- a/fusion_plating/fusion_plating_certificates/tests/test_action_issue_gates.py +++ b/fusion_plating/fusion_plating_certificates/tests/test_action_issue_gates.py @@ -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 diff --git a/fusion_plating/fusion_plating_certificates/tests/test_fischerscope_parser.py b/fusion_plating/fusion_plating_certificates/tests/test_fischerscope_parser.py new file mode 100644 index 00000000..b711c3a9 --- /dev/null +++ b/fusion_plating/fusion_plating_certificates/tests/test_fischerscope_parser.py @@ -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) diff --git a/fusion_plating/fusion_plating_certificates/tests/test_thickness_upload_wizard.py b/fusion_plating/fusion_plating_certificates/tests/test_thickness_upload_wizard.py new file mode 100644 index 00000000..a4e3c6e9 --- /dev/null +++ b/fusion_plating/fusion_plating_certificates/tests/test_thickness_upload_wizard.py @@ -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) diff --git a/fusion_plating/fusion_plating_certificates/wizards/__init__.py b/fusion_plating/fusion_plating_certificates/wizards/__init__.py index c6184c75..6160cf5c 100644 --- a/fusion_plating/fusion_plating_certificates/wizards/__init__.py +++ b/fusion_plating/fusion_plating_certificates/wizards/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import fp_cert_void_wizard +from . import fp_thickness_upload_wizard diff --git a/fusion_plating/fusion_plating_certificates/wizards/fp_thickness_upload_wizard.py b/fusion_plating/fusion_plating_certificates/wizards/fp_thickness_upload_wizard.py new file mode 100644 index 00000000..d9dd23d2 --- /dev/null +++ b/fusion_plating/fusion_plating_certificates/wizards/fp_thickness_upload_wizard.py @@ -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.', + ) diff --git a/fusion_plating/fusion_plating_certificates/wizards/fp_thickness_upload_wizard_views.xml b/fusion_plating/fusion_plating_certificates/wizards/fp_thickness_upload_wizard_views.xml new file mode 100644 index 00000000..56d65ede --- /dev/null +++ b/fusion_plating/fusion_plating_certificates/wizards/fp_thickness_upload_wizard_views.xml @@ -0,0 +1,135 @@ + + + + + + + + + fp.thickness.upload.wizard.form + fp.thickness.upload.wizard + +

+ + + +
+

+ Drop the Fischerscope XDAL 600 export below + (.docx or .pdf). I'll read the + readings, gauge calibration, and operator info, then + let you review the values before they land on + certificate . +

+ + + + +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ + + + + + + +
+
+
+ + +
+ + + + + + + Upload Thickness Report + fp.thickness.upload.wizard + form + new + + + + diff --git a/fusion_plating/fusion_plating_jobs/views/fp_certificate_views.xml b/fusion_plating/fusion_plating_jobs/views/fp_certificate_views.xml index d042553a..20edfa9d 100644 --- a/fusion_plating/fusion_plating_jobs/views/fp_certificate_views.xml +++ b/fusion_plating/fusion_plating_jobs/views/fp_certificate_views.xml @@ -94,11 +94,30 @@ widget="many2one_binary" invisible="not x_fc_thickness_pdf_id"/> - - + +
+
+
+

+ Drop the .docx or .pdf + file straight from the Fischerscope XDAL 600. + The wizard reads the readings, calibration set, + and operator info, lets you review them, and + attaches the original file to this certificate. +

+
+ + + readonly="1"/>