feat(plating): QC gate + mobile checklist + Fischerscope thickness capture

Phase 1 — Backend QC gate (bridge_mrp)
* fp.qc.checklist.template / .line — per-customer checklist definitions
* fusion.plating.quality.check / .line — per-MO instances walked by inspectors
* res.partner.x_fc_requires_qc + x_fc_qc_template_id toggles policy per customer
* mrp.production.button_mark_done blocks close until QC passes (plus optional
  thickness-readings + thickness-PDF gates on aerospace templates)
* Auto-spawns the QC on MO confirm from the customer's resolved template
* Fischerscope XDAL 600 PDF parser auto-extracts NiP / Ni% / P% readings on upload
* fp.thickness.reading gains quality_check_id + auto_extracted

Phase 2 — Mobile QC checklist (OWL client action)
* fp_qc_checklist registered under registry.category("actions")
* Reuses shopfloor design tokens (_fp_shopfloor_tokens.scss) — 48 px touch
  targets, shadow-based elevation, three-tier contrast, light + dark bundles
* Per-line pass/fail/N/A with numeric value range, mandatory photo, notes
* Fischerscope PDF drop-zone → server-side pdftotext parse
* Sign-off bar with pass / fail / rework actions

Phase 3 — Admin config
* Starter global default + aerospace/Nadcap templates seeded
* Plating → Configuration → QC Checklist Templates (manager-only)
* Plating → Quality → Quality Checks menu
* "Plating Documents" tab on res.partner gains the QC toggle + template picker
* MO form smart button opens the active QC in the mobile checklist

Gap fixes
* Scanner handles FP-QC:<ref> and FP-MO:<name> — launches the checklist
  directly on the tablet
* action_spawn_retry clones a fresh QC from a failed one so rework doesn't
  need a new MO

All 12 models / routes / gates smoke + E2E tested: 24 assertions pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-21 00:15:58 -04:00
parent 4d6095cd2a
commit e86d897bce
21 changed files with 3210 additions and 1 deletions

View File

