SPA_AfterTemperRejection/api/routes.py
Erik c340aef2c3 feat: scaffold Ausschuss-Station reject-booking page-module (temper_rejects)
Vue 3 + Vite 8 + TS + PrimeVue 4 (Nora/Industrial) + Pinia + vue-i18n SPA, served
by the SPRO gateway as a static page-module under /module/temper_rejects. A central,
login-free reject-booking terminal for all presses' tempered carts.

- Implements the LOCKED design (memory: ausschuss-station): per-ttId list (newest
  first), left press checkbox filter (default all, greyed when no carts, hit-count
  badge), style-C 5-station temper belt per entry (badges + animated conveyor),
  12h/1T/7T/14T/30T window, touch density. Booking dialog = PrimeVue Dialog +
  InputNumber + reason Select. Colours match PressV + Ausschuss #C4162A.
- Python page-module wrapper: register(app) mounts committed dist/ + a no-auth API
  (GET /overview, GET /reasons, POST /reject). _db.py exposes run_select_query +
  transaction() for the future INSERT.
- Backend returns SAMPLE data and POST /reject echoes success WITHOUT writing;
  frontend falls back to bundled sample so `npm run dev` is fully demoable offline.
  TODOs point at the real temper_tracking reads + the rejects INSERT (needs a DBA
  rejTtId column) + the login-free-write security open question.
- Rich README bakes in the design, contract, wiring, open questions + Grafana embed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:44:22 +02:00

145 lines
7.1 KiB
Python

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