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

@@ -834,6 +834,7 @@ Each script is self-contained — builds a fresh SO + job, walks the scenario, a
| **S17** | Operator drops parts, bumps `qty_scrapped` 0→2 | Silent — no AS9100 disposition record | `fp.job.write` hook auto-spawns `fusion.plating.quality.hold` for the scrap delta. Operator updates description with cause | `fusion_plating_jobs 19.0.6.18.0` | `bt_s17_scrap_ncr.py` |
| **S18** | CoC issuance broken in 4 places — operator can't actually email a cert | (a) auto-spawn left every useful field blank → Issue blocked on missing spec_reference; (b) Issue button never generated PDF → `attachment_id` stayed empty; (c) Send to Customer opened email composer with no attachment; (d) auto-spawn had no idempotency → dupes on `button_mark_done` retry | `_fp_create_certificates` now pre-fills `spec_reference` (from coating), `part_number`, `quantity_shipped` (qty scrap), `po_number`, `customer_job_no`, `process_description`, `entech_wo_number`, `sale_order_id`. Idempotency check skips dupes. `action_issue` now renders the EN CoC PDF via new `_fp_render_and_attach_pdf` and sets `attachment_id` so Send to Customer attaches it automatically. Smart button "Certificates" already on the job form (visible when count > 0) so Tom finds the cert from the job he just closed | `fusion_plating_certificates 19.0.5.1.0`, `fusion_plating_jobs 19.0.6.19.0` | `bt_s18_cert_flow.py` |
| **S19** | Lisa uploads Fischerscope X-Ray thickness PDF to QC; CoC ships without it as page 2 — and even after the back-end merge worked, operators couldn't *see* in the cert form whether the merge would happen | Existing merge logic lived in uninstalled `fusion_plating_bridge_mrp` (keyed off `mrp.production` — gone with Sub 11). Post-Sub-11 cert path rendered CoC only; Fischerscope PDF stayed orphaned on the QC record. Even after Phase 1 fix shipped, the cert form had **zero** indicator that a thickness PDF was on file or had been merged → user reported "I did not see anything in the certification issue" | **Phase 1 (back-end merge):** Ported merge to `fp.certificate._fp_merge_thickness_into_pdf`. New `_fp_render_and_attach_pdf` wraps cert PDF generation: renders the CoC via QWeb, then looks up the linked `fusion.plating.quality.check` (`x_fc_job_id → fp.job → QC`), finds the most recent passed QC with `thickness_report_pdf_id`, merges via `pypdf.PdfWriter.append()` (PyPDF2 `PdfMerger` fallback), posts chatter audit `Fischerscope thickness report from QC <name> appended to CoC PDF.`. Hooked into `action_issue` so the multi-page PDF lands on `attachment_id` automatically. **Phase 2 (UI surface):** Added 3 computed fields on `fp.certificate` (in `fusion_plating_jobs`): `x_fc_thickness_qc_id` (linked QC), `x_fc_thickness_pdf_id` (Fischerscope PDF), `x_fc_thickness_status` (`none` / `pending` / `merged`). Cert form now shows: (1) coloured banner above the title — blue "Will Append on Issue" / green "Merged" / amber "No PDF — operator action required"; (2) two new smart buttons (Plating Job, Fischerscope status); (3) new "Thickness Report (Fischerscope)" notebook tab with clickable PDF preview + step-by-step instructions when none uploaded | `fusion_plating_certificates 19.0.5.2.0`, `fusion_plating_jobs 19.0.6.20.0` | `bt_s19_fischer_merge.py` (asserts both pre-Issue `pending` + post-Issue `merged` status flips) |
| **S20** | Tablet Station UX hardening — three real-world UX gaps surfaced during a persona walk on the Tablet + Manager Desk client actions | (a) **Scrap reason dropped**: `/fp/shopfloor/bump_qty_scrapped` accepted operator's typed reason via `window.prompt`, passed it through context as `fp_scrap_reason` — but `fp.job.write` never read it, so the auto-spawned Hold's description had the generic "OPERATOR: replace this text with the actual reason" placeholder instead of what Carlos typed. Audit trail lost what just happened on the floor. (b) **KPI/panel mismatch**: tablet KPI strip showed plant-wide totals ("Quality Holds: 12") but the Holds panel below was scoped to the operator's own jobs (might show 0). Operator stares at a big red 12, scrolls down, sees nothing — confused/distrustful. (c) **UserError stack-trace leak**: when `start_wo` hit an S14 predecessor lock (or any other `button_start`-side guard), the raw `UserError` propagated through the JSON-RPC handler and operator got a Python stack-trace dialog instead of the nice `setMessage("...", "danger")` flash. Same hole on `stop_wo`, `start_bake`, `end_bake`, `mark_gate`, `bump_qty_done`, `bump_qty_scrapped`. | (a) `fp.job.write` now reads `self.env.context.get('fp_scrap_reason')` and prepends `Operator reason: <text>` to the Hold description so the audit row captures what the operator actually typed. (b) Tablet KPI strip now reuses `my_job_ids_for_kpi` (the operator's own steps) for `awaiting_bakes`, `bake_in_progress`, `missed`, `open_holds` — same scope as the panels below, so the strip never lies. Manager dashboard keeps its own plant-wide KPI set. (c) Wrapped every action endpoint in `try: ... except UserError as e: return {'ok': False, 'error': str(e.args[0])}` — operator now gets the clean `setMessage` flash with the real guard text ("Step 'X' requires predecessors done first…") instead of a stack-trace popup. | `fusion_plating_jobs 19.0.6.22.0`, `fusion_plating_shopfloor 19.0.24.4.0` | persona walk via `sim_tablet_actions.py` + `sim_reverify.py` (asserts: typed reason ends up in hold.description, KPI=panel for holds, `start_wo` returns `{ok:False, error:"..."}` for locked step) |
| **S20** | **Tablet usability pass** — operators were squinting at the tablet, scanning back-and-forth between recipe binders and the screen because the tablet showed step names but no targets, no live timer, no predecessor visibility. QC fail left parts in limbo with no Hold record. Manager Desk showed feel-good KPIs but hid the compliance bombs (missed bakes, stale steps, locked steps, holds, pending QC missing PDF) | Tablet `My Queue` rows had no `instructions`, `thickness_target`, `dwell_time_minutes`, `bake_setpoint_temp`, `requires_signoff` — operators kept scanning the QR code just to read the bake temperature. Steps with `requires_predecessor_done=True` (S14) showed a green Start that always failed with a UserError. Active step "duration" was a stale number that only refreshed every 30s. Holds and bake windows showed plant-wide noise from other crews. **No banner alerted Carlos when his job had a pending QC** (Lisa was not called → QC sat for hours). **No way to bump qty_done or scrap from the tablet** → S17 hold auto-spawn never fired because operators didn't update the field. **`action_fail` on QC marked the check failed but spawned no Hold** — AS9100 disposition trail broken. **Manager Desk KPIs were missing 7 compliance metrics**: stale paused/in-progress steps (cron data), missed bake windows, open holds, predecessor-locked steps, pending QCs, QCs missing Fischerscope PDF, draft cert pipeline | **Carlos's Shopfloor Tablet** — every queue row now carries the recipe-author fields (instructions snippet, thickness target chip, dwell-time chip, bake-temp chip, sign-off badge) so operators read the targets inline. Predecessor-blocked steps render with a 🔒 lock icon, an "Awaiting [step name]" notice, and a disabled `Locked` button (no more Start-then-fail). Active step now shows a **live ticking HH:MM:SS clock** (1s interval, computed from `date_started_iso` JS-side; flips to red on >1.5× planned duration) plus `+1 Done` and `Scrap` buttons that hit two new endpoints (`/fp/shopfloor/bump_qty_done`, `/fp/shopfloor/bump_qty_scrapped` — scrap prompts for reason and S17 auto-spawns the Hold). New **Pending QC banner** lists open QCs for my jobs with line-progress + Fischerscope-PDF status badge, and a tap deep-links into Lisa's mobile QC checklist. Holds and bake windows are now **scoped to my jobs first** (fall back to facility-wide for managers). **QC checklist** — `action_fail` now auto-creates a `fusion.plating.quality.hold` with `hold_reason='qc_failure'` (new selection value), populated description listing the failed checks, idempotent on retry. **Manager Desk** — 7 new clickable compliance KPI tiles: Missed Bakes (S15), Open Holds (S17 + QC fail), Stale Steps (S10/S16 cron data), Locked Steps (S14), Pending QC + "X need PDF" (S19 + missing-Fischerscope), Draft Certs + "Y today" (cert pipeline). Each tile drills into a list filtered to the relevant exception | `fusion_plating_shopfloor 19.0.24.3.0`, `fusion_plating_quality 19.0.4.8.0` | `sim_tablet_walk.py`, `sim_timer_pred_test.py`, `sim_qc_fail_hold.py`, `sim_manager_qc_fail.py` (one-off persona walkthroughs) |
### Manager-bypass context flags

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.6.21.0',
'version': '19.0.6.22.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -301,6 +301,26 @@ class FpJob(models.Model):
job.part_catalog_id.part_number if job.part_catalog_id
else job.product_id.default_code or job.name
)
# When the scrap was bumped from the tablet, the operator
# was prompted for a reason and we passed it via context as
# `fp_scrap_reason` (see /fp/shopfloor/bump_qty_scrapped).
# Prepend that reason to the description so the audit row
# captures what the operator actually typed instead of the
# generic "OPERATOR: replace this text..." placeholder.
scrap_reason = self.env.context.get('fp_scrap_reason')
if scrap_reason:
description = _(
'Operator reason: %s\n\n'
'Auto-spawned from job %s scrap update by %s: '
'qty_scrapped went from %g to %g (delta %g).'
) % (scrap_reason, job.name, self.env.user.name, old, new, delta)
else:
description = _(
'Auto-spawned from job %s scrap update by %s: '
'qty_scrapped went from %g to %g (delta %g). '
'OPERATOR: replace this text with the actual '
'reason (drop / contamination / out-of-spec / etc).'
) % (job.name, self.env.user.name, old, new, delta)
try:
hold = Hold.create({
'job_id': job.id,
@@ -309,12 +329,7 @@ class FpJob(models.Model):
'qty_original': int(job.qty or 0),
'mark_for_scrap': True,
'hold_reason': 'other',
'description': _(
'Auto-spawned from job %s scrap update by %s: '
'qty_scrapped went from %g to %g (delta %g). '
'OPERATOR: replace this text with the actual '
'reason (drop / contamination / out-of-spec / etc).'
) % (job.name, self.env.user.name, old, new, delta),
'description': description,
'facility_id': facility.id if facility else False,
})
job.message_post(body=_Markup(_(

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.24.3.1',
'version': '19.0.24.8.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',

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):

View File

@@ -42,11 +42,19 @@ export class PlantOverview extends Component {
});
this._refreshInterval = null;
this._tickInterval = null;
// tickEpoch is bumped every second so the OWL template re-renders
// — we read it inside getCardTimer() so the ticker is reactive
// without writing to every card on every second.
this.state.tickEpoch = 0;
onMounted(async () => {
await this.loadData();
// Auto-refresh every 30 seconds
// Auto-refresh every 30 seconds (data); timers tick every 1 s.
this._refreshInterval = setInterval(() => this.loadData(), 30000);
this._tickInterval = setInterval(() => {
this.state.tickEpoch += 1;
}, 1000);
});
onWillUnmount(() => {
@@ -54,6 +62,10 @@ export class PlantOverview extends Component {
clearInterval(this._refreshInterval);
this._refreshInterval = null;
}
if (this._tickInterval) {
clearInterval(this._tickInterval);
this._tickInterval = null;
}
});
}
@@ -225,6 +237,32 @@ export class PlantOverview extends Component {
return;
}
// ---- Optimistic UI (v19.0.24.7.0) ---------------------------------
// Old code awaited the move RPC and THEN called loadData() to repaint
// the entire 400-card board — felt laggy because the user had to
// wait for both the SQL update AND a full payload rebuild before the
// card appeared in its new column. Now we move it in `state.columns`
// immediately, fire the RPC in the background, and only roll back +
// reload if the server rejects the move.
const sourceColIdx = this.state.columns.findIndex(
(c) => c.work_center_id === dragged.source_wc_id,
);
const targetColIdx = this.state.columns.findIndex(
(c) => c.work_center_id === col.work_center_id,
);
let movedCard = null;
let cardOriginalIdx = -1;
if (sourceColIdx >= 0 && targetColIdx >= 0) {
const cards = this.state.columns[sourceColIdx].cards;
cardOriginalIdx = cards.findIndex((c) => c.id === dragged.id);
if (cardOriginalIdx >= 0) {
movedCard = cards[cardOriginalIdx];
cards.splice(cardOriginalIdx, 1);
this.state.columns[targetColIdx].cards.push(movedCard);
}
}
this._draggedCard = null;
try {
const result = await rpc("/fp/shopfloor/plant_overview/move_card", {
card_id: dragged.id,
@@ -236,20 +274,38 @@ export class PlantOverview extends Component {
`Moved to ${col.work_center_name}`,
{ type: "success" },
);
await this.loadData();
// Don't reload — optimistic move already updated the UI.
// The 30 s auto-refresh will reconcile any drift.
} else {
// Server said no — roll back the optimistic move.
this.notification.add(
result?.error || "Could not move card",
{ type: "warning" },
);
if (movedCard && sourceColIdx >= 0 && targetColIdx >= 0) {
const targetCards = this.state.columns[targetColIdx].cards;
const movedIdx = targetCards.findIndex((c) => c.id === movedCard.id);
if (movedIdx >= 0) targetCards.splice(movedIdx, 1);
this.state.columns[sourceColIdx].cards.splice(
cardOriginalIdx, 0, movedCard,
);
}
}
} catch (err) {
// Same rollback on network error.
this.notification.add(
`Move failed: ${err.message || err}`,
{ type: "danger" },
);
if (movedCard && sourceColIdx >= 0 && targetColIdx >= 0) {
const targetCards = this.state.columns[targetColIdx].cards;
const movedIdx = targetCards.findIndex((c) => c.id === movedCard.id);
if (movedIdx >= 0) targetCards.splice(movedIdx, 1);
this.state.columns[sourceColIdx].cards.splice(
cardOriginalIdx, 0, movedCard,
);
}
}
this._draggedCard = null;
}
// ----- Card actions ------------------------------------------------------
@@ -308,6 +364,85 @@ export class PlantOverview extends Component {
return "";
}
}
// ------ Per-step timer (v19.0.24.5.0) ------------------------------------
//
// Computes the live "Running 47m" / "Paused 3h" / "Queued 12m" chip text
// plus a tone (ok/warning/danger/muted) and a `critical` flag that the
// template binds to a pulse animation. The `state.tickEpoch` reference
// makes this getter reactive — it re-evaluates every 1 s.
//
// Thresholds chosen to mirror the existing battle-test rules:
// - in_progress 1.0×1.5× expected → warning, >1.5× → danger + pulse (S7)
// - paused >8 h → danger, >24 h → danger + pulse (S10)
// - queued >4 h → warning, >24 h → danger + pulse
//
// Returns an object with .label, .tone, .critical, .icon.
getCardTimer(card) {
// Reactive tick — never remove this read; OWL uses it to know
// when to re-evaluate this getter.
const _ = this.state.tickEpoch;
const empty = { label: "", tone: "muted", critical: false, icon: "fa-clock-o" };
if (!card.timer_kind || !card.timer_started_at_iso) return empty;
const isoUtc = card.timer_started_at_iso.replace(" ", "T") + "Z";
const startMs = Date.parse(isoUtc);
if (isNaN(startMs)) return empty;
const sec = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
const fmt = (s) => {
if (s < 60) return s + "s";
const m = Math.floor(s / 60);
if (m < 60) return m + "m";
const h = Math.floor(m / 60);
const rem = m % 60;
if (h < 24) return rem ? `${h}h ${rem}m` : `${h}h`;
const d = Math.floor(h / 24);
const hr = h % 24;
return hr ? `${d}d ${hr}h` : `${d}d`;
};
if (card.timer_kind === "running") {
const expSec = (card.timer_expected_minutes || 0) * 60;
let tone = "ok";
let critical = false;
if (expSec) {
if (sec > 1.5 * expSec) { tone = "danger"; critical = true; }
else if (sec > expSec) { tone = "warning"; }
}
return {
label: `Running ${fmt(sec)}` + (expSec ? ` / ${fmt(expSec)} planned` : ""),
tone,
critical,
icon: "fa-play-circle",
};
}
if (card.timer_kind === "paused") {
let tone = "warning";
let critical = false;
if (sec > 24 * 3600) { tone = "danger"; critical = true; }
else if (sec > 8 * 3600) { tone = "danger"; }
return {
label: `Paused ${fmt(sec)}`,
tone,
critical,
icon: "fa-pause-circle",
};
}
if (card.timer_kind === "queued") {
let tone = "muted";
let critical = false;
if (sec > 24 * 3600) { tone = "danger"; critical = true; }
else if (sec > 4 * 3600) { tone = "warning"; }
return {
label: `Queued ${fmt(sec)}`,
tone,
critical,
icon: "fa-hourglass-half",
};
}
return empty;
}
}
registry.category("actions").add("fp_plant_overview", PlantOverview);