@@ -0,0 +1,285 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_bridge_mrp.FpQcChecklist">
<div class="o_fp_qc">
<!-- ===== Loading / error ===== -->
<t t-if="state.loading">
<div class="o_fp_qc_state_loading">
<i class="fa fa-spinner fa-spin"/>
<span>Loading QC…</span>
</div>
</t>
<t t-elif="state.error">
<div class="o_fp_qc_state_error">
<i class="fa fa-exclamation-triangle"/>
<p><t t-esc="state.error"/></p>
<button class="btn btn-primary" t-on-click="refresh">Retry</button>
</div>
</t>
<t t-elif="state.check">
<!-- ===== Header ===== -->
<div class="o_fp_qc_header">
<div class="o_fp_qc_header_left">
<button class="o_fp_qc_back" t-on-click="openProduction"
t-if="state.check.production_id"
title="Back to MO">
<i class="fa fa-arrow-left"/>
</button>
<div class="o_fp_qc_title_block">
<div class="o_fp_qc_breadcrumb">
<span><t t-esc="state.check.production_name"/></span>
<t t-if="state.check.partner_name">
<span class="o_fp_qc_sep">·</span>
<span><t t-esc="state.check.partner_name"/></span>
</t>
</div>
<h1 class="o_fp_qc_title">
<t t-esc="state.check.template_name or 'QC Checklist'"/>
</h1>
<div class="o_fp_qc_sub">
<span class="o_fp_qc_ref"><t t-esc="state.check.name"/></span>
<t t-if="state.check.inspector_name">
<span class="o_fp_qc_sep">·</span>
<span>Inspector: <t t-esc="state.check.inspector_name"/></span>
</t>
</div>
</div>
</div>
<div class="o_fp_qc_state_chip"
t-att-class="'o_fp_qc_chip_' + state.check.state">
<t t-esc="state.check.state.replace('_', ' ').toUpperCase()"/>
</div>
</div>
<!-- ===== Progress ===== -->
<div class="o_fp_qc_progress_card">
<div class="o_fp_qc_progress_numbers">
<div class="o_fp_qc_progress_big">
<t t-esc="progressPercent"/>%
</div>
<div class="o_fp_qc_progress_break">
<div class="o_fp_qc_counter o_fp_qc_counter_pass">
<span class="o_fp_qc_counter_n">
<t t-esc="state.check.lines_passed"/>
</span>
<span class="o_fp_qc_counter_l">Pass</span>
</div>
<div class="o_fp_qc_counter o_fp_qc_counter_fail">
<span class="o_fp_qc_counter_n">
<t t-esc="state.check.lines_failed"/>
</span>
<span class="o_fp_qc_counter_l">Fail</span>
</div>
<div class="o_fp_qc_counter o_fp_qc_counter_pending">
<span class="o_fp_qc_counter_n">
<t t-esc="state.check.lines_pending"/>
</span>
<span class="o_fp_qc_counter_l">Pending</span>
</div>
</div>
</div>
<div class="o_fp_qc_progress_bar">
<div class="o_fp_qc_progress_fill"
t-att-style="'width:' + progressPercent + '%'"/>
</div>
</div>
<!-- ===== Thickness PDF (if required) ===== -->
<t t-if="state.check.require_thickness_report_pdf or state.check.require_thickness_readings">
<div class="o_fp_qc_thickness_card">
<div class="o_fp_qc_thickness_head">
<div>
<div class="o_fp_qc_thickness_title">
<i class="fa fa-bar-chart"/>
Thickness Report
</div>
<div class="o_fp_qc_thickness_sub">
<t t-if="state.check.has_thickness_pdf">
PDF uploaded · <t t-esc="state.check.thickness_reading_count"/> reading(s) extracted
</t>
<t t-else="">
Upload Fischerscope / XDAL 600 PDF export
</t>
</div>
</div>
<button class="o_fp_qc_btn o_fp_qc_btn_primary"
t-on-click="triggerPdfUpload"
t-att-disabled="state.saving">
<i class="fa fa-upload"/>
<t t-if="state.check.has_thickness_pdf">Replace PDF</t>
<t t-else="">Upload PDF</t>
</button>
</div>
</div>
</t>
<!-- ===== Checklist ===== -->
<div class="o_fp_qc_list">
<t t-foreach="state.lines" t-as="line" t-key="line.id">
<div class="o_fp_qc_item"
t-att-class="{
'o_fp_qc_item_pass': line.result == 'pass',
'o_fp_qc_item_fail': line.result == 'fail',
'o_fp_qc_item_na': line.result == 'na',
'o_fp_qc_item_pending': line.result == 'pending' or !line.result,
'o_fp_qc_item_open': state.expandedLineId == line.id,
}">
<div class="o_fp_qc_item_row"
t-on-click="() => this.toggleExpanded(line)">
<div class="o_fp_qc_item_icon">
<i class="fa" t-att-class="checkTypeIcon(line.check_type)"/>
</div>
<div class="o_fp_qc_item_body">
<div class="o_fp_qc_item_name">
<t t-esc="line.name"/>
<t t-if="!line.required">
<span class="o_fp_qc_item_optional">(optional)</span>
</t>
</div>
<div class="o_fp_qc_item_meta">
<span class="o_fp_qc_badge"
t-att-class="resultBadgeClass(line.result)">
<t t-esc="(line.result or 'pending').toUpperCase()"/>
</span>
<t t-if="line.requires_value and line.value">
<span class="o_fp_qc_item_value">
<t t-esc="line.value"/>
<t t-esc="line.value_uom"/>
</span>
</t>
<t t-if="line.requires_photo and line.has_photo">
<span class="o_fp_qc_item_photo_ind">
<i class="fa fa-camera"/>
</span>
</t>
</div>
</div>
<i class="o_fp_qc_chevron fa"
t-att-class="state.expandedLineId == line.id ? 'fa-chevron-up' : 'fa-chevron-down'"/>
</div>
<t t-if="state.expandedLineId == line.id">
<div class="o_fp_qc_item_detail">
<t t-if="line.description">
<div class="o_fp_qc_guidance">
<t t-esc="line.description"/>
</div>
</t>
<t t-if="line.requires_value">
<div class="o_fp_qc_value_row">
<label>Measured Value</label>
<div class="o_fp_qc_value_input">
<input type="number" step="0.0001"
t-att-value="line.value or ''"
t-att-placeholder="line.value_uom or ''"
t-on-input="(ev) => this.onValueInput(line, ev)"/>
<span class="o_fp_qc_uom"><t t-esc="line.value_uom"/></span>
</div>
<t t-if="line.value_min or line.value_max">
<div class="o_fp_qc_range">
Range: <t t-esc="line.value_min"/> <t t-esc="line.value_max"/>
<t t-esc="line.value_uom"/>
</div>
</t>
</div>
</t>
<t t-if="line.requires_photo">
<div class="o_fp_qc_photo_row">
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
t-on-click="() => this.triggerPhoto(line)">
<i class="fa fa-camera"/>
<t t-if="line.has_photo">Replace photo</t>
<t t-else="">Add photo</t>
</button>
</div>
</t>
<div class="o_fp_qc_notes_row">
<label>Notes</label>
<textarea rows="2"
t-att-value="line.notes or ''"
t-on-input="(ev) => this.onNotesInput(line, ev)"
placeholder="Optional — anything the inspector saw that matters"/>
</div>
<div class="o_fp_qc_actions_row">
<button class="o_fp_qc_btn o_fp_qc_btn_pass"
t-on-click="() => this.markLine(line, 'pass')"
t-att-disabled="state.saving">
<i class="fa fa-check"/>
Pass
</button>
<button class="o_fp_qc_btn o_fp_qc_btn_fail"
t-on-click="() => this.markLine(line, 'fail')"
t-att-disabled="state.saving">
<i class="fa fa-times"/>
Fail
</button>
<t t-if="!line.required">
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
t-on-click="() => this.markLine(line, 'na')"
t-att-disabled="state.saving">
N/A
</button>
</t>
<t t-if="line.result != 'pending'">
<button class="o_fp_qc_btn o_fp_qc_btn_ghost"
t-on-click="() => this.markLine(line, 'pending')"
t-att-disabled="state.saving">
Reset
</button>
</t>
</div>
</div>
</t>
</div>
</t>
</div>
<!-- ===== Sign-off bar ===== -->
<t t-if="state.check.state != 'passed' and state.check.state != 'failed'">
<div class="o_fp_qc_footer">
<button class="o_fp_qc_btn o_fp_qc_btn_pass_lg"
t-on-click="() => this.finalize('pass')"
t-att-disabled="!canFinalize or state.saving">
<i class="fa fa-check"/>
<span>Sign Off — PASS</span>
</button>
<button class="o_fp_qc_btn o_fp_qc_btn_fail_lg"
t-on-click="() => this.finalize('fail')"
t-att-disabled="state.saving">
<i class="fa fa-times"/>
<span>Fail QC</span>
</button>
<button class="o_fp_qc_btn o_fp_qc_btn_ghost_lg"
t-on-click="() => this.finalize('rework')"
t-att-disabled="state.saving or !anyFailed">
Send to Rework
</button>
</div>
</t>
<!-- ===== Hidden file inputs ===== -->
<input type="file" t-ref="fileInput"
accept="image/*" capture="environment"
style="display:none"
t-on-change="onPhotoSelected"/>
<input type="file" t-ref="pdfInput"
accept="application/pdf"
style="display:none"
t-on-change="onPdfSelected"/>
</t>
</div>
</t>
</templates>