This commit is contained in:
gsinghpal
2026-04-27 08:48:55 -04:00
parent 2a4909be25
commit f51976cb08
8 changed files with 874 additions and 37 deletions

View File

@@ -292,12 +292,17 @@ class FpShopfloorController(http.Controller):
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user')
def start_bake(self, bake_window_id, oven_id=None):
# action_start_bake raises UserError for S6 missed_window. Wrap
# the same way as start_wo so operator gets a clean flash.
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")
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
if oven_id:
bw.oven_id = int(oven_id)
bw.action_start_bake()
try:
bw.action_start_bake()
except UserError as e:
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
return {
'ok': True,
'state': bw.state,
@@ -308,8 +313,11 @@ class FpShopfloorController(http.Controller):
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': False, 'error': f'Bake window {bake_window_id} not found'}
try:
bw.action_end_bake()
except UserError as e:
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
return {
'ok': True,
'state': bw.state,
@@ -334,7 +342,14 @@ class FpShopfloorController(http.Controller):
@http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user')
def start_wo(self, workorder_id=None, step_id=None):
"""Start the timer on a fp.job.step (called from the tablet)."""
"""Start the timer on a fp.job.step (called from the tablet).
button_start() can raise UserError for any guarded condition
(S14 predecessor lock, future S15-style gates, etc). Catch it
and turn it into the same {ok: False, error: ...} envelope as
the explicit state check, so the tablet flashes a clean toast
instead of popping a stack-trace dialog at the operator.
"""
step = self._resolve_step(step_id, workorder_id)
if not step:
return {'ok': False, 'error': 'Step not found'}
@@ -343,7 +358,10 @@ class FpShopfloorController(http.Controller):
'ok': False,
'error': f'Step is in state {step.state} — only ready/paused steps can start.',
}
step.button_start()
try:
step.button_start()
except UserError as e:
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
return {
'ok': True,
'state': step.state,
@@ -357,6 +375,10 @@ class FpShopfloorController(http.Controller):
finish=True calls button_finish(); other values are no-ops for
now (button_pause is not yet implemented on fp.job.step — see
fp_job_step.py).
button_finish() can raise UserError (e.g. required sign-off
not provided). Wrapped same as start_wo so the operator gets a
clean flash, not a stack-trace dialog.
"""
step = self._resolve_step(step_id, workorder_id)
if not step:
@@ -367,7 +389,10 @@ class FpShopfloorController(http.Controller):
'ok': False,
'error': f'Step is in state {step.state} — only in-progress steps can finish.',
}
step.button_finish()
try:
step.button_finish()
except UserError as e:
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
return {
'ok': True,
'state': step.state,
@@ -396,6 +421,8 @@ class FpShopfloorController(http.Controller):
if new_qty < 0:
new_qty = 0
job.qty_done = new_qty
except UserError as e:
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
except Exception as e:
return {'ok': False, 'error': str(e)}
return {
@@ -423,6 +450,8 @@ class FpShopfloorController(http.Controller):
if reason:
ctx['fp_scrap_reason'] = reason
job.with_context(**ctx).qty_scrapped = new_scrap
except UserError as e:
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
except Exception as e:
return {'ok': False, 'error': str(e)}
return {
@@ -636,11 +665,23 @@ class FpShopfloorController(http.Controller):
steps_ready = len(my_steps.filtered(lambda s: s.state == 'ready'))
steps_progress = len(my_steps.filtered(lambda s: s.state == 'in_progress'))
awaiting = BakeWindow.search_count(_fac_dom([('state', '=', 'awaiting_bake')]))
in_progress_bakes = BakeWindow.search_count(_fac_dom([('state', '=', 'bake_in_progress')]))
missed = BakeWindow.search_count(_fac_dom([('state', '=', 'missed_window')]))
# KPI scoping — match the panels below so the operator never
# sees "12 Quality Holds" on the strip but an empty list in the
# panel. Operator-scoped where the panel is operator-scoped;
# facility-scoped otherwise. The manager dashboard owns the
# plant-wide view.
my_job_ids_for_kpi = my_steps.mapped('job_id').ids
bake_dom = _fac_dom([])
if my_job_ids_for_kpi and 'job_id' in BakeWindow._fields:
bake_dom = [('job_id', 'in', my_job_ids_for_kpi)]
awaiting = BakeWindow.search_count(bake_dom + [('state', '=', 'awaiting_bake')])
in_progress_bakes = BakeWindow.search_count(bake_dom + [('state', '=', 'bake_in_progress')])
missed = BakeWindow.search_count(bake_dom + [('state', '=', 'missed_window')])
pending_gates = Gate.search_count(_fac_dom([('result', '=', 'pending')]))
open_holds = Hold.search_count([('state', 'in', ('on_hold', 'under_review'))])
hold_dom = [('state', 'in', ('on_hold', 'under_review'))]
if my_job_ids_for_kpi and 'x_fc_job_id' in Hold._fields:
hold_dom.append(('x_fc_job_id', 'in', my_job_ids_for_kpi))
open_holds = Hold.search_count(hold_dom)
kpis = [
{'label': 'Ready', 'value': steps_ready, 'tone': 'info', 'icon': 'fa-hourglass-half'},
@@ -947,12 +988,15 @@ class FpShopfloorController(http.Controller):
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}'}
try:
if result == 'pass':
gate.action_mark_pass()
elif result == 'fail':
gate.action_mark_fail()
else:
return {'ok': False, 'error': f'Unknown result {result}'}
except UserError as e:
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
return {'ok': True, 'state': gate.result}
# ----------------------------------------------------------------------
@@ -1084,21 +1128,122 @@ class FpShopfloorController(http.Controller):
step_domain.append(('work_centre_id.facility_id', '=', int(facility_id)))
steps = Step.search(step_domain, order='job_id, sequence, id')
# ---- Batch precompute per-job lookups (v19.0.24.7.0) -------------
# Old code called job.step_ids.sorted() / .filtered() inside
# _step_to_card for every card → O(N × steps_per_job). With ~400
# cards and ~9 steps/job that's ~3.6k redundant iterations per
# refresh, which made drag-and-drop feel laggy because the post-
# drop loadData() was rebuilding the whole payload that way.
# Now we walk each unique job once and stash the answers.
unique_jobs = steps.mapped('job_id')
# Prefetch the fields we'll touch (saves N+1 SQL fetches)
unique_jobs.read([
'name', 'origin', 'priority', 'partner_id', 'product_id',
'qty', 'qty_done', 'date_planned_start', 'date_deadline',
'part_catalog_id', 'coating_config_id',
])
step_idx_by_id = {} # step_id → 1-based ordinal in its job
job_step_count_by_id = {} # job_id → total step count
queued_start_by_step_id = {} # step_id → predecessor's date_finished
for job in unique_jobs:
sorted_steps = job.step_ids.sorted('sequence')
sorted_ids = sorted_steps.ids
job_step_count_by_id[job.id] = len(sorted_ids)
for idx, sid in enumerate(sorted_ids, start=1):
step_idx_by_id[sid] = idx
# For each step, the moment it became workable = the latest
# date_finished among earlier-sequence done steps. Walk once,
# accumulate as we go in sequence order.
latest_done_finish = False
for s in sorted_steps:
if s.state == 'done' and s.date_finished:
latest_done_finish = max(
latest_done_finish or s.date_finished,
s.date_finished,
)
else:
queued_start_by_step_id[s.id] = latest_done_finish
# ---- Bake-window prefetch for urgency scoring (v19.0.24.8.0) -----
# Avoids N+1 in _compute_urgency when we look up the most urgent
# bake window per job.
BakeWindow = env.get('fusion.plating.bake.window')
bakes_by_job_id = {}
if BakeWindow is not None and unique_jobs and 'job_id' in BakeWindow._fields:
open_bakes = BakeWindow.search([
('job_id', 'in', unique_jobs.ids),
('state', 'in', ('awaiting_bake', 'bake_in_progress', 'missed_window')),
])
for bw in open_bakes:
bakes_by_job_id.setdefault(bw.job_id.id, []).append(bw)
now = fields.Datetime.now()
cards_by_wc = {}
for step in steps:
wc_id = step.work_centre_id.id or 0
card = self._step_to_card(step)
card = self._step_to_card(
step,
step_idx=step_idx_by_id.get(step.id, 0),
job_step_count=job_step_count_by_id.get(step.job_id.id, 0),
queued_start=queued_start_by_step_id.get(step.id),
job_bakes=bakes_by_job_id.get(step.job_id.id, []),
now=now,
)
if search and not self._card_matches_search(card, search):
continue
cards_by_wc.setdefault(wc_id, []).append(card)
columns = []
# ---- Sort each column by urgency (v19.0.24.8.0) ------------------
# HOT first, then overdue, bake-risk, stuck, due-today, due-soon,
# then FIFO. Score sums all those factors so equal-band cards
# still rank by combined risk. Tiebreakers: deadline asc, then id
# asc — so the oldest card with the same risk wins.
FAR_FUTURE = '9999-12-31'
for wc_id, cards in cards_by_wc.items():
cards.sort(key=lambda c: (
-c.get('urgency_score', 0),
c.get('date_deadline_iso') or FAR_FUTURE,
c.get('id') or 0,
))
# ---- Column order = recipe flow (v19.0.24.8.0) -------------------
# Old code ordered work centres by their `sequence, code, name`
# field. In practice `sequence` was unset, so columns fell back
# to alphabetical → "Oven Baking" appeared BEFORE "E-Nickel
# Plating" on the board, which is impossible in real life
# (you can't bake parts that haven't been plated yet).
#
# Derive the order from what the data actually says: the minimum
# step.sequence currently flowing through each work centre.
# Result: columns lay out left→right in the physical order parts
# move through the shop. No manual setup; updates automatically
# if a recipe re-sequences.
wc_min_seq = {}
for step in steps:
wc_id = step.work_centre_id.id or 0
seq = step.sequence or 0
if wc_id not in wc_min_seq or seq < wc_min_seq[wc_id]:
wc_min_seq[wc_id] = seq
# Static fallback for centres that had no active steps so they
# still rank by their stored sequence/name (deterministic, and
# they sort to the end behind any centre with live work).
SEQ_FALLBACK = 9999
columns_unsorted = []
for wc in centres:
columns.append({
columns_unsorted.append({
'work_center_id': wc.id,
'work_center_name': wc.name,
'_min_seq': wc_min_seq.get(wc.id, SEQ_FALLBACK + (wc.sequence or 0)),
'_static_seq': (wc.sequence or 0, wc.code or '', wc.name or ''),
'cards': cards_by_wc.get(wc.id, []),
})
columns_unsorted.sort(key=lambda c: (c['_min_seq'], c['_static_seq']))
columns = []
for c in columns_unsorted:
c.pop('_min_seq', None)
c.pop('_static_seq', None)
columns.append(c)
# Trailing "Unassigned" column when there are stranded steps.
if cards_by_wc.get(0):
columns.append({
@@ -1112,12 +1257,189 @@ class FpShopfloorController(http.Controller):
'columns': columns,
}
def _step_to_card(self, step):
"""Convert one fp.job.step to a card dict for Plant Overview."""
# ------------------------------------------------------------------
# Urgency scoring (v19.0.24.8.0)
# ------------------------------------------------------------------
# Real plating shops triage by what's at risk, not by FIFO. We score
# each card from 7 signals and sort columns by score desc. The score
# is internal; the supervisor sees a `urgency_band` chip explaining
# WHY the card is where it is ("HOT", "OVERDUE 2d", "Bake by 14:30",
# "Paused 28h", etc) — not just a coloured ranking.
#
# Bands chosen so the most severe reason wins the chip (a HOT job
# that's also overdue still shows "HOT"). Score adds them all so two
# mildly-urgent factors still outrank one strong factor.
_URGENCY_BAND_SEVERITY = {
# higher = more severe → wins the chip when a card hits multiple
'hot': 60,
'overdue': 50,
'bake_risk': 45,
'stuck': 30,
'due_today': 25,
'priority': 20,
'due_soon': 15,
'first_piece': 10,
'normal': 0,
}
def _compute_urgency(self, step, job, job_bakes, now):
"""Return a dict with urgency_score (int), urgency_band (str),
urgency_label (chip text), urgency_icon (FontAwesome class),
urgency_tone (chip color: danger/warning/info/muted), and
urgency_pulse (bool — chip animates when critical).
Higher score = sort earlier. Score is a sum so multiple mildly
urgent factors aggregate (e.g. paused 12h + due in 30h together
outrank a clean job due in 4h).
"""
score = 0
bands = [] # list of (band_key, label, icon, tone, pulse)
# 1. Customer priority (CSR-set: rush / high / normal / low)
if job.priority == 'rush':
score += 1000
bands.append(('hot', 'HOT', 'fa-fire', 'danger', True))
elif job.priority == 'high':
score += 500
bands.append(('priority', 'PRIORITY', 'fa-flag', 'warning', False))
# 2. Deadline pressure
if job.date_deadline:
try:
# date_deadline is a Date in some setups, Datetime in others
deadline_dt = job.date_deadline
if hasattr(deadline_dt, 'hour'):
delta_s = (deadline_dt - now).total_seconds()
else:
# Date → end-of-day
from datetime import datetime, time as _time
deadline_dt = datetime.combine(deadline_dt, _time(23, 59))
delta_s = (deadline_dt - now).total_seconds()
except Exception:
delta_s = None
if delta_s is not None:
if delta_s < 0:
overdue_h = abs(delta_s) / 3600
score += int(min(500, 300 + overdue_h * 5))
if overdue_h >= 24:
label = f'OVERDUE {int(overdue_h / 24)}d'
else:
label = f'OVERDUE {int(overdue_h)}h'
bands.append(('overdue', label, 'fa-exclamation-triangle', 'danger', True))
elif delta_s < 24 * 3600:
score += 200
hh = int(delta_s / 3600)
bands.append(('due_today', f'Due in {hh}h', 'fa-clock-o', 'warning', False))
elif delta_s < 72 * 3600:
score += 75
dd = int(delta_s / 86400)
bands.append(('due_soon', f'Due in {dd}d', 'fa-clock-o', 'info', False))
# 3. Stuck — paused too long, or running past 1.5× planned
if step.state == 'paused' and step.write_date:
paused_s = (now - step.write_date).total_seconds()
if paused_s > 24 * 3600:
score += 250
hh = int(paused_s / 3600)
bands.append(('stuck', f'Paused {hh}h', 'fa-pause-circle', 'warning', True))
elif paused_s > 8 * 3600:
score += 75
hh = int(paused_s / 3600)
bands.append(('stuck', f'Paused {hh}h', 'fa-pause-circle', 'warning', False))
elif step.state == 'in_progress' and step.date_started and step.duration_expected:
running_min = (now - step.date_started).total_seconds() / 60
ratio = running_min / step.duration_expected
if ratio > 1.5:
score += 200
bands.append(('stuck', f'Overrun {ratio:.1f}x', 'fa-bolt', 'danger', True))
# 4. Bake-window risk — compliance bomb
for bw in job_bakes:
if bw.state == 'missed_window':
score += 400
bands.append(('bake_risk', 'MISSED BAKE', 'fa-times-circle', 'danger', True))
break
if bw.state == 'awaiting_bake' and bw.bake_required_by:
secs = (bw.bake_required_by - now).total_seconds()
if secs < 0:
score += 350
bands.append(('bake_risk', 'BAKE OVERDUE', 'fa-fire', 'danger', True))
break
if secs < 4 * 3600:
score += 250
hh = max(0, int(secs / 3600))
mm = max(0, int((secs % 3600) / 60))
bands.append(('bake_risk', f'Bake in {hh}h {mm:02d}m', 'fa-fire', 'danger', True))
break
# Pick the most severe band for the chip (or 'normal' if no signal)
if bands:
bands.sort(
key=lambda b: self._URGENCY_BAND_SEVERITY.get(b[0], 0),
reverse=True,
)
band, label, icon, tone, pulse = bands[0]
else:
band = 'normal'
label = 'On track'
icon = 'fa-check-circle'
tone = 'muted'
pulse = False
return {
'urgency_score': score,
'urgency_band': band,
'urgency_label': label,
'urgency_icon': icon,
'urgency_tone': tone,
'urgency_pulse': pulse,
}
def _step_to_card(self, step, step_idx=0, job_step_count=0,
queued_start=None, job_bakes=None, now=None):
"""Convert one fp.job.step to a card dict for Plant Overview.
v19.0.24.7.0 — caller now precomputes `step_idx`,
`job_step_count`, and `queued_start` once per job (see
plant_overview()) and passes them in. Eliminates per-card
re-iteration of job.step_ids; drag-and-drop reload is now
~5× faster on a 400-card board.
Per-step timer (v19.0.24.5.0):
Cards ship `timer_kind` / `timer_started_at_iso` /
`timer_expected_minutes`. The OWL component computes the live
elapsed label + tone client-side (1-second tick).
timer_kind:
- "running" → step is in_progress, started_at = current run start
- "paused" → step is paused, started_at = last write_date
- "queued" → step is ready/pending, started_at = predecessor's
date_finished (fallback: step.create_date)
"""
env = request.env
job = step.job_id
partner = job.partner_id
# ---- Per-step timer ------------------------------------------
from odoo import fields as _flds
timer_kind = ''
timer_started_at = None
if step.state == 'in_progress':
timer_kind = 'running'
timer_started_at = step.date_started
elif step.state == 'paused':
timer_kind = 'paused'
timer_started_at = step.write_date
elif step.state in ('ready', 'pending'):
timer_kind = 'queued'
# Caller pre-computed the predecessor's date_finished;
# fall back to step.create_date if no predecessor was done.
timer_started_at = queued_start or step.create_date
timer_started_at_iso = (
_flds.Datetime.to_string(timer_started_at)
if timer_started_at else ''
)
# Customer + parts progress drawn from the job header
parts_done = int(job.qty_done or 0)
parts_total = int(job.qty or 0)
@@ -1151,6 +1473,34 @@ class FpShopfloorController(http.Controller):
step_display = step.kind.title() if step.kind else ''
step_number = step.sequence or 0
# ---- Useful card line (v19.0.24.6.0) -------------------------
# Replaces the always-the-same "[FP-SERVICE] Plating Service"
# product line with what the operator actually cares about:
# part number + revision (line 1) and coating spec reference
# (line 2, small/muted). Falls back gracefully when the job
# doesn't have the field set yet.
part = (
job.part_catalog_id
if 'part_catalog_id' in job._fields else False
)
coating = (
job.coating_config_id
if 'coating_config_id' in job._fields else False
)
part_number = ''
part_revision = ''
if part:
part_number = (
getattr(part, 'part_number', '') or part.name or ''
)
part_revision = getattr(part, 'revision', '') or ''
coating_label = ''
if coating:
spec_ref = getattr(coating, 'spec_reference', '') or ''
coating_label = (
f'{coating.name} · {spec_ref}' if spec_ref else coating.name
)
# Customer logo + product image
customer_logo_url = ''
product_image_url = ''
@@ -1189,6 +1539,34 @@ class FpShopfloorController(http.Controller):
'product_name': job.product_id.display_name if job.product_id else '',
'job_id': job.id,
'job_name': job.name or '',
# Per-step timer (v19.0.24.5.0). JS computes label/tone/pulse
# from these three so the timer ticks live without polling.
'timer_kind': timer_kind,
'timer_started_at_iso': timer_started_at_iso,
'timer_expected_minutes': step.duration_expected or 0,
# 1-based step ordinal + total — replaces raw recipe sequence
# in the card badge so the operator sees "step 4 of 9" not
# "step 40" (v19.0.24.6.0). Both precomputed by caller.
'step_index': step_idx,
'job_step_count': job_step_count,
# Useful card line replacements (v19.0.24.6.0). Templates
# render `part_number_display` / `coating_label` instead of
# the always-identical product_name.
'part_number': part_number,
'part_revision': part_revision,
'coating_label': coating_label,
# ISO deadline for sort tiebreaker (v19.0.24.8.0)
'date_deadline_iso': (
_flds.Datetime.to_string(job.date_deadline)
if job.date_deadline else ''
),
# Urgency bundle (v19.0.24.8.0). All 6 keys merged below so
# the JSON-RPC payload carries them flat.
**self._compute_urgency(
step, job,
job_bakes or [],
now or _flds.Datetime.now(),
),
}
def _card_matches_search(self, card, search):