diff --git a/arbeitskacheln/00_index.md b/arbeitskacheln/00_index.md index 21212fa..8e89ccf 100644 --- a/arbeitskacheln/00_index.md +++ b/arbeitskacheln/00_index.md @@ -193,7 +193,7 @@ vergeben statt geraten. | LOAD-004 | Loadout | Digitale Beladungskontrolle | P1 | M | MEDIUM | LOAD-003 | ✅ (`kontrollen.py`, `KontrollPage.tsx`) | | LOAD-005 | Loadout | Kontrollhistorie | P1 | S | LOW | LOAD-004, FOUND-006 | ✅ (`Kontrolle`-Modell append-only, Historie-Anbindung) | | LOAD-006 | Loadout | Readiness-Kopplung | P1 | S | MEDIUM | LOAD-003, READY-001 | ✅ (Fehlbestand fließt in `einsatzbereitschaft()`-Berechnung ein) | -| LOAD-007 | Loadout | Gemeinsame Sollmenge über mehrere Standorte (neu, Referenz: HiOrg-Server) | P3 | M | MEDIUM | LOAD-002, WH-004 | ⬜ geplant 2026-09-10 | +| LOAD-007 | Loadout | Gemeinsame Sollmenge über mehrere Standorte (neu, Referenz: HiOrg-Server) | P3 | M | MEDIUM | LOAD-002, WH-004 | ✅ erledigt 2026-09-10 | | INSP-001 | Inspections | Prüfarten-Stammdaten | P1 | S | LOW | ASSET-002 | ⬜ (kein eigenständiges Prüfart-Stammdatenmodell gefunden) | | INSP-002 | Inspections | Prüfintervalle | P1 | S | LOW | INSP-001 | 🔶 (`pruefintervall_monate` an Objektposition, aber nicht generisch je Prüfart) | | INSP-003 | Inspections | Prüfdurchführung/-ergebnis | P1 | M | MEDIUM | INSP-002 | 🔶 (Statuswechsel an Geräteinstanz inkl. `naechste_pruefung`, kein separates Prüfergebnis-Protokoll) | diff --git a/arbeitskacheln/08_loadout.md b/arbeitskacheln/08_loadout.md index 7d4c636..de67705 100644 --- a/arbeitskacheln/08_loadout.md +++ b/arbeitskacheln/08_loadout.md @@ -181,11 +181,28 @@ Details zu LOAD-001 … LOAD-006 (vollständig). Gruppen-Zuordnung funktionieren unverändert wie bisher (Rückwärts- kompatibilität, kein Zwang zur Migration bestehender Daten). - **Tests:** Gruppen-Summen-Test, Regressionstest für Positionen ohne Gruppe. -- **DoD:** offen — **P3, kleinste Priorität der fünf HiOrg-Nachträge**, da die - offene Fehlbestand-Zuordnungsfrage eine echte fachliche Vorentscheidung - braucht (nicht nur technische Umsetzung) und das bisher am weitesten - ausgereifte Epic im Backlog berührt — höheres Sorgfaltsbedürfnis vor einer - Umsetzung. +- **DoD:** ✅ erledigt (2026-09-10). DECISION (User): Fehlbestand auf + Gruppenebene statt Standort-Rätselraten — `Fehlbestand.sollmengen_gruppe_id` + (neu), `sollmenge`/`istmenge`/`fehlmenge` sind bei Gruppen-Positionen + Gruppen-Summenwerte, nicht die der einzelnen Position. `objekt_id`/ + `standort_id` bleiben trotzdem gesetzt (Anker = die Position, an der die + Kontrolle die Unterdeckung tatsächlich festgestellt hat, keine willkürliche + Zuordnung) — dadurch bleibt `nachfuellen()` unverändert funktionsfähig + (braucht einen konkreten Standort zum Ist-Menge-Setzen). Findet eine andere + Position derselben Gruppe später die Deckung, wird über + `sollmengen_gruppe_id` (nicht `objekt_id`) nach dem bestehenden offenen + Fehlbestand gesucht, damit er korrekt aufgelöst wird, auch wenn er an einer + anderen Position verankert war. `Kontrollposition.sollmenge_snapshot` bleibt + bewusst die EIGENE Sollmenge (üblicherweise 0 bei Gruppen-Mitgliedern) - nur + der Fehlbestand-Vergleich läuft gegen die Gruppensumme, dokumentierte + Abweichung zwischen Kontroll-UI und Fehlbestand-Logik. + Backend: `sollmengen_gruppe` (Tabelle, Migration `0051_sollmengen_gruppe.py`), + `Objektposition.sollmengen_gruppe_id`, CRUD `/sollmengen-gruppen`, + `app/services/kontrolle/erfassung.py` erweitert. Frontend: Admin-Tab + „Sollmengen-Gruppen" (Anlegen/Ändern), Dropdown in `ObjektPositionenPanel.tsx` + zur Zuordnung je Position. Tests: `test_load007_sollmengen_gruppe.py` + (Gruppen-Summe löst/verhindert Fehlbestand, Auflösung von anderer Position, + CRUD). - **Referenz (HiOrg-Server-Vergleich 2026-09-10):** HiOrg-Server führte 2026-07 eine "gemeinsame Sollmenge" für sein Materialsystem ein — bestätigt den Bedarf als real genug, um aufzunehmen, auch wenn MABEA aktuell nur eine diff --git a/backend/alembic/versions/0051_sollmengen_gruppe.py b/backend/alembic/versions/0051_sollmengen_gruppe.py new file mode 100644 index 0000000..890ab0a --- /dev/null +++ b/backend/alembic/versions/0051_sollmengen_gruppe.py @@ -0,0 +1,41 @@ +"""LOAD-007: gemeinsame Sollmenge über mehrere Standorte. + +Revision ID: 0051_sollmengen_gruppe +Revises: 0050_dienstleister +Create Date: 2026-09-10 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "0051_sollmengen_gruppe" +down_revision: Union[str, None] = "0050_dienstleister" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE sollmengen_gruppe ( + id SERIAL PRIMARY KEY, + material_id INTEGER NOT NULL REFERENCES material(id), + gemeinsame_sollmenge NUMERIC NOT NULL + ) + """ + ) + op.execute( + "ALTER TABLE objektposition ADD COLUMN sollmengen_gruppe_id INTEGER REFERENCES sollmengen_gruppe(id)" + ) + op.execute( + "ALTER TABLE fehlbestand ADD COLUMN sollmengen_gruppe_id INTEGER REFERENCES sollmengen_gruppe(id)" + ) + op.execute("CREATE INDEX ix_objektposition_sollmengen_gruppe ON objektposition (sollmengen_gruppe_id)") + + +def downgrade() -> None: + op.execute("DROP INDEX ix_objektposition_sollmengen_gruppe") + op.execute("ALTER TABLE fehlbestand DROP COLUMN sollmengen_gruppe_id") + op.execute("ALTER TABLE objektposition DROP COLUMN sollmengen_gruppe_id") + op.execute("DROP TABLE sollmengen_gruppe") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 5dcf6f7..be664cd 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -28,6 +28,7 @@ from app.api.v1.endpoints import ( readiness_regel, readiness_snapshot, ressourcenbuchung, + sollmengen_gruppe, stammdaten, suche, tankbuch, @@ -78,3 +79,4 @@ api_router.include_router(ressourcenbuchung.router, tags=["ressourcenbuchung"]) api_router.include_router(readiness_snapshot.router, tags=["readiness_snapshot"]) api_router.include_router(readiness_regel.router, tags=["readiness_regel"]) api_router.include_router(einsatz.router, tags=["einsatz"]) +api_router.include_router(sollmengen_gruppe.router, tags=["sollmengen_gruppe"]) diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index fe77f6b..3bf868f 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -168,6 +168,7 @@ async def hole_objektposition_per_code( chargennummer=position.chargennummer, code=position.code, pruefintervall_monate=position.pruefintervall_monate, + sollmengen_gruppe_id=position.sollmengen_gruppe_id, ) @@ -318,6 +319,7 @@ async def objektposition_code_neu_vergeben( chargennummer=position.chargennummer, code=position.code, pruefintervall_monate=position.pruefintervall_monate, + sollmengen_gruppe_id=position.sollmengen_gruppe_id, ) @@ -345,6 +347,7 @@ async def liste_objektpositionen( chargennummer=p.chargennummer, code=p.code, pruefintervall_monate=p.pruefintervall_monate, + sollmengen_gruppe_id=p.sollmengen_gruppe_id, ) for p in positionen ] @@ -503,6 +506,7 @@ async def aendere_objektposition( chargennummer=position.chargennummer, code=position.code, pruefintervall_monate=position.pruefintervall_monate, + sollmengen_gruppe_id=position.sollmengen_gruppe_id, ) @@ -548,6 +552,7 @@ async def fuege_zusatzposition_hinzu( chargennummer=position.chargennummer, code=position.code, pruefintervall_monate=position.pruefintervall_monate, + sollmengen_gruppe_id=position.sollmengen_gruppe_id, ) diff --git a/backend/app/api/v1/endpoints/sollmengen_gruppe.py b/backend/app/api/v1/endpoints/sollmengen_gruppe.py new file mode 100644 index 0000000..be2d84a --- /dev/null +++ b/backend/app/api/v1/endpoints/sollmengen_gruppe.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import require_roles +from app.db.session import get_db +from app.models.auth import RolleTyp +from app.models.sollmengen_gruppe import SollmengenGruppe +from app.schemas.sollmengen_gruppe import SollmengenGruppeCreate, SollmengenGruppeRead, SollmengenGruppeUpdate +from app.services.sollmengen_gruppe import aendere_gruppe, erstelle_gruppe, liste_gruppen + +router = APIRouter() + +_verantwortliche = require_roles( + RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher +) + + +@router.get("/sollmengen-gruppen", response_model=list[SollmengenGruppeRead]) +async def route_liste_gruppen( + db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche) +) -> list[SollmengenGruppe]: + return await liste_gruppen(db) + + +@router.post("/sollmengen-gruppen", response_model=SollmengenGruppeRead, status_code=status.HTTP_201_CREATED) +async def route_erstelle_gruppe( + payload: SollmengenGruppeCreate, db: AsyncSession = Depends(get_db), _=Depends(_verantwortliche) +) -> SollmengenGruppe: + return await erstelle_gruppe(db, material_id=payload.material_id, gemeinsame_sollmenge=payload.gemeinsame_sollmenge) + + +@router.patch("/sollmengen-gruppen/{gruppe_id}", response_model=SollmengenGruppeRead) +async def route_aendere_gruppe( + gruppe_id: int, + payload: SollmengenGruppeUpdate, + db: AsyncSession = Depends(get_db), + _=Depends(_verantwortliche), +) -> SollmengenGruppe: + gruppe = await db.get(SollmengenGruppe, gruppe_id) + if gruppe is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sollmengen-Gruppe nicht gefunden") + return await aendere_gruppe(db, gruppe=gruppe, gemeinsame_sollmenge=payload.gemeinsame_sollmenge) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 74f6e24..b2de5dc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -30,6 +30,7 @@ from app.models.personal import ( Qualifikationskategorie, Qualifikationstyp, ) +from app.models.sollmengen_gruppe import SollmengenGruppe from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort from app.models.tankbuch import TankbuchEintrag from app.models.verleih import ExterneStelle, ExterneStelleKategorie, Verleih @@ -111,6 +112,7 @@ __all__ = [ "Material", "MaterialTyp", "Objekttyp", + "SollmengenGruppe", "Standort", "TankbuchEintrag", "ExterneStelle", diff --git a/backend/app/models/fehlbestand.py b/backend/app/models/fehlbestand.py index 05fbf25..d71c30b 100644 --- a/backend/app/models/fehlbestand.py +++ b/backend/app/models/fehlbestand.py @@ -49,3 +49,9 @@ class Fehlbestand(Base): # Prüflauf erneut verschickt wird (NULL = Stufe noch nicht ausgelöst). erinnerung_gesendet_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) eskalation_gesendet_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) + # LOAD-007: gesetzt, wenn dieser Fehlbestand aus einer Gruppen-Sollmenge + # entstanden ist (sollmenge/fehlmenge sind dann Gruppen-Summenwerte, nicht + # die der einzelnen Objektposition) - objekt_id/standort_id bleiben der + # Anker der Position, an der die Kontrolle die Unterdeckung festgestellt + # hat (keine beliebige Standort-Zuordnung, sondern die real geprüfte). + sollmengen_gruppe_id: Mapped[int | None] = mapped_column(ForeignKey("sollmengen_gruppe.id")) diff --git a/backend/app/models/objektposition.py b/backend/app/models/objektposition.py index 326dedf..51a2fbc 100644 --- a/backend/app/models/objektposition.py +++ b/backend/app/models/objektposition.py @@ -53,3 +53,7 @@ class Objektposition(Base): # (wie sollmenge_override) - NULL = kein Prüfintervall (nicht prüfpflichtig, # oder Standard aus dem Material/der Vorlage folgt später bei Bedarf). pruefintervall_monate: Mapped[int | None] = mapped_column(Integer) + # LOAD-007: NULL = eigene sollmenge_override/-vorlage gilt wie bisher; gesetzt + # = Ist/Soll-Bewertung erfolgt gegen die GRUPPEN-Summe, sollmenge_override + # bleibt dabei unbeachtet (kein Zwang, ihn zurückzusetzen). + sollmengen_gruppe_id: Mapped[int | None] = mapped_column(ForeignKey("sollmengen_gruppe.id")) diff --git a/backend/app/models/sollmengen_gruppe.py b/backend/app/models/sollmengen_gruppe.py new file mode 100644 index 0000000..3edac7d --- /dev/null +++ b/backend/app/models/sollmengen_gruppe.py @@ -0,0 +1,18 @@ +from decimal import Decimal + +from sqlalchemy import ForeignKey, Numeric +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class SollmengenGruppe(Base): + """LOAD-007: mehrere Objektpositionen (dasselbe Material an verschiedenen + Standorten/Objekten) teilen sich EINE gemeinsame Sollmenge statt je + Standort einzeln gepflegt zu werden.""" + + __tablename__ = "sollmengen_gruppe" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False) + gemeinsame_sollmenge: Mapped[Decimal] = mapped_column(Numeric, nullable=False) diff --git a/backend/app/schemas/fehlbestand.py b/backend/app/schemas/fehlbestand.py index c4f5843..7d9ecb4 100644 --- a/backend/app/schemas/fehlbestand.py +++ b/backend/app/schemas/fehlbestand.py @@ -23,6 +23,7 @@ class FehlbestandRead(BaseModel): verantwortlicher_id: int | None status: FehlbestandStatus erledigt_am: datetime | None + sollmengen_gruppe_id: int | None class NachfuellungCreate(BaseModel): diff --git a/backend/app/schemas/objekt.py b/backend/app/schemas/objekt.py index f3ccb70..34eea57 100644 --- a/backend/app/schemas/objekt.py +++ b/backend/app/schemas/objekt.py @@ -84,6 +84,7 @@ class ObjektpositionRead(BaseModel): chargennummer: str | None code: str | None pruefintervall_monate: int | None + sollmengen_gruppe_id: int | None = None class ObjektpositionUpdate(BaseModel): @@ -95,6 +96,7 @@ class ObjektpositionUpdate(BaseModel): chargennummer: str | None = None code: str | None = None pruefintervall_monate: int | None = None + sollmengen_gruppe_id: int | None = None class ObjektpositionCreate(BaseModel): diff --git a/backend/app/schemas/sollmengen_gruppe.py b/backend/app/schemas/sollmengen_gruppe.py new file mode 100644 index 0000000..1e119eb --- /dev/null +++ b/backend/app/schemas/sollmengen_gruppe.py @@ -0,0 +1,19 @@ +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + + +class SollmengenGruppeCreate(BaseModel): + material_id: int + gemeinsame_sollmenge: Decimal + + +class SollmengenGruppeUpdate(BaseModel): + gemeinsame_sollmenge: Decimal + + +class SollmengenGruppeRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + material_id: int + gemeinsame_sollmenge: Decimal diff --git a/backend/app/services/kontrolle/erfassung.py b/backend/app/services/kontrolle/erfassung.py index 9b542ca..6eefb25 100644 --- a/backend/app/services/kontrolle/erfassung.py +++ b/backend/app/services/kontrolle/erfassung.py @@ -10,9 +10,11 @@ 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.models.sollmengen_gruppe import SollmengenGruppe from app.services import benachrichtigung as benachrichtigung_service from app.services import historie as historie_service from app.services.objekte import sollmenge_effektiv +from app.services.sollmengen_gruppe import gruppen_istsumme async def erfasse_position( @@ -90,14 +92,28 @@ async def erfasse_position( neuer_wert={"material_id": material_id, "sollmenge": str(sollmenge), "istmenge": str(istmenge)}, ) + fehlbestand_sollmenge = sollmenge + fehlbestand_istmenge = istmenge + sollmengen_gruppe_id: int | None = None + if objektposition is not None and objektposition.sollmengen_gruppe_id is not None: + # LOAD-007: Ist/Soll-Bewertung erfolgt gegen die Gruppen-Summe, nicht die + # eigene Position - istmenge dieser Position ist oben bereits gesetzt, + # geht also in die Summe ein. Fehlbestand bleibt trotzdem an DIESER + # Position verankert (real geprüfter Standort, keine willkürliche Wahl). + sollmengen_gruppe_id = objektposition.sollmengen_gruppe_id + gruppe = await db.get(SollmengenGruppe, sollmengen_gruppe_id) + fehlbestand_sollmenge = gruppe.gemeinsame_sollmenge + fehlbestand_istmenge = await gruppen_istsumme(db, gruppe_id=sollmengen_gruppe_id) + fehlbestand_id = await _synchronisiere_fehlbestand( db, kontrolle=kontrolle, objekt=objekt, material_id=material_id, - sollmenge=sollmenge, - istmenge=istmenge, + sollmenge=fehlbestand_sollmenge, + istmenge=fehlbestand_istmenge, zustaendiger_server_id=zustaendiger_server_id, + sollmengen_gruppe_id=sollmengen_gruppe_id, ) return kontrollposition, fehlbestand_id @@ -111,14 +127,28 @@ async def _synchronisiere_fehlbestand( sollmenge: Decimal, istmenge: Decimal, zustaendiger_server_id: int, + sollmengen_gruppe_id: int | None = None, ) -> uuid.UUID | None: - result = await db.execute( - select(Fehlbestand).where( - Fehlbestand.objekt_id == objekt.id, - Fehlbestand.material_id == material_id, - Fehlbestand.status == FehlbestandStatus.offen, + if sollmengen_gruppe_id is not None: + # LOAD-007: der bestehende Fehlbestand kann an einer ANDEREN Position + # derselben Gruppe verankert sein (dort zuerst festgestellt) - Suche + # daher über die Gruppe, nicht über objekt_id, sonst würde die + # Erfassung an einer anderen Gruppen-Position den alten Fehlbestand + # nie finden/auflösen können. + result = await db.execute( + select(Fehlbestand).where( + Fehlbestand.sollmengen_gruppe_id == sollmengen_gruppe_id, + Fehlbestand.status == FehlbestandStatus.offen, + ) + ) + else: + result = await db.execute( + select(Fehlbestand).where( + 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 @@ -139,6 +169,7 @@ async def _synchronisiere_fehlbestand( festgestellt_von=kontrolle.benutzer_id, kontrolle_id=kontrolle.id, status=FehlbestandStatus.offen, + sollmengen_gruppe_id=sollmengen_gruppe_id, ) db.add(bestehender) await db.flush() diff --git a/backend/app/services/sollmengen_gruppe.py b/backend/app/services/sollmengen_gruppe.py new file mode 100644 index 0000000..9bd5137 --- /dev/null +++ b/backend/app/services/sollmengen_gruppe.py @@ -0,0 +1,35 @@ +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.objektposition import Objektposition +from app.models.sollmengen_gruppe import SollmengenGruppe + + +async def erstelle_gruppe(db: AsyncSession, *, material_id: int, gemeinsame_sollmenge: Decimal) -> SollmengenGruppe: + gruppe = SollmengenGruppe(material_id=material_id, gemeinsame_sollmenge=gemeinsame_sollmenge) + db.add(gruppe) + await db.flush() + return gruppe + + +async def liste_gruppen(db: AsyncSession) -> list[SollmengenGruppe]: + result = await db.execute(select(SollmengenGruppe)) + return list(result.scalars().all()) + + +async def aendere_gruppe(db: AsyncSession, *, gruppe: SollmengenGruppe, gemeinsame_sollmenge: Decimal) -> SollmengenGruppe: + gruppe.gemeinsame_sollmenge = gemeinsame_sollmenge + await db.flush() + return gruppe + + +async def gruppen_istsumme(db: AsyncSession, *, gruppe_id: int) -> Decimal: + """Summe der Ist-Mengen aller Objektpositionen dieser Gruppe (über alle + Standorte/Objekte hinweg), Grundlage für den Gruppen-Soll/Ist-Vergleich.""" + result = await db.execute( + select(Objektposition).where(Objektposition.sollmengen_gruppe_id == gruppe_id) + ) + positionen = result.scalars().all() + return sum((p.istmenge for p in positionen), Decimal(0)) diff --git a/backend/tests/test_load007_sollmengen_gruppe.py b/backend/tests/test_load007_sollmengen_gruppe.py new file mode 100644 index 0000000..01d8f1a --- /dev/null +++ b/backend/tests/test_load007_sollmengen_gruppe.py @@ -0,0 +1,94 @@ +import pytest +from sqlalchemy import select + +from app.models.fehlbestand import Fehlbestand +from app.models.objektposition import Objektposition +from app.models.sollmengen_gruppe import SollmengenGruppe +from tests.conftest import auth_header, login + + +async def _starte(client, token, objekt_id): + return await client.post(f"/api/v1/objekte/{objekt_id}/kontrollen", json={}, headers=auth_header(token)) + + +@pytest.mark.asyncio +async def test_gruppen_fehlbestand_entsteht_erst_bei_unterschreitung_der_summe( + client, db_session, mitarbeiter_user, standort_factory, objekt_factory, material_factory +): + material = await material_factory("Verbandmittel-LOAD007") + standort_a = await standort_factory("Standort-A-LOAD007") + standort_b = await standort_factory("Standort-B-LOAD007") + objekt_a = await objekt_factory(name="Obj-A", code="LOAD007-A", standort=standort_a) + objekt_b = await objekt_factory(name="Obj-B", code="LOAD007-B", standort=standort_b) + + gruppe = SollmengenGruppe(material_id=material.id, gemeinsame_sollmenge=15) + db_session.add(gruppe) + await db_session.flush() + + pos_a = Objektposition(objekt_id=objekt_a.id, material_id=material.id, sollmengen_gruppe_id=gruppe.id) + pos_b = Objektposition(objekt_id=objekt_b.id, material_id=material.id, sollmengen_gruppe_id=gruppe.id) + db_session.add_all([pos_a, pos_b]) + await db_session.commit() + + token = await login(client, "mitarbeiter1") + + start_a = await _starte(client, token, objekt_a.id) + assert start_a.status_code == 201 + kontrolle_a_id = start_a.json()["id"] + r = await client.put( + f"/api/v1/kontrollen/{kontrolle_a_id}/positionen/{material.id}", + json={"istmenge": "10"}, + headers=auth_header(token), + ) + assert r.status_code == 200 + + result = await db_session.execute(select(Fehlbestand).where(Fehlbestand.objekt_id == objekt_a.id)) + fehlbestand = result.scalar_one() + assert fehlbestand.sollmengen_gruppe_id == gruppe.id + assert float(fehlbestand.sollmenge) == 15 + assert float(fehlbestand.istmenge) == 10 + assert float(fehlbestand.fehlmenge) == 5 + + start_b = await _starte(client, token, objekt_b.id) + assert start_b.status_code == 201 + kontrolle_b_id = start_b.json()["id"] + r = await client.put( + f"/api/v1/kontrollen/{kontrolle_b_id}/positionen/{material.id}", + json={"istmenge": "5"}, + headers=auth_header(token), + ) + assert r.status_code == 200 + + result = await db_session.execute(select(Fehlbestand).where(Fehlbestand.objekt_id == objekt_b.id)) + assert result.scalar_one_or_none() is None + + result = await db_session.execute(select(Fehlbestand).where(Fehlbestand.objekt_id == objekt_a.id)) + fehlbestand = result.scalar_one() + assert fehlbestand.status.value == "erledigt" + assert float(fehlbestand.istmenge) == 15 + + +@pytest.mark.asyncio +async def test_sollmengen_gruppe_crud(client, db_session, admin_user, material_factory): + material = await material_factory("Material-LOAD007-CRUD") + token = await login(client, "admin1") + + erstellt = await client.post( + "/api/v1/sollmengen-gruppen", + json={"material_id": material.id, "gemeinsame_sollmenge": "20"}, + headers=auth_header(token), + ) + assert erstellt.status_code == 201 + gruppe_id = erstellt.json()["id"] + + geaendert = await client.patch( + f"/api/v1/sollmengen-gruppen/{gruppe_id}", + json={"gemeinsame_sollmenge": "25"}, + headers=auth_header(token), + ) + assert geaendert.status_code == 200 + assert geaendert.json()["gemeinsame_sollmenge"] == "25" + + liste = await client.get("/api/v1/sollmengen-gruppen", headers=auth_header(token)) + assert liste.status_code == 200 + assert any(g["id"] == gruppe_id for g in liste.json()) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index fc22cb0..ec04f60 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -35,6 +35,13 @@ export interface Objektposition { chargennummer: string | null; code: string | null; pruefintervall_monate: number | null; + sollmengen_gruppe_id: number | null; +} + +export interface SollmengenGruppe { + id: number; + material_id: number; + gemeinsame_sollmenge: string; } export interface GeraetInstanz { @@ -272,6 +279,7 @@ export interface Fehlbestand { verantwortlicher_id: number | null; status: "offen" | "in_bearbeitung" | "nachgefuellt_teilweise" | "erledigt"; erledigt_am: string | null; + sollmengen_gruppe_id: number | null; } export interface Kontrollposition { diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index a073822..3d90cf6 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -30,6 +30,7 @@ import { ZustaendigkeitSection } from "./admin/ZustaendigkeitSection"; import { ReadinessRegelSection } from "./admin/ReadinessRegelSection"; import { EinsatzSection } from "./admin/EinsatzSection"; import { MaterialSetSection } from "./admin/MaterialSetSection"; +import { SollmengenGruppeSection } from "./admin/SollmengenGruppeSection"; import { VerleihSection } from "./admin/VerleihSection"; type Tab = @@ -52,7 +53,8 @@ type Tab = | "readinessRegeln" | "einsaetze" | "materialSets" - | "verleih"; + | "verleih" + | "sollmengenGruppen"; // Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte // (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt @@ -99,6 +101,11 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera { key: "einsaetze", label: "Einsätze", rollen: ["administration", "leitungsverantwortlicher"] }, { key: "materialSets", label: "Material-Sets", rollen: ["administration", "materialverantwortlicher"] }, { key: "verleih", label: "Verleih", rollen: ["administration", "materialverantwortlicher"] }, + { + key: "sollmengenGruppen", + label: "Sollmengen-Gruppen", + rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"], + }, ]; /** @@ -319,6 +326,9 @@ export function AdminPage() { {aktiverTab === "readinessRegeln" && } {aktiverTab === "einsaetze" && } {aktiverTab === "materialSets" && } + {aktiverTab === "sollmengenGruppen" && ( + + )} {aktiverTab === "verleih" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( diff --git a/frontend/src/pages/admin/ObjektPositionenPanel.tsx b/frontend/src/pages/admin/ObjektPositionenPanel.tsx index 41f5264..56d6ee2 100644 --- a/frontend/src/pages/admin/ObjektPositionenPanel.tsx +++ b/frontend/src/pages/admin/ObjektPositionenPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { apiRequest } from "../../api/client"; -import type { Beladungsvorlage, Material, Objektposition } from "../../api/types"; +import type { Beladungsvorlage, Material, Objektposition, SollmengenGruppe } from "../../api/types"; import { gruppiereNachFach, OHNE_FACH } from "../../utils/fachGruppierung"; import { GeraeteInstanzenListe } from "./GeraeteInstanzenListe"; @@ -17,6 +17,7 @@ interface ZeilenFormular { ablaufdatum: string; chargennummer: string; pruefintervallMonate: string; + sollmengenGruppeId: number | ""; } function leeresFormular(p: Objektposition): ZeilenFormular { @@ -25,6 +26,7 @@ function leeresFormular(p: Objektposition): ZeilenFormular { ablaufdatum: p.ablaufdatum ?? "", chargennummer: p.chargennummer ?? "", pruefintervallMonate: p.pruefintervall_monate?.toString() ?? "", + sollmengenGruppeId: p.sollmengen_gruppe_id ?? "", }; } @@ -51,15 +53,18 @@ export function ObjektPositionenPanel({ objektId, vorlageId, materialien, onFehl const [neueSollmenge, setNeueSollmenge] = useState(""); const [wirdHinzugefuegt, setWirdHinzugefuegt] = useState(false); const [aktivesFach, setAktivesFach] = useState(null); + const [sollmengenGruppen, setSollmengenGruppen] = useState([]); async function laden() { try { - const [liste, vorlageDaten] = await Promise.all([ + const [liste, vorlageDaten, gruppen] = await Promise.all([ apiRequest(`/objekte/${objektId}/positionen`), vorlageId ? apiRequest(`/vorlagen/${vorlageId}`) : Promise.resolve(null), + apiRequest("/sollmengen-gruppen"), ]); setPositionen(liste); setVorlage(vorlageDaten); + setSollmengenGruppen(gruppen); setFormulare(Object.fromEntries(liste.map((p) => [p.id, leeresFormular(p)]))); } catch { onFehler("Positionen konnten nicht geladen werden."); @@ -107,6 +112,7 @@ export function ObjektPositionenPanel({ objektId, vorlageId, materialien, onFehl ablaufdatum: formular.ablaufdatum || null, chargennummer: formular.chargennummer || null, pruefintervall_monate: formular.pruefintervallMonate ? Number(formular.pruefintervallMonate) : null, + sollmengen_gruppe_id: formular.sollmengenGruppeId === "" ? null : formular.sollmengenGruppeId, }, }); setPositionen((bisherige) => bisherige.map((p) => (p.id === position.id ? aktualisiert : p))); @@ -239,6 +245,30 @@ export function ObjektPositionenPanel({ objektId, vorlageId, materialien, onFehl } /> + {sollmengenGruppen.some((g) => g.material_id === p.material_id) && ( + + Sollmengen-Gruppe (LOAD-007) + + setFormulare((f) => ({ + ...f, + [p.id]: { ...formular, sollmengenGruppeId: e.target.value ? Number(e.target.value) : "" }, + })) + } + > + keine (eigene Sollmenge) + {sollmengenGruppen + .filter((g) => g.material_id === p.material_id) + .map((g) => ( + + Gruppe #{g.id} (gemeinsam: {g.gemeinsame_sollmenge}) + + ))} + + + )} {typ === "ablauf_charge" && ( <> void; +} + +// LOAD-007: mehrere Objektpositionen (dasselbe Material an verschiedenen +// Standorten/Objekten) teilen sich eine gemeinsame Sollmenge - Zuordnung der +// einzelnen Position erfolgt in der Objekt-Bestückung (ObjektPositionenPanel). +export function SollmengenGruppeSection({ materialien, onFehler }: Props) { + const [gruppen, setGruppen] = useState([]); + const [laedt, setLaedt] = useState(true); + const [neuesMaterialId, setNeuesMaterialId] = useState(""); + const [neueSollmenge, setNeueSollmenge] = useState(""); + const [bearbeitungWerte, setBearbeitungWerte] = useState>({}); + + async function laden() { + setLaedt(true); + try { + const liste = await apiRequest("/sollmengen-gruppen"); + setGruppen(liste); + setBearbeitungWerte(Object.fromEntries(liste.map((g) => [g.id, g.gemeinsame_sollmenge]))); + } catch { + onFehler("Sollmengen-Gruppen konnten nicht geladen werden."); + } finally { + setLaedt(false); + } + } + + useEffect(() => { + laden(); + }, []); + + async function anlegen() { + if (neuesMaterialId === "" || !neueSollmenge) return; + try { + await apiRequest("/sollmengen-gruppen", { + method: "POST", + body: { material_id: neuesMaterialId, gemeinsame_sollmenge: neueSollmenge }, + }); + setNeuesMaterialId(""); + setNeueSollmenge(""); + await laden(); + } catch { + onFehler("Gruppe konnte nicht angelegt werden."); + } + } + + async function speichern(gruppe: SollmengenGruppe) { + const wert = bearbeitungWerte[gruppe.id]; + if (!wert) return; + try { + await apiRequest(`/sollmengen-gruppen/${gruppe.id}`, { + method: "PATCH", + body: { gemeinsame_sollmenge: wert }, + }); + await laden(); + } catch { + onFehler("Gruppe konnte nicht geändert werden."); + } + } + + function materialName(materialId: number) { + return materialien.find((m) => m.id === materialId)?.name ?? `Material ${materialId}`; + } + + return ( + + Sollmengen-Gruppen (standortübergreifend) + + + setNeuesMaterialId(Number(e.target.value) || "")} + > + Material wählen… + {materialien.map((m) => ( + + {m.name} + + ))} + + setNeueSollmenge(e.target.value)} + /> + + Anlegen + + + + {laedt && Lade…} + {!laedt && gruppen.length === 0 && Noch keine Gruppen angelegt.} + {!laedt && gruppen.length > 0 && ( + + {gruppen.map((g) => ( + + + {materialName(g.material_id)}{" "} + (Gruppe #{g.id}) + + + setBearbeitungWerte((v) => ({ ...v, [g.id]: e.target.value }))} + /> + speichern(g)}> + Speichern + + + + ))} + + )} + + + ); +}
Lade…
Noch keine Gruppen angelegt.