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 models
|
||||||
from . import wizard
|
from . import wizard
|
||||||
|
from . import controllers
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
"name": "Fusion Plating — MRP Bridge",
|
"name": "Fusion Plating — MRP Bridge",
|
||||||
'version': '19.0.7.0.0',
|
'version': '19.0.8.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -41,6 +41,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'fusion_plating_batch',
|
'fusion_plating_batch',
|
||||||
'fusion_plating_shopfloor',
|
'fusion_plating_shopfloor',
|
||||||
'fusion_plating_configurator',
|
'fusion_plating_configurator',
|
||||||
|
'fusion_plating_certificates',
|
||||||
'hr',
|
'hr',
|
||||||
# hr_attendance gives us the standard hr.attendance model
|
# hr_attendance gives us the standard hr.attendance model
|
||||||
# (check_in / check_out). fusion_clock builds on the same 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',
|
'security/ir.model.access.csv',
|
||||||
'data/fp_work_role_data.xml',
|
'data/fp_work_role_data.xml',
|
||||||
'data/fp_cron_data.xml',
|
'data/fp_cron_data.xml',
|
||||||
|
'data/fp_qc_data.xml',
|
||||||
'wizard/fp_recipe_config_wizard_views.xml',
|
'wizard/fp_recipe_config_wizard_views.xml',
|
||||||
'views/mrp_workcenter_views.xml',
|
'views/mrp_workcenter_views.xml',
|
||||||
'views/mrp_workorder_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/mrp_production_views.xml',
|
||||||
'views/sale_order_views.xml',
|
'views/sale_order_views.xml',
|
||||||
'views/fp_quality_hold_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_workorder_priority_views.xml',
|
||||||
'views/fp_job_consumption_views.xml',
|
'views/fp_job_consumption_views.xml',
|
||||||
'views/fp_work_role_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,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
'auto_install': 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 hr_employee
|
||||||
from . import fp_proficiency
|
from . import fp_proficiency
|
||||||
from . import fp_process_node
|
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',
|
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) ----
|
# ---- WO grouping + start-at-node (from direct-order wizard Phases B/C) ----
|
||||||
x_fc_wo_group_tag = fields.Char(
|
x_fc_wo_group_tag = fields.Char(
|
||||||
string='WO Group Tag',
|
string='WO Group Tag',
|
||||||
@@ -302,6 +333,40 @@ class MrpProduction(models.Model):
|
|||||||
for rec in self:
|
for rec in self:
|
||||||
rec.x_fc_override_count = len(rec.x_fc_override_ids)
|
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):
|
def _compute_rework_count(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
rec.x_fc_rework_count = len(rec.x_fc_rework_children_ids)
|
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)
|
# Generate work orders from recipe (after portal job creation)
|
||||||
self._generate_workorders_from_recipe()
|
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
|
return res
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -807,7 +899,17 @@ class MrpProduction(models.Model):
|
|||||||
- Renders each cert's PDF immediately and links it to the
|
- Renders each cert's PDF immediately and links it to the
|
||||||
portal job + delivery so the operator doesn't have to open
|
portal job + delivery so the operator doesn't have to open
|
||||||
the cert and click "Generate".
|
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()
|
res = super().button_mark_done()
|
||||||
Delivery = self.env.get('fusion.plating.delivery')
|
Delivery = self.env.get('fusion.plating.delivery')
|
||||||
Certificate = self.env.get('fp.certificate')
|
Certificate = self.env.get('fp.certificate')
|
||||||
@@ -934,6 +1036,119 @@ class MrpProduction(models.Model):
|
|||||||
)
|
)
|
||||||
return res
|
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
|
# #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_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_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_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"
|
<field name="x_fc_consumption_count" widget="statinfo"
|
||||||
string="Consumables"/>
|
string="Consumables"/>
|
||||||
</button>
|
</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>
|
</xpath>
|
||||||
|
|
||||||
</field>
|
</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 '',
|
'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}'}
|
return {'ok': False, 'error': f'Unrecognised QR payload: {code}'}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -107,6 +107,20 @@ export class ShopfloorTablet extends Component {
|
|||||||
this.state.stationId = result.id;
|
this.state.stationId = result.id;
|
||||||
localStorage.setItem("fp_tablet_station_id", String(result.id));
|
localStorage.setItem("fp_tablet_station_id", String(result.id));
|
||||||
this.setMessage(`Station paired: ${result.name}`, "success");
|
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 {
|
} else {
|
||||||
this.setMessage(`Scanned ${result.model} — ${result.name || ""}`, "info");
|
this.setMessage(`Scanned ${result.model} — ${result.name || ""}`, "info");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user