Split 49 modules/suites into independent git repos; untrack from monorepo
Each top-level module/suite folder is now its own private repo on GitHub (gsinghpal/<name>) and gitea (admin/<name>), with a fresh single initial commit. The monorepo no longer tracks them (added to .gitignore + git rm --cached); working-tree files are retained on disk and managed in their own repos. The monorepo keeps shared root files (CLAUDE.md, docs/, scripts/, tools/, AGENTS.md, WIP/obsolete dirs) and full history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
from . import controllers
|
||||
@@ -1,90 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
{
|
||||
"name": "Fusion Plating - MRP Bridge",
|
||||
'version': '19.0.13.0.5',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||
'description': """
|
||||
Fusion Plating - MRP Bridge
|
||||
============================
|
||||
|
||||
Part of the Fusion Plating product family by Nexa Systems Inc.
|
||||
|
||||
Links Fusion Plating infrastructure (facilities, work centres, baths, tanks)
|
||||
to Odoo's native MRP manufacturing orders and work orders so shops can:
|
||||
|
||||
* Assign a plating facility and FP work centre to an MRP work centre.
|
||||
* Tag each work order with the specific bath, tank, rack/fixture, target
|
||||
thickness, and dwell time for traceability.
|
||||
* Attach a customer specification and facility to a manufacturing order.
|
||||
* Create an MRP work centre directly from a Fusion Plating work centre.
|
||||
* Link a portal job to a manufacturing order for customer visibility.
|
||||
|
||||
Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
""",
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://www.nexasystems.ca',
|
||||
'maintainer': 'Nexa Systems Inc.',
|
||||
'support': 'support@nexasystems.ca',
|
||||
'license': 'OPL-1',
|
||||
'price': 0.00,
|
||||
'currency': 'CAD',
|
||||
'depends': [
|
||||
'fusion_plating',
|
||||
'fusion_plating_portal',
|
||||
'fusion_plating_quality',
|
||||
'fusion_plating_logistics',
|
||||
'fusion_plating_batch',
|
||||
'fusion_plating_shopfloor',
|
||||
'fusion_plating_configurator',
|
||||
'fusion_plating_certificates',
|
||||
'fusion_plating_receiving',
|
||||
'hr',
|
||||
# hr_attendance gives us the standard hr.attendance model
|
||||
# (check_in / check_out). fusion_clock builds on the same model
|
||||
# so this works whether the shop runs vanilla attendance or the
|
||||
# full Fusion Clock T&A. Bringing the dep into the bridge keeps
|
||||
# the Manager Desk's "show only clocked-in workers" filter
|
||||
# working out of the box.
|
||||
'hr_attendance',
|
||||
# mrp / mrp_workorder / mrp_account / sale_mrp deps dropped post-
|
||||
# Sub 11. This module is itself uninstalled; the manifest is kept
|
||||
# on disk for archaeology only. Listing those deps here would let
|
||||
# Odoo silently re-pull them on any addons rescan.
|
||||
'account',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
# Phase 1 (Sub 11) - fp_work_role_data + fp_qc_data relocated
|
||||
# to fusion_plating_jobs.
|
||||
'data/fp_cron_data.xml',
|
||||
'wizard/fp_recipe_config_wizard_views.xml',
|
||||
'views/mrp_workcenter_views.xml',
|
||||
'views/mrp_workorder_views.xml',
|
||||
# Phase 1 (Sub 11) - relocated to fusion_plating_jobs / fusion_plating_quality.
|
||||
# 'views/fp_qc_template_views.xml',
|
||||
# 'views/fp_quality_check_views.xml',
|
||||
# 'views/fp_job_consumption_views.xml',
|
||||
# 'views/fp_work_role_views.xml',
|
||||
'views/mrp_production_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/fp_quality_hold_views.xml',
|
||||
'views/fp_batch_views.xml',
|
||||
# Phase 3 (Sub 11) - replaced by native fp.job.step priority kanban
|
||||
# in fusion_plating_jobs/views/fp_step_priority_views.xml.
|
||||
# 'views/fp_workorder_priority_views.xml',
|
||||
# Phase 4 (Sub 11) - relocated to fusion_plating_quality.
|
||||
# 'views/res_partner_views.xml',
|
||||
'views/fp_serial_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
# Phase 2 (Sub 11) - QC tablet OWL relocated to fusion_plating_quality.
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
# Phase 2 (Sub 11) - QC controller relocated to fusion_plating_quality.
|
||||
# from . import fp_qc_controller
|
||||
@@ -1,284 +0,0 @@
|
||||
# -*- 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)}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!--
|
||||
Cron - auto-finish WOs whose recipe step is `auto_complete`
|
||||
once they've been in Progress for at least their expected
|
||||
duration. Used for fully-automated steps (timed immersion,
|
||||
automated rinse) where the equipment runs unattended.
|
||||
|
||||
Fires every minute so the operator's queue clears promptly
|
||||
once a bath dwell completes.
|
||||
-->
|
||||
<record id="ir_cron_fp_auto_finish_wos" model="ir.cron">
|
||||
<field name="name">Fusion Plating: Auto-finish auto_complete WOs</field>
|
||||
<field name="model_id" ref="mrp.model_mrp_workorder"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._fp_cron_auto_finish_completed_wos()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,161 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Sequence for QC checks + a starter default template so QC works
|
||||
out of the box for any customer that has x_fc_requires_qc=True
|
||||
but no per-customer template yet.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- ===== Sequence ===== -->
|
||||
<record id="seq_fp_quality_check" model="ir.sequence">
|
||||
<field name="name">Fusion Plating: Quality Check</field>
|
||||
<field name="code">fusion.plating.quality.check</field>
|
||||
<field name="prefix">QC/%(year)s/</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- ===== Default checklist template (global - partner_id blank) =====
|
||||
sequence=5 so it wins over any other global template when
|
||||
resolve_for_partner falls back from a missing per-customer match. -->
|
||||
<record id="qc_template_default" model="fp.qc.checklist.template">
|
||||
<field name="name">Standard Plating QC</field>
|
||||
<field name="sequence">5</field>
|
||||
<field name="active">True</field>
|
||||
<field name="require_inspector_signoff">True</field>
|
||||
<field name="require_thickness_readings">False</field>
|
||||
<field name="require_thickness_report_pdf">False</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_line_visual" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_default"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="name">Visual - no pits, burns, or bare spots</field>
|
||||
<field name="description">Examine the entire plated surface under shop lighting. Look for pits, burns, dewetting, bare spots, or rough texture. Reject if any defect is visible to the naked eye.</field>
|
||||
<field name="check_type">visual</field>
|
||||
<field name="required">True</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_line_colour" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_default"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="name">Colour - uniform finish across part</field>
|
||||
<field name="description">Finish should be uniform with no streaking, blotching, or dull-vs-bright zones. Compare against the customer colour sample if one is on file.</field>
|
||||
<field name="check_type">visual</field>
|
||||
<field name="required">True</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_line_adhesion" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_default"/>
|
||||
<field name="sequence">30</field>
|
||||
<field name="name">Adhesion - tape test pass</field>
|
||||
<field name="description">Apply tape to an inconspicuous area, press firmly for 3 seconds, pull at 90°. No flaking permitted.</field>
|
||||
<field name="check_type">adhesion</field>
|
||||
<field name="required">True</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_line_masking" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_default"/>
|
||||
<field name="sequence">40</field>
|
||||
<field name="name">Masking - no plating in masked zones</field>
|
||||
<field name="description">Areas that were masked per customer print must be free of plating deposit. Light staining acceptable; build-up is not.</field>
|
||||
<field name="check_type">visual</field>
|
||||
<field name="required">True</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_line_quantity" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_default"/>
|
||||
<field name="sequence">50</field>
|
||||
<field name="name">Quantity - matches WO count</field>
|
||||
<field name="description">Count the parts. Must equal the WO quantity minus any documented rework/scrap.</field>
|
||||
<field name="check_type">functional</field>
|
||||
<field name="required">True</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_line_packaging" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_default"/>
|
||||
<field name="sequence">60</field>
|
||||
<field name="name">Packaging - parts protected for shipping</field>
|
||||
<field name="description">Parts individually bagged / padded, no direct metal-on-metal contact that could scratch the finish in transit.</field>
|
||||
<field name="check_type">visual</field>
|
||||
<field name="required">False</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Aerospace checklist (stricter - used as a starter for
|
||||
Nadcap customers; admin copies and reassigns to partner) ===== -->
|
||||
<record id="qc_template_aerospace" model="fp.qc.checklist.template">
|
||||
<field name="name">Aerospace / Nadcap QC</field>
|
||||
<field name="sequence">100</field>
|
||||
<field name="active">True</field>
|
||||
<field name="require_inspector_signoff">True</field>
|
||||
<field name="require_thickness_readings">True</field>
|
||||
<field name="require_thickness_report_pdf">True</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_aero_visual" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_aerospace"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="name">Visual - 10× loupe, no discontinuities</field>
|
||||
<field name="description">Inspect under 10× magnification. Reject any pit, crack, inclusion, or discontinuity visible at that power.</field>
|
||||
<field name="check_type">visual</field>
|
||||
<field name="required">True</field>
|
||||
<field name="requires_photo">True</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_aero_thickness_1" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_aerospace"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="name">Thickness - Fischerscope reading #1</field>
|
||||
<field name="description">Fischerscope XDAL 600 XRF measurement at primary inspection point. Value must fall inside the customer spec range. Record the NiP mils reading.</field>
|
||||
<field name="check_type">thickness</field>
|
||||
<field name="required">True</field>
|
||||
<field name="requires_value">True</field>
|
||||
<field name="value_uom">mils</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_aero_thickness_2" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_aerospace"/>
|
||||
<field name="sequence">30</field>
|
||||
<field name="name">Thickness - Fischerscope reading #2</field>
|
||||
<field name="description">Second XRF point - per customer print's secondary inspection location.</field>
|
||||
<field name="check_type">thickness</field>
|
||||
<field name="required">True</field>
|
||||
<field name="requires_value">True</field>
|
||||
<field name="value_uom">mils</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_aero_thickness_3" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_aerospace"/>
|
||||
<field name="sequence">40</field>
|
||||
<field name="name">Thickness - Fischerscope reading #3</field>
|
||||
<field name="description">Third XRF point - per customer print's tertiary inspection location.</field>
|
||||
<field name="check_type">thickness</field>
|
||||
<field name="required">True</field>
|
||||
<field name="requires_value">True</field>
|
||||
<field name="value_uom">mils</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_aero_adhesion" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_aerospace"/>
|
||||
<field name="sequence">50</field>
|
||||
<field name="name">Adhesion - ASTM B571 tape test</field>
|
||||
<field name="description">Apply ASTM B571 tape to freshly-scribed area, remove at 90° per standard. No flaking of plating permitted.</field>
|
||||
<field name="check_type">adhesion</field>
|
||||
<field name="required">True</field>
|
||||
</record>
|
||||
|
||||
<record id="qc_tpl_aero_dimensional" model="fp.qc.checklist.template.line">
|
||||
<field name="template_id" ref="qc_template_aerospace"/>
|
||||
<field name="sequence">60</field>
|
||||
<field name="name">Dimensional - critical feature verification</field>
|
||||
<field name="description">Caliper / mic any feature marked critical on the customer print. Confirm plating did not push dimensions out of tolerance.</field>
|
||||
<field name="check_type">dimensional</field>
|
||||
<field name="required">True</field>
|
||||
<field name="requires_value">False</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,76 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Default shop roles. noupdate="1" so shops can rename/prune freely
|
||||
without upgrades clobbering their changes.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="work_role_masking" model="fp.work.role">
|
||||
<field name="name">Masking</field>
|
||||
<field name="code">masking</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="icon">fa-scissors</field>
|
||||
<field name="description">Applies masking tape/lacquer before plating and removes after.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_racking" model="fp.work.role">
|
||||
<field name="name">Racking</field>
|
||||
<field name="code">racking</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="icon">fa-cogs</field>
|
||||
<field name="description">Fixtures parts onto racks/barrels for processing.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_plating" model="fp.work.role">
|
||||
<field name="name">Plating Operator</field>
|
||||
<field name="code">plating_op</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="icon">fa-flask</field>
|
||||
<field name="description">Runs the plating line - chemistry checks, dwell, thickness.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_demask" model="fp.work.role">
|
||||
<field name="name">De-Mask</field>
|
||||
<field name="code">demask</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="icon">fa-scissors</field>
|
||||
<field name="description">Removes masking material after plating.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_oven" model="fp.work.role">
|
||||
<field name="name">Oven / Bake</field>
|
||||
<field name="code">oven</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="description">Loads and operates embrittlement-relief ovens.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_derack" model="fp.work.role">
|
||||
<field name="name">De-Rack</field>
|
||||
<field name="code">derack</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="icon">fa-cogs</field>
|
||||
<field name="description">Removes parts from racks/barrels for inspection.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_inspection" model="fp.work.role">
|
||||
<field name="name">Inspection / QA</field>
|
||||
<field name="code">inspection</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="icon">fa-search</field>
|
||||
<field name="description">Post-plate inspection, Fischerscope, first-piece sign-off.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_rework" model="fp.work.role">
|
||||
<field name="name">Rework</field>
|
||||
<field name="code">rework</field>
|
||||
<field name="sequence">80</field>
|
||||
<field name="icon">fa-wrench</field>
|
||||
<field name="description">Strips bad plating; routes parts back for re-processing.</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,45 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import mrp_workcenter
|
||||
from . import mrp_workorder
|
||||
from . import mrp_production
|
||||
from . import fp_work_center
|
||||
from . import fp_portal_job
|
||||
from . import fp_quality_hold
|
||||
from . import fp_delivery
|
||||
from . import fp_batch
|
||||
# fusion.plating.job.node.override (mrp.production-bound) - kept here
|
||||
# until Phase 5 deletes the bridge module. The native fp.job-bound
|
||||
# override is `fp.job.node.override` in fusion_plating_jobs (different
|
||||
# model, different table).
|
||||
from . import fp_job_node_override
|
||||
# Phase 1 (Sub 11) - fp.job.consumption is now in fusion_plating_jobs.
|
||||
# bridge_mrp can't depend on jobs (would create a cycle through
|
||||
# notifications/reports), so the legacy production_id/workorder_id
|
||||
# fields are gone for good. mrp.production has 0 rows in native mode
|
||||
# so the loss of the back-link is data-safe.
|
||||
# from . import fp_job_consumption
|
||||
from . import account_move
|
||||
from . import sale_order
|
||||
# Phase 1 (Sub 11) - relocated to fusion_plating_jobs.
|
||||
# from . import fp_work_role
|
||||
# Phase 1 (Sub 11) - relocated to fusion_plating_jobs.
|
||||
# from . import hr_employee
|
||||
# Phase 1 (Sub 11) - relocated to fusion_plating_jobs.
|
||||
# from . import fp_proficiency
|
||||
# Phase 1 (Sub 11) - relocated to fusion_plating_jobs (fp.work.role lives there).
|
||||
# from . import fp_process_node
|
||||
# Phase 1 (Sub 11) - relocated to fusion_plating_jobs.
|
||||
# from . import fp_qc_template
|
||||
# Phase 1 (Sub 11) - model relocated to fusion_plating_quality.
|
||||
# This file now contains only a thin inherit that restores the
|
||||
# legacy production_id back-link until Phase 5 retires the bridge.
|
||||
from . import fp_quality_check
|
||||
# Phase 1 (Sub 11) - relocated to fusion_plating_quality.
|
||||
# from . import fp_thickness_reading
|
||||
# Phase 4 (Sub 11) - relocated to fusion_plating_quality.
|
||||
# from . import res_partner
|
||||
from . import fp_serial
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,53 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
"""Extend invoice to auto-complete portal job when posted.
|
||||
|
||||
GAP 7: Invoice posted → find portal job for same customer/SO
|
||||
→ set state "complete" + invoice_ref.
|
||||
"""
|
||||
_inherit = 'account.move'
|
||||
|
||||
def action_post(self):
|
||||
"""Override to cascade invoice posting to portal job completion."""
|
||||
res = super().action_post()
|
||||
PortalJob = self.env.get('fusion.plating.portal.job')
|
||||
if PortalJob is None:
|
||||
return res
|
||||
for invoice in self:
|
||||
if invoice.move_type != 'out_invoice':
|
||||
continue
|
||||
# Find portal jobs for this customer that are shipped but not complete
|
||||
# Match by SO origin from the invoice lines
|
||||
origin = invoice.invoice_origin or ''
|
||||
jobs = PortalJob.browse()
|
||||
if origin:
|
||||
# Try to find MO linked to this SO, then its portal job
|
||||
mos = self.env['mrp.production'].search(
|
||||
[('origin', '=', origin)],
|
||||
)
|
||||
for mo in mos:
|
||||
if mo.x_fc_portal_job_id and mo.x_fc_portal_job_id.state == 'shipped':
|
||||
jobs |= mo.x_fc_portal_job_id
|
||||
# Fallback: find shipped jobs for same partner
|
||||
if not jobs:
|
||||
jobs = PortalJob.search([
|
||||
('partner_id', '=', invoice.partner_id.id),
|
||||
('state', '=', 'shipped'),
|
||||
('invoice_ref', '=', False),
|
||||
], limit=1)
|
||||
for job in jobs:
|
||||
job.write({
|
||||
'state': 'complete',
|
||||
'invoice_ref': invoice.name,
|
||||
})
|
||||
job.message_post(
|
||||
body='Invoice %s posted - job complete.' % invoice.name,
|
||||
)
|
||||
return res
|
||||
@@ -1,32 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpBatch(models.Model):
|
||||
"""Extend batch with M2M link to MRP work orders.
|
||||
|
||||
GAP 6: Batch ↔ Work Order linkage so the shop knows which WOs
|
||||
were processed in which tank batch (rack/barrel load).
|
||||
"""
|
||||
_inherit = 'fusion.plating.batch'
|
||||
|
||||
workorder_ids = fields.Many2many(
|
||||
'mrp.workorder',
|
||||
'fp_batch_mrp_workorder_rel',
|
||||
'batch_id',
|
||||
'workorder_id',
|
||||
string='Work Orders',
|
||||
help='MRP work orders processed in this batch.',
|
||||
)
|
||||
workorder_count = fields.Integer(
|
||||
string='WO Count',
|
||||
compute='_compute_workorder_count',
|
||||
)
|
||||
|
||||
def _compute_workorder_count(self):
|
||||
for rec in self:
|
||||
rec.workorder_count = len(rec.workorder_ids)
|
||||
@@ -1,51 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpDelivery(models.Model):
|
||||
"""Extend delivery to auto-update portal job and block shipment
|
||||
when hydrogen embrittlement bake window isn't closed.
|
||||
"""
|
||||
_inherit = 'fusion.plating.delivery'
|
||||
|
||||
def action_mark_delivered(self):
|
||||
"""Override to cascade delivery completion to the portal job and
|
||||
enforce the bake-window lockout."""
|
||||
# --- Lockout: refuse to ship if any bake window for this job isn't complete
|
||||
BakeWindow = self.env.get('fusion.plating.bake.window')
|
||||
if BakeWindow is not None:
|
||||
for delivery in self:
|
||||
if not delivery.job_ref:
|
||||
continue
|
||||
open_windows = BakeWindow.search([
|
||||
('lot_ref', '=', delivery.job_ref),
|
||||
('state', 'not in', ('baked', 'scrapped')),
|
||||
])
|
||||
if open_windows:
|
||||
bad = open_windows[0]
|
||||
raise UserError(_(
|
||||
'Cannot mark delivery %s delivered - job %s has an open '
|
||||
'bake window (%s, state: %s). Complete the relief bake '
|
||||
'or mark it scrapped before shipping.'
|
||||
) % (delivery.name, delivery.job_ref, bad.name, bad.state))
|
||||
|
||||
res = super().action_mark_delivered()
|
||||
PortalJob = self.env['fusion.plating.portal.job']
|
||||
for delivery in self:
|
||||
if not delivery.job_ref:
|
||||
continue
|
||||
job = PortalJob.search([('name', '=', delivery.job_ref)], limit=1)
|
||||
if not job:
|
||||
continue
|
||||
job.write({
|
||||
'state': 'shipped',
|
||||
'actual_ship_date': fields.Date.today(),
|
||||
'tracking_ref': delivery.name,
|
||||
})
|
||||
job.message_post(body=_('Parts shipped - delivery %s marked delivered.') % delivery.name)
|
||||
return res
|
||||
@@ -1,25 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
#
|
||||
# Phase 1 (Sub 11) - the model proper now lives in
|
||||
# fusion_plating_jobs. This file restores the legacy production_id +
|
||||
# workorder_id back-links so bridge_mrp's mrp.production O2M
|
||||
# (x_fc_consumption_ids) keeps resolving until Phase 5 deletes the
|
||||
# bridge module.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpJobConsumption(models.Model):
|
||||
_inherit = 'fp.job.consumption'
|
||||
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production', string='Manufacturing Order',
|
||||
ondelete='cascade', index=True,
|
||||
)
|
||||
workorder_id = fields.Many2one(
|
||||
'mrp.workorder', string='Work Order',
|
||||
domain="[('production_id', '=', production_id)]",
|
||||
)
|
||||
@@ -1,73 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpJobNodeOverride(models.Model):
|
||||
"""Per-job override for optional recipe steps.
|
||||
|
||||
When a recipe is assigned to a manufacturing order, nodes with
|
||||
opt_in_out != 'disabled' can be toggled on or off for that specific
|
||||
job. Opt-in nodes default to excluded; opt-out nodes default to
|
||||
included. The planner changes these via the configuration wizard.
|
||||
"""
|
||||
_name = 'fusion.plating.job.node.override'
|
||||
_description = 'Fusion Plating - Job Node Override'
|
||||
_order = 'node_sequence, id'
|
||||
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production',
|
||||
string='Manufacturing Order',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
node_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe Step',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
node_name = fields.Char(
|
||||
related='node_id.name',
|
||||
string='Step Name',
|
||||
readonly=True,
|
||||
)
|
||||
node_type = fields.Selection(
|
||||
related='node_id.node_type',
|
||||
string='Type',
|
||||
readonly=True,
|
||||
)
|
||||
node_sequence = fields.Integer(
|
||||
related='node_id.sequence',
|
||||
string='Sequence',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
opt_in_out = fields.Selection(
|
||||
related='node_id.opt_in_out',
|
||||
string='Default',
|
||||
readonly=True,
|
||||
)
|
||||
included = fields.Boolean(
|
||||
string='Included',
|
||||
default=True,
|
||||
help='Whether this optional step is active for this job.',
|
||||
)
|
||||
|
||||
@api.depends('production_id', 'node_id', 'included')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
mo = rec.production_id.name or '(no MO)'
|
||||
node = rec.node_id.display_name or '(no node)'
|
||||
tag = 'included' if rec.included else 'excluded'
|
||||
rec.display_name = '%s · %s [%s]' % (mo, node, tag)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_production_node',
|
||||
'unique(production_id, node_id)',
|
||||
'Each recipe step can only have one override per job.'),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpPortalJob(models.Model):
|
||||
"""Extend portal job with a link to its manufacturing order."""
|
||||
_inherit = 'fusion.plating.portal.job'
|
||||
|
||||
x_fc_production_id = fields.Many2one(
|
||||
'mrp.production',
|
||||
string='Manufacturing Order',
|
||||
help='The Odoo manufacturing order linked to this portal job.',
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpProcessNode(models.Model):
|
||||
"""Tag each recipe operation with the shop role that performs it.
|
||||
|
||||
The auto-assigner reads this when generating WOs: each WO inherits
|
||||
its operation node's role, then hunts for an employee with a
|
||||
matching x_fc_work_role_ids membership.
|
||||
"""
|
||||
_inherit = 'fusion.plating.process.node'
|
||||
|
||||
x_fc_work_role_id = fields.Many2one(
|
||||
'fp.work.role', string='Performed By (Role)',
|
||||
ondelete='set null',
|
||||
help='Shop role that performs this step. When the WO is '
|
||||
'generated it auto-routes to an employee with this role.',
|
||||
)
|
||||
@@ -1,175 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Operator proficiency tracker - counts successful WO completions per
|
||||
(employee, role) pair and auto-promotes the employee once the role's
|
||||
mastery threshold is crossed.
|
||||
|
||||
The promotion mechanic lets managers casually train workers on the job:
|
||||
they assign someone a task they've never done, the worker finishes it
|
||||
successfully, and after N successes the role is added to the employee's
|
||||
Shop Roles automatically. The operator never has to fill in a form;
|
||||
their growing skill set just unlocks itself.
|
||||
"""
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class FpOperatorProficiency(models.Model):
|
||||
_name = 'fp.operator.proficiency'
|
||||
_description = 'Fusion Plating - Operator Task Proficiency'
|
||||
_rec_name = 'display_name'
|
||||
_order = 'employee_id, role_id'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee', string='Operator',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
role_id = fields.Many2one(
|
||||
'fp.work.role', string='Role',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
completed_count = fields.Integer(
|
||||
string='Completions',
|
||||
default=0,
|
||||
help='Number of times this operator has successfully finished a '
|
||||
'WO that required this role.',
|
||||
)
|
||||
first_completed_at = fields.Datetime(
|
||||
string='First Success',
|
||||
help='When the operator finished their first WO for this role.',
|
||||
)
|
||||
last_completed_at = fields.Datetime(
|
||||
string='Last Success',
|
||||
help='Most recent WO completion against this role.',
|
||||
)
|
||||
promoted = fields.Boolean(
|
||||
string='Promoted',
|
||||
default=False,
|
||||
index=True,
|
||||
help='True once the role has been added to the operator\'s Shop '
|
||||
'Roles automatically. Stays True even if a manager removes '
|
||||
'the role afterwards - the count and promotion history are '
|
||||
'preserved as a training record.',
|
||||
)
|
||||
promoted_at = fields.Datetime(
|
||||
string='Promoted On',
|
||||
help='When the auto-promotion fired (count crossed the role\'s '
|
||||
'mastery threshold).',
|
||||
)
|
||||
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name', store=True,
|
||||
)
|
||||
progress_label = fields.Char(
|
||||
compute='_compute_progress_label',
|
||||
help='"3 / 5" style indicator of how close this operator is to '
|
||||
'mastery.',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_proficiency_uniq',
|
||||
'unique(employee_id, role_id)',
|
||||
'There is already a proficiency record for this operator and role.'),
|
||||
]
|
||||
|
||||
@api.depends('employee_id.name', 'role_id.name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
rec.display_name = (
|
||||
f'{rec.employee_id.name or "?"} - {rec.role_id.name or "?"}'
|
||||
)
|
||||
|
||||
@api.depends('completed_count', 'role_id.mastery_required')
|
||||
def _compute_progress_label(self):
|
||||
for rec in self:
|
||||
target = rec.role_id.mastery_required or 0
|
||||
rec.progress_label = (
|
||||
f'{rec.completed_count} / {target}' if target
|
||||
else str(rec.completed_count)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API used by mrp.workorder.button_finish (via _fp_record_proficiency).
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _record_completion(self, employee, role):
|
||||
"""Increment the (employee, role) tally and promote if at threshold.
|
||||
|
||||
Idempotent for the (employee, role) pair - if no record exists,
|
||||
we create one. Always uses sudo() because the worker may not
|
||||
have write access to their own profile.
|
||||
"""
|
||||
if not employee or not role:
|
||||
return self.browse()
|
||||
|
||||
rec = self.sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('role_id', '=', role.id),
|
||||
], limit=1)
|
||||
now = fields.Datetime.now()
|
||||
if rec:
|
||||
new_count = rec.completed_count + 1
|
||||
rec.write({
|
||||
'completed_count': new_count,
|
||||
'last_completed_at': now,
|
||||
})
|
||||
else:
|
||||
rec = self.sudo().create({
|
||||
'employee_id': employee.id,
|
||||
'role_id': role.id,
|
||||
'completed_count': 1,
|
||||
'first_completed_at': now,
|
||||
'last_completed_at': now,
|
||||
})
|
||||
rec._maybe_promote()
|
||||
return rec
|
||||
|
||||
def _maybe_promote(self):
|
||||
"""Promote the employee if they've crossed the role's threshold.
|
||||
|
||||
- Already promoted: no-op (history is preserved but no duplicate
|
||||
chatter spam).
|
||||
- Already in Shop Roles (e.g. manager added it manually): mark
|
||||
promoted but don't post chatter.
|
||||
- Below threshold: nothing to do.
|
||||
- At/above threshold AND not on Shop Roles yet: add the role and
|
||||
post a celebratory chatter line on the employee.
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.promoted:
|
||||
continue
|
||||
target = rec.role_id.mastery_required or 0
|
||||
if target <= 0:
|
||||
continue # Auto-promotion disabled for this role
|
||||
if rec.completed_count < target:
|
||||
continue
|
||||
employee = rec.employee_id
|
||||
role = rec.role_id
|
||||
already_assigned = role in employee.x_fc_work_role_ids
|
||||
rec.sudo().write({
|
||||
'promoted': True,
|
||||
'promoted_at': fields.Datetime.now(),
|
||||
})
|
||||
if already_assigned:
|
||||
# Manager pre-added the role; don't double-announce.
|
||||
continue
|
||||
# Add to Shop Roles + announce on the employee chatter.
|
||||
employee.sudo().write({
|
||||
'x_fc_work_role_ids': [(4, role.id)],
|
||||
})
|
||||
employee.message_post(
|
||||
body=Markup(_(
|
||||
'🎉 <b>%(name)s promoted</b> - qualified for '
|
||||
'<b>%(role)s</b> after %(count)s successful '
|
||||
'completions.'
|
||||
)) % {
|
||||
'name': employee.name,
|
||||
'role': role.name,
|
||||
'count': rec.completed_count,
|
||||
},
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
@@ -1,168 +0,0 @@
|
||||
# -*- 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.',
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
#
|
||||
# Phase 1 (Sub 11) - the QC model proper now lives in
|
||||
# fusion_plating_quality. This file restores the legacy production_id
|
||||
# back-link on fusion.plating.quality.check so bridge_mrp's
|
||||
# mrp.production O2M (x_fc_qc_check_ids) keeps resolving until Phase 5
|
||||
# deletes the bridge module entirely.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpQualityCheck(models.Model):
|
||||
_inherit = 'fusion.plating.quality.check'
|
||||
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production', string='Manufacturing Order',
|
||||
ondelete='cascade', index=True,
|
||||
help='Legacy MRP back-link. Native flow uses job_id; this stays '
|
||||
'for bridge_mrp until Phase 5 cuts the module.',
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpQualityHoldMrp(models.Model):
|
||||
"""Add MRP references to the quality hold record.
|
||||
|
||||
These fields live here (not in fusion_plating_quality) so the QMS
|
||||
module can install without an mrp dependency.
|
||||
"""
|
||||
_inherit = 'fusion.plating.quality.hold'
|
||||
|
||||
workorder_id = fields.Many2one(
|
||||
'mrp.workorder',
|
||||
string='Work Order',
|
||||
)
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production',
|
||||
string='Manufacturing Order',
|
||||
)
|
||||
portal_job_id = fields.Many2one(
|
||||
'fusion.plating.portal.job',
|
||||
string='Portal Job',
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
#
|
||||
# Sub 5 - attach MO reverse links to fp.serial. Defined here rather than
|
||||
# in fusion_plating_configurator because configurator loads before
|
||||
# bridge_mrp; declaring the O2M at configurator setup time would fail
|
||||
# because mrp.production.x_fc_serial_id wouldn't exist yet.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpSerial(models.Model):
|
||||
_inherit = 'fp.serial'
|
||||
|
||||
production_ids = fields.One2many(
|
||||
'mrp.production', 'x_fc_serial_id',
|
||||
string='Manufacturing Orders',
|
||||
)
|
||||
production_count = fields.Integer(compute='_compute_production_count')
|
||||
|
||||
@api.depends('production_ids')
|
||||
def _compute_production_count(self):
|
||||
for rec in self:
|
||||
rec.production_count = len(rec.production_ids)
|
||||
@@ -1,29 +0,0 @@
|
||||
# -*- 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.',
|
||||
)
|
||||
@@ -1,55 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpWorkCenter(models.Model):
|
||||
"""Extend FP work centre with a link to its mirrored MRP work centre."""
|
||||
_inherit = 'fusion.plating.work.center'
|
||||
|
||||
x_fc_mrp_workcenter_id = fields.Many2one(
|
||||
'mrp.workcenter',
|
||||
string='MRP Work Centre',
|
||||
copy=False,
|
||||
help='The Odoo MRP work centre that mirrors this FP work centre.',
|
||||
)
|
||||
|
||||
def action_sync_to_mrp(self):
|
||||
"""Create or update the mirrored mrp.workcenter record.
|
||||
|
||||
If the FP work centre already has an MRP work centre linked,
|
||||
update its name and facility reference. Otherwise create a new
|
||||
one and link it back.
|
||||
"""
|
||||
Workcenter = self.env['mrp.workcenter']
|
||||
for rec in self:
|
||||
vals = {
|
||||
'name': rec.name,
|
||||
'code': rec.code,
|
||||
'x_fc_facility_id': rec.facility_id.id,
|
||||
'x_fc_fp_work_center_id': rec.id,
|
||||
'company_id': rec.facility_id.company_id.id,
|
||||
}
|
||||
if rec.x_fc_mrp_workcenter_id:
|
||||
rec.x_fc_mrp_workcenter_id.write(vals)
|
||||
_logger.info(
|
||||
'Fusion Plating MRP bridge: updated mrp.workcenter %s '
|
||||
'from FP work centre %s',
|
||||
rec.x_fc_mrp_workcenter_id.id, rec.name,
|
||||
)
|
||||
else:
|
||||
wc = Workcenter.create(vals)
|
||||
rec.x_fc_mrp_workcenter_id = wc.id
|
||||
_logger.info(
|
||||
'Fusion Plating MRP bridge: created mrp.workcenter %s '
|
||||
'from FP work centre %s',
|
||||
wc.id, rec.name,
|
||||
)
|
||||
return {'type': 'ir.actions.client', 'tag': 'reload'}
|
||||
@@ -1,67 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpWorkRole(models.Model):
|
||||
"""A shop role assigned to a recipe step and to the employees who
|
||||
can perform it.
|
||||
|
||||
Shops run the same part with different staffing models:
|
||||
- One employee does every step (small shop): give them every role.
|
||||
- Specialists per operation (masking person, racker, plater): one
|
||||
role each.
|
||||
- Cross-trained workers: multiple roles per worker.
|
||||
|
||||
The model is intentionally flat - no hierarchy, no workflow. Roles
|
||||
are just tags that the WO auto-assignment compares.
|
||||
"""
|
||||
_name = 'fp.work.role'
|
||||
_description = 'Fusion Plating - Shop Work Role'
|
||||
_order = 'sequence, code'
|
||||
|
||||
name = fields.Char(string='Role Name', required=True, translate=True)
|
||||
code = fields.Char(string='Code', required=True,
|
||||
help='Short stable identifier used in auto-assignment.')
|
||||
sequence = fields.Integer(default=10)
|
||||
description = fields.Char(
|
||||
string='Description',
|
||||
help='Short operator-facing description of what this role covers.',
|
||||
)
|
||||
icon = fields.Selection(
|
||||
[('fa-scissors', 'Scissors (masking)'),
|
||||
('fa-cogs', 'Cogs (racking)'),
|
||||
('fa-flask', 'Flask (plating)'),
|
||||
('fa-fire', 'Fire (oven)'),
|
||||
('fa-search', 'Inspection'),
|
||||
('fa-wrench', 'Wrench (rework)'),
|
||||
('fa-user', 'Generic worker')],
|
||||
string='Icon', default='fa-user',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mastery threshold - how many successful WO completions a worker
|
||||
# needs on this role before they're auto-promoted (added to their
|
||||
# x_fc_work_role_ids). Default reads from the company-level Fusion
|
||||
# Plating settings so a new role inherits the shop default; the
|
||||
# manager can override per role for tasks that need more practice
|
||||
# (e.g. masking = 1, electroless nickel plating = 5).
|
||||
# ------------------------------------------------------------------
|
||||
mastery_required = fields.Integer(
|
||||
string='Mastery Threshold',
|
||||
default=lambda self: self._default_mastery_required(),
|
||||
help='Number of successful WO completions a worker needs on this '
|
||||
"role before they're added to its qualified-operators list "
|
||||
'automatically. 1 = promote on first success; 3 = solid '
|
||||
"default for everyday roles; 5+ for tasks that need real "
|
||||
'practice. Defaults from Settings > Fusion Plating > '
|
||||
'Default Mastery Threshold.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _default_mastery_required(self):
|
||||
return self.env.company.x_fc_default_mastery_threshold or 3
|
||||
@@ -1,161 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
"""Tag employees with the shop roles they can perform.
|
||||
|
||||
An employee with role 'masking' receives the masking steps when WOs
|
||||
are generated; an employee with multiple roles receives WOs for all
|
||||
of them. A small shop where the owner wears every hat just tags
|
||||
themselves with every role.
|
||||
|
||||
Lead hands are a separate per-role list - they don't have to be
|
||||
primary owners of those roles, but they're authorised to step in
|
||||
when the regular owner is absent or behind. The Manager Desk
|
||||
promotes lead hands above other workers in its dropdown for any
|
||||
role they cover.
|
||||
"""
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
x_fc_work_role_ids = fields.Many2many(
|
||||
'fp.work.role', 'fp_employee_work_role_rel',
|
||||
'employee_id', 'role_id', string='Shop Roles',
|
||||
help='Which shop roles this employee performs. Used by the '
|
||||
'Manager Desk and auto-assignment on WO generation. '
|
||||
'Roles are added automatically when an employee completes '
|
||||
'a task that meets the role mastery threshold.',
|
||||
)
|
||||
# Per-role lead-hand list. Sarah might be a lead hand for masking +
|
||||
# racking but not for plating; Mike might cover everything during
|
||||
# a graveyard shift. Stored on a separate relation table so the
|
||||
# primary "Shop Roles" list stays distinct from the cover-anything
|
||||
# authority.
|
||||
x_fc_lead_hand_role_ids = fields.Many2many(
|
||||
'fp.work.role', 'fp_employee_lead_hand_role_rel',
|
||||
'employee_id', 'role_id', string='Lead Hand For',
|
||||
help='Roles where this employee is authorised to lead or cover '
|
||||
'for an absent operator. Lead hands are surfaced first in '
|
||||
'the Manager Desk worker picker for these roles.',
|
||||
)
|
||||
|
||||
x_fc_proficiency_ids = fields.One2many(
|
||||
'fp.operator.proficiency', 'employee_id',
|
||||
string='Task Proficiency',
|
||||
help='Per-role completion tally. Workers earn one count per WO '
|
||||
'they finish on a given role. Once the count crosses the '
|
||||
"role's mastery threshold the role is added to their "
|
||||
'Shop Roles list automatically.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attendance helpers - used by the Manager Desk to show who is
|
||||
# currently clocked in. Works with vanilla hr_attendance or the
|
||||
# full fusion_clock module - both store an open record (no
|
||||
# check_out) for as long as the employee is on shift.
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_is_clocked_in = fields.Boolean(
|
||||
string='Clocked In',
|
||||
compute='_compute_x_fc_is_clocked_in',
|
||||
search='_search_x_fc_is_clocked_in',
|
||||
help='True if this employee currently has an open hr.attendance '
|
||||
'record (clocked in but not clocked out).',
|
||||
)
|
||||
|
||||
def _compute_x_fc_is_clocked_in(self):
|
||||
"""Compute attendance status from hr.attendance.
|
||||
|
||||
Batched so the manager dashboard doesn't issue one query per
|
||||
employee - important when the shop has dozens of operators.
|
||||
"""
|
||||
if not self:
|
||||
return
|
||||
Att = self.env.get('hr.attendance')
|
||||
if Att is None:
|
||||
for emp in self:
|
||||
emp.x_fc_is_clocked_in = False
|
||||
return
|
||||
# One read for the whole recordset.
|
||||
open_emp_ids = set(Att.sudo().search([
|
||||
('employee_id', 'in', self.ids),
|
||||
('check_out', '=', False),
|
||||
]).mapped('employee_id').ids)
|
||||
for emp in self:
|
||||
emp.x_fc_is_clocked_in = emp.id in open_emp_ids
|
||||
|
||||
def _search_x_fc_is_clocked_in(self, *args):
|
||||
"""Lets `[('x_fc_is_clocked_in', '=', True)]` work as a domain.
|
||||
|
||||
Two compounding gotchas surfaced after fusion_clock auto-closed
|
||||
the demo open attendances:
|
||||
|
||||
1. Odoo 19 normalises ``('=', True)`` into
|
||||
``('in', OrderedSet([True]))`` before invoking the search
|
||||
method. The previous code only handled ``=`` / ``!=`` and
|
||||
fell through to ``return []`` for ``in`` / ``not in`` -
|
||||
which Odoo treats as "no constraint" and matches every
|
||||
row.
|
||||
|
||||
2. ``('id', 'in', [])`` is also treated as no-constraint in
|
||||
some Odoo versions; replaced with a ``[0]`` sentinel so
|
||||
the empty-open-list case correctly matches nothing.
|
||||
|
||||
Strategy: reduce caller intent to a *match_set* of booleans
|
||||
(which values of ``x_fc_is_clocked_in`` should match), flip on
|
||||
negative operators, then translate into ``id IN`` / ``NOT IN``
|
||||
on the cached open-attendance employee ids. Variable signature
|
||||
future-proofs against Odoo's compute-field API shifting again.
|
||||
"""
|
||||
# Variable signature - Odoo 19 may pass (records, op, val).
|
||||
if len(args) == 3:
|
||||
_records, operator, value = args
|
||||
elif len(args) == 2:
|
||||
operator, value = args
|
||||
else:
|
||||
return [('id', '=', False)]
|
||||
|
||||
Att = self.env.get('hr.attendance')
|
||||
if Att is None:
|
||||
return [('id', '=', False)]
|
||||
|
||||
if operator in ('=', '!='):
|
||||
match_set = {bool(value)}
|
||||
elif operator in ('in', 'not in'):
|
||||
match_set = set(map(bool, value))
|
||||
else:
|
||||
return [('id', '=', False)]
|
||||
|
||||
# Negated operators flip the match set.
|
||||
if operator in ('!=', 'not in'):
|
||||
match_set = {True, False} - match_set
|
||||
|
||||
if not match_set:
|
||||
return [('id', '=', False)]
|
||||
if match_set == {True, False}:
|
||||
return [] # every row matches
|
||||
|
||||
open_emp_ids = Att.sudo().search(
|
||||
[('check_out', '=', False)]
|
||||
).employee_id.ids
|
||||
ids_term = open_emp_ids or [0]
|
||||
return [('id', 'in' if True in match_set else 'not in', ids_term)]
|
||||
|
||||
@api.model
|
||||
def _fp_clocked_in_user_ids(self):
|
||||
"""Return the set of res.users.ids whose linked employee is on shift.
|
||||
|
||||
Used by the Manager Desk controller to short-circuit the worker
|
||||
dropdown to "present today" without an N+1 attendance query
|
||||
per worker.
|
||||
"""
|
||||
Att = self.env.get('hr.attendance')
|
||||
if Att is None:
|
||||
return set()
|
||||
emps = Att.sudo().search([
|
||||
('check_out', '=', False),
|
||||
]).mapped('employee_id')
|
||||
return set(emps.user_id.ids)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class MrpWorkcenter(models.Model):
|
||||
"""Extend MRP work centre with Fusion Plating facility and work centre."""
|
||||
_inherit = 'mrp.workcenter'
|
||||
|
||||
x_fc_facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Plating Facility',
|
||||
help='The Fusion Plating facility this MRP work centre belongs to.',
|
||||
)
|
||||
x_fc_fp_work_center_id = fields.Many2one(
|
||||
'fusion.plating.work.center',
|
||||
string='FP Work Centre',
|
||||
domain="[('facility_id', '=', x_fc_facility_id)]",
|
||||
help='The Fusion Plating work centre mirrored by this MRP work centre.',
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
# -*- 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.',
|
||||
)
|
||||
@@ -1,650 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
"""Add smart-button counts + actions so the SO form is a hub for the
|
||||
full production lifecycle: MOs, WOs, Portal Jobs, Quality Holds,
|
||||
Certificates, Deliveries.
|
||||
"""
|
||||
_inherit = 'sale.order'
|
||||
|
||||
x_fc_production_ids = fields.One2many(
|
||||
'mrp.production', compute='_compute_fp_related_records',
|
||||
string='Manufacturing Orders',
|
||||
)
|
||||
x_fc_production_count = fields.Integer(
|
||||
compute='_compute_fp_related_records',
|
||||
)
|
||||
x_fc_workorder_count = fields.Integer(
|
||||
compute='_compute_fp_related_records',
|
||||
)
|
||||
x_fc_portal_job_count = fields.Integer(
|
||||
compute='_compute_fp_related_records',
|
||||
)
|
||||
x_fc_quality_hold_count = fields.Integer(
|
||||
compute='_compute_fp_related_records',
|
||||
)
|
||||
x_fc_certificate_count = fields.Integer(
|
||||
compute='_compute_fp_related_records',
|
||||
)
|
||||
x_fc_delivery_count = fields.Integer(
|
||||
compute='_compute_fp_related_records',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Workflow stage - drives which contextual next-step button appears
|
||||
# on the SO form header. Shows ONE action at a time so users aren't
|
||||
# overwhelmed. Pattern mirrors fusion_claims ADP case buttons.
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_workflow_stage = fields.Selection(
|
||||
[
|
||||
('draft', 'Quote'),
|
||||
('awaiting_parts', 'Parts'),
|
||||
('inspecting', 'Inspecting'),
|
||||
('accept_parts', 'Accept'),
|
||||
('assign_work', 'Assign'),
|
||||
('in_production', 'Production'),
|
||||
('ready_to_ship', 'Ready'),
|
||||
('shipped', 'Shipped'),
|
||||
('invoicing', 'Invoicing'),
|
||||
('paid', 'Paid'),
|
||||
('complete', 'Done'),
|
||||
('cancelled', 'Cancelled'),
|
||||
],
|
||||
compute='_compute_workflow_stage',
|
||||
string='Workflow Stage',
|
||||
help='Current position in the SO → Ship → Invoice workflow. '
|
||||
'Drives which next-step button is shown on the SO header.',
|
||||
)
|
||||
x_fc_assigned_manager_id = fields.Many2one(
|
||||
'res.users', string='Assigned Manager',
|
||||
help='The manager responsible for this job. Set automatically when '
|
||||
'the MO is confirmed (falls back to the salesperson).',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SO confirm → auto-create a draft MO so the manager has something
|
||||
# to assign. The configurator emits a service-product line, which
|
||||
# bypasses Odoo's native MO routing - without this hook the workflow
|
||||
# stage stalls at 'assign_work' because action_fp_assign_to_me
|
||||
# searches for DRAFT MOs that don't exist.
|
||||
#
|
||||
# Idempotent - never creates a second MO for the same SO.
|
||||
# ------------------------------------------------------------------
|
||||
def action_confirm(self):
|
||||
res = super().action_confirm()
|
||||
# Cutover gate (2026-04-25): when the native job model is the
|
||||
# primary, skip MO creation here - fusion_plating_jobs handles
|
||||
# SO → fp.job. Both modules' SO-confirm hooks would otherwise
|
||||
# run on every confirm and create duplicate work.
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True':
|
||||
return res
|
||||
for so in self:
|
||||
try:
|
||||
so._fp_auto_create_mo()
|
||||
except Exception as exc:
|
||||
# Don't block SO confirm - log + continue. The manager
|
||||
# can still create the MO manually.
|
||||
so.message_post(
|
||||
body=Markup(_('Auto-MO creation failed: <code>%s</code>. '
|
||||
'Create the MO manually from MRP.')) % exc,
|
||||
)
|
||||
return res
|
||||
|
||||
def _fp_auto_create_mo(self):
|
||||
"""Create draft MO(s) for this SO, grouping by x_fc_wo_group_tag.
|
||||
|
||||
Grouping rules (new in v19.0.7.x):
|
||||
- Lines sharing a non-empty x_fc_wo_group_tag collapse into ONE MO
|
||||
with product = first line's part product, qty = sum of line
|
||||
qtys, recipe = first line's coating_config.recipe_id.
|
||||
- Lines with blank tag each get their own MO (one-to-one with
|
||||
the line).
|
||||
- If the SO has no plating lines at all, fall back to the legacy
|
||||
one-MO-per-SO path using configurator data.
|
||||
|
||||
Idempotent: skips any group for which an MO with matching
|
||||
(origin, x_fc_wo_group_tag) already exists.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Production = self.env['mrp.production']
|
||||
existing_mos = Production.search([('origin', '=', self.name)])
|
||||
existing_tags = set(existing_mos.mapped('x_fc_wo_group_tag'))
|
||||
# Legacy MOs = untagged MOs created before this PR that never
|
||||
# had x_fc_sale_order_line_ids populated. We adopt them 1-for-1
|
||||
# onto the first N untagged groups so re-confirm doesn't
|
||||
# double-book.
|
||||
legacy_untagged = existing_mos.filtered(
|
||||
lambda m: not m.x_fc_wo_group_tag and not m.x_fc_sale_order_line_ids
|
||||
)
|
||||
|
||||
# Build groups from SO lines that carry plating data
|
||||
plating_lines = self.order_line.filtered(
|
||||
lambda l: l.x_fc_part_catalog_id or l.x_fc_coating_config_id
|
||||
)
|
||||
if not plating_lines:
|
||||
return self._fp_auto_create_mo_legacy()
|
||||
|
||||
created = []
|
||||
adopted = []
|
||||
|
||||
# If a legacy untagged MO already exists for this SO, it
|
||||
# represents the pre-PR "one MO for the whole order" work.
|
||||
# Adopt it by linking EVERY untagged plating line to it, and
|
||||
# treat those lines as covered - don't create per-line MOs on
|
||||
# top of the legacy MO.
|
||||
untagged_lines = plating_lines.filtered(lambda l: not l.x_fc_wo_group_tag)
|
||||
tagged_lines = plating_lines - untagged_lines
|
||||
covered_untagged_ids = set()
|
||||
if legacy_untagged and untagged_lines:
|
||||
legacy = legacy_untagged[0]
|
||||
legacy.write({
|
||||
'x_fc_sale_order_line_ids': [(4, ln.id) for ln in untagged_lines],
|
||||
})
|
||||
adopted.append(legacy)
|
||||
covered_untagged_ids = set(untagged_lines.ids)
|
||||
|
||||
groups = {} # {tag_or_line_key: [lines]}
|
||||
for line in tagged_lines:
|
||||
groups.setdefault(line.x_fc_wo_group_tag, []).append(line)
|
||||
for line in untagged_lines:
|
||||
if line.id in covered_untagged_ids:
|
||||
continue # already adopted onto legacy MO
|
||||
groups['__line__%d' % line.id] = [line]
|
||||
|
||||
for key, lines in groups.items():
|
||||
tag = lines[0].x_fc_wo_group_tag or False
|
||||
# Skip if we already have an MO for this (origin, tag) pair.
|
||||
if tag and tag in existing_tags:
|
||||
continue
|
||||
if not tag:
|
||||
# Untagged link-based idempotency (rerun protection)
|
||||
if Production.search_count([
|
||||
('origin', '=', self.name),
|
||||
('x_fc_sale_order_line_ids', 'in', [lines[0].id]),
|
||||
]):
|
||||
continue
|
||||
|
||||
# Per-group savepoint so one broken group can't block later
|
||||
# ones AND can't leave partial state committed.
|
||||
savepoint_name = 'fp_mo_group_%s' % abs(hash(key))
|
||||
self.env.cr.execute('SAVEPOINT %s' % savepoint_name)
|
||||
try:
|
||||
# Resolve product: part catalog's linked product if any,
|
||||
# else FP-WIDGET fallback.
|
||||
product = False
|
||||
for ln in lines:
|
||||
pc = ln.x_fc_part_catalog_id
|
||||
if pc and 'product_id' in pc._fields and pc.product_id:
|
||||
product = pc.product_id
|
||||
break
|
||||
if not product:
|
||||
product = self.env['product.product'].search(
|
||||
[('default_code', '=', 'FP-WIDGET')], limit=1,
|
||||
)
|
||||
if not product:
|
||||
self.env.cr.execute('RELEASE SAVEPOINT %s' % savepoint_name)
|
||||
self.message_post(body=_(
|
||||
'Auto-MO skipped (group %s) - no manufacturable '
|
||||
'product available.'
|
||||
) % (tag or 'single-line'))
|
||||
continue
|
||||
|
||||
# Recipe priority (Sub 9):
|
||||
# 1. Line's explicit process variant
|
||||
# 2. Line's part default variant
|
||||
# 3. Line's coating recipe_id
|
||||
# 4. Any recipe-type process node (last-ditch fallback)
|
||||
recipe = False
|
||||
for ln in lines:
|
||||
if ('x_fc_process_variant_id' in ln._fields
|
||||
and ln.x_fc_process_variant_id):
|
||||
recipe = ln.x_fc_process_variant_id
|
||||
break
|
||||
if not recipe:
|
||||
for ln in lines:
|
||||
pc = ln.x_fc_part_catalog_id
|
||||
if (pc and 'default_process_id' in pc._fields
|
||||
and pc.default_process_id):
|
||||
recipe = pc.default_process_id
|
||||
break
|
||||
if not recipe:
|
||||
for ln in lines:
|
||||
cc = ln.x_fc_coating_config_id
|
||||
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
|
||||
recipe = cc.recipe_id
|
||||
break
|
||||
if not recipe:
|
||||
recipe = self.env['fusion.plating.process.node'].search(
|
||||
[('node_type', '=', 'recipe')], limit=1,
|
||||
)
|
||||
|
||||
qty = sum(ln.product_uom_qty for ln in lines) or 1
|
||||
# Start-at-node: first non-blank wins
|
||||
start_node = False
|
||||
for ln in lines:
|
||||
if ln.x_fc_start_at_node_id:
|
||||
start_node = ln.x_fc_start_at_node_id
|
||||
break
|
||||
|
||||
# Sub 5 - carry serial / job# / thickness / revision from
|
||||
# the first line of the group. Single-line groups pick it up
|
||||
# cleanly; multi-line groups inherit the primary line's
|
||||
# tracking data. These fields are metadata only (reports and
|
||||
# smart buttons), so a "first line wins" rule is safe.
|
||||
primary = lines[0]
|
||||
|
||||
mo_vals = {
|
||||
'product_id': product.id,
|
||||
'product_qty': qty,
|
||||
'product_uom_id': product.uom_id.id,
|
||||
'origin': self.name,
|
||||
'x_fc_wo_group_tag': tag or False,
|
||||
'x_fc_sale_order_line_ids': [(6, 0, [ln.id for ln in lines])],
|
||||
}
|
||||
if recipe and 'x_fc_recipe_id' in Production._fields:
|
||||
mo_vals['x_fc_recipe_id'] = recipe.id
|
||||
if start_node:
|
||||
mo_vals['x_fc_start_at_node_id'] = start_node.id
|
||||
if 'x_fc_serial_id' in Production._fields and primary.x_fc_serial_id:
|
||||
mo_vals['x_fc_serial_id'] = primary.x_fc_serial_id.id
|
||||
if 'x_fc_job_number' in Production._fields and primary.x_fc_job_number:
|
||||
mo_vals['x_fc_job_number'] = primary.x_fc_job_number
|
||||
if 'x_fc_thickness_id' in Production._fields and primary.x_fc_thickness_id:
|
||||
mo_vals['x_fc_thickness_id'] = primary.x_fc_thickness_id.id
|
||||
if 'x_fc_revision_snapshot' in Production._fields and primary.x_fc_revision_snapshot:
|
||||
mo_vals['x_fc_revision_snapshot'] = primary.x_fc_revision_snapshot
|
||||
mo = Production.create(mo_vals)
|
||||
created.append((mo, tag, len(lines)))
|
||||
# Sub 8 - the racking inspection is auto-created by
|
||||
# mrp.production.create() (see mrp_production.py), so
|
||||
# no extra work here. The hook there picks up the
|
||||
# x_fc_sale_order_line_ids written above to seed the
|
||||
# inspection lines correctly.
|
||||
self.env.cr.execute('RELEASE SAVEPOINT %s' % savepoint_name)
|
||||
except Exception as exc:
|
||||
self.env.cr.execute('ROLLBACK TO SAVEPOINT %s' % savepoint_name)
|
||||
self.message_post(body=(
|
||||
'Auto-MO group %s failed: %s'
|
||||
) % (tag or 'single-line', exc))
|
||||
continue
|
||||
|
||||
if created or adopted:
|
||||
# _() needs a lang in env.context; in shell/cron this may be
|
||||
# unset. Compose the message with plain format strings - this
|
||||
# text is an internal chatter log, not user-facing UI.
|
||||
msg_parts = []
|
||||
if created:
|
||||
lines_html = '<br/>'.join([
|
||||
'MO <a href="/odoo/manufacturing/%s">%s</a> '
|
||||
'(%s, %d source line%s)' % (
|
||||
mo.id, mo.name, tag or 'untagged',
|
||||
n, 's' if n != 1 else ''
|
||||
)
|
||||
for mo, tag, n in created
|
||||
])
|
||||
msg_parts.append(
|
||||
'%d draft MO(s) auto-created:<br/>%s' % (
|
||||
len(created), lines_html,
|
||||
)
|
||||
)
|
||||
if adopted:
|
||||
adopted_html = '<br/>'.join([
|
||||
'MO <a href="/odoo/manufacturing/%s">%s</a> '
|
||||
'(legacy, now line-linked)' % (mo.id, mo.name)
|
||||
for mo in adopted
|
||||
])
|
||||
msg_parts.append(
|
||||
'%d legacy MO(s) adopted:<br/>%s' % (
|
||||
len(adopted), adopted_html,
|
||||
)
|
||||
)
|
||||
self.message_post(body=Markup('<br/><br/>'.join(msg_parts)))
|
||||
|
||||
def _fp_auto_create_mo_legacy(self):
|
||||
"""Fallback for SOs with no plating order_line data (service lines).
|
||||
|
||||
Preserves the pre-v19.0.7 behaviour: one MO per SO using the
|
||||
configurator's part / coating / recipe references.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Production = self.env['mrp.production']
|
||||
if Production.search_count([('origin', '=', self.name)]):
|
||||
return
|
||||
|
||||
cfg = self.x_fc_configurator_id if 'x_fc_configurator_id' in self._fields else False
|
||||
product = False
|
||||
recipe = False
|
||||
if cfg:
|
||||
if cfg.part_catalog_id and 'product_id' in cfg.part_catalog_id._fields:
|
||||
product = cfg.part_catalog_id.product_id
|
||||
if not recipe and cfg.coating_config_id and 'recipe_id' in cfg.coating_config_id._fields:
|
||||
recipe = cfg.coating_config_id.recipe_id
|
||||
if not recipe and cfg.part_catalog_id and 'recipe_id' in cfg.part_catalog_id._fields:
|
||||
recipe = cfg.part_catalog_id.recipe_id
|
||||
if not product:
|
||||
product = self.env['product.product'].search(
|
||||
[('default_code', '=', 'FP-WIDGET')], limit=1,
|
||||
)
|
||||
if not recipe:
|
||||
recipe = self.env['fusion.plating.process.node'].search(
|
||||
[('node_type', '=', 'recipe')], limit=1,
|
||||
)
|
||||
if not product:
|
||||
self.message_post(body=_(
|
||||
'Auto-MO skipped - no manufacturable product available.'
|
||||
))
|
||||
return
|
||||
|
||||
qty = sum(self.order_line.mapped('product_uom_qty')) or 1
|
||||
mo_vals = {
|
||||
'product_id': product.id,
|
||||
'product_qty': qty,
|
||||
'product_uom_id': product.uom_id.id,
|
||||
'origin': self.name,
|
||||
}
|
||||
if recipe and 'x_fc_recipe_id' in Production._fields:
|
||||
mo_vals['x_fc_recipe_id'] = recipe.id
|
||||
mo = Production.create(mo_vals)
|
||||
self.message_post(body=Markup(_(
|
||||
'Draft Manufacturing Order <a href="/odoo/manufacturing/%s">%s</a> '
|
||||
'auto-created (legacy path).'
|
||||
)) % (mo.id, mo.name))
|
||||
|
||||
@api.depends(
|
||||
'state', 'invoice_status',
|
||||
'x_fc_receiving_status', 'x_fc_production_count',
|
||||
'x_fc_delivery_count', 'x_fc_assigned_manager_id',
|
||||
)
|
||||
def _compute_workflow_stage(self):
|
||||
Production = self.env['mrp.production']
|
||||
Delivery = self.env.get('fusion.plating.delivery')
|
||||
for so in self:
|
||||
if so.state == 'cancel':
|
||||
so.x_fc_workflow_stage = 'cancelled'
|
||||
continue
|
||||
if so.state in ('draft', 'sent'):
|
||||
so.x_fc_workflow_stage = 'draft'
|
||||
continue
|
||||
|
||||
# state == 'sale' or 'done' from here on
|
||||
mos = Production.search([('origin', '=', so.name)]) if so.name else Production
|
||||
all_mos_done = bool(mos) and all(m.state == 'done' for m in mos)
|
||||
|
||||
# Any delivery marked delivered?
|
||||
shipped = False
|
||||
if Delivery is not None and mos:
|
||||
jobs = mos.mapped('x_fc_portal_job_id')
|
||||
if jobs:
|
||||
shipped = bool(Delivery.search_count(
|
||||
[('job_ref', 'in', jobs.mapped('name')),
|
||||
('state', '=', 'delivered')]
|
||||
))
|
||||
|
||||
# Paid vs invoiced
|
||||
posted_invoices = so.invoice_ids.filtered(lambda i: i.state == 'posted')
|
||||
has_posted_invoice = bool(posted_invoices)
|
||||
all_paid = has_posted_invoice and all(
|
||||
i.payment_state in ('paid', 'in_payment') for i in posted_invoices
|
||||
)
|
||||
if shipped and all_paid:
|
||||
so.x_fc_workflow_stage = 'complete'
|
||||
continue
|
||||
if all_paid and not shipped:
|
||||
so.x_fc_workflow_stage = 'paid'
|
||||
continue
|
||||
# Once an invoice is posted (regardless of payment), the SO has
|
||||
# moved past 'shipped' - the action is on accounting, not us.
|
||||
if shipped and has_posted_invoice:
|
||||
so.x_fc_workflow_stage = 'invoicing'
|
||||
continue
|
||||
|
||||
if shipped:
|
||||
so.x_fc_workflow_stage = 'shipped'
|
||||
continue
|
||||
if all_mos_done:
|
||||
so.x_fc_workflow_stage = 'ready_to_ship'
|
||||
continue
|
||||
|
||||
# Parts receiving state governs the early phase
|
||||
recv_status = so.x_fc_receiving_status or 'not_received'
|
||||
if recv_status == 'not_received':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'partial':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'received':
|
||||
# Sub 8: 'received' is the terminal receiving state.
|
||||
# Inspection happens in the recipe's racking step, not
|
||||
# in receiving.
|
||||
if not so.x_fc_assigned_manager_id:
|
||||
so.x_fc_workflow_stage = 'assign_work'
|
||||
continue
|
||||
so.x_fc_workflow_stage = 'in_production'
|
||||
continue
|
||||
|
||||
# Fallback
|
||||
so.x_fc_workflow_stage = 'in_production' if mos else 'awaiting_parts'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Next-step action buttons (only one or two visible at any time)
|
||||
# ------------------------------------------------------------------
|
||||
def action_fp_mark_inspected(self):
|
||||
"""Flip all open receiving records from draft/inspecting → inspecting."""
|
||||
self.ensure_one()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
if Recv is None:
|
||||
return False
|
||||
for rec in Recv.search([('sale_order_id', '=', self.id)]):
|
||||
if rec.state in ('draft',):
|
||||
rec.state = 'inspecting'
|
||||
self.message_post(body=_('Parts marked as inspecting.'))
|
||||
return True
|
||||
|
||||
def action_fp_accept_parts(self):
|
||||
"""Mark receiving as accepted; this unlocks manager assignment.
|
||||
|
||||
Sub 8: receiving's terminal state is 'closed' (post-Sub-8) or
|
||||
'accepted' (legacy). Either maps to SO status 'received'. The
|
||||
old 'inspected' SO status no longer exists.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
if Recv is None:
|
||||
return False
|
||||
for rec in Recv.search([('sale_order_id', '=', self.id)]):
|
||||
if rec.state in ('draft', 'counted', 'staged'):
|
||||
rec.state = 'closed'
|
||||
elif rec.state == 'inspecting':
|
||||
rec.state = 'accepted'
|
||||
if 'x_fc_receiving_status' in self._fields:
|
||||
self.x_fc_receiving_status = 'received'
|
||||
self.message_post(body=_('Parts accepted - ready to assign manager.'))
|
||||
return True
|
||||
|
||||
def action_fp_assign_to_me(self):
|
||||
"""Manager claims this job (themselves) and confirms linked MOs."""
|
||||
self.ensure_one()
|
||||
user = self.env.user
|
||||
self.x_fc_assigned_manager_id = user.id
|
||||
mos = self.env['mrp.production'].search(
|
||||
[('origin', '=', self.name), ('state', '=', 'draft')]
|
||||
)
|
||||
mos.action_confirm()
|
||||
for mo in mos:
|
||||
if 'x_fc_assigned_manager_id' in mo._fields and not mo.x_fc_assigned_manager_id:
|
||||
mo.x_fc_assigned_manager_id = user.id
|
||||
self.message_post(
|
||||
body=Markup(_('Job assigned to <b>%s</b>. %d MO(s) released to the floor.'))
|
||||
% (user.name, len(mos)),
|
||||
)
|
||||
return True
|
||||
|
||||
def action_fp_mark_shipped(self):
|
||||
"""Mark the draft delivery as shipped (triggers auto-invoice)."""
|
||||
self.ensure_one()
|
||||
Delivery = self.env.get('fusion.plating.delivery')
|
||||
if Delivery is None:
|
||||
return False
|
||||
mos = self.env['mrp.production'].search([('origin', '=', self.name)])
|
||||
jobs = mos.mapped('x_fc_portal_job_id')
|
||||
deliveries = Delivery.search(
|
||||
[('job_ref', 'in', jobs.mapped('name')), ('state', '!=', 'delivered')]
|
||||
)
|
||||
for dlv in deliveries:
|
||||
dlv.action_mark_delivered()
|
||||
self.message_post(
|
||||
body=_('%d delivery record(s) marked delivered. '
|
||||
'Invoice flow triggered per invoice strategy.')
|
||||
% len(deliveries),
|
||||
)
|
||||
return True
|
||||
|
||||
def action_fp_open_shop_floor(self):
|
||||
"""Jump to the Plant Overview filtered to this SO's WOs."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_plant_overview',
|
||||
'name': _('Shop Floor - %s') % self.name,
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@api.depends('name', 'state')
|
||||
def _compute_fp_related_records(self):
|
||||
Production = self.env['mrp.production']
|
||||
PortalJob = self.env['fusion.plating.portal.job']
|
||||
QualityHold = self.env['fusion.plating.quality.hold']
|
||||
Certificate = self.env.get('fp.certificate')
|
||||
Delivery = self.env.get('fusion.plating.delivery')
|
||||
for so in self:
|
||||
mos = Production.search([('origin', '=', so.name)]) if so.name else Production
|
||||
so.x_fc_production_ids = mos
|
||||
so.x_fc_production_count = len(mos)
|
||||
so.x_fc_workorder_count = sum(len(mo.workorder_ids) for mo in mos)
|
||||
|
||||
job_ids = mos.mapped('x_fc_portal_job_id').ids
|
||||
if so.name and not job_ids:
|
||||
# Fallback: portal jobs named after the MO that share origin
|
||||
jobs = PortalJob.search([('name', 'in', mos.mapped('name'))])
|
||||
job_ids = jobs.ids
|
||||
so.x_fc_portal_job_count = len(job_ids)
|
||||
|
||||
so.x_fc_quality_hold_count = QualityHold.search_count(
|
||||
[('production_id', 'in', mos.ids)]
|
||||
) if mos else 0
|
||||
|
||||
so.x_fc_certificate_count = (
|
||||
Certificate.search_count([('sale_order_id', '=', so.id)])
|
||||
if Certificate is not None and so.id else 0
|
||||
)
|
||||
|
||||
if Delivery is not None and job_ids:
|
||||
job_names = PortalJob.browse(job_ids).mapped('name')
|
||||
so.x_fc_delivery_count = Delivery.search_count(
|
||||
[('job_ref', 'in', job_names)]
|
||||
) if job_names else 0
|
||||
else:
|
||||
so.x_fc_delivery_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Smart button actions
|
||||
# ------------------------------------------------------------------
|
||||
def action_view_productions(self):
|
||||
self.ensure_one()
|
||||
mos = self.env['mrp.production'].search([('origin', '=', self.name)])
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Manufacturing Orders - %s') % self.name,
|
||||
'res_model': 'mrp.production',
|
||||
'domain': [('id', 'in', mos.ids)],
|
||||
'context': {'default_origin': self.name},
|
||||
}
|
||||
if len(mos) == 1:
|
||||
action.update({'view_mode': 'form', 'res_id': mos.id})
|
||||
else:
|
||||
action['view_mode'] = 'list,form'
|
||||
return action
|
||||
|
||||
def action_view_workorders(self):
|
||||
self.ensure_one()
|
||||
mos = self.env['mrp.production'].search([('origin', '=', self.name)])
|
||||
wos = mos.mapped('workorder_ids')
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Work Orders - %s') % self.name,
|
||||
'res_model': 'mrp.workorder',
|
||||
'domain': [('id', 'in', wos.ids)],
|
||||
'view_mode': 'list,form,kanban',
|
||||
}
|
||||
if len(wos) == 1:
|
||||
action.update({'view_mode': 'form', 'res_id': wos.id})
|
||||
return action
|
||||
|
||||
def action_view_portal_jobs(self):
|
||||
self.ensure_one()
|
||||
mos = self.env['mrp.production'].search([('origin', '=', self.name)])
|
||||
jobs = mos.mapped('x_fc_portal_job_id')
|
||||
if not jobs:
|
||||
jobs = self.env['fusion.plating.portal.job'].search(
|
||||
[('name', 'in', mos.mapped('name'))]
|
||||
)
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Portal Jobs - %s') % self.name,
|
||||
'res_model': 'fusion.plating.portal.job',
|
||||
'domain': [('id', 'in', jobs.ids)],
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
if len(jobs) == 1:
|
||||
action.update({'view_mode': 'form', 'res_id': jobs.id})
|
||||
return action
|
||||
|
||||
def action_view_quality_holds(self):
|
||||
self.ensure_one()
|
||||
mos = self.env['mrp.production'].search([('origin', '=', self.name)])
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Quality Holds - %s') % self.name,
|
||||
'res_model': 'fusion.plating.quality.hold',
|
||||
'domain': [('production_id', 'in', mos.ids)],
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
def action_view_certificates(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Certificates - %s') % self.name,
|
||||
'res_model': 'fp.certificate',
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
'view_mode': 'list,form',
|
||||
'context': {'default_sale_order_id': self.id,
|
||||
'default_partner_id': self.partner_id.id},
|
||||
}
|
||||
|
||||
def action_view_fp_deliveries(self):
|
||||
self.ensure_one()
|
||||
mos = self.env['mrp.production'].search([('origin', '=', self.name)])
|
||||
jobs = mos.mapped('x_fc_portal_job_id')
|
||||
if not jobs:
|
||||
jobs = self.env['fusion.plating.portal.job'].search(
|
||||
[('name', 'in', mos.mapped('name'))]
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Deliveries - %s') % self.name,
|
||||
'res_model': 'fusion.plating.delivery',
|
||||
'domain': [('job_ref', 'in', jobs.mapped('name'))],
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_bridge_mrp_workcenter_manager,fp.bridge.mrp.workcenter.manager,mrp.model_mrp_workcenter,fusion_plating.group_fp_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_workcenter_supervisor,fp.bridge.mrp.workcenter.supervisor,mrp.model_mrp_workcenter,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_bridge_mrp_workorder_manager,fp.bridge.mrp.workorder.manager,mrp_workorder.model_mrp_workorder,fusion_plating.group_fp_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fp_manager,1,1,1,0
|
||||
access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fp_shop_manager_v2,1,0,0,0
|
||||
access_fp_recipe_config_wizard_supervisor,fp.recipe.config.wizard.supervisor,model_fp_recipe_config_wizard,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_recipe_config_wizard_manager,fp.recipe.config.wizard.manager,model_fp_recipe_config_wizard,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_recipe_config_wizard_line_supervisor,fp.recipe.config.wizard.line.supervisor,model_fp_recipe_config_wizard_line,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager,model_fp_recipe_config_wizard_line,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_job_node_override_legacy_operator,fusion.plating.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fp_technician,1,0,0,0
|
||||
access_fp_job_node_override_legacy_supervisor,fusion.plating.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_job_node_override_legacy_manager,fusion.plating.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
@@ -1,349 +0,0 @@
|
||||
/** @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);
|
||||
@@ -1,518 +0,0 @@
|
||||
// =============================================================================
|
||||
// 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%; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Extend batch form with work order M2M when MRP bridge is installed.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_batch_form_mrp_bridge" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.batch.form.mrp.bridge</field>
|
||||
<field name="model">fusion.plating.batch</field>
|
||||
<field name="inherit_id" ref="fusion_plating_batch.view_fp_batch_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='chemistry_ids']/.." position="after">
|
||||
<page string="Work Orders" name="work_orders">
|
||||
<field name="workorder_ids" widget="many2many_tags"/>
|
||||
<p class="text-muted" invisible="workorder_ids">
|
||||
Link MRP work orders processed in this batch for full traceability.
|
||||
</p>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,65 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_job_consumption_list" model="ir.ui.view">
|
||||
<field name="name">fp.job.consumption.list</field>
|
||||
<field name="model">fp.job.consumption</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom" default_order="logged_date desc">
|
||||
<field name="logged_date"/>
|
||||
<field name="production_id"/>
|
||||
<field name="workorder_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="quantity"/>
|
||||
<field name="uom_id"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="unit_cost" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="total_cost" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}" sum="Total"/>
|
||||
<field name="source"/>
|
||||
<field name="logged_by_id" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_job_consumption_form" model="ir.ui.view">
|
||||
<field name="name">fp.job.consumption.form</field>
|
||||
<field name="model">fp.job.consumption</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="production_id"/>
|
||||
<field name="workorder_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="product_name"/>
|
||||
<field name="source"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="quantity"/>
|
||||
<field name="uom_id"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="unit_cost" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="total_cost" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}" readonly="1"/>
|
||||
<field name="logged_date"/>
|
||||
<field name="logged_by_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_job_consumption" model="ir.actions.act_window">
|
||||
<field name="name">Job Consumables Log</field>
|
||||
<field name="res_model">fp.job.consumption</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,140 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Admin-facing views for QC checklist templates. Manager privilege
|
||||
group required to create / edit templates.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="fp_qc_checklist_template_list" model="ir.ui.view">
|
||||
<field name="name">fp.qc.checklist.template.list</field>
|
||||
<field name="model">fp.qc.checklist.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="QC Checklist Templates" decoration-muted="not active">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="require_inspector_signoff" widget="boolean_toggle"/>
|
||||
<field name="require_thickness_readings" widget="boolean_toggle"/>
|
||||
<field name="require_thickness_report_pdf" widget="boolean_toggle"/>
|
||||
<field name="check_count"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fp_qc_checklist_template_form" model="ir.ui.view">
|
||||
<field name="name">fp.qc.checklist.template.form</field>
|
||||
<field name="model">fp.qc.checklist.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="QC Template">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_checks" type="object"
|
||||
class="oe_stat_button" icon="fa-list">
|
||||
<field name="check_count" widget="statinfo"
|
||||
string="QC Instances"/>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived"
|
||||
invisible="active" bg_color="text-bg-danger"/>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="e.g. Aerospace / Nadcap QC"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Scope">
|
||||
<field name="partner_id"
|
||||
options="{'no_create': True}"
|
||||
placeholder="Global default (leave blank)"/>
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group string="Gate Policy">
|
||||
<field name="require_inspector_signoff"/>
|
||||
<field name="require_thickness_readings"/>
|
||||
<field name="require_thickness_report_pdf"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Checklist Items" name="items">
|
||||
<field name="line_ids">
|
||||
<list string="Items" editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="check_type"/>
|
||||
<field name="required" widget="boolean_toggle"/>
|
||||
<field name="requires_value" widget="boolean_toggle"/>
|
||||
<field name="value_min" optional="hide"/>
|
||||
<field name="value_max" optional="hide"/>
|
||||
<field name="value_uom" optional="hide"/>
|
||||
<field name="requires_photo" widget="boolean_toggle"/>
|
||||
</list>
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="check_type"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="required"/>
|
||||
<field name="requires_photo"/>
|
||||
<field name="requires_value"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Acceptance Range"
|
||||
invisible="not requires_value">
|
||||
<field name="value_min"/>
|
||||
<field name="value_max"/>
|
||||
<field name="value_uom"/>
|
||||
</group>
|
||||
<separator string="Guidance"/>
|
||||
<field name="description"
|
||||
placeholder="Inspection guidance shown to the operator on tap..."/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes" nolabel="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fp_qc_checklist_template_search" model="ir.ui.view">
|
||||
<field name="name">fp.qc.checklist.template.search</field>
|
||||
<field name="model">fp.qc.checklist.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<filter name="global_default" string="Global Default"
|
||||
domain="[('partner_id', '=', False)]"/>
|
||||
<filter name="per_customer" string="Per Customer"
|
||||
domain="[('partner_id', '!=', False)]"/>
|
||||
<filter name="inactive" string="Archived"
|
||||
domain="[('active', '=', False)]"/>
|
||||
<group>
|
||||
<filter name="by_customer" string="Customer"
|
||||
context="{'group_by': 'partner_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_qc_checklist_template" model="ir.actions.act_window">
|
||||
<field name="name">QC Checklist Templates</field>
|
||||
<field name="res_model">fp.qc.checklist.template</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="fp_qc_checklist_template_search"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,235 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="fp_quality_check_list" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.quality.check.list</field>
|
||||
<field name="model">fusion.plating.quality.check</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="QC Checks"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-warning="state == 'in_progress'"
|
||||
decoration-success="state == 'passed'"
|
||||
decoration-danger="state == 'failed'"
|
||||
decoration-muted="state == 'rework'">
|
||||
<field name="name"/>
|
||||
<field name="production_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="template_id"/>
|
||||
<field name="lines_passed"/>
|
||||
<field name="lines_failed"/>
|
||||
<field name="lines_pending"/>
|
||||
<field name="line_count" optional="hide"/>
|
||||
<field name="inspector_id"/>
|
||||
<field name="completed_at"/>
|
||||
<field name="state" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fp_quality_check_form" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.quality.check.form</field>
|
||||
<field name="model">fusion.plating.quality.check</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Quality Check">
|
||||
<header>
|
||||
<button name="action_open_tablet" type="object"
|
||||
string="Open Mobile Checklist" class="btn-primary"
|
||||
invisible="state in ('passed', 'failed')"/>
|
||||
<button name="action_start" type="object"
|
||||
string="Start Inspection" class="btn-secondary"
|
||||
invisible="state != 'draft'"/>
|
||||
<button name="action_pass" type="object"
|
||||
string="Mark Passed" class="btn-success"
|
||||
invisible="state in ('passed', 'failed', 'draft')"/>
|
||||
<button name="action_fail" type="object"
|
||||
string="Mark Failed" class="btn-danger"
|
||||
invisible="state in ('passed', 'failed', 'draft')"
|
||||
confirm="This marks the QC as FAILED and blocks the MO from closing. Continue?"/>
|
||||
<button name="action_rework" type="object"
|
||||
string="Send to Rework" class="btn-warning"
|
||||
invisible="state in ('passed', 'failed', 'draft')"/>
|
||||
<button name="action_reset_to_draft" type="object"
|
||||
string="Reset to Draft"
|
||||
invisible="state == 'draft'"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor"/>
|
||||
<button name="action_spawn_retry" type="object"
|
||||
string="Create Retry QC" class="btn-secondary"
|
||||
invisible="state != 'failed'"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,in_progress,passed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_open_tablet" type="object"
|
||||
class="oe_stat_button" icon="fa-tablet">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Tablet</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="PASSED"
|
||||
invisible="state != 'passed'" bg_color="text-bg-success"/>
|
||||
<widget name="web_ribbon" title="FAILED"
|
||||
invisible="state != 'failed'" bg_color="text-bg-danger"/>
|
||||
<widget name="web_ribbon" title="REWORK"
|
||||
invisible="state != 'rework'" bg_color="text-bg-warning"/>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Job">
|
||||
<field name="production_id" readonly="1"/>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="template_id"
|
||||
options="{'no_create': True}"
|
||||
readonly="state != 'draft'"/>
|
||||
</group>
|
||||
<group string="Sign-off">
|
||||
<field name="inspector_id" readonly="1"/>
|
||||
<field name="started_at" readonly="1"/>
|
||||
<field name="completed_at" readonly="1"/>
|
||||
<field name="overall_result" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Progress">
|
||||
<group>
|
||||
<field name="line_count" readonly="1"/>
|
||||
<field name="lines_passed" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="lines_failed" readonly="1"/>
|
||||
<field name="lines_pending" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Checklist" name="lines">
|
||||
<field name="line_ids">
|
||||
<list editable="bottom"
|
||||
decoration-success="result == 'pass'"
|
||||
decoration-danger="result == 'fail'"
|
||||
decoration-muted="result == 'na'">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="check_type"/>
|
||||
<field name="required" widget="boolean_toggle"/>
|
||||
<field name="requires_value" column_invisible="1"/>
|
||||
<field name="value" optional="show"
|
||||
invisible="not requires_value"/>
|
||||
<field name="value_uom" optional="hide"/>
|
||||
<field name="value_min" optional="hide"/>
|
||||
<field name="value_max" optional="hide"/>
|
||||
<field name="value_in_range" widget="boolean_toggle"
|
||||
optional="hide"/>
|
||||
<field name="requires_photo" column_invisible="1"/>
|
||||
<field name="photo_attachment_id"
|
||||
invisible="not requires_photo"
|
||||
widget="many2one_binary" optional="show"/>
|
||||
<field name="notes" optional="hide"/>
|
||||
<field name="result" widget="badge"
|
||||
decoration-success="result == 'pass'"
|
||||
decoration-danger="result == 'fail'"
|
||||
decoration-muted="result == 'na'"
|
||||
decoration-info="result == 'pending'"/>
|
||||
<button name="action_mark_pass" type="object"
|
||||
icon="fa-check" title="Pass"
|
||||
invisible="result == 'pass'"/>
|
||||
<button name="action_mark_fail" type="object"
|
||||
icon="fa-times" title="Fail"
|
||||
invisible="result == 'fail'"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Thickness Report" name="thickness">
|
||||
<group>
|
||||
<field name="thickness_report_pdf_id"
|
||||
widget="many2one_binary"
|
||||
help="Upload the Fischerscope / XDAL 600 PDF - readings will be auto-extracted."/>
|
||||
<field name="thickness_reading_count" readonly="1"/>
|
||||
<field name="require_thickness_readings" readonly="1"/>
|
||||
<field name="require_thickness_report_pdf" readonly="1"/>
|
||||
</group>
|
||||
<field name="thickness_reading_ids">
|
||||
<list editable="bottom">
|
||||
<field name="reading_number"/>
|
||||
<field name="position_label"/>
|
||||
<field name="nip_mils"/>
|
||||
<field name="ni_percent"/>
|
||||
<field name="p_percent"/>
|
||||
<field name="auto_extracted" widget="boolean_toggle"/>
|
||||
<field name="operator_id"/>
|
||||
<field name="reading_datetime"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes" nolabel="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fp_quality_check_search" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.quality.check.search</field>
|
||||
<field name="model">fusion.plating.quality.check</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="production_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="inspector_id"/>
|
||||
<filter name="draft" string="Draft"
|
||||
domain="[('state', '=', 'draft')]"/>
|
||||
<filter name="in_progress" string="In Progress"
|
||||
domain="[('state', '=', 'in_progress')]"/>
|
||||
<filter name="passed" string="Passed"
|
||||
domain="[('state', '=', 'passed')]"/>
|
||||
<filter name="failed" string="Failed"
|
||||
domain="[('state', '=', 'failed')]"/>
|
||||
<filter name="rework" string="Rework"
|
||||
domain="[('state', '=', 'rework')]"/>
|
||||
<separator/>
|
||||
<filter name="my_inspections" string="My Inspections"
|
||||
domain="[('inspector_id', '=', uid)]"/>
|
||||
<group>
|
||||
<filter name="by_customer" string="Customer"
|
||||
context="{'group_by': 'partner_id'}"/>
|
||||
<filter name="by_state" string="Status"
|
||||
context="{'group_by': 'state'}"/>
|
||||
<filter name="by_template" string="Template"
|
||||
context="{'group_by': 'template_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_quality_check" model="ir.actions.act_window">
|
||||
<field name="name">Quality Checks</field>
|
||||
<field name="res_model">fusion.plating.quality.check</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="fp_quality_check_search"/>
|
||||
</record>
|
||||
|
||||
<!-- ===== Menu - add QC Checks + QC Templates under Quality ===== -->
|
||||
<menuitem id="menu_fp_quality_check"
|
||||
name="Quality Checks"
|
||||
parent="fusion_plating_quality.menu_fp_quality"
|
||||
action="action_fp_quality_check"
|
||||
sequence="7"/>
|
||||
|
||||
<menuitem id="menu_fp_config_qc_template"
|
||||
name="QC Checklist Templates"
|
||||
parent="fusion_plating.menu_fp_config"
|
||||
action="action_fp_qc_checklist_template"
|
||||
sequence="85"
|
||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||
|
||||
</odoo>
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
Extend the quality hold form with MRP fields when the bridge is installed.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_quality_hold_form_mrp_bridge" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.quality.hold.form.mrp.bridge</field>
|
||||
<field name="model">fusion.plating.quality.hold</field>
|
||||
<field name="inherit_id" ref="fusion_plating_quality.view_fp_quality_hold_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//group[@name='resolution']" position="before">
|
||||
<group string="Source"
|
||||
invisible="not workorder_id and not production_id and not portal_job_id">
|
||||
<field name="workorder_id" readonly="1"/>
|
||||
<field name="production_id" readonly="1"/>
|
||||
<field name="portal_job_id" readonly="1"/>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,122 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
Sub 5 - Serial Number registry views.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_serial_form" model="ir.ui.view">
|
||||
<field name="name">fp.serial.form</field>
|
||||
<field name="model">fp.serial</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Serial Number">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_sale_order" type="object"
|
||||
class="oe_stat_button" icon="fa-file-text-o"
|
||||
invisible="not sale_order_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Sale Order</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_productions" type="object"
|
||||
class="oe_stat_button" icon="fa-cogs"
|
||||
invisible="production_count == 0">
|
||||
<field name="production_count" widget="statinfo" string="MOs"/>
|
||||
</button>
|
||||
<button name="action_view_deliveries" type="object"
|
||||
class="oe_stat_button" icon="fa-truck"
|
||||
invisible="delivery_count == 0">
|
||||
<field name="delivery_count" widget="statinfo" string="Deliveries"/>
|
||||
</button>
|
||||
<button name="action_view_invoices" type="object"
|
||||
class="oe_stat_button" icon="fa-money"
|
||||
invisible="invoice_count == 0">
|
||||
<field name="invoice_count" widget="statinfo" string="Invoices"/>
|
||||
</button>
|
||||
<button name="action_view_part" type="object"
|
||||
class="oe_stat_button" icon="fa-cube"
|
||||
invisible="not part_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Part</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Serial #"/>
|
||||
<h1><field name="name" placeholder="e.g. SN-12345"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="customer_id" readonly="1"/>
|
||||
<field name="part_id" readonly="1"/>
|
||||
<field name="sale_order_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sale_order_line_id" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes" nolabel="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_serial_list" model="ir.ui.view">
|
||||
<field name="name">fp.serial.list</field>
|
||||
<field name="model">fp.serial</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Serial Numbers">
|
||||
<field name="name"/>
|
||||
<field name="customer_id"/>
|
||||
<field name="part_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="production_count" string="MOs"/>
|
||||
<field name="delivery_count" string="Deliveries"/>
|
||||
<field name="invoice_count" string="Invoices"/>
|
||||
<field name="create_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_serial_search" model="ir.ui.view">
|
||||
<field name="name">fp.serial.search</field>
|
||||
<field name="model">fp.serial</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="customer_id"/>
|
||||
<field name="part_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
<group>
|
||||
<filter name="group_customer" string="Customer"
|
||||
context="{'group_by': 'customer_id'}"/>
|
||||
<filter name="group_part" string="Part"
|
||||
context="{'group_by': 'part_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_serial" model="ir.actions.act_window">
|
||||
<field name="name">Serial Numbers</field>
|
||||
<field name="res_model">fp.serial</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fp_serial_search"/>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_serial"
|
||||
name="Serial Numbers"
|
||||
parent="fusion_plating_configurator.menu_fp_sales"
|
||||
action="action_fp_serial"
|
||||
sequence="60"/>
|
||||
|
||||
</odoo>
|
||||
@@ -1,163 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_work_role_list" model="ir.ui.view">
|
||||
<field name="name">fp.work.role.list</field>
|
||||
<field name="model">fp.work.role</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon" optional="show"/>
|
||||
<field name="description"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_work_role_form" model="ir.ui.view">
|
||||
<field name="name">fp.work.role.form</field>
|
||||
<field name="model">fp.work.role</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. Plating Operator"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code" placeholder="plating_op"/>
|
||||
<field name="icon"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="mastery_required"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="description"
|
||||
placeholder="Short operator-facing description of what this role covers."/>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
<strong>Mastery Threshold</strong> controls auto-promotion: when an
|
||||
operator has finished this many WOs against this role, the role is
|
||||
added to their Shop Roles automatically and a chatter line is
|
||||
posted to their employee record. Defaults from
|
||||
<em>Settings > Fusion Plating > Default Mastery Threshold</em>.
|
||||
</div>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_work_role" model="ir.actions.act_window">
|
||||
<field name="name">Shop Roles</field>
|
||||
<field name="res_model">fp.work.role</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Define the roles on your shop floor
|
||||
</p>
|
||||
<p>
|
||||
Tag each employee with the roles they can perform and tag each
|
||||
recipe step with the role that performs it. Work orders will
|
||||
auto-route to the right worker when an MO is confirmed.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_work_roles"
|
||||
name="Shop Roles"
|
||||
parent="fusion_plating.menu_fp_config"
|
||||
action="action_fp_work_role"
|
||||
sequence="55"
|
||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||
|
||||
<!-- Employee form - Shop Roles + Lead Hand For + Proficiency tracker -->
|
||||
<record id="view_hr_employee_form_fp_roles" model="ir.ui.view">
|
||||
<field name="name">hr.employee.form.fp.roles</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Shop Roles" name="fp_shop_roles"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor">
|
||||
<group>
|
||||
<group string="Tasks This Operator Can Do">
|
||||
<field name="x_fc_work_role_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create_edit': True}"
|
||||
placeholder="Tag the shop roles this employee performs..."/>
|
||||
<div class="text-muted small" colspan="2">
|
||||
Work orders tagged with these roles auto-assign to
|
||||
this employee (or to whoever has the same role and
|
||||
the lighter open queue).
|
||||
</div>
|
||||
</group>
|
||||
<group string="Lead Hand For"
|
||||
groups="fusion_plating.group_fusion_plating_manager">
|
||||
<field name="x_fc_lead_hand_role_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create_edit': True}"
|
||||
placeholder="Roles where this employee can cover for absent operators..."/>
|
||||
<div class="text-muted small" colspan="2">
|
||||
Lead hands appear at the top of the Manager Desk
|
||||
worker dropdown for these roles, even when they
|
||||
aren't the primary owner. Use for cross-trained
|
||||
workers who can step in during absences.
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<separator string="Task Proficiency"/>
|
||||
<p class="text-muted small">
|
||||
Auto-tracked: every successfully completed WO bumps the
|
||||
count for its role. When the count crosses the role's
|
||||
mastery threshold the role is added to <em>Tasks This
|
||||
Operator Can Do</em> automatically.
|
||||
</p>
|
||||
<field name="x_fc_proficiency_ids" nolabel="1"
|
||||
readonly="1">
|
||||
<list>
|
||||
<field name="role_id"/>
|
||||
<field name="completed_count"/>
|
||||
<field name="progress_label" string="Progress"/>
|
||||
<field name="promoted" widget="boolean_toggle"
|
||||
readonly="1"/>
|
||||
<field name="first_completed_at"/>
|
||||
<field name="last_completed_at"/>
|
||||
<field name="promoted_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Process node form - add role field -->
|
||||
<record id="view_fp_process_node_form_fp_roles" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.process.node.form.fp.roles</field>
|
||||
<field name="model">fusion.plating.process.node</field>
|
||||
<field name="inherit_id" ref="fusion_plating.view_fp_process_node_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='work_center_id']" position="after">
|
||||
<field name="x_fc_work_role_id"
|
||||
options="{'no_create_edit': True}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!--
|
||||
NOTE: the WO form already shows x_fc_work_role_id + x_fc_assigned_user_id
|
||||
via mrp_workorder_views.xml (after production_id). The earlier inherit
|
||||
here would cause the fields to render twice.
|
||||
-->
|
||||
|
||||
</odoo>
|
||||
@@ -1,102 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Manager priority dashboard for MRP work orders. Drag-drop kanban
|
||||
for reordering production priorities. Only visible when MRP bridge
|
||||
is installed.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- Kanban view for work order prioritisation (MRP bridge required) -->
|
||||
<record id="view_mrp_workorder_fp_kanban" model="ir.ui.view">
|
||||
<field name="name">mrp.workorder.fp.priority.kanban</field>
|
||||
<field name="model">mrp.workorder</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="workcenter_id" default_order="sequence, date_start">
|
||||
<field name="name"/>
|
||||
<field name="workcenter_id"/>
|
||||
<field name="production_id"/>
|
||||
<field name="state"/>
|
||||
<field name="sequence"/>
|
||||
<field name="duration"/>
|
||||
<field name="qty_remaining"/>
|
||||
<field name="date_start"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="oe_kanban_card oe_kanban_global_click">
|
||||
<div class="o_kanban_record_top mb-0">
|
||||
<div class="o_kanban_record_headings">
|
||||
<strong class="o_kanban_record_title">
|
||||
<field name="production_id"/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_kanban_record_body">
|
||||
<div><strong><field name="name"/></strong></div>
|
||||
<div class="text-muted">
|
||||
Qty: <field name="qty_remaining"/>
|
||||
<t t-if="record.duration.raw_value"> - <field name="duration" widget="float_time"/> elapsed</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_kanban_record_bottom">
|
||||
<div class="oe_kanban_bottom_left">
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state == 'ready'"
|
||||
decoration-warning="state == 'progress'"
|
||||
decoration-success="state == 'done'"/>
|
||||
</div>
|
||||
<div class="oe_kanban_bottom_right">
|
||||
<field name="date_start" widget="date"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- List view for priority management -->
|
||||
<record id="view_mrp_workorder_fp_list" model="ir.ui.view">
|
||||
<field name="name">mrp.workorder.fp.priority.list</field>
|
||||
<field name="model">mrp.workorder</field>
|
||||
<field name="arch" type="xml">
|
||||
<list default_order="sequence, date_start">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="production_id"/>
|
||||
<field name="workcenter_id"/>
|
||||
<field name="qty_remaining"/>
|
||||
<field name="duration" widget="float_time"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state == 'ready'"
|
||||
decoration-warning="state == 'progress'"
|
||||
decoration-success="state == 'done'"/>
|
||||
<field name="date_start"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action: Production Priorities (Shopfloor Manager) -->
|
||||
<record id="action_fp_workorder_priority" model="ir.actions.act_window">
|
||||
<field name="name">Production Priorities</field>
|
||||
<field name="res_model">mrp.workorder</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
<field name="domain">[('state', 'in', ['ready', 'progress', 'pending'])]</field>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'kanban', 'view_id': ref('view_mrp_workorder_fp_kanban')}),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('view_mrp_workorder_fp_list')})]"/>
|
||||
</record>
|
||||
|
||||
<!-- Menu: Production Priorities under Operations -->
|
||||
<menuitem id="menu_fp_workorder_priority"
|
||||
name="Production Priorities"
|
||||
parent="fusion_plating.menu_fp_operations"
|
||||
action="action_fp_workorder_priority"
|
||||
sequence="10"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor"/>
|
||||
|
||||
</odoo>
|
||||
@@ -1,150 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- Extend mrp.production form: add Fusion Plating fields -->
|
||||
<record id="view_mrp_production_form_fp_bridge" model="ir.ui.view">
|
||||
<field name="name">mrp.production.form.fp.bridge</field>
|
||||
<field name="model">mrp.production</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Fusion Plating" name="fusion_plating">
|
||||
<group>
|
||||
<field name="x_fc_customer_spec_id"/>
|
||||
<field name="x_fc_facility_id"/>
|
||||
<field name="x_fc_current_location" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_portal_job_id"/>
|
||||
<field name="x_fc_recipe_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Job Costing" name="job_costing">
|
||||
<field name="x_fc_currency_id" invisible="1"/>
|
||||
<group>
|
||||
<field name="x_fc_quoted_revenue" readonly="1"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'x_fc_currency_id'}"/>
|
||||
<field name="x_fc_labour_cost" readonly="1"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'x_fc_currency_id'}"/>
|
||||
<field name="x_fc_consumables_cost" readonly="1"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'x_fc_currency_id'}"/>
|
||||
<field name="x_fc_actual_cost" readonly="1"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'x_fc_currency_id'}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_margin_actual" readonly="1"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'x_fc_currency_id'}"
|
||||
decoration-success="x_fc_margin_actual > 0"
|
||||
decoration-danger="x_fc_margin_actual < 0"/>
|
||||
<field name="x_fc_margin_pct" readonly="1"
|
||||
widget="percentage"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Rework" name="rework"
|
||||
invisible="not x_fc_is_rework and not x_fc_original_production_id">
|
||||
<group>
|
||||
<field name="x_fc_is_rework" readonly="1"/>
|
||||
<field name="x_fc_original_production_id" readonly="1"/>
|
||||
</group>
|
||||
<field name="x_fc_rework_reason"/>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<!-- Sale Order - back to the customer's order -->
|
||||
<button name="action_view_sale_order" type="object"
|
||||
class="oe_stat_button" icon="fa-file-text-o"
|
||||
invisible="not x_fc_sale_order_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="x_fc_sale_order_id" nolabel="1" readonly="1"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Sale Order</span>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Work Orders - drill into the WO list -->
|
||||
<button name="action_view_workorders" type="object"
|
||||
class="oe_stat_button" icon="fa-cogs">
|
||||
<field name="x_fc_workorder_count" widget="statinfo"
|
||||
string="Work Orders"/>
|
||||
</button>
|
||||
<!-- Receiving - link to the parts-receiving record(s) -->
|
||||
<button name="action_view_receiving" type="object"
|
||||
class="oe_stat_button" icon="fa-truck"
|
||||
invisible="x_fc_receiving_count == 0">
|
||||
<field name="x_fc_receiving_count" widget="statinfo"
|
||||
string="Receiving"/>
|
||||
</button>
|
||||
<button name="action_configure_recipe_steps" type="object"
|
||||
class="oe_stat_button" icon="fa-sliders"
|
||||
invisible="not x_fc_recipe_id">
|
||||
<field name="x_fc_override_count" widget="statinfo"
|
||||
string="Overrides"/>
|
||||
</button>
|
||||
<button name="action_view_reworks" type="object"
|
||||
class="oe_stat_button" icon="fa-recycle"
|
||||
invisible="x_fc_rework_count == 0">
|
||||
<field name="x_fc_rework_count" widget="statinfo"
|
||||
string="Reworks"/>
|
||||
</button>
|
||||
<button name="action_create_rework" type="object"
|
||||
class="oe_stat_button" icon="fa-refresh"
|
||||
invisible="state != 'done' or x_fc_is_rework"
|
||||
confirm="Create a rework MO from this completed order?">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Create Rework</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_consumption" type="object"
|
||||
class="oe_stat_button" icon="fa-flask">
|
||||
<field name="x_fc_consumption_count" widget="statinfo"
|
||||
string="Consumables"/>
|
||||
</button>
|
||||
<!-- Quality Check - tablet-style checklist -->
|
||||
<button name="action_open_active_qc" type="object"
|
||||
class="oe_stat_button" icon="fa-check-square-o"
|
||||
invisible="x_fc_qc_check_count == 0">
|
||||
<div class="o_stat_info">
|
||||
<field name="x_fc_qc_state" nolabel="1"
|
||||
widget="badge"
|
||||
decoration-info="x_fc_qc_state == 'draft'"
|
||||
decoration-warning="x_fc_qc_state == 'in_progress'"
|
||||
decoration-success="x_fc_qc_state == 'passed'"
|
||||
decoration-danger="x_fc_qc_state == 'failed'"/>
|
||||
<span class="o_stat_text">QC</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_qc_checks" type="object"
|
||||
class="oe_stat_button" icon="fa-list-alt"
|
||||
invisible="x_fc_qc_check_count < 2">
|
||||
<field name="x_fc_qc_check_count" widget="statinfo"
|
||||
string="QC History"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Surface QC summary + required flag in the Fusion Plating group -->
|
||||
<xpath expr="//group[@name='fusion_plating']" position="inside">
|
||||
<group string="Quality Control" colspan="2">
|
||||
<field name="x_fc_qc_required" readonly="1"/>
|
||||
<field name="x_fc_qc_state" readonly="1"
|
||||
invisible="not x_fc_active_qc_check_id"/>
|
||||
<field name="x_fc_active_qc_check_id" readonly="1"
|
||||
invisible="not x_fc_active_qc_check_id"/>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- Extend mrp.workcenter form: add Fusion Plating group -->
|
||||
<record id="view_mrp_workcenter_form_fp_bridge" model="ir.ui.view">
|
||||
<field name="name">mrp.workcenter.form.fp.bridge</field>
|
||||
<field name="model">mrp.workcenter</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_workcenter_view"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Fusion Plating" name="fusion_plating">
|
||||
<group>
|
||||
<field name="x_fc_facility_id"/>
|
||||
<field name="x_fc_fp_work_center_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,305 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Hub & Spoke layout for Work Order form:
|
||||
- Smart buttons (SO, MO, Portal Job, Quality Holds, Deliveries)
|
||||
- Process flow bar with step badge + tree action
|
||||
- 5-tab notebook: Time & Cost | Plating Details | Quality | Components | Blocked By
|
||||
- Chatter
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_mrp_workorder_form_fp_bridge" model="ir.ui.view">
|
||||
<field name="name">mrp.workorder.form.fp.bridge</field>
|
||||
<field name="model">mrp.workorder</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_production_workorder_form_view_inherit"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- ============================================================
|
||||
1. HIDDEN COMPUTED FIELDS (inside sheet, after company_id)
|
||||
============================================================ -->
|
||||
<xpath expr="//sheet//field[@name='company_id']" position="after">
|
||||
<field name="x_fc_sale_order_id" invisible="1"/>
|
||||
<field name="x_fc_portal_job_id" invisible="1"/>
|
||||
<field name="x_fc_sale_order_name" invisible="1"/>
|
||||
<field name="x_fc_production_name" invisible="1"/>
|
||||
<field name="x_fc_step_number" invisible="1"/>
|
||||
<field name="x_fc_total_steps" invisible="1"/>
|
||||
</xpath>
|
||||
|
||||
<!-- ============================================================
|
||||
2. SMART BUTTONS (inside button_box)
|
||||
============================================================ -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<!-- Sale Order -->
|
||||
<button name="action_view_sale_order" type="object"
|
||||
class="oe_stat_button" icon="fa-file-text-o"
|
||||
invisible="not x_fc_sale_order_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="x_fc_sale_order_name" nolabel="1"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Sale Order</span>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Manufacturing Order -->
|
||||
<button name="action_view_manufacturing_order" type="object"
|
||||
class="oe_stat_button" icon="fa-industry">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="x_fc_production_name" nolabel="1"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Manufacturing</span>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Portal Job -->
|
||||
<button name="action_view_portal_job" type="object"
|
||||
class="oe_stat_button" icon="fa-globe"
|
||||
invisible="not x_fc_portal_job_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Portal Job</span>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Quality Holds -->
|
||||
<button name="action_view_quality_holds" type="object"
|
||||
class="oe_stat_button" icon="fa-exclamation-triangle">
|
||||
<field name="x_fc_quality_hold_count" widget="statinfo"
|
||||
string="Quality Holds"/>
|
||||
</button>
|
||||
<!-- Deliveries -->
|
||||
<button name="action_view_deliveries" type="object"
|
||||
class="oe_stat_button" icon="fa-truck">
|
||||
<field name="x_fc_delivery_count" widget="statinfo"
|
||||
string="Deliveries"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- ============================================================
|
||||
3. CUSTOMER FIELD - left column, after workcenter_id
|
||||
============================================================ -->
|
||||
<xpath expr="//sheet//field[@name='workcenter_id']" position="after">
|
||||
<field name="x_fc_customer_id" readonly="1"
|
||||
options="{'no_open': False}"/>
|
||||
</xpath>
|
||||
|
||||
<!-- ============================================================
|
||||
3b. STEP BADGE + PRIORITY - right column, after production_id
|
||||
============================================================ -->
|
||||
<xpath expr="//sheet//field[@name='production_id']" position="after">
|
||||
<field name="x_fc_step_display" widget="badge" readonly="1"/>
|
||||
<field name="x_fc_priority" widget="priority"/>
|
||||
<field name="x_fc_assigned_user_id"
|
||||
string="Assigned To"
|
||||
required="1"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="x_fc_wo_kind" widget="badge" readonly="1"
|
||||
decoration-info="x_fc_wo_kind == 'wet'"
|
||||
decoration-warning="x_fc_wo_kind == 'bake'"
|
||||
decoration-muted="x_fc_wo_kind in ('mask', 'rack', 'inspect', 'other')"/>
|
||||
<field name="x_fc_requires_bath" invisible="1"/>
|
||||
<field name="x_fc_requires_oven" invisible="1"/>
|
||||
<field name="x_fc_recipe_node_id" invisible="1"/>
|
||||
<field name="x_fc_requires_signoff" invisible="1"/>
|
||||
<field name="x_fc_is_manual" invisible="1"/>
|
||||
<field name="x_fc_auto_complete" invisible="1"/>
|
||||
<field name="x_fc_signoff_user_id" readonly="1"
|
||||
invisible="not x_fc_requires_signoff"/>
|
||||
<field name="x_fc_signoff_date" readonly="1"
|
||||
invisible="not x_fc_requires_signoff"/>
|
||||
</xpath>
|
||||
|
||||
<!-- ============================================================
|
||||
SIGN OFF BUTTON - only visible when the recipe step
|
||||
requires a sign-off and the WO is in progress.
|
||||
============================================================ -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_signoff" type="object"
|
||||
string="Sign Off" class="oe_highlight"
|
||||
icon="fa-check-square-o"
|
||||
invisible="not x_fc_requires_signoff or x_fc_signoff_user_id or state in ('done', 'cancel')"/>
|
||||
</xpath>
|
||||
|
||||
<!-- ============================================================
|
||||
4. PROCESS FLOW BAR (before notebook)
|
||||
============================================================ -->
|
||||
<xpath expr="//notebook" position="before">
|
||||
<div class="o_fp_process_flow mb-3">
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-2 bg-light rounded">
|
||||
<i class="fa fa-random text-primary"/>
|
||||
<span class="fw-bold">Process Flow:</span>
|
||||
<field name="x_fc_step_display" widget="badge"
|
||||
readonly="1" nolabel="1"/>
|
||||
<span class="text-muted">|</span>
|
||||
<span class="text-muted small">
|
||||
Click "View Process Tree" for full routing
|
||||
</span>
|
||||
<button name="action_view_process_tree" type="object"
|
||||
string="View Process Tree"
|
||||
class="btn btn-sm btn-outline-primary ms-auto"
|
||||
icon="fa-sitemap"/>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!-- ============================================================
|
||||
5. NOTEBOOK - restructured tabs
|
||||
============================================================ -->
|
||||
|
||||
<!-- 5a. Rename "Time Tracking" → "Time & Cost" and add cost summary -->
|
||||
<xpath expr="//notebook/page[@name='time_tracking']" position="attributes">
|
||||
<attribute name="string">Time & Cost</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//notebook/page[@name='time_tracking']" position="inside">
|
||||
<group string="Cost Summary" name="cost_summary">
|
||||
<group>
|
||||
<field name="x_fc_workcenter_cost_hour"
|
||||
string="Station Rate ($/hr)" readonly="1"/>
|
||||
<field name="duration" widget="float_time"
|
||||
string="Actual Duration" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="duration_expected" widget="float_time"
|
||||
string="Expected Duration" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!--
|
||||
Audit trail surfaced from the timer overrides.
|
||||
Mirrors what's already in time_ids (one row per
|
||||
pause/resume) but distilled to the two events
|
||||
that matter to the manager: who first picked the
|
||||
job up, and who closed it out.
|
||||
-->
|
||||
<group string="Timer Audit" name="timer_audit">
|
||||
<group>
|
||||
<field name="x_fc_started_by_user_id" readonly="1"/>
|
||||
<field name="x_fc_started_at" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_finished_by_user_id" readonly="1"/>
|
||||
<field name="x_fc_finished_at" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
<!-- 5b. Process Details tab - content adapts to WO kind so
|
||||
operators see only the equipment fields that matter. -->
|
||||
<xpath expr="//notebook/page[@name='time_tracking']" position="after">
|
||||
<page string="Process Details" name="plating_details">
|
||||
<group>
|
||||
<group string="Where">
|
||||
<field name="x_fc_facility_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Wet / bath WOs -->
|
||||
<group invisible="x_fc_wo_kind != 'wet'">
|
||||
<group string="Bath & Tank">
|
||||
<field name="x_fc_bath_id"
|
||||
required="x_fc_requires_bath"/>
|
||||
<field name="x_fc_tank_id"
|
||||
required="x_fc_requires_bath"/>
|
||||
<field name="x_fc_rack_id"/>
|
||||
<field name="x_fc_rack_ref"/>
|
||||
</group>
|
||||
<group string="Process Parameters">
|
||||
<field name="x_fc_thickness_target"/>
|
||||
<field name="x_fc_thickness_uom"/>
|
||||
<field name="x_fc_dwell_time_minutes"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Bake / cure WOs -->
|
||||
<group invisible="x_fc_wo_kind != 'bake'">
|
||||
<group string="Oven">
|
||||
<field name="x_fc_oven_id"
|
||||
required="x_fc_requires_oven"/>
|
||||
</group>
|
||||
<group string="Bake Parameters (required at finish)">
|
||||
<label for="x_fc_bake_temp"/>
|
||||
<div class="o_row">
|
||||
<field name="x_fc_bake_temp" nolabel="1"/>
|
||||
<field name="x_fc_bake_temp_uom" nolabel="1"
|
||||
style="margin-left: 8px;"/>
|
||||
</div>
|
||||
<field name="x_fc_bake_duration_hours"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Rack / de-rack WOs.
|
||||
Note: required="x_fc_wo_kind == 'rack'" (not "1") -
|
||||
in Odoo 19 a `required="1"` on a field inside an
|
||||
invisible group still triggers the missing-required
|
||||
flag, painting the whole tab red on every WO. -->
|
||||
<group invisible="x_fc_wo_kind != 'rack'">
|
||||
<group string="Rack">
|
||||
<field name="x_fc_rack_id"
|
||||
required="x_fc_wo_kind == 'rack'"/>
|
||||
<field name="x_fc_rack_ref"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Mask / De-mask WOs -->
|
||||
<group invisible="x_fc_wo_kind != 'mask'">
|
||||
<group string="Masking">
|
||||
<field name="x_fc_masking_material"
|
||||
required="x_fc_wo_kind == 'mask'"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- Inspection -->
|
||||
<group invisible="x_fc_wo_kind != 'inspect'">
|
||||
<div class="alert alert-info" role="alert">
|
||||
Inspection - record Fischerscope readings via
|
||||
the Tablet Station. Cal-std + n measurements
|
||||
per part. Readings auto-link to the CoC.
|
||||
</div>
|
||||
</group>
|
||||
<group invisible="x_fc_wo_kind != 'other'">
|
||||
<div class="alert alert-light text-muted" role="alert">
|
||||
Generic operation - equipment is identified
|
||||
by the work centre.
|
||||
</div>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
<!-- 5c. Quality tab (insert AFTER Plating Details) -->
|
||||
<xpath expr="//notebook/page[@name='plating_details']" position="after">
|
||||
<page string="Quality" name="quality">
|
||||
<div class="mb-3">
|
||||
<button name="action_view_quality_holds" type="object"
|
||||
string="View Quality Holds"
|
||||
class="btn btn-sm btn-outline-warning"
|
||||
icon="fa-exclamation-triangle"/>
|
||||
</div>
|
||||
<p class="text-muted" invisible="x_fc_quality_hold_count > 0">
|
||||
No quality holds on this work order.
|
||||
</p>
|
||||
<div invisible="x_fc_quality_hold_count == 0">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<strong>
|
||||
<field name="x_fc_quality_hold_count" nolabel="1"/>
|
||||
quality hold(s)
|
||||
</strong>
|
||||
on this work order. Click the button above to review.
|
||||
</div>
|
||||
</div>
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
<!-- 5d. Components tab already exists at name="components" - no change needed -->
|
||||
|
||||
<!-- 5e. Blocked By tab - keep existing, just push it last visually
|
||||
(it's already the last page in the base view, so no reorder needed) -->
|
||||
|
||||
<!-- ============================================================
|
||||
6. CHATTER (after sheet)
|
||||
============================================================ -->
|
||||
<xpath expr="//sheet" position="after">
|
||||
<chatter/>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Extend res.partner with the QC requirement flag + template picker.
|
||||
Adds a "Quality Control" group to the "Plating Documents" tab that
|
||||
fusion_plating_certificates opens on the partner form.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_partner_form_fp_qc" model="ir.ui.view">
|
||||
<field name="name">res.partner.form.fp.qc</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="fusion_plating_certificates.view_partner_form_fp_document_prefs"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='fp_document_prefs']" position="inside">
|
||||
<group string="Quality Control"
|
||||
name="fp_qc_prefs_group">
|
||||
<p class="text-muted" colspan="2">
|
||||
When QC sign-off is required, confirming a Manufacturing Order
|
||||
auto-creates a checklist from the active template. The MO
|
||||
cannot be marked Done until an inspector passes the QC.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_requires_qc" widget="boolean_toggle"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_qc_template_id"
|
||||
options="{'no_create': True}"
|
||||
invisible="not x_fc_requires_qc"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,126 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Adds production-lifecycle smart buttons to the Sale Order form:
|
||||
Manufacturing, Work Orders, Portal Job, Quality Holds, Certificates, Deliveries.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_sale_order_form_fp_bridge_mrp" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.fp.bridge.mrp</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Manufacturing: right after Transfers (from configurator).
|
||||
Always visible (no invisible-on-zero) so users have a
|
||||
navigation entry point even when the SO has no MO yet. -->
|
||||
<xpath expr="//button[@name='action_view_pickings']" position="after">
|
||||
<button name="action_view_productions" type="object"
|
||||
class="oe_stat_button" icon="fa-industry">
|
||||
<field name="x_fc_production_count" widget="statinfo"
|
||||
string="Manufacturing"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Production-lifecycle extras: after NCRs, before BOM Items / By WO
|
||||
so the primary buttons stay leftmost and BOM / By WO overflow last. -->
|
||||
<xpath expr="//button[@name='action_view_ncrs']" position="after">
|
||||
<button name="action_view_portal_jobs" type="object"
|
||||
class="oe_stat_button" icon="fa-globe"
|
||||
invisible="x_fc_portal_job_count == 0">
|
||||
<field name="x_fc_portal_job_count" widget="statinfo"
|
||||
string="Portal Jobs"/>
|
||||
</button>
|
||||
<button name="action_view_quality_holds" type="object"
|
||||
class="oe_stat_button" icon="fa-exclamation-triangle"
|
||||
invisible="x_fc_quality_hold_count == 0">
|
||||
<field name="x_fc_quality_hold_count" widget="statinfo"
|
||||
string="Quality Holds"/>
|
||||
</button>
|
||||
<button name="action_view_certificates" type="object"
|
||||
class="oe_stat_button" icon="fa-certificate"
|
||||
invisible="x_fc_certificate_count == 0">
|
||||
<field name="x_fc_certificate_count" widget="statinfo"
|
||||
string="Certificates"/>
|
||||
</button>
|
||||
<button name="action_view_fp_deliveries" type="object"
|
||||
class="oe_stat_button" icon="fa-truck"
|
||||
invisible="x_fc_delivery_count == 0">
|
||||
<field name="x_fc_delivery_count" widget="statinfo"
|
||||
string="Deliveries"/>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Hide Odoo's default state statusbar - replaced below by
|
||||
the custom plating workflow statusbar that reflects the
|
||||
real lifecycle (awaiting parts → in production → shipped → ...). -->
|
||||
<xpath expr="//header//field[@name='state']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
|
||||
<!-- ===== Contextual workflow buttons on the header =====
|
||||
One (sometimes two) visible at a time. Pattern mirrors
|
||||
fusion_claims ADP handling - invisible bindings key off
|
||||
the computed x_fc_workflow_stage selector. -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_fc_workflow_stage" widget="statusbar"
|
||||
statusbar_visible="draft,awaiting_parts,inspecting,in_production,ready_to_ship,shipped,invoicing,complete"/>
|
||||
<field name="x_fc_assigned_manager_id" invisible="1"/>
|
||||
|
||||
<button name="action_fp_mark_inspected"
|
||||
string="Mark Inspecting" type="object"
|
||||
class="btn-primary" icon="fa-search"
|
||||
invisible="x_fc_workflow_stage != 'inspecting'"
|
||||
help="Move receiving record(s) into the Inspecting state."/>
|
||||
|
||||
<button name="action_fp_accept_parts"
|
||||
string="Accept Parts" type="object"
|
||||
class="btn-primary" icon="fa-check"
|
||||
invisible="x_fc_workflow_stage not in ('inspecting', 'accept_parts')"
|
||||
help="Parts pass inspection - ready for assignment."/>
|
||||
|
||||
<button name="action_fp_assign_to_me"
|
||||
string="Assign To Me & Release" type="object"
|
||||
class="btn-primary" icon="fa-user-plus"
|
||||
invisible="x_fc_workflow_stage != 'assign_work'"
|
||||
help="Take ownership as manager and release MOs to the shop floor."/>
|
||||
|
||||
<button name="action_fp_open_shop_floor"
|
||||
string="Open Shop Floor" type="object"
|
||||
class="btn-secondary" icon="fa-industry"
|
||||
invisible="x_fc_workflow_stage != 'in_production'"
|
||||
help="Jump to the Plant Overview to watch production."/>
|
||||
|
||||
<button name="action_fp_mark_shipped"
|
||||
string="Mark Shipped" type="object"
|
||||
class="btn-success" icon="fa-truck"
|
||||
invisible="x_fc_workflow_stage != 'ready_to_ship'"
|
||||
help="Close the open delivery record(s) and fire auto-invoice per strategy."/>
|
||||
</xpath>
|
||||
|
||||
<!-- Workflow stage banner - sits ABOVE the form header so it's
|
||||
the first thing users see, matches the Account Hold banner.
|
||||
Hidden for terminal states (invoicing/paid/complete/cancelled)
|
||||
and the initial draft so it only shows when there's an
|
||||
active in-progress step. -->
|
||||
<xpath expr="//form/header" position="before">
|
||||
<div class="alert alert-info mb-2"
|
||||
style="border-radius: 6px;"
|
||||
invisible="x_fc_workflow_stage in ('draft', 'invoicing', 'paid', 'complete', 'cancelled')">
|
||||
<i class="fa fa-compass me-2"/>
|
||||
<strong>Current stage:</strong>
|
||||
<field name="x_fc_workflow_stage" readonly="1" nolabel="1" class="ms-1"/>
|
||||
<span class="ms-3 text-muted"
|
||||
invisible="not x_fc_assigned_manager_id">
|
||||
· Assigned to
|
||||
<field name="x_fc_assigned_manager_id" readonly="1" nolabel="1" class="ms-1"/>
|
||||
</span>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,5 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import fp_recipe_config_wizard
|
||||
@@ -1,142 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpRecipeConfigWizard(models.TransientModel):
|
||||
"""Wizard to configure which optional recipe steps are included
|
||||
for a specific manufacturing order.
|
||||
|
||||
Shows all nodes where opt_in_out != 'disabled' as a checklist.
|
||||
Opt-in nodes default unchecked (skipped), opt-out nodes default
|
||||
checked (included). On confirm, creates or updates override records.
|
||||
"""
|
||||
_name = 'fp.recipe.config.wizard'
|
||||
_description = 'Configure Recipe Steps'
|
||||
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production',
|
||||
string='Manufacturing Order',
|
||||
required=True,
|
||||
)
|
||||
recipe_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe',
|
||||
required=True,
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fp.recipe.config.wizard.line',
|
||||
'wizard_id',
|
||||
string='Optional Steps',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
production_id = res.get('production_id') or self.env.context.get('default_production_id')
|
||||
recipe_id = res.get('recipe_id') or self.env.context.get('default_recipe_id')
|
||||
if not production_id or not recipe_id:
|
||||
return res
|
||||
|
||||
production = self.env['mrp.production'].browse(production_id)
|
||||
recipe = self.env['fusion.plating.process.node'].browse(recipe_id)
|
||||
|
||||
# Collect all optional nodes (recursive)
|
||||
optional_nodes = self._get_optional_nodes(recipe)
|
||||
if not optional_nodes:
|
||||
return res
|
||||
|
||||
# Check for existing overrides
|
||||
existing = {
|
||||
ov.node_id.id: ov.included
|
||||
for ov in production.x_fc_override_ids
|
||||
}
|
||||
|
||||
lines = []
|
||||
for node in optional_nodes:
|
||||
if node.id in existing:
|
||||
included = existing[node.id]
|
||||
else:
|
||||
# Default: opt-in → False (skipped), opt-out → True (included)
|
||||
included = node.opt_in_out == 'opt_out'
|
||||
lines.append((0, 0, {
|
||||
'node_id': node.id,
|
||||
'included': included,
|
||||
}))
|
||||
|
||||
res['line_ids'] = lines
|
||||
return res
|
||||
|
||||
def _get_optional_nodes(self, node):
|
||||
"""Recursively collect all nodes with opt_in_out != 'disabled'."""
|
||||
result = []
|
||||
if node.opt_in_out and node.opt_in_out != 'disabled':
|
||||
result.append(node)
|
||||
for child in node.child_ids.sorted('sequence'):
|
||||
result.extend(self._get_optional_nodes(child))
|
||||
return result
|
||||
|
||||
def action_confirm(self):
|
||||
"""Save overrides and close wizard."""
|
||||
self.ensure_one()
|
||||
Override = self.env['fusion.plating.job.node.override']
|
||||
production = self.production_id
|
||||
|
||||
# Delete existing overrides for this MO and recreate
|
||||
production.x_fc_override_ids.unlink()
|
||||
|
||||
for line in self.line_ids:
|
||||
Override.create({
|
||||
'production_id': production.id,
|
||||
'node_id': line.node_id.id,
|
||||
'included': line.included,
|
||||
})
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
|
||||
class FpRecipeConfigWizardLine(models.TransientModel):
|
||||
"""One line in the recipe config wizard - an optional step."""
|
||||
_name = 'fp.recipe.config.wizard.line'
|
||||
_description = 'Recipe Config Wizard Line'
|
||||
_order = 'node_sequence, id'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.recipe.config.wizard',
|
||||
string='Wizard',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
node_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Step',
|
||||
required=True,
|
||||
)
|
||||
node_name = fields.Char(
|
||||
related='node_id.name',
|
||||
string='Step Name',
|
||||
readonly=True,
|
||||
)
|
||||
node_type = fields.Selection(
|
||||
related='node_id.node_type',
|
||||
string='Type',
|
||||
readonly=True,
|
||||
)
|
||||
node_sequence = fields.Integer(
|
||||
related='node_id.sequence',
|
||||
string='Seq',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
opt_in_out = fields.Selection(
|
||||
related='node_id.opt_in_out',
|
||||
string='Default',
|
||||
readonly=True,
|
||||
)
|
||||
included = fields.Boolean(
|
||||
string='Include in Job',
|
||||
default=True,
|
||||
)
|
||||
@@ -1,47 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_recipe_config_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.recipe.config.wizard.form</field>
|
||||
<field name="model">fp.recipe.config.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Configure Recipe Steps">
|
||||
<group>
|
||||
<field name="production_id" readonly="True"/>
|
||||
<field name="recipe_id" readonly="True"/>
|
||||
</group>
|
||||
<separator string="Optional Steps"/>
|
||||
<p class="text-muted">
|
||||
Toggle which optional steps are included for this job.
|
||||
<strong>Opt-In</strong> steps are skipped by default - check to include.
|
||||
<strong>Opt-Out</strong> steps are included by default - uncheck to skip.
|
||||
</p>
|
||||
<field name="line_ids">
|
||||
<list editable="bottom" no_open="True">
|
||||
<field name="node_name" string="Step"/>
|
||||
<field name="node_type" widget="badge"
|
||||
decoration-success="node_type == 'operation'"
|
||||
decoration-warning="node_type == 'sub_process'"
|
||||
decoration-muted="node_type == 'step'"/>
|
||||
<field name="opt_in_out" widget="badge"
|
||||
decoration-info="opt_in_out == 'opt_in'"
|
||||
decoration-warning="opt_in_out == 'opt_out'"/>
|
||||
<field name="included" widget="boolean_toggle"/>
|
||||
<field name="node_id" column_invisible="True"/>
|
||||
<field name="node_sequence" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm" class="btn-primary"/>
|
||||
<button string="Cancel" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user