feat(wartung): MAINT-005 Ersatzteile/Kosten je Wartungsauftrag
Neue Tabelle wartungsauftrag_teil (Migration 0033), CRUD-Endpunkte
/wartungsauftraege/{id}/teile, UI-Panel in WartungSection.tsx mit
Summenanzeige. Backlog Epic 22 (UI-Redesign Runde 2, Kacheln UI2-001..005)
als geplant ergänzt, Umsetzung folgt separat.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -388,3 +388,53 @@ Keine Commits in dieser Session.
|
||||
- frontend/src/pages/AktePage.tsx | 11 +++++++++++
|
||||
|
||||
---
|
||||
## 2026-09-08 10:35 – 10:39 (3m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- arbeitskacheln/00_index.md | 2 +-
|
||||
- backend/alembic/versions/0026_dokumenttyp.py | 33 +++++++++++++++++++++++++++++++++
|
||||
- backend/app/api/v1/endpoints/dokument.py | 9 +++++++--
|
||||
- backend/app/models/dokument.py | 22 +++++++++++++++++++++-
|
||||
- backend/app/schemas/dokument.py | 3 +++
|
||||
- backend/app/services/dokument.py | 17 ++++++++++-------
|
||||
- backend/tests/test_dokument.py | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
|
||||
- frontend/src/api/client.ts | 2 ++
|
||||
- frontend/src/api/types.ts | 9 +++++++++
|
||||
- frontend/src/components/DokumentePanel.tsx | 52 ++++++++++++++++++++++++++++++++++++++++++++++++----
|
||||
|
||||
---
|
||||
## 2026-09-08 10:43 – 10:44 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** asb-material
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- .gitignore | 1 +
|
||||
- Handball Rucksack.xlsx | Bin 12700 -> 0 bytes
|
||||
- Rettungsrucksack.xlsx | Bin 12322 -> 0 bytes
|
||||
- Rucksack PAX SEG.xlsx | Bin 36047 -> 0 bytes
|
||||
- backend/tests/test_dokument.py | 10 +++++++---
|
||||
|
||||
---
|
||||
## 2026-09-08 10:45 – 10:46 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- .gitignore | 1 +
|
||||
- Handball Rucksack.xlsx | Bin 12700 -> 0 bytes
|
||||
- Rettungsrucksack.xlsx | Bin 12322 -> 0 bytes
|
||||
- Rucksack PAX SEG.xlsx | Bin 36047 -> 0 bytes
|
||||
- backend/tests/test_dokument.py | 10 +++++++---
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""MAINT-005: Ersatzteile/Kosten je Wartungsauftrag.
|
||||
|
||||
Revision ID: 0033_wartungsauftrag_teil
|
||||
Revises: 0032_nummernschema
|
||||
Create Date: 2026-09-08
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0033_wartungsauftrag_teil"
|
||||
down_revision: Union[str, None] = "0032_nummernschema"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE wartungsauftrag_teil (
|
||||
id UUID PRIMARY KEY,
|
||||
auftrag_id UUID NOT NULL REFERENCES wartungsauftrag(id),
|
||||
bezeichnung TEXT NOT NULL,
|
||||
menge NUMERIC NOT NULL DEFAULT 1,
|
||||
kosten NUMERIC NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_wartungsauftrag_teil_auftrag_id ON wartungsauftrag_teil (auftrag_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE wartungsauftrag_teil")
|
||||
@@ -8,26 +8,31 @@ from app.api.deps import get_current_user, require_roles
|
||||
from app.db.session import get_db
|
||||
from app.models.auth import Benutzer, RolleTyp
|
||||
from app.models.objekt import Objekt
|
||||
from app.models.wartung import Wartungsauftrag, Wartungsplan, WartungsauftragStatus
|
||||
from app.models.wartung import Wartungsauftrag, WartungsauftragTeil, Wartungsplan, WartungsauftragStatus
|
||||
from app.schemas.wartung import (
|
||||
FaelligeWartungRead,
|
||||
WartungsauftragCreate,
|
||||
WartungsauftragErledigen,
|
||||
WartungsauftragRead,
|
||||
WartungsauftragStatusUpdate,
|
||||
WartungsauftragTeilCreate,
|
||||
WartungsauftragTeilRead,
|
||||
WartungsplanCreate,
|
||||
WartungsplanPositionRead,
|
||||
WartungsplanRead,
|
||||
)
|
||||
from app.services.wartung import (
|
||||
aendere_status,
|
||||
entferne_wartungsauftrag_teil,
|
||||
erledige_wartungsauftrag,
|
||||
erstelle_wartungsauftrag,
|
||||
erstelle_wartungsauftrag_teil,
|
||||
erstelle_wartungsplan,
|
||||
faellige_wartungen_fuer_objekt,
|
||||
hole_intervalle,
|
||||
hole_positionen_mit_intervallen,
|
||||
liste_wartungsauftraege,
|
||||
liste_wartungsauftrag_teile,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -151,3 +156,45 @@ async def route_erledigen(
|
||||
kilometerstand_bei_erledigung=payload.kilometerstand_bei_erledigung,
|
||||
betriebsstunden_bei_erledigung=payload.betriebsstunden_bei_erledigung,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/wartungsauftraege/{auftrag_id}/teile", response_model=list[WartungsauftragTeilRead])
|
||||
async def liste_teile(
|
||||
auftrag_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
||||
) -> list[WartungsauftragTeil]:
|
||||
auftrag = await db.get(Wartungsauftrag, auftrag_id)
|
||||
if auftrag is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wartungsauftrag nicht gefunden")
|
||||
return await liste_wartungsauftrag_teile(db, auftrag_id=auftrag_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/wartungsauftraege/{auftrag_id}/teile",
|
||||
response_model=WartungsauftragTeilRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def erstelle_teil(
|
||||
auftrag_id: uuid.UUID,
|
||||
payload: WartungsauftragTeilCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(_verantwortliche),
|
||||
) -> WartungsauftragTeil:
|
||||
auftrag = await db.get(Wartungsauftrag, auftrag_id)
|
||||
if auftrag is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Wartungsauftrag nicht gefunden")
|
||||
return await erstelle_wartungsauftrag_teil(
|
||||
db, auftrag_id=auftrag_id, bezeichnung=payload.bezeichnung, menge=payload.menge, kosten=payload.kosten
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/wartungsauftraege/{auftrag_id}/teile/{teil_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def loesche_teil(
|
||||
auftrag_id: uuid.UUID,
|
||||
teil_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(_verantwortliche),
|
||||
) -> None:
|
||||
teil = await db.get(WartungsauftragTeil, teil_id)
|
||||
if teil is None or teil.auftrag_id != auftrag_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ersatzteil nicht gefunden")
|
||||
await entferne_wartungsauftrag_teil(db, teil=teil)
|
||||
|
||||
@@ -78,3 +78,13 @@ class Wartungsauftrag(Base):
|
||||
kosten: Mapped[Decimal | None] = mapped_column(Numeric)
|
||||
kilometerstand_bei_erledigung: Mapped[int | None] = mapped_column(Integer)
|
||||
betriebsstunden_bei_erledigung: Mapped[Decimal | None] = mapped_column(Numeric)
|
||||
|
||||
|
||||
class WartungsauftragTeil(Base):
|
||||
__tablename__ = "wartungsauftrag_teil"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
auftrag_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("wartungsauftrag.id"), nullable=False)
|
||||
bezeichnung: Mapped[str] = mapped_column(String, nullable=False)
|
||||
menge: Mapped[Decimal] = mapped_column(Numeric, nullable=False, default=1)
|
||||
kosten: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
|
||||
|
||||
@@ -91,6 +91,21 @@ class WartungsauftragRead(BaseModel):
|
||||
betriebsstunden_bei_erledigung: Decimal | None
|
||||
|
||||
|
||||
class WartungsauftragTeilCreate(BaseModel):
|
||||
bezeichnung: str
|
||||
menge: Decimal = Decimal(1)
|
||||
kosten: Decimal
|
||||
|
||||
|
||||
class WartungsauftragTeilRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
auftrag_id: uuid.UUID
|
||||
bezeichnung: str
|
||||
menge: Decimal
|
||||
kosten: Decimal
|
||||
|
||||
|
||||
class FaelligeWartungRead(BaseModel):
|
||||
wartungsplan_position_id: int
|
||||
wartungsart: str
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.models.wartung import (
|
||||
IntervallTyp,
|
||||
Wartungsauftrag,
|
||||
WartungsauftragStatus,
|
||||
WartungsauftragTeil,
|
||||
Wartungsintervall,
|
||||
Wartungsplan,
|
||||
WartungsplanPosition,
|
||||
@@ -135,6 +136,27 @@ async def liste_wartungsauftraege(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def erstelle_wartungsauftrag_teil(
|
||||
db: AsyncSession, *, auftrag_id, bezeichnung: str, menge: Decimal, kosten: Decimal
|
||||
) -> WartungsauftragTeil:
|
||||
teil = WartungsauftragTeil(auftrag_id=auftrag_id, bezeichnung=bezeichnung, menge=menge, kosten=kosten)
|
||||
db.add(teil)
|
||||
await db.flush()
|
||||
return teil
|
||||
|
||||
|
||||
async def liste_wartungsauftrag_teile(db: AsyncSession, *, auftrag_id) -> list[WartungsauftragTeil]:
|
||||
result = await db.execute(
|
||||
select(WartungsauftragTeil).where(WartungsauftragTeil.auftrag_id == auftrag_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def entferne_wartungsauftrag_teil(db: AsyncSession, *, teil: WartungsauftragTeil) -> None:
|
||||
await db.delete(teil)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def faellige_wartungen_fuer_objekt(db: AsyncSession, objekt: Objekt) -> list[dict]:
|
||||
"""MAINT-002: kombiniert Zeit-/km-/Betriebsstunden-Intervalle je Position -
|
||||
fällig, sobald das erste Kriterium erreicht ist. Nie durchgeführt gilt
|
||||
|
||||
@@ -212,3 +212,73 @@ async def test_ad_hoc_reparatur_ohne_wartungsplan_mit_externer_werkstatt(
|
||||
|
||||
historie = await client.get(f"/api/v1/wartungsauftraege?objekt_id={objekt_id}", headers=auth_header(token))
|
||||
assert len(historie.json()) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wartungsauftrag_teile_erfassen_und_summieren(
|
||||
client, admin_user, standort_factory, objekt_factory
|
||||
):
|
||||
token = await login(client, "admin1")
|
||||
standort = await standort_factory("Standort-W6")
|
||||
await objekt_factory(name="RTW W6", code="RTW-W6", standort=standort)
|
||||
objekte = await client.get("/api/v1/objekte", headers=auth_header(token))
|
||||
objekt_id = [o for o in objekte.json() if o["code"] == "RTW-W6"][0]["id"]
|
||||
|
||||
auftrag = await client.post(
|
||||
"/api/v1/wartungsauftraege",
|
||||
json={"objekt_id": objekt_id, "wartungsart": "Ölwechsel"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
auftrag_id = auftrag.json()["id"]
|
||||
|
||||
teil1 = await client.post(
|
||||
f"/api/v1/wartungsauftraege/{auftrag_id}/teile",
|
||||
json={"bezeichnung": "Ölfilter", "menge": "1", "kosten": "12.50"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert teil1.status_code == 201
|
||||
teil2 = await client.post(
|
||||
f"/api/v1/wartungsauftraege/{auftrag_id}/teile",
|
||||
json={"bezeichnung": "Motoröl 5L", "menge": "2", "kosten": "45.00"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert teil2.status_code == 201
|
||||
|
||||
liste = await client.get(f"/api/v1/wartungsauftraege/{auftrag_id}/teile", headers=auth_header(token))
|
||||
positionen = liste.json()
|
||||
assert len(positionen) == 2
|
||||
gesamt = sum(float(p["kosten"]) for p in positionen)
|
||||
assert gesamt == 57.50
|
||||
|
||||
loeschen = await client.delete(
|
||||
f"/api/v1/wartungsauftraege/{auftrag_id}/teile/{teil1.json()['id']}", headers=auth_header(token)
|
||||
)
|
||||
assert loeschen.status_code == 204
|
||||
liste_danach = await client.get(f"/api/v1/wartungsauftraege/{auftrag_id}/teile", headers=auth_header(token))
|
||||
assert len(liste_danach.json()) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mitarbeiter_darf_wartungsauftrag_teil_nicht_anlegen(
|
||||
client, admin_user, mitarbeiter_user, standort_factory, objekt_factory
|
||||
):
|
||||
admin_token = await login(client, "admin1")
|
||||
standort = await standort_factory("Standort-W7")
|
||||
await objekt_factory(name="RTW W7", code="RTW-W7", standort=standort)
|
||||
objekte = await client.get("/api/v1/objekte", headers=auth_header(admin_token))
|
||||
objekt_id = [o for o in objekte.json() if o["code"] == "RTW-W7"][0]["id"]
|
||||
|
||||
auftrag = await client.post(
|
||||
"/api/v1/wartungsauftraege",
|
||||
json={"objekt_id": objekt_id, "wartungsart": "Ölwechsel"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
auftrag_id = auftrag.json()["id"]
|
||||
|
||||
mitarbeiter_token = await login(client, "mitarbeiter1")
|
||||
antwort = await client.post(
|
||||
f"/api/v1/wartungsauftraege/{auftrag_id}/teile",
|
||||
json={"bezeichnung": "Ölfilter", "menge": "1", "kosten": "12.50"},
|
||||
headers=auth_header(mitarbeiter_token),
|
||||
)
|
||||
assert antwort.status_code == 403
|
||||
|
||||
Reference in New Issue
Block a user