"""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, 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" # 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: rows = run_select_query( "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 by_press: dict[str, list[str]] = defaultdict(list) for p, o in pairs: by_press[p].append(o) result: dict[tuple[str, str], int] = {} for press, orders in by_press.items(): try: obinds: dict[str, Any] = {"plant": plant, "press": press} for i, o in enumerate(orders): obinds[f"o{i}"] = o oin = ", ".join(f":o{i}" for i in range(len(orders))) # 1) cavities + target per active order (article/qty snapshot). active = run_select_query( "SELECT aoOrdernumber AS o, aoCavities AS cav FROM production.active_orders " f"WHERE aoPlant = :plant AND aoPress = :press AND aoOrdernumber IN ({oin})", obinds, ) cav_by_order = {str(r["o"]): int(r["cav"] or 0) for r in active} if not cav_by_order: continue # none of these orders are active → notYetSent stays 0 # 2) cycle counts grouped by Joborder (one scan for the press). cyc_groups = [ (cr["jo"], int(cr["c"] or 0)) for cr in run_select_query( "SELECT Joborder AS jo, COUNT(*) AS c FROM production.cycles_new " "WHERE Press = :press GROUP BY Joborder", {"press": press}, ) ] # 3) rejects summed per order (one grouped query). rej_by_order: dict[str, int] = {} for rr in run_select_query( "SELECT rejOrdernumber AS o, COALESCE(SUM(rejQuantity),0) AS total " "FROM production.rejects WHERE rejPress = :press " f"AND rejOrdernumber IN ({oin}) GROUP BY rejOrdernumber", obinds, ): rej_by_order[str(rr["o"])] = int(rr["total"] or 0) def _cycles_for(orderno: str) -> int: total = 0 for jo, c in cyc_groups: 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 for orderno, cav in cav_by_order.items(): produced = _cycles_for(orderno) * cav good = max(0, produced - rej_by_order.get(orderno, 0)) result[(press, orderno)] = good except Exception as e: # noqa: BLE001 — degrade this press to no notYetSent logger.warning("good-parts derivation failed for press %s: %s", press, e) continue 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"]) # ── 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")) -> dict[str, Any]: code = window if window in _WINDOW_MINUTES else _DEFAULT_WINDOW limit_min = _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 " " AND NOW() >= ttOutOfOvenAt " # fertig getempert " AND ttOutOfOvenAt >= (NOW() - INTERVAL :mins MINUTE) " "ORDER BY ttOutOfOvenAt DESC, ttId DESC " "LIMIT :lim", {"plant": _PLANT, "mins": limit_min, "lim": _MAX_ENTRIES + 1}, ) 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: rows = run_select_query( "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"} import json try: with transaction() as conn: # Latest cycle for this press + joborder (direct match or JSON list). cycle_row = conn.execute( text( "SELECT ToolNo, T_Cycle_Act, ArticleID, CycleID " "FROM production.cycles_new WHERE id = (" " SELECT MAX(id) FROM production.cycles_new " " WHERE Press = :press AND (Joborder = :orderno OR JSON_CONTAINS(Joborder, :orderjson))" ") LIMIT 1" ), {"press": press, "orderno": ordernumber, "orderjson": json.dumps(ordernumber)}, ).fetchone() mold = cycle_row[0] if cycle_row else None cycletime = cycle_row[1] if cycle_row else None raw_art_id = cycle_row[2] if cycle_row else None cycle_id = cycle_row[3] if cycle_row else None try: art_id = int(raw_art_id) if raw_art_id is not None else None except (ValueError, TypeError): art_id = None # 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 "" conn.execute( text( "INSERT INTO production.rejects " "(rejPress, rejOrdernumber, rejArticleDesc, rejReason_INT, rejReason_DE, rejReason_EN, " "rejQuantity, rejTargetCycletime, rejCycletimeAct, rejToolNo, rejCycleID, rejArticleID) " "VALUES (:press, :orderno, :part, :reason_int, :reason_de, :reason_en, " ":quantity, :target_cycletime, :cycletime_act, :toolno, :cycle_id, :article_id)" ), { "press": press, "orderno": ordernumber, "part": part, "reason_int": reason_id, "reason_de": reason_de, "reason_en": reason_en, "quantity": count, "target_cycletime": target_cycletime, # rejCycletimeAct is NOT NULL — coalesce a missing cycle to 0.0. "cycletime_act": cycletime if cycletime is not None else 0.0, "toolno": mold, "cycle_id": cycle_id, "article_id": art_id, }, ) logger.info("temper_rejects booked press=%s order=%s ttId=%s count=%s reason=%s", press, ordernumber, body.ttId, count, reason_id) return {"success": True, "message": "Ausschuss gebucht"} 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)}