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:
@@ -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
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user