feat(shopfloor): Manager Desk — assign workers, swap tanks, take over (CHUNK 2/4)

New client action "Manager Desk" under Shop Floor menu (manager-only).
Three-column dashboard designed for the shop manager's daily job:

  Column 1 — Needs a Worker
    MOs with active WOs missing user assignment. Each card expands to
    show per-WO rows with:
      - Assign Worker dropdown (pulls from group_fusion_plating_operator)
      - Tank swap dropdown (all tanks with current bath)
      - Take Over (claims for the manager in one click)
      - Open (jump to WO form)

  Column 2 — In Progress
    MOs with workers actively running WOs. Shows who's on each step,
    lets manager reassign or take over if someone steps away.

  Column 3 — Team
    Avatar grid of operators with live queue + in-progress counts.
    Click to drill into that operator's full WO list.

KPI strip on top: Unassigned WOs, In Progress, Ready to Ship, Awaiting
Assignment SOs.

Quick / Detailed view toggle — Detailed auto-expands every card body.

New field mrp.workorder.x_fc_assigned_user_id (indexed, tracked) —
the worker currently owning this step. Will be the pivot the Tablet
Station filters on in Chunk 4.

Three new endpoints:
  /fp/manager/overview       — dashboard snapshot (30s auto-refresh)
  /fp/manager/assign_worker  — set user on a WO
  /fp/manager/assign_tank    — swap tank on a WO
  /fp/manager/take_over      — manager claims the WO (no-show coverage)

Controller is graceful when mrp module isn't installed (empty overview,
no crash) and when the bridge_mrp assignment field isn't present (falls
back to showing all active WOs as "unassigned").

Verified: 4 WOs assigned across 2 users, overview queries return the
expected counts, res.groups.user_ids (Odoo 19 API, not deprecated .users).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-17 20:03:01 -04:00
parent 095d9f487c
commit 1c6a460ca1
8 changed files with 856 additions and 0 deletions

View File

@@ -3,3 +3,4 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from . import shopfloor_controller
from . import manager_controller

View File

@@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
# 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)."""
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class FpManagerDashboardController(http.Controller):
"""Manager-level view: unassigned jobs, in-progress jobs, team workload.
All endpoints require the user to be a manager or above. The UI locks
the menu behind group_fusion_plating_manager.
"""
# ------------------------------------------------------------------
# Overview snapshot — used on initial load + 30s auto-refresh
# ------------------------------------------------------------------
@http.route('/fp/manager/overview', type='jsonrpc', auth='user')
def overview(self, facility_id=None):
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,
}
# 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
# ---- Column 1: Unassigned (no worker on an active WO) ----------
domain_unassigned = [
('state', 'in', ('pending', 'waiting', 'ready', 'progress')),
]
if has_assign:
domain_unassigned.append(('x_fc_assigned_user_id', '=', False))
else:
# Without the assignment field, treat ALL active WOs as unassigned
pass
if facility_id:
domain_unassigned.append(
('workcenter_id.x_fc_facility_id', '=', int(facility_id)))
unassigned_wos = MrpWO.search(domain_unassigned, order='sequence, id')
# 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
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,
'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': str(mo.date_start)[:10] if mo.date_start else '',
'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]
),
'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 '',
'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 ''
),
}
for w in wos
],
}
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))
# ---- Column 2: In Progress (MOs with at least one active WO) ----
domain_active = [
('state', 'in', ('ready', 'progress')),
]
if has_assign:
domain_active.append(('x_fc_assigned_user_id', '!=', False))
if facility_id:
domain_active.append(
('workcenter_id.x_fc_facility_id', '=', int(facility_id)))
active_wos = MrpWO.search(domain_active, order='sequence, id')
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) -----------
operator_group = env.ref(
'fusion_plating.group_fusion_plating_operator', raise_if_not_found=False,
)
team = []
if operator_group and has_assign:
for user in operator_group.user_ids.sorted('name'):
open_wos = MrpWO.search([
('x_fc_assigned_user_id', '=', user.id),
('state', 'in', ('ready', 'progress', 'waiting')),
])
team.append({
'user_id': user.id,
'name': user.name,
'open_count': len(open_wos),
'in_progress_count': len(
open_wos.filtered(lambda w: w.state == 'progress')
),
'avatar_url': f'/web/image/res.users/{user.id}/avatar_128',
})
# ---- Pickers: operators, tanks, work centres ------------------
operators = [
{'id': u.id, 'name': u.name}
for u in (operator_group.user_ids if operator_group else env['res.users'])
]
Tank = env.get('fusion.plating.tank')
tanks = [
{
'id': t.id,
'name': t.name,
'state': t.state,
'current_bath': t.current_bath_id.name or '',
}
for t in (Tank.search([]) if Tank is not None else [])
]
# KPI summary
kpis = {
'unassigned_wos': MrpWO.search_count(domain_unassigned),
'active_wos': MrpWO.search_count(domain_active),
'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'),
]),
'pending_accept_sos': env['sale.order'].search_count(
[('x_fc_workflow_stage', '=', 'assign_work')]
) if 'x_fc_workflow_stage' in env['sale.order']._fields else 0,
}
return {
'ok': True,
'kpis': kpis,
'unassigned': unassigned_cards,
'active': active_cards,
'team': team,
'operators': operators,
'tanks': tanks,
'user_name': env.user.name,
}
# ------------------------------------------------------------------
# Assign a worker to a WO
# ------------------------------------------------------------------
@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=f'Worker assigned: <b>{wo.x_fc_assigned_user_id.name or "Unassigned"}</b>',
)
return {'ok': True, 'user_name': wo.x_fc_assigned_user_id.name or ''}
# ------------------------------------------------------------------
# Reassign or swap tank on a WO
# ------------------------------------------------------------------
@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=f'Tank assigned: <b>{wo.x_fc_tank_id.name or "Unassigned"}</b>',
)
return {'ok': True, 'tank_name': wo.x_fc_tank_id.name or ''}
# ------------------------------------------------------------------
# Manager takes over a WO (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.'}
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=f'Manager takeover: <b>{user.name}</b> replaces {previous}.',
)
return {'ok': True, 'user_name': user.name}