This commit is contained in:
gsinghpal
2026-04-26 15:05:17 -04:00
parent 160198edb1
commit d9f58b9851
110 changed files with 6210 additions and 1182 deletions

View File

@@ -4,3 +4,4 @@
# Part of the Fusion Plating product family.
from . import models
from . import controllers

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.2.3.0',
'version': '19.0.3.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',
@@ -67,6 +67,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'depends': [
'fusion_plating',
'fusion_plating_configurator',
'fusion_plating_certificates', # fp.thickness.reading link from QC
'fusion_plating_shopfloor', # _fp_shopfloor_tokens.scss for QC tablet
'mail',
],
'data': [
@@ -74,6 +76,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'data/fp_quality_hold_sequence_data.xml',
'data/fp_qc_data.xml',
'views/fp_qc_template_views.xml',
'views/fp_quality_hold_views.xml',
'views/fp_ncr_views.xml',
'views/fp_capa_views.xml',
@@ -84,9 +88,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_fair_views.xml',
'views/fp_doc_control_views.xml',
'views/res_partner_views.xml',
'views/res_partner_qc_views.xml',
'views/res_config_settings_views.xml',
'views/fp_contract_review_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_quality_check_views.xml',
'reports/fp_contract_review_report.xml',
'reports/fp_contract_review_template.xml',
'views/fp_menu.xml',
@@ -97,6 +103,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'assets': {
'web.assets_backend': [
'fusion_plating_quality/static/src/scss/fusion_plating_quality.scss',
# Phase 2 (Sub 11) — QC tablet OWL relocated from bridge_mrp.
'fusion_plating_quality/static/src/scss/fp_qc_checklist.scss',
'fusion_plating_quality/static/src/xml/fp_qc_checklist.xml',
'fusion_plating_quality/static/src/js/fp_qc_checklist.js',
],
},
'installable': True,

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
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 '',
'job_id': check.job_id.id if check.job_id else False,
'job_name': check.job_id.name if check.job_id else '',
'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, job_id=None, **kw):
check = self._check(check_id)
if not check and job_id:
# Resolve latest active QC for this fp.job
check = request.env['fusion.plating.quality.check'].search([
('job_id', '=', int(job_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

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
#
# Phase 1 (Sub 11) — relocate fusion.plating.quality.check (+line)
# from fusion_plating_bridge_mrp to fusion_plating_quality.
# Drop the legacy production_id column on the existing table.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return # Fresh install — nothing to migrate
patterns = [
'model_fusion_plating_quality_check',
'model_fusion_plating_quality_check_line',
'model_fp_qc_checklist_template',
'model_fp_qc_checklist_template_line',
'access_fp_qc_check_%',
'access_fp_qc_check_line_%',
'access_fp_qc_template_%',
'access_fp_qc_template_line_%',
'view_fp_quality_check%',
'view_fp_qc_template_%',
'fp_quality_check_%',
'action_fp_quality_check%',
'action_fp_qc_template%',
'menu_fp_quality_check%',
'menu_fp_qc_template%',
'qc_template_default',
'qc_template_aerospace',
'qc_tpl_%',
'seq_fp_quality_check',
]
for pat in patterns:
cr.execute(
"""
UPDATE ir_model_data
SET module = 'fusion_plating_quality'
WHERE module = 'fusion_plating_bridge_mrp'
AND name LIKE %s
AND NOT EXISTS (
SELECT 1 FROM ir_model_data d2
WHERE d2.module = 'fusion_plating_quality'
AND d2.name = ir_model_data.name
)
""",
(pat,),
)
if cr.rowcount:
_logger.info(
"Sub 11: re-keyed %d row(s) for %s -> fusion_plating_quality",
cr.rowcount, pat,
)
# Drop the legacy production_id column on fusion_plating_quality_check.
# Zero rows reference MRP (verified pre-cut). The new model declares
# job_id / sale_order_id / partner_id (related from job).
cr.execute(
"""
ALTER TABLE fusion_plating_quality_check
DROP COLUMN IF EXISTS production_id
"""
)
_logger.info("Sub 11: dropped production_id column on fusion_plating_quality_check")

View File

@@ -18,3 +18,8 @@ from . import res_company
from . import res_config_settings
from . import res_partner
from . import fp_part_catalog
# Phase 1 of MRP cut-out (Sub 11) — relocated from fusion_plating_bridge_mrp.
from . import fp_qc_template
from . import fp_thickness_reading
from . import fp_quality_check

View File

@@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp.
# This model never had MRP fields; the bridge module was just its
# initial home. Now lives under fusion_plating_jobs.
"""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 job 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,
)
require_thickness_readings = fields.Boolean(
string='Require Thickness Readings', default=False, tracking=True,
help='Job 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='Job 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,549 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp.
# Now binds to fp.job (native) instead of mrp.production.
"""Per-job QC instance.
When a plating job 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 the job's
`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,
)
job_id = fields.Many2one(
'fp.job', string='Plating Job',
required=True, ondelete='cascade', tracking=True,
index=True,
)
sale_order_id = fields.Many2one(
'sale.order', related='job_id.sale_order_id',
store=True, readonly=True,
)
partner_id = fields.Many2one(
'res.partner', related='job_id.partner_id',
store=True, readonly=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')
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',
)
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='job_id.company_id',
store=True, readonly=True,
)
@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)
@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)
@api.model
def create_for_job(self, job, template=None):
"""Spin up a QC record for a plating job, cloning lines from the
template.
If no template is passed, we resolve one from the job'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:
template = self.env['fp.qc.checklist.template'].resolve_for_partner(
job.partner_id,
)
if not template:
return self.browse()
existing = self.search([
('job_id', '=', job.id),
('state', '!=', 'failed'),
], limit=1)
if existing:
return existing
check = self.create({
'job_id': job.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',
})
job.message_post(
body=_('QC checklist "%s" created — %d items to inspect.') % (
template.name, len(template.line_ids),
),
)
return check
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 job after a failure."""
self.ensure_one()
if self.state != 'failed':
return
new_check = self.sudo().create_for_job(
self.job_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):
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)})
def _on_thickness_pdf_uploaded(self):
"""Parse the attached PDF and create fp.thickness.reading rows."""
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
auto = rec.thickness_reading_ids.filtered(
lambda r: r.auto_extracted
)
auto.unlink()
for idx, row in enumerate(readings, start=1):
vals = {
'quality_check_id': rec.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,
}
ThicknessReading.create(vals)
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):
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):
readings = []
row_re = re.compile(
r'^\s*(?:n\s*=\s*|N\s*)?(\d{1,3})[\s.:]+'
r'([0-9]*\.[0-9]+|\d+)'
r'(?:\s*(?:mils|microns|µm|um))?'
r'[\s|]+'
r'([0-9]*\.?[0-9]+)'
r'[\s|%]+'
r'([0-9]*\.?[0-9]+)'
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
if not (0 < nip < 1) and not (0 < nip < 30):
continue
if not (0 < ni < 100):
continue
if not (0 < p < 30):
continue
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,
})
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
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.job_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

@@ -30,10 +30,19 @@ class FpQualityHold(models.Model):
)
# ----- What's on hold -----
# NOTE: workorder_id, production_id, and portal_job_id live in
# fusion_plating_bridge_mrp (which depends on mrp and
# fusion_plating_portal). Keeping them here would force hard
# dependencies and break minimal CE-only installs.
# Phase 1 (Sub 11) — native plating-job link replaces the legacy
# workorder_id / production_id pair that lived in bridge_mrp.
# The bridge fields stay during the migration window so existing
# records keep their FKs; Phase 5 removes bridge_mrp entirely.
job_id = fields.Many2one(
'fp.job', string='Plating Job',
index=True, ondelete='set null',
)
step_id = fields.Many2one(
'fp.job.step', string='Job Step',
domain="[('job_id', '=', job_id)]",
ondelete='set null',
)
part_ref = fields.Char(string='Part Number')
# ----- Hold details -----

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.
#
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp.
# Adds the back-reference from fp.thickness.reading to the QC record
# that produced it. Lives here (not in fusion_plating_certificates)
# because the link target is fusion.plating.quality.check, owned by
# fusion_plating_quality.
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

@@ -17,3 +17,17 @@ class ResPartner(models.Model):
'fully optional — the reminder can be dismissed and never '
'blocks production.',
)
# Phase 4 (Sub 11) — relocated from fusion_plating_bridge_mrp.
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

@@ -32,3 +32,15 @@ access_fp_quality_hold_manager,fp.quality.hold.manager,model_fusion_plating_qual
access_fp_contract_review_operator,fp.contract.review.operator,model_fp_contract_review,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_contract_review_supervisor,fp.contract.review.supervisor,model_fp_contract_review,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_contract_review_manager,fp.contract.review.manager,model_fp_contract_review,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
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
32 access_fp_contract_review_operator fp.contract.review.operator model_fp_contract_review fusion_plating.group_fusion_plating_operator 1 0 0 0
33 access_fp_contract_review_supervisor fp.contract.review.supervisor model_fp_contract_review fusion_plating.group_fusion_plating_supervisor 1 1 1 0
34 access_fp_contract_review_manager fp.contract.review.manager model_fp_contract_review fusion_plating.group_fusion_plating_manager 1 1 1 1
35 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
36 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
37 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
38 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
39 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
40 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
41 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
42 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
43 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
44 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
45 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
46 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

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_quality.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.jobId = params.job_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,
job_id: this.jobId,
});
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 openJob() {
if (!this.state.check || !this.state.check.job_id) return;
this.action.doAction({
type: "ir.actions.act_window",
res_model: "fp.job",
res_id: this.state.check.job_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_quality.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="openJob"
t-if="state.check.job_id"
title="Back to Job">
<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.job_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="job_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="job_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="job_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

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