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:
@@ -3,5 +3,10 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# 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 models
|
||||||
from . import wizards
|
from . import wizards
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Certificates',
|
'name': 'Fusion Plating — Certificates',
|
||||||
'version': '19.0.6.4.0',
|
'version': '19.0.7.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -38,6 +38,7 @@ Includes Fischerscope thickness measurement data capture.
|
|||||||
'views/res_partner_views.xml',
|
'views/res_partner_views.xml',
|
||||||
'views/fp_certificates_menu.xml',
|
'views/fp_certificates_menu.xml',
|
||||||
'wizards/fp_cert_void_wizard_views.xml',
|
'wizards/fp_cert_void_wizard_views.xml',
|
||||||
|
'wizards/fp_thickness_upload_wizard_views.xml',
|
||||||
],
|
],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<n>\d+) # reading number
|
||||||
|
\s+NiP\s*\d*\s*=\s* # NiP label (channel number optional)
|
||||||
|
(?P<nip>[\d.]+)\s*mils # NiP thickness in mils
|
||||||
|
\s+Ni\s*\d*\s*=\s* # Ni label
|
||||||
|
(?P<ni>[\d.]+)\s*% # Ni percentage
|
||||||
|
\s+P\s*\d*\s*=\s* # P label
|
||||||
|
(?P<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
|
||||||
@@ -360,7 +360,13 @@ class FpCertificate(models.Model):
|
|||||||
rec.contact_partner_id = (
|
rec.contact_partner_id = (
|
||||||
rec.partner_id.x_fc_default_coc_contact_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
|
if (not rec.certified_by_id
|
||||||
|
and 'company_id' in rec._fields
|
||||||
and rec.company_id
|
and rec.company_id
|
||||||
and 'x_fc_owner_user_id' in rec.company_id._fields
|
and 'x_fc_owner_user_id' in rec.company_id._fields
|
||||||
and rec.company_id.x_fc_owner_user_id):
|
and rec.company_id.x_fc_owner_user_id):
|
||||||
|
|||||||
@@ -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_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_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_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
|
||||||
|
|||||||
|
@@ -1,2 +1,4 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import test_action_issue_gates
|
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({
|
cls.partner = cls.env['res.partner'].create({
|
||||||
'name': 'IssueCust',
|
'name': 'IssueCust',
|
||||||
'is_company': True,
|
'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_with_email.parent_id = cls.partner.id
|
||||||
cls.contact_no_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)
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import fp_cert_void_wizard
|
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>
|
||||||
@@ -94,11 +94,30 @@
|
|||||||
widget="many2one_binary"
|
widget="many2one_binary"
|
||||||
invisible="not x_fc_thickness_pdf_id"/>
|
invisible="not x_fc_thickness_pdf_id"/>
|
||||||
</group>
|
</group>
|
||||||
<separator string="Upload Fischerscope PDF here"/>
|
<separator string="Upload Fischerscope Report"/>
|
||||||
<group>
|
<div class="oe_button_box">
|
||||||
|
<button name="%(fusion_plating_certificates.action_fp_thickness_upload_wizard)d"
|
||||||
|
type="action"
|
||||||
|
class="btn-primary"
|
||||||
|
string="Upload Thickness Report"
|
||||||
|
context="{'default_certificate_id': id}"
|
||||||
|
invisible="state != 'draft'"/>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">
|
||||||
|
<p>
|
||||||
|
Drop the <code>.docx</code> or <code>.pdf</code>
|
||||||
|
file straight from the Fischerscope XDAL 600.
|
||||||
|
The wizard reads the readings, calibration set,
|
||||||
|
and operator info, lets you review them, and
|
||||||
|
attaches the original file to this certificate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<separator string="Attached File"
|
||||||
|
invisible="not x_fc_local_thickness_pdf"/>
|
||||||
|
<group invisible="not x_fc_local_thickness_pdf">
|
||||||
<field name="x_fc_local_thickness_pdf"
|
<field name="x_fc_local_thickness_pdf"
|
||||||
filename="x_fc_local_thickness_pdf_filename"
|
filename="x_fc_local_thickness_pdf_filename"
|
||||||
readonly="state != 'draft'"/>
|
readonly="1"/>
|
||||||
<field name="x_fc_local_thickness_pdf_filename"
|
<field name="x_fc_local_thickness_pdf_filename"
|
||||||
invisible="1"/>
|
invisible="1"/>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
Reference in New Issue
Block a user