refactor(shopfloor,jobs): consolidate operator UI into shopfloor

Removes the parallel OWL/controller stack I built in
fusion_plating_jobs (job_process_tree, job_plant_overview,
job_manager_dashboard, job_tablet, job_*.scss, plus parallel
controllers and action XML files). Refactors the existing
fusion_plating_shopfloor components in place to bind to
fp.job / fp.job.step instead of mrp.production / mrp.workorder.

End state:
- ONE operator UI module (shopfloor) instead of two parallel ones
- Existing token system (_fp_shopfloor_tokens.scss) reused as
  designed - no duplicate jobs tokens
- Existing /fp/shopfloor/* RPC URLs preserved (no integration
  breakage); workorder_id kwargs accepted as legacy aliases for
  step_id / job_id so older tablet clients keep working
- Existing visual designs preserved - only the data layer
  underneath changed
- Process Tree button on fp.job form now points at
  fusion_plating_shopfloor's fp_process_tree client action
- Bake Windows / First-Piece Gates / Bake Oven / Operator Queue
  models stay where they were
- legacy_menu_hide.xml trimmed: only the bridge_mrp Production
  Priorities entry remains; the 3 shopfloor menus (Manager Desk,
  Plant Overview, Tablet Station) are now visible (the canonical
  native consoles)

Manifests:
- fusion_plating_jobs 19.0.3.1.0 -> 19.0.4.0.0 (consolidation bump,
  no more bundled JS/SCSS, only job_scan controller retained)
- fusion_plating_shopfloor 19.0.14.4.0 -> 19.0.15.0.0 (asset bundle
  cache-bust + significant controller refactor)

Tests pass on entech: 0 failed, 0 errors of 41 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-25 06:45:15 -04:00
parent 667654bd4e
commit 5df7d5e6cf
36 changed files with 891 additions and 5128 deletions

View File

@@ -2,7 +2,20 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""JSON-RPC endpoints for the Manager Dashboard (client action)."""
"""JSON-RPC endpoints for the Manager Dashboard (client action).
Native fp.job / fp.job.step edition (consolidated 2026-04-24). All
endpoint URLs are preserved (`/fp/manager/*`); the underlying data
layer is now fp.job + fp.job.step.
Manager Desk ergonomics:
- Column 1 ("Needs a Worker") = jobs that have at least one step
missing the bits a manager has to set before an operator can tap
Start (worker, work centre, kind-specific equipment).
- Column 2 ("In Progress") = jobs whose steps are release-ready or
actively running.
- Column 3 ("Team") = operators with their open / in-progress counts.
"""
import logging
@@ -15,26 +28,45 @@ from odoo.http import request
_logger = logging.getLogger(__name__)
class FpManagerDashboardController(http.Controller):
"""Manager-level view: unassigned jobs, in-progress jobs, team workload.
# --- helpers -----------------------------------------------------------------
All endpoints require the user to be a manager or above. The UI locks
the menu behind group_fusion_plating_manager.
"""
_NEG_JOB_STATES = ('done', 'cancelled')
_ACTIVE_JOB_STATES = ('confirmed', 'in_progress', 'on_hold')
# A step needs an operator and (for wet/bake/mask) the right equipment
# before the operator can tap Start. Mirrors the legacy
# x_fc_is_release_ready compute on mrp.workorder.
def _step_release_readiness(step):
"""Return (is_release_ready, missing_str) for a fp.job.step."""
missing = []
if not step.assigned_user_id:
missing.append('worker')
if not step.work_centre_id:
missing.append('work centre')
if step.kind == 'wet':
if not step.bath_id:
missing.append('bath')
if not step.tank_id:
missing.append('tank')
elif step.kind == 'rack':
if not step.rack_id:
missing.append('rack')
return (not missing, ', '.join(missing))
def _priority_int(priority):
"""fp.job.priority → int 0/1/2 (parallel of legacy x_fc_priority)."""
return {'rush': 2, 'high': 1, 'normal': 0, 'low': 0}.get(priority, 0)
class FpManagerDashboardController(http.Controller):
"""Manager-level view: unassigned jobs, in-progress jobs, team workload."""
# ------------------------------------------------------------------
# Overview snapshot — used on initial load + 30s auto-refresh
# Overview snapshot — used on initial load + 8s auto-refresh
# ------------------------------------------------------------------
@http.route('/fp/manager/overview', type='jsonrpc', auth='user')
def overview(self, facility_id=None, known_hash=None):
"""Build the manager dashboard payload.
`known_hash`: if the client sends back the hash of its last
overview, we compare and return `{'unchanged': True}` when
nothing has moved. Keeps the UI flicker-free between polls
while still catching every shop-floor change within a few
seconds.
"""
try:
return self._overview_payload(facility_id, known_hash)
except Exception as exc: # noqa: BLE001
@@ -43,187 +75,122 @@ class FpManagerDashboardController(http.Controller):
def _overview_payload(self, facility_id, known_hash):
env = request.env
MrpWO = env.get('mrp.workorder')
Production = env.get('mrp.production')
if MrpWO is None or Production is None:
return {
'ok': True,
'kpis': {'unassigned_wos': 0, 'active_wos': 0,
'ready_to_ship_mos': 0, 'pending_accept_sos': 0},
'unassigned': [], 'active': [], 'team': [],
'operators': [], 'tanks': [],
'user_name': env.user.name,
'mrp_missing': True,
'payload_hash': '',
}
# The assignment field lives in fusion_plating_bridge_mrp. If it's
# missing, the dashboard still renders but the worker pickers are
# effectively read-only.
has_assign = 'x_fc_assigned_user_id' in MrpWO._fields
Job = env['fp.job']
Step = env['fp.job.step']
# ---- Column 1: Unassigned ("Setup Pending") --------------------
# A WO stays here until the manager has set EVERY field
# button_start would block on (operator + per-kind equipment).
# Without this, picking a worker would auto-jump the row to
# "In Progress" before bath/tank/oven/rack/material are set.
# We compute release-readiness in Python after the SQL search
# because x_fc_is_release_ready is a non-stored compute.
ACTIVE_NEG_STATES = ('done', 'cancel')
domain_active_states = [('state', 'not in', ACTIVE_NEG_STATES)]
# Pull in-flight jobs (confirmed / in_progress / on_hold)
domain = [('state', 'in', _ACTIVE_JOB_STATES)]
if facility_id:
domain_active_states.append(
('workcenter_id.x_fc_facility_id', '=', int(facility_id)))
all_active_wos = MrpWO.search(domain_active_states, order='sequence, id')
# Split: not-release-ready → Unassigned/Setup column; rest → In Progress
if 'x_fc_is_release_ready' in MrpWO._fields:
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_is_release_ready)
elif has_assign:
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_assigned_user_id)
else:
unassigned_wos = all_active_wos
domain.append(('facility_id', '=', int(facility_id)))
jobs = Job.search(domain, order='priority desc, date_deadline asc, id desc')
# Roll up to MO level
def _group_by_mo(wos):
groups = {}
for wo in wos:
mo_id = wo.production_id.id
groups.setdefault(mo_id, []).append(wo)
return groups
# Compute release-readiness per step in a single pass
all_steps = jobs.mapped('step_ids').filtered(
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
)
readiness_by_step = {}
for step in all_steps:
ready, missing = _step_release_readiness(step)
readiness_by_step[step.id] = (ready, missing)
# Bucket jobs: "needs a worker" vs "in progress".
# A job lands in unassigned iff at least one of its open steps
# is NOT release-ready. Otherwise it goes to in_progress.
unassigned_jobs = jobs.browse([])
active_jobs = jobs.browse([])
for job in jobs:
open_steps = job.step_ids.filtered(
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
)
if not open_steps:
continue
not_ready = any(not readiness_by_step.get(s.id, (False, ''))[0]
for s in open_steps)
if not_ready:
unassigned_jobs |= job
else:
active_jobs |= job
def _job_card(job, only_open=True):
partner = job.partner_id
steps_iter = job.step_ids
if only_open:
steps_iter = steps_iter.filtered(
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
)
steps_iter = steps_iter.sorted('sequence')
wo_rows = []
for s in steps_iter:
ready, missing = readiness_by_step.get(s.id, (False, ''))
wo_rows.append({
'id': s.id,
'name': s.name or '',
'workcenter': s.work_centre_id.name or '',
'state': s.state,
'sequence': s.sequence or 0,
'duration_expected': s.duration_expected or 0,
'bath': s.bath_id.name or '',
'tank': s.tank_id.name or '',
'tank_id': s.tank_id.id if s.tank_id else False,
'priority': str(_priority_int(job.priority)),
'assigned_user_id': s.assigned_user_id.id or False,
'assigned_user_name': s.assigned_user_id.name or '',
'role_id': False,
'role_name': '',
'wo_kind': s.kind or 'other',
'wo_kind_label': dict(s._fields['kind'].selection).get(
s.kind, '',
) if s.kind else '',
'is_release_ready': ready,
'missing_for_release': missing,
'oven': '',
'rack': s.rack_id.name or '',
'masking_material': '',
})
def _mo_card(mo, wos):
so_name = mo.origin or ''
partner = mo.x_fc_portal_job_id.partner_id if mo.x_fc_portal_job_id else None
return {
'mo_id': mo.id,
'mo_name': mo.name,
'so_name': so_name,
'mo_id': job.id,
'mo_name': job.name or '',
'so_name': job.origin or '',
'customer': partner.name if partner else '',
'product': mo.product_id.display_name if mo.product_id else '',
'qty_total': int(mo.product_qty or 0),
'date_planned': fp_format(request.env, mo.date_start, fmt='%Y-%m-%d'),
'recipe': mo.x_fc_recipe_id.name if mo.x_fc_recipe_id else '',
'priority_any': max(
[int(w.x_fc_priority or '0') for w in wos] + [0]
'product': job.product_id.display_name if job.product_id else '',
'qty_total': int(job.qty or 0),
'date_planned': fp_format(
request.env, job.date_planned_start or job.date_deadline,
fmt='%Y-%m-%d',
),
'current_location': mo.x_fc_current_location or '',
'wos': [
{
'id': w.id,
'name': w.display_name or w.name,
'workcenter': w.workcenter_id.name or '',
'state': w.state,
'sequence': w.sequence or 0,
'duration_expected': w.duration_expected or 0,
'bath': w.x_fc_bath_id.name or '',
'tank': w.x_fc_tank_id.name or '',
'tank_id': w.x_fc_tank_id.id if w.x_fc_tank_id else False,
'priority': w.x_fc_priority or '0',
'assigned_user_id': (
w.x_fc_assigned_user_id.id
if w.x_fc_assigned_user_id else False
),
'assigned_user_name': (
w.x_fc_assigned_user_id.name or ''
if w.x_fc_assigned_user_id else ''
),
# Role required by this step. Used by the
# Manager Desk worker dropdown to surface
# qualified operators first.
'role_id': (
w.x_fc_work_role_id.id
if w.x_fc_work_role_id else False
),
'role_name': (
w.x_fc_work_role_id.name or ''
if w.x_fc_work_role_id else ''
),
# WO kind classification + what's still missing
# before the WO can be released to the operator.
# Manager Desk uses these to render the kind
# badge and the "needs: bath, tank" hint chips.
'wo_kind': (
w.x_fc_wo_kind
if 'x_fc_wo_kind' in w._fields else 'other'
),
'wo_kind_label': dict(
w._fields['x_fc_wo_kind'].selection
).get(w.x_fc_wo_kind, '') if 'x_fc_wo_kind' in w._fields else '',
'is_release_ready': (
w.x_fc_is_release_ready
if 'x_fc_is_release_ready' in w._fields else False
),
'missing_for_release': (
w.x_fc_missing_for_release or ''
if 'x_fc_missing_for_release' in w._fields else ''
),
# Surface oven, rack, masking material so the
# manager can see at a glance what's set.
'oven': (
w.x_fc_oven_id.name or ''
if 'x_fc_oven_id' in w._fields and w.x_fc_oven_id
else ''
),
'rack': (
w.x_fc_rack_id.name or ''
if 'x_fc_rack_id' in w._fields and w.x_fc_rack_id
else ''
),
'masking_material': (
dict(w._fields['x_fc_masking_material'].selection).get(
w.x_fc_masking_material, ''
) if 'x_fc_masking_material' in w._fields and w.x_fc_masking_material
else ''
),
}
for w in wos
],
'recipe': job.recipe_id.name if job.recipe_id else '',
'priority_any': _priority_int(job.priority),
'current_location': job.current_location or '',
'wos': wo_rows,
}
unassigned_cards = []
for mo_id, wos in _group_by_mo(unassigned_wos).items():
mo = Production.browse(mo_id)
unassigned_cards.append(_mo_card(mo, wos))
unassigned_cards = [_job_card(j) for j in unassigned_jobs]
active_cards = [_job_card(j) for j in active_jobs]
# ---- Column 2: In Progress -------------------------------------
# Release-ready WOs (everything the manager needed to set is
# filled in) — operator can tap Start on the iPad.
if 'x_fc_is_release_ready' in MrpWO._fields:
active_wos = all_active_wos.filtered(lambda w: w.x_fc_is_release_ready)
elif has_assign:
active_wos = all_active_wos.filtered(lambda w: w.x_fc_assigned_user_id)
else:
active_wos = MrpWO # empty
active_cards = []
for mo_id, wos in _group_by_mo(active_wos).items():
mo = Production.browse(mo_id)
active_cards.append(_mo_card(mo, wos))
# ---- Column 3: Team (operators + their current load) -----------
# ---- Column 3: Team --------------------------------------------
operator_group = env.ref(
'fusion_plating.group_fusion_plating_operator', raise_if_not_found=False,
)
team = []
if operator_group and has_assign:
if operator_group:
for user in operator_group.user_ids.sorted('name'):
open_wos = MrpWO.search([
('x_fc_assigned_user_id', '=', user.id),
('state', 'not in', ACTIVE_NEG_STATES),
open_steps = Step.search([
('assigned_user_id', '=', user.id),
('state', 'in', ('ready', 'in_progress', 'paused')),
])
team.append({
'user_id': user.id,
'name': user.name,
'open_count': len(open_wos),
'open_count': len(open_steps),
'in_progress_count': len(
open_wos.filtered(lambda w: w.state == 'progress')
open_steps.filtered(lambda s: s.state == 'in_progress'),
),
'avatar_url': f'/web/image/res.users/{user.id}/avatar_128',
})
# ---- Pickers: operators (with presence + role data) -----------
# We send richer operator records so the Manager Desk dropdown can
# group qualified-and-present at the top, then lead hands, then
# off-shift workers (greyed). Without this the manager has to
# remember who's clocked in and who can do what.
# ---- Operators picker (with presence + role data) --------------
clocked_in_user_ids = (
env['hr.employee']._fp_clocked_in_user_ids()
if 'hr.employee' in env and hasattr(
@@ -237,7 +204,11 @@ class FpManagerDashboardController(http.Controller):
operators = []
for u in operator_users:
emp = u.employee_id
role_ids = emp.x_fc_work_role_ids.ids if emp else []
role_ids = (
emp.x_fc_work_role_ids.ids
if emp and 'x_fc_work_role_ids' in emp._fields
else []
)
lead_role_ids = (
emp.x_fc_lead_hand_role_ids.ids
if emp and 'x_fc_lead_hand_role_ids' in emp._fields
@@ -250,12 +221,12 @@ class FpManagerDashboardController(http.Controller):
'role_ids': role_ids,
'lead_hand_role_ids': lead_role_ids,
})
# Headline counts so the manager sees at-a-glance who's on shift.
present_count = sum(1 for o in operators if o['is_clocked_in'])
presence = {
'clocked_in': present_count,
'total': len(operators),
}
Tank = env.get('fusion.plating.tank')
tanks = [
{
@@ -267,36 +238,32 @@ class FpManagerDashboardController(http.Controller):
for t in (Tank.search([]) if Tank is not None else [])
]
# KPI summary — every query must use STORED fields only, otherwise
# Odoo raises "Cannot convert … to SQL because it is not stored".
# x_fc_workflow_stage is computed (non-stored); replicate the
# "awaiting assignment" stage directly via its stored antecedents.
# ---- KPI summary ----------------------------------------------
SO = env['sale.order']
so_fields = SO._fields
if ('x_fc_receiving_status' in so_fields
and 'x_fc_assigned_manager_id' in so_fields):
pending_accept_domain = [
pending_accept_sos = SO.search_count([
('state', '=', 'sale'),
('x_fc_receiving_status', '=', 'inspected'),
('x_fc_assigned_manager_id', '=', False),
]
pending_accept_sos = SO.search_count(pending_accept_domain)
])
else:
pending_accept_sos = 0
# KPI counts derived from the in-memory split we already have —
# don't re-query (the release-ready filter is a Python compute,
# not a stored column, so SQL search_count can't see it).
# Ready-to-ship: jobs that are done but the portal job hasn't
# been marked ready_to_ship yet (or no portal mirror at all).
ready_to_ship_jobs = Job.search_count([('state', '=', 'done')])
kpis = {
'unassigned_wos': len(unassigned_wos),
'active_wos': len(active_wos),
'ready_to_ship_mos': Production.search_count([
('state', '=', 'done'),
]) if 'x_fc_portal_job_id' not in Production._fields
else Production.search_count([
('state', '=', 'done'),
('x_fc_portal_job_id.state', '=', 'ready_to_ship'),
]),
'unassigned_wos': len(all_steps.filtered(
lambda s: not readiness_by_step.get(s.id, (False, ''))[0],
)),
'active_wos': len(all_steps.filtered(
lambda s: readiness_by_step.get(s.id, (False, ''))[0]
and s.state in ('ready', 'in_progress'),
)),
'ready_to_ship_mos': ready_to_ship_jobs,
'pending_accept_sos': pending_accept_sos,
}
@@ -325,45 +292,53 @@ class FpManagerDashboardController(http.Controller):
return payload
# ------------------------------------------------------------------
# Assign a worker to a WO
# Assign a worker to a step
# ------------------------------------------------------------------
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
def assign_worker(self, workorder_id, user_id):
wo = request.env['mrp.workorder'].browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': 'Work order not found.'}
wo.x_fc_assigned_user_id = int(user_id) if user_id else False
wo.message_post(
body=Markup('Worker assigned: <b>%s</b>') % (wo.x_fc_assigned_user_id.name or 'Unassigned'),
"""`workorder_id` is the canonical kwarg name from the legacy
XML; it now resolves to a fp.job.step id."""
step = request.env['fp.job.step'].browse(int(workorder_id))
if not step.exists():
return {'ok': False, 'error': 'Step not found.'}
step.assigned_user_id = int(user_id) if user_id else False
step.message_post(
body=Markup('Worker assigned: <b>%s</b>') % (
step.assigned_user_id.name or 'Unassigned'
),
)
return {'ok': True, 'user_name': wo.x_fc_assigned_user_id.name or ''}
return {'ok': True, 'user_name': step.assigned_user_id.name or ''}
# ------------------------------------------------------------------
# Reassign or swap tank on a WO
# Reassign or swap tank on a step
# ------------------------------------------------------------------
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
def assign_tank(self, workorder_id, tank_id):
wo = request.env['mrp.workorder'].browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': 'Work order not found.'}
wo.x_fc_tank_id = int(tank_id) if tank_id else False
wo.message_post(
body=Markup('Tank assigned: <b>%s</b>') % (wo.x_fc_tank_id.name or 'Unassigned'),
step = request.env['fp.job.step'].browse(int(workorder_id))
if not step.exists():
return {'ok': False, 'error': 'Step not found.'}
step.tank_id = int(tank_id) if tank_id else False
step.message_post(
body=Markup('Tank assigned: <b>%s</b>') % (
step.tank_id.name or 'Unassigned'
),
)
return {'ok': True, 'tank_name': wo.x_fc_tank_id.name or ''}
return {'ok': True, 'tank_name': step.tank_id.name or ''}
# ------------------------------------------------------------------
# Manager takes over a WO (no-show coverage)
# Manager takes over a step (no-show coverage)
# ------------------------------------------------------------------
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
def take_over(self, workorder_id):
wo = request.env['mrp.workorder'].browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': 'Work order not found.'}
step = request.env['fp.job.step'].browse(int(workorder_id))
if not step.exists():
return {'ok': False, 'error': 'Step not found.'}
user = request.env.user
previous = wo.x_fc_assigned_user_id.name or ''
wo.x_fc_assigned_user_id = user.id
wo.message_post(
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (user.name, previous),
previous = step.assigned_user_id.name or ''
step.assigned_user_id = user.id
step.message_post(
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (
user.name, previous,
),
)
return {'ok': True, 'user_name': user.name}