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 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 @contextmanager

View File

@ -51,7 +51,7 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import text from sqlalchemy import text
from starlette import status from starlette import status
from .._db import run_select_query, transaction from .._db import run_select_query, cached_select, transaction
logger = logging.getLogger(__name__) 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.""" if the registry is unavailable. Numeric press ids sort numerically."""
presses: set[str] = set(entry_presses) presses: set[str] = set(entry_presses)
try: 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", "SELECT psPress AS p FROM press_settings.presses WHERE psPlant = :plant",
{"plant": plant}, {"plant": plant},
) )
@ -172,71 +174,77 @@ def _good_parts_map(plant: str, pairs: set[tuple[str, str]]) -> dict[tuple[str,
return {} return {}
import json import json
by_press: dict[str, list[str]] = defaultdict(list) # Batched across ALL presses: 3 grouped queries total (was 3 per press = N+1).
for p, o in pairs: # Semantics preserved from the per-press version — only the query scope changed
by_press[p].append(o) # (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] = {} result: dict[tuple[str, str], int] = {}
for press, orders in by_press.items(): for (press, orderno), cav in cav_by.items():
try: if (press, orderno) not in pairs: # keep emitted keys identical to the input pairs
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 continue
produced = _cycles_for(press, orderno) * cav
result[(press, orderno)] = max(0, produced - rej_by.get((press, orderno), 0))
return result return result
@ -460,7 +468,9 @@ def reasons() -> dict[str, Any]:
"""Active reject reasons — same query as fastpress ``get_reject_reasons``. """Active reject reasons — same query as fastpress ``get_reject_reasons``.
Global (not press-scoped). Read-only.""" Global (not press-scoped). Read-only."""
try: 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 " "SELECT id, reason_de, reason_en FROM settings.reject_reasons "
"WHERE active = 1 ORDER BY id", "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 charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausschuss-Station</title> <title>Ausschuss-Station</title>
<script type="module" crossorigin src="/module/temper_rejects/assets/index-DceU8_gJ.js"></script> <script type="module" crossorigin src="/module/temper_rejects/assets/index-CjCewD1P.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-Oxhdj_dg.css"> <link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-CU7fLO-K.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -3,7 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { WindowCode } from '@/types/station' 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 emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>()
const { t } = useI18n() const { t } = useI18n()
@ -32,7 +32,8 @@ onUnmounted(() => {
</span> </span>
<span v-if="sample" class="demo">{{ t('app.demo') }}</span> <span v-if="sample" class="demo">{{ t('app.demo') }}</span>
<span class="spacer"></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="clock num">{{ clock }}</span>
<span class="rangep" role="group" :aria-label="t('window.label')"> <span class="rangep" role="group" :aria-label="t('window.label')">
<span class="rl">{{ t('window.label') }}</span> <span class="rl">{{ t('window.label') }}</span>
@ -117,6 +118,16 @@ onUnmounted(() => {
background: var(--done); background: var(--done);
animation: blink 2.4s ease-in-out infinite; 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 { .clock {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--ink-dim); color: var(--ink-dim);

View File

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

View File

@ -7,6 +7,11 @@ export default {
live: 'Live', live: 'Live',
demo: 'Sample data', demo: 'Sample data',
}, },
conn: {
connecting: 'Connecting to server …',
lost: 'Connection lost retrying …',
reconnecting: 'Reconnecting …',
},
filter: { filter: {
title: 'Presses', title: 'Presses',
all: 'All', 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' import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
// Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling(). // 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' const CACHE_KEY = 'temper_rejects_payload'
@ -18,9 +23,12 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const usingSample = ref(false) const usingSample = ref(false)
const disconnected = ref(false)
const lastUpdated = ref<number | null>(null) 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 let filterInitialized = false
const hasData = computed(() => payload.value !== null) const hasData = computed(() => payload.value !== null)
@ -76,13 +84,16 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
} }
payload.value = res.data payload.value = res.data
usingSample.value = res.data.sample === true usingSample.value = res.data.sample === true
disconnected.value = false
} catch (e) { } 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' error.value = e instanceof Error ? e.message : 'Overview API unreachable'
// Keep the last known REAL data on a transient failure (stale > fabricated): never // Sample data is for `npm run dev` only (no gateway) — `vite build` strips this.
// replace live carts with bundled demo carts on a live terminal, or an operator if (import.meta.env.DEV && (!payload.value || usingSample.value)) {
// 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) {
payload.value = sampleStation(window.value) payload.value = sampleStation(window.value)
usingSample.value = true usingSample.value = true
} }
@ -102,9 +113,15 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
async function fetchReasons() { async function fetchReasons() {
try { try {
const res = await api.get<{ reasons: Reason[] }>('/reasons') 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 { } 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) { function startPolling(ms = 20000) {
if (timer) return if (timer) return
timer = setInterval(refresh, ms) pollMs = ms
scheduleNext()
} }
function stopPolling() { function stopPolling() {
if (timer) { if (timer) {
clearInterval(timer) clearTimeout(timer)
timer = null timer = null
} }
} }
@ -173,6 +199,7 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
loading, loading,
error, error,
usingSample, usingSample,
disconnected,
lastUpdated, lastUpdated,
hasData, hasData,
allPresses, allPresses,

View File

@ -114,6 +114,27 @@ html:not(.dark) body {
background-position: 11px 0; 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) { @media (prefers-reduced-motion: reduce) {
* { * {

View File

@ -5,8 +5,10 @@
5-station temper belt and an "Ausschuss buchen" action booking popover 5-station temper belt and an "Ausschuss buchen" action booking popover
(a tooltip-style panel anchored to the button, not a centered modal). (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 Login-free by design. Data from the store (polled). On connection loss the terminal
bundled sample data so `npm run dev` renders + the booking flow is demoable. 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"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
@ -21,7 +23,7 @@ import type { RejectEntry, RejectRequest } from '@/types/station'
const { t } = useI18n() const { t } = useI18n()
const store = useTemperRejectsStore() 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 pop = ref<InstanceType<typeof RejectPopover> | null>(null)
const bookingEntry = ref<RejectEntry | null>(null) const bookingEntry = ref<RejectEntry | null>(null)
@ -80,9 +82,15 @@ onUnmounted(() => {
<template> <template>
<div class="wrap"> <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 <PressFilter
:presses="allPresses" :presses="allPresses"
:checked="checkedPresses" :checked="checkedPresses"
@ -140,6 +148,18 @@ onUnmounted(() => {
border: 1px dashed var(--line-2); border: 1px dashed var(--line-2);
border-radius: var(--r); 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 { .toast {
position: fixed; position: fixed;
left: 50%; left: 50%;