diff --git a/backend/alembic/versions/0010_objekt_kopplung.py b/backend/alembic/versions/0010_objekt_kopplung.py new file mode 100644 index 0000000..eaea590 --- /dev/null +++ b/backend/alembic/versions/0010_objekt_kopplung.py @@ -0,0 +1,36 @@ +"""objekt.gezogen_von_objekt_id -> fahrzeug_id (Umbenennung, gleiche Semantik) + +Revision ID: 0010_objekt_kopplung +Revises: 0009_gezogen_von +Create Date: 2026-09-05 + +Nutzer-Klarstellung zu Migration 0009: es geht nicht spezifisch um Anhänger- +Zugfahrzeug-Kopplung, sondern allgemein um die Zuordnung eines Objekts +(Rucksack, Gerät, Anhänger, ...) zu genau einem Fahrzeug (1:n, ein Objekt +gehört jederzeit höchstens einem Fahrzeug). Strukturell identisch mit 0009, +nur die Spalte/Constraint/Index sauberer benannt statt "gezogen_von". +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0010_objekt_kopplung" +down_revision: Union[str, None] = "0009_gezogen_von" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TABLE objekt RENAME COLUMN gezogen_von_objekt_id TO fahrzeug_id") + op.execute("ALTER INDEX idx_objekt_gezogen_von RENAME TO idx_objekt_fahrzeug") + op.execute( + "ALTER TABLE objekt RENAME CONSTRAINT objekt_kein_selbstbezug TO objekt_fahrzeug_kein_selbstbezug" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE objekt RENAME CONSTRAINT objekt_fahrzeug_kein_selbstbezug TO objekt_kein_selbstbezug" + ) + op.execute("ALTER INDEX idx_objekt_fahrzeug RENAME TO idx_objekt_gezogen_von") + op.execute("ALTER TABLE objekt RENAME COLUMN fahrzeug_id TO gezogen_von_objekt_id") diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index b2576bb..2398507 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -195,9 +195,9 @@ async def erstelle_objekt( vorlage = await db.get(Beladungsvorlage, payload.vorlage_id) if vorlage is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vorlage nicht gefunden") - if payload.gezogen_von_objekt_id is not None: - if await db.get(Objekt, payload.gezogen_von_objekt_id) is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zugfahrzeug nicht gefunden") + if payload.fahrzeug_id is not None: + if await db.get(Objekt, payload.fahrzeug_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fahrzeug nicht gefunden") hauptserver = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt)) zustaendiger_server_id = hauptserver.scalar_one() @@ -210,7 +210,7 @@ async def erstelle_objekt( standort_id=payload.standort_id, vorlage=vorlage, zustaendiger_server_id=zustaendiger_server_id, - gezogen_von_objekt_id=payload.gezogen_von_objekt_id, + fahrzeug_id=payload.fahrzeug_id, ) except IntegrityError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben") from exc @@ -220,24 +220,24 @@ async def erstelle_objekt( async def aendere_objekt( objekt_id: int, payload: ObjektUpdate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) ) -> Objekt: - """Fahrzeug/Anhänger-Kopplung nachträglich setzen/lösen (Nutzer-Vorgabe). - postgres-expert-Review-Fund: Zielobjekt-Existenz und direkter Zyklus (A↔B) - werden hier geprüft, mehrstufige Ketten sind fachlich nicht vorgesehen und - bewusst nicht geprüft (Aufwand/Nutzen).""" + """Fahrzeug-Zuordnung nachträglich setzen/lösen: jedes Objekt gehört zu + jedem Zeitpunkt höchstens einem Fahrzeug (1:n). Zielobjekt-Existenz und + direkter Zyklus (A↔B) werden geprüft, mehrstufige Ketten sind fachlich + nicht vorgesehen und bewusst nicht geprüft (Aufwand/Nutzen).""" objekt = await db.get(Objekt, objekt_id) if objekt is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") daten = payload.model_dump(exclude_unset=True) - ziel_id = daten.get("gezogen_von_objekt_id") + ziel_id = daten.get("fahrzeug_id") if ziel_id is not None: ziel = await db.get(Objekt, ziel_id) if ziel is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zugfahrzeug nicht gefunden") - if ziel.gezogen_von_objekt_id == objekt_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fahrzeug nicht gefunden") + if ziel.fahrzeug_id == objekt_id: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="Zyklus: Zielobjekt wird bereits von diesem Objekt gezogen", + detail="Zyklus: Zielobjekt ist bereits diesem Objekt zugeordnet", ) for feld, wert in daten.items(): diff --git a/backend/app/models/objekt.py b/backend/app/models/objekt.py index 2338329..93ce42f 100644 --- a/backend/app/models/objekt.py +++ b/backend/app/models/objekt.py @@ -33,9 +33,10 @@ class Objekt(Base): zustaendiger_server_id: Mapped[int] = mapped_column( ForeignKey("systemknoten.id"), nullable=False ) - # Fahrzeug/Anhänger-Kopplung (Nutzer-Vorgabe): optional, beide Fälle möglich - - # Anhänger als eigenständiges Objekt (NULL) oder fest einem Zugfahrzeug - # zugeordnet. Selbstreferenzierend, kein eigener Anhänger-Objekttyp nötig. - gezogen_von_objekt_id: Mapped[int | None] = mapped_column( + # Zuordnung zu einem Fahrzeug (Nutzer-Vorgabe): jedes Objekt (Rucksack, + # Gerät, Anhänger, ...) gehört zu jedem Zeitpunkt höchstens einem Fahrzeug + # (1:n), NULL = eigenständig/keinem Fahrzeug zugeordnet. Selbstreferenzierend, + # kein eigener Fahrzeug-Objekttyp nötig (Objekt bleibt generisch, Karte 11). + fahrzeug_id: Mapped[int | None] = mapped_column( ForeignKey("objekt.id", ondelete="SET NULL") ) diff --git a/backend/app/schemas/objekt.py b/backend/app/schemas/objekt.py index 843adf6..f22e53d 100644 --- a/backend/app/schemas/objekt.py +++ b/backend/app/schemas/objekt.py @@ -14,7 +14,7 @@ class ObjektCreate(BaseModel): objekttyp_id: int vorlage_id: int standort_id: int - gezogen_von_objekt_id: int | None = None + fahrzeug_id: int | None = None class ObjektRead(BaseModel): @@ -26,11 +26,11 @@ class ObjektRead(BaseModel): vorlage_id: int | None standort_id: int status: ObjektStatus - gezogen_von_objekt_id: int | None + fahrzeug_id: int | None class ObjektUpdate(BaseModel): - gezogen_von_objekt_id: int | None = None + fahrzeug_id: int | None = None class ObjektDuplizierenRequest(BaseModel): diff --git a/backend/app/services/objekte.py b/backend/app/services/objekte.py index cbadb1c..3f724b8 100644 --- a/backend/app/services/objekte.py +++ b/backend/app/services/objekte.py @@ -56,12 +56,11 @@ async def objekt_aus_vorlage( standort_id: int, vorlage: Beladungsvorlage, zustaendiger_server_id: int, - gezogen_von_objekt_id: int | None = None, + fahrzeug_id: int | None = None, ) -> Objekt: """Prompt 09 (Neuanlage, nicht Duplizieren): Objektpositionen werden aus den Vorlagenpositionen initialisiert, Ist-Menge startet bei 0 ("nicht kontrolliert"). - gezogen_von_objekt_id: optionale Fahrzeug/Anhänger-Kopplung (Nutzer-Vorgabe), - beide Fälle möglich - unabhängiges Objekt (NULL) oder fest zugeordnet.""" + fahrzeug_id: optionale Zuordnung zu einem Fahrzeug (1:n, Nutzer-Vorgabe).""" objekt = Objekt( code=code, name=name, @@ -69,7 +68,7 @@ async def objekt_aus_vorlage( vorlage_id=vorlage.id, standort_id=standort_id, zustaendiger_server_id=zustaendiger_server_id, - gezogen_von_objekt_id=gezogen_von_objekt_id, + fahrzeug_id=fahrzeug_id, ) db.add(objekt) await db.flush() diff --git a/backend/tests/test_objekte.py b/backend/tests/test_objekte.py index 37939c2..c1fb47f 100644 --- a/backend/tests/test_objekte.py +++ b/backend/tests/test_objekte.py @@ -300,23 +300,25 @@ async def test_objektposition_label_pdf_ohne_code_ist_404( assert mit_code.content.startswith(b"%PDF") + @pytest.mark.asyncio -async def test_anhaenger_kopplung_bei_anlage_und_nachtraeglich( +async def test_fahrzeug_zuordnung_bei_anlage_und_nachtraeglich( client, db_session, objekttyp_factory, standort_factory, admin_user ): - """Nutzer-Vorgabe: Fahrzeug/Anhänger beide als eigenständige Objekte möglich, - optional per gezogen_von_objekt_id gekoppelt.""" - objekttyp = await objekttyp_factory("Fahrzeug/Anhänger") - standort = await standort_factory("Wache-Kopplung") + """Nutzer-Vorgabe: jedes Objekt (Rucksack/Gerät/Anhänger) gehört zu jedem + Zeitpunkt höchstens einem Fahrzeug (1:n) - Fahrzeug selbst ist einfach ein + Objekt ohne eigene fahrzeug_id.""" + objekttyp = await objekttyp_factory("Fahrzeug/Zuordnung") + standort = await standort_factory("Wache-Zuordnung") vorlage = await erstelle_vorlage( - db_session, objekttyp_id=objekttyp.id, name="Kopplung Standard", positionen=[] + db_session, objekttyp_id=objekttyp.id, name="Zuordnung Standard", positionen=[] ) token = await login(client, "admin1") fahrzeug = await client.post( "/api/v1/objekte", json={ - "code": "FZ-K1", + "code": "ZO-FZ", "name": "Zugfahrzeug", "objekttyp_id": objekttyp.id, "vorlage_id": vorlage.id, @@ -325,29 +327,29 @@ async def test_anhaenger_kopplung_bei_anlage_und_nachtraeglich( headers=auth_header(token), ) fahrzeug_id = fahrzeug.json()["id"] - assert fahrzeug.json()["gezogen_von_objekt_id"] is None + assert fahrzeug.json()["fahrzeug_id"] is None - anhaenger = await client.post( + zugeordnet = await client.post( "/api/v1/objekte", json={ - "code": "AH-K1", - "name": "Anhänger", + "code": "ZO-RK", + "name": "Rucksack", "objekttyp_id": objekttyp.id, "vorlage_id": vorlage.id, "standort_id": standort.id, - "gezogen_von_objekt_id": fahrzeug_id, + "fahrzeug_id": fahrzeug_id, }, headers=auth_header(token), ) - assert anhaenger.status_code == 201 - assert anhaenger.json()["gezogen_von_objekt_id"] == fahrzeug_id + assert zugeordnet.status_code == 201 + assert zugeordnet.json()["fahrzeug_id"] == fahrzeug_id - # unabhängiger Anhänger, erst nachträglich gekoppelt + # unabhängiges Objekt, erst nachträglich zugeordnet unabhaengig = await client.post( "/api/v1/objekte", json={ - "code": "AH-K2", - "name": "Anhänger 2", + "code": "ZO-GR", + "name": "Gerät", "objekttyp_id": objekttyp.id, "vorlage_id": vorlage.id, "standort_id": standort.id, @@ -355,39 +357,35 @@ async def test_anhaenger_kopplung_bei_anlage_und_nachtraeglich( headers=auth_header(token), ) unabhaengig_id = unabhaengig.json()["id"] - assert unabhaengig.json()["gezogen_von_objekt_id"] is None + assert unabhaengig.json()["fahrzeug_id"] is None geaendert = await client.patch( - f"/api/v1/objekte/{unabhaengig_id}", - json={"gezogen_von_objekt_id": fahrzeug_id}, - headers=auth_header(token), + f"/api/v1/objekte/{unabhaengig_id}", json={"fahrzeug_id": fahrzeug_id}, headers=auth_header(token) ) assert geaendert.status_code == 200 - assert geaendert.json()["gezogen_von_objekt_id"] == fahrzeug_id + assert geaendert.json()["fahrzeug_id"] == fahrzeug_id geloest = await client.patch( - f"/api/v1/objekte/{unabhaengig_id}", - json={"gezogen_von_objekt_id": None}, - headers=auth_header(token), + f"/api/v1/objekte/{unabhaengig_id}", json={"fahrzeug_id": None}, headers=auth_header(token) ) - assert geloest.json()["gezogen_von_objekt_id"] is None + assert geloest.json()["fahrzeug_id"] is None @pytest.mark.asyncio -async def test_anhaenger_kopplung_lehnt_selbstbezug_und_zyklus_ab( +async def test_fahrzeug_zuordnung_lehnt_selbstbezug_und_zyklus_ab( client, db_session, objekttyp_factory, standort_factory, admin_user ): - objekttyp = await objekttyp_factory("Fahrzeug/Anhänger Zyklus") - standort = await standort_factory("Wache-Zyklus") + objekttyp = await objekttyp_factory("Fahrzeug/Zuordnung Zyklus") + standort = await standort_factory("Wache-Zyklus-Zuordnung") vorlage = await erstelle_vorlage( - db_session, objekttyp_id=objekttyp.id, name="Zyklus Standard", positionen=[] + db_session, objekttyp_id=objekttyp.id, name="Zyklus-Zuordnung Standard", positionen=[] ) token = await login(client, "admin1") a = await client.post( "/api/v1/objekte", json={ - "code": "ZY-A", + "code": "ZYO-A", "name": "Objekt A", "objekttyp_id": objekttyp.id, "vorlage_id": vorlage.id, @@ -397,35 +395,32 @@ async def test_anhaenger_kopplung_lehnt_selbstbezug_und_zyklus_ab( ) a_id = a.json()["id"] - # Selbstbezug: A zieht sich selbst selbstbezug = await client.patch( - f"/api/v1/objekte/{a_id}", json={"gezogen_von_objekt_id": a_id}, headers=auth_header(token) + f"/api/v1/objekte/{a_id}", json={"fahrzeug_id": a_id}, headers=auth_header(token) ) assert selbstbezug.status_code == 409 - # nicht existierendes Zielobjekt ungueltig = await client.patch( - f"/api/v1/objekte/{a_id}", json={"gezogen_von_objekt_id": 999999}, headers=auth_header(token) + f"/api/v1/objekte/{a_id}", json={"fahrzeug_id": 999999}, headers=auth_header(token) ) assert ungueltig.status_code == 404 b = await client.post( "/api/v1/objekte", json={ - "code": "ZY-B", + "code": "ZYO-B", "name": "Objekt B", "objekttyp_id": objekttyp.id, "vorlage_id": vorlage.id, "standort_id": standort.id, - "gezogen_von_objekt_id": a_id, + "fahrzeug_id": a_id, }, headers=auth_header(token), ) b_id = b.json()["id"] - assert b.json()["gezogen_von_objekt_id"] == a_id + assert b.json()["fahrzeug_id"] == a_id - # Zyklus: A soll jetzt von B gezogen werden (B wird bereits von A gezogen) zyklus = await client.patch( - f"/api/v1/objekte/{a_id}", json={"gezogen_von_objekt_id": b_id}, headers=auth_header(token) + f"/api/v1/objekte/{a_id}", json={"fahrzeug_id": b_id}, headers=auth_header(token) ) assert zyklus.status_code == 409 diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 7077a46..c114119 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -6,7 +6,7 @@ export interface Objekt { vorlage_id: number | null; standort_id: number; status: string; - gezogen_von_objekt_id: number | null; + fahrzeug_id: number | null; } export interface Objektposition { diff --git a/frontend/src/pages/admin/ObjektSection.tsx b/frontend/src/pages/admin/ObjektSection.tsx index 14ec3fe..9dbd183 100644 --- a/frontend/src/pages/admin/ObjektSection.tsx +++ b/frontend/src/pages/admin/ObjektSection.tsx @@ -33,6 +33,7 @@ export function ObjektSection({ const [name, setName] = useState(""); const [vorlageId, setVorlageId] = useState(""); const [standortId, setStandortId] = useState(""); + const [fahrzeugId, setFahrzeugId] = useState(""); const [wirdAngelegt, setWirdAngelegt] = useState(false); const [ausgeklapptId, setAusgeklapptId] = useState(null); const [duplizierId, setDuplizierId] = useState(null); @@ -40,11 +41,38 @@ export function ObjektSection({ const [neuerCode, setNeuerCode] = useState(""); const [neuerName, setNeuerName] = useState(""); const [neuerStandortId, setNeuerStandortId] = useState(""); - const [gezogenVonId, setGezogenVonId] = useState(""); - const [kopplungBearbeitenId, setKopplungBearbeitenId] = useState(null); - const [kopplungAuswahl, setKopplungAuswahl] = useState(""); + const [fahrzeugBearbeitenId, setFahrzeugBearbeitenId] = useState(null); + const [fahrzeugAuswahl, setFahrzeugAuswahl] = useState(""); const [suche, setSuche] = useState(""); + function objektName(id: number | null): string { + if (id === null) return ""; + const gefunden = objekte.find((x) => x.id === id); + return gefunden ? `${gefunden.name} (${gefunden.code})` : `#${id}`; + } + + function fahrzeugBearbeiten(o: Objekt) { + if (fahrzeugBearbeitenId === o.id) { + setFahrzeugBearbeitenId(null); + return; + } + setFahrzeugBearbeitenId(o.id); + setFahrzeugAuswahl(o.fahrzeug_id ?? ""); + } + + async function fahrzeugSpeichern(objektId: number) { + try { + await apiRequest(`/objekte/${objektId}`, { + method: "PATCH", + body: { fahrzeug_id: fahrzeugAuswahl === "" ? null : fahrzeugAuswahl }, + }); + setFahrzeugBearbeitenId(null); + await onGeaendert(); + } catch { + onFehler("Fahrzeug-Zuordnung konnte nicht gespeichert werden."); + } + } + async function codeVorschlagen() { if (!praefix) { onFehler("Präfix eingeben (z. B. RK, FZ)."); @@ -91,13 +119,13 @@ export function ObjektSection({ objekttyp_id: vorlage?.objekttyp_id, vorlage_id: vorlageId, standort_id: standortId, - gezogen_von_objekt_id: gezogenVonId || null, + fahrzeug_id: fahrzeugId === "" ? null : fahrzeugId, }, }); setPraefix(""); setCode(""); setName(""); - setGezogenVonId(""); + setFahrzeugId(""); await onGeaendert(); } catch { onFehler("Objekt konnte nicht angelegt werden (Code evtl. schon vergeben)."); @@ -106,30 +134,6 @@ export function ObjektSection({ } } - function objektName(id: number | null): string { - if (id === null) return ""; - const o = objekte.find((x) => x.id === id); - return o ? `${o.name} (${o.code})` : `Objekt ${id}`; - } - - function kopplungBearbeiten(o: Objekt) { - setKopplungBearbeitenId(kopplungBearbeitenId === o.id ? null : o.id); - setKopplungAuswahl(o.gezogen_von_objekt_id ?? ""); - } - - async function kopplungSpeichern(objektId: number) { - try { - await apiRequest(`/objekte/${objektId}`, { - method: "PATCH", - body: { gezogen_von_objekt_id: kopplungAuswahl || null }, - }); - setKopplungBearbeitenId(null); - await onGeaendert(); - } catch { - onFehler("Kopplung konnte nicht geändert werden."); - } - } - function duplizierenStarten(o: Objekt) { setDuplizierId(duplizierId === o.id ? null : o.id); setNeuerPraefix(""); @@ -222,16 +226,16 @@ export function ObjektSection({ setName(e.target.value)} />
- + @@ -260,8 +264,8 @@ export function ObjektSection({
{o.name} ({o.code}) - {o.gezogen_von_objekt_id !== null && ( - – gezogen von {objektName(o.gezogen_von_objekt_id)} + {o.fahrzeug_id !== null && ( + – Fahrzeug: {objektName(o.fahrzeug_id)} )}
@@ -272,8 +276,8 @@ export function ObjektSection({ {ausgeklapptId === o.id ? "Positionen ausblenden" : "Ablauf/Charge/SN pflegen"} {!nurPflegen && ( - )} {!nurPflegen && ( @@ -283,14 +287,14 @@ export function ObjektSection({ )}
- {kopplungBearbeitenId === o.id && ( + {fahrzeugBearbeitenId === o.id && (
-