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,
}
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": "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,
"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,
"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