diff --git a/fusion_plating/fusion_plating_bridge_mrp/__init__.py b/fusion_plating/fusion_plating_bridge_mrp/__init__.py index 6179cf28..af5d684b 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/__init__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/__init__.py @@ -5,3 +5,4 @@ from . import models from . import wizard +from . import controllers diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py index 1ef6bd6c..2af9af17 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Fusion Plating — MRP Bridge", - 'version': '19.0.7.0.0', + 'version': '19.0.8.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.', 'description': """ @@ -41,6 +41,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'fusion_plating_batch', 'fusion_plating_shopfloor', 'fusion_plating_configurator', + 'fusion_plating_certificates', 'hr', # hr_attendance gives us the standard hr.attendance model # (check_in / check_out). fusion_clock builds on the same model @@ -59,9 +60,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'security/ir.model.access.csv', 'data/fp_work_role_data.xml', 'data/fp_cron_data.xml', + 'data/fp_qc_data.xml', 'wizard/fp_recipe_config_wizard_views.xml', 'views/mrp_workcenter_views.xml', 'views/mrp_workorder_views.xml', + 'views/fp_qc_template_views.xml', + 'views/fp_quality_check_views.xml', 'views/mrp_production_views.xml', 'views/sale_order_views.xml', 'views/fp_quality_hold_views.xml', @@ -69,7 +73,18 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'views/fp_workorder_priority_views.xml', 'views/fp_job_consumption_views.xml', 'views/fp_work_role_views.xml', + 'views/res_partner_views.xml', ], + 'assets': { + 'web.assets_backend': [ + # Depends on _fp_shopfloor_tokens.scss being loaded first — + # shopfloor is in depends, so its tokens bundle-concatenate + # before this file and define $fp-card / $fp-accent / etc. + 'fusion_plating_bridge_mrp/static/src/scss/fp_qc_checklist.scss', + 'fusion_plating_bridge_mrp/static/src/xml/fp_qc_checklist.xml', + 'fusion_plating_bridge_mrp/static/src/js/fp_qc_checklist.js', + ], + }, 'installable': True, 'application': False, 'auto_install': False, diff --git a/fusion_plating/fusion_plating_bridge_mrp/controllers/__init__.py b/fusion_plating/fusion_plating_bridge_mrp/controllers/__init__.py new file mode 100644 index 00000000..a375bf01 --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/controllers/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_bridge_mrp/controllers/fp_qc_controller.py b/fusion_plating/fusion_plating_bridge_mrp/controllers/fp_qc_controller.py new file mode 100644 index 00000000..c3f464ce --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/controllers/fp_qc_controller.py @@ -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)} diff --git a/fusion_plating/fusion_plating_bridge_mrp/data/fp_qc_data.xml b/fusion_plating/fusion_plating_bridge_mrp/data/fp_qc_data.xml new file mode 100644 index 00000000..9466cb40 --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/data/fp_qc_data.xml @@ -0,0 +1,161 @@ + + + + + + + Fusion Plating: Quality Check + fusion.plating.quality.check + QC/%(year)s/ + 4 + + + + + + Standard Plating QC + 5 + True + True + False + False + + + + + 10 + Visual — no pits, burns, or bare spots + 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. + visual + True + + + + + 20 + Colour — uniform finish across part + Finish should be uniform with no streaking, blotching, or dull-vs-bright zones. Compare against the customer colour sample if one is on file. + visual + True + + + + + 30 + Adhesion — tape test pass + Apply tape to an inconspicuous area, press firmly for 3 seconds, pull at 90°. No flaking permitted. + adhesion + True + + + + + 40 + Masking — no plating in masked zones + Areas that were masked per customer print must be free of plating deposit. Light staining acceptable; build-up is not. + visual + True + + + + + 50 + Quantity — matches WO count + Count the parts. Must equal the WO quantity minus any documented rework/scrap. + functional + True + + + + + 60 + Packaging — parts protected for shipping + Parts individually bagged / padded, no direct metal-on-metal contact that could scratch the finish in transit. + visual + False + + + + + Aerospace / Nadcap QC + 100 + True + True + True + True + + + + + 10 + Visual — 10× loupe, no discontinuities + Inspect under 10× magnification. Reject any pit, crack, inclusion, or discontinuity visible at that power. + visual + True + True + + + + + 20 + Thickness — Fischerscope reading #1 + Fischerscope XDAL 600 XRF measurement at primary inspection point. Value must fall inside the customer spec range. Record the NiP mils reading. + thickness + True + True + mils + + + + + 30 + Thickness — Fischerscope reading #2 + Second XRF point — per customer print's secondary inspection location. + thickness + True + True + mils + + + + + 40 + Thickness — Fischerscope reading #3 + Third XRF point — per customer print's tertiary inspection location. + thickness + True + True + mils + + + + + 50 + Adhesion — ASTM B571 tape test + Apply ASTM B571 tape to freshly-scribed area, remove at 90° per standard. No flaking of plating permitted. + adhesion + True + + + + + 60 + Dimensional — critical feature verification + Caliper / mic any feature marked critical on the customer print. Confirm plating did not push dimensions out of tolerance. + dimensional + True + False + + + diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py b/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py index 6009e406..e535b3f1 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py @@ -19,3 +19,7 @@ from . import fp_work_role from . import hr_employee from . import fp_proficiency from . import fp_process_node +from . import fp_qc_template +from . import fp_quality_check +from . import fp_thickness_reading +from . import res_partner diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_qc_template.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_qc_template.py new file mode 100644 index 00000000..8fb27fdb --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_qc_template.py @@ -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.', + ) diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_quality_check.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_quality_check.py new file mode 100644 index 00000000..3b9684ff --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_quality_check.py @@ -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( + 'QC PASSED — 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( + 'QC FAILED — 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: + # 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(), + }) diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_thickness_reading.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_thickness_reading.py new file mode 100644 index 00000000..00c935ce --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_thickness_reading.py @@ -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.', + ) diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py index 26c59a1a..441ba5c7 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py @@ -58,6 +58,37 @@ class MrpProduction(models.Model): compute='_compute_override_count', ) + # ------------------------------------------------------------------ + # Quality Control gate (Phase 1 — 2026-04-20) + # ------------------------------------------------------------------ + x_fc_qc_check_ids = fields.One2many( + 'fusion.plating.quality.check', 'production_id', + string='Quality Checks', + ) + x_fc_active_qc_check_id = fields.Many2one( + 'fusion.plating.quality.check', string='Active QC', + compute='_compute_active_qc', store=True, + ) + x_fc_qc_state = fields.Selection( + [ + ('draft', 'Draft'), + ('in_progress', 'In Progress'), + ('passed', 'Passed'), + ('failed', 'Failed'), + ('rework', 'Rework Required'), + ], + string='QC State', compute='_compute_active_qc', + store=True, readonly=True, + ) + x_fc_qc_required = fields.Boolean( + string='QC Required', compute='_compute_qc_required', + help='Computed from the customer on this MO — true when the ' + 'customer has "Require QC Sign-off" turned on.', + ) + x_fc_qc_check_count = fields.Integer( + compute='_compute_qc_check_count', + ) + # ---- WO grouping + start-at-node (from direct-order wizard Phases B/C) ---- x_fc_wo_group_tag = fields.Char( string='WO Group Tag', @@ -302,6 +333,40 @@ class MrpProduction(models.Model): for rec in self: rec.x_fc_override_count = len(rec.x_fc_override_ids) + @api.depends('x_fc_qc_check_ids', 'x_fc_qc_check_ids.state') + def _compute_active_qc(self): + for rec in self: + # The "active" QC is the most recently created check that + # isn't a failed/cancelled one. A failed QC spawns a new + # draft on the next rework cycle; the old failed record + # stays in history. + active = rec.x_fc_qc_check_ids.filtered( + lambda c: c.state != 'failed' + ).sorted('create_date', reverse=True)[:1] + if not active: + active = rec.x_fc_qc_check_ids.sorted( + 'create_date', reverse=True, + )[:1] + rec.x_fc_active_qc_check_id = active + rec.x_fc_qc_state = active.state if active else False + + @api.depends('x_fc_qc_check_ids') + def _compute_qc_check_count(self): + for rec in self: + rec.x_fc_qc_check_count = len(rec.x_fc_qc_check_ids) + + @api.depends('origin') + def _compute_qc_required(self): + SO = self.env['sale.order'] + for rec in self: + required = False + if rec.origin: + so = SO.search([('name', '=', rec.origin)], limit=1) + partner = so.partner_id if so else False + if partner and 'x_fc_requires_qc' in partner._fields: + required = bool(partner.x_fc_requires_qc) + rec.x_fc_qc_required = required + def _compute_rework_count(self): for rec in self: rec.x_fc_rework_count = len(rec.x_fc_rework_children_ids) @@ -793,6 +858,33 @@ class MrpProduction(models.Model): # Generate work orders from recipe (after portal job creation) self._generate_workorders_from_recipe() + # Spawn a QC check for customers that require sign-off. + # Safe to call unconditionally — the factory returns an empty + # recordset when the customer hasn't opted in to QC. + QCheck = self.env.get('fusion.plating.quality.check') + if QCheck is not None: + for mo in self: + partner = False + if mo.origin: + so = self.env['sale.order'].search( + [('name', '=', mo.origin)], limit=1, + ) + partner = so.partner_id if so else False + if not partner: + continue + if not partner._fields.get('x_fc_requires_qc'): + continue + if not partner.x_fc_requires_qc: + continue + # Customer-specific template override wins, otherwise + # the factory resolves by partner → default. + template = ( + partner.x_fc_qc_template_id + if 'x_fc_qc_template_id' in partner._fields + else False + ) + QCheck.create_for_production(mo, template=template or None) + return res # ------------------------------------------------------------------ @@ -807,7 +899,17 @@ class MrpProduction(models.Model): - Renders each cert's PDF immediately and links it to the portal job + delivery so the operator doesn't have to open the cert and click "Generate". + + QC Gate (Phase 1 — 2026-04-20): + If the customer has `x_fc_requires_qc=True`, the active QC + check must be in the `passed` state. Additionally, if the + resolved QC template demands thickness readings / a + Fischerscope PDF, those must exist too. Gate can be bypassed + by a user in the `group_fusion_plating_manager` group with + the `fp_qc_bypass` context flag set (used for data-entry + cleanup; not exposed in the UI). """ + self._fp_qc_gate_check() res = super().button_mark_done() Delivery = self.env.get('fusion.plating.delivery') Certificate = self.env.get('fp.certificate') @@ -934,6 +1036,119 @@ class MrpProduction(models.Model): ) return res + # ------------------------------------------------------------------ + # QC gate enforcement (Phase 1) + # ------------------------------------------------------------------ + def _fp_qc_gate_check(self): + """Block MO completion when the customer requires QC but the + QC hasn't been signed off. + + Enforced conditions (all from the partner-resolved template): + 1. At least one QC record exists in state == 'passed' + 2. Template.require_thickness_readings → MO must have ≥1 reading + 3. Template.require_thickness_report_pdf → QC must carry the PDF + 4. Template.require_inspector_signoff → QC.inspector_id set + + The manager-bypass context flag `fp_qc_bypass` lets a plant + manager push a job through when the QC was done on paper and + logged late — they still own it via chatter. + """ + if self.env.context.get('fp_qc_bypass'): + return + SO = self.env['sale.order'] + ThicknessReading = self.env.get('fp.thickness.reading') + is_manager = self.env.user.has_group( + 'fusion_plating.group_fusion_plating_manager' + ) + for mo in self: + partner = False + if mo.origin: + so = SO.search([('name', '=', mo.origin)], limit=1) + partner = so.partner_id if so else False + if not partner or 'x_fc_requires_qc' not in partner._fields: + continue + if not partner.x_fc_requires_qc: + continue + + passed = mo.x_fc_qc_check_ids.filtered( + lambda c: c.state == 'passed' + ) + if not passed: + # Emit a gentle hint with a direct URL into the QC + # tablet so the user can fix it in one click. + raise UserError(_( + 'Cannot close MO "%(mo)s" — customer "%(cust)s" ' + 'requires QC sign-off and no passing quality check ' + 'exists yet.\n\nOpen Plating → Quality → Quality ' + 'Checks to inspect and sign off, or open the ' + 'active QC from the MO\'s "Quality Checks" tab.' + ) % { + 'mo': mo.name or mo.display_name, + 'cust': partner.name, + }) + qc = passed.sorted('completed_at', reverse=True)[:1] + + # Thickness readings check + if qc.require_thickness_readings: + reading_count = 0 + if ThicknessReading is not None: + reading_count = ThicknessReading.search_count([ + ('production_id', '=', mo.id), + ]) + if reading_count == 0: + raise UserError(_( + 'Cannot close MO "%(mo)s" — QC template requires ' + 'at least one Fischerscope thickness reading, ' + 'but none have been logged.' + ) % {'mo': mo.name}) + + # Thickness report PDF check + if qc.require_thickness_report_pdf and not qc.thickness_report_pdf_id: + raise UserError(_( + 'Cannot close MO "%(mo)s" — QC template requires ' + 'the Fischerscope / XDAL 600 report PDF, but none ' + 'has been uploaded to QC "%(qc)s".' + ) % {'mo': mo.name, 'qc': qc.name}) + + # Inspector sign-off + if qc.require_inspector_signoff and not qc.inspector_id: + raise UserError(_( + 'Cannot close MO "%(mo)s" — QC "%(qc)s" is flagged ' + 'passed but has no inspector on file.' + ) % {'mo': mo.name, 'qc': qc.name}) + + # Log the bypass so audits catch it + if is_manager and self.env.context.get('fp_qc_bypass'): + for mo in self: + mo.message_post(body=_( + 'QC gate bypassed by %s.' + ) % self.env.user.name) + + def action_open_active_qc(self): + """Smart-button action: open the mobile QC checklist for this MO.""" + self.ensure_one() + qc = self.x_fc_active_qc_check_id + if not qc: + raise UserError(_( + 'No QC check exists for this MO yet. Confirm the MO ' + 'after enabling "Require QC Sign-off" on the customer, ' + 'or create a QC manually from Plating → Quality.' + )) + return qc.action_open_tablet() + + def action_view_qc_checks(self): + """List view of all QC checks attached to this MO.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('QC Checks — %s') % self.name, + 'res_model': 'fusion.plating.quality.check', + 'view_mode': 'list,form', + 'domain': [('production_id', '=', self.id)], + 'context': {'default_production_id': self.id}, + 'target': 'current', + } + # ------------------------------------------------------------------ # #5 — Delivery auto-prefill helpers # ------------------------------------------------------------------ diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/res_partner.py b/fusion_plating/fusion_plating_bridge_mrp/models/res_partner.py new file mode 100644 index 00000000..4964c4d4 --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/models/res_partner.py @@ -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.', + ) diff --git a/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv b/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv index 78c019e0..b8cf7449 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv @@ -20,3 +20,15 @@ access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plati access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_operator,1,0,0,0 access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_qc_template_operator,fp.qc.checklist.template.operator,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_operator,1,0,0,0 +access_fp_qc_template_supervisor,fp.qc.checklist.template.supervisor,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_qc_template_manager,fp.qc.checklist.template.manager,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_qc_template_line_operator,fp.qc.checklist.template.line.operator,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_operator,1,0,0,0 +access_fp_qc_template_line_supervisor,fp.qc.checklist.template.line.supervisor,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_qc_template_line_manager,fp.qc.checklist.template.line.manager,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_qc_check_operator,fusion.plating.quality.check.operator,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_operator,1,1,1,0 +access_fp_qc_check_supervisor,fusion.plating.quality.check.supervisor,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_qc_check_manager,fusion.plating.quality.check.manager,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_qc_check_line_operator,fusion.plating.quality.check.line.operator,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_operator,1,1,1,0 +access_fp_qc_check_line_supervisor,fusion.plating.quality.check.line.supervisor,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_qc_check_line_manager,fusion.plating.quality.check.line.manager,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_manager,1,1,1,1 diff --git a/fusion_plating/fusion_plating_bridge_mrp/static/src/js/fp_qc_checklist.js b/fusion_plating/fusion_plating_bridge_mrp/static/src/js/fp_qc_checklist.js new file mode 100644 index 00000000..22613136 --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/static/src/js/fp_qc_checklist.js @@ -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); diff --git a/fusion_plating/fusion_plating_bridge_mrp/static/src/scss/fp_qc_checklist.scss b/fusion_plating/fusion_plating_bridge_mrp/static/src/scss/fp_qc_checklist.scss new file mode 100644 index 00000000..707cc38c --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/static/src/scss/fp_qc_checklist.scss @@ -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%; } + } + } +} diff --git a/fusion_plating/fusion_plating_bridge_mrp/static/src/xml/fp_qc_checklist.xml b/fusion_plating/fusion_plating_bridge_mrp/static/src/xml/fp_qc_checklist.xml new file mode 100644 index 00000000..d89c7200 --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/static/src/xml/fp_qc_checklist.xml @@ -0,0 +1,285 @@ + + + + + +
+ + + +
+ + Loading QC… +
+
+ + +
+ +

+ +
+
+ + + +
+
+ +
+
+ + + · + + +
+

+ +

+
+ + + · + Inspector: + +
+
+
+
+ +
+
+ + +
+
+
+ % +
+
+
+ + + + Pass +
+
+ + + + Fail +
+
+ + + + Pending +
+
+
+
+
+
+
+ + + +
+
+
+
+ + Thickness Report +
+
+ + PDF uploaded · reading(s) extracted + + + Upload Fischerscope / XDAL 600 PDF export + +
+
+ +
+
+
+ + +
+ +
+
+
+ +
+
+
+ + + (optional) + +
+
+ + + + + + + + + + + + + + +
+
+ +
+ + +
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ Range: + +
+
+
+
+ + +
+ +
+
+ +
+ +