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

@@ -5,3 +5,4 @@
from . import models
from . import wizard
from . import controllers

View File

@@ -5,7 +5,7 @@
{
"name": "Fusion Plating — MRP Bridge",
'version': '19.0.7.0.0',
'version': '19.0.8.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """
@@ -41,6 +41,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_batch',
'fusion_plating_shopfloor',
'fusion_plating_configurator',
'fusion_plating_certificates',
'hr',
# hr_attendance gives us the standard hr.attendance model
# (check_in / check_out). fusion_clock builds on the same model
@@ -59,9 +60,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/ir.model.access.csv',
'data/fp_work_role_data.xml',
'data/fp_cron_data.xml',
'data/fp_qc_data.xml',
'wizard/fp_recipe_config_wizard_views.xml',
'views/mrp_workcenter_views.xml',
'views/mrp_workorder_views.xml',
'views/fp_qc_template_views.xml',
'views/fp_quality_check_views.xml',
'views/mrp_production_views.xml',
'views/sale_order_views.xml',
'views/fp_quality_hold_views.xml',
@@ -69,7 +73,18 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_workorder_priority_views.xml',
'views/fp_job_consumption_views.xml',
'views/fp_work_role_views.xml',
'views/res_partner_views.xml',
],
'assets': {
'web.assets_backend': [
# Depends on _fp_shopfloor_tokens.scss being loaded first —
# shopfloor is in depends, so its tokens bundle-concatenate
# before this file and define $fp-card / $fp-accent / etc.
'fusion_plating_bridge_mrp/static/src/scss/fp_qc_checklist.scss',
'fusion_plating_bridge_mrp/static/src/xml/fp_qc_checklist.xml',
'fusion_plating_bridge_mrp/static/src/js/fp_qc_checklist.js',
],
},
'installable': True,
'application': False,
'auto_install': False,

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_qc_controller

View File

