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>
145 lines
7.1 KiB
Python
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},
|
|
}
|