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,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_qc_controller

View File

@@ -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)}