feat(plating): QC gate + mobile checklist + Fischerscope thickness capture
Phase 1 — Backend QC gate (bridge_mrp)
* fp.qc.checklist.template / .line — per-customer checklist definitions
* fusion.plating.quality.check / .line — per-MO instances walked by inspectors
* res.partner.x_fc_requires_qc + x_fc_qc_template_id toggles policy per customer
* mrp.production.button_mark_done blocks close until QC passes (plus optional
thickness-readings + thickness-PDF gates on aerospace templates)
* Auto-spawns the QC on MO confirm from the customer's resolved template
* Fischerscope XDAL 600 PDF parser auto-extracts NiP / Ni% / P% readings on upload
* fp.thickness.reading gains quality_check_id + auto_extracted
Phase 2 — Mobile QC checklist (OWL client action)
* fp_qc_checklist registered under registry.category("actions")
* Reuses shopfloor design tokens (_fp_shopfloor_tokens.scss) — 48 px touch
targets, shadow-based elevation, three-tier contrast, light + dark bundles
* Per-line pass/fail/N/A with numeric value range, mandatory photo, notes
* Fischerscope PDF drop-zone → server-side pdftotext parse
* Sign-off bar with pass / fail / rework actions
Phase 3 — Admin config
* Starter global default + aerospace/Nadcap templates seeded
* Plating → Configuration → QC Checklist Templates (manager-only)
* Plating → Quality → Quality Checks menu
* "Plating Documents" tab on res.partner gains the QC toggle + template picker
* MO form smart button opens the active QC in the mobile checklist
Gap fixes
* Scanner handles FP-QC:<ref> and FP-MO:<name> — launches the checklist
directly on the tablet
* action_spawn_retry clones a fresh QC from a failed one so rework doesn't
need a new MO
All 12 models / routes / gates smoke + E2E tested: 24 assertions pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,3 +19,7 @@ from . import fp_work_role
|
||||
from . import hr_employee
|
||||
from . import fp_proficiency
|
||||
from . import fp_process_node
|
||||
from . import fp_qc_template
|
||||
from . import fp_quality_check
|
||||
from . import fp_thickness_reading
|
||||
from . import res_partner
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""QC Checklist Template — admin config for per-customer QC requirements.
|
||||
|
||||
Customers differ wildly in what they expect from quality control:
|
||||
* commercial job-shop accounts often just want "did it plate?" — one
|
||||
visual check
|
||||
* aerospace / Nadcap customers expect visual, dimensional,
|
||||
adhesion, and Fischerscope thickness readings — every part, every
|
||||
lot, signed off
|
||||
* internal rework jobs may have no QC requirement at all
|
||||
|
||||
Rather than coding that policy into the shop, each customer gets their
|
||||
own checklist template. On MO confirm, the active template is cloned
|
||||
into a fresh `fusion.plating.quality.check` — the instance operators
|
||||
actually fill in.
|
||||
"""
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class FpQcChecklistTemplate(models.Model):
|
||||
_name = 'fp.qc.checklist.template'
|
||||
_description = 'Fusion Plating — QC Checklist Template'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'partner_id, sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Template Name', required=True, tracking=True,
|
||||
help='e.g. "Standard Aerospace CoC + Thickness" or '
|
||||
'"Commercial — Visual Only".',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
domain="[('customer_rank', '>', 0)]",
|
||||
help='Leave blank for the global default template. A customer-'
|
||||
'specific template wins over the default when both exist.',
|
||||
tracking=True,
|
||||
)
|
||||
notes = fields.Html(
|
||||
string='Notes',
|
||||
help='Context for QC inspectors — what this customer cares '
|
||||
'about, common reject reasons, spec docs to reference.',
|
||||
)
|
||||
|
||||
line_ids = fields.One2many(
|
||||
'fp.qc.checklist.template.line', 'template_id',
|
||||
string='Checklist Items', copy=True,
|
||||
)
|
||||
|
||||
# ---- Gate requirements beyond individual checklist items ----
|
||||
require_thickness_readings = fields.Boolean(
|
||||
string='Require Thickness Readings', default=False, tracking=True,
|
||||
help='MO cannot be marked done unless at least one '
|
||||
'fp.thickness.reading is logged against it. Use for '
|
||||
'aerospace / Nadcap accounts.',
|
||||
)
|
||||
require_thickness_report_pdf = fields.Boolean(
|
||||
string='Require Thickness Report PDF', default=False, tracking=True,
|
||||
help='MO cannot be marked done unless the operator has '
|
||||
'uploaded the Fischerscope / XDAL 600 PDF report to the '
|
||||
'quality check.',
|
||||
)
|
||||
require_inspector_signoff = fields.Boolean(
|
||||
string='Require Inspector Sign-off', default=True, tracking=True,
|
||||
help='The quality check itself must be in the "passed" state '
|
||||
'(not just draft or in-progress).',
|
||||
)
|
||||
|
||||
check_count = fields.Integer(
|
||||
string='# QC Checks Created', compute='_compute_check_count',
|
||||
)
|
||||
|
||||
def _compute_check_count(self):
|
||||
Check = self.env['fusion.plating.quality.check']
|
||||
for rec in self:
|
||||
rec.check_count = Check.search_count([
|
||||
('template_id', '=', rec.id),
|
||||
])
|
||||
|
||||
@api.model
|
||||
def resolve_for_partner(self, partner):
|
||||
"""Return the best-matching template for a customer.
|
||||
|
||||
Order: active customer-specific template > active default template >
|
||||
None (no QC required).
|
||||
"""
|
||||
if partner:
|
||||
specific = self.search([
|
||||
('partner_id', '=', partner.id),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
if specific:
|
||||
return specific
|
||||
return self.search([
|
||||
('partner_id', '=', False),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
|
||||
def action_view_checks(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('QC Checks — %s') % self.name,
|
||||
'res_model': 'fusion.plating.quality.check',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('template_id', '=', self.id)],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
|
||||
class FpQcChecklistTemplateLine(models.Model):
|
||||
_name = 'fp.qc.checklist.template.line'
|
||||
_description = 'Fusion Plating — QC Checklist Template Line'
|
||||
_order = 'sequence, id'
|
||||
|
||||
template_id = fields.Many2one(
|
||||
'fp.qc.checklist.template', string='Template',
|
||||
required=True, ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
name = fields.Char(
|
||||
string='Check Item', required=True, translate=True,
|
||||
help='The operator-facing question, e.g. "No visible pits or '
|
||||
'blemishes on surface", "Thickness within 0.0005–0.0010".',
|
||||
)
|
||||
description = fields.Text(
|
||||
string='Inspection Guidance',
|
||||
help='Extra detail shown on the tablet when the operator taps '
|
||||
'the item. Use for photos-to-compare-against, acceptable-'
|
||||
'colour ranges, how to position the part, etc.',
|
||||
)
|
||||
check_type = fields.Selection(
|
||||
[
|
||||
('visual', 'Visual Inspection'),
|
||||
('dimensional', 'Dimensional'),
|
||||
('thickness', 'Thickness'),
|
||||
('adhesion', 'Adhesion'),
|
||||
('hardness', 'Hardness'),
|
||||
('salt_spray', 'Salt Spray'),
|
||||
('functional', 'Functional'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Check Type', default='visual', required=True,
|
||||
)
|
||||
required = fields.Boolean(
|
||||
string='Required', default=True,
|
||||
help='If off, the inspector can skip this item without blocking '
|
||||
'the QC from passing.',
|
||||
)
|
||||
requires_value = fields.Boolean(
|
||||
string='Requires Numeric Value', default=False,
|
||||
help='Inspector must enter a measurement. If min/max are set, '
|
||||
'the reading must fall inside to count as pass.',
|
||||
)
|
||||
value_min = fields.Float(string='Min Value', digits=(12, 4))
|
||||
value_max = fields.Float(string='Max Value', digits=(12, 4))
|
||||
value_uom = fields.Char(
|
||||
string='Unit',
|
||||
help='Free text. e.g. "mils", "microns", "HV", "µm".',
|
||||
)
|
||||
requires_photo = fields.Boolean(
|
||||
string='Requires Photo', default=False,
|
||||
help='Inspector must attach a photo of the part.',
|
||||
)
|
||||
@@ -0,0 +1,622 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Per-MO QC instance.
|
||||
|
||||
When an MO confirms and the customer requires QC, we clone the active
|
||||
checklist template into a `fusion.plating.quality.check` with one line
|
||||
per template line. The inspector picks it up on the tablet, walks the
|
||||
checks, and signs off — which unblocks `mrp.production.button_mark_done`.
|
||||
|
||||
The QC also owns the Fischerscope / XDAL 600 thickness report PDF.
|
||||
When the operator uploads one, we extract per-reading data server-side
|
||||
and auto-create `fp.thickness.reading` rows so the CoC PDF picks them up.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpQualityCheck(models.Model):
|
||||
_name = 'fusion.plating.quality.check'
|
||||
_description = 'Fusion Plating — Quality Check'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'create_date desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference', required=True, copy=False, readonly=True,
|
||||
default=lambda self: self._default_name(), tracking=True,
|
||||
)
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production', string='Manufacturing Order',
|
||||
required=True, ondelete='cascade', tracking=True,
|
||||
index=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
compute='_compute_partner_id', store=True,
|
||||
)
|
||||
template_id = fields.Many2one(
|
||||
'fp.qc.checklist.template', string='Template',
|
||||
help='The checklist template these lines were cloned from.',
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
('in_progress', 'In Progress'),
|
||||
('passed', 'Passed'),
|
||||
('failed', 'Failed'),
|
||||
('rework', 'Rework Required'),
|
||||
],
|
||||
string='Status', default='draft', required=True, tracking=True,
|
||||
)
|
||||
overall_result = fields.Selection(
|
||||
[('pass', 'Pass'), ('fail', 'Fail'), ('partial', 'Partial Pass')],
|
||||
string='Result', tracking=True,
|
||||
help='Summary outcome — set when inspector signs off.',
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fusion.plating.quality.check.line', 'check_id',
|
||||
string='Check Items',
|
||||
)
|
||||
line_count = fields.Integer(compute='_compute_line_stats', store=True)
|
||||
lines_passed = fields.Integer(compute='_compute_line_stats', store=True)
|
||||
lines_failed = fields.Integer(compute='_compute_line_stats', store=True)
|
||||
lines_pending = fields.Integer(compute='_compute_line_stats', store=True)
|
||||
|
||||
inspector_id = fields.Many2one(
|
||||
'res.users', string='Inspector',
|
||||
help='Whoever signed the QC off. Filled when state moves to '
|
||||
'passed/failed.',
|
||||
tracking=True,
|
||||
)
|
||||
started_at = fields.Datetime(
|
||||
string='Started', help='First time inspector opened this check.',
|
||||
)
|
||||
completed_at = fields.Datetime(
|
||||
string='Completed', help='When the check was signed off.',
|
||||
tracking=True,
|
||||
)
|
||||
notes = fields.Html(string='Inspector Notes')
|
||||
|
||||
# Fischerscope / XDAL 600 PDF + auto-extracted readings
|
||||
thickness_report_pdf_id = fields.Many2one(
|
||||
'ir.attachment', string='Thickness Report PDF',
|
||||
help='Upload the Fischerscope / XDAL 600 export. On upload we '
|
||||
'parse the PDF and auto-create fp.thickness.reading rows.',
|
||||
)
|
||||
thickness_reading_ids = fields.One2many(
|
||||
'fp.thickness.reading', 'quality_check_id',
|
||||
string='Thickness Readings',
|
||||
)
|
||||
thickness_reading_count = fields.Integer(
|
||||
compute='_compute_thickness_count',
|
||||
)
|
||||
|
||||
# Cached gate-policy flags from the template (denormalized so
|
||||
# button_mark_done doesn't have to reach through a potentially-null
|
||||
# template).
|
||||
require_thickness_readings = fields.Boolean(
|
||||
related='template_id.require_thickness_readings',
|
||||
store=True, readonly=True,
|
||||
)
|
||||
require_thickness_report_pdf = fields.Boolean(
|
||||
related='template_id.require_thickness_report_pdf',
|
||||
store=True, readonly=True,
|
||||
)
|
||||
require_inspector_signoff = fields.Boolean(
|
||||
related='template_id.require_inspector_signoff',
|
||||
store=True, readonly=True,
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', related='production_id.company_id',
|
||||
store=True, readonly=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Computed
|
||||
# ------------------------------------------------------------------
|
||||
@api.depends('production_id.origin')
|
||||
def _compute_partner_id(self):
|
||||
SO = self.env['sale.order']
|
||||
for rec in self:
|
||||
partner = False
|
||||
mo = rec.production_id
|
||||
if mo and mo.origin:
|
||||
so = SO.search([('name', '=', mo.origin)], limit=1)
|
||||
if so:
|
||||
partner = so.partner_id
|
||||
rec.partner_id = partner
|
||||
|
||||
@api.depends('line_ids.result')
|
||||
def _compute_line_stats(self):
|
||||
for rec in self:
|
||||
rec.line_count = len(rec.line_ids)
|
||||
rec.lines_passed = len(rec.line_ids.filtered(
|
||||
lambda l: l.result == 'pass'
|
||||
))
|
||||
rec.lines_failed = len(rec.line_ids.filtered(
|
||||
lambda l: l.result == 'fail'
|
||||
))
|
||||
rec.lines_pending = len(rec.line_ids.filtered(
|
||||
lambda l: l.result in (False, 'pending')
|
||||
))
|
||||
|
||||
@api.depends('thickness_reading_ids')
|
||||
def _compute_thickness_count(self):
|
||||
for rec in self:
|
||||
rec.thickness_reading_count = len(rec.thickness_reading_ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create + sequence
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code(
|
||||
'fusion.plating.quality.check',
|
||||
)
|
||||
return seq or 'QC/NEW'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name') or vals.get('name') == '/':
|
||||
vals['name'] = self._default_name()
|
||||
return super().create(vals_list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Factory — spawn a QC from a template
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def create_for_production(self, production, template=None):
|
||||
"""Spin up a QC record for an MO, cloning lines from the template.
|
||||
|
||||
If no template is passed, we try to resolve one from the MO's
|
||||
customer. Returns the created check, or an empty recordset if
|
||||
no template matches (=> no QC required for this customer).
|
||||
"""
|
||||
self = self.sudo()
|
||||
if template is None:
|
||||
partner = False
|
||||
if production.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', production.origin)], limit=1,
|
||||
)
|
||||
if so:
|
||||
partner = so.partner_id
|
||||
template = self.env['fp.qc.checklist.template'].resolve_for_partner(
|
||||
partner,
|
||||
)
|
||||
if not template:
|
||||
return self.browse() # empty — no QC required
|
||||
|
||||
# Avoid duplicates — one active (non-failed) check per MO
|
||||
existing = self.search([
|
||||
('production_id', '=', production.id),
|
||||
('state', '!=', 'failed'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
check = self.create({
|
||||
'production_id': production.id,
|
||||
'template_id': template.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
Line = self.env['fusion.plating.quality.check.line']
|
||||
for tline in template.line_ids.sorted('sequence'):
|
||||
Line.create({
|
||||
'check_id': check.id,
|
||||
'sequence': tline.sequence,
|
||||
'name': tline.name,
|
||||
'description': tline.description,
|
||||
'check_type': tline.check_type,
|
||||
'required': tline.required,
|
||||
'requires_value': tline.requires_value,
|
||||
'value_min': tline.value_min,
|
||||
'value_max': tline.value_max,
|
||||
'value_uom': tline.value_uom,
|
||||
'requires_photo': tline.requires_photo,
|
||||
'result': 'pending',
|
||||
})
|
||||
production.message_post(
|
||||
body=_('QC checklist "%s" created — %d items to inspect.') % (
|
||||
template.name, len(template.line_ids),
|
||||
),
|
||||
)
|
||||
return check
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State actions
|
||||
# ------------------------------------------------------------------
|
||||
def action_start(self):
|
||||
for rec in self:
|
||||
if rec.state == 'draft':
|
||||
rec.write({
|
||||
'state': 'in_progress',
|
||||
'started_at': fields.Datetime.now(),
|
||||
'inspector_id': self.env.user.id,
|
||||
})
|
||||
rec.message_post(body=_('QC started.'))
|
||||
|
||||
def action_pass(self):
|
||||
for rec in self:
|
||||
rec._ensure_all_required_complete()
|
||||
rec.write({
|
||||
'state': 'passed',
|
||||
'overall_result': 'pass',
|
||||
'completed_at': fields.Datetime.now(),
|
||||
'inspector_id': self.env.user.id,
|
||||
})
|
||||
rec.message_post(body=Markup(
|
||||
'<b>QC PASSED</b> — inspector %s.'
|
||||
) % self.env.user.name)
|
||||
|
||||
def action_fail(self):
|
||||
for rec in self:
|
||||
rec.write({
|
||||
'state': 'failed',
|
||||
'overall_result': 'fail',
|
||||
'completed_at': fields.Datetime.now(),
|
||||
'inspector_id': self.env.user.id,
|
||||
})
|
||||
rec.message_post(body=Markup(
|
||||
'<b>QC FAILED</b> — inspector %s.'
|
||||
) % self.env.user.name)
|
||||
|
||||
def action_rework(self):
|
||||
for rec in self:
|
||||
rec.write({
|
||||
'state': 'rework',
|
||||
'overall_result': 'partial',
|
||||
'completed_at': fields.Datetime.now(),
|
||||
'inspector_id': self.env.user.id,
|
||||
})
|
||||
rec.message_post(body=_('QC flagged for rework.'))
|
||||
|
||||
def action_reset_to_draft(self):
|
||||
for rec in self:
|
||||
rec.write({
|
||||
'state': 'draft',
|
||||
'overall_result': False,
|
||||
'completed_at': False,
|
||||
})
|
||||
|
||||
def action_spawn_retry(self):
|
||||
"""Spin up a fresh QC instance for the same MO.
|
||||
|
||||
Used after a failed QC — the original stays in history, the
|
||||
new one gets the same template applied to a clean slate.
|
||||
Manager-only via ACL.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state != 'failed':
|
||||
return # no-op; user can just finish the existing one
|
||||
new_check = self.sudo().create_for_production(
|
||||
self.production_id, template=self.template_id,
|
||||
)
|
||||
if not new_check:
|
||||
return False
|
||||
self.message_post(body=_(
|
||||
'Retry QC created: %s'
|
||||
) % new_check.name)
|
||||
new_check.message_post(body=_(
|
||||
'Retry of failed QC %s'
|
||||
) % self.name)
|
||||
return new_check.action_open_tablet()
|
||||
|
||||
def _ensure_all_required_complete(self):
|
||||
"""Guard for action_pass — every required line must be resolved
|
||||
to pass or n/a (fail would be handled by action_fail) and any
|
||||
numeric-value / photo requirements must be honoured."""
|
||||
for rec in self:
|
||||
pending = rec.line_ids.filtered(
|
||||
lambda l: l.required and l.result in (False, 'pending')
|
||||
)
|
||||
if pending:
|
||||
raise UserError(_(
|
||||
'Cannot pass QC "%(name)s" — %(n)d required check '
|
||||
'item(s) still pending:\n • %(items)s'
|
||||
) % {
|
||||
'name': rec.name,
|
||||
'n': len(pending),
|
||||
'items': '\n • '.join(pending.mapped('name')),
|
||||
})
|
||||
failed = rec.line_ids.filtered(lambda l: l.result == 'fail')
|
||||
if failed:
|
||||
raise UserError(_(
|
||||
'Cannot pass QC "%(name)s" — %(n)d check item(s) '
|
||||
'failed. Fail the QC instead, or reset those '
|
||||
'items to pass.'
|
||||
) % {'name': rec.name, 'n': len(failed)})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fischerscope PDF upload → auto-extract readings
|
||||
# ------------------------------------------------------------------
|
||||
def _on_thickness_pdf_uploaded(self):
|
||||
"""Parse the attached PDF with `pdftotext` and create
|
||||
fp.thickness.reading rows.
|
||||
|
||||
Fischerscope XDAL 600 / WinFTM reports vary a bit in layout
|
||||
but consistently print one line per reading with a column for
|
||||
NiP thickness in mils and another for Ni / P percentages. The
|
||||
parser is conservative: if a column isn't confidently found,
|
||||
we skip that reading rather than write garbage.
|
||||
"""
|
||||
ThicknessReading = self.env['fp.thickness.reading']
|
||||
for rec in self:
|
||||
if not rec.thickness_report_pdf_id:
|
||||
continue
|
||||
try:
|
||||
text = rec._extract_pdf_text(rec.thickness_report_pdf_id)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'QC %s: pdftotext extraction failed', rec.name,
|
||||
)
|
||||
continue
|
||||
|
||||
readings = rec._parse_fischerscope_text(text)
|
||||
if not readings:
|
||||
rec.message_post(body=_(
|
||||
'Thickness report PDF attached but no readings '
|
||||
'could be extracted automatically. Please enter '
|
||||
'readings manually.'
|
||||
))
|
||||
continue
|
||||
|
||||
# Replace any prior auto-extracted readings so re-uploads
|
||||
# don't stack duplicates.
|
||||
auto = rec.thickness_reading_ids.filtered(
|
||||
lambda r: r.auto_extracted
|
||||
)
|
||||
auto.unlink()
|
||||
|
||||
for idx, row in enumerate(readings, start=1):
|
||||
ThicknessReading.create({
|
||||
'quality_check_id': rec.id,
|
||||
'production_id': rec.production_id.id,
|
||||
'reading_number': idx,
|
||||
'nip_mils': row.get('nip_mils', 0.0),
|
||||
'ni_percent': row.get('ni_percent', 0.0),
|
||||
'p_percent': row.get('p_percent', 0.0),
|
||||
'position_label': row.get('position', ''),
|
||||
'auto_extracted': True,
|
||||
})
|
||||
rec.message_post(body=_(
|
||||
'Extracted %d thickness reading(s) from "%s".'
|
||||
) % (len(readings), rec.thickness_report_pdf_id.name))
|
||||
|
||||
@staticmethod
|
||||
def _extract_pdf_text(attachment):
|
||||
"""Run pdftotext on an ir.attachment and return the text."""
|
||||
raw = base64.b64decode(attachment.datas or b'')
|
||||
if not raw:
|
||||
return ''
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix='.pdf', delete=True,
|
||||
) as tmp:
|
||||
tmp.write(raw)
|
||||
tmp.flush()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pdftotext', '-layout', tmp.name, '-'],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
return result.stdout or ''
|
||||
except FileNotFoundError:
|
||||
_logger.warning(
|
||||
'pdftotext not installed — cannot auto-extract '
|
||||
'Fischerscope PDF data. Install poppler-utils on '
|
||||
'the Odoo host.',
|
||||
)
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def _parse_fischerscope_text(text):
|
||||
"""Best-effort Fischerscope WinFTM table parser.
|
||||
|
||||
WinFTM single-reading export lines look like:
|
||||
n=1 0.000843 mils 91.5% Ni 8.5% P 120s
|
||||
or (with labels bleeding together from the PDF layout):
|
||||
1 0.000843 91.5 8.5 Pos 1
|
||||
|
||||
We match any row that has 1–4 floating-point numbers after a
|
||||
reading index. The heuristic stays narrow enough that it won't
|
||||
eat header rows like "Measuring time 120s" or junk lines.
|
||||
"""
|
||||
readings = []
|
||||
# Row: <index> <nip-mils-or-microns> <ni%> <p%>
|
||||
# Indices may appear as "n=1", "1.", "1", "N1"
|
||||
row_re = re.compile(
|
||||
r'^\s*(?:n\s*=\s*|N\s*)?(\d{1,3})[\s.:]+'
|
||||
r'([0-9]*\.[0-9]+|\d+)' # nip
|
||||
r'(?:\s*(?:mils|microns|µm|um))?'
|
||||
r'[\s|]+'
|
||||
r'([0-9]*\.?[0-9]+)' # ni%
|
||||
r'[\s|%]+'
|
||||
r'([0-9]*\.?[0-9]+)' # p%
|
||||
r'[\s|%]*'
|
||||
r'(.*)$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
m = row_re.match(line)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
idx = int(m.group(1))
|
||||
nip = float(m.group(2))
|
||||
ni = float(m.group(3))
|
||||
p = float(m.group(4))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
# Sanity guards — NiP > 1 mil is unheard of on plating;
|
||||
# Ni% and P% should sum to ~100.
|
||||
if not (0 < nip < 1) and not (0 < nip < 30): # 30µm envelope
|
||||
continue
|
||||
if not (0 < ni < 100):
|
||||
continue
|
||||
if not (0 < p < 30):
|
||||
continue
|
||||
# Throw out rows where index is obviously wrong
|
||||
if idx < 1 or idx > 500:
|
||||
continue
|
||||
position = (m.group(5) or '').strip()[:60]
|
||||
readings.append({
|
||||
'index': idx,
|
||||
'nip_mils': nip,
|
||||
'ni_percent': ni,
|
||||
'p_percent': p,
|
||||
'position': position,
|
||||
})
|
||||
# Keep only one reading per index (first wins)
|
||||
seen = set()
|
||||
dedup = []
|
||||
for r in readings:
|
||||
if r['index'] in seen:
|
||||
continue
|
||||
seen.add(r['index'])
|
||||
dedup.append(r)
|
||||
return dedup
|
||||
|
||||
def write(self, vals):
|
||||
trigger = 'thickness_report_pdf_id' in vals and vals.get(
|
||||
'thickness_report_pdf_id'
|
||||
)
|
||||
res = super().write(vals)
|
||||
if trigger:
|
||||
self._on_thickness_pdf_uploaded()
|
||||
return res
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Navigation helpers
|
||||
# ------------------------------------------------------------------
|
||||
def action_open_tablet(self):
|
||||
"""Launch the mobile QC checklist OWL client action."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_qc_checklist',
|
||||
'name': _('QC — %s') % (self.production_id.name or ''),
|
||||
'params': {'check_id': self.id},
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
|
||||
class FpQualityCheckLine(models.Model):
|
||||
_name = 'fusion.plating.quality.check.line'
|
||||
_description = 'Fusion Plating — Quality Check Line'
|
||||
_order = 'sequence, id'
|
||||
|
||||
check_id = fields.Many2one(
|
||||
'fusion.plating.quality.check', string='Check',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
name = fields.Char(string='Check Item', required=True)
|
||||
description = fields.Text(string='Guidance')
|
||||
check_type = fields.Selection(
|
||||
selection=lambda self: self.env[
|
||||
'fp.qc.checklist.template.line'
|
||||
]._fields['check_type'].selection,
|
||||
string='Type', default='visual',
|
||||
)
|
||||
required = fields.Boolean(default=True)
|
||||
requires_value = fields.Boolean()
|
||||
value = fields.Float(digits=(12, 4))
|
||||
value_min = fields.Float(digits=(12, 4))
|
||||
value_max = fields.Float(digits=(12, 4))
|
||||
value_uom = fields.Char(string='Unit')
|
||||
requires_photo = fields.Boolean()
|
||||
photo_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='Photo',
|
||||
)
|
||||
result = fields.Selection(
|
||||
[
|
||||
('pending', 'Pending'),
|
||||
('pass', 'Pass'),
|
||||
('fail', 'Fail'),
|
||||
('na', 'N/A'),
|
||||
],
|
||||
string='Result', default='pending', required=True,
|
||||
)
|
||||
notes = fields.Text(string='Note')
|
||||
inspector_id = fields.Many2one('res.users', string='Inspector')
|
||||
completed_at = fields.Datetime(string='Completed At')
|
||||
|
||||
value_in_range = fields.Boolean(
|
||||
compute='_compute_value_in_range', store=True,
|
||||
)
|
||||
|
||||
@api.depends('value', 'value_min', 'value_max', 'requires_value')
|
||||
def _compute_value_in_range(self):
|
||||
for rec in self:
|
||||
if not rec.requires_value:
|
||||
rec.value_in_range = True
|
||||
continue
|
||||
vmin = rec.value_min
|
||||
vmax = rec.value_max
|
||||
if vmin and rec.value < vmin:
|
||||
rec.value_in_range = False
|
||||
elif vmax and rec.value > vmax:
|
||||
rec.value_in_range = False
|
||||
else:
|
||||
rec.value_in_range = True
|
||||
|
||||
def action_mark_pass(self):
|
||||
for rec in self:
|
||||
if rec.requires_value and not rec.value_in_range:
|
||||
raise UserError(_(
|
||||
'Cannot pass "%(item)s" — value %(val)s is outside '
|
||||
'the acceptance range (%(min)s – %(max)s %(uom)s).'
|
||||
) % {
|
||||
'item': rec.name,
|
||||
'val': rec.value,
|
||||
'min': rec.value_min,
|
||||
'max': rec.value_max,
|
||||
'uom': rec.value_uom or '',
|
||||
})
|
||||
if rec.requires_photo and not rec.photo_attachment_id:
|
||||
raise UserError(_(
|
||||
'Cannot pass "%(item)s" — a photo is required.'
|
||||
) % {'item': rec.name})
|
||||
rec.write({
|
||||
'result': 'pass',
|
||||
'inspector_id': self.env.user.id,
|
||||
'completed_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
def action_mark_fail(self):
|
||||
for rec in self:
|
||||
rec.write({
|
||||
'result': 'fail',
|
||||
'inspector_id': self.env.user.id,
|
||||
'completed_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
def action_mark_na(self):
|
||||
for rec in self:
|
||||
if rec.required:
|
||||
raise UserError(_(
|
||||
'"%(item)s" is a required check and cannot be '
|
||||
'marked N/A.'
|
||||
) % {'item': rec.name})
|
||||
rec.write({
|
||||
'result': 'na',
|
||||
'inspector_id': self.env.user.id,
|
||||
'completed_at': fields.Datetime.now(),
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Link Fischerscope thickness readings to the new quality check.
|
||||
|
||||
Keeps the base model in fusion_plating_certificates unchanged; this
|
||||
bridge module just adds the back-reference to `quality_check_id` and
|
||||
the `auto_extracted` flag so auto-extracted readings can be replaced
|
||||
on a re-upload without touching manually-entered data.
|
||||
"""
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpThicknessReading(models.Model):
|
||||
_inherit = 'fp.thickness.reading'
|
||||
|
||||
quality_check_id = fields.Many2one(
|
||||
'fusion.plating.quality.check', string='Quality Check',
|
||||
ondelete='set null', index=True,
|
||||
help='The QC record the reading belongs to (populated when '
|
||||
'readings are logged from the mobile QC checklist).',
|
||||
)
|
||||
auto_extracted = fields.Boolean(
|
||||
string='Auto-Extracted',
|
||||
help='True for readings parsed out of a Fischerscope PDF. '
|
||||
'These are replaced when the PDF is re-uploaded; '
|
||||
'manually-entered readings are preserved.',
|
||||
)
|
||||
@@ -58,6 +58,37 @@ class MrpProduction(models.Model):
|
||||
compute='_compute_override_count',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Quality Control gate (Phase 1 — 2026-04-20)
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_qc_check_ids = fields.One2many(
|
||||
'fusion.plating.quality.check', 'production_id',
|
||||
string='Quality Checks',
|
||||
)
|
||||
x_fc_active_qc_check_id = fields.Many2one(
|
||||
'fusion.plating.quality.check', string='Active QC',
|
||||
compute='_compute_active_qc', store=True,
|
||||
)
|
||||
x_fc_qc_state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
('in_progress', 'In Progress'),
|
||||
('passed', 'Passed'),
|
||||
('failed', 'Failed'),
|
||||
('rework', 'Rework Required'),
|
||||
],
|
||||
string='QC State', compute='_compute_active_qc',
|
||||
store=True, readonly=True,
|
||||
)
|
||||
x_fc_qc_required = fields.Boolean(
|
||||
string='QC Required', compute='_compute_qc_required',
|
||||
help='Computed from the customer on this MO — true when the '
|
||||
'customer has "Require QC Sign-off" turned on.',
|
||||
)
|
||||
x_fc_qc_check_count = fields.Integer(
|
||||
compute='_compute_qc_check_count',
|
||||
)
|
||||
|
||||
# ---- WO grouping + start-at-node (from direct-order wizard Phases B/C) ----
|
||||
x_fc_wo_group_tag = fields.Char(
|
||||
string='WO Group Tag',
|
||||
@@ -302,6 +333,40 @@ class MrpProduction(models.Model):
|
||||
for rec in self:
|
||||
rec.x_fc_override_count = len(rec.x_fc_override_ids)
|
||||
|
||||
@api.depends('x_fc_qc_check_ids', 'x_fc_qc_check_ids.state')
|
||||
def _compute_active_qc(self):
|
||||
for rec in self:
|
||||
# The "active" QC is the most recently created check that
|
||||
# isn't a failed/cancelled one. A failed QC spawns a new
|
||||
# draft on the next rework cycle; the old failed record
|
||||
# stays in history.
|
||||
active = rec.x_fc_qc_check_ids.filtered(
|
||||
lambda c: c.state != 'failed'
|
||||
).sorted('create_date', reverse=True)[:1]
|
||||
if not active:
|
||||
active = rec.x_fc_qc_check_ids.sorted(
|
||||
'create_date', reverse=True,
|
||||
)[:1]
|
||||
rec.x_fc_active_qc_check_id = active
|
||||
rec.x_fc_qc_state = active.state if active else False
|
||||
|
||||
@api.depends('x_fc_qc_check_ids')
|
||||
def _compute_qc_check_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_qc_check_count = len(rec.x_fc_qc_check_ids)
|
||||
|
||||
@api.depends('origin')
|
||||
def _compute_qc_required(self):
|
||||
SO = self.env['sale.order']
|
||||
for rec in self:
|
||||
required = False
|
||||
if rec.origin:
|
||||
so = SO.search([('name', '=', rec.origin)], limit=1)
|
||||
partner = so.partner_id if so else False
|
||||
if partner and 'x_fc_requires_qc' in partner._fields:
|
||||
required = bool(partner.x_fc_requires_qc)
|
||||
rec.x_fc_qc_required = required
|
||||
|
||||
def _compute_rework_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_rework_count = len(rec.x_fc_rework_children_ids)
|
||||
@@ -793,6 +858,33 @@ class MrpProduction(models.Model):
|
||||
# Generate work orders from recipe (after portal job creation)
|
||||
self._generate_workorders_from_recipe()
|
||||
|
||||
# Spawn a QC check for customers that require sign-off.
|
||||
# Safe to call unconditionally — the factory returns an empty
|
||||
# recordset when the customer hasn't opted in to QC.
|
||||
QCheck = self.env.get('fusion.plating.quality.check')
|
||||
if QCheck is not None:
|
||||
for mo in self:
|
||||
partner = False
|
||||
if mo.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
partner = so.partner_id if so else False
|
||||
if not partner:
|
||||
continue
|
||||
if not partner._fields.get('x_fc_requires_qc'):
|
||||
continue
|
||||
if not partner.x_fc_requires_qc:
|
||||
continue
|
||||
# Customer-specific template override wins, otherwise
|
||||
# the factory resolves by partner → default.
|
||||
template = (
|
||||
partner.x_fc_qc_template_id
|
||||
if 'x_fc_qc_template_id' in partner._fields
|
||||
else False
|
||||
)
|
||||
QCheck.create_for_production(mo, template=template or None)
|
||||
|
||||
return res
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -807,7 +899,17 @@ class MrpProduction(models.Model):
|
||||
- Renders each cert's PDF immediately and links it to the
|
||||
portal job + delivery so the operator doesn't have to open
|
||||
the cert and click "Generate".
|
||||
|
||||
QC Gate (Phase 1 — 2026-04-20):
|
||||
If the customer has `x_fc_requires_qc=True`, the active QC
|
||||
check must be in the `passed` state. Additionally, if the
|
||||
resolved QC template demands thickness readings / a
|
||||
Fischerscope PDF, those must exist too. Gate can be bypassed
|
||||
by a user in the `group_fusion_plating_manager` group with
|
||||
the `fp_qc_bypass` context flag set (used for data-entry
|
||||
cleanup; not exposed in the UI).
|
||||
"""
|
||||
self._fp_qc_gate_check()
|
||||
res = super().button_mark_done()
|
||||
Delivery = self.env.get('fusion.plating.delivery')
|
||||
Certificate = self.env.get('fp.certificate')
|
||||
@@ -934,6 +1036,119 @@ class MrpProduction(models.Model):
|
||||
)
|
||||
return res
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# QC gate enforcement (Phase 1)
|
||||
# ------------------------------------------------------------------
|
||||
def _fp_qc_gate_check(self):
|
||||
"""Block MO completion when the customer requires QC but the
|
||||
QC hasn't been signed off.
|
||||
|
||||
Enforced conditions (all from the partner-resolved template):
|
||||
1. At least one QC record exists in state == 'passed'
|
||||
2. Template.require_thickness_readings → MO must have ≥1 reading
|
||||
3. Template.require_thickness_report_pdf → QC must carry the PDF
|
||||
4. Template.require_inspector_signoff → QC.inspector_id set
|
||||
|
||||
The manager-bypass context flag `fp_qc_bypass` lets a plant
|
||||
manager push a job through when the QC was done on paper and
|
||||
logged late — they still own it via chatter.
|
||||
"""
|
||||
if self.env.context.get('fp_qc_bypass'):
|
||||
return
|
||||
SO = self.env['sale.order']
|
||||
ThicknessReading = self.env.get('fp.thickness.reading')
|
||||
is_manager = self.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager'
|
||||
)
|
||||
for mo in self:
|
||||
partner = False
|
||||
if mo.origin:
|
||||
so = SO.search([('name', '=', mo.origin)], limit=1)
|
||||
partner = so.partner_id if so else False
|
||||
if not partner or 'x_fc_requires_qc' not in partner._fields:
|
||||
continue
|
||||
if not partner.x_fc_requires_qc:
|
||||
continue
|
||||
|
||||
passed = mo.x_fc_qc_check_ids.filtered(
|
||||
lambda c: c.state == 'passed'
|
||||
)
|
||||
if not passed:
|
||||
# Emit a gentle hint with a direct URL into the QC
|
||||
# tablet so the user can fix it in one click.
|
||||
raise UserError(_(
|
||||
'Cannot close MO "%(mo)s" — customer "%(cust)s" '
|
||||
'requires QC sign-off and no passing quality check '
|
||||
'exists yet.\n\nOpen Plating → Quality → Quality '
|
||||
'Checks to inspect and sign off, or open the '
|
||||
'active QC from the MO\'s "Quality Checks" tab.'
|
||||
) % {
|
||||
'mo': mo.name or mo.display_name,
|
||||
'cust': partner.name,
|
||||
})
|
||||
qc = passed.sorted('completed_at', reverse=True)[:1]
|
||||
|
||||
# Thickness readings check
|
||||
if qc.require_thickness_readings:
|
||||
reading_count = 0
|
||||
if ThicknessReading is not None:
|
||||
reading_count = ThicknessReading.search_count([
|
||||
('production_id', '=', mo.id),
|
||||
])
|
||||
if reading_count == 0:
|
||||
raise UserError(_(
|
||||
'Cannot close MO "%(mo)s" — QC template requires '
|
||||
'at least one Fischerscope thickness reading, '
|
||||
'but none have been logged.'
|
||||
) % {'mo': mo.name})
|
||||
|
||||
# Thickness report PDF check
|
||||
if qc.require_thickness_report_pdf and not qc.thickness_report_pdf_id:
|
||||
raise UserError(_(
|
||||
'Cannot close MO "%(mo)s" — QC template requires '
|
||||
'the Fischerscope / XDAL 600 report PDF, but none '
|
||||
'has been uploaded to QC "%(qc)s".'
|
||||
) % {'mo': mo.name, 'qc': qc.name})
|
||||
|
||||
# Inspector sign-off
|
||||
if qc.require_inspector_signoff and not qc.inspector_id:
|
||||
raise UserError(_(
|
||||
'Cannot close MO "%(mo)s" — QC "%(qc)s" is flagged '
|
||||
'passed but has no inspector on file.'
|
||||
) % {'mo': mo.name, 'qc': qc.name})
|
||||
|
||||
# Log the bypass so audits catch it
|
||||
if is_manager and self.env.context.get('fp_qc_bypass'):
|
||||
for mo in self:
|
||||
mo.message_post(body=_(
|
||||
'QC gate bypassed by %s.'
|
||||
) % self.env.user.name)
|
||||
|
||||
def action_open_active_qc(self):
|
||||
"""Smart-button action: open the mobile QC checklist for this MO."""
|
||||
self.ensure_one()
|
||||
qc = self.x_fc_active_qc_check_id
|
||||
if not qc:
|
||||
raise UserError(_(
|
||||
'No QC check exists for this MO yet. Confirm the MO '
|
||||
'after enabling "Require QC Sign-off" on the customer, '
|
||||
'or create a QC manually from Plating → Quality.'
|
||||
))
|
||||
return qc.action_open_tablet()
|
||||
|
||||
def action_view_qc_checks(self):
|
||||
"""List view of all QC checks attached to this MO."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('QC Checks — %s') % self.name,
|
||||
'res_model': 'fusion.plating.quality.check',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('production_id', '=', self.id)],
|
||||
'context': {'default_production_id': self.id},
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# #5 — Delivery auto-prefill helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Per-customer QC policy — does this customer require quality control
|
||||
sign-off on every job, and which checklist template governs the checks?
|
||||
"""
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
x_fc_requires_qc = fields.Boolean(
|
||||
string='Require QC Sign-off',
|
||||
default=False, tracking=True,
|
||||
help='When enabled, a job for this customer cannot be marked '
|
||||
'complete until a QC inspector has signed off on the '
|
||||
'quality checklist.',
|
||||
)
|
||||
x_fc_qc_template_id = fields.Many2one(
|
||||
'fp.qc.checklist.template', string='QC Checklist Template',
|
||||
help='Override the auto-resolved template for this customer. '
|
||||
'Leave blank to use any active customer-specific template, '
|
||||
'falling back to the global default.',
|
||||
)
|
||||
Reference in New Issue
Block a user