feat(shopfloor): rich Tablet Station dashboard + full shop-floor demo data

Tablet Station rebuilt as a live dashboard (not just a QR scanner):

  * KPI strip — WOs Ready/Progress, Awaiting/Missed bakes,
    First-Piece pending, Quality Holds (each tinted by state)
  * Active WO banner with pulsing indicator when a WO is running
  * My Queue panel (left) — priority-badged operator next-up list,
    clickable rows that jump to the WO/bake/gate form
  * Baths tile grid (right) — last-log status chips, MTO count,
    hover jump to chemistry log
  * Bake Windows list — inline Start/End/Open actions, colour-coded
    by state (awaiting / in-progress / missed)
  * First-Piece Gates — Pass/Fail buttons for pending inspections
  * Quality Holds — Review jump when any open holds exist
  * Station picker + scan drawer (collapsed by default)
  * 30s auto-refresh, persists picked station in localStorage

New controller endpoints: /fp/shopfloor/tablet_overview,
/fp/shopfloor/pair_station, /fp/shopfloor/mark_gate.

Demo seeder (Phase 12.5) now populates:
  * 5 shop-floor stations (Plating, Bake, Inspection, Shipping, Receiving)
  * +3 bake windows (awaiting / in-progress / near-due)
  * 4 first-piece gates (1 pending, 1 passed+released, 1 passed-holding, 1 failed)
  * 2 quality holds on active MOs (one on_hold, one under_review)

All four Shop Floor menu pages (Plant Overview, Tablet Station, Bake
Windows, First-Piece Gates) now have meaningful content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-17 07:43:10 -04:00
parent 3b5b5cbf7c
commit e07002d550
5 changed files with 1079 additions and 353 deletions

View File

