fix/spa-reconnect-no-demo-data #1

Merged
Erik merged 2 commits from fix/spa-reconnect-no-demo-data into main 2026-07-09 10:57:31 +00:00
12 changed files with 217 additions and 118 deletions

4
_db.py
View File

@ -18,9 +18,9 @@ from __future__ import annotations
from contextlib import contextmanager
from app.db.mariadb import engine, run_select_query # noqa: F401 (kernel DB layer)
from app.db.mariadb import engine, run_select_query, cached_select # noqa: F401 (kernel DB layer)
__all__ = ["run_select_query", "transaction", "engine"]
__all__ = ["run_select_query", "cached_select", "transaction", "engine"]
@contextmanager

View File

@ -51,7 +51,7 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import text
from starlette import status
from .._db import run_select_query, transaction
from .._db import run_select_query, cached_select, transaction
logger = logging.getLogger(__name__)
@ -96,7 +96,9 @@ def _press_list(plant: str, entry_presses: set[str]) -> list[str]:
if the registry is unavailable. Numeric press ids sort numerically."""
presses: set[str] = set(entry_presses)
try:
rows = run_select_query(
# 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},
)
@ -172,71 +174,77 @@ def _good_parts_map(plant: str, pairs: set[tuple[str, str]]) -> dict[tuple[str,
return {}
import json
by_press: dict[str, list[str]] = defaultdict(list)
for p, o in pairs:
by_press[p].append(o)
# 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, 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)
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
@ -460,7 +468,9 @@ 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(
# 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",
{},

1
dist/assets/index-CU7fLO-K.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -9,8 +9,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausschuss-Station</title>
<script type="module" crossorigin src="/module/temper_rejects/assets/index-DceU8_gJ.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-Oxhdj_dg.css">
<script type="module" crossorigin src="/module/temper_rejects/assets/index-CjCewD1P.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-CU7fLO-K.css">
</head>
<body>
<div id="app"></div>

View File

@ -3,7 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { WindowCode } from '@/types/station'
defineProps<{ window: WindowCode; sample: boolean }>()
defineProps<{ window: WindowCode; sample: boolean; disconnected?: boolean }>()
const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>()
const { t } = useI18n()
@ -32,7 +32,8 @@ onUnmounted(() => {
</span>
<span v-if="sample" class="demo">{{ t('app.demo') }}</span>
<span class="spacer"></span>
<span class="live"><span class="led"></span>{{ t('app.live') }}</span>
<span v-if="disconnected" class="reconnect"><span class="spinner sm" aria-hidden="true"></span>{{ t('conn.reconnecting') }}</span>
<span v-else class="live"><span class="led"></span>{{ t('app.live') }}</span>
<span class="clock num">{{ clock }}</span>
<span class="rangep" role="group" :aria-label="t('window.label')">
<span class="rl">{{ t('window.label') }}</span>
@ -117,6 +118,16 @@ onUnmounted(() => {
background: var(--done);
animation: blink 2.4s ease-in-out infinite;
}
.reconnect {
display: flex;
align-items: center;
gap: 7px;
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--transit);
}
.clock {
font-size: 0.95rem;
color: var(--ink-dim);

View File

@ -6,6 +6,11 @@ export default {
live: 'Live',
demo: 'Demo-Daten',
},
conn: {
connecting: 'Verbinde mit Server …',
lost: 'Verbindung verloren neuer Versuch …',
reconnecting: 'Verbinde neu …',
},
filter: {
title: 'Maschinen',
all: 'Alle',

View File

@ -7,6 +7,11 @@ export default {
live: 'Live',
demo: 'Sample data',
},
conn: {
connecting: 'Connecting to server …',
lost: 'Connection lost retrying …',
reconnecting: 'Reconnecting …',
},
filter: {
title: 'Presses',
all: 'All',

View File

@ -6,7 +6,12 @@ import { sampleStation, SAMPLE_REASONS } from '@/data/sample'
import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
// Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling().
// Falls back to bundled sample data when the API is unreachable (dev / no gateway).
//
// Connection loss on this LIVE terminal: never fabricate carts. A failed fetch keeps
// the last known REAL payload (stale > fake — an operator must never book against a
// demo cart) and flips `disconnected`, so the UI shows a reconnect spinner and polling
// retries faster. Bundled sample data renders ONLY under `npm run dev` (no gateway) —
// gated behind `import.meta.env.DEV`, so `vite build` strips it from production.
const CACHE_KEY = 'temper_rejects_payload'
@ -18,9 +23,12 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
const loading = ref(false)
const error = ref<string | null>(null)
const usingSample = ref(false)
const disconnected = ref(false)
const lastUpdated = ref<number | null>(null)
let timer: ReturnType<typeof setInterval> | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let pollMs = 20000
const RETRY_MS = 5000 // faster cadence while disconnected, to recover quickly
let filterInitialized = false
const hasData = computed(() => payload.value !== null)
@ -76,13 +84,16 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
}
payload.value = res.data
usingSample.value = res.data.sample === true
disconnected.value = false
} catch (e) {
// Connection lost. Keep the last known REAL data (stale > fabricated): never
// replace live carts with demo carts on a live terminal, or an operator could
// book against a fabricated entry. Flag `disconnected` so the UI shows a
// reconnect spinner (cold start) or a header indicator (stale data) and retries.
disconnected.value = true
error.value = e instanceof Error ? e.message : 'Overview API unreachable'
// Keep the last known REAL data on a transient failure (stale > fabricated): never
// replace live carts with bundled demo carts on a live terminal, or an operator
// could book against a fabricated entry. Only fall back to sample on a COLD start
// with nothing to show (dev standalone, where there is no gateway).
if (!payload.value || usingSample.value) {
// Sample data is for `npm run dev` only (no gateway) — `vite build` strips this.
if (import.meta.env.DEV && (!payload.value || usingSample.value)) {
payload.value = sampleStation(window.value)
usingSample.value = true
}
@ -102,9 +113,15 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
async function fetchReasons() {
try {
const res = await api.get<{ reasons: Reason[] }>('/reasons')
reasons.value = Array.isArray(res.data?.reasons) ? res.data.reasons : SAMPLE_REASONS
if (Array.isArray(res.data?.reasons) && res.data.reasons.length) {
reasons.value = res.data.reasons
return
}
throw new Error('no reasons')
} catch {
reasons.value = SAMPLE_REASONS
// Keep any reasons already loaded; only seed the bundled sample reasons under
// `npm run dev` — production must never offer fabricated reason codes.
if (import.meta.env.DEV && !reasons.value.length) reasons.value = SAMPLE_REASONS
}
}
@ -152,13 +169,22 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
}
}
// Self-scheduling poll: next tick is the normal cadence when connected, a faster
// retry while disconnected so the terminal recovers as soon as the gateway is back.
function scheduleNext() {
timer = setTimeout(async () => {
await refresh()
scheduleNext()
}, disconnected.value ? RETRY_MS : pollMs)
}
function startPolling(ms = 20000) {
if (timer) return
timer = setInterval(refresh, ms)
pollMs = ms
scheduleNext()
}
function stopPolling() {
if (timer) {
clearInterval(timer)
clearTimeout(timer)
timer = null
}
}
@ -173,6 +199,7 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
loading,
error,
usingSample,
disconnected,
lastUpdated,
hasData,
allPresses,

View File

@ -114,6 +114,27 @@ html:not(.dark) body {
background-position: 11px 0;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Reconnect spinner (connection-loss states). `.sm` is the inline header variant. */
.spinner {
display: inline-block;
width: 30px;
height: 30px;
border: 3px solid var(--line-2);
border-top-color: var(--transit);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner.sm {
width: 13px;
height: 13px;
border-width: 2px;
}
@media (prefers-reduced-motion: reduce) {
* {

View File

@ -5,8 +5,10 @@
5-station temper belt and an "Ausschuss buchen" action booking popover
(a tooltip-style panel anchored to the button, not a centered modal).
Login-free by design. Data from the store (polled); no-backend falls back to
bundled sample data so `npm run dev` renders + the booking flow is demoable.
Login-free by design. Data from the store (polled). On connection loss the terminal
keeps the last real carts (or shows a reconnect spinner on a cold start) and retries
it never shows demo carts in production. Sample data renders only under
`npm run dev` (no gateway), where the booking flow is demoable.
-->
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
@ -21,7 +23,7 @@ import type { RejectEntry, RejectRequest } from '@/types/station'
const { t } = useI18n()
const store = useTemperRejectsStore()
const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample } = storeToRefs(store)
const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample, hasData, disconnected } = storeToRefs(store)
const pop = ref<InstanceType<typeof RejectPopover> | null>(null)
const bookingEntry = ref<RejectEntry | null>(null)
@ -80,9 +82,15 @@ onUnmounted(() => {
<template>
<div class="wrap">
<StationHeader :window="window" :sample="usingSample" @update:window="store.setWindow" />
<StationHeader :window="window" :sample="usingSample" :disconnected="disconnected" @update:window="store.setWindow" />
<div class="layout">
<!-- Cold start / no data yet: never blank, never fake spinner that keeps retrying. -->
<div v-if="!hasData" class="state connect">
<span class="spinner" aria-hidden="true"></span>
<span class="msg">{{ disconnected ? t('conn.lost') : t('conn.connecting') }}</span>
</div>
<div v-else class="layout">
<PressFilter
:presses="allPresses"
:checked="checkedPresses"
@ -140,6 +148,18 @@ onUnmounted(() => {
border: 1px dashed var(--line-2);
border-radius: var(--r);
}
/* Connection spinner state (cold start / reconnecting): full-width, no dashed box. */
.state.connect {
flex-direction: column;
gap: 16px;
min-height: 60vh;
margin-top: 14px;
border: 0;
}
.state.connect .msg {
font-size: 0.85rem;
letter-spacing: 0.04em;
}
.toast {
position: fixed;
left: 50%;