This commit is contained in:
gsinghpal
2026-05-18 22:33:23 -04:00
parent 25f568f225
commit 091f98e1f9
76 changed files with 4521 additions and 220 deletions

View File

@@ -4,3 +4,4 @@
from . import fp_job_step_move_wizard
from . import fp_job_step_input_wizard
from . import fp_cert_issue_wizard

View File

@@ -0,0 +1,375 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Issue Certs Wizard.
Opened from a job's "Issue Certs" milestone button. Walks each draft
cert on the job, lets the manager upload the Fischerscope/XDAL output
(PDF or .docx) per cert that needs thickness data, and tries to parse
the .docx to pre-populate the readings table. Manager can edit/add
readings before confirming. On confirm:
- PDF uploads land on cert.x_fc_local_thickness_pdf (merged as page 2
of the issued CoC).
- .docx uploads are attached as ir.attachment on the cert (evidence)
and the parsed readings are written as fp.thickness.reading rows.
- cert.action_issue() is called for each cert.
The wizard is a convenience layer — it does NOT replace the per-cert
Issue button on the cert form, which stays as the fallback path.
"""
import base64
import io
import logging
import re
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Fischerscope XDAL 600 reading line, e.g.
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
_FISCHER_READING_RE = re.compile(
r'n\s*=\s*(\d+)'
r'\s+NiP\s+\d+\s*=\s*([\d.]+)\s*mils'
r'\s+Ni\s+\d+\s*=\s*([\d.]+)\s*%'
r'\s+P\s+\d+\s*=\s*([\d.]+)\s*%',
re.IGNORECASE,
)
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+)', re.IGNORECASE)
_FISCHER_OPERATOR_RE = re.compile(r'Operator:\s*(\S+)', re.IGNORECASE)
_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
_FISCHER_TIME_RE = re.compile(r'Time:\s*([\d:]+\s*[APMapm]*)')
def _fp_parse_fischerscope_docx(raw_bytes):
"""Best-effort parse of a Fischerscope XDAL 600 .docx report.
Returns dict:
{
'readings': [(nip_mils, ni_pct, p_pct), ...],
'calibration': str or '',
'operator': str or '',
'date_str': str or '',
'time_str': str or '',
'raw_text': str (the extracted document body, for chatter),
}
Soft-fails to an empty dict-like result when python-docx isn't
installed or the bytes don't parse — the wizard still works, the
operator just has to type readings manually.
"""
empty = {
'readings': [], 'calibration': '', 'operator': '',
'date_str': '', 'time_str': '', 'raw_text': '',
}
if not raw_bytes:
return empty
try:
import docx # python-docx
except ImportError:
_logger.info(
'python-docx not installed — Fischerscope auto-parse '
'skipped. Operator will enter readings manually.'
)
return empty
try:
doc = docx.Document(io.BytesIO(raw_bytes))
except Exception as e:
_logger.warning('Fischerscope .docx parse failed: %s', e)
return empty
# Pull text from paragraphs AND tables (Fischerscope reports
# sometimes lay the readings inside a table cell).
parts = [p.text for p in doc.paragraphs]
for tbl in doc.tables:
for row in tbl.rows:
for cell in row.cells:
parts.append(cell.text)
text = '\n'.join(parts)
readings = []
for m in _FISCHER_READING_RE.finditer(text):
try:
readings.append((
float(m.group(2)), # nip mils
float(m.group(3)), # Ni %
float(m.group(4)), # P %
))
except ValueError:
continue
calib = ''
m = _FISCHER_CALIB_RE.search(text)
if m:
calib = m.group(1).strip()
operator = ''
m = _FISCHER_OPERATOR_RE.search(text)
if m:
operator = m.group(1).strip()
date_str = ''
m = _FISCHER_DATE_RE.search(text)
if m:
date_str = m.group(1).strip()
time_str = ''
m = _FISCHER_TIME_RE.search(text)
if m:
time_str = m.group(1).strip()
return {
'readings': readings,
'calibration': calib,
'operator': operator,
'date_str': date_str,
'time_str': time_str,
'raw_text': text,
}
class FpCertIssueWizard(models.TransientModel):
_name = 'fp.cert.issue.wizard'
_description = 'Fusion Plating — Issue Certs Wizard'
job_id = fields.Many2one(
'fp.job', string='Job', required=True, readonly=True,
)
line_ids = fields.One2many(
'fp.cert.issue.wizard.line', 'wizard_id', string='Certs to Issue',
)
has_blocking_lines = fields.Boolean(
compute='_compute_has_blocking_lines',
help='True when at least one line is missing data the gate '
'requires (no readings, no file, etc.). Used to disable '
'the Confirm button.',
)
@api.depends('line_ids', 'line_ids.is_ready')
def _compute_has_blocking_lines(self):
for w in self:
w.has_blocking_lines = any(not ln.is_ready for ln in w.line_ids)
@api.model
def open_for_job(self, job):
"""Factory — create a wizard pre-populated with one line per
draft cert on the job. Returns an action dict that opens the
wizard form."""
Cert = self.env['fp.certificate'].sudo()
certs = Cert.search([
('x_fc_job_id', '=', job.id),
('state', '=', 'draft'),
])
if not certs:
raise UserError(_(
'No draft certificates on %s to issue.'
) % job.name)
wiz = self.create({
'job_id': job.id,
'line_ids': [(0, 0, {'cert_id': c.id}) for c in certs],
})
return {
'type': 'ir.actions.act_window',
'name': _('Issue Certs — %s') % job.name,
'res_model': self._name,
'res_id': wiz.id,
'view_mode': 'form',
'target': 'new',
}
def action_confirm(self):
"""Apply every line's file + readings, then issue each cert.
Order matters: write the file/readings BEFORE calling action_issue
so the gate sees the populated data. If a single cert raises on
issue, the whole wizard rolls back (transactional).
"""
self.ensure_one()
issued = []
for ln in self.line_ids:
ln._apply_to_cert()
cert = ln.cert_id
if cert.state == 'draft':
cert.action_issue()
issued.append(cert.name)
if not issued:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Certs Issued'),
'message': _('%d cert(s) issued: %s') % (
len(issued), ', '.join(issued),
),
'sticky': False,
'type': 'success',
'next': {'type': 'ir.actions.act_window_close'},
},
}
class FpCertIssueWizardLine(models.TransientModel):
_name = 'fp.cert.issue.wizard.line'
_description = 'Fusion Plating — Issue Certs Wizard Line'
wizard_id = fields.Many2one(
'fp.cert.issue.wizard', required=True, ondelete='cascade',
)
cert_id = fields.Many2one(
'fp.certificate', string='Certificate', required=True, readonly=True,
)
cert_name = fields.Char(related='cert_id.name', readonly=True)
cert_type = fields.Selection(
related='cert_id.certificate_type', readonly=True,
)
partner_id = fields.Many2one(
related='cert_id.partner_id', readonly=True,
)
needs_thickness = fields.Boolean(
compute='_compute_needs_thickness', store=False,
)
fischer_file = fields.Binary(string='Fischerscope File (PDF or .docx)')
fischer_filename = fields.Char(string='Filename')
parsed_summary = fields.Text(
string='Parsed Summary', readonly=True,
help='Output of the .docx parser. Populated when you attach a '
'Fischerscope .docx; the readings table below is auto-'
'filled from the same parse. Empty for PDF uploads.',
)
reading_line_ids = fields.One2many(
'fp.cert.issue.wizard.reading', 'line_id', string='Readings',
)
is_ready = fields.Boolean(
compute='_compute_is_ready',
help='True when this cert has enough data to issue: thickness '
'data present if needed.',
)
@api.depends('cert_id.certificate_type',
'cert_id.partner_id.x_fc_send_thickness_report',
'cert_id.partner_id.x_fc_strict_thickness_required')
def _compute_needs_thickness(self):
for ln in self:
cert = ln.cert_id
partner = cert.partner_id
ln.needs_thickness = (
cert.certificate_type == 'thickness_report'
or (cert.certificate_type == 'coc' and partner and (
partner.x_fc_strict_thickness_required
or partner.x_fc_send_thickness_report
))
)
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
'cert_id.thickness_reading_ids',
'cert_id.x_fc_local_thickness_pdf')
def _compute_is_ready(self):
for ln in self:
if not ln.needs_thickness:
ln.is_ready = True
continue
ln.is_ready = bool(
ln.fischer_file
or ln.reading_line_ids
or ln.cert_id.thickness_reading_ids
or ln.cert_id.x_fc_local_thickness_pdf
)
@api.onchange('fischer_file', 'fischer_filename')
def _onchange_fischer_file(self):
"""Try to parse .docx on upload; prefill the readings + summary."""
if not self.fischer_file:
return
name = (self.fischer_filename or '').lower()
if not name.endswith('.docx'):
self.parsed_summary = _(
'Non-.docx upload (%s) — file will be attached as '
'evidence. Type readings manually below if needed.'
) % (self.fischer_filename or 'unnamed')
return
try:
raw = base64.b64decode(self.fischer_file)
except Exception:
self.parsed_summary = _('Could not decode the uploaded file.')
return
parsed = _fp_parse_fischerscope_docx(raw)
readings = parsed.get('readings') or []
if readings:
self.reading_line_ids = [(5, 0, 0)] + [
(0, 0, {
'sequence': i + 1,
'nip_mils': nip,
'ni_percent': ni,
'p_percent': p,
})
for i, (nip, ni, p) in enumerate(readings)
]
self.parsed_summary = _(
'Parsed %(n)d reading(s) · Calibration: %(c)s · '
'Operator: %(o)s · Date: %(d)s %(t)s'
) % {
'n': len(readings),
'c': parsed.get('calibration') or '',
'o': parsed.get('operator') or '',
'd': parsed.get('date_str') or '',
't': parsed.get('time_str') or '',
}
def _apply_to_cert(self):
"""Write this line's data into the cert."""
self.ensure_one()
cert = self.cert_id.sudo()
if not self.fischer_file:
# Just push manual readings, if any.
self._push_readings_to_cert()
return
name = (self.fischer_filename or 'fischerscope').lower()
if name.endswith('.pdf'):
# Drop the PDF into the cert-local field — merges into page 2.
cert.write({
'x_fc_local_thickness_pdf': self.fischer_file,
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
})
else:
# .doc / .docx / anything else — attach as evidence.
self.env['ir.attachment'].sudo().create({
'name': self.fischer_filename or 'fischerscope-report',
'type': 'binary',
'datas': self.fischer_file,
'res_model': 'fp.certificate',
'res_id': cert.id,
})
cert.message_post(body=_(
'Fischerscope file <b>%s</b> attached via Issue wizard.'
) % (self.fischer_filename or 'unnamed'))
self._push_readings_to_cert()
def _push_readings_to_cert(self):
"""Create fp.thickness.reading rows on the cert from wizard rows.
Skips when no rows. Does not deduplicate against existing
readings — the manager has just told us this is the new data."""
self.ensure_one()
Reading = self.env.get('fp.thickness.reading')
if Reading is None or not self.reading_line_ids:
return
for r in self.reading_line_ids:
vals = {
'certificate_id': self.cert_id.id,
'nip_mils': r.nip_mils,
'ni_percent': r.ni_percent,
'p_percent': r.p_percent,
}
if 'reading_number' in Reading._fields:
vals['reading_number'] = r.sequence
Reading.sudo().create(vals)
class FpCertIssueWizardReading(models.TransientModel):
_name = 'fp.cert.issue.wizard.reading'
_description = 'Fusion Plating — Issue Certs Wizard Reading Row'
_order = 'sequence, id'
line_id = fields.Many2one(
'fp.cert.issue.wizard.line', required=True, ondelete='cascade',
)
sequence = fields.Integer(default=1)
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, 3))

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<record id="view_fp_cert_issue_wizard_form" model="ir.ui.view">
<field name="name">fp.cert.issue.wizard.form</field>
<field name="model">fp.cert.issue.wizard</field>
<field name="arch" type="xml">
<form string="Issue Certs">
<sheet>
<div class="oe_title">
<h2>
Issue Certs —
<field name="job_id" readonly="1" nolabel="1"/>
</h2>
</div>
<div class="alert alert-info" role="alert"
invisible="not has_blocking_lines">
<i class="fa fa-info-circle"/>
At least one cert still needs thickness data
(Fischerscope file or readings). Fill it in
below before confirming.
</div>
<field name="line_ids" nolabel="1">
<list editable="bottom" create="false" delete="false">
<field name="cert_name" readonly="1"/>
<field name="cert_type" readonly="1"/>
<field name="partner_id" readonly="1"/>
<field name="needs_thickness" readonly="1"
widget="boolean_toggle"/>
<field name="is_ready" widget="boolean_toggle"
readonly="1"
decoration-success="is_ready"
decoration-danger="not is_ready"/>
</list>
<form>
<header>
<field name="is_ready" widget="statusbar"
statusbar_visible="True,False"/>
</header>
<sheet>
<group>
<group>
<field name="cert_name" readonly="1"/>
<field name="cert_type" readonly="1"/>
</group>
<group>
<field name="partner_id" readonly="1"/>
<field name="needs_thickness"
readonly="1"
widget="boolean_toggle"/>
</group>
</group>
<group string="Fischerscope File"
invisible="not needs_thickness">
<field name="fischer_file"
filename="fischer_filename"/>
<field name="fischer_filename"
invisible="1"/>
</group>
<div class="text-muted"
invisible="not needs_thickness or not parsed_summary">
<field name="parsed_summary"
readonly="1" nolabel="1"/>
</div>
<separator string="Thickness Readings"
invisible="not needs_thickness"/>
<field name="reading_line_ids"
invisible="not needs_thickness">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="nip_mils"/>
<field name="ni_percent"/>
<field name="p_percent"/>
</list>
</field>
</sheet>
</form>
</field>
</sheet>
<footer>
<button name="action_confirm" type="object"
string="Confirm &amp; Issue"
class="btn-primary"
invisible="has_blocking_lines"/>
<button name="action_confirm" type="object"
string="Confirm &amp; Issue"
class="btn-secondary"
invisible="not has_blocking_lines"
disabled="1"
help="One or more certs still need thickness data."/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>