diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index 3e0472a..77489e7 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -228,9 +228,8 @@ async def einsatzbereitschaft(db: AsyncSession) -> dict: "unbekannt": unbekannt, "nicht_einsatzbereit": len(objekte) - bereit - eingeschraenkt - unbekannt, "gesamt": len(objekte), - # Bug-Fund 2026-09-06: "unbekannt" zählt nur Objekte OHNE jeden anderen - # Grund - diese Zahl zählt "nie kontrolliert" unabhängig von sonstigen - # Problemen mit, damit sie beim manuellen Nachzählen stimmt. + # "unbekannt" zählt nur Objekte ohne jeden anderen Grund - diese Zahl + # zählt "nie kontrolliert" unabhängig von sonstigen Problemen mit. "nie_kontrolliert_gesamt": nie_kontrolliert_gesamt, "details": details, } @@ -247,7 +246,7 @@ def _kategorie_und_gruende( objekt_ids_kritischer_mangel: set, objekt_ids_hu_uvv_ueberfaellig: set, ) -> tuple[str, list[str]]: - """UI-005 (Epic 20): aus der `einsatzbereitschaft()`-Schleife extrahiert, damit + """aus der `einsatzbereitschaft()`-Schleife extrahiert, damit `objekt_readiness()` (Begründung auf der Objektakte) dieselbe Logik nutzt statt sie zu duplizieren.""" gruende: list[str] = [] @@ -267,12 +266,8 @@ def _kategorie_und_gruende( if objekt.id in objekt_ids_hu_uvv_ueberfaellig: gruende.append("hu_uvv_ueberfaellig") - # Bug-Fund 2026-09-06 (Nutzer: "beim genauen Schauen sind es mehr"): - # "noch nie kontrolliert" wurde bisher nur gezählt, wenn es der EINZIGE - # Grund war - ein Objekt mit z.B. offenem Fehlbestand UND nie kontrolliert - # verlor den Hinweis komplett (fiel unter "nicht einsatzbereit" ohne - # diesen Grund zu nennen). Jetzt immer als Zusatzgrund angehängt, wenn - # zutreffend, unabhängig von anderen Gründen. + # "noch nie kontrolliert" gilt zusätzlich zu jedem anderen Grund, nicht nur + # wenn es der einzige ist. nie_kontrolliert = letzte_kontrolle is None if not gruende and fehlbestand_ids and fehlbestand_ids <= fehlbestand_ids_genehmigt: @@ -288,7 +283,7 @@ def _kategorie_und_gruende( async def objekt_readiness(db: AsyncSession, objekt_id: int) -> dict: - """UI-005 (Epic 20): begründete Einsatzbereitschaft für EIN Objekt (Objektakte), + """begründete Einsatzbereitschaft für EIN Objekt (Objektakte), dieselbe Fachlogik wie `einsatzbereitschaft()` (Dashboard-Aggregat), nur für einen einzelnen Datensatz statt aller Objekte. `ausser_dienst` zählt hier bewusst nicht als eigener Fall - Objektakte zeigt das Objekt so oder so an, diff --git a/backend/app/services/kontrolle/erfassung.py b/backend/app/services/kontrolle/erfassung.py index f0082e4..3233598 100644 --- a/backend/app/services/kontrolle/erfassung.py +++ b/backend/app/services/kontrolle/erfassung.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.fehlbestand import Fehlbestand, FehlbestandStatus from app.models.kontrolle import Kontrolle, Kontrollposition +from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung from app.models.objekt import Objekt from app.models.objektposition import Objektposition from app.services import benachrichtigung as benachrichtigung_service @@ -109,12 +110,13 @@ async def _synchronisiere_fehlbestand( ) -> uuid.UUID | None: result = await db.execute( select(Fehlbestand).where( - Fehlbestand.kontrolle_id == kontrolle.id, + Fehlbestand.objekt_id == objekt.id, Fehlbestand.material_id == material_id, Fehlbestand.status == FehlbestandStatus.offen, ) ) bestehender = result.scalar_one_or_none() + von_dieser_kontrolle = bestehender is not None and bestehender.kontrolle_id == kontrolle.id unterdeckung = sollmenge - istmenge if unterdeckung > 0: @@ -153,16 +155,48 @@ async def _synchronisiere_fehlbestand( ) await benachrichtigung_service.benachrichtige_neuer_fehlbestand(db, fehlbestand=bestehender) else: - # Korrektur vor Abschluss (z. B. Tippfehler nochmal erfasst) - solange die - # Kontrolle noch läuft, ist der von IHR erzeugte Fehlbestand noch nicht - # final und darf berichtigt werden. + # kontrolle_id bleibt unangetastet, sonst würde ein Abbruch dieser + # Kontrolle einen fremden Fehlbestand mitlöschen (_verwirf_kontrolldaten). bestehender.sollmenge = sollmenge bestehender.istmenge = istmenge bestehender.fehlmenge = unterdeckung elif bestehender is not None: - # Abweichung wurde vor Abschluss korrigiert - der von dieser Kontrolle - # erzeugte Fehlbestand hat sich erledigt, bevor er je "real" wurde. - await db.delete(bestehender) - bestehender = None + if von_dieser_kontrolle: + await db.delete(bestehender) + bestehender = None + else: + bestehender.istmenge = istmenge + bestehender.fehlmenge = Decimal(0) + bestehender.status = FehlbestandStatus.erledigt + bestehender.erledigt_am = datetime.now(timezone.utc) + await historie_service.log( + db, + zustaendiger_server_id=zustaendiger_server_id, + benutzer_id=kontrolle.benutzer_id, + ereignistyp="fehlbestand_erledigt", + entitaet_typ="fehlbestand", + entitaet_id=bestehender.id, + neuer_wert={"status": "erledigt"}, + ) + genehmigung_result = await db.execute( + select(MindermengenGenehmigung).where( + MindermengenGenehmigung.fehlbestand_id == bestehender.id, + MindermengenGenehmigung.status == MindermengeStatus.aktiv, + ) + ) + genehmigung = genehmigung_result.scalar_one_or_none() + if genehmigung is not None: + genehmigung.status = MindermengeStatus.beendet_durch_erledigung + genehmigung.beendet_am = datetime.now(timezone.utc) + await historie_service.log( + db, + zustaendiger_server_id=zustaendiger_server_id, + benutzer_id=kontrolle.benutzer_id, + ereignistyp="mindermenge_beendet_durch_erledigung", + entitaet_typ="mindermengen_genehmigung", + entitaet_id=genehmigung.id, + neuer_wert={"status": "beendet_durch_erledigung"}, + ) + bestehender = None await db.flush() return bestehender.id if bestehender is not None else None diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index b76eee8..8e93ac1 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -204,9 +204,6 @@ async def test_offener_fehlbestand_macht_objekt_nicht_einsatzbereit( body = response.json() assert body["einsatzbereit"] == 0 assert body["nicht_einsatzbereit"] == 1 - # Bug-Fund 2026-09-06: "nie kontrolliert" darf nicht verloren gehen, nur - # weil das Objekt zusätzlich einen anderen Grund hat (objekt_mit_position - # hat nie eine abgeschlossene Kontrolle). assert body["details"][0]["gruende"] == ["fehlbestand_offen", "noch_nie_kontrolliert"] assert body["nie_kontrolliert_gesamt"] == 1 diff --git a/backend/tests/test_kontrolle.py b/backend/tests/test_kontrolle.py index 476c1e9..9ad2cae 100644 --- a/backend/tests/test_kontrolle.py +++ b/backend/tests/test_kontrolle.py @@ -306,3 +306,70 @@ async def test_verschiedene_objekte_parallel_kontrollierbar( zweiter = await _starte(client, token2, objekt_b.id) assert zweiter.status_code == 201 + + +@pytest.mark.asyncio +async def test_zweite_kontrolle_aktualisiert_offenen_fehlbestand_statt_duplikat( + client, db_session, objekt_mit_position, mitarbeiter_user +): + """Ein offener Fehlbestand aus einer früheren Kontrolle wird aktualisiert + statt einen zweiten parallelen zu erzeugen.""" + objekt, material = objekt_mit_position + token = await login(client, "mitarbeiter1") + + erste_kontrolle = (await _starte(client, token, objekt.id)).json()["id"] + await client.put( + f"/api/v1/kontrollen/{erste_kontrolle}/positionen/{material.id}", + json={"istmenge": "8"}, + headers=auth_header(token), + ) + await client.post(f"/api/v1/kontrollen/{erste_kontrolle}/abschliessen", headers=auth_header(token)) + + zweite_kontrolle = (await _starte(client, token, objekt.id)).json()["id"] + await client.put( + f"/api/v1/kontrollen/{zweite_kontrolle}/positionen/{material.id}", + json={"istmenge": "7"}, + headers=auth_header(token), + ) + + result = await db_session.execute( + select(Fehlbestand).where(Fehlbestand.objekt_id == objekt.id, Fehlbestand.material_id == material.id) + ) + fehlbestaende = result.scalars().all() + assert len(fehlbestaende) == 1 + assert float(fehlbestaende[0].istmenge) == 7 + assert float(fehlbestaende[0].fehlmenge) == 3 + assert fehlbestaende[0].kontrolle_id == erste_kontrolle + + +@pytest.mark.asyncio +async def test_zweite_kontrolle_erledigt_offenen_fehlbestand_bei_korrektur( + client, db_session, objekt_mit_position, mitarbeiter_user +): + """Ergänzend: löst die Folge-Kontrolle die Abweichung auf, wird der + Fehlbestand aus der früheren Kontrolle als erledigt markiert (nicht + gelöscht - er war bereits real, anders als der Same-Session-Fall).""" + objekt, material = objekt_mit_position + token = await login(client, "mitarbeiter1") + + erste_kontrolle = (await _starte(client, token, objekt.id)).json()["id"] + await client.put( + f"/api/v1/kontrollen/{erste_kontrolle}/positionen/{material.id}", + json={"istmenge": "8"}, + headers=auth_header(token), + ) + await client.post(f"/api/v1/kontrollen/{erste_kontrolle}/abschliessen", headers=auth_header(token)) + + zweite_kontrolle = (await _starte(client, token, objekt.id)).json()["id"] + await client.put( + f"/api/v1/kontrollen/{zweite_kontrolle}/positionen/{material.id}", + json={"istmenge": "10"}, + headers=auth_header(token), + ) + + result = await db_session.execute( + select(Fehlbestand).where(Fehlbestand.objekt_id == objekt.id, Fehlbestand.material_id == material.id) + ) + fehlbestand = result.scalar_one() + assert fehlbestand.status.value == "erledigt" + assert fehlbestand.fehlmenge == 0 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f80b175..d8e51d7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -106,7 +106,7 @@ export async function ladeDokumentHerunter(dokumentId: string, dateiname: string URL.revokeObjectURL(url); } -// UI-005 (Epic 20): Dokumente waren bisher nur per Zwangs-Download einsehbar, +// Dokumente waren bisher nur per Zwangs-Download einsehbar, // nicht direkt im Browser anschaubar (PDF/Bild in neuem Tab statt Datei // speichern). Auch hier: Auth-Header nötig, daher Blob statt einfachem // auf den Download-Endpunkt. Object-URL wird bewusst NICHT sofort diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index a29fbd3..158d24c 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -13,7 +13,7 @@ interface Eintrag { } /** - * UI-002 (Epic 20): globale Suche/Quick Actions per Ctrl+K bzw. ⌘+K. + * globale Suche/Quick Actions per Ctrl+K bzw. ⌘+K. * Öffnet sich über der ganzen App (AppShell), navigiert nur - keine eigene * Fachlogik, nur Zusammenfassung bereits vorhandener Routen/Aktionen. */ diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index 6073feb..9510ffa 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -25,7 +25,7 @@ function gespeichertesTheme(): Theme { const REIHENFOLGE: Theme[] = ["system", "light", "dark"]; const LABEL: Record = { system: "System", light: "Hell", dark: "Dunkel" }; -/** UI-001 (Epic 20): manueller Dark-Mode-Schalter, Präferenz überlebt Reload. */ +/** manueller Dark-Mode-Schalter, Präferenz überlebt Reload. */ export function ThemeToggle() { const [theme, setTheme] = useState(() => gespeichertesTheme()); diff --git a/frontend/src/components/status/FristBadge.tsx b/frontend/src/components/status/FristBadge.tsx index 2080742..c0d7548 100644 --- a/frontend/src/components/status/FristBadge.tsx +++ b/frontend/src/components/status/FristBadge.tsx @@ -17,7 +17,7 @@ interface Props { tage?: number; } -/** UI-003 (Epic 20): gemeinsame Basis für InspectionStatus/InventoryStatus - +/** gemeinsame Basis für InspectionStatus/InventoryStatus - * beide Backend-Endpunkte (Prüftermine/Ablaufdaten) liefern dieselben zwei * Fristzustände (siehe app/services/dashboard.py `_ablauf_status`). */ export function FristBadge({ status, tage }: Props) { diff --git a/frontend/src/components/status/InspectionStatus.tsx b/frontend/src/components/status/InspectionStatus.tsx index ead2bbd..f29b21f 100644 --- a/frontend/src/components/status/InspectionStatus.tsx +++ b/frontend/src/components/status/InspectionStatus.tsx @@ -1,6 +1,6 @@ import { FristBadge } from "./FristBadge"; -/** UI-003 (Epic 20): Geräteprüfung-Fristanzeige (Dashboard "Nächste Prüftermine"). */ +/** Geräteprüfung-Fristanzeige (Dashboard "Nächste Prüftermine"). */ export function InspectionStatus({ status, tage }: { status: string; tage?: number }) { return ; } diff --git a/frontend/src/components/status/InventoryStatus.tsx b/frontend/src/components/status/InventoryStatus.tsx index 365c64f..d948a00 100644 --- a/frontend/src/components/status/InventoryStatus.tsx +++ b/frontend/src/components/status/InventoryStatus.tsx @@ -1,6 +1,6 @@ import { FristBadge } from "./FristBadge"; -/** UI-003 (Epic 20): Chargen-/Ablaufdatum-Fristanzeige (Dashboard "Bevorstehende Ablaufdaten"). */ +/** Chargen-/Ablaufdatum-Fristanzeige (Dashboard "Bevorstehende Ablaufdaten"). */ export function InventoryStatus({ status, tage }: { status: string; tage?: number }) { return ; } diff --git a/frontend/src/components/status/ReadinessBadge.tsx b/frontend/src/components/status/ReadinessBadge.tsx index 309e7cb..491cd04 100644 --- a/frontend/src/components/status/ReadinessBadge.tsx +++ b/frontend/src/components/status/ReadinessBadge.tsx @@ -27,7 +27,7 @@ interface Props { } /** - * UI-003 (Epic 20): einzige Stelle, die Einsatzbereitschaft farblich/textlich + * einzige Stelle, die Einsatzbereitschaft farblich/textlich * darstellt - "unbekannt" (noch nie kontrolliert) sieht bewusst NIE wie * "einsatzbereit" aus (eigene Farbe/Icon, siehe global.css badge-unknown). */ diff --git a/frontend/src/components/status/SyncStatus.tsx b/frontend/src/components/status/SyncStatus.tsx index d015555..81a150d 100644 --- a/frontend/src/components/status/SyncStatus.tsx +++ b/frontend/src/components/status/SyncStatus.tsx @@ -4,7 +4,7 @@ import { alleEintraege } from "../../offline/queue"; import { useOnlineStatus } from "../../offline/useOnlineStatus"; /** - * UI-003 (Epic 20): Online-/Offline-/Sync-Status war bisher nirgends sichtbar + * Online-/Offline-/Sync-Status war bisher nirgends sichtbar * (useOnlineStatus/Queue existierten nur intern für den Kontroll-Workflow) - * echte Lücke laut Redesign-Vorgabe, kein reiner Umbau. */ diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index bbe5173..5361ed1 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -104,7 +104,7 @@ export function AdminPage() { const [qualifikationstypen, setQualifikationstypen] = useState([]); const [fehler, setFehler] = useState(null); const [laedt, setLaedt] = useState(true); - // UI-002 (Epic 20): Tab im URL-Query statt nur lokalem State - erlaubt + // Tab im URL-Query statt nur lokalem State - erlaubt // Deep-Links von der Command Palette (z.B. /admin?tab=material). const [suchparameter, setSuchparameter] = useSearchParams(); const tab = suchparameter.get("tab") as Tab | null; diff --git a/frontend/src/pages/AktePage.tsx b/frontend/src/pages/AktePage.tsx index 734e706..521c53c 100644 --- a/frontend/src/pages/AktePage.tsx +++ b/frontend/src/pages/AktePage.tsx @@ -58,7 +58,7 @@ export function AktePage() { )} - {/* UI-007 (Epic 20): nach dem QR-Scan (MOBILE-002) landet man hier - die + {/* nach dem QR-Scan (MOBILE-002) landet man hier - die Kernaktionen sollen direkt sichtbar sein, nicht erst am Seitenende. */}
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index a143920..8835342 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -118,10 +118,6 @@ export function DashboardPage() { const pruefungenUeberfaellig = prueftermine.filter((p) => p.status === "abgelaufen").length; const ablaufdatenAbgelaufen = ablaufdaten.filter((a) => a.status === "abgelaufen").length; - // UI-004 (Epic 20): "Was ist einsatzbereit und was muss ich jetzt tun?" in - // 5 Sekunden beantwortbar - eine Aufgaben-Karte statt Zahlen über sechs - // gleichrangige Karten verteilt suchen zu müssen. Keine neuen Daten, nur - // Zusammenfassung der bereits geladenen Kennzahlen mit Direktlinks. const aufgaben = [ kennzahlen && kennzahlen.anzahl_offener_fehlbestaende > 0 ? { text: `${kennzahlen.anzahl_offener_fehlbestaende} offene Fehlbestände`, ziel: "/fehlbestaende" } diff --git a/frontend/src/pages/FehlbestandListePage.tsx b/frontend/src/pages/FehlbestandListePage.tsx index ba946fc..33352f5 100644 --- a/frontend/src/pages/FehlbestandListePage.tsx +++ b/frontend/src/pages/FehlbestandListePage.tsx @@ -4,7 +4,7 @@ import { apiRequest } from "../api/client"; import { useAuth } from "../auth/AuthContext"; import type { Fehlbestand, Material, Objekt } from "../api/types"; -// UI-008 (Epic 20): Status war bisher immer badge-neutral, unabhängig vom +// Status war bisher immer badge-neutral, unabhängig vom // tatsächlichen Zustand - andere Listen (Mängel, Ausgabe) unterscheiden längst // visuell zwischen offen/erledigt. const STATUS_BADGE: Record = { diff --git a/frontend/src/pages/MangelListePage.tsx b/frontend/src/pages/MangelListePage.tsx index e05f60a..a9e8462 100644 --- a/frontend/src/pages/MangelListePage.tsx +++ b/frontend/src/pages/MangelListePage.tsx @@ -14,7 +14,7 @@ const STATUS_LABEL: Record = { erledigt: "Erledigt", }; -// UI-008 (Epic 20): Status-Badge war immer neutral, unabhängig vom Zustand. +// Status-Badge war immer neutral, unabhängig vom Zustand. const STATUS_BADGE: Record = { neu: "badge-warning", in_bearbeitung: "badge-warning", @@ -45,7 +45,7 @@ export function MangelListePage() { const [nurOffene, setNurOffene] = useState(true); const [suchparameter] = useSearchParams(); - // UI-007 (Epic 20): Quick Action "Mangel melden" aus der Objektakte springt + // Quick Action "Mangel melden" aus der Objektakte springt // hierher mit ?objekt_id=X und wählt das Objekt direkt vor. const [objektId, setObjektId] = useState(() => { const wert = Number(suchparameter.get("objekt_id")); diff --git a/frontend/src/pages/ObjektListPage.tsx b/frontend/src/pages/ObjektListPage.tsx index f006531..a567cc9 100644 --- a/frontend/src/pages/ObjektListPage.tsx +++ b/frontend/src/pages/ObjektListPage.tsx @@ -5,10 +5,8 @@ import { apiRequest, ApiError } from "../api/client"; import type { Objekt } from "../api/types"; import { BarcodeScanner } from "../components/BarcodeScanner"; -// Prompt 11 Screen 1: Standort/Objekt wählen. Objekt-Code (MOBILE-002) → -// Direktsprung zur Akte (Zielseite laut Kachel 15), sonst füllt der Scan -// dasselbe Textfeld wie die manuelle Suche (Kamera fällt bei Etikettschaden -// auf Tippen zurück). +// Objekt-Code-Scan springt direkt zur Akte, sonst füllt er dasselbe Textfeld +// wie die manuelle Suche (Kamera fällt bei Etikettschaden auf Tippen zurück). export function ObjektListPage() { const navigate = useNavigate(); const [objekte, setObjekte] = useState([]); @@ -18,7 +16,7 @@ export function ObjektListPage() { const [scannerOffen, setScannerOffen] = useState(false); const [suchparameter, setSuchparameter] = useSearchParams(); - // UI-002 (Epic 20): Quick Action "QR scannen" aus der Command Palette springt + // Quick Action "QR scannen" aus der Command Palette springt // hierher mit ?scan=1 und öffnet den Scanner direkt, statt nur die Liste zu zeigen. useEffect(() => { if (suchparameter.get("scan") === "1") { diff --git a/frontend/src/pages/admin/GeraeteInstanzenListe.tsx b/frontend/src/pages/admin/GeraeteInstanzenListe.tsx index 074aa81..11a6436 100644 --- a/frontend/src/pages/admin/GeraeteInstanzenListe.tsx +++ b/frontend/src/pages/admin/GeraeteInstanzenListe.tsx @@ -15,7 +15,7 @@ const STATUS_LABEL: Record = { in_reparatur: "In Reparatur", }; -// UI-008 (Epic 20): Status-Badge war immer neutral, unabhängig vom Zustand. +// Status-Badge war immer neutral, unabhängig vom Zustand. const STATUS_BADGE: Record = { einsatzbereit: "badge-success", defekt: "badge-danger", diff --git a/frontend/src/pages/kontrolle/PositionCard.tsx b/frontend/src/pages/kontrolle/PositionCard.tsx index 78a6e60..227cef5 100644 --- a/frontend/src/pages/kontrolle/PositionCard.tsx +++ b/frontend/src/pages/kontrolle/PositionCard.tsx @@ -21,7 +21,7 @@ function ablaufFarbe(ablaufdatum: string): string | undefined { return "var(--color-success)"; } -// UI-006 (Epic 20): "Fehlende oder problematische Positionen klar hervorheben" - +// "Fehlende oder problematische Positionen klar hervorheben" - // Rand-Farbe macht eine Abweichung auch nach dem Speichern noch auf einen Blick // sichtbar, nicht nur kurz während des Nachfüll-Dialogs. function rahmenFarbe(status: PositionZustand["status"], passt: boolean): string {