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:
parent
bd9103c172
commit
a24f7d2c1e
595
api/routes.py
595
api/routes.py
@ -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
|
Grafana iframe (non-sensitive shop-floor data). Keep every route narrow and
|
||||||
read-only — NEVER proxy arbitrary SQL.
|
read-only — NEVER proxy arbitrary SQL.
|
||||||
|
|
||||||
── STATUS: BASE SCAFFOLD ────────────────────────────────────────────────────────
|
── STATUS: REAL DATA ─────────────────────────────────────────────────────────────
|
||||||
This returns realistic SAMPLE data shaped EXACTLY like the frontend contract
|
This binds the whole-line board to the live database, **un-scoped across presses**
|
||||||
(``src/types/overview.ts``) so the page renders end-to-end with no database. The
|
but scoped to one plant (``PLANT_NAME`` env, default ``Cadolzburg`` — same
|
||||||
``range`` query param scales only the "Fertig" (finished) figures, mirroring the
|
convention as ``modules/fastpress/press/_identity.py``). It reuses the temper
|
||||||
locked design (presses / on-the-way / oven are always real-time).
|
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
|
Data sources (all through the kernel SELECT-only proxy ``.._db.run_select_query``):
|
||||||
``.claude/memory/temper-line-overview.md``):
|
* ``press_settings.presses`` — the canonical press list for the plant.
|
||||||
* Import the kernel SELECT proxy: ``from .._db import run_select_query``
|
* ``production.active_orders`` — per-press active orders + produced good parts.
|
||||||
* Whole-line, un-scoped across presses. Reuse the temper status-derivation from
|
* ``production.rejects`` — reject totals per order (no plant column; keyed
|
||||||
``modules/fastpress/press/temper.py`` (``_temper_aggregates_all`` derives
|
by ``rejOrdernumber`` like the fastpress overview).
|
||||||
on_way / in_oven / finished from planned timestamps vs NOW()); read
|
* ``production.cycles_new`` — last cycle time + run/stop recency per press.
|
||||||
``production.temper_tracking`` (carts, grouped by ``ttCartId``, keyed by origin
|
* ``production.downtimes`` — the latest downtime reason per press (the note).
|
||||||
press ``ttPress``) + ``production.active_orders``/``finished_orders``.
|
* ``settings.downtime_reasons`` — reason texts (DE) for the note.
|
||||||
* "nichtVerladen" = produced good parts (cycles) − parts logged to temper
|
* ``production.temper_tracking`` — carts: on-the-way, in-oven, finished, and the
|
||||||
(open question: per-press or plant-wide — see the memory).
|
parts sent to temper (for "nicht verladen").
|
||||||
* Scope the finished aggregates by ``range`` (12h..30d) on ``ttOutOfOvenAt``.
|
|
||||||
* Never echo customer/price fields.
|
Decisions (confirmed with the user, 2026-07-02):
|
||||||
Alternatively, add a public ``GET /press/temper/overview`` in fastpress and have
|
* run/stop/idle = **cycle recency**. idle = no active order; run = a cycle ended
|
||||||
the page call that instead — decide before wiring; keep the payload shape below.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Query
|
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"])
|
router = APIRouter(prefix="/module/temper_overview/api", tags=["temper_overview"])
|
||||||
|
|
||||||
# ── Sample data (ported verbatim from the locked prototype) ─────────────────────
|
# ── Config (module-only; the generic kernel never reads these) ──────────────────
|
||||||
# Order identity: key → (Auftragsnummer, colour index 0..3). The 4 colours live in
|
# The plant this board scopes to — same convention as fastpress _identity.PLANT_NAME.
|
||||||
# the frontend (src/data/orderColors.ts) — the API only sends the index, so the
|
PLANT_NAME = os.getenv("PLANT_NAME", "Cadolzburg")
|
||||||
# page and legend always line up.
|
# A press counts as "running" if a cycle ended within this many minutes.
|
||||||
_ORDERS: dict[str, tuple[str, int]] = {
|
RUN_RECENCY_MINUTES = int(os.getenv("TEMPER_OVERVIEW_RUN_RECENCY_MIN", "10") or 10)
|
||||||
"o1": ("O-4471", 0),
|
# Fallback average oven duration when no cart carries a planned time yet.
|
||||||
"o2": ("O-4460", 1),
|
DEFAULT_OVEN_MINUTES = int(os.getenv("TEMPER_DEFAULT_MINUTES", "120") or 120)
|
||||||
"o3": ("O-4455", 2),
|
|
||||||
"o4": ("O-4489", 3),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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"
|
_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]]:
|
# ── Small pure helpers (unit-testable, no DB) ───────────────────────────────────
|
||||||
"""[(order_key, qty), ...] → [{orderNo, qty, colorIndex}, ...]."""
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
for key, qty in specs:
|
def _range_code(raw: str) -> str:
|
||||||
name, color = _ORDERS[key]
|
return raw if raw in _RANGE_MINUTES else _DEFAULT_RANGE
|
||||||
out.append({"orderNo": name, "qty": qty, "colorIndex": color})
|
|
||||||
|
|
||||||
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _order_chips(*keys: str) -> list[dict[str, Any]]:
|
def _fetch_rejects(ordernumbers: list[str]) -> dict[str, int]:
|
||||||
"""order keys → [{orderNo, colorIndex}, ...] (no qty — for the press header)."""
|
"""Reject totals per order. ``production.rejects`` has no plant column, so it is
|
||||||
return [{"orderNo": _ORDERS[k][0], "colorIndex": _ORDERS[k][1]} for k in keys]
|
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]] = [
|
def _fetch_last_cycle(plant: str) -> dict[str, dict[str, Any]]:
|
||||||
{"name": "P1", "status": "run", "orders": _order_chips("o1"), "good": 1240, "cycleTime": "4.2", "notVerladen": 34, "note": None, "placeholder": False},
|
"""Per press: the last cycle time (T_Cycle_Act of the newest cycle by id) and
|
||||||
{"name": "P2", "status": "run", "orders": _order_chips("o2"), "good": 880, "cycleTime": "6.1", "notVerladen": 18, "note": None, "placeholder": False},
|
whether it ended within ``RUN_RECENCY_MINUTES`` (recency resolved in SQL against
|
||||||
{"name": "P3", "status": "run", "orders": _order_chips("o1", "o4"), "good": 612, "cycleTime": "4.3", "notVerladen": 52, "note": None, "placeholder": False},
|
the DB clock). ``{press: {"ct": float|None, "recent": bool}}``."""
|
||||||
{"name": "P4", "status": "stop", "orders": _order_chips("o2"), "good": 430, "cycleTime": None, "notVerladen": 0, "note": "Werkzeug rüsten", "placeholder": False},
|
try:
|
||||||
{"name": "P5", "status": "run", "orders": _order_chips("o3"), "good": 1975, "cycleTime": "2.8", "notVerladen": 41, "note": None, "placeholder": False},
|
rows = run_select_query(
|
||||||
{"name": "P6", "status": "run", "orders": _order_chips("o2"), "good": 744, "cycleTime": "6.0", "notVerladen": 23, "note": None, "placeholder": False},
|
"SELECT c.Press AS press, c.T_Cycle_Act AS ct, "
|
||||||
{"name": "P7", "status": "idle", "orders": [], "good": 0, "cycleTime": None, "notVerladen": 0, "note": "Kein Auftrag", "placeholder": False},
|
"(c.DT_CycEnd >= NOW() - INTERVAL :recency MINUTE) AS recent "
|
||||||
{"name": "P8", "status": "run", "orders": _order_chips("o4"), "good": 1102, "cycleTime": "3.9", "notVerladen": 29, "note": None, "placeholder": False},
|
"FROM production.cycles_new c "
|
||||||
{"name": "P9", "status": "run", "orders": _order_chips("o1"), "good": 968, "cycleTime": "4.1", "notVerladen": 12, "note": None, "placeholder": False},
|
"JOIN (SELECT Press, MAX(id) AS mid FROM production.cycles_new "
|
||||||
{"name": "P10", "status": "idle", "orders": [], "good": 0, "cycleTime": None, "notVerladen": 0, "note": None, "placeholder": True},
|
" 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
|
||||||
|
|
||||||
# carts on the way, keyed by origin press (a press may have several → its column scrolls).
|
|
||||||
_ON_WAY: dict[str, list[dict[str, Any]]] = {
|
def _fetch_downtime_reasons(plant: str) -> dict[str, int]:
|
||||||
"P3": [{"cartId": "C-014a", "orders": _orders(("o1", 54), ("o2", 42)), "etaMin": 3}],
|
"""The latest downtime ``Reason_int`` per press (by ``DT_start``). Used only for
|
||||||
"P5": [{"cartId": "C-014b", "orders": _orders(("o3", 60)), "etaMin": 7}],
|
the press note text."""
|
||||||
"P8": [
|
try:
|
||||||
{"cartId": "C-015", "orders": _orders(("o1", 30), ("o4", 30), ("o2", 30), ("o3", 30)), "etaMin": 9},
|
rows = run_select_query(
|
||||||
{"cartId": "C-019", "orders": _orders(("o4", 36)), "etaMin": 14},
|
"SELECT d.Press AS press, d.Reason_int AS reason "
|
||||||
{"cartId": "C-020", "orders": _orders(("o1", 28)), "etaMin": 19},
|
"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
|
||||||
_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"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview", summary="Whole temper-line overview (SAMPLE data)")
|
def _fetch_finished_carts(plant: str, window_min: int) -> list[dict[str, Any]]:
|
||||||
def overview(range: str = Query(_DEFAULT_RANGE, description="finished-figures window: 12h|1d|7d|14d|30d")) -> dict[str, Any]:
|
"""Carts out of the oven within the selected window — one row per (cart,order),
|
||||||
code = range if range in _RANGE_FACTORS else _DEFAULT_RANGE
|
newest first. Excludes reprints."""
|
||||||
factor = _RANGE_FACTORS[code]
|
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 = {
|
finished = {
|
||||||
"carts": round(_FINISHED_BASE["carts"] * factor),
|
"carts": len(fin_carts),
|
||||||
"parts": round(_FINISHED_BASE["parts"] * factor),
|
"parts": finished_parts,
|
||||||
"scrap": round(_FINISHED_BASE["scrap"] * factor),
|
"scrap": finished_scrap,
|
||||||
"avgOvenMin": _FINISHED_BASE["avgOvenMin"],
|
"avgOvenMin": finished_avg,
|
||||||
"byOrder": [
|
"byOrder": by_order,
|
||||||
{"orderNo": _ORDERS[k][0], "colorIndex": _ORDERS[k][1], "parts": round(v * factor)}
|
|
||||||
for k, v in _FINISHED_BY_ORDER.items()
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
finished_list = [
|
||||||
"plant": "Werk 1",
|
{
|
||||||
"generatedAt": datetime.now().astimezone().isoformat(),
|
"cartId": cart["cartId"],
|
||||||
"range": code,
|
"orders": cart["orders"],
|
||||||
"ovenTempC": 185,
|
"ago": _fmt_ago(min((r["agoMin"] for r in cart["_rows"] if r["agoMin"] is not None), default=None)),
|
||||||
"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,
|
|
||||||
}
|
}
|
||||||
|
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
4
dist/index.html
vendored
@ -9,8 +9,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Temper-Linie · Live-Übersicht</title>
|
<title>Temper-Linie · Live-Übersicht</title>
|
||||||
<script type="module" crossorigin src="/module/temper_overview/assets/index-BOzN3Frr.js"></script>
|
<script type="module" crossorigin src="/module/temper_overview/assets/index-C3BK_Qev.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-xY8RtBtx.css">
|
<link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-BxZ3b12T.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import OrderLine from '@/components/OrderLine.vue'
|
import OrderLine from '@/components/OrderLine.vue'
|
||||||
import type { OvenBatch } from '@/types/overview'
|
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 { t } = useI18n()
|
||||||
|
|
||||||
const partsOf = (b: OvenBatch) => b.orders.reduce((s, o) => s + o.qty, 0)
|
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" />
|
<rect x="6" y="11" width="12" height="6" rx="1" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="t">{{ t('oven.title') }}</span>
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
|
|||||||
@ -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>
|
<span class="lbl"><b>{{ t('line.toOven') }}</b> → {{ t('line.toOvenTail') }} · {{ t('line.wagen', { n: wayCarts }) }}</span>
|
||||||
</div>
|
</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)' }">
|
<div class="link" :style="{ '--lc': 'var(--done)' }">
|
||||||
<span class="lbl"><b>{{ t('line.toStorage') }}</b> → {{ t('line.toStorageTail') }}</span>
|
<span class="lbl"><b>{{ t('line.toStorage') }}</b> → {{ t('line.toStorageTail') }}</span>
|
||||||
|
|||||||
@ -71,7 +71,8 @@ export interface OverviewPayload {
|
|||||||
/** ISO timestamp the payload was generated (tz-aware) */
|
/** ISO timestamp the payload was generated (tz-aware) */
|
||||||
generatedAt: string
|
generatedAt: string
|
||||||
range: RangeCode
|
range: RangeCode
|
||||||
ovenTempC: number
|
/** oven temperature in °C; omitted when no sensor feed exists → the chip is hidden */
|
||||||
|
ovenTempC?: number | null
|
||||||
ovenAvgMin: number
|
ovenAvgMin: number
|
||||||
/** true when the backend is returning sample data (base scaffold) */
|
/** true when the backend is returning sample data (base scaffold) */
|
||||||
sample?: boolean
|
sample?: boolean
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user