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