@@ -313,6 +313,248 @@ class FpShopfloorController(http.Controller):
'state': hold.state,
}
# ----------------------------------------------------------------------
# Tablet Overview — one-shot dashboard payload
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/tablet_overview', type='jsonrpc', auth='user')
def tablet_overview(self, station_id=None, facility_id=None):
"""Return a rich dashboard snapshot for the Tablet Station page.
Shape:
{
station: {...} or None,
facility: {id, name} or None,
kpis: [{label, value, tone}], # top strip
my_queue: [...], # top 8 queue rows
active_wo: {...} or None, # the one WO currently in progress
baths: [...], # chemistry quick view (top 6)
bake_windows: [...], # top 6 awaiting/in-progress, soonest first
gates: [...], # pending first-piece gates
holds: [...], # open quality holds
stations: [...], # all stations, for the picker
}
"""
env = request.env
user = env.user
# -- Resolve station / facility -----------------------------------
station = None
if station_id:
stn = env['fusion.plating.shopfloor.station'].browse(int(station_id))
if stn.exists():
station = stn
if not facility_id:
facility_id = stn.facility_id.id
fac = None
if facility_id:
fac = env['fusion.plating.facility'].browse(int(facility_id))
# -- KPI counts ---------------------------------------------------
BakeWindow = env['fusion.plating.bake.window']
Gate = env['fusion.plating.first.piece.gate']
Hold = env['fusion.plating.quality.hold']
MrpWO = env.get('mrp.workorder')
def _dom(dom):
return dom + ([('facility_id', '=', fac.id)] if fac else [])
wos_ready = wos_progress = 0
if MrpWO is not None:
wo_base = []
if fac:
wo_base = [('workcenter_id.x_fc_facility_id', '=', fac.id)]
wos_ready = MrpWO.search_count(wo_base + [('state', '=', 'ready')])
wos_progress = MrpWO.search_count(wo_base + [('state', '=', 'progress')])
awaiting = BakeWindow.search_count(_dom([('state', '=', 'awaiting_bake')]))
in_progress_bakes = BakeWindow.search_count(_dom([('state', '=', 'bake_in_progress')]))
missed = BakeWindow.search_count(_dom([('state', '=', 'missed_window')]))
pending_gates = Gate.search_count(_dom([('result', '=', 'pending')]))
open_holds = Hold.search_count([('state', 'in', ('on_hold', 'under_review'))])
kpis = [
{'label': 'WOs Ready', 'value': wos_ready, 'tone': 'info', 'icon': 'fa-hourglass-half'},
{'label': 'WOs In Progress', 'value': wos_progress, 'tone': 'success', 'icon': 'fa-cogs'},
{'label': 'Awaiting Bake', 'value': awaiting, 'tone': 'warning', 'icon': 'fa-fire'},
{'label': 'Missed Windows', 'value': missed, 'tone': 'danger' if missed else 'muted', 'icon': 'fa-exclamation-triangle'},
{'label': 'First-Piece', 'value': pending_gates, 'tone': 'info', 'icon': 'fa-flag-checkered'},
{'label': 'Quality Holds', 'value': open_holds, 'tone': 'danger' if open_holds else 'muted', 'icon': 'fa-pause-circle'},
]
# -- My Queue (top 8) --------------------------------------------
queue_rows = env['fusion.plating.operator.queue'].build_for_user(
user_id=user.id, facility_id=fac.id if fac else None,
)
my_queue = [
{
'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 queue_rows[:8]
]
# -- Active WO for this user -------------------------------------
active_wo = None
if MrpWO is not None:
wo = MrpWO.search([('state', '=', 'progress')], limit=1)
if wo:
prod = wo.production_id
active_wo = {
'id': wo.id,
'name': wo.display_name or wo.name,
'workcenter': wo.workcenter_id.name or '',
'mo_name': prod.name or '',
'product_name': prod.product_id.display_name if prod.product_id else '',
'qty_done': int(getattr(prod, 'qty_produced', None) or 0),
'qty_total': int(prod.product_qty or 0),
'duration': wo.duration or 0,
'step_display': getattr(wo, 'x_fc_step_display', '') or '',
'customer': prod.origin or '',
}
# -- Baths chemistry quick view ----------------------------------
bath_domain = [('state', 'in', ('operational', 'low', 'out_of_spec'))]
if fac:
bath_domain.append(('facility_id', '=', fac.id))
baths = env['fusion.plating.bath'].search(bath_domain, order='last_log_date desc, id', limit=6)
baths_data = [
{
'id': b.id,
'name': b.display_name or b.name,
'tank': b.tank_id.name or '',
'state': b.state or '',
'last_log_date': fields.Datetime.to_string(b.last_log_date) if b.last_log_date else '',
'last_log_status': b.last_log_status or '',
'mto': round(b.mto_count or 0, 2),
}
for b in baths
]
# -- Bake windows (top 6 awaiting/in-progress, soonest first) -----
bw_domain = _dom([('state', 'in', ('awaiting_bake', 'bake_in_progress', 'missed_window'))])
bws = BakeWindow.search(bw_domain, order='bake_required_by asc', limit=6)
bw_data = [
{
'id': bw.id,
'name': bw.name,
'part_ref': bw.part_ref or '',
'lot_ref': bw.lot_ref or '',
'customer': bw.customer_ref or '',
'state': bw.state,
'remaining': bw.time_remaining_display or '',
'required_by': fields.Datetime.to_string(bw.bake_required_by) if bw.bake_required_by else '',
'quantity': bw.quantity or 0,
}
for bw in bws
]
# -- First-piece gates (top 6 pending or recently failed) ---------
gate_domain = _dom([('result', 'in', ('pending', 'fail'))])
gates = Gate.search(gate_domain, order='first_piece_produced desc', limit=6)
gates_data = [
{
'id': g.id,
'name': g.name,
'part_ref': g.part_ref or '',
'customer': g.customer_ref or '',
'bath': g.bath_id.name or '',
'result': g.result,
'first_piece': fields.Datetime.to_string(g.first_piece_produced) if g.first_piece_produced else '',
'inspector': g.inspector_id.name or '',
}
for g in gates
]
# -- Quality holds (top 6 open) -----------------------------------
hold_domain = [('state', 'in', ('on_hold', 'under_review'))]
holds = Hold.search(hold_domain, order='create_date desc', limit=6)
holds_data = [
{
'id': h.id,
'name': h.name,
'part_ref': h.part_ref or '',
'qty': h.qty_on_hold or 0,
'reason': dict(h._fields['hold_reason'].selection).get(h.hold_reason, h.hold_reason or ''),
'state': h.state,
'operator': h.operator_id.name or '',
}
for h in holds
]
# -- All stations for picker --------------------------------------
station_list = env['fusion.plating.shopfloor.station'].search([], order='facility_id, name')
stations = [
{
'id': s.id,
'name': s.name,
'code': s.code or '',
'facility': s.facility_id.name or '',
'work_center': s.work_center_id.name or '',
'current_operator': s.current_operator_id.name or '',
}
for s in station_list
]
return {
'ok': True,
'station': {
'id': station.id,
'name': station.name,
'code': station.code or '',
'facility': station.facility_id.name or '',
'work_center': station.work_center_id.name or '',
} if station else None,
'facility': {
'id': fac.id,
'name': fac.name,
} if fac else None,
'user_name': user.name,
'kpis': kpis,
'my_queue': my_queue,
'active_wo': active_wo,
'baths': baths_data,
'bake_windows': bw_data,
'gates': gates_data,
'holds': holds_data,
'stations': stations,
'server_time': fields.Datetime.to_string(fields.Datetime.now()),
}
# ----------------------------------------------------------------------
# Pair a station (bumps current_operator_id + last_ping)
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/pair_station', type='jsonrpc', auth='user')
def pair_station(self, station_id):
stn = request.env['fusion.plating.shopfloor.station'].browse(int(station_id))
if not stn.exists():
return {'ok': False, 'error': 'Station not found.'}
stn.write({
'current_operator_id': request.env.user.id,
'last_ping': fields.Datetime.now(),
})
return {'ok': True}
# ----------------------------------------------------------------------
# Mark a first-piece gate result from the tablet
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
def mark_gate(self, gate_id, result):
gate = request.env['fusion.plating.first.piece.gate'].browse(int(gate_id))
if not gate.exists():
return {'ok': False, 'error': 'Gate not found.'}
if result == 'pass':
gate.action_mark_pass()
elif result == 'fail':
gate.action_mark_fail()
else:
return {'ok': False, 'error': f'Unknown result {result}'}
return {'ok': True, 'state': gate.result}
# ----------------------------------------------------------------------
# Operator queue snapshot
# ----------------------------------------------------------------------