feat(loadout): LOAD-007 Gemeinsame Sollmenge über mehrere Standorte
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:
1 parent
c18e4481b6
commit
bc1542a5e9
20 files changed
+516
-17
No files matched your search
@@ -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) |
|
||||
|
||||
@@ -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")
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user