From fca9ee8fd8d1e72201173a62ef94933ba1e7219e Mon Sep 17 00:00:00 2001 From: patrick Date: Sat, 5 Sep 2026 17:57:14 +0200 Subject: [PATCH] fix(ci): CI-Testdatenbank-Encoding + 3 echte Session-/Logikfehler behoben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root Cause der "CI rot"-Meldung gefunden und behoben: - CI legte Testdatenbank ohne explizites ENCODING an, erbte SQL_ASCII vom Runner-Postgres-Template. Umlaute in JSONB (Historie-Einträge) brachen mit UntranslatableCharacterError - betraf auch die Produktions-DB (separat gemeldet, nicht Teil dieses Commits). CI legt jetzt explizit UTF8 an. Drei echte Bugs beim Verifizieren gegen eine isolierte Testdatenbank gefunden: - objekte.py PATCH /objekte/{id}: Selbstbezug-Check verließ sich auf den DB-CHECK-Constraint statt vorab zu prüfen - ein Flush-Fehlschlag hinterlässt die Session im Zustand DEACTIVE, jeder folgende Request in derselben Session crasht mit PendingRollbackError (401 statt 404 im Test). Jetzt expliziter Vorab-Check. - geraet_instanz.py: gleiches Muster bei doppelter Seriennummer - jetzt expliziter Vorab-Check statt UNIQUE-Constraint-Exception. - test_lagerbewegung.py: Testbug, las objekt.standort_id NACH dem POST (durch geteilte Session bereits auf den neuen Wert mutiert) statt vorher. Nebenbei (Auftrag Priorität 4): "Mindermenge genehmigen"-Button direkt im Nachfüll-Dialog der Kontroll-Erfassung (nur für materialverantwortlicher/ leitungsverantwortlicher/administration), nutzt den bereits bestehenden POST /fehlbestaende/{id}/mindermenge Endpunkt. Priorität 2 (Fahrzeug-Feldnamen-Mismatch) und Priorität 3 (nur Zugfahrzeuge wählbar) waren bereits in früheren Commits erledigt (38b8ce1, 54fc296) - Auftragsbeschreibung war auf altem Stand. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- .gitea/workflows/ci.yml | 7 ++- .../app/api/v1/endpoints/geraet_instanz.py | 11 +++-- backend/app/api/v1/endpoints/objekte.py | 10 +++++ backend/app/services/geraet_instanz.py | 17 +++++++ backend/tests/test_lagerbewegung.py | 6 ++- frontend/src/pages/KontrollPage.tsx | 4 ++ .../src/pages/kontrolle/NachfuellDialog.tsx | 44 ++++++++++++++++++- frontend/src/pages/kontrolle/useKontrolle.ts | 12 +++++ 8 files changed, 104 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a911564..5d0c3bb 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -30,11 +30,16 @@ jobs: - uses: actions/checkout@v4 - name: Test-Datenbank anlegen (idempotent, System-PostgreSQL des Runner-Hosts) + # Explizit ENCODING UTF8 TEMPLATE template0: der Runner-Host-Cluster hat + # SQL_ASCII als Default-Encoding (via template1), CREATE DATABASE ohne + # explizite Angabe erbt das - jeder Test mit Umlauten in JSONB-Feldern + # (z.B. Historie-Einträge) schlug dadurch mit UntranslatableCharacterError + # fehl (Fund 2026-09-05, betraf auch die Produktions-DB). run: | su -s /bin/bash postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='${TEST_DB_USER}'\"" | grep -q 1 || \ su -s /bin/bash postgres -c "psql -c \"CREATE ROLE ${TEST_DB_USER} WITH LOGIN PASSWORD '${TEST_DB_PASSWORD}';\"" su -s /bin/bash postgres -c "psql -c \"DROP DATABASE IF EXISTS ${TEST_DB_NAME};\"" - su -s /bin/bash postgres -c "psql -c \"CREATE DATABASE ${TEST_DB_NAME} OWNER ${TEST_DB_USER};\"" + su -s /bin/bash postgres -c "psql -c \"CREATE DATABASE ${TEST_DB_NAME} OWNER ${TEST_DB_USER} ENCODING 'UTF8' TEMPLATE template0;\"" su -s /bin/bash postgres -c "psql -d ${TEST_DB_NAME} -c \"CREATE EXTENSION IF NOT EXISTS pgcrypto;\"" - name: Virtualenv + Dependencies installieren diff --git a/backend/app/api/v1/endpoints/geraet_instanz.py b/backend/app/api/v1/endpoints/geraet_instanz.py index 4a06332..a3f227d 100644 --- a/backend/app/api/v1/endpoints/geraet_instanz.py +++ b/backend/app/api/v1/endpoints/geraet_instanz.py @@ -2,7 +2,6 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select -from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import require_roles, require_roles_or_permission @@ -11,7 +10,13 @@ from app.models.auth import KnotenTyp, RolleTyp, Systemknoten from app.models.geraet_instanz import GeraetInstanz from app.models.objektposition import Objektposition from app.schemas.geraet_instanz import GeraetInstanzCreate, GeraetInstanzRead, GeraetInstanzStatusUpdate -from app.services.geraet_instanz import aendere_status, erstelle, liste_fuer_position, loesche +from app.services.geraet_instanz import ( + SeriennummerBereitsVergebenError, + aendere_status, + erstelle, + liste_fuer_position, + loesche, +) router = APIRouter() @@ -58,7 +63,7 @@ async def erstelle_geraet( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden") try: return await erstelle(db, objektposition_id=position_id, seriennummer=payload.seriennummer) - except IntegrityError as exc: + except SeriennummerBereitsVergebenError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Seriennummer an dieser Position bereits vergeben" ) from exc diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index 9d403d5..4b49609 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -273,6 +273,16 @@ async def aendere_objekt( daten = payload.model_dump(exclude_unset=True) ziel_id = daten.get("fahrzeug_id") if ziel_id is not None: + # Selbstbezug explizit vorab abfangen statt sich auf den DB-CHECK- + # Constraint zu verlassen: ein Flush-Fehlschlag hier hinterlässt die + # Session im Zustand DEACTIVE (SQLAlchemy 2.0), was in einer über + # mehrere Requests geteilten Session (z.B. Testsuite) jeden folgenden + # Request mit PendingRollbackError zum Absturz bringt (Fund 2026-09-05). + if ziel_id == objekt_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Zyklus: Objekt kann nicht sich selbst als Fahrzeug zugeordnet werden", + ) ziel = await _pruefe_zugfahrzeug(db, ziel_id) if ziel.fahrzeug_id == objekt_id: raise HTTPException( diff --git a/backend/app/services/geraet_instanz.py b/backend/app/services/geraet_instanz.py index 7e8fa1a..4f1b52b 100644 --- a/backend/app/services/geraet_instanz.py +++ b/backend/app/services/geraet_instanz.py @@ -32,7 +32,24 @@ async def liste_fuer_position(db: AsyncSession, objektposition_id) -> list[Gerae return list(result.scalars().all()) +class SeriennummerBereitsVergebenError(Exception): + pass + + async def erstelle(db: AsyncSession, *, objektposition_id, seriennummer: str) -> GeraetInstanz: + # Explizit vorab prüfen statt sich auf den UNIQUE-Constraint zu verlassen: + # ein Flush-Fehlschlag hier hinterlässt die Session im Zustand DEACTIVE + # (SQLAlchemy 2.0), was in einer über mehrere Requests geteilten Session + # (z.B. Testsuite) jeden folgenden Request mit PendingRollbackError zum + # Absturz bringt (Fund 2026-09-05). + result = await db.execute( + select(GeraetInstanz).where( + GeraetInstanz.objektposition_id == objektposition_id, + GeraetInstanz.seriennummer == seriennummer, + ) + ) + if result.scalar_one_or_none() is not None: + raise SeriennummerBereitsVergebenError instanz = GeraetInstanz(objektposition_id=objektposition_id, seriennummer=seriennummer) db.add(instanz) await db.flush() diff --git a/backend/tests/test_lagerbewegung.py b/backend/tests/test_lagerbewegung.py index d4a67c6..4efc81d 100644 --- a/backend/tests/test_lagerbewegung.py +++ b/backend/tests/test_lagerbewegung.py @@ -10,6 +10,10 @@ async def test_objekt_verschieben_protokolliert_bewegung( objekt, _material = objekt_mit_position ziel = await standort_factory("Aussenlager") token = await login(client, "materialverantwortlicher1") + # Muss vor dem POST gelesen werden: Service/Endpunkt aktualisiert objekt.standort_id + # in-place auf derselben (in Tests geteilten) Session, danach zeigt objekt.standort_id + # bereits den NEUEN Wert (Fund 2026-09-05). + alter_standort_id = objekt.standort_id bewegung = await client.post( f"/api/v1/objekte/{objekt.id}/lagerbewegungen", @@ -19,7 +23,7 @@ async def test_objekt_verschieben_protokolliert_bewegung( assert bewegung.status_code == 201 body = bewegung.json() assert body["objekt_id"] == objekt.id - assert body["von_standort_id"] == objekt.standort_id + assert body["von_standort_id"] == alter_standort_id assert body["nach_standort_id"] == ziel.id assert body["grund"] == "Umzug" diff --git a/frontend/src/pages/KontrollPage.tsx b/frontend/src/pages/KontrollPage.tsx index 637d3a4..e83d5aa 100644 --- a/frontend/src/pages/KontrollPage.tsx +++ b/frontend/src/pages/KontrollPage.tsx @@ -25,6 +25,7 @@ export function KontrollPage() { abschliessen, abbrechen, jetztNachfuellen, + jetztMindermengeGenehmigen, nachfuellDialogSchliessen, } = useKontrolle(objektId); @@ -116,6 +117,9 @@ export function KontrollPage() { Number(zustand.position.sollmenge_effektiv) - Number(zustand.eingabe) )} onNachfuellen={(menge) => jetztNachfuellen(nachfuellDialog.fehlbestandId, menge)} + onMindermengeGenehmigen={(begruendung) => + jetztMindermengeGenehmigen(nachfuellDialog.fehlbestandId, begruendung) + } onSchliessen={nachfuellDialogSchliessen} /> )} diff --git a/frontend/src/pages/kontrolle/NachfuellDialog.tsx b/frontend/src/pages/kontrolle/NachfuellDialog.tsx index d9c6afb..a62d1f7 100644 --- a/frontend/src/pages/kontrolle/NachfuellDialog.tsx +++ b/frontend/src/pages/kontrolle/NachfuellDialog.tsx @@ -1,15 +1,29 @@ import { useState } from "react"; +import { useAuth } from "../../auth/AuthContext"; + interface Props { materialName: string; vorschlagMenge: string; onNachfuellen: (menge: string) => void; + onMindermengeGenehmigen: (begruendung: string) => void; onSchliessen: () => void; } -/** Karte 07: nach einer Abweichung direkt "jetzt nachfüllen?" statt Umweg über Dashboard. */ -export function NachfuellDialog({ materialName, vorschlagMenge, onNachfuellen, onSchliessen }: Props) { +/** Karte 07: nach einer Abweichung direkt "jetzt nachfüllen?" statt Umweg über Dashboard. + * Mindermenge genehmigen (Prompt 04) darf hier direkt inline erfolgen, nicht nur über + * die separate Fehlbestandsliste - aber wie dort NIEMALS durch Mitarbeiter. */ +export function NachfuellDialog({ + materialName, + vorschlagMenge, + onNachfuellen, + onMindermengeGenehmigen, + onSchliessen, +}: Props) { + const { istVerantwortlich } = useAuth(); const [menge, setMenge] = useState(vorschlagMenge); + const [mindermengeOffen, setMindermengeOffen] = useState(false); + const [begruendung, setBegruendung] = useState(""); return (
@@ -27,10 +41,36 @@ export function NachfuellDialog({ materialName, vorschlagMenge, onNachfuellen, o + {istVerantwortlich && !mindermengeOffen && ( + + )}
+ {mindermengeOffen && ( +
+ setBegruendung(e.target.value)} + /> + + +
+ )} ); } diff --git a/frontend/src/pages/kontrolle/useKontrolle.ts b/frontend/src/pages/kontrolle/useKontrolle.ts index 8808cee..45bad94 100644 --- a/frontend/src/pages/kontrolle/useKontrolle.ts +++ b/frontend/src/pages/kontrolle/useKontrolle.ts @@ -152,6 +152,17 @@ export function useKontrolle(objektId: string | undefined) { setNachfuellDialog(null); } + /** Prompt 04: Mindermenge direkt aus der Kontroll-Erfassung genehmigen, ohne Umweg + * über die separate Fehlbestandsliste. Backend erzwingt Rollen-Dependency ohnehin + * (nie durch Mitarbeiter), Frontend blendet den Button zusätzlich aus. */ + async function jetztMindermengeGenehmigen(fehlbestandId: string, begruendung: string) { + await apiRequest(`/fehlbestaende/${fehlbestandId}/mindermenge`, { + method: "POST", + body: { begruendung }, + }); + setNachfuellDialog(null); + } + function nachfuellDialogSchliessen() { setNachfuellDialog(null); } @@ -208,6 +219,7 @@ export function useKontrolle(objektId: string | undefined) { abschliessen, abbrechen, jetztNachfuellen, + jetztMindermengeGenehmigen, nachfuellDialogSchliessen, }; }