View File

@@ -388,6 +388,260 @@
color: $fp-ink-mute;
margin-bottom: $fp-space-2;
}
// ---------- Urgency chip (v19.0.24.8.0) --------------------------------------
// Always visible on every card; explains WHY it's at this sort position.
// Tones map to existing semantic colors. Critical bands (hot/overdue/bake_risk)
// pulse via the same `fp-timer-pulse` keyframes already shipped for timer chips.
//
// Light/dark mode: warning text branches at compile time on $o-webclient-color-scheme
// (same pattern as the timer chip). Other tones rely on $fp-* tokens / --bs-*
// CSS vars that flip automatically with the bundle.
$_fp-urg-warn-text-hex: #856404;
$_fp-urg-warn-bg-alpha: 0.20;
@if $o-webclient-color-scheme == dark {
$_fp-urg-warn-text-hex: #ffda6a !global;
$_fp-urg-warn-bg-alpha: 0.28 !global;
}
.o_fp_po_card_urgency {
display: inline-flex;
align-items: center;
gap: $fp-space-1;
margin: $fp-space-1 0 $fp-space-1;
padding: 2px $fp-space-2;
font-size: $fp-text-xs;
font-weight: $fp-weight-bold;
letter-spacing: 0.03em;
border-radius: $fp-radius-pill;
text-transform: uppercase;
line-height: 1.2;
i { font-size: 11px; }
// Tones (mirror timer chip)
&.o_fp_po_urg_tone_muted {
background: $fp-card-soft;
color: $fp-ink-faint;
font-weight: $fp-weight-medium;
text-transform: none;
letter-spacing: normal;
}
&.o_fp_po_urg_tone_info {
background: rgba(13, 110, 253, 0.14);
color: var(--bs-primary, #0d6efd);
}
&.o_fp_po_urg_tone_warning {
background: rgba(255, 193, 7, $_fp-urg-warn-bg-alpha);
color: $_fp-urg-warn-text-hex;
}
&.o_fp_po_urg_tone_danger {
background: rgba(220, 53, 69, 0.16);
color: var(--bs-danger, #c52131);
}
// Pulse for critical (HOT / OVERDUE / BAKE / paused-stuck-24h)
&.o_fp_po_urg_pulse {
animation: fp-timer-pulse 1.4s $fp-ease-out infinite;
position: relative;
z-index: 1;
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: $fp-radius-pill;
border: 2px solid currentColor;
opacity: 0;
animation: fp-timer-halo 1.6s $fp-ease-out infinite;
pointer-events: none;
}
}
}
// HOT band gets the fattest treatment — solid red fill, white text.
// Overrides the danger tone above so this band can't fade into the
// other danger chips.
.o_fp_po_card_urgency.o_fp_po_urg_hot {
background: var(--bs-danger, #c52131);
color: #fff;
box-shadow: 0 1px 4px rgba(220, 53, 69, 0.35);
}
@media (prefers-reduced-motion: reduce) {
.o_fp_po_urg_pulse {
animation: none;
&::after { animation: none; opacity: 0.45; }
}
}
// ---------- Card part / coating lines (v19.0.24.6.0) -------------------------
// Replaces the always-identical "[FP-SERVICE] Plating Service" line with the
// part number + coating spec the operator actually cares about. Both lines
// rely on $fp-ink / $fp-ink-mute tokens so they flip cleanly between the
// light and dark bundles — no hard-coded hex.
.o_fp_po_card_part {
display: flex;
align-items: center;
gap: $fp-space-1;
font-size: $fp-text-sm;
font-weight: $fp-weight-semibold;
color: $fp-ink;
margin-bottom: 2px;
line-height: 1.3;
.o_fp_po_card_part_icon {
font-size: 11px;
color: $fp-ink-mute;
}
.o_fp_po_card_part_rev {
font-weight: $fp-weight-medium;
font-size: $fp-text-xs;
color: $fp-ink-mute;
margin-left: $fp-space-1;
}
}
.o_fp_po_card_coating {
display: flex;
align-items: center;
gap: $fp-space-1;
font-size: $fp-text-xs;
color: $fp-ink-soft;
margin-bottom: $fp-space-2;
line-height: 1.3;
.o_fp_po_card_coating_icon {
font-size: 10px;
color: $fp-ink-faint;
}
}
.o_fp_po_card_no_part {
display: flex;
align-items: center;
gap: $fp-space-1;
font-style: italic;
color: $fp-ink-faint;
margin-bottom: $fp-space-2;
}
// Step-ordinal badge — separator + total in mute tone (1-based "4/9").
.o_fp_po_card_step_total {
font-weight: $fp-weight-medium;
color: $fp-ink-faint;
margin-left: 1px;
}
// ---------- Per-step timer chip (v19.0.24.5.0) -------------------------------
// Live-ticking elapsed-in-stage label. JS getCardTimer() picks the tone
// (muted/ok/warning/danger) and a `critical` flag that toggles the pulse
// animation. Critical = step is overrun (>1.5× expected), paused >24h, or
// queued >24h — any of those conditions need supervisor attention NOW.
//
// Light/dark mode: warning text needs different hex per bundle so it
// stays legible against the translucent yellow tint. Other tones use
// $fp-* tokens or --bs-* CSS vars which Odoo flips automatically.
$_fp-timer-warn-text-hex: #856404; // dark brown — readable on light card
$_fp-timer-warn-bg-alpha: 0.20;
@if $o-webclient-color-scheme == dark {
$_fp-timer-warn-text-hex: #ffda6a !global; // light yellow on dark card
$_fp-timer-warn-bg-alpha: 0.28 !global; // a touch more saturation
}
.o_fp_po_card_timer {
display: inline-flex;
align-items: center;
gap: $fp-space-1;
margin: $fp-space-1 0 $fp-space-2;
padding: 2px $fp-space-2;
font-size: $fp-text-xs;
font-weight: $fp-weight-semibold;
border-radius: $fp-radius-pill;
i { font-size: 11px; }
// Tones — backgrounds use rgba() with a low alpha so the underlying
// card surface tints through; text uses the strong hue.
&.o_fp_po_timer_muted {
background: $fp-card-soft;
color: $fp-ink-mute;
}
&.o_fp_po_timer_ok {
background: rgba(25, 135, 84, 0.14);
color: var(--bs-success, #198754);
}
&.o_fp_po_timer_warning {
background: rgba(255, 193, 7, $_fp-timer-warn-bg-alpha);
color: $_fp-timer-warn-text-hex;
}
&.o_fp_po_timer_danger {
background: rgba(220, 53, 69, 0.16);
color: var(--bs-danger, #c52131);
}
// Critical attention-grabber. Two layers of motion so it's hard to
// ignore: (a) the chip itself pulses scale+glow, (b) a soft halo
// expands behind it like a sonar ping. Honours prefers-reduced-motion.
&.o_fp_po_timer_critical {
animation: fp-timer-pulse 1.4s $fp-ease-out infinite;
position: relative;
z-index: 1;
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: $fp-radius-pill;
border: 2px solid var(--bs-danger, #c52131);
opacity: 0;
animation: fp-timer-halo 1.6s $fp-ease-out infinite;
pointer-events: none;
}
}
}
// Critical card halo — when ANY card carries a critical timer, give the
// whole card a subtle red border-glow so the supervisor can spot which
// card is the problem from across the room without scanning every chip.
.o_fp_po_card:has(.o_fp_po_timer_critical) {
box-shadow: $fp-elev-2,
0 0 0 2px rgba(220, 53, 69, 0.55),
0 0 18px rgba(220, 53, 69, 0.22);
animation: fp-card-attention 2.2s $fp-ease-out infinite;
}
@keyframes fp-timer-pulse {
0%, 100% { transform: scale(1.0); }
50% { transform: scale(1.06); }
}
@keyframes fp-timer-halo {
0% { transform: scale(0.92); opacity: 0.0; }
35% { transform: scale(1.05); opacity: 0.55; }
100% { transform: scale(1.30); opacity: 0.0; }
}
@keyframes fp-card-attention {
0%, 100% {
box-shadow: $fp-elev-2,
0 0 0 2px rgba(220, 53, 69, 0.55),
0 0 14px rgba(220, 53, 69, 0.18);
}
50% {
box-shadow: $fp-elev-2,
0 0 0 2px rgba(220, 53, 69, 0.85),
0 0 28px rgba(220, 53, 69, 0.42);
}
}
@media (prefers-reduced-motion: reduce) {
.o_fp_po_timer_critical {
animation: none;
&::after { animation: none; opacity: 0.45; }
}
.o_fp_po_card:has(.o_fp_po_timer_critical) {
animation: none;
}
}
.o_fp_po_card_footer {
display: flex; justify-content: space-between; align-items: center;
margin-top: $fp-space-2;

View File

@@ -106,19 +106,61 @@
<div class="o_fp_po_card_title">
<strong t-esc="card.customer_name || 'Walk-In'"/>
</div>
<span class="o_fp_po_card_step_badge" t-if="card.step_number">
<t t-esc="card.step_number"/>
<!-- 1-based step ordinal: "4/9" -->
<!-- not "40" (v19.0.24.6.0) -->
<span class="o_fp_po_card_step_badge"
t-if="card.step_index">
<t t-esc="card.step_index"/>
<span class="o_fp_po_card_step_total"
t-if="card.job_step_count">
/<t t-esc="card.job_step_count"/>
</span>
</span>
</div>
<!-- Urgency chip (v19.0.24.8.0) — always -->
<!-- visible. Explains WHY the card is at -->
<!-- this position in the sort. Critical -->
<!-- bands (HOT, OVERDUE, MISSED BAKE) -->
<!-- pulse to grab attention. -->
<div t-if="card.urgency_band"
t-att-class="'o_fp_po_card_urgency o_fp_po_urg_' + card.urgency_band + ' o_fp_po_urg_tone_' + card.urgency_tone + (card.urgency_pulse ? ' o_fp_po_urg_pulse' : '')"
t-att-title="'Urgency score: ' + card.urgency_score">
<i t-att-class="'fa ' + card.urgency_icon"/>
<span class="o_fp_po_card_urgency_label"
t-esc="card.urgency_label"/>
</div>
<!-- SO / WO refs + product name -->
<div class="o_fp_po_card_refs">
<span t-if="card.so_name" t-esc="card.so_name"/>
<span t-if="card.so_name and card.wo_name"> | </span>
<span t-if="card.wo_name" t-esc="card.wo_name"/>
</div>
<div class="o_fp_po_card_product text-muted small" t-if="card.product_name">
<t t-esc="card.product_name"/>
<!-- Useful per-card detail (v19.0.24.6.0). -->
<!-- Line 1: part number + revision (what -->
<!-- the operator is holding). Line 2: -->
<!-- coating spec (what process they're -->
<!-- running). Falls back to product name -->
<!-- only if neither is set (legacy data). -->
<div class="o_fp_po_card_part"
t-if="card.part_number">
<i class="fa fa-tag o_fp_po_card_part_icon"/>
<strong t-esc="card.part_number"/>
<span class="o_fp_po_card_part_rev"
t-if="card.part_revision">
rev <t t-esc="card.part_revision"/>
</span>
</div>
<div class="o_fp_po_card_coating"
t-if="card.coating_label">
<i class="fa fa-flask o_fp_po_card_coating_icon"/>
<t t-esc="card.coating_label"/>
</div>
<div class="o_fp_po_card_no_part text-muted small"
t-if="!card.part_number and !card.coating_label">
<i class="fa fa-question-circle"/>
No part / coating set on job
</div>
<!-- Parts progress -->
@@ -138,6 +180,18 @@
<t t-esc="card.step_display"/>
</div>
<!-- Per-step timer (v19.0.24.5.0). -->
<!-- Live-ticking elapsed in this stage, -->
<!-- color-coded by tone, with a critical -->
<!-- pulse animation on overrun / stuck. -->
<t t-set="t" t-value="getCardTimer(card)"/>
<div t-if="t.label"
t-att-class="'o_fp_po_card_timer o_fp_po_timer_' + t.tone + (t.critical ? ' o_fp_po_timer_critical' : '')"
t-att-title="card.timer_kind === 'running' ? 'Time in this step' : (card.timer_kind === 'paused' ? 'Time since paused' : 'Time queued in this stage')">
<i t-att-class="'fa ' + t.icon"/>
<span class="o_fp_po_timer_label" t-esc="t.label"/>
</div>
<!-- Last activity -->
<div class="o_fp_po_card_last text-muted"
t-if="card.last_operator">