folder rename
This commit is contained in:
82
fusion_plating/fusion_plating_shopfloor/README.md
Normal file
82
fusion_plating/fusion_plating_shopfloor/README.md
Normal 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
|
||||
```
|
||||
7
fusion_plating/fusion_plating_shopfloor/__init__.py
Normal file
7
fusion_plating/fusion_plating_shopfloor/__init__.py
Normal 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
|
||||
74
fusion_plating/fusion_plating_shopfloor/__manifest__.py
Normal file
74
fusion_plating/fusion_plating_shopfloor/__manifest__.py
Normal 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,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import shopfloor_controller
|
||||
Binary file not shown.
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
10
fusion_plating/fusion_plating_shopfloor/models/__init__.py
Normal file
10
fusion_plating/fusion_plating_shopfloor/models/__init__.py
Normal 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
|
||||
@@ -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.',
|
||||
),
|
||||
]
|
||||
288
fusion_plating/fusion_plating_shopfloor/models/fp_bake_window.py
Normal file
288
fusion_plating/fusion_plating_shopfloor/models/fp_bake_window.py
Normal 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
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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)])
|
||||
@@ -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
|
||||
54
fusion_plating/fusion_plating_shopfloor/models/fp_tank.py
Normal file
54
fusion_plating/fusion_plating_shopfloor/models/fp_tank.py
Normal 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',
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
55
fusion_plating/fusion_plating_shopfloor/views/fp_menu.xml
Normal file
55
fusion_plating/fusion_plating_shopfloor/views/fp_menu.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user