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:
gsinghpal
2026-04-21 00:15:58 -04:00
parent 4d6095cd2a
commit e86d897bce
21 changed files with 3210 additions and 1 deletions

View File

@@ -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

View File

@@ -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.00050.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.',
)

View File

@@ -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 14 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(),
})

View File

@@ -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.',
)

View File

@@ -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
# ------------------------------------------------------------------

View File

@@ -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.',
)