SPA_AfterTemperRejection/api/routes.py
Erik 5f8bbe18a4 feat: Grafana-embed mode — hide native picker, follow Grafana time range
With ?embed=grafana on the URL the terminal hides its in-page window picker and drives the listed-carts window from Grafana's exact from/to (epoch ms) instead of a preset. Backend /overview now accepts from/to (aliased) via FROM_UNIXTIME, which resolves in the same DB session clock as NOW() so timezone handling is unchanged. Standalone behaviour (own picker + preset window) is untouched. Includes rebuilt dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:14:15 +02:00

643 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""No-auth JSON API for the Ausschuss-Station (central reject-booking terminal).
Served same-origin under ``/module/temper_rejects``. Three routes:
* GET /module/temper_rejects/api/overview?window=7T → tempered carts (per ttId)
* GET /module/temper_rejects/api/reasons → active reject reasons
* POST /module/temper_rejects/api/reject → book one Ausschuss
No ``verify_token`` dependency: this is a central login-free terminal (no device
context). ``press`` + ``orderNo`` come from the request BODY, unlike the device-scoped
``POST /press/rejects/send`` in fastpress (which derives the press from the JWT). The
booking is recorded against the ORDER (``rejOrdernumber`` + ``rejPress``) — the same
key PressV has always used — so it needs NO schema change (the cart ``ttId`` is shown
for context only, never written).
── LIVE DATA ─────────────────────────────────────────────────────────────────────
Reads come from the same tables + the same time-derived temper status as
fastpress (``modules/fastpress/press/temper.py`` / ``rejects.py``), re-implemented
here so the module stays self-contained (it ships as its own repo and must NOT
import the sibling fastpress package). The SQL is faithful to those sources:
* OVERVIEW: ``production.temper_tracking`` across ALL presses of the plant
(``PLANT_NAME``), one row per ``ttId``, DONE carts (``NOW() >= ttOutOfOvenAt``)
within the window, newest first. Each entry carries its order's 5-station
temper distribution (belt), derived from the planned timestamps vs ``NOW()``.
* REASONS: ``SELECT id, reason_de, reason_en FROM settings.reject_reasons WHERE active=1``.
* REJECT: resolve latest cycle + article + reason texts, then one INSERT into
``production.rejects`` inside a ``transaction()`` — identical shape to fastpress
``send_reject``, but ``press`` comes from the body (login-free central terminal).
Belt mapping (5 UI nodes, 4 real quantities): the live model tracks ONE pre-oven
quantity — the buffer/Wagenzeit before ``ttIntoOvenAt`` (there is no scan data to
split "sitting on the cart" from "moving to the oven"). Per the design decision it
is shown under **Unterwegs** (amber); **Wagen** (cyan) stays 0. See
``.claude/memory/ausschuss-station.md``.
Security posture of the login-free write: network / reverse-proxy restriction only
(no token guard) — consistent with the rest of this module. Revisit if the station
is ever exposed beyond the shop-floor LAN.
"""
from __future__ import annotations
import logging
import os
from collections import defaultdict
from datetime import datetime
from typing import Any, Optional
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import text
from starlette import status
from .._db import run_select_query, cached_select, transaction
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/module/temper_rejects/api", tags=["temper_rejects"])
# The plant this station serves. Un-scoped across presses, scoped to ONE plant —
# same per-deployment constant fastpress reads (``_identity.PLANT_NAME``). The
# generic kernel never looks at it.
_PLANT = os.getenv("PLANT_NAME", "Cadolzburg")
# window code → minutes (max 30 days). Scopes which finished carts are LISTED
# (the belt aggregates the whole order regardless of the window).
_WINDOW_MINUTES: dict[str, int] = {"12h": 720, "1T": 1440, "7T": 10080, "14T": 20160, "30T": 43200}
_DEFAULT_WINDOW = "7T"
def _abs_window_label(from_ms: int, to_ms: int) -> str:
"""Compact human label for an absolute (Grafana) window, returned in `window`
where the preset code used to be — e.g. '26 h', '3 d'. Purely cosmetic."""
minutes = max(0, round((to_ms - from_ms) / 60000))
if minutes < 90:
return f"{minutes} min"
hours = minutes / 60
return f"{round(hours)} h" if hours < 48 else f"{round(hours / 24)} d"
# Cap the listed carts so a wide window can't return an unbounded payload (the
# frontend renders one card per entry). Newest-first, so the cap drops the oldest.
_MAX_ENTRIES = 500
def _iso_aware(v: Any) -> Any:
"""Serialise a naive DB ``DATETIME`` as a timezone-AWARE ISO string.
The temper times are stored as the gateway's local wall clock in naive
``DATETIME`` columns; emitting them naive makes the browser guess the zone
(it assumes UTC → the Protokoll shifts by the local offset). ``astimezone()``
stamps the server's actual offset so the frontend converts back correctly.
Mirrors ``fastpress temper._iso_aware``."""
return v.astimezone().isoformat() if isinstance(v, datetime) else v
# ── press filter list ───────────────────────────────────────────────────────────
def _press_list(plant: str, entry_presses: set[str]) -> list[str]:
"""The presses shown as filter checkboxes (default all).
Canonical registry (``press_settings.presses`` for the plant) UNIONed with any
press that actually has entries — the union is REQUIRED: the frontend defaults
the checked set to ``payload.presses`` and hides any entry whose press is not in
it, so every entry's press must be present. Degrades to just the entry presses
if the registry is unavailable. Numeric press ids sort numerically."""
presses: set[str] = set(entry_presses)
try:
# Reference registry (admin-edited); served from cache so it's off the pool each poll.
rows = cached_select(
f"trj:presses:{plant}",
"SELECT psPress AS p FROM press_settings.presses WHERE psPlant = :plant",
{"plant": plant},
)
presses.update(str(r["p"]) for r in rows if r.get("p") is not None)
except Exception as e: # noqa: BLE001 — registry is optional polish; entries still render
logger.warning("press registry unavailable (using entry presses only): %s", e)
def _key(p: str) -> tuple[int, Any]:
return (0, int(p)) if p.isdigit() else (1, p)
return sorted(presses, key=_key)
# ── belt: order-level 5-station temper distribution ───────────────────────────────
def _temper_agg_map(plant: str, pairs: set[tuple[str, str]]) -> dict[tuple[str, str], dict[str, int]]:
"""Per ``(press, orderno)`` temper aggregate, over ALL that order's carts (any
status — the belt shows the whole order, not just the windowed carts).
Same live-oven derivation as fastpress ``_temper_aggregates_all`` (planned
timestamps vs ``NOW()``): ``sent`` = all parts on carts; ``on_way`` = still in
the buffer (``NOW() < ttIntoOvenAt``); ``in_oven`` / ``finished`` split by
``ttOutOfOvenAt``. Degrades to ``{}`` on any DB error."""
if not pairs:
return {}
presses = sorted({p for p, _ in pairs})
orders = sorted({o for _, o in pairs})
binds: dict[str, Any] = {"plant": plant}
for i, p in enumerate(presses):
binds[f"p{i}"] = p
for i, o in enumerate(orders):
binds[f"o{i}"] = o
pin = ", ".join(f":p{i}" for i in range(len(presses)))
oin = ", ".join(f":o{i}" for i in range(len(orders)))
try:
rows = run_select_query(
"SELECT ttPress AS press, ttOrdernumber AS o, "
" COALESCE(SUM(ttPartsGood),0) AS sent, "
" COALESCE(SUM(CASE WHEN NOW() < ttIntoOvenAt THEN ttPartsGood ELSE 0 END),0) AS on_way, "
" COALESCE(SUM(CASE WHEN NOW() >= ttIntoOvenAt AND NOW() < ttOutOfOvenAt THEN ttPartsGood ELSE 0 END),0) AS in_oven, "
" COALESCE(SUM(CASE WHEN NOW() >= ttOutOfOvenAt THEN ttPartsGood ELSE 0 END),0) AS finished "
"FROM production.temper_tracking "
f"WHERE ttPlant = :plant AND ttPress IN ({pin}) AND ttOrdernumber IN ({oin}) "
"GROUP BY ttPress, ttOrdernumber",
binds,
)
except Exception as e: # noqa: BLE001
logger.warning("temper aggregate unavailable: %s", e)
return {}
out: dict[tuple[str, str], dict[str, int]] = {}
for r in rows:
out[(str(r["press"]), str(r["o"]))] = {
"sent": int(r["sent"] or 0),
"on_way": int(r["on_way"] or 0),
"in_oven": int(r["in_oven"] or 0),
"finished": int(r["finished"] or 0),
}
return out
def _good_parts_map(plant: str, pairs: set[tuple[str, str]]) -> dict[tuple[str, str], int]:
"""Per ``(press, orderno)`` good parts PRODUCED at the press (cycles × cavities
rejects), for the belt's ``notYetSent`` = produced sent-to-temper.
Faithful (batched) port of fastpress ``_all_order_stats``: one cycle scan +
one reject scan per press, matched back to orders in Python (``Joborder`` is a
plain order number OR a JSON array of them). Only ACTIVE orders are counted;
an order that has finished (moved to ``finished_orders``) is absent → the
caller treats ``notYetSent`` as 0 (nothing left at the press). Degrades to
``{}`` per-press on any DB error."""
if not pairs:
return {}
import json
# Batched across ALL presses: 3 grouped queries total (was 3 per press = N+1).
# Semantics preserved from the per-press version — only the query scope changed
# (grouped by press). On any DB error the whole map degrades to {} (the caller's
# _flow_for then treats notYetSent as 0), matching the sibling _temper_agg_map.
presses = sorted({p for p, _ in pairs})
orders = sorted({o for _, o in pairs})
pbinds = {f"p{i}": p for i, p in enumerate(presses)}
obinds = {f"o{i}": o for i, o in enumerate(orders)}
pin = ", ".join(f":p{i}" for i in range(len(presses)))
oin = ", ".join(f":o{i}" for i in range(len(orders)))
try:
# 1) cavities per (press, order) — one query.
cav_by: dict[tuple[str, str], int] = {
(str(r["press"]), str(r["o"])): int(r["cav"] or 0)
for r in run_select_query(
"SELECT aoPress AS press, aoOrdernumber AS o, aoCavities AS cav "
"FROM production.active_orders "
f"WHERE aoPlant = :plant AND aoPress IN ({pin}) AND aoOrdernumber IN ({oin})",
{"plant": plant, **pbinds, **obinds},
)
}
if not cav_by:
return {} # none of these orders are active → notYetSent stays 0
# 2) cycle counts grouped by (Press, Joborder) — one scan. Joborder is unfiltered
# (a plain order number OR a JSON array), matched to orders in Python below.
cyc_by_press: dict[str, list[tuple[Any, int]]] = defaultdict(list)
for cr in run_select_query(
"SELECT Press AS press, Joborder AS jo, COUNT(*) AS c "
f"FROM production.cycles_new WHERE Press IN ({pin}) GROUP BY Press, Joborder",
pbinds,
):
cyc_by_press[str(cr["press"])].append((cr["jo"], int(cr["c"] or 0)))
# 3) rejects summed per (press, order) — one grouped query.
rej_by: dict[tuple[str, str], int] = {
(str(rr["press"]), str(rr["o"])): int(rr["total"] or 0)
for rr in run_select_query(
"SELECT rejPress AS press, rejOrdernumber AS o, "
"COALESCE(SUM(rejQuantity),0) AS total FROM production.rejects "
f"WHERE rejPress IN ({pin}) AND rejOrdernumber IN ({oin}) "
"GROUP BY rejPress, rejOrdernumber",
{**pbinds, **obinds},
)
}
except Exception as e: # noqa: BLE001 — whole map degrades to {} (matches _temper_agg_map)
logger.warning("good-parts derivation failed: %s", e)
return {}
def _cycles_for(press: str, orderno: str) -> int:
total = 0
for jo, c in cyc_by_press.get(press, ()):
if jo is None:
continue
if jo == orderno:
total += c
elif isinstance(jo, str) and orderno in jo:
try:
arr = json.loads(jo)
except Exception: # noqa: BLE001
arr = None
if isinstance(arr, list) and orderno in arr:
total += c
return total
result: dict[tuple[str, str], int] = {}
for (press, orderno), cav in cav_by.items():
if (press, orderno) not in pairs: # keep emitted keys identical to the input pairs
continue
produced = _cycles_for(press, orderno) * cav
result[(press, orderno)] = max(0, produced - rej_by.get((press, orderno), 0))
return result
def _flow(not_sent: int, wagen: int, unterwegs: int, ofen: int, fertig: int) -> dict[str, int]:
"""Order-level 5-station distribution shown on the belt (frontend TemperBelt):
notYetSent=violet, wagen=cyan, unterwegs=amber, ofen=orange, fertig=emerald."""
return {"notYetSent": not_sent, "wagen": wagen, "unterwegs": unterwegs, "ofen": ofen, "fertig": fertig}
def _flow_for(
press: str,
orderno: str,
agg_map: dict[tuple[str, str], dict[str, int]],
good_map: dict[tuple[str, str], int],
) -> dict[str, int]:
"""Assemble one order's belt distribution from the aggregates.
The live model has ONE pre-oven quantity (``on_way`` = buffer/Wagenzeit) → it
goes under **Unterwegs**; **Wagen** stays 0 (no scan data to split them).
``notYetSent`` = produced good sent-to-temper (0 when the order isn't active)."""
agg = agg_map.get((press, orderno), {"sent": 0, "on_way": 0, "in_oven": 0, "finished": 0})
good = good_map.get((press, orderno))
not_sent = max(0, good - agg["sent"]) if good is not None else 0
return _flow(not_sent, 0, agg["on_way"], agg["in_oven"], agg["finished"])
# ── cart<->cycle attribution (migration 009) ────────────────────────────────────
# A tempered cart's parts were made hours earlier across a RANGE of press cycles, so a
# reject booked against a cart must attribute to a cycle that actually produced a part
# on it — not the press's latest cycle (MAX(id)). fastpress print_label snapshots each
# cart's cycle interval onto its temper_tracking row; here we read it back and pick the
# interval's MEDIAN cycle. Self-contained copies of the fastpress rejects.py logic (this
# module ships as its own repo and MUST NOT import the sibling fastpress package).
_CART_CYCLE_COLUMNS: Optional[bool] = None
_REJ_TTID_COLUMN: Optional[bool] = None
def _has_cart_cycle_columns() -> bool:
"""Whether ``temper_tracking.ttCycleFirstId`` exists yet (migration 009). Cached.
Absent (pre-009) → reject attribution falls back to the legacy ``MAX(id)`` cycle."""
global _CART_CYCLE_COLUMNS
if _CART_CYCLE_COLUMNS is not None:
return _CART_CYCLE_COLUMNS
try:
rows = run_select_query(
"SELECT COUNT(*) AS c FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = 'production' AND TABLE_NAME = 'temper_tracking' "
"AND COLUMN_NAME = 'ttCycleFirstId'",
{},
)
_CART_CYCLE_COLUMNS = bool(rows and int(rows[0]["c"]) > 0)
return _CART_CYCLE_COLUMNS
except Exception as e: # noqa: BLE001 — transient probe failure: do NOT cache, retry next call
logger.warning("cart-cycle column probe failed (assuming absent, will retry): %s", e)
return False
def _has_rej_ttid_column() -> bool:
"""Whether ``production.rejects.rejTtId`` exists yet (migration 009). Cached.
Present → the INSERT records the booked-against cart ttId; absent → column omitted."""
global _REJ_TTID_COLUMN
if _REJ_TTID_COLUMN is not None:
return _REJ_TTID_COLUMN
try:
rows = run_select_query(
"SELECT COUNT(*) AS c FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = 'production' AND TABLE_NAME = 'rejects' "
"AND COLUMN_NAME = 'rejTtId'",
{},
)
_REJ_TTID_COLUMN = bool(rows and int(rows[0]["c"]) > 0)
return _REJ_TTID_COLUMN
except Exception as e: # noqa: BLE001 — transient probe failure: do NOT cache, retry next call
logger.warning("rejTtId column probe failed (assuming absent, will retry): %s", e)
return False
def _resolve_reject_cycle(conn, press: str, ordernumber: str, tt_id, has_cycle_cols: bool):
"""Resolve the ONE cycle a reject references → ``(toolNo, cycletimeAct, articleId,
cycleId, intervalStr|None)``. Faithful copy of fastpress ``rejects._resolve_reject_cycle``.
Cart booking (``tt_id`` given, migrated box, cart has a stored interval): attribute to
the interval's MEDIAN cycle (``ORDER BY id LIMIT 1 OFFSET (count-1)//2``). The cart
row's ``ttOrdernumber`` is authoritative for the cycle predicate. Otherwise fall back
to the legacy latest cycle (``MAX(id)``) — verbatim as before."""
import json
order_pred = "(Joborder = :orderno OR JSON_CONTAINS(Joborder, :orderjson))"
interval_str = None
rep = None
if has_cycle_cols and tt_id is not None:
snap = conn.execute(
text(
"SELECT ttOrdernumber, ttCycleFirstId, ttCycleLastId, ttCycleCount "
"FROM production.temper_tracking WHERE ttId = :ttId"
),
{"ttId": tt_id},
).fetchone()
if snap is not None and snap[1] is not None and snap[2] is not None:
snap_order = snap[0] or ordernumber
first_id, last_id = int(snap[1]), int(snap[2])
cnt = int(snap[3]) if snap[3] is not None else 1
off = max(0, (cnt - 1) // 2) # median cycle of the cart's interval
rep = conn.execute(
text(
"SELECT ToolNo, T_Cycle_Act, ArticleID, CycleID FROM production.cycles_new "
f"WHERE Press = :press AND {order_pred} AND id BETWEEN :first AND :last "
"ORDER BY id ASC LIMIT 1 OFFSET :off"
),
{
"press": press, "orderno": snap_order, "orderjson": json.dumps(snap_order),
"first": first_id, "last": last_id, "off": off,
},
).fetchone()
if rep is not None:
interval_str = f"CY.{tt_id}:{first_id}-{last_id}"
if rep is None:
rep = conn.execute(
text(
"SELECT ToolNo, T_Cycle_Act, ArticleID, CycleID "
"FROM production.cycles_new WHERE id = ("
" SELECT MAX(id) FROM production.cycles_new "
f" WHERE Press = :press AND {order_pred}"
") LIMIT 1"
),
{"press": press, "orderno": ordernumber, "orderjson": json.dumps(ordernumber)},
).fetchone()
mold = rep[0] if rep else None
cycletime = rep[1] if rep else None
raw_art_id = rep[2] if rep else None
cycle_id = rep[3] if rep else None
try:
art_id = int(raw_art_id) if raw_art_id is not None else None
except (ValueError, TypeError):
art_id = None
return mold, cycletime, art_id, cycle_id, interval_str
# ── request schema ────────────────────────────────────────────────────────────
class RejectBody(BaseModel):
# permissive like fastpress's reject schema; validated in the handler. ``ttId``
# is accepted (the frontend sends the selected cart) but only logged — the
# booking is recorded against the ORDER, not the cart.
model_config = ConfigDict(extra="allow")
press: str | None = None
orderNo: str | None = None
ttId: int | None = None
count: int | None = None
reason: int | None = None
# ── endpoints ─────────────────────────────────────────────────────────────────
@router.get("/overview", summary="Tempered carts per ttId, newest first (live)")
def overview(
window: str = Query(_DEFAULT_WINDOW, description="12h|1T|7T|14T|30T"),
from_ms: Optional[int] = Query(
None, alias="from",
description="absolute window START, epoch ms (Grafana ${__from}). When paired "
"with `to`, overrides `window` with an exact from→to range.",
),
to_ms: Optional[int] = Query(
None, alias="to", description="absolute window END, epoch ms (Grafana ${__to}).",
),
) -> dict[str, Any]:
# Grafana embed passes an absolute [from,to] window (epoch ms); it wins over the
# preset `window`. FROM_UNIXTIME resolves in the same DB session clock as NOW(), so
# the timezone handling matches the preset path.
absolute = from_ms is not None and to_ms is not None and to_ms > from_ms
if absolute:
code = _abs_window_label(from_ms, to_ms)
bound_sql = (
" AND NOW() >= ttOutOfOvenAt "
" AND ttOutOfOvenAt >= FROM_UNIXTIME(:from_s) "
" AND ttOutOfOvenAt <= FROM_UNIXTIME(:to_s) "
)
bounds: dict[str, Any] = {"from_s": from_ms / 1000.0, "to_s": to_ms / 1000.0}
else:
code = window if window in _WINDOW_MINUTES else _DEFAULT_WINDOW
bound_sql = (
" AND NOW() >= ttOutOfOvenAt " # fertig getempert
" AND ttOutOfOvenAt >= (NOW() - INTERVAL :mins MINUTE) "
)
bounds = {"mins": _WINDOW_MINUTES[code]}
try:
rows = run_select_query(
"SELECT ttId, ttCartId, ttPress, ttOrdernumber, ttArticleDesc, "
"ttPartsGood, ttScrap, ttOutOfOvenAt "
"FROM production.temper_tracking "
"WHERE ttPlant = :plant "
" AND ttReprintOf IS NULL " # a reprint is not a new label
" AND ttOutOfOvenAt IS NOT NULL "
+ bound_sql
+ "ORDER BY ttOutOfOvenAt DESC, ttId DESC "
"LIMIT :lim",
{"plant": _PLANT, "lim": _MAX_ENTRIES + 1, **bounds},
)
except Exception as e:
logger.error("temper_rejects overview query failed: %s", e, exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"success": False, "error": "database_error", "message": str(e)},
)
truncated = len(rows) > _MAX_ENTRIES
if truncated:
rows = rows[:_MAX_ENTRIES]
logger.warning("temper_rejects overview truncated to %d carts (window=%s)", _MAX_ENTRIES, code)
pairs = {(str(r["ttPress"]), str(r["ttOrdernumber"])) for r in rows}
agg_map = _temper_agg_map(_PLANT, pairs)
good_map = _good_parts_map(_PLANT, pairs)
entries = [
{
"ttId": int(r["ttId"]),
"cartId": r.get("ttCartId") or "",
"press": str(r["ttPress"]),
"orderNo": r["ttOrdernumber"],
"article": r.get("ttArticleDesc") or "",
"partsGood": int(r["ttPartsGood"] or 0),
"scrap": int(r["ttScrap"] or 0),
"doneAt": _iso_aware(r["ttOutOfOvenAt"]),
"flow": _flow_for(str(r["ttPress"]), r["ttOrdernumber"], agg_map, good_map),
}
for r in rows
]
entry_presses = {e["press"] for e in entries}
return {
"window": code,
"generatedAt": datetime.now().astimezone().isoformat(),
"sample": False, # live data → frontend shows "Live", not "Demo-Daten"
"truncated": truncated,
"presses": _press_list(_PLANT, entry_presses),
"entries": entries,
}
@router.get("/reasons", summary="Active reject reasons")
def reasons() -> dict[str, Any]:
"""Active reject reasons — same query as fastpress ``get_reject_reasons``.
Global (not press-scoped). Read-only."""
try:
# Reference data (admin-edited); served from cache.
rows = cached_select(
"trj:reject_reasons",
"SELECT id, reason_de, reason_en FROM settings.reject_reasons "
"WHERE active = 1 ORDER BY id",
{},
)
except Exception as e:
logger.error("temper_rejects reasons query failed: %s", e, exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"success": False, "error": "database_error", "message": str(e)},
)
return {"reasons": rows, "sample": False}
@router.post("/reject", summary="Book one Ausschuss against an order (writes production.rejects)")
def reject(body: RejectBody) -> dict[str, Any]:
"""Record one reject against the ORDER. Un-scoped port of fastpress
``send_reject``: resolve the latest cycle (``MAX(id)`` in ``cycles_new`` for the
press + joborder, incl. ``JSON_CONTAINS``), the article desc / target cycletime
(``active_orders`` → ``finished_orders`` fallback), and the reason texts
(``settings.reject_reasons``), then INSERT ``production.rejects`` — all in one
transaction.
Unlike fastpress, ``press`` comes from the BODY (login-free central terminal, no
device JWT). Keyed by ``rejOrdernumber`` + ``rejPress`` → no cart reference / no
schema change. ``ttId`` is accepted for context but not written."""
# IMPORTANT — booking outcomes (validation refusals AND write failures) are
# returned as HTTP 200 + {"success": False, ...}, NOT 4xx/5xx. The frontend store
# (src/stores/temperRejects.ts submitReject) treats ANY non-2xx as a transport
# failure and swallows it into a FAKE success ("Ausschuss gebucht (Demo — kein
# Backend)"). On this live, login-free terminal that would make a refused/failed
# booking read as booked. A 200 body keeps the store's success path in control so
# it surfaces the real error; its catch stays reserved for a truly unreachable
# backend. Do NOT "fix" these back to 400/500. (/overview + /reasons DO use 500 —
# there a DB error is not swallowed into a false success.)
press = str(body.press or "").strip()
ordernumber = str(body.orderNo or "").strip()
try:
count = int(body.count or 0)
reason_id = int(body.reason or 0)
except (ValueError, TypeError):
return {"success": False, "error": "bad_request", "message": "count and reason must be integers"}
if not press or not ordernumber:
return {"success": False, "error": "bad_request", "message": "press and orderNo are required"}
if count <= 0 or reason_id <= 0:
return {"success": False, "error": "bad_request", "message": "count and reason must be positive"}
has_cycle_cols = _has_cart_cycle_columns()
has_rej_ttid = _has_rej_ttid_column()
try:
with transaction() as conn:
# Resolve the ONE cycle this reject references: the cart's interval median
# when booked against a tempered cart (body.ttId), else the legacy latest
# cycle (MAX(id)). See _resolve_reject_cycle.
mold, cycletime, art_id, cycle_id, interval_str = _resolve_reject_cycle(
conn, press, ordernumber, body.ttId, has_cycle_cols
)
# Article desc / target cycletime — active first, finished as fallback.
part = target_cycletime = None
prod_row = conn.execute(
text(
"SELECT aoArticleDesc, aoTargetCycletime FROM production.active_orders "
"WHERE aoOrdernumber = :orderno AND aoPress = :press LIMIT 1"
),
{"orderno": ordernumber, "press": press},
).fetchone()
if not prod_row:
fo_row = conn.execute(
text(
"SELECT foArticleDesc, foTargetCycletime FROM production.finished_orders "
"WHERE foOrderNumber = :orderno AND foPress = :press "
"ORDER BY foDT_End DESC LIMIT 1"
),
{"orderno": ordernumber, "press": press},
).fetchone()
if fo_row:
part = fo_row[0]
target_cycletime = fo_row[1]
else:
part = prod_row[0]
target_cycletime = prod_row[1]
# Reason texts (settings schema, fully-qualified).
reason_row = conn.execute(
text("SELECT reason_de, reason_en FROM settings.reject_reasons WHERE id = :reason_id"),
{"reason_id": reason_id},
).fetchone()
reason_de = reason_row[0] if reason_row else ""
reason_en = reason_row[1] if reason_row else ""
# rejCycletimeAct is NOT NULL — coalesce a missing cycle to 0.0. rejTtId
# (migration 009) records the booked-against cart for a durable reject→cart
# audit link, appended only when the column exists (pre-009 boxes omit it).
cols = ["rejPress", "rejOrdernumber", "rejArticleDesc", "rejReason_INT",
"rejReason_DE", "rejReason_EN", "rejQuantity", "rejTargetCycletime",
"rejCycletimeAct", "rejToolNo", "rejCycleID", "rejArticleID"]
vals = [":press", ":orderno", ":part", ":reason_int", ":reason_de", ":reason_en",
":quantity", ":target_cycletime", ":cycletime_act", ":toolno",
":cycle_id", ":article_id"]
binds = {
"press": press,
"orderno": ordernumber,
"part": part,
"reason_int": reason_id,
"reason_de": reason_de,
"reason_en": reason_en,
"quantity": count,
"target_cycletime": target_cycletime,
"cycletime_act": cycletime if cycletime is not None else 0.0,
"toolno": mold,
"cycle_id": cycle_id,
"article_id": art_id,
}
if has_rej_ttid and body.ttId is not None:
cols.append("rejTtId")
vals.append(":rej_ttid")
binds["rej_ttid"] = body.ttId
conn.execute(
text(
"INSERT INTO production.rejects (" + ", ".join(cols) + ") "
"VALUES (" + ", ".join(vals) + ")"
),
binds,
)
logger.info("temper_rejects booked press=%s order=%s ttId=%s count=%s reason=%s cycles=%s",
press, ordernumber, body.ttId, count, reason_id, interval_str or "MAX(id)")
return {"success": True, "message": "Ausschuss gebucht", "cycles": interval_str}
except Exception as e: # transaction() already rolled back on exit
logger.error("temper_rejects reject write failed press=%s order=%s: %s", press, ordernumber, e, exc_info=True)
# 200 + success:False on purpose (see the note at the top of this handler) so
# the operator sees the real failure instead of a swallowed false success.
return {"success": False, "error": "database_error", "message": str(e)}