feat(loadout): LOAD-007 Gemeinsame Sollmenge über mehrere Standorte
CI / backend-tests (push) Successful in 3m3s
CI / frontend-build (push) Successful in 18s

Objektposition kann optional einer sollmengen_gruppe zugeordnet werden;
Fehlbestand-Prüfung erfolgt dann gegen die Gruppen-Summe statt die
eigene Sollmenge. Fehlbestand bleibt an der Position verankert, die
die Unterdeckung real festgestellt hat (kein Standort-Raten), wird
aber gruppenweit gesucht/aufgelöst, damit Deckung durch eine andere
Position im Verbund den Fehlbestand korrekt schließt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 18:15:22 +02:00
1 parent c18e4481b6
commit bc1542a5e9
20 files changed
+516 -17

No files matched your search

+1 -1
View File
@@ -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) |
+22 -5
View File
@@ -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
@@ -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")
+2
View File
@@ -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"])
+5
View File
@@ -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,
)
@@ -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)
+2
View File
@@ -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",
+6
View File
@@ -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"))
+4
View File
@@ -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"))
+18
View File
@@ -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)
+1
View File
@@ -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):
+2
View File
@@ -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):
+19
View File
@@ -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
+39 -8
View File
@@ -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()
+35
View File
@@ -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))
@@ -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())
+8
View File
@@ -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 {
+11 -1
View File
@@ -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" && <ReadinessRegelSection onFehler={setFehler} />}
{aktiverTab === "einsaetze" && <EinsatzSection objekte={objekte} onFehler={setFehler} />}
{aktiverTab === "materialSets" && <MaterialSetSection materialien={materialien} onFehler={setFehler} />}
{aktiverTab === "sollmengenGruppen" && (
<SollmengenGruppeSection materialien={materialien} onFehler={setFehler} />
)}
{aktiverTab === "verleih" && <VerleihSection objekte={objekte} onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
@@ -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<string | null>(null);
const [sollmengenGruppen, setSollmengenGruppen] = useState<SollmengenGruppe[]>([]);
async function laden() {
try {
const [liste, vorlageDaten] = await Promise.all([
const [liste, vorlageDaten, gruppen] = await Promise.all([
apiRequest<Objektposition[]>(`/objekte/${objektId}/positionen`),
vorlageId ? apiRequest<Beladungsvorlage>(`/vorlagen/${vorlageId}`) : Promise.resolve(null),
apiRequest<SollmengenGruppe[]>("/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
}
/>
</div>
{sollmengenGruppen.some((g) => g.material_id === p.material_id) && (
<div className="field" style={{ maxWidth: "14rem" }}>
<label>Sollmengen-Gruppe (LOAD-007)</label>
<select
className="input"
value={formular.sollmengenGruppeId}
onChange={(e) =>
setFormulare((f) => ({
...f,
[p.id]: { ...formular, sollmengenGruppeId: e.target.value ? Number(e.target.value) : "" },
}))
}
>
<option value="">keine (eigene Sollmenge)</option>
{sollmengenGruppen
.filter((g) => g.material_id === p.material_id)
.map((g) => (
<option key={g.id} value={g.id}>
Gruppe #{g.id} (gemeinsam: {g.gemeinsame_sollmenge})
</option>
))}
</select>
</div>
)}
{typ === "ablauf_charge" && (
<>
<input
@@ -0,0 +1,132 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { Material, SollmengenGruppe } from "../../api/types";
interface Props {
materialien: Material[];
onFehler: (text: string) => 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<SollmengenGruppe[]>([]);
const [laedt, setLaedt] = useState(true);
const [neuesMaterialId, setNeuesMaterialId] = useState<number | "">("");
const [neueSollmenge, setNeueSollmenge] = useState("");
const [bearbeitungWerte, setBearbeitungWerte] = useState<Record<number, string>>({});
async function laden() {
setLaedt(true);
try {
const liste = await apiRequest<SollmengenGruppe[]>("/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 (
<section className="section">
<h2>Sollmengen-Gruppen (standortübergreifend)</h2>
<div className="card">
<div className="row" style={{ flexWrap: "wrap", marginBottom: "1rem" }}>
<select
className="input"
value={neuesMaterialId}
onChange={(e) => setNeuesMaterialId(Number(e.target.value) || "")}
>
<option value="">Material wählen…</option>
{materialien.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
<input
type="number"
min="0"
className="input"
style={{ maxWidth: "8rem" }}
placeholder="Gemeinsame Sollmenge"
value={neueSollmenge}
onChange={(e) => setNeueSollmenge(e.target.value)}
/>
<button className="btn btn-primary" onClick={anlegen} disabled={neuesMaterialId === "" || !neueSollmenge}>
Anlegen
</button>
</div>
{laedt && <p className="text-muted">Lade…</p>}
{!laedt && gruppen.length === 0 && <p className="text-muted">Noch keine Gruppen angelegt.</p>}
{!laedt && gruppen.length > 0 && (
<ul className="card-list">
{gruppen.map((g) => (
<li key={g.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>
<strong>{materialName(g.material_id)}</strong>{" "}
<span className="text-muted">(Gruppe #{g.id})</span>
</span>
<div className="row" style={{ gap: "0.3rem" }}>
<input
type="number"
min="0"
className="input"
style={{ maxWidth: "6rem" }}
value={bearbeitungWerte[g.id] ?? ""}
onChange={(e) => setBearbeitungWerte((v) => ({ ...v, [g.id]: e.target.value }))}
/>
<button className="btn btn-secondary" onClick={() => speichern(g)}>
Speichern
</button>
</div>
</li>
))}
</ul>
)}
</div>
</section>
);
}