fix(ci): CI-Testdatenbank-Encoding + 3 echte Session-/Logikfehler behoben
CI / backend-tests (push) Successful in 1m54s
CI / frontend-build (push) Successful in 17s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-05 17:57:14 +02:00
co-authored by Claude Sonnet 5
parent c7dd47f5bf
commit fca9ee8fd8
8 changed files with 104 additions and 7 deletions
+6 -1
View File
@@ -30,11 +30,16 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Test-Datenbank anlegen (idempotent, System-PostgreSQL des Runner-Hosts) - 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: | 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 -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 \"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 \"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;\"" su -s /bin/bash postgres -c "psql -d ${TEST_DB_NAME} -c \"CREATE EXTENSION IF NOT EXISTS pgcrypto;\""
- name: Virtualenv + Dependencies installieren - name: Virtualenv + Dependencies installieren
@@ -2,7 +2,6 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_roles, require_roles_or_permission 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.geraet_instanz import GeraetInstanz
from app.models.objektposition import Objektposition from app.models.objektposition import Objektposition
from app.schemas.geraet_instanz import GeraetInstanzCreate, GeraetInstanzRead, GeraetInstanzStatusUpdate 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() router = APIRouter()
@@ -58,7 +63,7 @@ async def erstelle_geraet(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden")
try: try:
return await erstelle(db, objektposition_id=position_id, seriennummer=payload.seriennummer) return await erstelle(db, objektposition_id=position_id, seriennummer=payload.seriennummer)
except IntegrityError as exc: except SeriennummerBereitsVergebenError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Seriennummer an dieser Position bereits vergeben" status_code=status.HTTP_409_CONFLICT, detail="Seriennummer an dieser Position bereits vergeben"
) from exc ) from exc
+10
View File
@@ -273,6 +273,16 @@ async def aendere_objekt(
daten = payload.model_dump(exclude_unset=True) daten = payload.model_dump(exclude_unset=True)
ziel_id = daten.get("fahrzeug_id") ziel_id = daten.get("fahrzeug_id")
if ziel_id is not None: 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) ziel = await _pruefe_zugfahrzeug(db, ziel_id)
if ziel.fahrzeug_id == objekt_id: if ziel.fahrzeug_id == objekt_id:
raise HTTPException( raise HTTPException(
+17
View File
@@ -32,7 +32,24 @@ async def liste_fuer_position(db: AsyncSession, objektposition_id) -> list[Gerae
return list(result.scalars().all()) return list(result.scalars().all())
class SeriennummerBereitsVergebenError(Exception):
pass
async def erstelle(db: AsyncSession, *, objektposition_id, seriennummer: str) -> GeraetInstanz: 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) instanz = GeraetInstanz(objektposition_id=objektposition_id, seriennummer=seriennummer)
db.add(instanz) db.add(instanz)
await db.flush() await db.flush()
+5 -1
View File
@@ -10,6 +10,10 @@ async def test_objekt_verschieben_protokolliert_bewegung(
objekt, _material = objekt_mit_position objekt, _material = objekt_mit_position
ziel = await standort_factory("Aussenlager") ziel = await standort_factory("Aussenlager")
token = await login(client, "materialverantwortlicher1") 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( bewegung = await client.post(
f"/api/v1/objekte/{objekt.id}/lagerbewegungen", f"/api/v1/objekte/{objekt.id}/lagerbewegungen",
@@ -19,7 +23,7 @@ async def test_objekt_verschieben_protokolliert_bewegung(
assert bewegung.status_code == 201 assert bewegung.status_code == 201
body = bewegung.json() body = bewegung.json()
assert body["objekt_id"] == objekt.id 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["nach_standort_id"] == ziel.id
assert body["grund"] == "Umzug" assert body["grund"] == "Umzug"
+4
View File
@@ -25,6 +25,7 @@ export function KontrollPage() {
abschliessen, abschliessen,
abbrechen, abbrechen,
jetztNachfuellen, jetztNachfuellen,
jetztMindermengeGenehmigen,
nachfuellDialogSchliessen, nachfuellDialogSchliessen,
} = useKontrolle(objektId); } = useKontrolle(objektId);
@@ -116,6 +117,9 @@ export function KontrollPage() {
Number(zustand.position.sollmenge_effektiv) - Number(zustand.eingabe) Number(zustand.position.sollmenge_effektiv) - Number(zustand.eingabe)
)} )}
onNachfuellen={(menge) => jetztNachfuellen(nachfuellDialog.fehlbestandId, menge)} onNachfuellen={(menge) => jetztNachfuellen(nachfuellDialog.fehlbestandId, menge)}
onMindermengeGenehmigen={(begruendung) =>
jetztMindermengeGenehmigen(nachfuellDialog.fehlbestandId, begruendung)
}
onSchliessen={nachfuellDialogSchliessen} onSchliessen={nachfuellDialogSchliessen}
/> />
)} )}
@@ -1,15 +1,29 @@
import { useState } from "react"; import { useState } from "react";
import { useAuth } from "../../auth/AuthContext";
interface Props { interface Props {
materialName: string; materialName: string;
vorschlagMenge: string; vorschlagMenge: string;
onNachfuellen: (menge: string) => void; onNachfuellen: (menge: string) => void;
onMindermengeGenehmigen: (begruendung: string) => void;
onSchliessen: () => void; onSchliessen: () => void;
} }
/** Karte 07: nach einer Abweichung direkt "jetzt nachfüllen?" statt Umweg über Dashboard. */ /** Karte 07: nach einer Abweichung direkt "jetzt nachfüllen?" statt Umweg über Dashboard.
export function NachfuellDialog({ materialName, vorschlagMenge, onNachfuellen, onSchliessen }: Props) { * 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 [menge, setMenge] = useState(vorschlagMenge);
const [mindermengeOffen, setMindermengeOffen] = useState(false);
const [begruendung, setBegruendung] = useState("");
return ( return (
<div className="card" style={{ borderColor: "var(--color-warning, #b45309)", marginTop: "0.6rem" }}> <div className="card" style={{ borderColor: "var(--color-warning, #b45309)", marginTop: "0.6rem" }}>
@@ -27,10 +41,36 @@ export function NachfuellDialog({ materialName, vorschlagMenge, onNachfuellen, o
<button className="btn btn-primary" onClick={() => onNachfuellen(menge)}> <button className="btn btn-primary" onClick={() => onNachfuellen(menge)}>
Ja, nachfüllen Ja, nachfüllen
</button> </button>
{istVerantwortlich && !mindermengeOffen && (
<button className="btn btn-secondary" onClick={() => setMindermengeOffen(true)}>
Mindermenge genehmigen
</button>
)}
<button className="btn btn-secondary" onClick={onSchliessen}> <button className="btn btn-secondary" onClick={onSchliessen}>
Nein Nein
</button> </button>
</div> </div>
{mindermengeOffen && (
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
<input
className="input"
style={{ flex: 1 }}
placeholder="Begründung (Pflicht)"
value={begruendung}
onChange={(e) => setBegruendung(e.target.value)}
/>
<button
className="btn btn-primary"
disabled={!begruendung.trim()}
onClick={() => onMindermengeGenehmigen(begruendung.trim())}
>
Genehmigen
</button>
<button className="btn btn-secondary" onClick={() => setMindermengeOffen(false)}>
Abbrechen
</button>
</div>
)}
</div> </div>
); );
} }
@@ -152,6 +152,17 @@ export function useKontrolle(objektId: string | undefined) {
setNachfuellDialog(null); 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() { function nachfuellDialogSchliessen() {
setNachfuellDialog(null); setNachfuellDialog(null);
} }
@@ -208,6 +219,7 @@ export function useKontrolle(objektId: string | undefined) {
abschliessen, abschliessen,
abbrechen, abbrechen,
jetztNachfuellen, jetztNachfuellen,
jetztMindermengeGenehmigen,
nachfuellDialogSchliessen, nachfuellDialogSchliessen,
}; };
} }