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 (