+ # 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 @@
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+