@@ -0,0 +1,284 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""HTTP endpoints for the mobile QC checklist OWL client action.
Kept narrow (read state + mark-pass/fail + upload PDF + finalize). The
OWL component is purely a thin client over these endpoints so any
future native mobile app can reuse the same API.
"""
import base64
import logging
from odoo import http, _
from odoo.http import request
_logger = logging.getLogger(__name__)
class FpQcController(http.Controller):
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _check(check_id):
"""Resolve and access-check a QC record."""
if not check_id:
return False
check = request.env['fusion.plating.quality.check'].browse(
int(check_id)
).exists()
if not check:
return False
check.check_access('read')
return check
@staticmethod
def _line_payload(line):
return {
'id': line.id,
'sequence': line.sequence,
'name': line.name,
'description': line.description or '',
'check_type': line.check_type,
'required': line.required,
'requires_value': line.requires_value,
'value': line.value,
'value_min': line.value_min,
'value_max': line.value_max,
'value_uom': line.value_uom or '',
'value_in_range': line.value_in_range,
'requires_photo': line.requires_photo,
'has_photo': bool(line.photo_attachment_id),
'photo_attachment_id': line.photo_attachment_id.id or False,
'result': line.result or 'pending',
'notes': line.notes or '',
'inspector_name': (
line.inspector_id.name if line.inspector_id else ''
),
}
@staticmethod
def _check_payload(check):
return {
'id': check.id,
'name': check.name,
'state': check.state,
'overall_result': check.overall_result or '',
'production_id': check.production_id.id,
'production_name': check.production_id.name or '',
'partner_name': (
check.partner_id.name if check.partner_id else ''
),
'template_name': (
check.template_id.name if check.template_id else ''
),
'inspector_name': (
check.inspector_id.name if check.inspector_id else ''
),
'line_count': check.line_count,
'lines_passed': check.lines_passed,
'lines_failed': check.lines_failed,
'lines_pending': check.lines_pending,
'require_thickness_readings': check.require_thickness_readings,
'require_thickness_report_pdf': check.require_thickness_report_pdf,
'has_thickness_pdf': bool(check.thickness_report_pdf_id),
'thickness_reading_count': check.thickness_reading_count,
'notes': check.notes or '',
}
# ------------------------------------------------------------------
# GET state — OWL calls this on mount + after every action
# ------------------------------------------------------------------
@http.route(
'/fp/qc/get', type='jsonrpc', auth='user', methods=['POST'],
)
def get_state(self, check_id=None, production_id=None, **kw):
check = self._check(check_id)
if not check and production_id:
# Resolve latest active QC for this MO
check = request.env['fusion.plating.quality.check'].search([
('production_id', '=', int(production_id)),
], order='create_date desc', limit=1)
if not check:
return {'ok': False, 'error': 'no_qc'}
if not check:
return {'ok': False, 'error': 'not_found'}
return {
'ok': True,
'check': self._check_payload(check),
'lines': [
self._line_payload(l)
for l in check.line_ids.sorted('sequence')
],
}
# ------------------------------------------------------------------
# Line actions
# ------------------------------------------------------------------
@http.route(
'/fp/qc/line/mark', type='jsonrpc', auth='user', methods=['POST'],
)
def line_mark(self, check_id=None, line_id=None, result=None,
value=None, notes=None, **kw):
check = self._check(check_id)
if not check:
return {'ok': False, 'error': 'not_found'}
Line = request.env['fusion.plating.quality.check.line']
line = Line.browse(int(line_id)).exists()
if not line or line.check_id.id != check.id:
return {'ok': False, 'error': 'invalid_line'}
# Start the check if it's still draft
if check.state == 'draft':
check.action_start()
# Numeric value handling — write before action to let
# _compute_value_in_range update the record.
vals = {}
if value is not None and line.requires_value:
try:
vals['value'] = float(value)
except (TypeError, ValueError):
return {'ok': False, 'error': 'invalid_value'}
if notes is not None:
vals['notes'] = notes
if vals:
line.write(vals)
try:
if result == 'pass':
line.action_mark_pass()
elif result == 'fail':
line.action_mark_fail()
elif result == 'na':
line.action_mark_na()
elif result == 'pending':
line.write({
'result': 'pending',
'inspector_id': False,
'completed_at': False,
})
except Exception as e:
return {'ok': False, 'error': str(e)}
return {
'ok': True,
'line': self._line_payload(line),
'check': self._check_payload(check),
}
# ------------------------------------------------------------------
# Photo upload for an individual line
# ------------------------------------------------------------------
@http.route(
'/fp/qc/line/photo', type='http', auth='user', methods=['POST'],
csrf=False,
)
def line_photo(self, line_id=None, **kw):
Line = request.env['fusion.plating.quality.check.line']
line = Line.browse(int(line_id)).exists()
if not line:
return request.make_json_response(
{'ok': False, 'error': 'invalid_line'},
)
upload = request.httprequest.files.get('file')
if not upload:
return request.make_json_response(
{'ok': False, 'error': 'no_file'},
)
data = upload.read()
if not data:
return request.make_json_response(
{'ok': False, 'error': 'empty_file'},
)
att = request.env['ir.attachment'].create({
'name': upload.filename or 'qc_photo.jpg',
'type': 'binary',
'datas': base64.b64encode(data),
'res_model': 'fusion.plating.quality.check.line',
'res_id': line.id,
'mimetype': upload.mimetype or 'image/jpeg',
})
line.write({'photo_attachment_id': att.id})
return request.make_json_response({
'ok': True,
'attachment_id': att.id,
})
# ------------------------------------------------------------------
# Fischerscope PDF upload
# ------------------------------------------------------------------
@http.route(
'/fp/qc/thickness_pdf', type='http', auth='user',
methods=['POST'], csrf=False,
)
def thickness_pdf(self, check_id=None, **kw):
check = self._check(check_id)
if not check:
return request.make_json_response(
{'ok': False, 'error': 'not_found'},
)
upload = request.httprequest.files.get('file')
if not upload:
return request.make_json_response(
{'ok': False, 'error': 'no_file'},
)
data = upload.read()
if not data:
return request.make_json_response(
{'ok': False, 'error': 'empty_file'},
)
att = request.env['ir.attachment'].create({
'name': upload.filename or 'thickness_report.pdf',
'type': 'binary',
'datas': base64.b64encode(data),
'res_model': 'fusion.plating.quality.check',
'res_id': check.id,
'mimetype': upload.mimetype or 'application/pdf',
})
# Triggers _on_thickness_pdf_uploaded via write() override.
check.write({'thickness_report_pdf_id': att.id})
return request.make_json_response({
'ok': True,
'attachment_id': att.id,
'reading_count': check.thickness_reading_count,
})
# ------------------------------------------------------------------
# Check-level actions
# ------------------------------------------------------------------
@http.route(
'/fp/qc/finalize', type='jsonrpc', auth='user', methods=['POST'],
)
def finalize(self, check_id=None, result=None, notes=None, **kw):
check = self._check(check_id)
if not check:
return {'ok': False, 'error': 'not_found'}
if notes is not None:
check.write({'notes': notes})
try:
if result == 'pass':
check.action_pass()
elif result == 'fail':
check.action_fail()
elif result == 'rework':
check.action_rework()
else:
return {'ok': False, 'error': 'invalid_result'}
except Exception as e:
return {'ok': False, 'error': str(e)}
return {'ok': True, 'check': self._check_payload(check)}
@http.route(
'/fp/qc/start', type='jsonrpc', auth='user', methods=['POST'],
)
def start(self, check_id=None, **kw):
check = self._check(check_id)
if not check:
return {'ok': False, 'error': 'not_found'}
if check.state == 'draft':
check.action_start()
return {'ok': True, 'check': self._check_payload(check)}

View File

@@ -0,0 +1,161 @@
<?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.
Sequence for QC checks + a starter default template so QC works
out of the box for any customer that has x_fc_requires_qc=True
but no per-customer template yet.
-->
<odoo noupdate="1">
<!-- ===== Sequence ===== -->
<record id="seq_fp_quality_check" model="ir.sequence">
<field name="name">Fusion Plating: Quality Check</field>
<field name="code">fusion.plating.quality.check</field>
<field name="prefix">QC/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
<!-- ===== Default checklist template (global — partner_id blank) =====
sequence=5 so it wins over any other global template when
resolve_for_partner falls back from a missing per-customer match. -->
<record id="qc_template_default" model="fp.qc.checklist.template">
<field name="name">Standard Plating QC</field>
<field name="sequence">5</field>
<field name="active">True</field>
<field name="require_inspector_signoff">True</field>
<field name="require_thickness_readings">False</field>
<field name="require_thickness_report_pdf">False</field>
</record>
<record id="qc_tpl_line_visual" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">10</field>
<field name="name">Visual — no pits, burns, or bare spots</field>
<field name="description">Examine the entire plated surface under shop lighting. Look for pits, burns, dewetting, bare spots, or rough texture. Reject if any defect is visible to the naked eye.</field>
<field name="check_type">visual</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_line_colour" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">20</field>
<field name="name">Colour — uniform finish across part</field>
<field name="description">Finish should be uniform with no streaking, blotching, or dull-vs-bright zones. Compare against the customer colour sample if one is on file.</field>
<field name="check_type">visual</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_line_adhesion" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">30</field>
<field name="name">Adhesion — tape test pass</field>
<field name="description">Apply tape to an inconspicuous area, press firmly for 3 seconds, pull at 90°. No flaking permitted.</field>
<field name="check_type">adhesion</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_line_masking" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">40</field>
<field name="name">Masking — no plating in masked zones</field>
<field name="description">Areas that were masked per customer print must be free of plating deposit. Light staining acceptable; build-up is not.</field>
<field name="check_type">visual</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_line_quantity" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">50</field>
<field name="name">Quantity — matches WO count</field>
<field name="description">Count the parts. Must equal the WO quantity minus any documented rework/scrap.</field>
<field name="check_type">functional</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_line_packaging" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_default"/>
<field name="sequence">60</field>
<field name="name">Packaging — parts protected for shipping</field>
<field name="description">Parts individually bagged / padded, no direct metal-on-metal contact that could scratch the finish in transit.</field>
<field name="check_type">visual</field>
<field name="required">False</field>
</record>
<!-- ===== Aerospace checklist (stricter — used as a starter for
Nadcap customers; admin copies and reassigns to partner) ===== -->
<record id="qc_template_aerospace" model="fp.qc.checklist.template">
<field name="name">Aerospace / Nadcap QC</field>
<field name="sequence">100</field>
<field name="active">True</field>
<field name="require_inspector_signoff">True</field>
<field name="require_thickness_readings">True</field>
<field name="require_thickness_report_pdf">True</field>
</record>
<record id="qc_tpl_aero_visual" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">10</field>
<field name="name">Visual — 10× loupe, no discontinuities</field>
<field name="description">Inspect under 10× magnification. Reject any pit, crack, inclusion, or discontinuity visible at that power.</field>
<field name="check_type">visual</field>
<field name="required">True</field>
<field name="requires_photo">True</field>
</record>
<record id="qc_tpl_aero_thickness_1" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">20</field>
<field name="name">Thickness — Fischerscope reading #1</field>
<field name="description">Fischerscope XDAL 600 XRF measurement at primary inspection point. Value must fall inside the customer spec range. Record the NiP mils reading.</field>
<field name="check_type">thickness</field>
<field name="required">True</field>
<field name="requires_value">True</field>
<field name="value_uom">mils</field>
</record>
<record id="qc_tpl_aero_thickness_2" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">30</field>
<field name="name">Thickness — Fischerscope reading #2</field>
<field name="description">Second XRF point — per customer print's secondary inspection location.</field>
<field name="check_type">thickness</field>
<field name="required">True</field>
<field name="requires_value">True</field>
<field name="value_uom">mils</field>
</record>
<record id="qc_tpl_aero_thickness_3" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">40</field>
<field name="name">Thickness — Fischerscope reading #3</field>
<field name="description">Third XRF point — per customer print's tertiary inspection location.</field>
<field name="check_type">thickness</field>
<field name="required">True</field>
<field name="requires_value">True</field>
<field name="value_uom">mils</field>
</record>
<record id="qc_tpl_aero_adhesion" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">50</field>
<field name="name">Adhesion — ASTM B571 tape test</field>
<field name="description">Apply ASTM B571 tape to freshly-scribed area, remove at 90° per standard. No flaking of plating permitted.</field>
<field name="check_type">adhesion</field>
<field name="required">True</field>
</record>
<record id="qc_tpl_aero_dimensional" model="fp.qc.checklist.template.line">
<field name="template_id" ref="qc_template_aerospace"/>
<field name="sequence">60</field>
<field name="name">Dimensional — critical feature verification</field>
<field name="description">Caliper / mic any feature marked critical on the customer print. Confirm plating did not push dimensions out of tolerance.</field>
<field name="check_type">dimensional</field>
<field name="required">True</field>
<field name="requires_value">False</field>
</record>
</odoo>

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

View File

@@ -20,3 +20,15 @@ access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plati
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_template_operator,fp.qc.checklist.template.operator,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_qc_template_supervisor,fp.qc.checklist.template.supervisor,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_template_manager,fp.qc.checklist.template.manager,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_template_line_operator,fp.qc.checklist.template.line.operator,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_qc_template_line_supervisor,fp.qc.checklist.template.line.supervisor,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_template_line_manager,fp.qc.checklist.template.line.manager,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_check_operator,fusion.plating.quality.check.operator,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_qc_check_supervisor,fusion.plating.quality.check.supervisor,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_check_manager,fusion.plating.quality.check.manager,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_check_line_operator,fusion.plating.quality.check.line.operator,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_qc_check_line_supervisor,fusion.plating.quality.check.line.supervisor,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_check_line_manager,fusion.plating.quality.check.line.manager,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
20 access_fp_proficiency_operator fp.operator.proficiency.operator model_fp_operator_proficiency fusion_plating.group_fusion_plating_operator 1 0 0 0
21 access_fp_proficiency_supervisor fp.operator.proficiency.supervisor model_fp_operator_proficiency fusion_plating.group_fusion_plating_supervisor 1 1 1 0
22 access_fp_proficiency_manager fp.operator.proficiency.manager model_fp_operator_proficiency fusion_plating.group_fusion_plating_manager 1 1 1 1
23 access_fp_qc_template_operator fp.qc.checklist.template.operator model_fp_qc_checklist_template fusion_plating.group_fusion_plating_operator 1 0 0 0
24 access_fp_qc_template_supervisor fp.qc.checklist.template.supervisor model_fp_qc_checklist_template fusion_plating.group_fusion_plating_supervisor 1 1 1 0
25 access_fp_qc_template_manager fp.qc.checklist.template.manager model_fp_qc_checklist_template fusion_plating.group_fusion_plating_manager 1 1 1 1
26 access_fp_qc_template_line_operator fp.qc.checklist.template.line.operator model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_operator 1 0 0 0
27 access_fp_qc_template_line_supervisor fp.qc.checklist.template.line.supervisor model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
28 access_fp_qc_template_line_manager fp.qc.checklist.template.line.manager model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_manager 1 1 1 1
29 access_fp_qc_check_operator fusion.plating.quality.check.operator model_fusion_plating_quality_check fusion_plating.group_fusion_plating_operator 1 1 1 0
30 access_fp_qc_check_supervisor fusion.plating.quality.check.supervisor model_fusion_plating_quality_check fusion_plating.group_fusion_plating_supervisor 1 1 1 0
31 access_fp_qc_check_manager fusion.plating.quality.check.manager model_fusion_plating_quality_check fusion_plating.group_fusion_plating_manager 1 1 1 1
32 access_fp_qc_check_line_operator fusion.plating.quality.check.line.operator model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_operator 1 1 1 0
33 access_fp_qc_check_line_supervisor fusion.plating.quality.check.line.supervisor model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
34 access_fp_qc_check_line_manager fusion.plating.quality.check.line.manager model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,349 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Mobile QC Checklist (OWL backend client action)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Matches the existing Tablet Station / Plant Overview conventions:
// * `static template` + `static props = ["*"]`
// * Standalone rpc() from @web/core/network/rpc
// * Design tokens from _fp_shopfloor_tokens.scss (no borders, shadow
// elevation, 48 px touch targets)
//
// Invoked either via the MO "Open QC" smart-button (action_open_tablet)
// or directly with `ir.actions.client` tag `fp_qc_checklist` and the
// action's params.check_id.
// =============================================================================
import { Component, useState, onMounted, useRef } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
export class FpQcChecklist extends Component {
static template = "fusion_plating_bridge_mrp.FpQcChecklist";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.fileInput = useRef("fileInput");
this.pdfInput = useRef("pdfInput");
this.photoLineId = null;
this.state = useState({
loading: true,
saving: false,
error: null,
check: null,
lines: [],
expandedLineId: null,
showFinalize: false,
finalizeNotes: "",
});
// action.params (from ir.actions.client) is the canonical
// source; fall back to URL query params for deep-linking.
const params = (this.props.action && this.props.action.params) || {};
this.checkId = params.check_id || null;
this.productionId = params.production_id || null;
onMounted(() => this.refresh());
}
// ------------------------------------------------------------------
// Data
// ------------------------------------------------------------------
async refresh() {
this.state.loading = true;
this.state.error = null;
try {
const res = await rpc("/fp/qc/get", {
check_id: this.checkId,
production_id: this.productionId,
});
if (!res.ok) {
this.state.error = res.error === "no_qc"
? "No QC checklist exists for this MO yet."
: (res.error || "QC not found");
return;
}
this.state.check = res.check;
this.state.lines = res.lines || [];
this.checkId = res.check.id;
} catch (err) {
this.state.error = err && err.message ? err.message : String(err);
} finally {
this.state.loading = false;
}
}
// ------------------------------------------------------------------
// Line actions
// ------------------------------------------------------------------
async markLine(line, result) {
if (this.state.saving) return;
this.state.saving = true;
try {
const payload = {
check_id: this.checkId,
line_id: line.id,
result,
};
if (line.requires_value) {
payload.value = line.value;
}
if (line.notes !== undefined) payload.notes = line.notes;
const res = await rpc("/fp/qc/line/mark", payload);
if (!res.ok) {
this.notification.add(res.error || "Mark failed", {
type: "danger",
title: line.name,
});
return;
}
// Merge updated line into state
const idx = this.state.lines.findIndex((l) => l.id === line.id);
if (idx >= 0) this.state.lines[idx] = res.line;
this.state.check = res.check;
this.notification.add(
result === "pass" ? "Passed" : result === "fail" ? "Failed" : "Marked",
{ type: result === "fail" ? "danger" : "success" },
);
} catch (err) {
this.notification.add(
err && err.message ? err.message : String(err),
{ type: "danger" },
);
} finally {
this.state.saving = false;
}
}
// Value input — debounced write on blur. Pending result stays until
// operator taps pass/fail.
onValueInput(line, ev) {
const v = parseFloat(ev.target.value);
line.value = isNaN(v) ? 0 : v;
if (line.requires_value) {
const inRange =
(!line.value_min || line.value >= line.value_min) &&
(!line.value_max || line.value <= line.value_max);
line.value_in_range = inRange;
}
}
onNotesInput(line, ev) {
line.notes = ev.target.value;
}
toggleExpanded(line) {
this.state.expandedLineId =
this.state.expandedLineId === line.id ? null : line.id;
}
// ------------------------------------------------------------------
// Photo upload
// ------------------------------------------------------------------
triggerPhoto(line) {
this.photoLineId = line.id;
if (this.fileInput.el) {
this.fileInput.el.value = "";
this.fileInput.el.click();
}
}
async onPhotoSelected(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file || !this.photoLineId) return;
const fd = new FormData();
fd.append("file", file);
fd.append("line_id", this.photoLineId);
try {
const resp = await fetch("/fp/qc/line/photo", {
method: "POST",
body: fd,
credentials: "same-origin",
});
const json = await resp.json();
if (!json.ok) {
this.notification.add(json.error || "Upload failed", {
type: "danger",
});
return;
}
this.notification.add("Photo uploaded", { type: "success" });
await this.refresh();
} catch (err) {
this.notification.add(
err && err.message ? err.message : String(err),
{ type: "danger" },
);
} finally {
this.photoLineId = null;
}
}
// ------------------------------------------------------------------
// Fischerscope PDF upload
// ------------------------------------------------------------------
triggerPdfUpload() {
if (this.pdfInput.el) {
this.pdfInput.el.value = "";
this.pdfInput.el.click();
}
}
async onPdfSelected(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
const fd = new FormData();
fd.append("file", file);
fd.append("check_id", this.checkId);
try {
this.state.saving = true;
const resp = await fetch("/fp/qc/thickness_pdf", {
method: "POST",
body: fd,
credentials: "same-origin",
});
const json = await resp.json();
if (!json.ok) {
this.notification.add(json.error || "Upload failed", {
type: "danger",
});
return;
}
this.notification.add(
`Uploaded — ${json.reading_count || 0} reading(s) extracted`,
{ type: "success" },
);
await this.refresh();
} catch (err) {
this.notification.add(
err && err.message ? err.message : String(err),
{ type: "danger" },
);
} finally {
this.state.saving = false;
}
}
// ------------------------------------------------------------------
// Finalize
// ------------------------------------------------------------------
openFinalize() {
this.state.showFinalize = true;
this.state.finalizeNotes = this.state.check
? this.state.check.notes || ""
: "";
}
closeFinalize() {
this.state.showFinalize = false;
}
async finalize(result) {
try {
this.state.saving = true;
const res = await rpc("/fp/qc/finalize", {
check_id: this.checkId,
result,
notes: this.state.finalizeNotes,
});
if (!res.ok) {
this.notification.add(res.error || "Finalize failed", {
type: "danger",
});
return;
}
this.state.check = res.check;
this.state.showFinalize = false;
this.notification.add(
result === "pass"
? "QC passed. MO can now be marked Done."
: result === "fail"
? "QC failed. Go to the MO to decide scrap/rework."
: "QC flagged for rework.",
{ type: result === "pass" ? "success" : "warning" },
);
await this.refresh();
} catch (err) {
this.notification.add(
err && err.message ? err.message : String(err),
{ type: "danger" },
);
} finally {
this.state.saving = false;
}
}
// ------------------------------------------------------------------
// Navigation
// ------------------------------------------------------------------
async openProduction() {
if (!this.state.check || !this.state.check.production_id) return;
this.action.doAction({
type: "ir.actions.act_window",
res_model: "mrp.production",
res_id: this.state.check.production_id,
views: [[false, "form"]],
target: "current",
});
}
// ------------------------------------------------------------------
// Helpers used by the template
// ------------------------------------------------------------------
resultBadgeClass(result) {
return {
pass: "o_fp_qc_badge_pass",
fail: "o_fp_qc_badge_fail",
na: "o_fp_qc_badge_na",
pending: "o_fp_qc_badge_pending",
}[result || "pending"] || "o_fp_qc_badge_pending";
}
checkTypeIcon(type) {
return {
visual: "fa-eye",
dimensional: "fa-arrows-h",
thickness: "fa-bar-chart",
adhesion: "fa-link",
hardness: "fa-diamond",
salt_spray: "fa-tint",
functional: "fa-cogs",
other: "fa-circle-o",
}[type] || "fa-circle-o";
}
get progressPercent() {
if (!this.state.check || !this.state.check.line_count) return 0;
const done = this.state.check.lines_passed +
this.state.check.lines_failed;
return Math.round((done / this.state.check.line_count) * 100);
}
get canFinalize() {
if (!this.state.check) return false;
if (["passed", "failed"].includes(this.state.check.state)) return false;
// Required items must be resolved
const pendingRequired = this.state.lines.filter(
(l) => l.required && (l.result === "pending" || !l.result),
);
if (pendingRequired.length > 0) return false;
// Thickness PDF requirement
if (this.state.check.require_thickness_report_pdf &&
!this.state.check.has_thickness_pdf) return false;
// Thickness readings requirement
if (this.state.check.require_thickness_readings &&
this.state.check.thickness_reading_count === 0) return false;
return true;
}
get anyFailed() {
return this.state.lines.some((l) => l.result === "fail");
}
}
registry.category("actions").add("fp_qc_checklist", FpQcChecklist);

View File

@@ -0,0 +1,518 @@
// =============================================================================
// Fusion Plating — Mobile QC Checklist styles
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Built on the shop-floor design system tokens (_fp_shopfloor_tokens.scss).
// Same language as Tablet Station / Plant Overview: no borders, shadow-
// based elevation, 48 px touch targets, three-layer contrast.
// =============================================================================
.o_fp_qc {
background-color: $fp-page;
color: $fp-ink;
min-height: 100vh;
padding: $fp-space-4;
font-family: $fp-font-stack;
font-size: $fp-text-base;
// ---------- State ----------
.o_fp_qc_state_loading,
.o_fp_qc_state_error {
max-width: 480px;
margin: $fp-space-10 auto;
@include fp-card($fp-elev-2);
padding: $fp-space-7;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: $fp-space-3;
.fa {
font-size: $fp-text-2xl;
color: $fp-ink-mute;
}
p { color: $fp-ink-soft; margin: 0; }
}
.o_fp_qc_state_error .fa { color: $fp-bad; }
// ---------- Header ----------
.o_fp_qc_header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: $fp-space-4;
margin-bottom: $fp-space-5;
.o_fp_qc_header_left {
display: flex;
gap: $fp-space-3;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.o_fp_qc_back {
width: $fp-touch-min;
height: $fp-touch-min;
border-radius: $fp-radius-md;
background-color: $fp-card;
box-shadow: $fp-elev-1;
border: none;
color: $fp-ink-soft;
font-size: $fp-text-md;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: box-shadow $fp-dur $fp-ease;
@include fp-hover-only {
&:hover { box-shadow: $fp-elev-2; }
}
}
.o_fp_qc_title_block {
min-width: 0;
flex: 1;
}
.o_fp_qc_breadcrumb {
color: $fp-ink-mute;
font-size: $fp-text-sm;
margin-bottom: $fp-space-1;
}
.o_fp_qc_title {
font-size: $fp-text-2xl;
font-weight: $fp-weight-semibold;
margin: 0 0 $fp-space-1 0;
color: $fp-ink;
line-height: 1.2;
}
.o_fp_qc_sub {
color: $fp-ink-mute;
font-size: $fp-text-sm;
}
.o_fp_qc_sep {
margin: 0 $fp-space-2;
color: $fp-ink-faint;
}
.o_fp_qc_ref { font-weight: $fp-weight-medium; }
}
.o_fp_qc_state_chip {
padding: $fp-space-2 $fp-space-4;
border-radius: $fp-radius-pill;
font-size: $fp-text-sm;
font-weight: $fp-weight-semibold;
letter-spacing: 0.02em;
white-space: nowrap;
&.o_fp_qc_chip_draft { @include fp-pill('--bs-info'); }
&.o_fp_qc_chip_in_progress { @include fp-pill('--bs-warning'); }
&.o_fp_qc_chip_passed { @include fp-pill('--bs-success'); }
&.o_fp_qc_chip_failed { @include fp-pill('--bs-danger'); }
&.o_fp_qc_chip_rework { @include fp-pill('--bs-secondary'); }
}
// ---------- Progress card ----------
.o_fp_qc_progress_card {
@include fp-card($fp-elev-2);
padding: $fp-space-5 $fp-space-6;
margin-bottom: $fp-space-5;
}
.o_fp_qc_progress_numbers {
display: flex;
justify-content: space-between;
align-items: center;
gap: $fp-space-6;
flex-wrap: wrap;
margin-bottom: $fp-space-4;
}
.o_fp_qc_progress_big {
font-size: $fp-text-3xl;
font-weight: $fp-weight-bold;
color: $fp-accent;
line-height: 1;
}
.o_fp_qc_progress_break {
display: flex;
gap: $fp-space-6;
flex-wrap: wrap;
}
.o_fp_qc_counter {
display: flex;
flex-direction: column;
align-items: flex-end;
.o_fp_qc_counter_n {
font-size: $fp-text-xl;
font-weight: $fp-weight-bold;
line-height: 1.1;
}
.o_fp_qc_counter_l {
font-size: $fp-text-xs;
color: $fp-ink-mute;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&.o_fp_qc_counter_pass .o_fp_qc_counter_n { color: $fp-ok; }
&.o_fp_qc_counter_fail .o_fp_qc_counter_n { color: $fp-bad; }
&.o_fp_qc_counter_pending .o_fp_qc_counter_n { color: $fp-ink-mute; }
}
.o_fp_qc_progress_bar {
height: 6px;
background-color: $fp-card-soft;
border-radius: $fp-radius-pill;
overflow: hidden;
}
.o_fp_qc_progress_fill {
height: 100%;
background-color: $fp-accent;
border-radius: $fp-radius-pill;
transition: width $fp-dur $fp-ease;
}
// ---------- Thickness card ----------
.o_fp_qc_thickness_card {
@include fp-card($fp-elev-1);
padding: $fp-space-4 $fp-space-5;
margin-bottom: $fp-space-5;
}
.o_fp_qc_thickness_head {
display: flex;
justify-content: space-between;
align-items: center;
gap: $fp-space-4;
flex-wrap: wrap;
}
.o_fp_qc_thickness_title {
font-size: $fp-text-md;
font-weight: $fp-weight-semibold;
.fa { color: $fp-accent; margin-right: $fp-space-2; }
}
.o_fp_qc_thickness_sub {
font-size: $fp-text-sm;
color: $fp-ink-mute;
margin-top: $fp-space-1;
}
// ---------- Checklist ----------
.o_fp_qc_list {
display: flex;
flex-direction: column;
gap: $fp-space-3;
margin-bottom: $fp-space-6;
}
.o_fp_qc_item {
@include fp-card($fp-elev-1);
overflow: hidden;
transition: box-shadow $fp-dur $fp-ease,
transform $fp-dur $fp-ease;
&.o_fp_qc_item_pass {
// Left accent strip — subtle indicator that doesn't scream at you
background:
linear-gradient(to right, $fp-ok 4px, transparent 4px) $fp-card;
}
&.o_fp_qc_item_fail {
background:
linear-gradient(to right, $fp-bad 4px, transparent 4px) $fp-card;
}
&.o_fp_qc_item_na {
background:
linear-gradient(to right, $fp-ink-faint 4px, transparent 4px) $fp-card;
}
&.o_fp_qc_item_open { box-shadow: $fp-elev-2; }
}
.o_fp_qc_item_row {
display: flex;
align-items: center;
gap: $fp-space-4;
padding: $fp-space-4 $fp-space-5;
min-height: $fp-touch-min + $fp-space-3;
cursor: pointer;
@include fp-hover-only {
&:hover { background-color: color-mix(in srgb, #{$fp-accent} 4%, transparent); }
}
}
.o_fp_qc_item_icon {
width: 40px;
height: 40px;
border-radius: $fp-radius-md;
background-color: $fp-card-soft;
display: flex;
align-items: center;
justify-content: center;
color: $fp-ink-soft;
font-size: $fp-text-md;
flex-shrink: 0;
}
.o_fp_qc_item_body {
flex: 1;
min-width: 0;
}
.o_fp_qc_item_name {
font-size: $fp-text-md;
font-weight: $fp-weight-medium;
color: $fp-ink;
line-height: 1.3;
}
.o_fp_qc_item_optional {
margin-left: $fp-space-2;
font-size: $fp-text-xs;
color: $fp-ink-mute;
font-weight: normal;
}
.o_fp_qc_item_meta {
display: flex;
gap: $fp-space-3;
align-items: center;
margin-top: $fp-space-1;
flex-wrap: wrap;
}
.o_fp_qc_item_value {
font-size: $fp-text-sm;
color: $fp-ink-soft;
font-variant-numeric: tabular-nums;
}
.o_fp_qc_item_photo_ind {
color: $fp-accent;
font-size: $fp-text-sm;
}
.o_fp_qc_badge {
display: inline-block;
padding: 2px $fp-space-2;
font-size: $fp-text-xs;
font-weight: $fp-weight-semibold;
border-radius: $fp-radius-sm;
letter-spacing: 0.04em;
}
.o_fp_qc_badge_pass { @include fp-pill('--bs-success'); }
.o_fp_qc_badge_fail { @include fp-pill('--bs-danger'); }
.o_fp_qc_badge_na { @include fp-pill('--bs-secondary'); }
.o_fp_qc_badge_pending { @include fp-pill('--bs-info'); }
.o_fp_qc_chevron {
color: $fp-ink-mute;
font-size: $fp-text-sm;
flex-shrink: 0;
}
// ---------- Expanded detail ----------
.o_fp_qc_item_detail {
padding: $fp-space-4 $fp-space-5 $fp-space-5;
border-top: 1px solid color-mix(in srgb, #{$fp-border} 60%, transparent);
display: flex;
flex-direction: column;
gap: $fp-space-4;
}
.o_fp_qc_guidance {
background-color: $fp-card-soft;
padding: $fp-space-3 $fp-space-4;
border-radius: $fp-radius-md;
color: $fp-ink-soft;
font-size: $fp-text-sm;
line-height: 1.5;
white-space: pre-wrap;
}
.o_fp_qc_value_row,
.o_fp_qc_notes_row,
.o_fp_qc_photo_row {
display: flex;
flex-direction: column;
gap: $fp-space-2;
label {
font-size: $fp-text-xs;
font-weight: $fp-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $fp-ink-mute;
}
}
.o_fp_qc_value_input {
display: flex;
align-items: center;
gap: $fp-space-3;
input {
flex: 1;
height: $fp-touch-min;
padding: 0 $fp-space-4;
font-size: $fp-text-lg;
font-variant-numeric: tabular-nums;
background-color: $fp-card-soft;
border: none;
border-radius: $fp-radius-md;
color: $fp-ink;
&:focus { @include fp-focus-ring; }
}
.o_fp_qc_uom {
color: $fp-ink-mute;
font-size: $fp-text-md;
min-width: 40px;
}
}
.o_fp_qc_range {
font-size: $fp-text-xs;
color: $fp-ink-mute;
}
.o_fp_qc_notes_row textarea {
width: 100%;
padding: $fp-space-3 $fp-space-4;
font-size: $fp-text-base;
background-color: $fp-card-soft;
border: none;
border-radius: $fp-radius-md;
color: $fp-ink;
font-family: inherit;
resize: vertical;
&:focus { @include fp-focus-ring; }
}
.o_fp_qc_actions_row {
display: flex;
gap: $fp-space-3;
flex-wrap: wrap;
}
// ---------- Buttons ----------
.o_fp_qc_btn {
display: inline-flex;
align-items: center;
gap: $fp-space-2;
min-height: $fp-touch-min;
padding: 0 $fp-space-5;
font-size: $fp-text-md;
font-weight: $fp-weight-semibold;
border: none;
border-radius: $fp-radius-md;
cursor: pointer;
transition: transform $fp-dur-fast $fp-ease,
box-shadow $fp-dur $fp-ease,
background-color $fp-dur $fp-ease;
&:active:not([disabled]) { transform: scale(0.97); }
&[disabled] { opacity: 0.5; cursor: not-allowed; }
.fa { font-size: $fp-text-md; }
}
.o_fp_qc_btn_primary {
background-color: $fp-accent;
color: white;
box-shadow: $fp-elev-1;
@include fp-hover-only {
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
}
}
.o_fp_qc_btn_pass,
.o_fp_qc_btn_pass_lg {
background-color: $fp-ok;
color: white;
box-shadow: $fp-elev-1;
@include fp-hover-only {
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
}
}
.o_fp_qc_btn_fail,
.o_fp_qc_btn_fail_lg {
background-color: $fp-bad;
color: white;
box-shadow: $fp-elev-1;
@include fp-hover-only {
&:hover:not([disabled]) { box-shadow: $fp-elev-2; }
}
}
.o_fp_qc_btn_ghost,
.o_fp_qc_btn_ghost_lg {
background-color: $fp-card-soft;
color: $fp-ink-soft;
@include fp-hover-only {
&:hover:not([disabled]) {
background-color: color-mix(in srgb, #{$fp-ink-soft} 10%, $fp-card-soft);
}
}
}
.o_fp_qc_btn_pass_lg,
.o_fp_qc_btn_fail_lg,
.o_fp_qc_btn_ghost_lg {
flex: 1;
min-height: 60px;
font-size: $fp-text-lg;
justify-content: center;
}
// ---------- Sign-off footer ----------
.o_fp_qc_footer {
position: sticky;
bottom: $fp-space-4;
background: color-mix(in srgb, $fp-page 85%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: $fp-space-4;
border-radius: $fp-radius-lg;
box-shadow: $fp-elev-2;
display: flex;
gap: $fp-space-3;
flex-wrap: wrap;
}
// ---------- Responsive ----------
@media (max-width: 640px) {
padding: $fp-space-3;
.o_fp_qc_header .o_fp_qc_title { font-size: $fp-text-xl; }
.o_fp_qc_progress_big { font-size: $fp-text-2xl; }
.o_fp_qc_footer {
flex-direction: column;
.o_fp_qc_btn_pass_lg,
.o_fp_qc_btn_fail_lg,
.o_fp_qc_btn_ghost_lg { width: 100%; }
}
}
}

View File

@@ -0,0 +1,285 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_bridge_mrp.FpQcChecklist">
<div class="o_fp_qc">
<!-- ===== Loading / error ===== -->
<t t-if="state.loading">
<div class="o_fp_qc_state_loading">
<i class="fa fa-spinner fa-spin"/>
<span>Loading QC…</span>
</div>
</t>
<t t-elif="state.error">
<div class="o_fp_qc_state_error">
<i class="fa fa-exclamation-triangle"/>
<p><t t-esc="state.error"/></p>
<button class="btn btn-primary" t-on-click="refresh">Retry</button>
</div>
</t>
<t t-elif="state.check">
<!-- ===== Header ===== -->
<div class="o_fp_qc_header">
<div class="o_fp_qc_header_left">
<button class="o_fp_qc_back" t-on-click="openProduction"
t-if="state.check.production_id"
title="Back to MO">
<i class="fa fa-arrow-left"/>
</button>
<div class="o_fp_qc_title_block">
<div class="o_fp_qc_breadcrumb">
<span><t t-esc="state.check.production_name"/></span>
<t t-if="state.check.partner_name">
<span class="o_fp_qc_sep">·</span>
<span><t t-esc="state.check.partner_name"/></span>
</t>
</div>
<h1 class="o_fp_qc_title">
<t t-esc="state.check.template_name or 'QC Checklist'"/>
</h1>
<div class="o_fp_qc_sub">
<span class="o_fp_qc_ref"><t t-esc="state.check.name"/></span>
<t t-if="state.check.inspector_name">
<span class="o_fp_qc_sep">·</span>
<span>Inspector: <t t-esc="state.check.inspector_name"/></span>
</t>
</div>
</div>
</div>
<div class="o_fp_qc_state_chip"
t-att-class="'o_fp_qc_chip_' + state.check.state">
<t t-esc="state.check.state.replace('_', ' ').toUpperCase()"/>
</div>
</div>
<!-- ===== Progress ===== -->
<div class="o_fp_qc_progress_card">
<div class="o_fp_qc_progress_numbers">
<div class="o_fp_qc_progress_big">
<t t-esc="progressPercent"/>%
</div>
<div class="o_fp_qc_progress_break">
<div class="o_fp_qc_counter o_fp_qc_counter_pass">
<span class="o_fp_qc_counter_n">
<t t-esc="state.check.lines_passed"/>
</span>
<span class="o_fp_qc_counter_l">Pass</span>
</div>
<div class="o_fp_qc_counter o_fp_qc_counter_fail">
<span class="o_fp_qc_counter_n">
<t t-esc="state.check.lines_failed"/>
</span>
<span class="o_fp_qc_counter_l">Fail</span>
</div>
<div class="o_fp_qc_counter o_fp_qc_counter_pending">
<span class="o_fp_qc_counter_n">
<t t-esc="state.check.lines_pending"/>
</span>
<span class="o_fp_qc_counter_l">Pending</span>
</div>
</div>
</div>
<div class="o_fp_qc_progress_bar">
<div class="o_fp_qc_progress_fill"
t-att-style="'width:' + progressPercent + '%'"/>
</div>
</div>
<!-- ===== Thickness PDF (if required) ===== -->
<t t-if="state.check.require_thickness_report_pdf or state.check.require_thickness_readings">
<div class="o_fp_qc_thickness_card">
<div class="o_fp_qc_thickness_head">
<div>
<div class="o_fp_qc_thickness_title">
<i class="fa fa-bar-chart"/>
Thickness Report
</div>
<div class="o_fp_qc_thickness_sub">
<t t-if="state.check.has_thickness_pdf">
PDF uploaded · <t t-esc="state.check.thickness_reading_count"/> reading(s) extracted
</t>
<t t-else="">
Upload Fischerscope / XDAL 600 PDF export
</t>
</div>
</div>
<button class="o_fp_qc_btn o_fp_qc_btn_primary"
t-on-click="triggerPdfUpload"
t-att-disabled="state.saving">
<i class="fa fa-upload"/>
<t t-if="state.check.has_thickness_pdf">Replace PDF</t>
<t t-else="">Upload PDF</t>
</button>
</div>
</div>
</t>
<!-- ===== Checklist ===== -->
<div class="o_fp_qc_list">
<t t-foreach="state.lines" t-as="line" t-key="line.id">
<div class="o_fp_qc_item"
t-att-class="{
'o_fp_qc_item_pass': line.result == 'pass',
'o_fp_qc_item_fail': line.result == 'fail',
'o_fp_qc_item_na': line.result == 'na',
'o_fp_qc_item_pending': line.result == 'pending' or !line.result,
'o_fp_qc_item_open': state.expandedLineId == line.id,
}">
<div class="o_fp_qc_item_row"
t-on-click="() => this.toggleExpanded(line)">
<div class="o_fp_qc_item_icon">
<i class="fa" t-att-class="checkTypeIcon(line.check_type)"/>
</div>
<div class="o_fp_qc_item_body">
<div class="o_fp_qc_item_name">
<t t-esc="line.name"/>
<t t-if="!line.required">
<span class="o_fp_qc_item_optional">(optional)</span>
</t>
</div>
<div class="o_fp_qc_item_meta">
<span class="o_fp_qc_badge"
t-att-class="resultBadgeClass(line.result)">
<t t-esc="(line.result or 'pending').toUpperCase()"/>
</span>
<t t-if="line.requires_value and line.value">
<span class="o_fp_qc_item_value">
<t t-esc="line.value"/>
<t t-esc="line.value_uom"/>
</span>
</t>
<t t-if="line.requires_photo and line.has_photo">
<span class="o_fp_qc_item_photo_ind">
<i class="fa fa-camera"/>
</span>
</t>
</div>
</div>
<i class="o_fp_qc_chevron fa"
t-att-class="state.expandedLineId == line.id ? 'fa-chevron-up' : 'fa-chevron-down'"/>
</div>
<t t-if="state.expandedLineId == line.id">
<div class="o_fp_qc_item_detail">
<t t-if="line.description">
<div class="o_fp_qc_guidance">
<t t-esc="line.description"/>
</div>
</t>
<t t-if="line.requires_value">
<div class="o_fp_qc_value_row">
<label>Measured Value</label>
<div class="o_fp_qc_value_input">
<input type="number" step="0.0001"
t-att-value="line.value or ''"
t-att-placeholder="line.value_uom or ''"
t-on-input="(ev) => this.onValueInput(line, ev)"/>
<span class="o_fp_qc_uom"><t t-esc="line.value_uom"/></span>
</div>
<t t-if="line.value_min or line.value_max">
<div class="o_fp_qc_range">
Range: <t t-esc="line.value_min"/> <t t-esc="line.value_max"/>
<t t-esc="line.value_uom"/>
</div>
</t>
</div>
</t>
<t t-if="line.requires_photo">
<div class="o_fp_qc_photo_row">
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
t-on-click="() => this.triggerPhoto(line)">
<i class="fa fa-camera"/>
<t t-if="line.has_photo">Replace photo</t>
<t t-else="">Add photo</t>
</button>
</div>
</t>
<div class="o_fp_qc_notes_row">
<label>Notes</label>
<textarea rows="2"
t-att-value="line.notes or ''"
t-on-input="(ev) => this.onNotesInput(line, ev)"
placeholder="Optional — anything the inspector saw that matters"/>
</div>
<div class="o_fp_qc_actions_row">
<button class="o_fp_qc_btn o_fp_qc_btn_pass"
t-on-click="() => this.markLine(line, 'pass')"
t-att-disabled="state.saving">
<i class="fa fa-check"/>
Pass
</button>
<button class="o_fp_qc_btn o_fp_qc_btn_fail"
t-on-click="() => this.markLine(line, 'fail')"
t-att-disabled="state.saving">
<i class="fa fa-times"/>
Fail
</button>
<t t-if="!line.required">
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
t-on-click="() => this.markLine(line, 'na')"
t-att-disabled="state.saving">
N/A
</button>
</t>
<t t-if="line.result != 'pending'">
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
t-on-click="() => this.markLine(line, 'pending')"
t-att-disabled="state.saving">
Reset
</button>
</t>
</div>
</div>
</t>
</div>
</t>
</div>
<!-- ===== Sign-off bar ===== -->
<t t-if="state.check.state != 'passed' and state.check.state != 'failed'">
<div class="o_fp_qc_footer">
<button class="o_fp_qc_btn o_fp_qc_btn_pass_lg"
t-on-click="() => this.finalize('pass')"
t-att-disabled="!canFinalize or state.saving">
<i class="fa fa-check"/>
<span>Sign Off — PASS</span>
</button>
<button class="o_fp_qc_btn o_fp_qc_btn_fail_lg"
t-on-click="() => this.finalize('fail')"
t-att-disabled="state.saving">
<i class="fa fa-times"/>
<span>Fail QC</span>
</button>
<button class="o_fp_qc_btn o_fp_qc_btn_ghost_lg"
t-on-click="() => this.finalize('rework')"
t-att-disabled="state.saving or !anyFailed">
Send to Rework
</button>
</div>
</t>
<!-- ===== Hidden file inputs ===== -->
<input type="file" t-ref="fileInput"
accept="image/*" capture="environment"
style="display:none"
t-on-change="onPhotoSelected"/>
<input type="file" t-ref="pdfInput"
accept="application/pdf"
style="display:none"
t-on-change="onPdfSelected"/>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,141 @@
<?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.
Admin-facing views for QC checklist templates. Manager privilege
group required to create / edit templates.
-->
<odoo>
<record id="fp_qc_checklist_template_list" model="ir.ui.view">
<field name="name">fp.qc.checklist.template.list</field>
<field name="model">fp.qc.checklist.template</field>
<field name="arch" type="xml">
<list string="QC Checklist Templates" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="partner_id"/>
<field name="require_inspector_signoff" widget="boolean_toggle"/>
<field name="require_thickness_readings" widget="boolean_toggle"/>
<field name="require_thickness_report_pdf" widget="boolean_toggle"/>
<field name="check_count"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="fp_qc_checklist_template_form" model="ir.ui.view">
<field name="name">fp.qc.checklist.template.form</field>
<field name="model">fp.qc.checklist.template</field>
<field name="arch" type="xml">
<form string="QC Template">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_checks" type="object"
class="oe_stat_button" icon="fa-list">
<field name="check_count" widget="statinfo"
string="QC Instances"/>
</button>
</div>
<widget name="web_ribbon" title="Archived"
invisible="active" bg_color="text-bg-danger"/>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Aerospace / Nadcap QC"/></h1>
</div>
<group>
<group string="Scope">
<field name="partner_id"
options="{'no_create': True}"
placeholder="Global default (leave blank)"/>
<field name="sequence"/>
<field name="active"/>
</group>
<group string="Gate Policy">
<field name="require_inspector_signoff"/>
<field name="require_thickness_readings"/>
<field name="require_thickness_report_pdf"/>
</group>
</group>
<notebook>
<page string="Checklist Items" name="items">
<field name="line_ids">
<list string="Items" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="check_type"/>
<field name="required" widget="boolean_toggle"/>
<field name="requires_value" widget="boolean_toggle"/>
<field name="value_min" optional="hide"/>
<field name="value_max" optional="hide"/>
<field name="value_uom" optional="hide"/>
<field name="requires_photo" widget="boolean_toggle"/>
</list>
<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="check_type"/>
<field name="sequence"/>
</group>
<group>
<field name="required"/>
<field name="requires_photo"/>
<field name="requires_value"/>
</group>
</group>
<group string="Acceptance Range"
invisible="not requires_value">
<field name="value_min"/>
<field name="value_max"/>
<field name="value_uom"/>
</group>
<group string="Guidance">
<field name="description" nolabel="1"
placeholder="Inspection guidance shown to the operator on tap..."/>
</group>
</sheet>
</form>
</field>
</page>
<page string="Notes" name="notes">
<field name="notes" nolabel="1"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="fp_qc_checklist_template_search" model="ir.ui.view">
<field name="name">fp.qc.checklist.template.search</field>
<field name="model">fp.qc.checklist.template</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="partner_id"/>
<filter name="global_default" string="Global Default"
domain="[('partner_id', '=', False)]"/>
<filter name="per_customer" string="Per Customer"
domain="[('partner_id', '!=', False)]"/>
<filter name="inactive" string="Archived"
domain="[('active', '=', False)]"/>
<group>
<filter name="by_customer" string="Customer"
context="{'group_by': 'partner_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_qc_checklist_template" model="ir.actions.act_window">
<field name="name">QC Checklist Templates</field>
<field name="res_model">fp.qc.checklist.template</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="fp_qc_checklist_template_search"/>
</record>
</odoo>

View File

@@ -0,0 +1,235 @@
<?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="fp_quality_check_list" model="ir.ui.view">
<field name="name">fusion.plating.quality.check.list</field>
<field name="model">fusion.plating.quality.check</field>
<field name="arch" type="xml">
<list string="QC Checks"
decoration-info="state == 'draft'"
decoration-warning="state == 'in_progress'"
decoration-success="state == 'passed'"
decoration-danger="state == 'failed'"
decoration-muted="state == 'rework'">
<field name="name"/>
<field name="production_id"/>
<field name="partner_id"/>
<field name="template_id"/>
<field name="lines_passed"/>
<field name="lines_failed"/>
<field name="lines_pending"/>
<field name="line_count" optional="hide"/>
<field name="inspector_id"/>
<field name="completed_at"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<record id="fp_quality_check_form" model="ir.ui.view">
<field name="name">fusion.plating.quality.check.form</field>
<field name="model">fusion.plating.quality.check</field>
<field name="arch" type="xml">
<form string="Quality Check">
<header>
<button name="action_open_tablet" type="object"
string="Open Mobile Checklist" class="btn-primary"
invisible="state in ('passed', 'failed')"/>
<button name="action_start" type="object"
string="Start Inspection" class="btn-secondary"
invisible="state != 'draft'"/>
<button name="action_pass" type="object"
string="Mark Passed" class="btn-success"
invisible="state in ('passed', 'failed', 'draft')"/>
<button name="action_fail" type="object"
string="Mark Failed" class="btn-danger"
invisible="state in ('passed', 'failed', 'draft')"
confirm="This marks the QC as FAILED and blocks the MO from closing. Continue?"/>
<button name="action_rework" type="object"
string="Send to Rework" class="btn-warning"
invisible="state in ('passed', 'failed', 'draft')"/>
<button name="action_reset_to_draft" type="object"
string="Reset to Draft"
invisible="state == 'draft'"
groups="fusion_plating.group_fusion_plating_supervisor"/>
<button name="action_spawn_retry" type="object"
string="Create Retry QC" class="btn-secondary"
invisible="state != 'failed'"
groups="fusion_plating.group_fusion_plating_supervisor"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,in_progress,passed"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_open_tablet" type="object"
class="oe_stat_button" icon="fa-tablet">
<div class="o_stat_info">
<span class="o_stat_text">Tablet</span>
</div>
</button>
</div>
<widget name="web_ribbon" title="PASSED"
invisible="state != 'passed'" bg_color="text-bg-success"/>
<widget name="web_ribbon" title="FAILED"
invisible="state != 'failed'" bg_color="text-bg-danger"/>
<widget name="web_ribbon" title="REWORK"
invisible="state != 'rework'" bg_color="text-bg-warning"/>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group string="Job">
<field name="production_id" readonly="1"/>
<field name="partner_id" readonly="1"/>
<field name="template_id"
options="{'no_create': True}"
readonly="state != 'draft'"/>
</group>
<group string="Sign-off">
<field name="inspector_id" readonly="1"/>
<field name="started_at" readonly="1"/>
<field name="completed_at" readonly="1"/>
<field name="overall_result" readonly="1"/>
</group>
</group>
<group string="Progress">
<group>
<field name="line_count" readonly="1"/>
<field name="lines_passed" readonly="1"/>
</group>
<group>
<field name="lines_failed" readonly="1"/>
<field name="lines_pending" readonly="1"/>
</group>
</group>
<notebook>
<page string="Checklist" name="lines">
<field name="line_ids">
<list editable="bottom"
decoration-success="result == 'pass'"
decoration-danger="result == 'fail'"
decoration-muted="result == 'na'">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="check_type"/>
<field name="required" widget="boolean_toggle"/>
<field name="requires_value" column_invisible="1"/>
<field name="value" optional="show"
invisible="not requires_value"/>
<field name="value_uom" optional="hide"/>
<field name="value_min" optional="hide"/>
<field name="value_max" optional="hide"/>
<field name="value_in_range" widget="boolean_toggle"
optional="hide"/>
<field name="requires_photo" column_invisible="1"/>
<field name="photo_attachment_id"
invisible="not requires_photo"
widget="many2one_binary" optional="show"/>
<field name="notes" optional="hide"/>
<field name="result" widget="badge"
decoration-success="result == 'pass'"
decoration-danger="result == 'fail'"
decoration-muted="result == 'na'"
decoration-info="result == 'pending'"/>
<button name="action_mark_pass" type="object"
icon="fa-check" title="Pass"
invisible="result == 'pass'"/>
<button name="action_mark_fail" type="object"
icon="fa-times" title="Fail"
invisible="result == 'fail'"/>
</list>
</field>
</page>
<page string="Thickness Report" name="thickness">
<group>
<field name="thickness_report_pdf_id"
widget="many2one_binary"
help="Upload the Fischerscope / XDAL 600 PDF — readings will be auto-extracted."/>
<field name="thickness_reading_count" readonly="1"/>
<field name="require_thickness_readings" readonly="1"/>
<field name="require_thickness_report_pdf" readonly="1"/>
</group>
<field name="thickness_reading_ids">
<list editable="bottom">
<field name="reading_number"/>
<field name="position_label"/>
<field name="nip_mils"/>
<field name="ni_percent"/>
<field name="p_percent"/>
<field name="auto_extracted" widget="boolean_toggle"/>
<field name="operator_id"/>
<field name="reading_datetime"/>
</list>
</field>
</page>
<page string="Notes" name="notes">
<field name="notes" nolabel="1"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="fp_quality_check_search" model="ir.ui.view">
<field name="name">fusion.plating.quality.check.search</field>
<field name="model">fusion.plating.quality.check</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="production_id"/>
<field name="partner_id"/>
<field name="inspector_id"/>
<filter name="draft" string="Draft"
domain="[('state', '=', 'draft')]"/>
<filter name="in_progress" string="In Progress"
domain="[('state', '=', 'in_progress')]"/>
<filter name="passed" string="Passed"
domain="[('state', '=', 'passed')]"/>
<filter name="failed" string="Failed"
domain="[('state', '=', 'failed')]"/>
<filter name="rework" string="Rework"
domain="[('state', '=', 'rework')]"/>
<separator/>
<filter name="my_inspections" string="My Inspections"
domain="[('inspector_id', '=', uid)]"/>
<group>
<filter name="by_customer" string="Customer"
context="{'group_by': 'partner_id'}"/>
<filter name="by_state" string="Status"
context="{'group_by': 'state'}"/>
<filter name="by_template" string="Template"
context="{'group_by': 'template_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_quality_check" model="ir.actions.act_window">
<field name="name">Quality Checks</field>
<field name="res_model">fusion.plating.quality.check</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="fp_quality_check_search"/>
</record>
<!-- ===== Menu — add QC Checks + QC Templates under Quality ===== -->
<menuitem id="menu_fp_quality_check"
name="Quality Checks"
parent="fusion_plating_quality.menu_fp_quality"
action="action_fp_quality_check"
sequence="7"/>
<menuitem id="menu_fp_config_qc_template"
name="QC Checklist Templates"
parent="fusion_plating.menu_fp_config"
action="action_fp_qc_checklist_template"
sequence="85"
groups="fusion_plating.group_fusion_plating_manager"/>
</odoo>

View File

@@ -113,6 +113,37 @@
<field name="x_fc_consumption_count" widget="statinfo"
string="Consumables"/>
</button>
<!-- Quality Check — tablet-style checklist -->
<button name="action_open_active_qc" type="object"
class="oe_stat_button" icon="fa-check-square-o"
invisible="x_fc_qc_check_count == 0">
<div class="o_stat_info">
<field name="x_fc_qc_state" nolabel="1"
widget="badge"
decoration-info="x_fc_qc_state == 'draft'"
decoration-warning="x_fc_qc_state == 'in_progress'"
decoration-success="x_fc_qc_state == 'passed'"
decoration-danger="x_fc_qc_state == 'failed'"/>
<span class="o_stat_text">QC</span>
</div>
</button>
<button name="action_view_qc_checks" type="object"
class="oe_stat_button" icon="fa-list-alt"
invisible="x_fc_qc_check_count &lt; 2">
<field name="x_fc_qc_check_count" widget="statinfo"
string="QC History"/>
</button>
</xpath>
<!-- Surface QC summary + required flag in the Fusion Plating group -->
<xpath expr="//group[@name='fusion_plating']" position="inside">
<group string="Quality Control" colspan="2">
<field name="x_fc_qc_required" readonly="1"/>
<field name="x_fc_qc_state" readonly="1"
invisible="not x_fc_active_qc_check_id"/>
<field name="x_fc_active_qc_check_id" readonly="1"
invisible="not x_fc_active_qc_check_id"/>
</group>
</xpath>
</field>

View File

@@ -0,0 +1,39 @@
<?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.
Extend res.partner with the QC requirement flag + template picker.
Adds a "Quality Control" group to the "Plating Documents" tab that
fusion_plating_certificates opens on the partner form.
-->
<odoo>
<record id="view_partner_form_fp_qc" model="ir.ui.view">
<field name="name">res.partner.form.fp.qc</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="fusion_plating_certificates.view_partner_form_fp_document_prefs"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='fp_document_prefs']" position="inside">
<group string="Quality Control"
name="fp_qc_prefs_group">
<p class="text-muted" colspan="2">
When QC sign-off is required, confirming a Manufacturing Order
auto-creates a checklist from the active template. The MO
cannot be marked Done until an inspector passes the QC.
</p>
<group>
<field name="x_fc_requires_qc" widget="boolean_toggle"/>
</group>
<group>
<field name="x_fc_qc_template_id"
options="{'no_create': True}"
invisible="not x_fc_requires_qc"/>
</group>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -143,6 +143,61 @@ class FpShopfloorController(http.Controller):
'product_name': wo.production_id.product_id.display_name or '',
}
# FP-QC:<ref> → directly into the mobile checklist screen
if code.startswith('FP-QC:'):
QC = request.env.get('fusion.plating.quality.check')
if QC is None:
return {'ok': False, 'error': 'QC module not installed'}
qc = QC.search(
[('name', '=', code.split(':', 1)[1])], limit=1,
)
if not qc:
return {'ok': False, 'error': f'QC {code} not found'}
return {
'ok': True,
'model': 'fusion.plating.quality.check',
'id': qc.id,
'name': qc.name,
'state': qc.state,
'production_id': qc.production_id.id or False,
'production_name': qc.production_id.name or '',
'partner_name': qc.partner_id.name or '',
'action_tag': 'fp_qc_checklist',
'action_params': {'check_id': qc.id},
}
# FP-MO:<name> → the MO. If it has a pending QC, surface that.
if code.startswith('FP-MO:'):
MO = request.env.get('mrp.production')
QC = request.env.get('fusion.plating.quality.check')
if MO is None:
return {'ok': False, 'error': 'MRP not installed'}
mo = MO.search(
[('name', '=', code.split(':', 1)[1])], limit=1,
)
if not mo:
return {'ok': False, 'error': f'MO {code} not found'}
resp = {
'ok': True,
'model': 'mrp.production',
'id': mo.id,
'name': mo.name,
'state': mo.state,
'product_name': mo.product_id.display_name or '',
}
if QC is not None:
active = QC.search([
('production_id', '=', mo.id),
('state', 'in', ('draft', 'in_progress')),
], order='create_date desc', limit=1)
if active:
resp['pending_qc_id'] = active.id
resp['pending_qc_name'] = active.name
resp['pending_qc_state'] = active.state
resp['action_tag'] = 'fp_qc_checklist'
resp['action_params'] = {'check_id': active.id}
return resp
return {'ok': False, 'error': f'Unrecognised QR payload: {code}'}
# ----------------------------------------------------------------------

View File

@@ -107,6 +107,20 @@ export class ShopfloorTablet extends Component {
this.state.stationId = result.id;
localStorage.setItem("fp_tablet_station_id", String(result.id));
this.setMessage(`Station paired: ${result.name}`, "success");
} else if (result.action_tag) {
// e.g. FP-QC:<ref> or FP-MO:<name> with a pending QC →
// launch the mobile checklist directly.
this.setMessage(`Launching ${result.pending_qc_name || result.name}`, "info");
this.action.doAction({
type: "ir.actions.client",
tag: result.action_tag,
name: result.pending_qc_name || result.name,
params: result.action_params || {},
target: "current",
});
this.state.scannedCode = "";
this.state.loading = false;
return;
} else {
this.setMessage(`Scanned ${result.model}${result.name || ""}`, "info");
}