Split 49 modules/suites into independent git repos; untrack from monorepo
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled

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:
gsinghpal
2026-06-07 01:54:34 -04:00
parent 2a7b315e98
commit a66cdefc01
6740 changed files with 51 additions and 1277207 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)]",
)

View File

@@ -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.'),
]

View File

@@ -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.',
)

View File

@@ -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.',
)

View File

@@ -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',
)

View File

@@ -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.',
)

View File

@@ -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.',
)

View File

@@ -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',
)

View File

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

View File

@@ -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.',
)

View File

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

View File

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

View File

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

View File

@@ -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.',
)

View File

@@ -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.',
)

View File

@@ -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',
}

View File

@@ -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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_bridge_mrp_workcenter_manager fp.bridge.mrp.workcenter.manager mrp.model_mrp_workcenter fusion_plating.group_fp_manager 1 1 1 0
3 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
4 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
5 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
6 access_fp_bridge_mrp_production_manager fp.bridge.mrp.production.manager mrp.model_mrp_production fusion_plating.group_fp_manager 1 1 1 0
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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

View File

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

View File

@@ -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%; }
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &gt; Fusion Plating &gt; 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>

View File

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

View File

@@ -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 &gt; 0"
decoration-danger="x_fc_margin_actual &lt; 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 &lt; 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>

View File

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

View File

@@ -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 &amp; Cost" and add cost summary -->
<xpath expr="//notebook/page[@name='time_tracking']" position="attributes">
<attribute name="string">Time &amp; 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 &amp; 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>

View File

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

View File

@@ -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 &amp; 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>

View File

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

View File

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

View File

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