feat(overview): bind whole-line board to live temper data

Replace the sample-data scaffold with real SQL, un-scoped across all
presses and scoped to one plant (PLANT_NAME env, default Cadolzburg).

- api/routes.py: press list from press_settings.presses; per-press
  good/chips from active_orders + rejects; run/stop/idle by cycle
  recency (cycles_new); stop note from downtimes + downtime_reasons;
  on-way / in-oven / finished carts + nicht-verladen from
  temper_tracking (live position derived IN SQL from planned times vs
  NOW(); reprint rows excluded so carts never double-count). Thin
  _fetch_* helpers (degrade to empty, never 500) + a pure _build_payload.
- Oven temperature omitted (no sensor feed): ovenTempC optional in the
  type, °C chip hidden in OvenBand.vue, coerced in ProductionLine.vue.
- Rebuilt dist/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 13:50:03 +02:00
parent bd9103c172
commit a24f7d2c1e
7 changed files with 501 additions and 113 deletions

View File

@ -6,136 +6,523 @@ No ``verify_token`` dependency: this is the deliberate no-auth read surface for
Grafana iframe (non-sensitive shop-floor data). Keep every route narrow and
read-only NEVER proxy arbitrary SQL.
STATUS: BASE SCAFFOLD
This returns realistic SAMPLE data shaped EXACTLY like the frontend contract
(``src/types/overview.ts``) so the page renders end-to-end with no database. The
``range`` query param scales only the "Fertig" (finished) figures, mirroring the
locked design (presses / on-the-way / oven are always real-time).
STATUS: REAL DATA
This binds the whole-line board to the live database, **un-scoped across presses**
but scoped to one plant (``PLANT_NAME`` env, default ``Cadolzburg`` same
convention as ``modules/fastpress/press/_identity.py``). It reuses the temper
status-derivation the fastpress module already relies on: a cart's live position is
derived from its planned timestamps vs ``NOW()`` (``on_way`` while ``NOW() <
ttIntoOvenAt``; ``in_oven`` until ``ttOutOfOvenAt``; ``done`` after), computed in SQL
so the DB clock is the single source of truth and the timezone is never guessed.
TODO wire real data (see the gateway repo, this repo's README, and the memory
``.claude/memory/temper-line-overview.md``):
* Import the kernel SELECT proxy: ``from .._db import run_select_query``
* Whole-line, un-scoped across presses. Reuse the temper status-derivation from
``modules/fastpress/press/temper.py`` (``_temper_aggregates_all`` derives
on_way / in_oven / finished from planned timestamps vs NOW()); read
``production.temper_tracking`` (carts, grouped by ``ttCartId``, keyed by origin
press ``ttPress``) + ``production.active_orders``/``finished_orders``.
* "nichtVerladen" = produced good parts (cycles) parts logged to temper
(open question: per-press or plant-wide see the memory).
* Scope the finished aggregates by ``range`` (12h..30d) on ``ttOutOfOvenAt``.
* Never echo customer/price fields.
Alternatively, add a public ``GET /press/temper/overview`` in fastpress and have
the page call that instead decide before wiring; keep the payload shape below.
Data sources (all through the kernel SELECT-only proxy ``.._db.run_select_query``):
* ``press_settings.presses`` the canonical press list for the plant.
* ``production.active_orders`` per-press active orders + produced good parts.
* ``production.rejects`` reject totals per order (no plant column; keyed
by ``rejOrdernumber`` like the fastpress overview).
* ``production.cycles_new`` last cycle time + run/stop recency per press.
* ``production.downtimes`` the latest downtime reason per press (the note).
* ``settings.downtime_reasons`` reason texts (DE) for the note.
* ``production.temper_tracking`` carts: on-the-way, in-oven, finished, and the
parts sent to temper (for "nicht verladen").
Decisions (confirmed with the user, 2026-07-02):
* run/stop/idle = **cycle recency**. idle = no active order; run = a cycle ended
within ``RUN_RECENCY_MINUTES`` (env, default 10); else stop. The note text comes
from the latest ``downtimes.Reason_int`` (99 "Im Betrieb" is treated as "no note").
* The oven **temperature is omitted** (no sensor feed exists) the frontend hides
the °C chip. ``ovenAvgMin`` is a real planned-oven-minutes average.
* Reprint rows (``ttReprintOf IS NOT NULL``) are EXCLUDED from every parts/cart sum
so a re-printed label never double-counts its cart.
Never echoes customer/price fields only order number, article description, counts.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from typing import Any
from typing import Any, Optional
from fastapi import APIRouter, Query
from .._db import run_select_query
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/module/temper_overview/api", tags=["temper_overview"])
# ── Sample data (ported verbatim from the locked prototype) ─────────────────────
# Order identity: key → (Auftragsnummer, colour index 0..3). The 4 colours live in
# the frontend (src/data/orderColors.ts) — the API only sends the index, so the
# page and legend always line up.
_ORDERS: dict[str, tuple[str, int]] = {
"o1": ("O-4471", 0),
"o2": ("O-4460", 1),
"o3": ("O-4455", 2),
"o4": ("O-4489", 3),
}
# ── Config (module-only; the generic kernel never reads these) ──────────────────
# The plant this board scopes to — same convention as fastpress _identity.PLANT_NAME.
PLANT_NAME = os.getenv("PLANT_NAME", "Cadolzburg")
# A press counts as "running" if a cycle ended within this many minutes.
RUN_RECENCY_MINUTES = int(os.getenv("TEMPER_OVERVIEW_RUN_RECENCY_MIN", "10") or 10)
# Fallback average oven duration when no cart carries a planned time yet.
DEFAULT_OVEN_MINUTES = int(os.getenv("TEMPER_DEFAULT_MINUTES", "120") or 120)
# range code → factor applied to the finished figures only (from the prototype).
_RANGE_FACTORS: dict[str, float] = {"12h": 0.5, "1d": 1.0, "7d": 6.4, "14d": 12.7, "30d": 27.2}
_DEFAULT_RANGE = "1d"
# finished-figures window → minutes back from NOW() (mirrors the design's picker).
_RANGE_MINUTES: dict[str, int] = {
"12h": 12 * 60,
"1d": 24 * 60,
"7d": 7 * 24 * 60,
"14d": 14 * 24 * 60,
"30d": 30 * 24 * 60,
}
# Reason_int values that mean "running / no stoppage" → not shown as a stop note.
_RUNNING_REASONS = {99}
# How many recent finished carts to list in the "Fertig/Lager" band.
_FINISHED_LIST_LIMIT = 8
def _orders(*specs: tuple[str, int]) -> list[dict[str, Any]]:
"""[(order_key, qty), ...] → [{orderNo, qty, colorIndex}, ...]."""
out: list[dict[str, Any]] = []
for key, qty in specs:
name, color = _ORDERS[key]
out.append({"orderNo": name, "qty": qty, "colorIndex": color})
# ── Small pure helpers (unit-testable, no DB) ───────────────────────────────────
def _range_code(raw: str) -> str:
return raw if raw in _RANGE_MINUTES else _DEFAULT_RANGE
def _color_map(*ordernumbers: str) -> dict[str, int]:
"""Assign each distinct order number a colour INDEX 0..3 (the frontend maps the
index hex via ``src/data/orderColors.ts``). Sorted so the mapping is stable for
a given SET of orders and distinct for up to four concurrent orders; it wraps
beyond four. Falsy names are ignored."""
names = sorted({o for o in ordernumbers if o})
return {name: i % 4 for i, name in enumerate(names)}
def _fmt_ago(minutes: Optional[int]) -> str:
"""German 'time ago' label from whole minutes (e.g. 8 → 'vor 8 min', 66 → 'vor
1 h 6 min'). ``None``/negative collapses to 'gerade eben'."""
if minutes is None or minutes < 1:
return "gerade eben"
if minutes < 60:
return f"vor {minutes} min"
h, m = divmod(minutes, 60)
return f"vor {h} h {m} min" if m else f"vor {h} h"
def _portion(orderno: str, qty: Any, colors: dict[str, int]) -> dict[str, Any]:
return {"orderNo": orderno, "qty": int(qty or 0), "colorIndex": colors.get(orderno, 0)}
def _group_carts(rows: list[dict[str, Any]], colors: dict[str, int]) -> "dict[str, dict[str, Any]]":
"""Group temper rows into physical carts by ``cartId`` (falling back to ``#ttId``
when a row predates the shared cart id). Preserves first-seen order. Returns
``{displayCartId: {"cartId", "orders": [portion...], "_rows": [row...]}}`` the
caller reads whatever cart-level fields it needs (eta / remaining / ago) off the
grouped rows (all rows of one cart share the same planned timestamps)."""
carts: dict[str, dict[str, Any]] = {}
for r in rows:
cid = r.get("cartId") or f"#{r['ttId']}"
cart = carts.get(cid)
if cart is None:
cart = {"cartId": cid, "orders": [], "_rows": []}
carts[cid] = cart
cart["orders"].append(_portion(r["orderno"], r["qty"], colors))
cart["_rows"].append(r)
return carts
# ── SQL fetches (each degrades to empty on failure so the board never 500s) ─────
def _fetch_presses(plant: str) -> list[str]:
try:
rows = run_select_query(
"SELECT psPress AS press FROM press_settings.presses WHERE psPlant = :plant "
"ORDER BY CAST(psPress AS UNSIGNED), psPress",
{"plant": plant},
)
except Exception as e: # noqa: BLE001
logger.warning("temper_overview: press list unavailable: %s", e)
return []
return [str(r["press"]) for r in rows if r.get("press") is not None]
def _fetch_active_orders(plant: str) -> list[dict[str, Any]]:
try:
rows = run_select_query(
"SELECT aoPress AS press, aoOrdernumber AS orderno, aoArticleDesc AS descr, "
"COALESCE(aoActualCycles,0) AS actual, COALESCE(aoCavities,0) AS cavities "
"FROM production.active_orders WHERE aoPlant = :plant "
"ORDER BY aoPress, aoOrdernumber",
{"plant": plant},
)
except Exception as e: # noqa: BLE001
logger.warning("temper_overview: active orders unavailable: %s", e)
return []
out = []
for r in rows:
cavities = int(r["cavities"] or 0)
out.append(
{
"press": str(r["press"]),
"orderno": r["orderno"],
"produced": int(r["actual"] or 0) * cavities,
}
)
return out
def _order_chips(*keys: str) -> list[dict[str, Any]]:
"""order keys → [{orderNo, colorIndex}, ...] (no qty — for the press header)."""
return [{"orderNo": _ORDERS[k][0], "colorIndex": _ORDERS[k][1]} for k in keys]
def _fetch_rejects(ordernumbers: list[str]) -> dict[str, int]:
"""Reject totals per order. ``production.rejects`` has no plant column, so it is
keyed by ``rejOrdernumber`` (as the fastpress overview does) over just this plant's
active orders."""
if not ordernumbers:
return {}
placeholders = ", ".join(f":o{i}" for i in range(len(ordernumbers)))
binds = {f"o{i}": o for i, o in enumerate(ordernumbers)}
try:
rows = run_select_query(
"SELECT rejOrdernumber AS orderno, COALESCE(SUM(rejQuantity),0) AS qty "
f"FROM production.rejects WHERE rejOrdernumber IN ({placeholders}) "
"GROUP BY rejOrdernumber",
binds,
)
except Exception as e: # noqa: BLE001
logger.warning("temper_overview: rejects unavailable: %s", e)
return {}
return {r["orderno"]: int(r["qty"] or 0) for r in rows}
_PRESSES: list[dict[str, Any]] = [
{"name": "P1", "status": "run", "orders": _order_chips("o1"), "good": 1240, "cycleTime": "4.2", "notVerladen": 34, "note": None, "placeholder": False},
{"name": "P2", "status": "run", "orders": _order_chips("o2"), "good": 880, "cycleTime": "6.1", "notVerladen": 18, "note": None, "placeholder": False},
{"name": "P3", "status": "run", "orders": _order_chips("o1", "o4"), "good": 612, "cycleTime": "4.3", "notVerladen": 52, "note": None, "placeholder": False},
{"name": "P4", "status": "stop", "orders": _order_chips("o2"), "good": 430, "cycleTime": None, "notVerladen": 0, "note": "Werkzeug rüsten", "placeholder": False},
{"name": "P5", "status": "run", "orders": _order_chips("o3"), "good": 1975, "cycleTime": "2.8", "notVerladen": 41, "note": None, "placeholder": False},
{"name": "P6", "status": "run", "orders": _order_chips("o2"), "good": 744, "cycleTime": "6.0", "notVerladen": 23, "note": None, "placeholder": False},
{"name": "P7", "status": "idle", "orders": [], "good": 0, "cycleTime": None, "notVerladen": 0, "note": "Kein Auftrag", "placeholder": False},
{"name": "P8", "status": "run", "orders": _order_chips("o4"), "good": 1102, "cycleTime": "3.9", "notVerladen": 29, "note": None, "placeholder": False},
{"name": "P9", "status": "run", "orders": _order_chips("o1"), "good": 968, "cycleTime": "4.1", "notVerladen": 12, "note": None, "placeholder": False},
{"name": "P10", "status": "idle", "orders": [], "good": 0, "cycleTime": None, "notVerladen": 0, "note": None, "placeholder": True},
]
# carts on the way, keyed by origin press (a press may have several → its column scrolls).
_ON_WAY: dict[str, list[dict[str, Any]]] = {
"P3": [{"cartId": "C-014a", "orders": _orders(("o1", 54), ("o2", 42)), "etaMin": 3}],
"P5": [{"cartId": "C-014b", "orders": _orders(("o3", 60)), "etaMin": 7}],
"P8": [
{"cartId": "C-015", "orders": _orders(("o1", 30), ("o4", 30), ("o2", 30), ("o3", 30)), "etaMin": 9},
{"cartId": "C-019", "orders": _orders(("o4", 36)), "etaMin": 14},
{"cartId": "C-020", "orders": _orders(("o1", 28)), "etaMin": 19},
],
}
_OVEN: list[dict[str, Any]] = [
{"cartId": "C-011", "orders": _orders(("o1", 88)), "totalMin": 120, "remainingMin": 42},
{"cartId": "C-012", "orders": _orders(("o2", 60), ("o4", 44)), "totalMin": 120, "remainingMin": 71},
{"cartId": "C-013", "orders": _orders(("o3", 72)), "totalMin": 120, "remainingMin": 12},
{"cartId": "C-016", "orders": _orders(("o1", 40), ("o2", 36)), "totalMin": 120, "remainingMin": 98},
{"cartId": "C-017", "orders": _orders(("o4", 80)), "totalMin": 120, "remainingMin": 104},
]
_FINISHED_BASE = {"carts": 47, "parts": 5120, "scrap": 63, "avgOvenMin": 118}
_FINISHED_BY_ORDER: dict[str, int] = {"o1": 1680, "o2": 1290, "o3": 1150, "o4": 1000}
_FINISHED_LIST: list[dict[str, Any]] = [
{"cartId": "C-010", "orders": _orders(("o2", 50), ("o3", 40)), "ago": "vor 8 min"},
{"cartId": "C-009", "orders": _orders(("o1", 64)), "ago": "vor 21 min"},
{"cartId": "C-008", "orders": _orders(("o4", 58)), "ago": "vor 35 min"},
{"cartId": "C-007", "orders": _orders(("o2", 44), ("o1", 20)), "ago": "vor 52 min"},
{"cartId": "C-006", "orders": _orders(("o3", 72)), "ago": "vor 1 h 6 min"},
]
def _fetch_last_cycle(plant: str) -> dict[str, dict[str, Any]]:
"""Per press: the last cycle time (T_Cycle_Act of the newest cycle by id) and
whether it ended within ``RUN_RECENCY_MINUTES`` (recency resolved in SQL against
the DB clock). ``{press: {"ct": float|None, "recent": bool}}``."""
try:
rows = run_select_query(
"SELECT c.Press AS press, c.T_Cycle_Act AS ct, "
"(c.DT_CycEnd >= NOW() - INTERVAL :recency MINUTE) AS recent "
"FROM production.cycles_new c "
"JOIN (SELECT Press, MAX(id) AS mid FROM production.cycles_new "
" WHERE Plant = :plant GROUP BY Press) m "
" ON c.id = m.mid AND c.Press = m.Press "
"WHERE c.Plant = :plant",
{"plant": plant, "recency": RUN_RECENCY_MINUTES},
)
except Exception as e: # noqa: BLE001
logger.warning("temper_overview: cycle recency unavailable: %s", e)
return {}
out: dict[str, dict[str, Any]] = {}
for r in rows:
try:
ct = round(float(r["ct"]), 1) if r["ct"] is not None else None
except (TypeError, ValueError):
ct = None
out[str(r["press"])] = {"ct": ct, "recent": bool(r["recent"])}
return out
@router.get("/overview", summary="Whole temper-line overview (SAMPLE data)")
def overview(range: str = Query(_DEFAULT_RANGE, description="finished-figures window: 12h|1d|7d|14d|30d")) -> dict[str, Any]:
code = range if range in _RANGE_FACTORS else _DEFAULT_RANGE
factor = _RANGE_FACTORS[code]
def _fetch_downtime_reasons(plant: str) -> dict[str, int]:
"""The latest downtime ``Reason_int`` per press (by ``DT_start``). Used only for
the press note text."""
try:
rows = run_select_query(
"SELECT d.Press AS press, d.Reason_int AS reason "
"FROM production.downtimes d "
"JOIN (SELECT Press, MAX(DT_start) AS mx FROM production.downtimes "
" WHERE Plant = :plant GROUP BY Press) m "
" ON d.Press = m.Press AND d.DT_start = m.mx "
"WHERE d.Plant = :plant",
{"plant": plant},
)
except Exception as e: # noqa: BLE001
logger.warning("temper_overview: downtime reasons unavailable: %s", e)
return {}
out: dict[str, int] = {}
for r in rows:
p = str(r["press"])
if p not in out and r["reason"] is not None: # first wins on a DT_start tie
out[p] = int(r["reason"])
return out
def _fetch_reason_texts() -> dict[int, str]:
try:
rows = run_select_query(
"SELECT id, reason_DE AS de FROM settings.downtime_reasons", {}
)
except Exception as e: # noqa: BLE001
logger.warning("temper_overview: reason texts unavailable: %s", e)
return {}
return {int(r["id"]): r["de"] for r in rows if r.get("de")}
def _fetch_temper_sent(plant: str) -> dict[tuple[str, str], int]:
"""Good parts sent to temper per (press, order) — the basis for 'nicht verladen'.
Excludes reprint rows so a re-printed label doesn't double-count."""
try:
rows = run_select_query(
"SELECT ttPress AS press, ttOrdernumber AS orderno, "
"COALESCE(SUM(ttPartsGood),0) AS sent "
"FROM production.temper_tracking "
"WHERE ttPlant = :plant AND ttReprintOf IS NULL "
"GROUP BY ttPress, ttOrdernumber",
{"plant": plant},
)
except Exception as e: # noqa: BLE001
logger.warning("temper_overview: temper-sent aggregate unavailable: %s", e)
return {}
return {(str(r["press"]), r["orderno"]): int(r["sent"] or 0) for r in rows}
def _fetch_active_carts(plant: str) -> list[dict[str, Any]]:
"""Carts not yet out of the oven — on-the-way + in-oven — one row per (cart,order).
Live position + eta/remaining minutes are derived in SQL from the planned times."""
try:
rows = run_select_query(
"SELECT ttCartId AS cartId, ttId, ttPress AS press, ttOrdernumber AS orderno, "
"COALESCE(ttPartsGood,0) AS qty, COALESCE(ttTemperMinutes,0) AS totalMin, "
"CASE WHEN NOW() < ttIntoOvenAt THEN 'on_way' ELSE 'in_oven' END AS st, "
"CEIL(GREATEST(0, TIMESTAMPDIFF(SECOND, NOW(), ttIntoOvenAt)) / 60) AS etaMin, "
"CEIL(GREATEST(0, TIMESTAMPDIFF(SECOND, NOW(), ttOutOfOvenAt)) / 60) AS remainingMin "
"FROM production.temper_tracking "
"WHERE ttPlant = :plant AND ttReprintOf IS NULL "
" AND ttIntoOvenAt IS NOT NULL AND ttOutOfOvenAt IS NOT NULL "
" AND NOW() < ttOutOfOvenAt "
"ORDER BY ttIntoOvenAt, ttId",
{"plant": plant},
)
except Exception as e: # noqa: BLE001
logger.warning("temper_overview: active carts unavailable: %s", e)
return []
return [
{
"cartId": r.get("cartId"),
"ttId": r["ttId"],
"press": str(r["press"]),
"orderno": r["orderno"],
"qty": int(r["qty"] or 0),
"totalMin": int(r["totalMin"] or 0),
"st": r["st"],
"etaMin": int(r["etaMin"] or 0),
"remainingMin": int(r["remainingMin"] or 0),
}
for r in rows
]
def _fetch_finished_carts(plant: str, window_min: int) -> list[dict[str, Any]]:
"""Carts out of the oven within the selected window — one row per (cart,order),
newest first. Excludes reprints."""
try:
rows = run_select_query(
"SELECT ttCartId AS cartId, ttId, ttPress AS press, ttOrdernumber AS orderno, "
"COALESCE(ttPartsGood,0) AS qty, COALESCE(ttScrap,0) AS scrap, "
"COALESCE(ttTemperMinutes,0) AS temperMin, "
"TIMESTAMPDIFF(MINUTE, ttOutOfOvenAt, NOW()) AS agoMin "
"FROM production.temper_tracking "
"WHERE ttPlant = :plant AND ttReprintOf IS NULL "
" AND ttOutOfOvenAt IS NOT NULL AND NOW() >= ttOutOfOvenAt "
" AND ttOutOfOvenAt >= NOW() - INTERVAL :window_min MINUTE "
"ORDER BY ttOutOfOvenAt DESC, ttId DESC",
{"plant": plant, "window_min": window_min},
)
except Exception as e: # noqa: BLE001
logger.warning("temper_overview: finished carts unavailable: %s", e)
return []
return [
{
"cartId": r.get("cartId"),
"ttId": r["ttId"],
"press": str(r["press"]),
"orderno": r["orderno"],
"qty": int(r["qty"] or 0),
"scrap": int(r["scrap"] or 0),
"temperMin": int(r["temperMin"] or 0),
"agoMin": int(r["agoMin"]) if r["agoMin"] is not None else None,
}
for r in rows
]
# ── Pure payload builder (no DB — testable with fabricated rows) ────────────────
def _build_payload(
*,
plant: str,
range_code: str,
presses: list[str],
active_orders: list[dict[str, Any]],
rejects: dict[str, int],
last_cycle: dict[str, dict[str, Any]],
downtime_reason: dict[str, int],
reason_texts: dict[int, str],
temper_sent: dict[tuple[str, str], int],
active_carts: list[dict[str, Any]],
finished_carts: list[dict[str, Any]],
generated_at: str,
) -> dict[str, Any]:
"""Assemble the whole-line payload from already-fetched rows. Pure: given the same
rows it always produces the same dict (the only I/O-derived input is ``generated_at``)."""
# One stable colour index per order number, over every order that appears anywhere.
colors = _color_map(
*[o["orderno"] for o in active_orders],
*[c["orderno"] for c in active_carts],
*[c["orderno"] for c in finished_carts],
)
# Per-press good parts + order chips, folding in rejects (produced good = produced rejects).
good_by_press: dict[str, int] = {}
chips_by_press: dict[str, list[dict[str, Any]]] = {}
not_verladen_by_press: dict[str, int] = {}
seen_chip: dict[str, set] = {}
for o in active_orders:
press, orderno = o["press"], o["orderno"]
good = max(0, o["produced"] - rejects.get(orderno, 0))
good_by_press[press] = good_by_press.get(press, 0) + good
sent = temper_sent.get((press, orderno), 0)
not_verladen_by_press[press] = not_verladen_by_press.get(press, 0) + max(0, good - sent)
seen = seen_chip.setdefault(press, set())
if orderno not in seen:
seen.add(orderno)
chips_by_press.setdefault(press, []).append(
{"orderNo": orderno, "colorIndex": colors.get(orderno, 0)}
)
presses_with_order = {o["press"] for o in active_orders}
presses_out: list[dict[str, Any]] = []
for press in presses:
has_order = press in presses_with_order
cyc = last_cycle.get(press, {})
recent = bool(cyc.get("recent"))
if not has_order:
# No active order → the defining fact is "no order". Match the design mock
# (P7: idle · Kein Auftrag) and ignore any stale downtime reason.
status = "idle"
note = "Kein Auftrag"
elif recent:
status = "run"
note = None
else:
status = "stop"
reason = downtime_reason.get(press)
note = reason_texts.get(reason) if (reason is not None and reason not in _RUNNING_REASONS) else None
presses_out.append(
{
"name": press,
"status": status,
"orders": chips_by_press.get(press, []),
"good": good_by_press.get(press, 0),
"cycleTime": (f"{cyc['ct']:.1f}" if (status == "run" and cyc.get("ct") is not None) else None),
"notVerladen": not_verladen_by_press.get(press, 0),
"note": note,
"placeholder": False,
}
)
# On-the-way carts, keyed by origin press. Each cart's eta = its planned into-oven.
on_way_rows = [c for c in active_carts if c["st"] == "on_way"]
on_way: dict[str, list[dict[str, Any]]] = {}
for press in {c["press"] for c in on_way_rows}:
carts = _group_carts([c for c in on_way_rows if c["press"] == press], colors)
on_way[press] = [
{
"cartId": cart["cartId"],
"orders": cart["orders"],
"etaMin": min(r["etaMin"] for r in cart["_rows"]),
}
for cart in carts.values()
]
# In-oven batches (flat; the frontend sorts soonest-out first).
oven_rows = [c for c in active_carts if c["st"] == "in_oven"]
oven_carts = _group_carts(oven_rows, colors)
oven: list[dict[str, Any]] = []
for cart in oven_carts.values():
rows = cart["_rows"]
total_min = max((r["totalMin"] for r in rows), default=0) or DEFAULT_OVEN_MINUTES
oven.append(
{
"cartId": cart["cartId"],
"orders": cart["orders"],
"totalMin": total_min,
"remainingMin": min(r["remainingMin"] for r in rows),
}
)
# Finished figures for the window.
fin_carts = _group_carts(finished_carts, colors)
finished_parts = sum(c["qty"] for c in finished_carts)
finished_scrap = sum(c["scrap"] for c in finished_carts)
temper_mins = [c["temperMin"] for c in finished_carts if c["temperMin"] > 0]
finished_avg = round(sum(temper_mins) / len(temper_mins)) if temper_mins else DEFAULT_OVEN_MINUTES
by_order_parts: dict[str, int] = {}
for c in finished_carts:
by_order_parts[c["orderno"]] = by_order_parts.get(c["orderno"], 0) + c["qty"]
by_order = [
{"orderNo": o, "colorIndex": colors.get(o, 0), "parts": p}
for o, p in sorted(by_order_parts.items(), key=lambda kv: kv[1], reverse=True)
]
finished = {
"carts": round(_FINISHED_BASE["carts"] * factor),
"parts": round(_FINISHED_BASE["parts"] * factor),
"scrap": round(_FINISHED_BASE["scrap"] * factor),
"avgOvenMin": _FINISHED_BASE["avgOvenMin"],
"byOrder": [
{"orderNo": _ORDERS[k][0], "colorIndex": _ORDERS[k][1], "parts": round(v * factor)}
for k, v in _FINISHED_BY_ORDER.items()
],
"carts": len(fin_carts),
"parts": finished_parts,
"scrap": finished_scrap,
"avgOvenMin": finished_avg,
"byOrder": by_order,
}
return {
"plant": "Werk 1",
"generatedAt": datetime.now().astimezone().isoformat(),
"range": code,
"ovenTempC": 185,
"ovenAvgMin": 120,
"sample": True, # frontend shows a "Demo-Daten" hint while this is set
"presses": _PRESSES,
"onWay": _ON_WAY,
"oven": _OVEN,
"finished": finished,
"finishedList": _FINISHED_LIST,
finished_list = [
{
"cartId": cart["cartId"],
"orders": cart["orders"],
"ago": _fmt_ago(min((r["agoMin"] for r in cart["_rows"] if r["agoMin"] is not None), default=None)),
}
for cart in list(fin_carts.values())[:_FINISHED_LIST_LIMIT]
]
# Header oven average = mean planned minutes of the carts currently in the oven,
# falling back to the finished-window average, then the global default.
in_oven_mins = [b["totalMin"] for b in oven if b["totalMin"] > 0]
oven_avg_min = round(sum(in_oven_mins) / len(in_oven_mins)) if in_oven_mins else finished_avg
return {
"plant": plant,
"generatedAt": generated_at,
"range": range_code,
# ovenTempC intentionally omitted — no sensor feed; the frontend hides the chip.
"ovenAvgMin": oven_avg_min,
"sample": False,
"presses": presses_out,
"onWay": on_way,
"oven": oven,
"finished": finished,
"finishedList": finished_list,
}
@router.get("/overview", summary="Whole temper-line overview (live data)")
def overview(range: str = Query(_DEFAULT_RANGE, description="finished-figures window: 12h|1d|7d|14d|30d")) -> dict[str, Any]:
code = _range_code(range)
window_min = _RANGE_MINUTES[code]
plant = PLANT_NAME
presses = _fetch_presses(plant)
active_orders = _fetch_active_orders(plant)
rejects = _fetch_rejects([o["orderno"] for o in active_orders])
last_cycle = _fetch_last_cycle(plant)
downtime_reason = _fetch_downtime_reasons(plant)
reason_texts = _fetch_reason_texts()
temper_sent = _fetch_temper_sent(plant)
active_carts = _fetch_active_carts(plant)
finished_carts = _fetch_finished_carts(plant, window_min)
return _build_payload(
plant=plant,
range_code=code,
presses=presses,
active_orders=active_orders,
rejects=rejects,
last_cycle=last_cycle,
downtime_reason=downtime_reason,
reason_texts=reason_texts,
temper_sent=temper_sent,
active_carts=active_carts,
finished_carts=finished_carts,
generated_at=datetime.now().astimezone().isoformat(),
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -9,8 +9,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Temper-Linie · Live-Übersicht</title>
<script type="module" crossorigin src="/module/temper_overview/assets/index-BOzN3Frr.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-xY8RtBtx.css">
<script type="module" crossorigin src="/module/temper_overview/assets/index-C3BK_Qev.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-BxZ3b12T.css">
</head>
<body>
<div id="app"></div>

View File

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import OrderLine from '@/components/OrderLine.vue'
import type { OvenBatch } from '@/types/overview'
const props = defineProps<{ batches: OvenBatch[]; tempC: number; avgMin: number }>()
const props = defineProps<{ batches: OvenBatch[]; tempC?: number | null; avgMin: number }>()
const { t } = useI18n()
const partsOf = (b: OvenBatch) => b.orders.reduce((s, o) => s + o.qty, 0)
@ -38,7 +38,7 @@ function trayStyle(b: OvenBatch) {
<rect x="6" y="11" width="12" height="6" rx="1" />
</svg>
<span class="t">{{ t('oven.title') }}</span>
<span class="flame">
<span v-if="tempC != null" class="flame">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3c1 3-2 4-2 7a2 2 0 0 0 4 0c0-1 0-1 .3-1.8C16 11 17 13 17 15a5 5 0 0 1-10 0c0-4 5-7 5-12z" />
</svg>

View File

@ -39,7 +39,7 @@ function cartsFor(pressName: string): CartOnWay[] {
<span class="lbl"><b>{{ t('line.toOven') }}</b> {{ t('line.toOvenTail') }} · {{ t('line.wagen', { n: wayCarts }) }}</span>
</div>
<OvenBand :batches="payload.oven" :temp-c="payload.ovenTempC" :avg-min="payload.ovenAvgMin" />
<OvenBand :batches="payload.oven" :temp-c="payload.ovenTempC ?? null" :avg-min="payload.ovenAvgMin" />
<div class="link" :style="{ '--lc': 'var(--done)' }">
<span class="lbl"><b>{{ t('line.toStorage') }}</b> {{ t('line.toStorageTail') }}</span>

View File

@ -71,7 +71,8 @@ export interface OverviewPayload {
/** ISO timestamp the payload was generated (tz-aware) */
generatedAt: string
range: RangeCode
ovenTempC: number
/** oven temperature in °C; omitted when no sensor feed exists → the chip is hidden */
ovenTempC?: number | null
ovenAvgMin: number
/** true when the backend is returning sample data (base scaffold) */
sample?: boolean