folder rename

This commit is contained in:
gsinghpal
2026-04-16 20:53:53 -04:00
parent 3f3ddcbab4
commit 7c7ef06057
634 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
# Fusion Plating — Shop Floor
Tablet / operator ergonomics layer for the **fusion_plating** core.
Part of the Fusion Plating product family by Nexa Systems Inc.
Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
Licensed under OPL-1 (Odoo Proprietary License v1.0).
## What this module adds
| Feature | Model |
|---|---|
| Tablet station registration | `fusion.plating.shopfloor.station` |
| Bake oven master + chart recorder ref | `fusion.plating.bake.oven` |
| Hydrogen embrittlement bake-window enforcer | `fusion.plating.bake.window` |
| First-piece inspection gate | `fusion.plating.first.piece.gate` |
| Operator next-up queue (transient) | `fusion.plating.operator.queue` |
## Bake-window enforcer
When a high-strength-steel part exits the plating tank, a clock starts.
Customer specification dictates the window (typically 1-4 hours) inside
which the relief bake must begin. Missing the window requires scrap or
rework — there is no retroactive fix.
The module models this as a first-class entity with:
* `plate_exit_time` — clock start
* `window_hours` — customer spec
* `bake_required_by` — computed deadline
* `state``awaiting_bake -> bake_in_progress -> baked`, with
`missed_window` / `scrapped` exit paths
* A 5-minute cron that flips records past their deadline to
`missed_window` automatically
* A kanban board grouped by state with theme-aware status colours
## Tablet client
A backend OWL component (Odoo 19 conventions) registered as the
`fp_shopfloor_tablet` client action. It hosts:
* QR scan input (wedge scanner or on-screen keyboard friendly)
* Live tank / bath / job preview cards
* One-tap **Start Bake** / **End Bake** buttons
* Live operator next-up queue
JSON-RPC endpoints (Odoo 19 `type='jsonrpc'`):
* `POST /fp/shopfloor/scan`
* `POST /fp/shopfloor/log_chemistry`
* `POST /fp/shopfloor/start_bake`
* `POST /fp/shopfloor/end_bake`
* `POST /fp/shopfloor/queue`
## QR code conventions
| Prefix | Resolves to |
|---|---|
| `FP-TANK:<code>` | `fusion.plating.tank` |
| `FP-BATH:<name>` | `fusion.plating.bath` |
| `FP-STATION:<code>` | `fusion.plating.shopfloor.station` |
| `FP-JOB:<name>` | `fusion.plating.bake.window` |
| `FP-OVEN:<code>` | `fusion.plating.bake.oven` |
## Security
Reuses the four core Fusion Plating groups (operator, supervisor, manager,
admin) defined in `fusion_plating`. No new groups, no new privilege block.
ACLs are graded operator (read/write logs), supervisor (create), manager
(full).
## Theme
All styling is theme-aware: CSS custom properties + `color-mix()` against
Bootstrap / Odoo tokens. No hex codes, no media queries — works in light
and dark mode out of the box.
## Install / update
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating_shopfloor --stop-after-init
```

View File

@@ -0,0 +1,7 @@
# -*- 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 controllers
from . import models

View File

@@ -0,0 +1,74 @@
# -*- 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 — Shop Floor',
'version': '19.0.1.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
'description': """
Fusion Plating — Shop Floor
===========================
Tablet / operator ergonomics layer for the Fusion Plating core. Adds:
* Tablet station registration with QR code scanning
* Operator next-up queue (transient)
* Bake-window enforcer for hydrogen embrittlement relief baking
(high-strength steel — clock starts when the part exits plating;
customer spec defines window before relief bake must begin)
* Bake oven master with chart recorder reference
* First-piece inspection gates per routing
* JSON-RPC endpoints (Odoo 19) for the tablet front-end
* OWL component shop-floor tablet view (theme-aware, large touch targets)
Part of the Fusion Plating product family by Nexa Systems Inc.
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',
'web',
],
'data': [
'security/fp_shopfloor_security.xml',
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'data/fp_cron_data.xml',
'views/fp_shopfloor_station_views.xml',
'views/fp_bake_oven_views.xml',
'views/fp_bake_window_views.xml',
'views/fp_first_piece_gate_views.xml',
'views/fp_plant_overview_views.xml',
'views/fp_menu.xml',
],
'demo': [
'data/fp_demo_shopfloor_data.xml',
],
'assets': {
'web.assets_backend': [
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
'fusion_plating_shopfloor/static/src/scss/process_tree.scss',
'fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml',
'fusion_plating_shopfloor/static/src/xml/plant_overview.xml',
'fusion_plating_shopfloor/static/src/xml/process_tree.xml',
'fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js',
'fusion_plating_shopfloor/static/src/js/plant_overview.js',
'fusion_plating_shopfloor/static/src/js/process_tree.js',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import shopfloor_controller

View File

@@ -0,0 +1,767 @@
# -*- 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 datetime import datetime
from odoo import fields, http
from odoo.exceptions import UserError
from odoo.http import request
_logger = logging.getLogger(__name__)
class FpShopfloorController(http.Controller):
"""JSON-RPC endpoints for the shop-floor tablet client.
NOTE — Odoo 19 requires `type='jsonrpc'`. The legacy `type='json'`
decorator was removed.
"""
# ----------------------------------------------------------------------
# QR scan dispatch
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/scan', type='jsonrpc', auth='user')
def scan(self, qr_code):
"""Resolve a scanned QR code to a target record.
Recognised payloads:
FP-TANK:<code> -> fusion.plating.tank
FP-BATH:<name> -> fusion.plating.bath
FP-STATION:<code> -> fusion.plating.shopfloor.station
FP-JOB:<name> -> fusion.plating.bake.window
FP-OVEN:<code> -> fusion.plating.bake.oven
"""
if not qr_code:
return {'ok': False, 'error': 'Empty QR code'}
code = qr_code.strip()
if code.startswith('FP-TANK:'):
tank = request.env['fusion.plating.tank'].search(
[('code', '=', code.split(':', 1)[1])], limit=1
)
if not tank:
return {'ok': False, 'error': f'Tank {code} not found'}
return {
'ok': True,
'model': 'fusion.plating.tank',
'id': tank.id,
'name': tank.name,
'state': tank.state,
'current_bath_id': tank.current_bath_id.id or False,
'current_bath_name': tank.current_bath_id.name or '',
'queue_size': tank.x_fp_shopfloor_queue_size,
}
if code.startswith('FP-BATH:'):
bath = request.env['fusion.plating.bath'].search(
[('name', '=', code.split(':', 1)[1])], limit=1
)
if not bath:
return {'ok': False, 'error': f'Bath {code} not found'}
return {
'ok': True,
'model': 'fusion.plating.bath',
'id': bath.id,
'name': bath.name,
'state': bath.state,
'tank_id': bath.tank_id.id or False,
'tank_name': bath.tank_id.name or '',
}
if code.startswith('FP-STATION:'):
station = request.env['fusion.plating.shopfloor.station'].search(
[('code', '=', code.split(':', 1)[1])], limit=1
)
if not station:
return {'ok': False, 'error': f'Station {code} not found'}
station.action_ping()
return {
'ok': True,
'model': 'fusion.plating.shopfloor.station',
'id': station.id,
'name': station.name,
'facility_id': station.facility_id.id,
'facility_name': station.facility_id.name,
'work_center_id': station.work_center_id.id or False,
'work_center_name': station.work_center_id.name or '',
}
if code.startswith('FP-JOB:'):
bw = request.env['fusion.plating.bake.window'].search(
[('name', '=', code.split(':', 1)[1])], limit=1
)
if not bw:
return {'ok': False, 'error': f'Job {code} not found'}
return {
'ok': True,
'model': 'fusion.plating.bake.window',
'id': bw.id,
'name': bw.name,
'state': bw.state,
'time_remaining': bw.time_remaining_display,
'bake_required_by': fields.Datetime.to_string(bw.bake_required_by) if bw.bake_required_by else '',
}
if code.startswith('FP-OVEN:'):
oven = request.env['fusion.plating.bake.oven'].search(
[('code', '=', code.split(':', 1)[1])], limit=1
)
if not oven:
return {'ok': False, 'error': f'Oven {code} not found'}
return {
'ok': True,
'model': 'fusion.plating.bake.oven',
'id': oven.id,
'name': oven.name,
}
if code.startswith('FP-WO:'):
MrpWO = request.env.get('mrp.workorder')
if MrpWO is None:
return {'ok': False, 'error': 'MRP not installed'}
wo = MrpWO.search(
[('name', '=', code.split(':', 1)[1])], limit=1
)
if not wo:
return {'ok': False, 'error': f'Work order {code} not found'}
return {
'ok': True,
'model': 'mrp.workorder',
'id': wo.id,
'name': wo.display_name,
'state': wo.state,
'duration': wo.duration,
'production_name': wo.production_id.name or '',
'product_name': wo.production_id.product_id.display_name or '',
}
return {'ok': False, 'error': f'Unrecognised QR payload: {code}'}
# ----------------------------------------------------------------------
# Quick chemistry log from the tablet
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/log_chemistry', type='jsonrpc', auth='user')
def log_chemistry(self, bath_id, readings, shift=None, notes=None):
"""Create a fusion.plating.bath.log with one line per reading.
readings: list of {parameter_id, value} dicts.
"""
if not bath_id:
raise UserError("bath_id required")
bath = request.env['fusion.plating.bath'].browse(int(bath_id))
if not bath.exists():
raise UserError(f"Bath {bath_id} not found")
line_vals = []
for reading in (readings or []):
param_id = reading.get('parameter_id')
value = reading.get('value')
if not param_id:
continue
line_vals.append((0, 0, {
'parameter_id': int(param_id),
'value': float(value) if value not in (None, '') else 0.0,
}))
log = request.env['fusion.plating.bath.log'].create({
'bath_id': bath.id,
'shift': shift or False,
'notes': notes or False,
'line_ids': line_vals,
})
return {
'ok': True,
'log_id': log.id,
'log_name': log.name,
'status': log.status,
}
# ----------------------------------------------------------------------
# Bake window controls
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user')
def start_bake(self, bake_window_id, oven_id=None):
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id))
if not bw.exists():
raise UserError(f"Bake window {bake_window_id} not found")
if oven_id:
bw.oven_id = int(oven_id)
bw.action_start_bake()
return {
'ok': True,
'state': bw.state,
'bake_start_time': fields.Datetime.to_string(bw.bake_start_time) if bw.bake_start_time else '',
}
@http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user')
def end_bake(self, bake_window_id):
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id))
if not bw.exists():
raise UserError(f"Bake window {bake_window_id} not found")
bw.action_end_bake()
return {
'ok': True,
'state': bw.state,
'bake_end_time': fields.Datetime.to_string(bw.bake_end_time) if bw.bake_end_time else '',
'bake_duration_hours': bw.bake_duration_hours,
}
# ----------------------------------------------------------------------
# MRP work order controls (requires fusion_plating_bridge_mrp)
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user')
def start_wo(self, workorder_id):
"""Start the MRP timer on a work order."""
MrpWO = request.env.get('mrp.workorder')
if MrpWO is None:
return {'ok': False, 'error': 'MRP not installed'}
wo = MrpWO.browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': f'Work order {workorder_id} not found'}
if wo.state == 'ready':
wo.button_start()
return {
'ok': True,
'state': wo.state,
'duration': wo.duration,
}
@http.route('/fp/shopfloor/stop_wo', type='jsonrpc', auth='user')
def stop_wo(self, workorder_id, finish=False):
"""Pause or finish the MRP timer on a work order.
finish=True calls button_finish(), otherwise button_pending().
"""
MrpWO = request.env.get('mrp.workorder')
if MrpWO is None:
return {'ok': False, 'error': 'MRP not installed'}
wo = MrpWO.browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': f'Work order {workorder_id} not found'}
if finish:
wo.button_finish()
else:
wo.button_pending()
return {
'ok': True,
'state': wo.state,
'duration': wo.duration,
}
# ----------------------------------------------------------------------
# Quality hold — partial qty split
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/quality_hold', type='jsonrpc', auth='user')
def quality_hold(self, workorder_id=None, portal_job_id=None,
part_ref=None, qty_on_hold=0, qty_original=0,
hold_reason='other', description=None,
mark_for_scrap=False, facility_id=None,
work_center_id=None, current_process_node=None):
"""Create a quality hold record, splitting qty from the original lot.
If the MRP bridge is installed and a workorder_id is provided the
work order's qty_producing is reduced to reflect the split.
"""
if not qty_on_hold or int(qty_on_hold) <= 0:
raise UserError("qty_on_hold must be a positive integer.")
vals = {
'part_ref': part_ref or '',
'qty_on_hold': int(qty_on_hold),
'qty_original': int(qty_original) if qty_original else 0,
'hold_reason': hold_reason or 'other',
'description': description or '',
'mark_for_scrap': bool(mark_for_scrap),
'current_process_node': current_process_node or '',
}
if facility_id:
vals['facility_id'] = int(facility_id)
if work_center_id:
vals['work_center_id'] = int(work_center_id)
# Link to portal job if provided
if portal_job_id:
job = request.env['fusion.plating.portal.job'].browse(
int(portal_job_id),
)
if job.exists():
vals['portal_job_id'] = job.id
# Link to MRP work order if provided and MRP bridge installed
if workorder_id:
MrpWO = request.env.get('mrp.workorder')
if MrpWO is not None:
wo = MrpWO.browse(int(workorder_id))
if wo.exists():
vals['workorder_id'] = wo.id
vals['production_id'] = wo.production_id.id or False
# Reduce qty_producing to reflect the split
new_qty = wo.qty_producing - int(qty_on_hold)
if new_qty >= 0:
wo.qty_producing = new_qty
hold = request.env['fusion.plating.quality.hold'].create(vals)
return {
'ok': True,
'hold_id': hold.id,
'hold_name': hold.name,
'state': hold.state,
}
# ----------------------------------------------------------------------
# Operator queue snapshot
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/queue', type='jsonrpc', auth='user')
def queue(self, facility_id=None):
Queue = request.env['fusion.plating.operator.queue']
rows = Queue.build_for_user(
user_id=request.env.user.id,
facility_id=int(facility_id) if facility_id else None,
)
return {
'ok': True,
'count': len(rows),
'rows': [
{
'id': r.id,
'label': r.label,
'description': r.description,
'priority': r.priority,
'due_at': fields.Datetime.to_string(r.due_at) if r.due_at else '',
'source_model': r.source_model,
'source_id': r.source_id,
}
for r in rows
],
}
# ==================================================================
# Plant Overview Dashboard
# ==================================================================
@http.route('/fp/shopfloor/plant_overview/move_card',
type='jsonrpc', auth='user')
def plant_overview_move_card(self, card_id, source_model,
target_workcenter_id):
"""Move a work order card to a different work centre (drag & drop).
Only mrp.workorder is supported for now — other source models
will return an error so the frontend can display it gracefully.
"""
if source_model != 'mrp.workorder':
return {'ok': False,
'error': 'Drag & drop is only supported for work orders.'}
MrpWO = request.env.get('mrp.workorder')
if MrpWO is None:
return {'ok': False, 'error': 'MRP module not available.'}
wo = MrpWO.browse(int(card_id))
if not wo.exists():
return {'ok': False, 'error': f'Work order {card_id} not found.'}
wc = request.env['mrp.workcenter'].browse(int(target_workcenter_id))
if not wc.exists():
return {'ok': False,
'error': f'Work centre {target_workcenter_id} not found.'}
try:
wo.write({'workcenter_id': wc.id})
_logger.info(
'Plant Overview: moved WO %s (%s) → WC %s (%s) by uid %s',
wo.id, wo.display_name, wc.id, wc.name,
request.env.uid,
)
except Exception as exc:
_logger.exception('Plant Overview move_card failed')
return {'ok': False, 'error': str(exc)}
return {'ok': True}
@http.route('/fp/shopfloor/plant_overview', type='jsonrpc', auth='user')
def plant_overview(self, facility_id=None, search=None):
"""Return work orders grouped by work centre for the plant overview.
Works in two modes:
1. MRP installed (fusion_plating_bridge_mrp) — pulls mrp.workorder
records grouped by workcenter_id.
2. MRP not installed — falls back to bake windows and first-piece
gates as card sources.
"""
search = (search or '').strip().lower()
# Determine facility name
facility_name = "Plant 1"
if facility_id:
fac = request.env['fusion.plating.facility'].browse(int(facility_id))
if fac.exists():
facility_name = fac.name
else:
fac = request.env['fusion.plating.facility'].search([], limit=1)
if fac:
facility_name = fac.name
columns = []
# ---- MODE 1: MRP bridge available ------------------------------------
MrpWO = request.env.get('mrp.workorder')
if MrpWO is not None:
columns = self._plant_overview_mrp(MrpWO, search)
else:
# ---- MODE 2: Fallback — bake windows + first-piece gates ---------
columns = self._plant_overview_fallback(search)
return {
'facility_name': facility_name,
'columns': columns,
}
def _plant_overview_mrp(self, MrpWO, search):
"""Build columns from mrp.workorder records."""
domain = [('state', 'not in', ['done', 'cancel'])]
work_orders = MrpWO.search(domain, order='workcenter_id, sequence, id')
# Group by work centre
wc_map = {} # workcenter_id → {'name': ..., 'cards': [...]}
for wo in work_orders:
wc_id = wo.workcenter_id.id if wo.workcenter_id else 0
wc_name = wo.workcenter_id.name if wo.workcenter_id else 'Unassigned'
if wc_id not in wc_map:
wc_map[wc_id] = {
'work_center_id': wc_id,
'work_center_name': wc_name,
'cards': [],
}
card = self._wo_to_card(wo)
# Apply search filter
if search and not self._card_matches_search(card, search):
continue
wc_map[wc_id]['cards'].append(card)
# Sort columns by name, put Unassigned last
columns = sorted(
wc_map.values(),
key=lambda c: (c['work_center_id'] == 0, c['work_center_name']),
)
return columns
def _wo_to_card(self, wo):
"""Convert an mrp.workorder to a card dict."""
# Customer name — from sale order if available
customer_name = ''
so_name = ''
production = wo.production_id
if production:
# Try origin field for SO ref
so_name = getattr(production, 'origin', '') or ''
# Try to get customer from linked sale order
sale_order = None
if hasattr(production, 'sale_id') and production.sale_id:
sale_order = production.sale_id
elif so_name:
sale_order = request.env['sale.order'].search([('name', '=', so_name)], limit=1)
if sale_order:
customer_name = sale_order.partner_id.name or ''
elif hasattr(production, 'partner_id') and production.partner_id:
customer_name = production.partner_id.name or ''
# Parts progress
parts_done = 0
parts_total = 0
if production:
parts_total = int(production.product_qty or 0)
# qty_produced may not exist in all versions; fall back to qty_producing
parts_done = int(
getattr(production, 'qty_produced', None)
or getattr(production, 'qty_producing', None)
or 0
)
# Last operator and activity
last_operator = ''
last_activity = ''
if wo.date_start:
# user_id may not exist on mrp.workorder in all Odoo versions
if hasattr(wo, 'user_id') and wo.user_id:
last_operator = wo.user_id.name or ''
elif wo.time_ids:
last_log = wo.time_ids.sorted('date_start', reverse=True)[:1]
if last_log and last_log.user_id:
last_operator = last_log.user_id.name or ''
last_activity = self._time_ago(wo.write_date or wo.date_start)
# Tags from priority field
tags = []
prio = getattr(wo, 'x_fc_priority', '0') or '0'
if prio == '2':
tags.append('HOT')
elif prio == '1':
tags.append('Priority')
# Date display
date_display = ''
if wo.date_start:
date_display = wo.date_start.strftime('%-m/%-d')
elif production and production.date_start:
date_display = production.date_start.strftime('%-m/%-d')
# Step info
step_display = getattr(wo, 'x_fc_step_display', '') or ''
step_number = getattr(wo, 'x_fc_step_number', 0) or 0
# Customer logo URL (from partner) + product image
customer_logo_url = ''
product_image_url = ''
if sale_order and sale_order.partner_id:
customer_logo_url = f'/web/image/res.partner/{sale_order.partner_id.id}/image_128'
if production and production.product_id:
product_image_url = f'/web/image/product.product/{production.product_id.id}/image_128'
return {
'id': wo.id,
'source_model': 'mrp.workorder',
'customer_name': customer_name,
'so_name': f'SO {so_name}' if so_name else '',
'wo_name': wo.display_name or '',
'parts_done': parts_done,
'parts_total': parts_total,
'last_operator': last_operator,
'last_activity': last_activity,
'tags': tags,
'date_display': date_display,
'state': wo.state or '',
'priority': prio,
'step_display': step_display,
'step_number': step_number,
'customer_logo_url': customer_logo_url,
'product_image_url': product_image_url,
'product_name': production.product_id.display_name if production and production.product_id else '',
}
def _plant_overview_fallback(self, search):
"""Build columns from bake windows + first-piece gates when MRP
is not installed."""
columns = []
# Bake windows as one column
bw_cards = []
BakeWindow = request.env['fusion.plating.bake.window']
bake_windows = BakeWindow.search(
[('state', 'in', ('awaiting_bake', 'bake_in_progress'))],
order='bake_required_by asc',
)
for bw in bake_windows:
card = {
'id': bw.id,
'source_model': 'fusion.plating.bake.window',
'customer_name': bw.customer_ref or '',
'so_name': '',
'wo_name': bw.name or '',
'parts_done': 0,
'parts_total': 0,
'last_operator': '',
'last_activity': self._time_ago(bw.write_date) if bw.write_date else '',
'tags': ['HOT'] if bw.state == 'missed_window' else [],
'date_display': bw.bake_required_by.strftime('%-m/%-d') if bw.bake_required_by else '',
'state': bw.state or '',
}
if not search or self._card_matches_search(card, search):
bw_cards.append(card)
if bw_cards:
columns.append({
'work_center_id': -1,
'work_center_name': 'Bake Windows',
'cards': bw_cards,
})
# First-piece gates as another column
fp_cards = []
FpGate = request.env['fusion.plating.first.piece.gate']
gates = FpGate.search(
[('result', 'in', ('pending', 'fail'))],
order='create_date desc',
)
for gate in gates:
card = {
'id': gate.id,
'source_model': 'fusion.plating.first.piece.gate',
'customer_name': '',
'so_name': '',
'wo_name': gate.name or '',
'parts_done': 0,
'parts_total': 0,
'last_operator': gate.inspector_id.name if gate.inspector_id else '',
'last_activity': self._time_ago(gate.write_date) if gate.write_date else '',
'tags': [],
'date_display': gate.create_date.strftime('%-m/%-d') if gate.create_date else '',
'state': gate.result or '',
}
if not search or self._card_matches_search(card, search):
fp_cards.append(card)
if fp_cards:
columns.append({
'work_center_id': -2,
'work_center_name': 'First-Piece Gates',
'cards': fp_cards,
})
return columns
def _card_matches_search(self, card, search):
"""Return True if the card matches the search term."""
searchable = ' '.join([
card.get('customer_name', ''),
card.get('so_name', ''),
card.get('wo_name', ''),
]).lower()
return search in searchable
def _time_ago(self, dt):
"""Return a human-readable 'time ago' string from a datetime."""
if not dt:
return ''
now = datetime.now()
if hasattr(dt, 'replace'):
# Make both naive for comparison
dt = dt.replace(tzinfo=None)
delta = now - dt
total_seconds = int(delta.total_seconds())
if total_seconds < 60:
return 'just now'
minutes = total_seconds // 60
if minutes < 60:
return f'{minutes}m ago'
hours = minutes // 60
if hours < 24:
return f'{hours}h ago'
days = hours // 24
if days < 7:
return f'{days}d ago'
weeks = days // 7
remaining_days = days % 7
if remaining_days:
return f'{weeks}w {remaining_days}d ago'
return f'{weeks}w ago'
# ==================================================================
# Work Order — Process Flow + Cost Summary
# ==================================================================
@http.route('/fp/shopfloor/wo_process_flow', type='jsonrpc', auth='user')
def wo_process_flow(self, workorder_id):
"""Return process flow steps for the horizontal bar."""
wo = request.env['mrp.workorder'].browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': 'Work order not found'}
return {'ok': True, 'steps': wo.get_process_flow()}
@http.route('/fp/shopfloor/wo_cost_summary', type='jsonrpc', auth='user')
def wo_cost_summary(self, workorder_id):
"""Return cost breakdown for a work order's MO."""
wo = request.env['mrp.workorder'].browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': 'Work order not found'}
return {'ok': True, 'data': wo.get_cost_summary()}
# ==================================================================
# Process Tree
# ==================================================================
@http.route('/fp/shopfloor/process_tree', type='jsonrpc', auth='user')
def process_tree(self, production_id):
"""Return routing tree for a manufacturing order.
Each node is an operation/work-order step. Children represent
sub-states (ready vs active) within that step.
"""
MrpWO = request.env.get('mrp.workorder')
if MrpWO is None:
return {
'production_name': '',
'product_name': '',
'state': '',
'nodes': [],
}
MrpProduction = request.env['mrp.production']
production = MrpProduction.browse(int(production_id))
if not production.exists():
raise UserError(f"Manufacturing order {production_id} not found")
work_orders = MrpWO.search(
[('production_id', '=', production.id)],
order='sequence, id',
)
nodes = []
for wo in work_orders:
qty_done = int(wo.qty_produced or 0)
qty_total = int(wo.qty_production or production.product_qty or 0)
# Duration display
duration_mins = wo.duration or 0
if duration_mins >= 60:
duration_display = f'{duration_mins / 60:.1f}h'
elif duration_mins > 0:
duration_display = f'{int(duration_mins)}m'
else:
duration_display = ''
# Build children — sub-state nodes
children = []
if wo.state in ('ready', 'waiting'):
children.append({
'id': f'{wo.id}_ready',
'name': f'Ready for {wo.workcenter_id.name or wo.name}',
'state': 'ready',
'qty_done': 0,
'qty_total': qty_total,
})
elif wo.state == 'progress':
children.append({
'id': f'{wo.id}_active',
'name': f'{wo.workcenter_id.name or wo.name}-ing',
'state': 'progress',
'qty_done': qty_done,
'qty_total': qty_total,
})
# Also show "remaining" child if partial
remaining = qty_total - qty_done
if remaining > 0:
children.append({
'id': f'{wo.id}_remaining',
'name': f'Ready for {wo.workcenter_id.name or wo.name}',
'state': 'ready',
'qty_done': 0,
'qty_total': remaining,
})
nodes.append({
'id': wo.id,
'workorder_id': wo.id,
'sequence': wo.sequence or 0,
'name': wo.display_name or wo.name,
'work_center_name': wo.workcenter_id.name if wo.workcenter_id else '',
'state': wo.state or '',
'qty_done': qty_done,
'qty_total': qty_total,
'duration_display': duration_display,
'children': children,
})
return {
'production_name': production.name or '',
'product_name': production.product_id.display_name if production.product_id else '',
'state': production.state or '',
'nodes': nodes,
}

View File

@@ -0,0 +1,19 @@
<?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="ir_cron_update_bake_window_state" model="ir.cron">
<field name="name">Fusion Plating: Update Bake Window states</field>
<field name="model_id" ref="model_fusion_plating_bake_window"/>
<field name="state">code</field>
<field name="code">model._cron_update_states()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,158 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc. — DEMO DATA (temporary)
Remove this file and its manifest entry before production release.
-->
<odoo noupdate="1">
<record id="demo_station_en" model="fusion.plating.shopfloor.station">
<field name="name">EN Line Operator Station</field>
<field name="code">STN-EN-01</field>
<field name="facility_id" ref="fusion_plating.demo_facility_main"/>
<field name="work_center_id" ref="fusion_plating.demo_wc_en_line"/>
<field name="station_type">tablet</field>
</record>
<record id="demo_station_cr" model="fusion.plating.shopfloor.station">
<field name="name">Chrome Line Operator Station</field>
<field name="code">STN-CR-01</field>
<field name="facility_id" ref="fusion_plating.demo_facility_main"/>
<field name="work_center_id" ref="fusion_plating.demo_wc_chrome_line"/>
<field name="station_type">tablet</field>
</record>
<record id="demo_station_an" model="fusion.plating.shopfloor.station">
<field name="name">Anodize Line Operator Station</field>
<field name="code">STN-AN-01</field>
<field name="facility_id" ref="fusion_plating.demo_facility_main"/>
<field name="work_center_id" ref="fusion_plating.demo_wc_anodize_line"/>
<field name="station_type">tablet</field>
</record>
<record id="demo_station_qc" model="fusion.plating.shopfloor.station">
<field name="name">QC Inspection Station</field>
<field name="code">STN-QC-01</field>
<field name="facility_id" ref="fusion_plating.demo_facility_main"/>
<field name="station_type">kiosk</field>
</record>
<record id="demo_station_ship" model="fusion.plating.shopfloor.station">
<field name="name">Shipping / Receiving</field>
<field name="code">STN-SHIP</field>
<field name="facility_id" ref="fusion_plating.demo_facility_main"/>
<field name="station_type">desktop</field>
</record>
<!-- ========== BAKE OVENS ========== -->
<record id="demo_oven_1" model="fusion.plating.bake.oven">
<field name="name">Bake Oven A — EN Post-Plate</field>
<field name="code">OVEN-A</field>
<field name="facility_id" ref="fusion_plating.demo_facility_main"/>
<field name="work_center_id" ref="fusion_plating.demo_wc_en_line"/>
<field name="target_temp_min">190</field>
<field name="target_temp_max">210</field>
<field name="chart_recorder_ref">CR-OVEN-A-001</field>
</record>
<record id="demo_oven_2" model="fusion.plating.bake.oven">
<field name="name">Bake Oven B — Chrome Stress Relief</field>
<field name="code">OVEN-B</field>
<field name="facility_id" ref="fusion_plating.demo_facility_main"/>
<field name="work_center_id" ref="fusion_plating.demo_wc_chrome_line"/>
<field name="target_temp_min">170</field>
<field name="target_temp_max">190</field>
<field name="chart_recorder_ref">CR-OVEN-B-001</field>
</record>
<!-- ========== BAKE WINDOWS ========== -->
<record id="demo_bake_1" model="fusion.plating.bake.window">
<field name="bath_id" ref="fusion_plating.demo_bath_en_mp"/>
<field name="part_ref">P/N 4422-B — Hydraulic Cylinder Rod</field>
<field name="lot_ref">LOT-2026-0415</field>
<field name="customer_ref">WO-8841</field>
<field name="quantity">25</field>
<field name="plate_exit_time" eval="(DateTime.now() - timedelta(hours=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="window_hours">4.0</field>
<field name="state">awaiting_bake</field>
<field name="oven_id" ref="demo_oven_1"/>
</record>
<record id="demo_bake_2" model="fusion.plating.bake.window">
<field name="bath_id" ref="fusion_plating.demo_bath_cr_hard"/>
<field name="part_ref">P/N 7810-A — Landing Gear Pin</field>
<field name="lot_ref">LOT-2026-0413</field>
<field name="customer_ref">WO-8835</field>
<field name="quantity">6</field>
<field name="plate_exit_time" eval="(DateTime.now() - timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="window_hours">1.0</field>
<field name="bake_start_time" eval="(DateTime.now() - timedelta(minutes=40)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="bake_temp">185</field>
<field name="bake_duration_hours">3.0</field>
<field name="oven_id" ref="demo_oven_2"/>
<field name="state">bake_in_progress</field>
</record>
<record id="demo_bake_3" model="fusion.plating.bake.window">
<field name="bath_id" ref="fusion_plating.demo_bath_en_hp"/>
<field name="part_ref">P/N 2290-D — Valve Body</field>
<field name="lot_ref">LOT-2026-0410</field>
<field name="customer_ref">WO-8820</field>
<field name="quantity">12</field>
<field name="plate_exit_time" eval="(DateTime.now() - timedelta(hours=6)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="window_hours">4.0</field>
<field name="bake_start_time" eval="(DateTime.now() - timedelta(hours=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="bake_end_time" eval="(DateTime.now() - timedelta(hours=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="bake_temp">200</field>
<field name="bake_duration_hours">3.0</field>
<field name="oven_id" ref="demo_oven_1"/>
<field name="state">baked</field>
</record>
<record id="demo_bake_4" model="fusion.plating.bake.window">
<field name="bath_id" ref="fusion_plating.demo_bath_en_mp"/>
<field name="part_ref">P/N 5500-E — Piston Rod</field>
<field name="lot_ref">LOT-2026-0408</field>
<field name="customer_ref">WO-8810</field>
<field name="quantity">8</field>
<field name="plate_exit_time" eval="(DateTime.now() - timedelta(hours=10)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="window_hours">4.0</field>
<field name="state">missed_window</field>
<field name="notes" type="html"><p>Window missed — operator shift change, parts left on rack. Flagged for quality review.</p></field>
</record>
<!-- ========== FIRST PIECE GATES ========== -->
<record id="demo_fpg_1" model="fusion.plating.first.piece.gate">
<field name="bath_id" ref="fusion_plating.demo_bath_en_mp"/>
<field name="part_ref">P/N 4422-B — Hydraulic Cylinder Rod</field>
<field name="customer_ref">WO-8841</field>
<field name="routing_first_run" eval="True"/>
<field name="first_piece_produced" eval="(DateTime.now() - timedelta(hours=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="first_piece_inspected" eval="(DateTime.now() - timedelta(hours=2, minutes=30)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="result">pass</field>
<field name="rest_of_lot_released" eval="True"/>
<field name="notes" type="html"><p>Thickness: 0.0005" ± 0.0001" — within spec. Adhesion bend test passed. Lot released for full production.</p></field>
</record>
<record id="demo_fpg_2" model="fusion.plating.first.piece.gate">
<field name="bath_id" ref="fusion_plating.demo_bath_cr_hard"/>
<field name="part_ref">P/N 7810-A — Landing Gear Pin</field>
<field name="customer_ref">WO-8835</field>
<field name="routing_first_run" eval="False"/>
<field name="first_piece_produced" eval="(DateTime.now() - timedelta(hours=4)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="result">pending</field>
<field name="rest_of_lot_released" eval="False"/>
</record>
<record id="demo_fpg_3" model="fusion.plating.first.piece.gate">
<field name="bath_id" ref="fusion_plating.demo_bath_an_typeii"/>
<field name="part_ref">P/N 3300-F — Enclosure Panel</field>
<field name="customer_ref">WO-8830</field>
<field name="routing_first_run" eval="True"/>
<field name="first_piece_produced" eval="(DateTime.now() - timedelta(days=1, hours=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="first_piece_inspected" eval="(DateTime.now() - timedelta(days=1, hours=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="result">fail</field>
<field name="rest_of_lot_released" eval="False"/>
<field name="notes" type="html"><p>Colour variation on test coupon — dye bath concentration too low. Bath adjusted and retested before proceeding.</p></field>
</record>
</odoo>

View File

@@ -0,0 +1,26 @@
<?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 noupdate="1">
<record id="seq_fp_bake_window" model="ir.sequence">
<field name="name">Fusion Plating: Bake Window</field>
<field name="code">fusion.plating.bake.window</field>
<field name="prefix">BAKE/%(year)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_fp_first_piece_gate" model="ir.sequence">
<field name="name">Fusion Plating: First-Piece Gate</field>
<field name="code">fusion.plating.first.piece.gate</field>
<field name="prefix">FPG/%(year)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_shopfloor_station
from . import fp_bake_oven
from . import fp_bake_window
from . import fp_first_piece_gate
from . import fp_operator_queue
from . import fp_tank

View File

@@ -0,0 +1,68 @@
# -*- 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 FpBakeOven(models.Model):
"""A bake oven master record.
Used by hydrogen embrittlement relief baking and other post-process bakes.
Carries a chart-recorder reference so traceability evidence can be stitched
to a bake window record by serial number.
"""
_name = 'fusion.plating.bake.oven'
_description = 'Fusion Plating — Bake Oven'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'facility_id, code'
name = fields.Char(
string='Name',
required=True,
tracking=True,
)
code = fields.Char(
string='Code',
required=True,
tracking=True,
)
facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
required=True,
ondelete='restrict',
tracking=True,
)
work_center_id = fields.Many2one(
'fusion.plating.work.center',
string='Work Center',
domain="[('facility_id','=',facility_id)]",
ondelete='restrict',
tracking=True,
)
target_temp_min = fields.Float(
string='Target Temp Min',
help='Lower bound of target oven temperature.',
)
target_temp_max = fields.Float(
string='Target Temp Max',
help='Upper bound of target oven temperature.',
)
chart_recorder_ref = fields.Char(
string='Chart Recorder Ref',
help='Serial / asset reference of the chart recorder providing trace evidence.',
)
active = fields.Boolean(
string='Active',
default=True,
)
_sql_constraints = [
(
'fp_bake_oven_code_facility_uniq',
'unique(code, facility_id)',
'Bake oven code must be unique within a facility.',
),
]

View File

@@ -0,0 +1,288 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from datetime import timedelta
from odoo import api, fields, models
class FpBakeWindow(models.Model):
"""Hydrogen embrittlement relief bake window enforcer.
When a high-strength-steel part exits a plating tank, a clock starts.
The customer / specification defines a window (typically 1 to 4 hours)
inside which the relief bake MUST begin. Missing the window requires
scrap or rework — there is no retroactive fix.
This model is the headline differentiator of the shop-floor module.
A cron job updates state every 5 minutes so the kanban board on the
tablet always reflects current jeopardy.
"""
_name = 'fusion.plating.bake.window'
_description = 'Fusion Plating — Bake Window'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'bake_required_by, id desc'
_rec_name = 'name'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: self._default_name(),
tracking=True,
)
bath_id = fields.Many2one(
'fusion.plating.bath',
string='Bath',
required=True,
ondelete='restrict',
tracking=True,
)
tank_id = fields.Many2one(
'fusion.plating.tank',
related='bath_id.tank_id',
store=True,
readonly=True,
)
facility_id = fields.Many2one(
'fusion.plating.facility',
related='bath_id.facility_id',
store=True,
readonly=True,
)
company_id = fields.Many2one(
'res.company',
related='bath_id.company_id',
store=True,
readonly=True,
)
# ----- Job identity ---------------------------------------------------
part_ref = fields.Char(
string='Part Reference',
tracking=True,
)
lot_ref = fields.Char(
string='Lot Reference',
tracking=True,
)
customer_ref = fields.Char(
string='Customer Reference',
tracking=True,
)
quantity = fields.Integer(
string='Quantity',
)
# ----- The clock ------------------------------------------------------
plate_exit_time = fields.Datetime(
string='Plate Exit Time',
required=True,
default=fields.Datetime.now,
tracking=True,
help='Moment the part left the plating tank. Starts the bake-window clock.',
)
window_hours = fields.Float(
string='Window (hours)',
default=1.0,
required=True,
tracking=True,
help='Customer-specified window inside which the relief bake must begin.',
)
bake_required_by = fields.Datetime(
string='Bake Required By',
compute='_compute_required_by',
store=True,
tracking=True,
)
# ----- The bake -------------------------------------------------------
bake_start_time = fields.Datetime(
string='Bake Start Time',
tracking=True,
)
bake_end_time = fields.Datetime(
string='Bake End Time',
tracking=True,
)
bake_temp = fields.Float(
string='Bake Temp',
)
bake_duration_hours = fields.Float(
string='Bake Duration (hours)',
)
oven_id = fields.Many2one(
'fusion.plating.bake.oven',
string='Oven',
ondelete='restrict',
tracking=True,
)
operator_id = fields.Many2one(
'res.users',
string='Operator',
default=lambda self: self.env.user,
tracking=True,
)
# ----- State / display ------------------------------------------------
state = fields.Selection(
[
('awaiting_bake', 'Awaiting Bake'),
('bake_in_progress', 'Bake In Progress'),
('baked', 'Baked'),
('missed_window', 'Missed Window'),
('scrapped', 'Scrapped'),
],
string='Status',
default='awaiting_bake',
required=True,
tracking=True,
)
status_color = fields.Integer(
string='Status Color',
compute='_compute_status_color',
help='Kanban colour index. Green if plenty of time, yellow at 75% '
'of the window consumed, red if missed.',
)
time_remaining_display = fields.Char(
string='Time Remaining',
compute='_compute_time_remaining',
help='HH:MM:SS countdown to bake_required_by.',
)
notes = fields.Html(
string='Notes',
)
active = fields.Boolean(
default=True,
)
# ==========================================================================
# Defaults
# ==========================================================================
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code('fusion.plating.bake.window')
return seq or '/'
# ==========================================================================
# Computes
# ==========================================================================
@api.depends('plate_exit_time', 'window_hours')
def _compute_required_by(self):
for rec in self:
if rec.plate_exit_time and rec.window_hours:
rec.bake_required_by = rec.plate_exit_time + timedelta(
hours=rec.window_hours
)
else:
rec.bake_required_by = False
@api.depends('state', 'plate_exit_time', 'window_hours', 'bake_required_by',
'bake_start_time')
def _compute_status_color(self):
"""Kanban colour index — neutral palette that works in light + dark.
0=no color, 1=red, 2=orange, 3=yellow, 4=green, 5=purple, 10=grey
"""
now = fields.Datetime.now()
for rec in self:
if rec.state == 'baked':
rec.status_color = 4 # green
elif rec.state == 'scrapped':
rec.status_color = 1 # red
elif rec.state == 'missed_window':
rec.status_color = 1 # red
elif rec.state == 'bake_in_progress':
rec.status_color = 5 # purple
elif rec.state == 'awaiting_bake' and rec.bake_required_by:
if now >= rec.bake_required_by:
rec.status_color = 1 # red — missed
elif rec.plate_exit_time and rec.window_hours:
elapsed = (now - rec.plate_exit_time).total_seconds()
total = rec.window_hours * 3600.0
pct = (elapsed / total) if total else 0.0
if pct >= 0.75:
rec.status_color = 3 # yellow
elif pct >= 0.5:
rec.status_color = 2 # orange (warning lean)
else:
rec.status_color = 4 # green
else:
rec.status_color = 4
else:
rec.status_color = 0
@api.depends('bake_required_by', 'state')
def _compute_time_remaining(self):
now = fields.Datetime.now()
for rec in self:
if rec.state in ('baked', 'scrapped'):
rec.time_remaining_display = ''
continue
if not rec.bake_required_by:
rec.time_remaining_display = ''
continue
delta = rec.bake_required_by - now
seconds = int(delta.total_seconds())
if seconds <= 0:
rec.time_remaining_display = 'OVERDUE'
continue
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
rec.time_remaining_display = f"{hours:02d}:{minutes:02d}:{secs:02d}"
# ==========================================================================
# Actions
# ==========================================================================
def action_start_bake(self):
for rec in self:
rec.write({
'state': 'bake_in_progress',
'bake_start_time': fields.Datetime.now(),
})
def action_end_bake(self):
for rec in self:
vals = {
'state': 'baked',
'bake_end_time': fields.Datetime.now(),
}
if rec.bake_start_time:
delta = fields.Datetime.now() - rec.bake_start_time
vals['bake_duration_hours'] = delta.total_seconds() / 3600.0
rec.write(vals)
def action_scrap(self):
self.write({'state': 'scrapped'})
# ==========================================================================
# Cron
# ==========================================================================
@api.model
def _cron_update_states(self):
"""Flip awaiting_bake records past their window to missed_window."""
now = fields.Datetime.now()
candidates = self.search([
('state', '=', 'awaiting_bake'),
('bake_required_by', '!=', False),
('bake_required_by', '<', now),
])
if candidates:
candidates.write({'state': 'missed_window'})
for rec in candidates:
rec.message_post(
body=(
f"Bake window missed: required by "
f"{fields.Datetime.to_string(rec.bake_required_by)}, "
f"now {fields.Datetime.to_string(now)}."
)
)
# Touching status_color/time_remaining is automatic via compute on read,
# but we trigger a recompute marker so kanban refreshes pick up colour
# changes within the 5-minute window.
return True

View File

@@ -0,0 +1,128 @@
# -*- 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 FpFirstPieceGate(models.Model):
"""First-piece inspection gate per routing.
Aerospace, nuclear and many automotive customers require that the FIRST
piece off a routing be inspected and dispositioned BEFORE the rest of
the lot is allowed to run. This model captures that gate.
"""
_name = 'fusion.plating.first.piece.gate'
_description = 'Fusion Plating — First-Piece Inspection Gate'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'first_piece_produced desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: self._default_name(),
tracking=True,
)
bath_id = fields.Many2one(
'fusion.plating.bath',
string='Bath',
ondelete='restrict',
tracking=True,
)
facility_id = fields.Many2one(
'fusion.plating.facility',
related='bath_id.facility_id',
store=True,
readonly=True,
)
part_ref = fields.Char(
string='Part Reference',
tracking=True,
)
customer_ref = fields.Char(
string='Customer Reference',
tracking=True,
)
routing_first_run = fields.Boolean(
string='First Run of Routing',
help='Tick if this is the first time this routing runs this part.',
)
first_piece_produced = fields.Datetime(
string='First Piece Produced',
tracking=True,
)
first_piece_inspected = fields.Datetime(
string='First Piece Inspected',
tracking=True,
)
inspector_id = fields.Many2one(
'res.users',
string='Inspector',
tracking=True,
)
result = fields.Selection(
[
('pending', 'Pending'),
('pass', 'Pass'),
('fail', 'Fail'),
],
string='Result',
default='pending',
required=True,
tracking=True,
)
rest_of_lot_released = fields.Boolean(
string='Rest of Lot Released',
tracking=True,
)
notes = fields.Html(
string='Notes',
)
status_color = fields.Integer(
compute='_compute_status_color',
)
active = fields.Boolean(default=True)
# ==========================================================================
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code('fusion.plating.first.piece.gate')
return seq or '/'
@api.depends('result', 'rest_of_lot_released')
def _compute_status_color(self):
for rec in self:
if rec.result == 'fail':
rec.status_color = 1 # red
elif rec.result == 'pass' and rec.rest_of_lot_released:
rec.status_color = 4 # green
elif rec.result == 'pass':
rec.status_color = 3 # yellow — passed but not released
else:
rec.status_color = 5 # purple — pending
# ==========================================================================
# Actions
# ==========================================================================
def action_mark_pass(self):
self.write({
'result': 'pass',
'first_piece_inspected': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
def action_mark_fail(self):
self.write({
'result': 'fail',
'first_piece_inspected': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
def action_release_lot(self):
self.filtered(lambda r: r.result == 'pass').write({
'rest_of_lot_released': True,
})

View File

@@ -0,0 +1,119 @@
# -*- 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 FpOperatorQueue(models.TransientModel):
"""Transient operator next-up queue.
Built on demand from in-flight bake windows + bath log activities,
so the tablet always renders fresh state without persisting a queue
table that would drift from reality.
"""
_name = 'fusion.plating.operator.queue'
_description = 'Fusion Plating — Operator Next-Up Queue'
_order = 'priority desc, due_at, id'
operator_id = fields.Many2one(
'res.users',
string='Operator',
default=lambda self: self.env.user,
)
facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
)
work_center_id = fields.Many2one(
'fusion.plating.work.center',
string='Work Center',
)
label = fields.Char(string='Label')
description = fields.Text(string='Description')
priority = fields.Integer(string='Priority', default=0)
due_at = fields.Datetime(string='Due At')
source_model = fields.Char(string='Source Model')
source_id = fields.Integer(string='Source ID')
@api.model
def build_for_user(self, user_id=None, facility_id=None):
"""Build (and return) a transient queue snapshot for the given user."""
user_id = user_id or self.env.user.id
self.search([('operator_id', '=', user_id)]).unlink()
rows = []
bake_domain = [('state', 'in', ('awaiting_bake', 'bake_in_progress'))]
if facility_id:
bake_domain.append(('facility_id', '=', facility_id))
bakes = self.env['fusion.plating.bake.window'].search(
bake_domain, order='bake_required_by'
)
for bw in bakes:
rows.append({
'operator_id': user_id,
'facility_id': bw.facility_id.id,
'label': f"Bake: {bw.name}",
'description': (
f"Part {bw.part_ref or '?'} • Lot {bw.lot_ref or '?'}"
f"Required by {fields.Datetime.to_string(bw.bake_required_by) if bw.bake_required_by else '?'}"
),
'priority': 100 if bw.state == 'awaiting_bake' else 50,
'due_at': bw.bake_required_by,
'source_model': 'fusion.plating.bake.window',
'source_id': bw.id,
})
gate_domain = [('result', '=', 'pending')]
if facility_id:
gate_domain.append(('facility_id', '=', facility_id))
gates = self.env['fusion.plating.first.piece.gate'].search(gate_domain)
for g in gates:
rows.append({
'operator_id': user_id,
'facility_id': g.facility_id.id,
'label': f"First piece: {g.name}",
'description': f"Part {g.part_ref or '?'}",
'priority': 80,
'source_model': 'fusion.plating.first.piece.gate',
'source_id': g.id,
})
# ----- MRP work orders (if fusion_plating_bridge_mrp installed) -----
MrpWO = self.env.get('mrp.workorder')
if MrpWO is not None:
wo_domain = [('state', 'in', ('ready', 'progress'))]
if facility_id:
wo_domain.append(('workcenter_id.x_fc_facility_id', '=', facility_id))
work_orders = MrpWO.search(wo_domain, order='sequence, date_start')
for wo in work_orders:
rows.append({
'operator_id': user_id,
'facility_id': (
wo.workcenter_id.x_fc_facility_id.id
if hasattr(wo.workcenter_id, 'x_fc_facility_id')
and wo.workcenter_id.x_fc_facility_id
else False
),
'work_center_id': (
wo.workcenter_id.x_fc_fp_work_center_id.id
if hasattr(wo.workcenter_id, 'x_fc_fp_work_center_id')
and wo.workcenter_id.x_fc_fp_work_center_id
else False
),
'label': f"WO: {wo.display_name}",
'description': (
f"{wo.production_id.product_id.display_name or '?'}"
f"MO {wo.production_id.name or '?'}"
f"Qty {getattr(wo, 'qty_remaining', '') or getattr(wo, 'qty_production', '') or wo.production_id.product_qty}"
),
'priority': 90 if wo.state == 'ready' else 60,
'due_at': wo.date_start or False,
'source_model': 'mrp.workorder',
'source_id': wo.id,
})
if rows:
self.create(rows)
return self.search([('operator_id', '=', user_id)])

View File

@@ -0,0 +1,94 @@
# -*- 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 FpShopfloorStation(models.Model):
"""A registered shop-floor station: tablet, kiosk, desktop, or mobile.
Each station is identified by a unique code and a scannable QR identifier
so an operator can pair their device to a work centre with a single tap.
"""
_name = 'fusion.plating.shopfloor.station'
_description = 'Fusion Plating — Shop Floor Station'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'facility_id, work_center_id, code'
name = fields.Char(
string='Name',
required=True,
tracking=True,
)
code = fields.Char(
string='Code',
required=True,
tracking=True,
help='Short unique identifier (e.g. "TAB-EN-01").',
)
qr_code = fields.Char(
string='QR Code',
help='Scannable station identifier. Defaults to FP-STATION:<code>.',
)
active = fields.Boolean(
string='Active',
default=True,
)
facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
required=True,
ondelete='restrict',
tracking=True,
)
work_center_id = fields.Many2one(
'fusion.plating.work.center',
string='Work Center',
domain="[('facility_id','=',facility_id)]",
ondelete='restrict',
tracking=True,
)
station_type = fields.Selection(
[
('tablet', 'Tablet'),
('kiosk', 'Kiosk'),
('desktop', 'Desktop'),
('mobile', 'Mobile'),
],
string='Station Type',
default='tablet',
required=True,
)
current_operator_id = fields.Many2one(
'res.users',
string='Current Operator',
tracking=True,
)
last_ping = fields.Datetime(
string='Last Ping',
)
notes = fields.Text(
string='Notes',
)
_sql_constraints = [
(
'fp_shopfloor_station_code_uniq',
'unique(code)',
'Shop-floor station code must be unique.',
),
]
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('qr_code') and vals.get('code'):
vals['qr_code'] = f"FP-STATION:{vals['code']}"
return super().create(vals_list)
def action_ping(self):
"""Bump the last_ping timestamp (called from the tablet client)."""
self.write({'last_ping': fields.Datetime.now()})
return True

View File

@@ -0,0 +1,54 @@
# -*- 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 FpTank(models.Model):
"""Extend the core tank with shop-floor helpers.
Adds a queue-size badge so the tank kanban can advertise jeopardy and
a one-tap action to launch the tablet view focused on this tank.
"""
_inherit = 'fusion.plating.tank'
x_fp_shopfloor_queue_size = fields.Integer(
string='Shopfloor Queue',
compute='_compute_shopfloor_queue_size',
help='Number of bake windows + first-piece gates currently waiting on '
'this tank\'s current bath.',
)
def _compute_shopfloor_queue_size(self):
BakeWindow = self.env['fusion.plating.bake.window']
Gate = self.env['fusion.plating.first.piece.gate']
for rec in self:
bath_ids = rec.bath_ids.ids
if not bath_ids:
rec.x_fp_shopfloor_queue_size = 0
continue
count = BakeWindow.search_count([
('bath_id', 'in', bath_ids),
('state', 'in', ('awaiting_bake', 'bake_in_progress')),
])
count += Gate.search_count([
('bath_id', 'in', bath_ids),
('result', '=', 'pending'),
])
rec.x_fp_shopfloor_queue_size = count
def action_open_tablet_view(self):
"""Open the tablet client action focused on this tank."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_shopfloor_tablet',
'name': 'Shop Floor Tablet',
'params': {
'tank_id': self.id,
'qr_code': self.qr_code or '',
},
'target': 'fullscreen',
}

View File

@@ -0,0 +1,37 @@
<?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.
This module reuses the core fusion_plating groups (operator/supervisor/
manager/admin) so a single privilege block governs the whole product
family. We do not declare new groups here.
-->
<odoo>
<!-- ================================================================== -->
<!-- RECORD RULE — Multi-company isolation on shop-floor stations -->
<!-- ================================================================== -->
<record id="fp_shopfloor_station_company_rule" model="ir.rule">
<field name="name">Fusion Plating: Shopfloor Station — multi-company</field>
<field name="model_id" ref="model_fusion_plating_shopfloor_station"/>
<field name="global" eval="True"/>
<field name="domain_force">['|', ('facility_id.company_id', '=', False), ('facility_id.company_id', 'in', company_ids)]</field>
</record>
<record id="fp_bake_oven_company_rule" model="ir.rule">
<field name="name">Fusion Plating: Bake Oven — multi-company</field>
<field name="model_id" ref="model_fusion_plating_bake_oven"/>
<field name="global" eval="True"/>
<field name="domain_force">['|', ('facility_id.company_id', '=', False), ('facility_id.company_id', 'in', company_ids)]</field>
</record>
<record id="fp_bake_window_company_rule" model="ir.rule">
<field name="name">Fusion Plating: Bake Window — multi-company</field>
<field name="model_id" ref="model_fusion_plating_bake_window"/>
<field name="global" eval="True"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
</odoo>

View File

@@ -0,0 +1,16 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_shopfloor_station_operator,fp.shopfloor.station.operator,model_fusion_plating_shopfloor_station,fusion_plating.group_fusion_plating_operator,1,1,0,0
access_fp_shopfloor_station_supervisor,fp.shopfloor.station.supervisor,model_fusion_plating_shopfloor_station,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_shopfloor_station_manager,fp.shopfloor.station.manager,model_fusion_plating_shopfloor_station,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_bake_oven_operator,fp.bake.oven.operator,model_fusion_plating_bake_oven,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_bake_oven_supervisor,fp.bake.oven.supervisor,model_fusion_plating_bake_oven,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_bake_oven_manager,fp.bake.oven.manager,model_fusion_plating_bake_oven,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_bake_window_operator,fp.bake.window.operator,model_fusion_plating_bake_window,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_bake_window_supervisor,fp.bake.window.supervisor,model_fusion_plating_bake_window,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_bake_window_manager,fp.bake.window.manager,model_fusion_plating_bake_window,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_first_piece_gate_operator,fp.first.piece.gate.operator,model_fusion_plating_first_piece_gate,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_first_piece_gate_supervisor,fp.first.piece.gate.supervisor,model_fusion_plating_first_piece_gate,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_first_piece_gate_manager,fp.first.piece.gate.manager,model_fusion_plating_first_piece_gate,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_operator_queue_operator,fp.operator.queue.operator,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_operator_queue_supervisor,fp.operator.queue.supervisor,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_operator_queue_manager,fp.operator.queue.manager,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_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_shopfloor_station_operator fp.shopfloor.station.operator model_fusion_plating_shopfloor_station fusion_plating.group_fusion_plating_operator 1 1 0 0
3 access_fp_shopfloor_station_supervisor fp.shopfloor.station.supervisor model_fusion_plating_shopfloor_station fusion_plating.group_fusion_plating_supervisor 1 1 1 0
4 access_fp_shopfloor_station_manager fp.shopfloor.station.manager model_fusion_plating_shopfloor_station fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_bake_oven_operator fp.bake.oven.operator model_fusion_plating_bake_oven fusion_plating.group_fusion_plating_operator 1 0 0 0
6 access_fp_bake_oven_supervisor fp.bake.oven.supervisor model_fusion_plating_bake_oven fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_bake_oven_manager fp.bake.oven.manager model_fusion_plating_bake_oven fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_bake_window_operator fp.bake.window.operator model_fusion_plating_bake_window fusion_plating.group_fusion_plating_operator 1 1 1 0
9 access_fp_bake_window_supervisor fp.bake.window.supervisor model_fusion_plating_bake_window fusion_plating.group_fusion_plating_supervisor 1 1 1 0
10 access_fp_bake_window_manager fp.bake.window.manager model_fusion_plating_bake_window fusion_plating.group_fusion_plating_manager 1 1 1 1
11 access_fp_first_piece_gate_operator fp.first.piece.gate.operator model_fusion_plating_first_piece_gate fusion_plating.group_fusion_plating_operator 1 1 1 0
12 access_fp_first_piece_gate_supervisor fp.first.piece.gate.supervisor model_fusion_plating_first_piece_gate fusion_plating.group_fusion_plating_supervisor 1 1 1 0
13 access_fp_first_piece_gate_manager fp.first.piece.gate.manager model_fusion_plating_first_piece_gate fusion_plating.group_fusion_plating_manager 1 1 1 1
14 access_fp_operator_queue_operator fp.operator.queue.operator model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_operator 1 1 1 1
15 access_fp_operator_queue_supervisor fp.operator.queue.supervisor model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_supervisor 1 1 1 1
16 access_fp_operator_queue_manager fp.operator.queue.manager model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,238 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Plant Overview Dashboard (OWL backend client action)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Steelhead-style multi-column kanban showing all active work orders grouped
// by work centre / station. Auto-refreshes every 30 s.
//
// Odoo 19 conventions:
// * Backend OWL component: `static template` + `static props = ["*"]`
// * RPC via standalone `rpc()` from @web/core/network/rpc
// * Registered under registry.category("actions") → "fp_plant_overview"
// =============================================================================
import { Component, useState, onMounted, onWillUnmount } 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 PlantOverview extends Component {
static template = "fusion_plating_shopfloor.PlantOverview";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
facilityName: "",
columns: [],
searchTerm: "",
loading: false,
lastRefresh: null,
});
this._refreshInterval = null;
onMounted(async () => {
await this.loadData();
// Auto-refresh every 30 seconds
this._refreshInterval = setInterval(() => this.loadData(), 30000);
});
onWillUnmount(() => {
if (this._refreshInterval) {
clearInterval(this._refreshInterval);
this._refreshInterval = null;
}
});
}
// ----- Data loading ------------------------------------------------------
async loadData() {
this.state.loading = true;
try {
const result = await rpc("/fp/shopfloor/plant_overview", {
search: this.state.searchTerm || null,
});
if (result) {
this.state.facilityName = result.facility_name || "Plant 1";
this.state.columns = result.columns || [];
this.state.lastRefresh = new Date().toLocaleTimeString();
}
} catch (err) {
this.notification.add(
`Failed to load plant overview: ${err.message || err}`,
{ type: "danger" },
);
} finally {
this.state.loading = false;
}
}
// ----- Search ------------------------------------------------------------
onSearchInput(ev) {
this.state.searchTerm = ev.target.value;
}
onSearchKey(ev) {
if (ev.key === "Enter") {
this.loadData();
}
}
onSearchClear() {
this.state.searchTerm = "";
this.loadData();
}
onRefresh() {
this.loadData();
}
// ----- Drag & drop --------------------------------------------------------
onCardDragStart(card, col, ev) {
this._draggedCard = {
id: card.id,
source_model: card.source_model || "mrp.workorder",
source_wc_id: col.work_center_id,
};
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", String(card.id));
// Add ghost class to the dragged card after a tick (so the drag image isn't affected)
requestAnimationFrame(() => {
if (ev.target && ev.target.classList) {
ev.target.classList.add("o_fp_dragging");
}
});
}
onCardDragEnd(ev) {
this._draggedCard = null;
if (ev.target && ev.target.classList) {
ev.target.classList.remove("o_fp_dragging");
}
// Remove any lingering drop-target highlights
document.querySelectorAll(".o_fp_drop_target").forEach((el) => {
el.classList.remove("o_fp_drop_target");
});
}
onColDragOver(col, ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
const body = ev.currentTarget;
if (body && !body.classList.contains("o_fp_drop_target")) {
body.classList.add("o_fp_drop_target");
}
}
onColDragLeave(col, ev) {
// Only remove highlight if we actually left the column body
// (not just hovering over a child element)
const body = ev.currentTarget;
if (body && !body.contains(ev.relatedTarget)) {
body.classList.remove("o_fp_drop_target");
}
}
async onColDrop(col, ev) {
ev.preventDefault();
const body = ev.currentTarget;
if (body) {
body.classList.remove("o_fp_drop_target");
}
const dragged = this._draggedCard;
if (!dragged) {
return;
}
// No-op if dropped on the same column
if (dragged.source_wc_id === col.work_center_id) {
this._draggedCard = null;
return;
}
try {
const result = await rpc("/fp/shopfloor/plant_overview/move_card", {
card_id: dragged.id,
source_model: dragged.source_model,
target_workcenter_id: col.work_center_id,
});
if (result && result.ok) {
this.notification.add(
`Moved to ${col.work_center_name}`,
{ type: "success" },
);
await this.loadData();
} else {
this.notification.add(
result?.error || "Could not move card",
{ type: "warning" },
);
}
} catch (err) {
this.notification.add(
`Move failed: ${err.message || err}`,
{ type: "danger" },
);
}
this._draggedCard = null;
}
// ----- Card actions ------------------------------------------------------
onCardClick(card) {
if (!card.id) {
return;
}
// Try opening the work order form if MRP is available, otherwise
// fall back to bake window or first-piece gate
const model = card.source_model || "mrp.workorder";
this.action.doAction({
type: "ir.actions.act_window",
res_model: model,
res_id: card.id,
views: [[false, "form"]],
target: "current",
});
}
// ----- Helpers -----------------------------------------------------------
getTagClass(tag) {
const lower = (tag || "").toLowerCase();
if (lower === "hot") {
return "o_fp_tag_hot";
}
if (lower === "priority" || lower === "high priority") {
return "o_fp_tag_priority";
}
if (lower.includes("attention") || lower.includes("special")) {
return "o_fp_tag_attention";
}
return "o_fp_tag_default";
}
getStateClass(state) {
switch (state) {
case "progress":
return "o_fp_card_progress";
case "ready":
return "o_fp_card_ready";
case "done":
return "o_fp_card_done";
case "pending":
return "o_fp_card_pending";
default:
return "";
}
}
}
registry.category("actions").add("fp_plant_overview", PlantOverview);

View File

@@ -0,0 +1,165 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Process Tree View (OWL backend client action)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Visual routing-step tree for a single manufacturing order showing progress
// bars per work order.
//
// Odoo 19 conventions:
// * Backend OWL component: `static template` + `static props = ["*"]`
// * RPC via standalone `rpc()` from @web/core/network/rpc
// * Registered under registry.category("actions") → "fp_process_tree"
// =============================================================================
import { Component, useState, onMounted } 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 ProcessTree extends Component {
static template = "fusion_plating_shopfloor.ProcessTree";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
productionName: "",
productName: "",
moState: "",
nodes: [],
loading: false,
collapsed: {}, // node id → boolean
});
onMounted(async () => {
await this.loadTree();
});
}
// ----- Data loading ------------------------------------------------------
get productionId() {
// Client action may receive production_id via action context or params
const ctx = this.props.action && this.props.action.context;
if (ctx && ctx.production_id) {
return ctx.production_id;
}
const params = this.props.action && this.props.action.params;
if (params && params.production_id) {
return params.production_id;
}
return null;
}
async loadTree() {
const prodId = this.productionId;
if (!prodId) {
this.notification.add(
"No manufacturing order specified for the process tree.",
{ type: "warning" },
);
return;
}
this.state.loading = true;
try {
const result = await rpc("/fp/shopfloor/process_tree", {
production_id: prodId,
});
if (result) {
this.state.productionName = result.production_name || "";
this.state.productName = result.product_name || "";
this.state.moState = result.state || "";
this.state.nodes = result.nodes || [];
}
} catch (err) {
this.notification.add(
`Failed to load process tree: ${err.message || err}`,
{ type: "danger" },
);
} finally {
this.state.loading = false;
}
}
// ----- Collapse / expand -------------------------------------------------
toggleNode(nodeId) {
this.state.collapsed[nodeId] = !this.state.collapsed[nodeId];
}
isCollapsed(nodeId) {
return !!this.state.collapsed[nodeId];
}
// ----- Navigation --------------------------------------------------------
onNodeClick(node) {
if (!node.workorder_id) {
return;
}
this.action.doAction({
type: "ir.actions.act_window",
res_model: "mrp.workorder",
res_id: node.workorder_id,
views: [[false, "form"]],
target: "current",
});
}
onBackToOverview() {
this.action.doAction("fusion_plating_shopfloor.action_fp_plant_overview");
}
// ----- Helpers -----------------------------------------------------------
getProgressPct(node) {
if (!node.qty_total || node.qty_total === 0) {
return 0;
}
return Math.round((node.qty_done / node.qty_total) * 100);
}
getProgressClass(node) {
const pct = this.getProgressPct(node);
if (pct >= 100) {
return "o_fp_tree_progress_done";
}
if (pct > 0) {
return "o_fp_tree_progress_active";
}
return "o_fp_tree_progress_empty";
}
getNodeStateLabel(state) {
const map = {
pending: "Pending",
waiting: "Waiting",
ready: "Ready",
progress: "In Progress",
done: "Done",
cancel: "Cancelled",
};
return map[state] || state || "—";
}
getNodeStateClass(state) {
switch (state) {
case "done":
return "o_fp_tree_state_done";
case "progress":
return "o_fp_tree_state_progress";
case "ready":
return "o_fp_tree_state_ready";
case "cancel":
return "o_fp_tree_state_cancel";
default:
return "o_fp_tree_state_pending";
}
}
}
registry.category("actions").add("fp_process_tree", ProcessTree);

View File

@@ -0,0 +1,178 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Shop Floor Tablet (OWL backend client action)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Odoo 19 conventions:
// * Backend OWL component using `static template` + `static props = []`
// (note: empty array, NOT empty object).
// * RPC via standalone `rpc()` from @web/core/network/rpc — NOT useService.
// * Registered under registry.category("actions") so the menu / record
// action can launch it as a client action ("fp_shopfloor_tablet").
// =============================================================================
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 ShopfloorTablet extends Component {
static template = "fusion_plating_shopfloor.ShopfloorTablet";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.scanInput = useRef("scanInput");
this.state = useState({
scannedCode: "",
station: null,
currentTank: null,
currentBath: null,
currentJob: null,
queueRows: [],
message: "",
messageType: "info", // info | success | warning | danger
loading: false,
});
onMounted(async () => {
await this.refreshQueue();
if (this.scanInput.el) {
this.scanInput.el.focus();
}
});
}
// ----- Helpers --------------------------------------------------------
setMessage(text, type = "info") {
this.state.message = text;
this.state.messageType = type;
}
clearTargets() {
this.state.currentTank = null;
this.state.currentBath = null;
this.state.currentJob = null;
}
// ----- QR scan --------------------------------------------------------
async onScan() {
const code = (this.state.scannedCode || "").trim();
if (!code) {
return;
}
this.state.loading = true;
try {
const result = await rpc("/fp/shopfloor/scan", { qr_code: code });
if (!result || !result.ok) {
this.setMessage(
(result && result.error) || "Unrecognised QR code",
"danger",
);
this.state.loading = false;
return;
}
this.clearTargets();
switch (result.model) {
case "fusion.plating.tank":
this.state.currentTank = result;
this.setMessage(
`Tank ${result.name}${result.queue_size} in queue`,
"info",
);
break;
case "fusion.plating.bath":
this.state.currentBath = result;
this.setMessage(`Bath ${result.name}`, "info");
break;
case "fusion.plating.bake.window":
this.state.currentJob = result;
this.setMessage(
`Job ${result.name}${result.time_remaining || ""} remaining`,
result.state === "missed_window" ? "danger" : "warning",
);
break;
case "fusion.plating.shopfloor.station":
this.state.station = result;
this.setMessage(
`Station paired: ${result.name}`,
"success",
);
break;
default:
this.setMessage(`Scanned ${result.model}`, "info");
}
} catch (err) {
this.setMessage(`Scan error: ${err.message || err}`, "danger");
} finally {
this.state.scannedCode = "";
this.state.loading = false;
if (this.scanInput.el) {
this.scanInput.el.focus();
}
await this.refreshQueue();
}
}
onScanKey(ev) {
if (ev.key === "Enter") {
this.onScan();
}
}
// ----- Bake controls --------------------------------------------------
async onStartBake() {
if (!this.state.currentJob) {
return;
}
try {
const res = await rpc("/fp/shopfloor/start_bake", {
bake_window_id: this.state.currentJob.id,
});
if (res && res.ok) {
this.setMessage("Bake started", "success");
this.state.currentJob.state = res.state;
}
} catch (err) {
this.setMessage(`Start bake failed: ${err.message || err}`, "danger");
}
await this.refreshQueue();
}
async onEndBake() {
if (!this.state.currentJob) {
return;
}
try {
const res = await rpc("/fp/shopfloor/end_bake", {
bake_window_id: this.state.currentJob.id,
});
if (res && res.ok) {
this.setMessage(
`Bake complete — ${res.bake_duration_hours.toFixed(2)} h`,
"success",
);
this.state.currentJob.state = res.state;
}
} catch (err) {
this.setMessage(`End bake failed: ${err.message || err}`, "danger");
}
await this.refreshQueue();
}
// ----- Queue ----------------------------------------------------------
async refreshQueue() {
try {
const res = await rpc("/fp/shopfloor/queue", {});
if (res && res.ok) {
this.state.queueRows = res.rows || [];
}
} catch (err) {
// Non-fatal: queue refresh shouldn't block scanning
}
}
}
registry.category("actions").add("fp_shopfloor_tablet", ShopfloorTablet);

View File

@@ -0,0 +1,280 @@
// =============================================================================
// Fusion Plating — Shop Floor backend / tablet styles
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// THEME AWARENESS
// ---------------
// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
// the tablet view renders correctly in BOTH light and dark mode without any
// duplication or media queries. Status tints use color-mix() against the
// theme token so green/yellow/red adapt to the surface.
//
// background: var(--bs-body-bg)
// surface: var(--o-view-background-color)
// foreground: var(--bs-body-color)
// muted text: var(--bs-secondary-color)
// border: var(--bs-border-color)
// primary: var(--o-action)
// =============================================================================
// -----------------------------------------------------------------------------
// Local mixin — semantic tint that respects light/dark mode
// -----------------------------------------------------------------------------
@mixin fp-shop-tint($color-var, $amount: 14%) {
background-color: color-mix(in srgb, var(#{$color-var}) #{$amount}, transparent);
color: var(#{$color-var});
border: 1px solid color-mix(in srgb, var(#{$color-var}) 35%, transparent);
}
// -----------------------------------------------------------------------------
// Tablet root container — large touch targets, generous whitespace
// -----------------------------------------------------------------------------
.o_fp_tablet {
background-color: var(--o-view-background-color, var(--bs-body-bg));
color: var(--bs-body-color);
min-height: 100%;
padding: 24px;
font-size: 1.1rem;
display: flex;
flex-direction: column;
gap: 18px;
.o_fp_tablet_header {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--bs-border-color);
}
.o_fp_tablet_title {
font-size: 1.6rem;
font-weight: 600;
color: var(--bs-body-color);
}
.o_fp_tablet_station {
color: var(--bs-secondary-color);
font-size: 1rem;
}
.o_fp_tablet_scan_row {
display: flex;
gap: 12px;
align-items: stretch;
}
.o_fp_tablet_message {
padding: 14px 18px;
border-radius: 10px;
font-size: 1.1rem;
line-height: 1.4;
&.o_fp_msg_info { @include fp-shop-tint(--bs-info); }
&.o_fp_msg_success { @include fp-shop-tint(--bs-success); }
&.o_fp_msg_warning { @include fp-shop-tint(--bs-warning); }
&.o_fp_msg_danger { @include fp-shop-tint(--bs-danger); }
}
.o_fp_tablet_grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.o_fp_tablet_queue {
background-color: var(--o-view-background-color, var(--bs-body-bg));
border: 1px solid var(--bs-border-color);
border-radius: 12px;
padding: 16px 18px;
.o_fp_tablet_queue_title {
font-size: 1.2rem;
font-weight: 600;
color: var(--bs-body-color);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px dashed var(--bs-border-color);
}
.o_fp_tablet_queue_list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.o_fp_tablet_queue_item {
background-color: color-mix(in srgb, var(--bs-body-color) 4%, transparent);
border: 1px solid var(--bs-border-color);
border-radius: 8px;
padding: 10px 14px;
.o_fp_tablet_queue_label {
color: var(--bs-body-color);
}
.o_fp_tablet_queue_desc {
color: var(--bs-secondary-color);
font-size: 0.95rem;
}
}
}
}
// -----------------------------------------------------------------------------
// Large card surface used for tank / bath info on the tablet
// -----------------------------------------------------------------------------
.o_fp_tablet_card {
background-color: var(--o-view-background-color, var(--bs-body-bg));
color: var(--bs-body-color);
border: 1px solid var(--bs-border-color);
border-radius: 12px;
padding: 18px 20px;
min-height: 140px;
transition: border-color 120ms ease, box-shadow 120ms ease;
&:hover {
border-color: color-mix(in srgb, var(--o-action) 50%, var(--bs-border-color));
box-shadow: 0 2px 10px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
}
.o_fp_tablet_card_label {
font-size: 0.85rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bs-secondary-color);
margin-bottom: 6px;
}
.o_fp_tablet_card_value {
font-size: 1.6rem;
font-weight: 600;
color: var(--bs-body-color);
margin-bottom: 6px;
line-height: 1.1;
}
.o_fp_tablet_card_meta {
font-size: 0.95rem;
color: var(--bs-secondary-color);
}
}
// -----------------------------------------------------------------------------
// Bake window card — colour shifts with state
// -----------------------------------------------------------------------------
.o_fp_bake_window_card {
background-color: var(--o-view-background-color, var(--bs-body-bg));
color: var(--bs-body-color);
border: 1px solid var(--bs-border-color);
border-left-width: 6px;
border-radius: 12px;
padding: 18px 20px;
min-height: 160px;
.o_fp_tablet_card_label {
font-size: 0.85rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bs-secondary-color);
margin-bottom: 6px;
}
.o_fp_tablet_card_value {
font-size: 1.6rem;
font-weight: 600;
line-height: 1.1;
}
.o_fp_tablet_card_meta {
font-size: 0.95rem;
color: var(--bs-secondary-color);
}
.o_fp_tablet_card_actions {
display: flex;
gap: 10px;
margin-top: 12px;
}
&[data-status="awaiting_bake"] {
border-left-color: var(--bs-warning);
background-color: color-mix(in srgb, var(--bs-warning) 6%, var(--o-view-background-color, var(--bs-body-bg)));
}
&[data-status="bake_in_progress"] {
border-left-color: var(--bs-info, var(--o-action));
background-color: color-mix(in srgb, var(--bs-info, var(--o-action)) 6%, var(--o-view-background-color, var(--bs-body-bg)));
}
&[data-status="baked"] {
border-left-color: var(--bs-success);
background-color: color-mix(in srgb, var(--bs-success) 6%, var(--o-view-background-color, var(--bs-body-bg)));
}
&[data-status="missed_window"],
&[data-status="scrapped"] {
border-left-color: var(--bs-danger);
background-color: color-mix(in srgb, var(--bs-danger) 8%, var(--o-view-background-color, var(--bs-body-bg)));
}
}
// -----------------------------------------------------------------------------
// Large QR scan input — friendly to tablet keyboards / wedge scanners
// -----------------------------------------------------------------------------
.o_fp_scan_input {
flex: 1 1 auto;
min-height: 56px;
padding: 12px 18px;
font-size: 1.3rem;
border: 2px solid var(--bs-border-color);
border-radius: 10px;
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
&:focus {
outline: none;
border-color: var(--o-action);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--o-action) 25%, transparent);
}
&::placeholder {
color: var(--bs-secondary-color);
}
}
// -----------------------------------------------------------------------------
// Big touch-friendly action button
// -----------------------------------------------------------------------------
.o_fp_big_button {
min-height: 56px;
min-width: 120px;
padding: 12px 24px;
font-size: 1.1rem;
font-weight: 500;
border-radius: 10px;
border: 1px solid var(--o-action);
background-color: var(--o-action);
color: var(--o-we-text-on-action, #fff);
cursor: pointer;
transition: filter 120ms ease, transform 80ms ease;
&:hover:not(:disabled) {
filter: brightness(1.05);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}

View File

@@ -0,0 +1,441 @@
// =============================================================================
// Fusion Plating — Plant Overview Dashboard
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// THEME AWARENESS
// ---------------
// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
// the dashboard renders correctly in BOTH light and dark mode.
//
// background: var(--bs-body-bg)
// surface: var(--o-view-background-color)
// foreground: var(--bs-body-color)
// muted text: var(--bs-secondary-color)
// border: var(--bs-border-color)
// primary: var(--o-action)
// =============================================================================
.o_fp_plant_overview {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--o-view-background-color, var(--bs-body-bg));
padding: 0;
}
// ---- Header -----------------------------------------------------------------
.o_fp_po_header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 16px 20px;
background: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
box-shadow: 0 1px 3px color-mix(in srgb, var(--bs-body-color) 6%, transparent);
.o_fp_po_header_left {
display: flex;
align-items: center;
}
.o_fp_po_title {
margin: 0;
font-size: 1.3rem;
font-weight: 700;
color: var(--bs-body-color);
}
.o_fp_po_refresh_ts {
font-size: 0.8rem;
}
.o_fp_po_header_right {
display: flex;
align-items: center;
gap: 10px;
}
}
// ---- Search -----------------------------------------------------------------
.o_fp_po_search_box {
position: relative;
display: flex;
align-items: center;
.o_fp_po_search_icon {
position: absolute;
left: 10px;
color: var(--bs-secondary-color);
pointer-events: none;
}
.o_fp_po_search_input {
padding: 6px 32px 6px 32px;
border: 1px solid var(--bs-border-color);
border-radius: 6px;
font-size: 0.875rem;
width: 260px;
outline: none;
transition: border-color 0.15s;
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
&:focus {
border-color: var(--o-action);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--o-action) 15%, transparent);
}
}
.o_fp_po_search_clear {
position: absolute;
right: 6px;
background: none;
border: none;
color: var(--bs-secondary-color);
cursor: pointer;
padding: 2px 6px;
&:hover {
color: var(--bs-body-color);
}
}
}
.o_fp_po_refresh_btn {
width: 36px;
height: 36px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
// ---- Columns container ------------------------------------------------------
.o_fp_po_columns {
display: flex;
gap: 12px;
padding: 16px 20px;
overflow-x: auto;
flex: 1;
min-height: 0;
align-items: flex-start;
}
// ---- Single column (work centre) --------------------------------------------
.o_fp_po_column {
flex: 0 0 280px;
min-width: 260px;
max-width: 320px;
display: flex;
flex-direction: column;
background: var(--bs-body-bg);
border-radius: 10px;
box-shadow: 0 1px 4px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
max-height: calc(100vh - 140px);
}
.o_fp_po_col_header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 2px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
border-radius: 10px 10px 0 0;
.o_fp_po_col_name {
font-weight: 700;
font-size: 0.9rem;
color: var(--bs-body-color);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.o_fp_po_col_count {
background: var(--bs-secondary-color);
color: #fff;
font-size: 0.75rem;
min-width: 24px;
text-align: center;
}
}
.o_fp_po_col_body {
overflow-y: auto;
padding: 8px;
flex: 1;
transition: background-color 0.15s, border-color 0.15s;
border: 2px solid transparent;
border-radius: 0 0 10px 10px;
// Drop target highlight when dragging a card over this column
&.o_fp_drop_target {
background-color: color-mix(in srgb, var(--o-action) 8%, transparent);
border-color: color-mix(in srgb, var(--o-action) 40%, transparent);
}
}
// ---- Card -------------------------------------------------------------------
.o_fp_po_card {
background: var(--bs-body-bg);
border-width: 1px;
border-style: solid;
border-color: $border-color;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
cursor: grab;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.15s, transform 0.1s, opacity 0.15s;
&:hover {
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
border-color: darken($border-color, 10%);
}
&:active {
cursor: grabbing;
}
&:last-child {
margin-bottom: 0;
}
// Dragging ghost state
&.o_fp_dragging {
opacity: 0.4;
border-style: dashed;
box-shadow: none;
transform: none;
}
// State variants
&.o_fp_card_progress {
border-left: 4px solid var(--bs-warning);
}
&.o_fp_card_ready {
border-left: 4px solid var(--bs-primary);
}
&.o_fp_card_done {
border-left: 4px solid var(--bs-success);
opacity: 0.75;
}
&.o_fp_card_pending {
border-left: 4px solid var(--bs-warning);
}
}
// ---- Card top row (image + title + step badge) --------------------------------
.o_fp_po_card_top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.o_fp_po_card_img {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.o_fp_po_card_img_placeholder {
width: 32px;
height: 32px;
border-radius: 4px;
background: var(--bs-tertiary-bg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--bs-secondary-color);
font-size: 14px;
}
.o_fp_po_card_title {
flex: 1;
min-width: 0;
font-size: 0.9rem;
color: var(--bs-body-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.o_fp_po_card_step_badge {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--bs-info);
color: #fff;
font-size: 0.7rem;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
// ---- Priority card borders ---------------------------------------------------
.o_fp_po_card_hot {
border-left: 4px solid var(--bs-danger) !important;
background: color-mix(in srgb, var(--bs-danger) 8%, var(--bs-body-bg));
}
.o_fp_po_card_urgent {
border-left: 4px solid var(--bs-warning) !important;
background: color-mix(in srgb, var(--bs-warning) 8%, var(--bs-body-bg));
}
// ---- Product name and step display -------------------------------------------
.o_fp_po_card_product {
margin-bottom: 4px;
}
.o_fp_po_card_step {
margin-bottom: 4px;
}
.o_fp_po_card_customer {
font-size: 0.9rem;
margin-bottom: 2px;
color: var(--bs-body-color);
}
.o_fp_po_card_refs {
font-size: 0.8rem;
color: var(--bs-secondary-color);
margin-bottom: 6px;
}
// ---- Parts progress bar -----------------------------------------------------
.o_fp_po_card_parts {
margin-bottom: 6px;
}
.o_fp_po_parts_bar {
height: 6px;
background: var(--bs-tertiary-bg);
border-radius: 3px;
overflow: hidden;
margin-bottom: 2px;
}
.o_fp_po_parts_fill {
height: 100%;
background: var(--bs-warning);
border-radius: 3px;
transition: width 0.3s ease;
}
.o_fp_po_parts_label {
font-size: 0.75rem;
color: var(--bs-secondary-color);
}
.o_fp_po_card_last {
font-size: 0.75rem;
margin-bottom: 6px;
}
// ---- Tags + date footer -----------------------------------------------------
.o_fp_po_card_footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
flex-wrap: wrap;
}
.o_fp_po_card_tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.o_fp_po_tag {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 2px 6px;
border-radius: 4px;
line-height: 1.4;
&.o_fp_tag_hot {
background: var(--bs-danger);
color: #fff;
}
&.o_fp_tag_priority {
background: var(--bs-success);
color: #fff;
}
&.o_fp_tag_attention {
background: var(--bs-warning);
color: var(--bs-body-color);
}
&.o_fp_tag_default {
background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
}
}
.o_fp_po_card_date {
font-size: 0.75rem;
font-weight: 600;
color: var(--bs-secondary-color);
background: var(--bs-tertiary-bg);
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
}
// ---- Empty / no-cards -------------------------------------------------------
.o_fp_po_no_cards {
font-size: 0.85rem;
}
// ---- Responsive -------------------------------------------------------------
@media (max-width: 768px) {
.o_fp_po_columns {
flex-direction: column;
align-items: stretch;
padding: 12px;
}
.o_fp_po_column {
flex: 1 1 auto;
min-width: 100%;
max-width: 100%;
max-height: none;
}
.o_fp_po_search_input {
width: 180px !important;
}
.o_fp_po_header {
padding: 12px;
}
}

View File

@@ -0,0 +1,298 @@
// =============================================================================
// Fusion Plating — Process Tree View
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// THEME AWARENESS
// ---------------
// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
// the tree view renders correctly in BOTH light and dark mode.
//
// background: var(--bs-body-bg)
// surface: var(--o-view-background-color)
// foreground: var(--bs-body-color)
// muted text: var(--bs-secondary-color)
// border: var(--bs-border-color)
// primary: var(--o-action)
// =============================================================================
.o_fp_process_tree {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--o-view-background-color, var(--bs-body-bg));
padding: 0;
}
// ---- Header -----------------------------------------------------------------
.o_fp_pt_header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 16px 24px;
background: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
box-shadow: 0 1px 3px color-mix(in srgb, var(--bs-body-color) 6%, transparent);
.o_fp_pt_header_left {
display: flex;
align-items: center;
}
.o_fp_pt_title {
font-size: 1.2rem;
font-weight: 700;
color: var(--bs-body-color);
}
.o_fp_pt_subtitle {
font-size: 0.85rem;
display: block;
}
}
// ---- Tree container ---------------------------------------------------------
.o_fp_pt_tree {
padding: 24px;
padding-left: 48px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
// ---- Node wrapper -----------------------------------------------------------
.o_fp_pt_node_wrapper {
position: relative;
margin-bottom: 0;
}
// ---- Connector line (vertical line between nodes) ---------------------------
.o_fp_pt_connector {
width: 3px;
height: 20px;
background: var(--bs-border-color);
margin-left: 28px;
}
// ---- Node box ---------------------------------------------------------------
.o_fp_pt_node {
background: var(--bs-secondary-bg);
color: var(--bs-body-color);
border-radius: 10px;
padding: 14px 18px;
max-width: 440px;
cursor: pointer;
transition: box-shadow 0.15s, transform 0.1s;
position: relative;
&:hover {
box-shadow: 0 3px 12px color-mix(in srgb, var(--bs-body-color) 15%, transparent);
transform: translateX(2px);
}
// State colour accents (left border)
&.o_fp_tree_state_done {
border-left: 5px solid var(--bs-success);
}
&.o_fp_tree_state_progress {
border-left: 5px solid var(--bs-warning);
}
&.o_fp_tree_state_ready {
border-left: 5px solid var(--bs-primary);
}
&.o_fp_tree_state_cancel {
border-left: 5px solid var(--bs-secondary);
opacity: 0.6;
}
&.o_fp_tree_state_pending {
border-left: 5px solid var(--bs-border-color);
}
}
.o_fp_pt_node_header {
display: flex;
align-items: center;
justify-content: space-between;
}
.o_fp_pt_node_name {
font-size: 1rem;
}
.o_fp_pt_node_seq {
color: var(--bs-secondary-color);
font-weight: 400;
margin-right: 4px;
}
.o_fp_pt_toggle_btn {
background: none;
border: none;
color: var(--bs-secondary-color);
cursor: pointer;
padding: 2px 6px;
font-size: 0.85rem;
&:hover {
color: var(--bs-body-color);
}
}
.o_fp_pt_node_wc {
font-size: 0.8rem;
color: var(--bs-secondary-color) !important;
margin-top: 2px;
}
// ---- State badges inside tree -----------------------------------------------
.o_fp_pt_node_state {
.badge {
font-size: 0.7rem;
font-weight: 600;
padding: 3px 8px;
}
.o_fp_tree_state_done {
background: var(--bs-success) !important;
color: #fff !important;
}
.o_fp_tree_state_progress {
background: var(--bs-warning) !important;
color: #fff !important;
}
.o_fp_tree_state_ready {
background: var(--bs-primary) !important;
color: #fff !important;
}
.o_fp_tree_state_cancel {
background: var(--bs-secondary) !important;
color: #fff !important;
}
.o_fp_tree_state_pending {
background: var(--bs-tertiary-bg) !important;
color: var(--bs-secondary-color) !important;
}
}
// ---- Progress bar -----------------------------------------------------------
.o_fp_pt_bar {
height: 8px;
background: var(--bs-tertiary-bg);
border-radius: 4px;
overflow: hidden;
&.o_fp_pt_bar_sm {
height: 6px;
}
.o_fp_pt_bar_fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
&.o_fp_tree_progress_active .o_fp_pt_bar_fill {
background: var(--bs-warning);
}
&.o_fp_tree_progress_done .o_fp_pt_bar_fill {
background: var(--bs-success);
}
&.o_fp_tree_progress_empty .o_fp_pt_bar_fill {
background: var(--bs-secondary);
}
}
.o_fp_pt_bar_label {
font-size: 0.75rem;
color: var(--bs-secondary-color);
margin-top: 2px;
display: inline-block;
}
.o_fp_pt_node_duration {
font-size: 0.75rem;
color: var(--bs-secondary-color) !important;
}
// ---- Children (sub-state nodes) ---------------------------------------------
.o_fp_pt_children {
margin-left: 48px;
padding-top: 4px;
}
.o_fp_pt_child_connector {
width: 3px;
height: 12px;
background: var(--bs-border-color);
margin-left: 20px;
}
.o_fp_pt_child_node {
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
border-radius: 8px;
padding: 10px 14px;
max-width: 360px;
margin-bottom: 0;
&.o_fp_tree_state_progress {
border-left: 4px solid var(--bs-warning);
}
&.o_fp_tree_state_ready {
border-left: 4px solid var(--bs-primary);
}
&.o_fp_tree_state_done {
border-left: 4px solid var(--bs-success);
}
&.o_fp_tree_state_pending {
border-left: 4px solid var(--bs-secondary);
}
}
.o_fp_pt_child_name {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 4px;
}
.o_fp_pt_child_progress {
display: flex;
align-items: center;
gap: 8px;
.o_fp_pt_bar {
flex: 1;
}
}
// ---- Responsive -------------------------------------------------------------
@media (max-width: 768px) {
.o_fp_pt_tree {
padding: 16px;
padding-left: 24px;
}
.o_fp_pt_node {
max-width: 100%;
}
.o_fp_pt_child_node {
max-width: 100%;
}
.o_fp_pt_children {
margin-left: 24px;
}
}

View File

@@ -0,0 +1,171 @@
<?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.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.PlantOverview">
<div class="o_fp_plant_overview">
<!-- ========== HEADER ========== -->
<div class="o_fp_po_header">
<div class="o_fp_po_header_left">
<h2 class="o_fp_po_title">
<i class="fa fa-industry me-2"/>
<t t-esc="state.facilityName || 'Plant 1'"/> Overview
</h2>
<span class="o_fp_po_refresh_ts text-muted ms-3"
t-if="state.lastRefresh">
Updated <t t-esc="state.lastRefresh"/>
</span>
</div>
<div class="o_fp_po_header_right">
<div class="o_fp_po_search_box">
<i class="fa fa-search o_fp_po_search_icon"/>
<input type="text"
class="o_fp_po_search_input"
placeholder="Search customer, SO, WO, part..."
t-att-value="state.searchTerm"
t-on-input="onSearchInput"
t-on-keydown="onSearchKey"/>
<button class="o_fp_po_search_clear"
t-if="state.searchTerm"
t-on-click="onSearchClear"
title="Clear search">
<i class="fa fa-times"/>
</button>
</div>
<button class="btn btn-outline-secondary o_fp_po_refresh_btn"
t-on-click="onRefresh"
t-att-disabled="state.loading"
title="Refresh">
<i t-att-class="state.loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"/>
</button>
</div>
</div>
<!-- ========== LOADING ========== -->
<div class="o_fp_po_loading text-center py-5" t-if="state.loading and !state.columns.length">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2 text-muted">Loading plant data...</p>
</div>
<!-- ========== EMPTY STATE ========== -->
<div class="o_fp_po_empty text-center py-5"
t-if="!state.loading and !state.columns.length">
<i class="fa fa-inbox fa-3x text-muted"/>
<p class="mt-3 text-muted">No work centres with active orders found.</p>
</div>
<!-- ========== COLUMNS (work centres) ========== -->
<div class="o_fp_po_columns" t-if="state.columns.length">
<t t-foreach="state.columns" t-as="col" t-key="col.work_center_id">
<div class="o_fp_po_column">
<!-- Column header -->
<div class="o_fp_po_col_header">
<span class="o_fp_po_col_name" t-esc="col.work_center_name"/>
<span class="o_fp_po_col_count badge rounded-pill">
<t t-esc="col.cards.length"/>
</span>
</div>
<!-- Cards (drop zone) -->
<div class="o_fp_po_col_body"
t-on-dragover="(ev) => this.onColDragOver(col, ev)"
t-on-dragleave="(ev) => this.onColDragLeave(col, ev)"
t-on-drop="(ev) => this.onColDrop(col, ev)">
<t t-if="!col.cards.length">
<div class="o_fp_po_no_cards text-muted text-center py-3">
<i class="fa fa-check-circle"/> Clear
</div>
</t>
<t t-foreach="col.cards" t-as="card" t-key="card.id">
<div t-att-class="'o_fp_po_card ' + getStateClass(card.state) + (card.priority === '2' ? ' o_fp_po_card_hot' : card.priority === '1' ? ' o_fp_po_card_urgent' : '')"
draggable="true"
t-att-data-card-id="card.id"
t-att-data-source-model="card.source_model"
t-att-data-source-wc="col.work_center_id"
t-on-dragstart="(ev) => this.onCardDragStart(card, col, ev)"
t-on-dragend="(ev) => this.onCardDragEnd(ev)"
t-on-click="() => this.onCardClick(card)">
<!-- Top row: product image + customer + step badge -->
<div class="o_fp_po_card_top">
<img t-if="card.customer_logo_url"
t-att-src="card.customer_logo_url"
class="o_fp_po_card_img"
alt="Customer"/>
<div class="o_fp_po_card_img_placeholder" t-else="">
<i class="fa fa-building"/>
</div>
<div class="o_fp_po_card_title">
<strong t-esc="card.customer_name || 'Walk-In'"/>
</div>
<span class="o_fp_po_card_step_badge" t-if="card.step_number">
<t t-esc="card.step_number"/>
</span>
</div>
<!-- SO / WO refs + product name -->
<div class="o_fp_po_card_refs">
<span t-if="card.so_name" t-esc="card.so_name"/>
<span t-if="card.so_name and card.wo_name"> | </span>
<span t-if="card.wo_name" t-esc="card.wo_name"/>
</div>
<div class="o_fp_po_card_product text-muted small" t-if="card.product_name">
<t t-esc="card.product_name"/>
</div>
<!-- Parts progress -->
<div class="o_fp_po_card_parts" t-if="card.parts_total">
<div class="o_fp_po_parts_bar">
<div class="o_fp_po_parts_fill"
t-att-style="'width:' + Math.round((card.parts_done / card.parts_total) * 100) + '%'"/>
</div>
<span class="o_fp_po_parts_label">
<t t-esc="card.parts_done"/>/<t t-esc="card.parts_total"/> Parts
</span>
</div>
<!-- Step display -->
<div class="o_fp_po_card_step text-muted small" t-if="card.step_display">
<i class="fa fa-map-signs me-1"/>
<t t-esc="card.step_display"/>
</div>
<!-- Last activity -->
<div class="o_fp_po_card_last text-muted"
t-if="card.last_operator">
Last: <t t-esc="card.last_operator"/>
<t t-if="card.last_activity">
<span class="ms-1" t-esc="card.last_activity"/>
</t>
</div>
<!-- Tags + date badge row -->
<div class="o_fp_po_card_footer">
<div class="o_fp_po_card_tags">
<t t-foreach="card.tags || []" t-as="tag" t-key="tag">
<span t-att-class="'o_fp_po_tag ' + getTagClass(tag)"
t-esc="tag"/>
</t>
</div>
<div class="o_fp_po_card_date" t-if="card.date_display">
<t t-esc="card.date_display"/>
</div>
</div>
</div>
</t>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,154 @@
<?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.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ProcessTree">
<div class="o_fp_process_tree">
<!-- ========== HEADER ========== -->
<div class="o_fp_pt_header">
<div class="o_fp_pt_header_left">
<button class="btn btn-outline-secondary btn-sm me-3"
t-on-click="onBackToOverview"
title="Back to Plant Overview">
<i class="fa fa-arrow-left me-1"/> Overview
</button>
<div class="o_fp_pt_title_block">
<h3 class="o_fp_pt_title mb-0">
<i class="fa fa-sitemap me-2"/>
Process Tree
</h3>
<span class="o_fp_pt_subtitle text-muted" t-if="state.productionName">
<t t-esc="state.productionName"/>
<t t-if="state.productName">
<t t-esc="state.productName"/>
</t>
</span>
</div>
</div>
<div class="o_fp_pt_header_right" t-if="state.moState">
<span class="badge bg-secondary">
MO: <t t-esc="state.moState"/>
</span>
</div>
</div>
<!-- ========== LOADING ========== -->
<div class="o_fp_pt_loading text-center py-5" t-if="state.loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2 text-muted">Loading process tree...</p>
</div>
<!-- ========== NO PRODUCTION ID ========== -->
<div class="o_fp_pt_empty text-center py-5"
t-if="!state.loading and !productionId">
<i class="fa fa-exclamation-triangle fa-3x text-warning"/>
<p class="mt-3">No manufacturing order selected.
Open this view from a production order to see its routing tree.</p>
</div>
<!-- ========== EMPTY TREE ========== -->
<div class="o_fp_pt_empty text-center py-5"
t-if="!state.loading and productionId and !state.nodes.length">
<i class="fa fa-sitemap fa-3x text-muted"/>
<p class="mt-3 text-muted">No routing steps found for this order.</p>
</div>
<!-- ========== TREE ========== -->
<div class="o_fp_pt_tree" t-if="state.nodes.length">
<t t-foreach="state.nodes" t-as="node" t-key="node.id">
<div class="o_fp_pt_node_wrapper">
<!-- Connecting line (not on first node) -->
<div class="o_fp_pt_connector" t-if="!node_first"/>
<!-- Node box -->
<div t-att-class="'o_fp_pt_node ' + getNodeStateClass(node.state)"
t-on-click="() => this.onNodeClick(node)">
<div class="o_fp_pt_node_header">
<div class="o_fp_pt_node_name">
<span class="o_fp_pt_node_seq"
t-if="node.sequence">
<t t-esc="node.sequence"/>.
</span>
<strong t-esc="node.name"/>
</div>
<button class="o_fp_pt_toggle_btn"
t-if="node.children and node.children.length"
t-on-click.stop="() => this.toggleNode(node.id)"
title="Expand / collapse">
<i t-att-class="isCollapsed(node.id) ? 'fa fa-chevron-right' : 'fa fa-chevron-down'"/>
</button>
</div>
<!-- Work centre -->
<div class="o_fp_pt_node_wc text-muted"
t-if="node.work_center_name">
<i class="fa fa-cog me-1"/>
<t t-esc="node.work_center_name"/>
</div>
<!-- State badge -->
<div class="o_fp_pt_node_state mt-1">
<span t-att-class="'badge ' + getNodeStateClass(node.state)">
<t t-esc="getNodeStateLabel(node.state)"/>
</span>
</div>
<!-- Progress bar -->
<div class="o_fp_pt_node_progress mt-2"
t-if="node.qty_total">
<div t-att-class="'o_fp_pt_bar ' + getProgressClass(node)">
<div class="o_fp_pt_bar_fill"
t-att-style="'width:' + getProgressPct(node) + '%'"/>
</div>
<span class="o_fp_pt_bar_label">
<t t-esc="node.qty_done"/>/<t t-esc="node.qty_total"/>
(<t t-esc="getProgressPct(node)"/>%)
</span>
</div>
<!-- Duration -->
<div class="o_fp_pt_node_duration text-muted mt-1"
t-if="node.duration_display">
<i class="fa fa-clock-o me-1"/>
<t t-esc="node.duration_display"/>
</div>
</div>
<!-- Child nodes (sub-states: Ready for X, X-ing) -->
<div class="o_fp_pt_children"
t-if="node.children and node.children.length and !isCollapsed(node.id)">
<t t-foreach="node.children" t-as="child" t-key="child.id">
<div class="o_fp_pt_child_connector"/>
<div t-att-class="'o_fp_pt_child_node ' + getNodeStateClass(child.state)">
<div class="o_fp_pt_child_name">
<t t-esc="child.name"/>
</div>
<div class="o_fp_pt_child_progress"
t-if="child.qty_total">
<div t-att-class="'o_fp_pt_bar o_fp_pt_bar_sm ' + getProgressClass(child)">
<div class="o_fp_pt_bar_fill"
t-att-style="'width:' + getProgressPct(child) + '%'"/>
</div>
<span class="o_fp_pt_bar_label">
<t t-esc="child.qty_done"/>/<t t-esc="child.qty_total"/>
</span>
</div>
</div>
</t>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,115 @@
<?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.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ShopfloorTablet">
<div class="o_fp_tablet">
<div class="o_fp_tablet_header">
<div class="o_fp_tablet_title">Fusion Plating — Shop Floor</div>
<div class="o_fp_tablet_station" t-if="state.station">
Station: <strong t-esc="state.station.name"/>
</div>
</div>
<div class="o_fp_tablet_scan_row">
<input
type="text"
class="o_fp_scan_input"
placeholder="Scan QR code"
t-ref="scanInput"
t-model="state.scannedCode"
t-on-keydown="onScanKey"
/>
<button class="o_fp_big_button" t-on-click="onScan" t-att-disabled="state.loading">
Scan
</button>
</div>
<div t-if="state.message" t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
<span t-esc="state.message"/>
</div>
<div class="o_fp_tablet_grid">
<div class="o_fp_tablet_card" t-if="state.currentTank">
<div class="o_fp_tablet_card_label">Tank</div>
<div class="o_fp_tablet_card_value">
<t t-esc="state.currentTank.name"/>
</div>
<div class="o_fp_tablet_card_meta">
State: <t t-esc="state.currentTank.state"/>
</div>
<div class="o_fp_tablet_card_meta" t-if="state.currentTank.current_bath_name">
Bath: <t t-esc="state.currentTank.current_bath_name"/>
</div>
<div class="o_fp_tablet_card_meta">
Queue: <t t-esc="state.currentTank.queue_size"/>
</div>
</div>
<div class="o_fp_tablet_card" t-if="state.currentBath">
<div class="o_fp_tablet_card_label">Bath</div>
<div class="o_fp_tablet_card_value">
<t t-esc="state.currentBath.name"/>
</div>
<div class="o_fp_tablet_card_meta">
State: <t t-esc="state.currentBath.state"/>
</div>
<div class="o_fp_tablet_card_meta" t-if="state.currentBath.tank_name">
Tank: <t t-esc="state.currentBath.tank_name"/>
</div>
</div>
<div class="o_fp_bake_window_card"
t-if="state.currentJob"
t-att-data-status="state.currentJob.state">
<div class="o_fp_tablet_card_label">Bake Job</div>
<div class="o_fp_tablet_card_value">
<t t-esc="state.currentJob.name"/>
</div>
<div class="o_fp_tablet_card_meta">
State: <t t-esc="state.currentJob.state"/>
</div>
<div class="o_fp_tablet_card_meta">
Remaining: <t t-esc="state.currentJob.time_remaining"/>
</div>
<div class="o_fp_tablet_card_actions">
<button class="o_fp_big_button"
t-if="state.currentJob.state === 'awaiting_bake'"
t-on-click="onStartBake">
Start Bake
</button>
<button class="o_fp_big_button"
t-if="state.currentJob.state === 'bake_in_progress'"
t-on-click="onEndBake">
End Bake
</button>
</div>
</div>
</div>
<div class="o_fp_tablet_queue">
<div class="o_fp_tablet_queue_title">Next Up</div>
<div t-if="!state.queueRows.length" class="text-muted">
Queue is empty.
</div>
<ul class="o_fp_tablet_queue_list" t-if="state.queueRows.length">
<t t-foreach="state.queueRows" t-as="row" t-key="row.id">
<li class="o_fp_tablet_queue_item">
<div class="o_fp_tablet_queue_label">
<strong t-esc="row.label"/>
</div>
<div class="o_fp_tablet_queue_desc text-muted">
<t t-esc="row.description"/>
</div>
</li>
</t>
</ul>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,83 @@
<?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_bake_oven_list" model="ir.ui.view">
<field name="name">fp.bake.oven.list</field>
<field name="model">fusion.plating.bake.oven</field>
<field name="arch" type="xml">
<list string="Bake Ovens">
<field name="code"/>
<field name="name"/>
<field name="facility_id"/>
<field name="work_center_id"/>
<field name="target_temp_min"/>
<field name="target_temp_max"/>
<field name="chart_recorder_ref" optional="show"/>
<field name="active" widget="boolean_toggle" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_bake_oven_form" model="ir.ui.view">
<field name="name">fp.bake.oven.form</field>
<field name="model">fusion.plating.bake.oven</field>
<field name="arch" type="xml">
<form string="Bake Oven">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Oven A"/></h1>
<div class="text-muted">
<field name="code" placeholder="OVN-A"/>
</div>
</div>
<group>
<group string="Location">
<field name="facility_id"/>
<field name="work_center_id"/>
</group>
<group string="Targets">
<field name="target_temp_min"/>
<field name="target_temp_max"/>
<field name="chart_recorder_ref"/>
<field name="active"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_bake_oven_search" model="ir.ui.view">
<field name="name">fp.bake.oven.search</field>
<field name="model">fusion.plating.bake.oven</field>
<field name="arch" type="xml">
<search string="Bake Ovens">
<field name="name"/>
<field name="code"/>
<field name="facility_id"/>
<field name="work_center_id"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Facility" name="group_facility" context="{'group_by':'facility_id'}"/>
<filter string="Work Center" name="group_wc" context="{'group_by':'work_center_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_bake_oven" model="ir.actions.act_window">
<field name="name">Bake Ovens</field>
<field name="res_model">fusion.plating.bake.oven</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_bake_oven_search"/>
</record>
</odoo>

View File

@@ -0,0 +1,172 @@
<?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_bake_window_list" model="ir.ui.view">
<field name="name">fp.bake.window.list</field>
<field name="model">fusion.plating.bake.window</field>
<field name="arch" type="xml">
<list string="Bake Windows" decoration-danger="state == 'missed_window'"
decoration-success="state == 'baked'"
decoration-info="state == 'bake_in_progress'"
decoration-muted="state == 'scrapped'">
<field name="name"/>
<field name="bath_id"/>
<field name="tank_id" optional="show"/>
<field name="part_ref"/>
<field name="lot_ref" optional="show"/>
<field name="customer_ref" optional="show"/>
<field name="quantity" optional="hide"/>
<field name="plate_exit_time"/>
<field name="window_hours"/>
<field name="bake_required_by"/>
<field name="time_remaining_display" string="Remaining"/>
<field name="oven_id" optional="show"/>
<field name="state" widget="badge"
decoration-success="state == 'baked'"
decoration-info="state == 'bake_in_progress'"
decoration-warning="state == 'awaiting_bake'"
decoration-danger="state in ('missed_window','scrapped')"/>
</list>
</field>
</record>
<record id="view_fp_bake_window_form" model="ir.ui.view">
<field name="name">fp.bake.window.form</field>
<field name="model">fusion.plating.bake.window</field>
<field name="arch" type="xml">
<form string="Bake Window">
<header>
<button name="action_start_bake" string="Start Bake" type="object"
class="oe_highlight" invisible="state != 'awaiting_bake'"/>
<button name="action_end_bake" string="End Bake" type="object"
class="oe_highlight" invisible="state != 'bake_in_progress'"/>
<button name="action_scrap" string="Scrap" type="object"
invisible="state in ('baked','scrapped')"/>
<field name="state" widget="statusbar"
statusbar_visible="awaiting_bake,bake_in_progress,baked"/>
</header>
<sheet>
<widget name="web_ribbon" title="Missed Window" bg_color="text-bg-danger"
invisible="state != 'missed_window'"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group string="Job">
<field name="bath_id"/>
<field name="tank_id" readonly="1"/>
<field name="facility_id" readonly="1"/>
<field name="part_ref"/>
<field name="lot_ref"/>
<field name="customer_ref"/>
<field name="quantity"/>
</group>
<group string="Window">
<field name="plate_exit_time"/>
<field name="window_hours"/>
<field name="bake_required_by" readonly="1"/>
<field name="time_remaining_display" readonly="1"/>
</group>
</group>
<group>
<group string="Bake">
<field name="oven_id"/>
<field name="operator_id"/>
<field name="bake_start_time" readonly="1"/>
<field name="bake_end_time" readonly="1"/>
</group>
<group string="Result">
<field name="bake_temp"/>
<field name="bake_duration_hours" readonly="1"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_bake_window_kanban" model="ir.ui.view">
<field name="name">fp.bake.window.kanban</field>
<field name="model">fusion.plating.bake.window</field>
<field name="arch" type="xml">
<kanban default_group_by="state" class="o_fp_bake_window_kanban">
<field name="id"/>
<field name="name"/>
<field name="part_ref"/>
<field name="lot_ref"/>
<field name="customer_ref"/>
<field name="bake_required_by"/>
<field name="time_remaining_display"/>
<field name="state"/>
<field name="status_color"/>
<templates>
<t t-name="card">
<div class="o_fp_card o_fp_bake_window_card"
t-att-data-status="record.state.raw_value">
<div class="d-flex align-items-start justify-content-between">
<div>
<strong class="o_fp_card_title"><field name="name"/></strong>
<div class="small text-muted"><field name="part_ref"/></div>
</div>
<span class="o_fp_health_dot" t-att-data-status="record.state.raw_value"/>
</div>
<div class="mt-2 small">
<div><i class="fa fa-clock-o me-1 text-muted"/><field name="time_remaining_display"/></div>
<div class="text-muted">Lot <field name="lot_ref"/></div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_fp_bake_window_search" model="ir.ui.view">
<field name="name">fp.bake.window.search</field>
<field name="model">fusion.plating.bake.window</field>
<field name="arch" type="xml">
<search string="Bake Windows">
<field name="name"/>
<field name="part_ref"/>
<field name="lot_ref"/>
<field name="customer_ref"/>
<field name="bath_id"/>
<field name="tank_id"/>
<field name="oven_id"/>
<separator/>
<filter string="Awaiting Bake" name="awaiting" domain="[('state','=','awaiting_bake')]"/>
<filter string="In Progress" name="in_progress" domain="[('state','=','bake_in_progress')]"/>
<filter string="Baked" name="baked" domain="[('state','=','baked')]"/>
<filter string="Missed Window" name="missed" domain="[('state','=','missed_window')]"/>
<filter string="Scrapped" name="scrapped" domain="[('state','=','scrapped')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="State" name="group_state" context="{'group_by':'state'}"/>
<filter string="Tank" name="group_tank" context="{'group_by':'tank_id'}"/>
<filter string="Oven" name="group_oven" context="{'group_by':'oven_id'}"/>
<filter string="Customer" name="group_customer" context="{'group_by':'customer_ref'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_bake_window" model="ir.actions.act_window">
<field name="name">Bake Windows</field>
<field name="res_model">fusion.plating.bake.window</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_fp_bake_window_search"/>
<field name="context">{'search_default_awaiting': 1, 'search_default_in_progress': 1}</field>
</record>
</odoo>

View File

@@ -0,0 +1,147 @@
<?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_first_piece_gate_list" model="ir.ui.view">
<field name="name">fp.first.piece.gate.list</field>
<field name="model">fusion.plating.first.piece.gate</field>
<field name="arch" type="xml">
<list string="First-Piece Gates"
decoration-success="result == 'pass' and rest_of_lot_released"
decoration-warning="result == 'pass' and not rest_of_lot_released"
decoration-danger="result == 'fail'"
decoration-muted="result == 'pending'">
<field name="name"/>
<field name="bath_id"/>
<field name="part_ref"/>
<field name="customer_ref" optional="show"/>
<field name="routing_first_run" widget="boolean_toggle" optional="hide"/>
<field name="first_piece_produced" optional="show"/>
<field name="first_piece_inspected" optional="show"/>
<field name="inspector_id" optional="show"/>
<field name="result" widget="badge"
decoration-success="result == 'pass'"
decoration-danger="result == 'fail'"
decoration-muted="result == 'pending'"/>
<field name="rest_of_lot_released" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fp_first_piece_gate_form" model="ir.ui.view">
<field name="name">fp.first.piece.gate.form</field>
<field name="model">fusion.plating.first.piece.gate</field>
<field name="arch" type="xml">
<form string="First-Piece Gate">
<header>
<button name="action_mark_pass" string="Mark Pass" type="object"
class="oe_highlight" invisible="result != 'pending'"/>
<button name="action_mark_fail" string="Mark Fail" type="object"
invisible="result != 'pending'"/>
<button name="action_release_lot" string="Release Lot" type="object"
invisible="result != 'pass' or rest_of_lot_released"/>
<field name="result" widget="statusbar"
statusbar_visible="pending,pass"/>
</header>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group string="Job">
<field name="bath_id"/>
<field name="facility_id" readonly="1"/>
<field name="part_ref"/>
<field name="customer_ref"/>
<field name="routing_first_run"/>
</group>
<group string="Inspection">
<field name="first_piece_produced"/>
<field name="first_piece_inspected"/>
<field name="inspector_id"/>
<field name="rest_of_lot_released"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_first_piece_gate_kanban" model="ir.ui.view">
<field name="name">fp.first.piece.gate.kanban</field>
<field name="model">fusion.plating.first.piece.gate</field>
<field name="arch" type="xml">
<kanban default_group_by="result">
<field name="id"/>
<field name="name"/>
<field name="part_ref"/>
<field name="customer_ref"/>
<field name="result"/>
<field name="rest_of_lot_released"/>
<field name="status_color"/>
<templates>
<t t-name="card">
<div class="o_fp_card">
<div class="d-flex align-items-start justify-content-between">
<div>
<strong class="o_fp_card_title"><field name="name"/></strong>
<div class="small text-muted"><field name="part_ref"/></div>
</div>
</div>
<div class="mt-2 small text-muted">
<field name="customer_ref"/>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_fp_first_piece_gate_search" model="ir.ui.view">
<field name="name">fp.first.piece.gate.search</field>
<field name="model">fusion.plating.first.piece.gate</field>
<field name="arch" type="xml">
<search string="First-Piece Gates">
<field name="name"/>
<field name="part_ref"/>
<field name="customer_ref"/>
<field name="bath_id"/>
<field name="inspector_id"/>
<separator/>
<filter string="Pending" name="pending" domain="[('result','=','pending')]"/>
<filter string="Passed" name="passed" domain="[('result','=','pass')]"/>
<filter string="Failed" name="failed" domain="[('result','=','fail')]"/>
<separator/>
<filter string="Lot Released" name="released" domain="[('rest_of_lot_released','=',True)]"/>
<filter string="Lot On Hold" name="on_hold"
domain="[('result','=','pass'),('rest_of_lot_released','=',False)]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Result" name="group_result" context="{'group_by':'result'}"/>
<filter string="Customer" name="group_customer" context="{'group_by':'customer_ref'}"/>
<filter string="Inspector" name="group_inspector" context="{'group_by':'inspector_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_first_piece_gate" model="ir.actions.act_window">
<field name="name">First-Piece Gates</field>
<field name="res_model">fusion.plating.first.piece.gate</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_fp_first_piece_gate_search"/>
<field name="context">{'search_default_pending': 1}</field>
</record>
</odoo>

View File

@@ -0,0 +1,55 @@
<?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>
<!-- ===== SHOP FLOOR (top-level under Plating) ===== -->
<menuitem id="menu_fp_shopfloor"
name="Shop Floor"
parent="fusion_plating.menu_fp_root"
sequence="12"
groups="fusion_plating.group_fusion_plating_operator"/>
<menuitem id="menu_fp_shopfloor_plant_overview"
name="Plant Overview"
parent="menu_fp_shopfloor"
action="action_fp_plant_overview"
sequence="5"/>
<menuitem id="menu_fp_shopfloor_tablet"
name="Tablet Station"
parent="menu_fp_shopfloor"
action="action_fp_shopfloor_tablet"
sequence="10"/>
<menuitem id="menu_fp_shopfloor_bake_windows"
name="Bake Windows"
parent="menu_fp_shopfloor"
action="action_fp_bake_window"
sequence="20"/>
<menuitem id="menu_fp_shopfloor_first_piece"
name="First-Piece Gates"
parent="menu_fp_shopfloor"
action="action_fp_first_piece_gate"
sequence="30"/>
<!-- ===== Configuration (under existing core Configuration menu) ===== -->
<menuitem id="menu_fp_shopfloor_stations_cfg"
name="Shopfloor Stations"
parent="fusion_plating.menu_fp_config"
action="action_fp_shopfloor_station"
sequence="60"
groups="fusion_plating.group_fusion_plating_manager"/>
<menuitem id="menu_fp_shopfloor_ovens_cfg"
name="Bake Ovens"
parent="fusion_plating.menu_fp_config"
action="action_fp_bake_oven"
sequence="65"
groups="fusion_plating.group_fusion_plating_manager"/>
</odoo>

View File

@@ -0,0 +1,25 @@
<?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>
<!-- ================================================================== -->
<!-- Client action — Plant Overview Dashboard -->
<!-- ================================================================== -->
<record id="action_fp_plant_overview" model="ir.actions.client">
<field name="name">Plant Overview</field>
<field name="tag">fp_plant_overview</field>
</record>
<!-- ================================================================== -->
<!-- Client action — Process Tree -->
<!-- ================================================================== -->
<record id="action_fp_process_tree" model="ir.actions.client">
<field name="name">Process Tree</field>
<field name="tag">fp_process_tree</field>
</record>
</odoo>

View File

@@ -0,0 +1,100 @@
<?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_shopfloor_station_list" model="ir.ui.view">
<field name="name">fp.shopfloor.station.list</field>
<field name="model">fusion.plating.shopfloor.station</field>
<field name="arch" type="xml">
<list string="Shopfloor Stations">
<field name="code"/>
<field name="name"/>
<field name="facility_id"/>
<field name="work_center_id"/>
<field name="station_type"/>
<field name="current_operator_id" optional="show"/>
<field name="last_ping" optional="show"/>
<field name="active" widget="boolean_toggle" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_shopfloor_station_form" model="ir.ui.view">
<field name="name">fp.shopfloor.station.form</field>
<field name="model">fusion.plating.shopfloor.station</field>
<field name="arch" type="xml">
<form string="Shopfloor Station">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Tablet — EN Line"/></h1>
<div class="text-muted">
<field name="code" placeholder="TAB-EN-01"/>
</div>
</div>
<group>
<group string="Location">
<field name="facility_id"/>
<field name="work_center_id"/>
<field name="station_type"/>
</group>
<group string="Identity / State">
<field name="qr_code"/>
<field name="current_operator_id"/>
<field name="last_ping" readonly="1"/>
<field name="active"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_shopfloor_station_search" model="ir.ui.view">
<field name="name">fp.shopfloor.station.search</field>
<field name="model">fusion.plating.shopfloor.station</field>
<field name="arch" type="xml">
<search string="Shopfloor Stations">
<field name="name"/>
<field name="code"/>
<field name="qr_code"/>
<field name="facility_id"/>
<field name="work_center_id"/>
<separator/>
<filter string="Tablets" name="tablets" domain="[('station_type','=','tablet')]"/>
<filter string="Kiosks" name="kiosks" domain="[('station_type','=','kiosk')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Facility" name="group_facility" context="{'group_by':'facility_id'}"/>
<filter string="Work Center" name="group_wc" context="{'group_by':'work_center_id'}"/>
<filter string="Type" name="group_type" context="{'group_by':'station_type'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_shopfloor_station" model="ir.actions.act_window">
<field name="name">Shopfloor Stations</field>
<field name="res_model">fusion.plating.shopfloor.station</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_shopfloor_station_search"/>
</record>
<!-- ================================================================== -->
<!-- Client action that launches the OWL tablet component -->
<!-- ================================================================== -->
<record id="action_fp_shopfloor_tablet" model="ir.actions.client">
<field name="name">Tablet Station</field>
<field name="tag">fp_shopfloor_tablet</field>
</record>
</odoo>