feat(maint): MAINT-008 Lieferanten-/Dienstleister-Verzeichnis
CI / backend-tests (push) Successful in 3m2s
CI / frontend-build (push) Successful in 18s

ExterneStelle (ASSET-011) um Kategorie erweitert statt zweites
Adressbuch anzulegen; Wartungsauftrag optional mit dienstleister_id
verknuepfbar, Freitext werkstatt_name bleibt als Fallback bestehen.

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 17:43:42 +02:00
1 parent 3d596ab456
commit c18e4481b6
15 files changed
+257 -20

No files matched your search

+1 -1
View File
@@ -207,7 +207,7 @@ vergeben statt geraten.
| MAINT-005 | Maintenance | Ersatzteile/Kosten | P3 | M | MEDIUM | MAINT-003 | ✅ (`WartungsauftragTeil`, Migration 0033, `/wartungsauftraege/{id}/teile`-CRUD, UI in `WartungSection.tsx`) |
| MAINT-006 | Maintenance | Wartungsdokument-Anbindung | P2 | XS | LOW | MAINT-003, DOC-001 | ✅ (Dokument polymorph, deckt Wartungsauftrag ab) |
| MAINT-007 | Maintenance | Tankbuch/Kraftstoffverbrauch (neu, Referenz: FleetMS) | P3 | S | LOW | FLEET-002, ASSET-009 | ✅ (`tankbuch_eintrag`, `app/services/tankbuch.py`, Verbrauchsberechnung) |
| MAINT-008 | Maintenance | Lieferanten-/Dienstleister-Verzeichnis (neu, Referenz: HiOrg-Server) | P3 | S | LOW | MAINT-003 | ⬜ geplant 2026-09-10 |
| MAINT-008 | Maintenance | Lieferanten-/Dienstleister-Verzeichnis (neu, Referenz: HiOrg-Server) | P3 | S | LOW | MAINT-003 | ✅ erledigt 2026-09-10 |
| DEFECT-001 | Defects | Mangelmeldung + Foto | P1 | M | LOW | ASSET-003, DOC-001 | ✅ (`mangel.py`, `MangelListePage.tsx`, Dokument-Anhang möglich) |
| DEFECT-002 | Defects | Priorität & Status-Workflow | P1 | S | LOW | DEFECT-001 | ✅ (`MangelStatus`, `MangelPrioritaet` Enums) |
| DEFECT-003 | Defects | Verantwortlicher & Reparaturzuordnung | P1 | S | LOW | DEFECT-002, PERS-002 | ✅ (Zustaendigkeit/Kontrollverantwortung koppelt an Mangel-Objekt) |
+12 -1
View File
@@ -219,7 +219,18 @@ ebenfalls P3/offen.
zuordnen, bestehende Freitext-Einträge bleiben unverändert lesbar.
- **Tests:** CRUD-Test, Test dass ein Wartungsauftrag ohne `dienstleister_id`
(nur Freitext) weiterhin funktioniert.
- **DoD:** offen.
- **DoD:** ✅ erledigt (2026-09-10). DECISION: `dienstleister` mit
ASSET-011s `externe_stelle` zusammengelegt (kein zweites Adressbuch) —
`externe_stelle.kategorie` (werkstatt/lieferant/verleiher/sonstiges, neu)
statt eigener Tabelle. Migration `0050_dienstleister.py`.
`Wartungsauftrag.dienstleister_id` (nullable FK auf `externe_stelle`,
Freitext `werkstatt_name` bleibt als Fallback bestehen, keine
Zwangsmigration). Backend: `erstelle_externe_stelle`/
`erstelle_wartungsauftrag` um `kategorie`/`dienstleister_id` erweitert,
404 bei unbekanntem `dienstleister_id`. Frontend: `WartungSection.tsx`
lädt `externe_stellen` (kategorie=werkstatt) nur bei "externe Werkstatt",
Dropdown mit Freitext-Fallback, Liste zeigt verknüpften Namen. Tests:
`test_maint008_dienstleister.py` (Verknüpfung, 404, Freitext-Regression).
- **Referenz (HiOrg-Server-Vergleich 2026-09-10):** HiOrg-Server führt ein
Adressbuch für Lieferanten/Dienstleister, verknüpfbar mit Material-/
Wartungsvorgängen — MABEA hat dafür aktuell nur Freitext, kleine, aber
@@ -0,0 +1,38 @@
"""MAINT-008: externe_stelle als gemeinsames Adressbuch für Verleih (ASSET-011)
UND Wartungsdienstleister/Lieferanten - kategorie-Feld ergänzt,
wartungsauftrag.dienstleister_id (optionale Verknüpfung, werkstatt_name
bleibt als Freitext-Fallback bestehen).
Revision ID: 0050_dienstleister
Revises: 0049_ressourcenbuchung
Create Date: 2026-09-10
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0050_dienstleister"
down_revision: Union[str, None] = "0049_ressourcenbuchung"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"CREATE TYPE externe_stelle_kategorie AS ENUM "
"('werkstatt', 'lieferant', 'verleiher', 'sonstiges')"
)
op.execute(
"ALTER TABLE externe_stelle ADD COLUMN kategorie externe_stelle_kategorie "
"NOT NULL DEFAULT 'sonstiges'"
)
op.execute(
"ALTER TABLE wartungsauftrag ADD COLUMN dienstleister_id INTEGER REFERENCES externe_stelle(id)"
)
def downgrade() -> None:
op.execute("ALTER TABLE wartungsauftrag DROP COLUMN dienstleister_id")
op.execute("ALTER TABLE externe_stelle DROP COLUMN kategorie")
op.execute("DROP TYPE IF EXISTS externe_stelle_kategorie")
+3 -1
View File
@@ -49,7 +49,9 @@ async def hole_externe_stellen(
async def erstelle_externe_stelle_endpunkt(
payload: ExterneStelleCreate, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
) -> ExterneStelle:
return await erstelle_externe_stelle(db, name=payload.name, kontakt=payload.kontakt, adresse=payload.adresse)
return await erstelle_externe_stelle(
db, name=payload.name, kontakt=payload.kontakt, adresse=payload.adresse, kategorie=payload.kategorie
)
@router.get("/verleihe", response_model=list[VerleihRead])
+4
View File
@@ -8,6 +8,7 @@ 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.verleih import ExterneStelle
from app.models.wartung import Wartungsauftrag, WartungsauftragTeil, Wartungsplan, WartungsauftragStatus
from app.schemas.wartung import (
FaelligeWartungRead,
@@ -111,6 +112,8 @@ async def erstelle_auftrag(
objekt = await db.get(Objekt, payload.objekt_id)
if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
if payload.dienstleister_id is not None and await db.get(ExterneStelle, payload.dienstleister_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dienstleister nicht gefunden")
return await erstelle_wartungsauftrag(
db,
objekt_id=payload.objekt_id,
@@ -120,6 +123,7 @@ async def erstelle_auftrag(
beschreibung=payload.beschreibung,
werkstatt_intern=payload.werkstatt_intern,
werkstatt_name=payload.werkstatt_name,
dienstleister_id=payload.dienstleister_id,
faelligkeit_am=payload.faelligkeit_am,
)
+2 -1
View File
@@ -32,7 +32,7 @@ from app.models.personal import (
)
from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort
from app.models.tankbuch import TankbuchEintrag
from app.models.verleih import ExterneStelle, Verleih
from app.models.verleih import ExterneStelle, ExterneStelleKategorie, Verleih
from app.models.vorlage import Beladungsvorlage, VorlageStatus, Vorlagenposition
from app.models.wartung_benachrichtigung import WartungBenachrichtigung
from app.models.wartung import (
@@ -114,6 +114,7 @@ __all__ = [
"Standort",
"TankbuchEintrag",
"ExterneStelle",
"ExterneStelleKategorie",
"Verleih",
"Beladungsvorlage",
"VorlageStatus",
+23 -4
View File
@@ -1,17 +1,33 @@
import enum
import uuid
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ExterneStelleKategorie(str, enum.Enum):
werkstatt = "werkstatt"
lieferant = "lieferant"
verleiher = "verleiher"
sonstiges = "sonstiges"
externe_stelle_kategorie_pg = PgEnum(
ExterneStelleKategorie, name="externe_stelle_kategorie", create_type=False
)
class ExterneStelle(Base):
"""ASSET-011: externe Organisation/Stelle OHNE eigenen MABEA-Zugang -
bewusst getrennt von `Person` (PERS-001, i.d.R. eigenes Personal mit
optionalem Benutzerkonto)."""
"""ASSET-011/MAINT-008: EIN Adressbuch für externe Organisationen -
Verleih-Empfänger (ASSET-011) UND Wartungsdienstleister/Lieferanten
(MAINT-008) sind fachlich dasselbe Konzept (externe Stelle mit
Kontaktdaten), Entscheidung 2026-09-10 bewusst gegen zwei getrennte
Tabellen. Bewusst getrennt von `Person` (PERS-001, i.d.R. eigenes
Personal mit optionalem Benutzerkonto)."""
__tablename__ = "externe_stelle"
@@ -19,6 +35,9 @@ class ExterneStelle(Base):
name: Mapped[str] = mapped_column(String, nullable=False)
kontakt: Mapped[str | None] = mapped_column(String)
adresse: Mapped[str | None] = mapped_column(Text)
kategorie: Mapped[ExterneStelleKategorie] = mapped_column(
externe_stelle_kategorie_pg, nullable=False, default=ExterneStelleKategorie.sonstiges
)
class Verleih(Base):
+5
View File
@@ -71,6 +71,11 @@ class Wartungsauftrag(Base):
)
werkstatt_intern: Mapped[bool] = mapped_column(nullable=False, default=True)
werkstatt_name: Mapped[str | None] = mapped_column(String)
# MAINT-008: optionale Verknüpfung zu strukturierten Stammdaten (dieselbe
# `externe_stelle`-Tabelle wie ASSET-011 - ein Adressbuch statt zwei,
# Entscheidung 2026-09-10). werkstatt_name bleibt als Freitext-Fallback
# bestehen, kein Zwang zur Migration alter Einträge.
dienstleister_id: Mapped[int | None] = mapped_column(ForeignKey("externe_stelle.id"))
faelligkeit_am: Mapped[date | None] = mapped_column()
erstellt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
erledigt_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
+4
View File
@@ -3,11 +3,14 @@ from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
from app.models.verleih import ExterneStelleKategorie
class ExterneStelleCreate(BaseModel):
name: str
kontakt: str | None = None
adresse: str | None = None
kategorie: ExterneStelleKategorie = ExterneStelleKategorie.sonstiges
class ExterneStelleRead(BaseModel):
@@ -16,6 +19,7 @@ class ExterneStelleRead(BaseModel):
name: str
kontakt: str | None
adresse: str | None
kategorie: ExterneStelleKategorie
class VerleihCreate(BaseModel):
+2
View File
@@ -57,6 +57,7 @@ class WartungsauftragCreate(BaseModel):
beschreibung: str | None = None
werkstatt_intern: bool = True
werkstatt_name: str | None = None
dienstleister_id: int | None = None
faelligkeit_am: date | None = None
@@ -82,6 +83,7 @@ class WartungsauftragRead(BaseModel):
status: WartungsauftragStatus
werkstatt_intern: bool
werkstatt_name: str | None
dienstleister_id: int | None
faelligkeit_am: date | None
erstellt_am: datetime
erledigt_am: datetime | None
+8 -3
View File
@@ -4,7 +4,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.verleih import ExterneStelle, Verleih
from app.models.verleih import ExterneStelle, ExterneStelleKategorie, Verleih
class ObjektBereitsVerliehenError(Exception):
@@ -16,9 +16,14 @@ class KeinOffenerVerleihError(Exception):
async def erstelle_externe_stelle(
db: AsyncSession, *, name: str, kontakt: str | None, adresse: str | None
db: AsyncSession,
*,
name: str,
kontakt: str | None,
adresse: str | None,
kategorie: ExterneStelleKategorie = ExterneStelleKategorie.sonstiges,
) -> ExterneStelle:
stelle = ExterneStelle(name=name, kontakt=kontakt, adresse=adresse)
stelle = ExterneStelle(name=name, kontakt=kontakt, adresse=adresse, kategorie=kategorie)
db.add(stelle)
await db.flush()
return stelle
+2
View File
@@ -67,6 +67,7 @@ async def erstelle_wartungsauftrag(
werkstatt_intern: bool,
werkstatt_name: str | None,
faelligkeit_am: date | None,
dienstleister_id: int | None = None,
) -> Wartungsauftrag:
auftrag = Wartungsauftrag(
objekt_id=objekt_id,
@@ -77,6 +78,7 @@ async def erstelle_wartungsauftrag(
status=WartungsauftragStatus.offen,
werkstatt_intern=werkstatt_intern,
werkstatt_name=werkstatt_name,
dienstleister_id=dienstleister_id,
faelligkeit_am=faelligkeit_am,
erstellt_am=datetime.now(timezone.utc),
)
@@ -0,0 +1,101 @@
import pytest
from app.models.objekt import Objekt
from tests.conftest import auth_header, login
@pytest.mark.asyncio
async def test_wartungsauftrag_mit_dienstleister_verknuepft(
client, db_session, hauptserver_id, admin_user, objekttyp_factory, standort_factory
):
token = await login(client, "admin1")
objekttyp = await objekttyp_factory("RTW-MAINT008")
standort = await standort_factory("Standort-MAINT008")
objekt = Objekt(
code="RTW-MAINT008",
name="RTW MAINT008",
objekttyp_id=objekttyp.id,
standort_id=standort.id,
zustaendiger_server_id=hauptserver_id,
)
db_session.add(objekt)
await db_session.flush()
await db_session.commit()
dienstleister = await client.post(
"/api/v1/externe-stellen",
json={"name": "Werkstatt Muster GmbH", "kategorie": "werkstatt"},
headers=auth_header(token),
)
assert dienstleister.status_code == 201
assert dienstleister.json()["kategorie"] == "werkstatt"
dienstleister_id = dienstleister.json()["id"]
auftrag = await client.post(
"/api/v1/wartungsauftraege",
json={
"objekt_id": objekt.id,
"wartungsart": "Bremsen prüfen",
"werkstatt_intern": False,
"dienstleister_id": dienstleister_id,
},
headers=auth_header(token),
)
assert auftrag.status_code == 201
assert auftrag.json()["dienstleister_id"] == dienstleister_id
@pytest.mark.asyncio
async def test_wartungsauftrag_mit_unbekanntem_dienstleister_404(
client, db_session, hauptserver_id, admin_user, objekttyp_factory, standort_factory
):
token = await login(client, "admin1")
objekttyp = await objekttyp_factory("RTW-MAINT008-2")
standort = await standort_factory("Standort-MAINT008-2")
objekt = Objekt(
code="RTW-MAINT008-2",
name="RTW MAINT008-2",
objekttyp_id=objekttyp.id,
standort_id=standort.id,
zustaendiger_server_id=hauptserver_id,
)
db_session.add(objekt)
await db_session.flush()
await db_session.commit()
response = await client.post(
"/api/v1/wartungsauftraege",
json={"objekt_id": objekt.id, "wartungsart": "X", "dienstleister_id": 999999},
headers=auth_header(token),
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_wartungsauftrag_freitext_weiterhin_moeglich(
client, db_session, hauptserver_id, admin_user, objekttyp_factory, standort_factory
):
"""Regressionstest: bestehendes Freitext-Feld funktioniert unverändert,
kein Zwang zur dienstleister_id."""
token = await login(client, "admin1")
objekttyp = await objekttyp_factory("RTW-MAINT008-3")
standort = await standort_factory("Standort-MAINT008-3")
objekt = Objekt(
code="RTW-MAINT008-3",
name="RTW MAINT008-3",
objekttyp_id=objekttyp.id,
standort_id=standort.id,
zustaendiger_server_id=hauptserver_id,
)
db_session.add(objekt)
await db_session.flush()
await db_session.commit()
response = await client.post(
"/api/v1/wartungsauftraege",
json={"objekt_id": objekt.id, "wartungsart": "X", "werkstatt_intern": False, "werkstatt_name": "Freitext GmbH"},
headers=auth_header(token),
)
assert response.status_code == 201
assert response.json()["werkstatt_name"] == "Freitext GmbH"
assert response.json()["dienstleister_id"] is None
+4
View File
@@ -98,6 +98,7 @@ export interface Wartungsauftrag {
status: "offen" | "in_arbeit" | "erledigt";
werkstatt_intern: boolean;
werkstatt_name: string | null;
dienstleister_id: number | null;
faelligkeit_am: string | null;
erstellt_am: string;
erledigt_am: string | null;
@@ -489,9 +490,12 @@ export interface Ressourcenbuchung {
erstellt_am: string;
}
export type ExterneStelleKategorie = "werkstatt" | "lieferant" | "verleiher" | "sonstiges";
export interface ExterneStelle {
id: number;
name: string;
kategorie: ExterneStelleKategorie;
kontakt: string | null;
adresse: string | null;
}
+48 -9
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { apiRequest } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { FaelligeWartung, Wartungsauftrag, WartungsauftragTeil } from "../api/types";
import type { ExterneStelle, FaelligeWartung, Wartungsauftrag, WartungsauftragTeil } from "../api/types";
import { DokumentePanel } from "./DokumentePanel";
interface Props {
@@ -36,6 +36,8 @@ export function WartungSection({ objektId, onFehler }: Props) {
const [neuArt, setNeuArt] = useState("");
const [neuExtern, setNeuExtern] = useState(false);
const [neuWerkstattName, setNeuWerkstattName] = useState("");
const [neuDienstleisterId, setNeuDienstleisterId] = useState<number | "">("");
const [werkstaetten, setWerkstaetten] = useState<ExterneStelle[]>([]);
async function laden() {
try {
@@ -55,6 +57,19 @@ export function WartungSection({ objektId, onFehler }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objektId]);
useEffect(() => {
// MAINT-008: strukturierte Dienstleister (kategorie=werkstatt) als
// Alternative zum Freitext-Feld - nur wenn "externe Werkstatt" gewählt
// ist, wird geladen (kein unnötiger Request im Normalfall).
if (!neuExtern || werkstaetten.length > 0) return;
apiRequest<ExterneStelle[]>("/externe-stellen")
.then((alle) => setWerkstaetten(alle.filter((s) => s.kategorie === "werkstatt")))
.catch(() => {
/* optional - Freitext bleibt nutzbar */
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [neuExtern]);
async function auftragAnlegen() {
if (!neuArt) {
onFehler("Wartungsart angeben.");
@@ -67,12 +82,14 @@ export function WartungSection({ objektId, onFehler }: Props) {
objekt_id: objektId,
wartungsart: neuArt,
werkstatt_intern: !neuExtern,
werkstatt_name: neuExtern ? neuWerkstattName || null : null,
werkstatt_name: neuExtern && neuDienstleisterId === "" ? neuWerkstattName || null : null,
dienstleister_id: neuExtern && neuDienstleisterId !== "" ? neuDienstleisterId : null,
},
});
setNeuArt("");
setNeuExtern(false);
setNeuWerkstattName("");
setNeuDienstleisterId("");
await laden();
} catch {
onFehler("Wartungsauftrag konnte nicht angelegt werden.");
@@ -174,12 +191,28 @@ export function WartungSection({ objektId, onFehler }: Props) {
externe Werkstatt
</label>
{neuExtern && (
<input
className="input"
placeholder="Werkstatt-Name"
value={neuWerkstattName}
onChange={(e) => setNeuWerkstattName(e.target.value)}
/>
<>
<select
className="input"
value={neuDienstleisterId}
onChange={(e) => setNeuDienstleisterId(e.target.value ? Number(e.target.value) : "")}
>
<option value="">Werkstatt wählen (optional)…</option>
{werkstaetten.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
{neuDienstleisterId === "" && (
<input
className="input"
placeholder="oder Werkstatt-Name (Freitext)"
value={neuWerkstattName}
onChange={(e) => setNeuWerkstattName(e.target.value)}
/>
)}
</>
)}
<button className="btn btn-primary" onClick={auftragAnlegen}>
Auftrag anlegen
@@ -199,7 +232,13 @@ export function WartungSection({ objektId, onFehler }: Props) {
<span>
{a.wartungsart}{" "}
<span className="text-muted">
({a.werkstatt_intern ? "intern" : a.werkstatt_name || "extern"})
(
{a.werkstatt_intern
? "intern"
: werkstaetten.find((w) => w.id === a.dienstleister_id)?.name ||
a.werkstatt_name ||
"extern"}
)
</span>
</span>
<span className={`badge ${STATUS_BADGE[a.status]}`}>{STATUS_LABEL[a.status]}</span>