# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # # Phase 3 — parallel job link on fp.certificate. # Coexists with bridge_mrp's production_id link. # # v19.0.6.20.0 — surface the Fischerscope PDF on the cert form so # operators can SEE that the thickness report will be (or has been) # merged into the CoC. The merge logic itself lives in # fusion_plating_certificates/models/fp_certificate.py — this file # only adds the human-readable indicators. from odoo import api, fields, models class FpCertificate(models.Model): _inherit = 'fp.certificate' x_fc_job_id = fields.Many2one( 'fp.job', string='Work Order', index=True, help="Native fp.job link. Coexists with bridge_mrp's production_id.", ) # ---- Fischerscope thickness-PDF visibility (S19) --------------------- # These three fields are computed from the linked job's QC checks so # the cert form can show the operator BEFORE issuing whether a # Fischerscope report is on file and will be appended as page 2. x_fc_thickness_qc_id = fields.Many2one( 'fusion.plating.quality.check', string='Linked QC (Thickness)', compute='_compute_fischer_visibility', help='Quality check on the linked plating job that has a ' 'Fischerscope / XDAL 600 thickness PDF uploaded. Used to ' 'merge that PDF into the CoC on Issue.', ) x_fc_thickness_pdf_id = fields.Many2one( 'ir.attachment', string='Fischerscope PDF', compute='_compute_fischer_visibility', help='Thickness report PDF that will be appended as page 2 of ' 'the CoC when the certificate is issued.', ) x_fc_thickness_status = fields.Selection( [ ('none', 'No PDF Uploaded'), ('pending', 'Will Append on Issue'), ('merged', 'Merged into CoC'), ], string='Thickness Report', compute='_compute_fischer_visibility', help='none = QC has no Fischerscope upload · ' 'pending = will be appended when Issue is clicked · ' 'merged = already in the issued CoC PDF', ) @api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id', 'x_fc_local_thickness_pdf') def _compute_fischer_visibility(self): QC = self.env.get('fusion.plating.quality.check') empty_qc = self.env['fusion.plating.quality.check'] if QC is not None else None empty_att = self.env['ir.attachment'] for rec in self: qc = empty_qc pdf = empty_att status = 'none' # Cert-local upload wins over QC-side PDF (matches the # merge resolution order in fp_certificate.py). if rec.x_fc_local_thickness_pdf: if rec.state == 'issued' and rec.attachment_id: status = 'merged' else: status = 'pending' elif QC is not None and rec.x_fc_job_id: # Same lookup the merge method uses — passed-first, # then any QC with a PDF. qc = QC.sudo().search([ ('job_id', '=', rec.x_fc_job_id.id), ('state', '=', 'passed'), ('thickness_report_pdf_id', '!=', False), ], order='completed_at desc', limit=1) if not qc: qc = QC.sudo().search([ ('job_id', '=', rec.x_fc_job_id.id), ('thickness_report_pdf_id', '!=', False), ], order='create_date desc', limit=1) if qc and qc.thickness_report_pdf_id: pdf = qc.thickness_report_pdf_id if rec.state == 'issued' and rec.attachment_id: status = 'merged' else: status = 'pending' rec.x_fc_thickness_qc_id = qc or empty_qc rec.x_fc_thickness_pdf_id = pdf or empty_att rec.x_fc_thickness_status = status def action_view_thickness_qc(self): """Smart-button target — open the linked QC for inspection.""" self.ensure_one() if not self.x_fc_thickness_qc_id: return False return { 'type': 'ir.actions.act_window', 'name': self.x_fc_thickness_qc_id.name, 'res_model': 'fusion.plating.quality.check', 'res_id': self.x_fc_thickness_qc_id.id, 'view_mode': 'form', 'target': 'current', } def action_open_job(self): """Smart-button target — open the linked plating job.""" self.ensure_one() if not self.x_fc_job_id: return False return { 'type': 'ir.actions.act_window', 'name': self.x_fc_job_id.name, 'res_model': 'fp.job', 'res_id': self.x_fc_job_id.id, 'view_mode': 'form', 'target': 'current', } # ---- Parse-on-upload for the cert-form Fischerscope field (2026-05-28) # The Issue Certs wizard parses .doc/.docx/RTF Fischerscope exports into # readings + metadata + microscope image. Dropping the same file straight # onto the cert form's x_fc_local_thickness_pdf field did nothing — it # just stored the bytes. These hooks give the form the SAME behaviour as # the wizard: on save, a non-PDF upload is parsed and relocated to the # evidence field (a real PDF is left in place to merge as page 2). @api.model_create_multi def create(self, vals_list): recs = super().create(vals_list) for rec in recs: if (rec.x_fc_local_thickness_pdf and not self.env.context.get('fp_skip_thickness_parse')): rec._fp_parse_local_thickness_upload() return recs def write(self, vals): res = super().write(vals) if (vals.get('x_fc_local_thickness_pdf') and not self.env.context.get('fp_skip_thickness_parse')): for rec in self: rec._fp_parse_local_thickness_upload() return res def _fp_parse_local_thickness_upload(self): """Parse a Fischerscope .doc/.docx/RTF dropped on the cert form's x_fc_local_thickness_pdf field, exactly like the Issue Certs wizard: extract readings → thickness_reading_ids, header metadata → x_fc_thickness_* fields, microscope image → x_fc_thickness_image_id, then relocate the non-PDF source to x_fc_local_thickness_evidence_id and clear the PDF field (so the page-2 merge doesn't choke on it). A real PDF is left in place — it merges as page 2 of the CoC on Issue and carries no parseable readings. Unknown non-PDF types are left untouched. """ import base64 from datetime import datetime self.ensure_one() if not self.x_fc_local_thickness_pdf: return try: raw = base64.b64decode(self.x_fc_local_thickness_pdf) except Exception: return # Real PDF → leave it (merges as page 2). XDAL 600 names RTF files # ".doc"; detect by magic bytes, not extension (see CLAUDE.md). if raw[:4] == b'%PDF': return name = (self.x_fc_local_thickness_pdf_filename or '').lower() is_rtf = raw[:5] == b'{\\rtf' is_docx = name.endswith('.docx') if not (is_rtf or is_docx): return # unknown non-PDF — don't guess from ..wizards.fp_cert_issue_wizard import ( _fp_parse_fischerscope_rtf, _fp_parse_fischerscope_docx, _fp_extract_rtf_images, _fp_pick_microscope_image, ) parsed = (_fp_parse_fischerscope_rtf(raw) if is_rtf else _fp_parse_fischerscope_docx(raw)) vals = {} for fname, fval in ( ('x_fc_thickness_operator', parsed.get('operator')), ('x_fc_thickness_product', parsed.get('product')), ('x_fc_thickness_directory', parsed.get('directory')), ('x_fc_thickness_application', parsed.get('application')), ('x_fc_thickness_measuring_time_sec', parsed.get('measuring_time_sec') or 0), ('x_fc_thickness_equipment', parsed.get('equipment') or 'Fischerscope XDAL 600'), ('x_fc_thickness_source_filename', self.x_fc_local_thickness_pdf_filename or ''), ): if fname in self._fields and fval: vals[fname] = fval date_str = (parsed.get('date_str') or '').strip() time_str = (parsed.get('time_str') or '').strip() if date_str and 'x_fc_thickness_datetime' in self._fields: combined = ('%s %s' % (date_str, time_str)).strip() for fmt in ( '%m/%d/%Y %I:%M:%S %p', '%m/%d/%Y %I:%M %p', '%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M', '%m/%d/%Y', ): try: vals['x_fc_thickness_datetime'] = datetime.strptime( combined, fmt, ) break except ValueError: continue # Readings — replace any existing set with the freshly-parsed rows # (the uploaded report is authoritative for this cert). readings = parsed.get('readings') or [] Reading = self.env.get('fp.thickness.reading') if readings and Reading is not None: calibration = parsed.get('calibration') or '' cmds = [(5, 0, 0)] for i, (nip, ni, p) in enumerate(readings): rvals = {'nip_mils': nip, 'ni_percent': ni, 'p_percent': p} if 'reading_number' in Reading._fields: rvals['reading_number'] = i + 1 if calibration and 'calibration_std_ref' in Reading._fields: rvals['calibration_std_ref'] = calibration cmds.append((0, 0, rvals)) vals['thickness_reading_ids'] = cmds # Relocate the non-PDF source to the evidence slot + clear the PDF # field (mirrors the wizard's non-PDF end state). att = self.env['ir.attachment'].sudo().create({ 'name': self.x_fc_local_thickness_pdf_filename or 'fischerscope-report', 'type': 'binary', 'datas': self.x_fc_local_thickness_pdf, 'res_model': self._name, 'res_id': self.id, }) if 'x_fc_local_thickness_evidence_id' in self._fields: vals['x_fc_local_thickness_evidence_id'] = att.id vals['x_fc_local_thickness_pdf'] = False vals['x_fc_local_thickness_pdf_filename'] = False # Microscope image (RTF only — .docx images need a different path). if is_rtf and 'x_fc_thickness_image_id' in self._fields: try: pngs = _fp_extract_rtf_images(raw) img_bytes, img_w, img_h = _fp_pick_microscope_image(pngs) if img_bytes: img_att = self.env['ir.attachment'].sudo().create({ 'name': '%s-microscope.png' % ( (self.x_fc_local_thickness_pdf_filename or 'fischerscope').rsplit('.', 1)[0] ), 'type': 'binary', 'datas': base64.b64encode(img_bytes), 'mimetype': 'image/png', 'res_model': self._name, 'res_id': self.id, }) vals['x_fc_thickness_image_id'] = img_att.id except Exception: pass self.with_context(fp_skip_thickness_parse=True).write(vals) from markupsafe import Markup from odoo import _ self.message_post(body=Markup(_( 'Fischerscope file %s parsed from the cert form: ' '%d reading(s) extracted.' )) % ( self.x_fc_thickness_source_filename or name or 'unnamed', len(readings), ))