Compare commits

..

No commits in common. "0696f1645823f1159133fa0bafac2592de4a303e" and "7529bc19497a84ea2de4b98cac26b78c54dd27f7" have entirely different histories.

12 changed files with 118 additions and 217 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, cached_select # noqa: F401 (kernel DB layer) from app.db.mariadb import engine, run_select_query # noqa: F401 (kernel DB layer)
__all__ = ["run_select_query", "cached_select", "transaction", "engine"] __all__ = ["run_select_query", "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, cached_select, transaction from .._db import run_select_query, transaction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -96,9 +96,7 @@ 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:
# Reference registry (admin-edited); served from cache so it's off the pool each poll. rows = run_select_query(
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},
) )
@ -174,77 +172,71 @@ def _good_parts_map(plant: str, pairs: set[tuple[str, str]]) -> dict[tuple[str,
return {} return {}
import json import json
# Batched across ALL presses: 3 grouped queries total (was 3 per press = N+1). by_press: dict[str, list[str]] = defaultdict(list)
# Semantics preserved from the per-press version — only the query scope changed for p, o in pairs:
# (grouped by press). On any DB error the whole map degrades to {} (the caller's by_press[p].append(o)
# _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, orderno), cav in cav_by.items(): for press, orders in by_press.items():
if (press, orderno) not in pairs: # keep emitted keys identical to the input pairs 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)
continue continue
produced = _cycles_for(press, orderno) * cav
result[(press, orderno)] = max(0, produced - rej_by.get((press, orderno), 0))
return result return result
@ -468,9 +460,7 @@ 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:
# Reference data (admin-edited); served from cache. rows = run_select_query(
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",
{}, {},

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-Oxhdj_dg.css vendored Normal file

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-CjCewD1P.js"></script> <script type="module" crossorigin src="/module/temper_rejects/assets/index-DceU8_gJ.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-CU7fLO-K.css"> <link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-Oxhdj_dg.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; disconnected?: boolean }>() defineProps<{ window: WindowCode; sample: 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,8 +32,7 @@ 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 v-if="disconnected" class="reconnect"><span class="spinner sm" aria-hidden="true"></span>{{ t('conn.reconnecting') }}</span> <span class="live"><span class="led"></span>{{ t('app.live') }}</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>
@ -118,16 +117,6 @@ 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,11 +6,6 @@ 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,11 +7,6 @@ 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,12 +6,7 @@ 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'
@ -23,12 +18,9 @@ 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 setTimeout> | null = null let timer: ReturnType<typeof setInterval> | 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)
@ -84,16 +76,13 @@ 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'
// Sample data is for `npm run dev` only (no gateway) — `vite build` strips this. // Keep the last known REAL data on a transient failure (stale > fabricated): never
if (import.meta.env.DEV && (!payload.value || usingSample.value)) { // 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) {
payload.value = sampleStation(window.value) payload.value = sampleStation(window.value)
usingSample.value = true usingSample.value = true
} }
@ -113,15 +102,9 @@ 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')
if (Array.isArray(res.data?.reasons) && res.data.reasons.length) { reasons.value = Array.isArray(res.data?.reasons) ? res.data.reasons : SAMPLE_REASONS
reasons.value = res.data.reasons
return
}
throw new Error('no reasons')
} catch { } catch {
// Keep any reasons already loaded; only seed the bundled sample reasons under reasons.value = SAMPLE_REASONS
// `npm run dev` — production must never offer fabricated reason codes.
if (import.meta.env.DEV && !reasons.value.length) reasons.value = SAMPLE_REASONS
} }
} }
@ -169,22 +152,13 @@ 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
pollMs = ms timer = setInterval(refresh, ms)
scheduleNext()
} }
function stopPolling() { function stopPolling() {
if (timer) { if (timer) {
clearTimeout(timer) clearInterval(timer)
timer = null timer = null
} }
} }
@ -199,7 +173,6 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
loading, loading,
error, error,
usingSample, usingSample,
disconnected,
lastUpdated, lastUpdated,
hasData, hasData,
allPresses, allPresses,

View File

@ -114,27 +114,6 @@ 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,10 +5,8 @@
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). On connection loss the terminal Login-free by design. Data from the store (polled); no-backend falls back to
keeps the last real carts (or shows a reconnect spinner on a cold start) and retries bundled sample data so `npm run dev` renders + the booking flow is demoable.
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'
@ -23,7 +21,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, hasData, disconnected } = storeToRefs(store) const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample } = 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)
@ -82,15 +80,9 @@ onUnmounted(() => {
<template> <template>
<div class="wrap"> <div class="wrap">
<StationHeader :window="window" :sample="usingSample" :disconnected="disconnected" @update:window="store.setWindow" /> <StationHeader :window="window" :sample="usingSample" @update:window="store.setWindow" />
<!-- Cold start / no data yet: never blank, never fake spinner that keeps retrying. --> <div class="layout">
<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"
@ -148,18 +140,6 @@ 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%;