"""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`` + ``ttId`` come from the request BODY, unlike the device-scoped ``POST /press/rejects/send`` in fastpress. ── STATUS: BASE SCAFFOLD ──────────────────────────────────────────────────────── Returns realistic SAMPLE data shaped EXACTLY like the frontend contract (``src/types/station.ts``); POST /reject validates + echoes success WITHOUT writing. This lets the terminal render + exercise the booking flow with no database. TODO — wire real data (see ``.claude/memory/ausschuss-station.md`` in the gateway): * ``from .._db import run_select_query, transaction`` * OVERVIEW: read ``production.temper_tracking`` across ALL presses (un-scoped), one row per ``ttId``, newest first by ``ttOutOfOvenAt`` (status='done'); join ``active_orders``/``finished_orders`` for article. Per entry, add the ORDER's 5-station distribution (reuse fastpress ``temper._temper_aggregates_all``: notYetSent / sentToTemper / onWayToOven / inOven / finishedTempering). * REASONS: ``SELECT id, reason_de, reason_en FROM settings.reject_reasons WHERE active=1``. * REJECT: one INSERT into ``production.rejects`` inside a ``transaction()`` (same shape as fastpress ``rejects.send_reject``). Booking references the cart → needs the additive nullable ``rejTtId`` column (DBA applies it; runtime account has no ALTER). Confirm the security posture (network-only vs. a static token) before exposing the write. """ from __future__ import annotations from datetime import datetime, timedelta from typing import Any from fastapi import APIRouter, Query from pydantic import BaseModel, ConfigDict router = APIRouter(prefix="/module/temper_rejects/api", tags=["temper_rejects"]) # window code → minutes (max 30 days). Scopes which finished carts are listed. _WINDOW_MINUTES: dict[str, int] = {"12h": 720, "1T": 1440, "7T": 10080, "14T": 20160, "30T": 43200} _DEFAULT_WINDOW = "7T" # All presses in the plant — drives the left-hand filter checkboxes (default all). _PRESSES = ["P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9"] _REASONS = [ {"id": 1, "reason_de": "Maßabweichung", "reason_en": "Dimensional deviation"}, {"id": 2, "reason_de": "Lunker", "reason_en": "Voids"}, {"id": 3, "reason_de": "Bindenaht", "reason_en": "Weld line"}, {"id": 4, "reason_de": "Verbrennung", "reason_en": "Burn mark"}, {"id": 5, "reason_de": "Verzug", "reason_en": "Warping"}, {"id": 6, "reason_de": "Sonstiges", "reason_en": "Other"}, ] 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 (see the frontend TemperBelt colours: nichtGesendet=violet, wagen=cyan, unterwegs=amber, ofen=orange, fertig=emerald).""" return {"notYetSent": not_sent, "wagen": wagen, "unterwegs": unterwegs, "ofen": ofen, "fertig": fertig} # (ttId, cartId, press, orderNo, article, partsGood, scrap, ageMin, flow) _ENTRIES_RAW: list[tuple[int, str, str, str, str, int, int, int, dict[str, int]]] = [ (10231, "00A17226", "P5", "O-4455", "Gehäuse 12 mm", 60, 2, 90, _flow(120, 60, 40, 72, 260)), (10230, "00917226", "P8", "O-4489", "Halter Typ A", 80, 1, 150, _flow(40, 30, 36, 80, 210)), (10229, "00817226", "P3", "O-4471", "Deckel rund", 54, 4, 320, _flow(180, 54, 30, 88, 300)), (10228, "00717226", "P1", "O-4471", "Deckel rund", 64, 0, 500, _flow(180, 54, 30, 88, 300)), (10227, "00617226", "P2", "O-4460", "Klammer klein", 50, 3, 900, _flow(90, 42, 0, 60, 190)), (10226, "00517226", "P5", "O-4455", "Gehäuse 12 mm", 72, 5, 1400, _flow(120, 60, 40, 72, 260)), (10225, "00417226", "P6", "O-4460", "Klammer klein", 44, 1, 3000, _flow(90, 42, 0, 60, 190)), (10224, "00317226", "P8", "O-4489", "Halter Typ A", 58, 2, 8000, _flow(40, 30, 36, 80, 210)), (10223, "00217226", "P9", "O-4471", "Deckel rund", 40, 0, 22000, _flow(180, 54, 30, 88, 300)), ] def _entry(row: tuple, now: datetime) -> dict[str, Any]: tt_id, cart_id, press, order_no, article, good, scrap, age_min, flow = row return { "ttId": tt_id, "cartId": cart_id, "press": press, "orderNo": order_no, "article": article, "partsGood": good, "scrap": scrap, "doneAt": (now - timedelta(minutes=age_min)).astimezone().isoformat(), "flow": flow, } class RejectBody(BaseModel): # permissive like fastpress's reject schema; validated in the handler. 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 @router.get("/overview", summary="Tempered carts per ttId, newest first (SAMPLE data)") 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] now = datetime.now() entries = [_entry(r, now) for r in _ENTRIES_RAW if r[7] <= limit_min] # r[7] = ageMin return { "window": code, "generatedAt": now.astimezone().isoformat(), "sample": True, # frontend shows a "Demo-Daten" hint while set "presses": _PRESSES, "entries": entries, } @router.get("/reasons", summary="Active reject reasons") def reasons() -> dict[str, Any]: return {"reasons": _REASONS, "sample": True} @router.post("/reject", summary="Book one Ausschuss against a ttId (SAMPLE — no write)") def reject(body: RejectBody) -> dict[str, Any]: # Validation mirrors fastpress send_reject (positive count + reason). The base # scaffold does NOT write — it echoes success so the terminal's booking flow is # exercisable end-to-end. See the module TODO for the real INSERT. try: count = int(body.count or 0) reason_id = int(body.reason or 0) tt_id = int(body.ttId or 0) except (ValueError, TypeError): return {"success": False, "error": "bad_request", "message": "count, reason, ttId must be integers"} if not body.press or tt_id <= 0: return {"success": False, "error": "bad_request", "message": "press and ttId are required"} if count <= 0 or reason_id <= 0: return {"success": False, "error": "bad_request", "message": "count and reason must be positive"} return { "success": True, "sample": True, "message": "Ausschuss gebucht (Demo — nicht gespeichert)", "booked": {"press": body.press, "orderNo": body.orderNo, "ttId": tt_id, "count": count, "reason": reason_id}, }