feat(lager): Warehouse-Epic Kern (WH-001..004+007)
CI / backend-tests (push) Successful in 1m56s
CI / frontend-build (push) Successful in 18s

Lagerort-Hierarchie (organisation/standort/gebaeude/raum/lager, self-referenzierend),
Lagerplätze mit eindeutigem Code, Bestand je Lagerplatz+Material, Ein-/Auslagerung
mit lückenlosem Materialbewegungsprotokoll (append-only, analog Lagerbewegung
auf Objekt-Ebene, hier aber Material-/Lagerplatz-fein).

- Migration 0019: 4 neue Tabellen (lagerort, lagerplatz, bestand, materialbewegung)
- Backend: CRUD Lagerort/Lagerplatz, Bestandsabfrage, Ein-/Auslagern-Endpunkte,
  Negativbestand vorab abgefangen (kein DB-Constraint-Exception-Pfad, gleiches
  Muster wie objekte.py-Fix vom 2026-09-05)
- Frontend: neuer Admin-Tab "Lager" (Lagerort/-platz-Verwaltung, Bestand+Buchung
  je Lagerplatz)
- 2 neue Tests, 139 Tests weiterhin grün

WH-005 (Umlagerung) und WH-006 (Inventur) bewusst zurückgestellt - Kern zuerst,
laut Nutzer-Entscheidung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-05 21:42:37 +02:00
co-authored by Claude Sonnet 5
parent ca0befd4a0
commit f7367f6df3
11 changed files with 918 additions and 0 deletions
+34
View File
@@ -96,6 +96,40 @@ export interface Lagerbewegung {
grund: string | null;
}
export type LagerortTyp = "organisation" | "standort" | "gebaeude" | "raum" | "lager";
export interface Lagerort {
id: number;
name: string;
parent_id: number | null;
typ: LagerortTyp;
}
export interface Lagerplatz {
id: number;
lagerort_id: number;
code: string;
bezeichnung: string | null;
}
export interface Bestand {
id: string;
lagerplatz_id: number;
material_id: number;
menge: string;
}
export interface Materialbewegung {
id: string;
material_id: number;
menge: string;
von_lagerplatz_id: number | null;
nach_lagerplatz_id: number | null;
benutzer_id: number;
zeitpunkt: string;
grund: string | null;
}
export interface Fehlbestand {
id: string;
objekt_id: number;
+4
View File
@@ -11,6 +11,7 @@ import { FachSection } from "./admin/FachSection";
import { HistorieSection } from "./admin/HistorieSection";
import { KategorieSection } from "./admin/KategorieSection";
import { LagerbewegungSection } from "./admin/LagerbewegungSection";
import { LagerSection } from "./admin/LagerSection";
import { RolleSection } from "./admin/RolleSection";
import { KontrollverantwortungSection } from "./admin/KontrollverantwortungSection";
import { MaterialSection } from "./admin/MaterialSection";
@@ -34,6 +35,7 @@ type Tab =
| "zustaendigkeit"
| "personal"
| "lagerbewegungen"
| "lager"
| "rollen";
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
@@ -65,6 +67,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
label: "Lagerbewegungen",
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{ key: "lager", label: "Lager", rollen: ["administration", "materialverantwortlicher"] },
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
];
@@ -225,6 +228,7 @@ export function AdminPage() {
{aktiverTab === "lagerbewegungen" && (
<LagerbewegungSection objekte={objekte} standorte={standorte} onFehler={setFehler} />
)}
{aktiverTab === "lager" && <LagerSection materialien={materialien} onFehler={setFehler} />}
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
+311
View File
@@ -0,0 +1,311 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { Bestand, Lagerort, LagerortTyp, Lagerplatz, Material, Materialbewegung } from "../../api/types";
interface Props {
materialien: Material[];
onFehler: (text: string) => void;
}
const TYP_LABEL: Record<LagerortTyp, string> = {
organisation: "Organisation",
standort: "Standort",
gebaeude: "Gebäude",
raum: "Raum",
lager: "Lager",
};
// WH-001..004+007 (Warehouse-Kern): Lagerort-Hierarchie, Lagerplätze, Bestand
// je Lagerplatz, Ein-/Auslagerung. Umlagerung (WH-005) und Inventur (WH-006)
// bewusst als Folgeschritt zurückgestellt (arbeitskacheln/07_warehouse.md).
export function LagerSection({ materialien, onFehler }: Props) {
const [lagerorte, setLagerorte] = useState<Lagerort[]>([]);
const [lagerplaetze, setLagerplaetze] = useState<Lagerplatz[]>([]);
const [neuerOrtName, setNeuerOrtName] = useState("");
const [neuerOrtTyp, setNeuerOrtTyp] = useState<LagerortTyp>("lager");
const [neuerOrtParent, setNeuerOrtParent] = useState<number | "">("");
const [neuerPlatzOrt, setNeuerPlatzOrt] = useState<number | "">("");
const [neuerPlatzCode, setNeuerPlatzCode] = useState("");
const [neuerPlatzBezeichnung, setNeuerPlatzBezeichnung] = useState("");
const [ausgewaehlterPlatz, setAusgewaehlterPlatz] = useState<number | "">("");
const [bestand, setBestand] = useState<Bestand[]>([]);
const [bewegungen, setBewegungen] = useState<Materialbewegung[]>([]);
const [buchungMaterial, setBuchungMaterial] = useState<number | "">("");
const [buchungMenge, setBuchungMenge] = useState("");
const [buchungGrund, setBuchungGrund] = useState("");
async function laden() {
try {
const [orte, plaetze] = await Promise.all([
apiRequest<Lagerort[]>("/lagerorte"),
apiRequest<Lagerplatz[]>("/lagerplaetze"),
]);
setLagerorte(orte);
setLagerplaetze(plaetze);
} catch {
onFehler("Lagerorte/-plätze konnten nicht geladen werden.");
}
}
useEffect(() => {
laden();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function ladeBestandUndBewegungen(platzId: number) {
try {
const [b, bw] = await Promise.all([
apiRequest<Bestand[]>(`/lagerplaetze/${platzId}/bestand`),
apiRequest<Materialbewegung[]>(`/materialbewegungen?lagerplatz_id=${platzId}`),
]);
setBestand(b);
setBewegungen(bw);
} catch {
onFehler("Bestand konnte nicht geladen werden.");
}
}
useEffect(() => {
if (ausgewaehlterPlatz !== "") void ladeBestandUndBewegungen(ausgewaehlterPlatz);
}, [ausgewaehlterPlatz]);
function ortName(id: number | null): string {
if (id === null) return "";
const o = lagerorte.find((x) => x.id === id);
return o ? o.name : `#${id}`;
}
function materialName(id: number): string {
const m = materialien.find((x) => x.id === id);
return m ? m.name : `#${id}`;
}
function platzCode(id: number | null): string {
if (id === null) return "extern";
const p = lagerplaetze.find((x) => x.id === id);
return p ? p.code : `#${id}`;
}
async function lagerortAnlegen() {
if (!neuerOrtName) {
onFehler("Name eingeben.");
return;
}
try {
await apiRequest("/lagerorte", {
method: "POST",
body: { name: neuerOrtName, typ: neuerOrtTyp, parent_id: neuerOrtParent === "" ? null : neuerOrtParent },
});
setNeuerOrtName("");
setNeuerOrtParent("");
await laden();
} catch {
onFehler("Lagerort konnte nicht angelegt werden.");
}
}
async function lagerplatzAnlegen() {
if (neuerPlatzOrt === "" || !neuerPlatzCode) {
onFehler("Lagerort und Code eingeben.");
return;
}
try {
await apiRequest("/lagerplaetze", {
method: "POST",
body: { lagerort_id: neuerPlatzOrt, code: neuerPlatzCode, bezeichnung: neuerPlatzBezeichnung || null },
});
setNeuerPlatzCode("");
setNeuerPlatzBezeichnung("");
await laden();
} catch {
onFehler("Lagerplatz konnte nicht angelegt werden (Code evtl. schon vergeben).");
}
}
async function buchen(richtung: "einlagern" | "auslagern") {
if (ausgewaehlterPlatz === "" || buchungMaterial === "" || !buchungMenge) {
onFehler("Material und Menge angeben.");
return;
}
try {
await apiRequest(`/lagerplaetze/${ausgewaehlterPlatz}/${richtung}`, {
method: "POST",
body: { material_id: buchungMaterial, menge: buchungMenge, grund: buchungGrund || null },
});
setBuchungMenge("");
setBuchungGrund("");
await ladeBestandUndBewegungen(ausgewaehlterPlatz);
} catch {
onFehler(richtung === "auslagern" ? "Nicht genug Bestand am Lagerplatz." : "Buchung fehlgeschlagen.");
}
}
return (
<section className="section">
<h2>Lager</h2>
<div className="card">
<h3 style={{ marginTop: 0 }}>Lagerort anlegen</h3>
<div className="row" style={{ flexWrap: "wrap" }}>
<input
className="input"
placeholder="Name"
value={neuerOrtName}
onChange={(e) => setNeuerOrtName(e.target.value)}
/>
<select className="input" value={neuerOrtTyp} onChange={(e) => setNeuerOrtTyp(e.target.value as LagerortTyp)}>
{Object.entries(TYP_LABEL).map(([wert, label]) => (
<option key={wert} value={wert}>
{label}
</option>
))}
</select>
<select
className="input"
value={neuerOrtParent}
onChange={(e) => setNeuerOrtParent(Number(e.target.value) || "")}
>
<option value="">Kein übergeordneter Lagerort</option>
{lagerorte.map((o) => (
<option key={o.id} value={o.id}>
{o.name} ({TYP_LABEL[o.typ]})
</option>
))}
</select>
<button className="btn btn-primary" onClick={lagerortAnlegen}>
Anlegen
</button>
</div>
<ul className="card-list" style={{ marginTop: "0.75rem" }}>
{lagerorte.map((o) => (
<li key={o.id}>
{o.name} <span className="text-muted">({TYP_LABEL[o.typ]}{o.parent_id !== null ? `, unter ${ortName(o.parent_id)}` : ""})</span>
</li>
))}
</ul>
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Lagerplatz anlegen</h3>
<div className="row" style={{ flexWrap: "wrap" }}>
<select
className="input"
value={neuerPlatzOrt}
onChange={(e) => setNeuerPlatzOrt(Number(e.target.value) || "")}
>
<option value="">Lagerort wählen</option>
{lagerorte.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
<input
className="input"
placeholder="Code (z. B. REGAL-A1)"
value={neuerPlatzCode}
onChange={(e) => setNeuerPlatzCode(e.target.value)}
/>
<input
className="input"
placeholder="Bezeichnung (optional)"
value={neuerPlatzBezeichnung}
onChange={(e) => setNeuerPlatzBezeichnung(e.target.value)}
/>
<button className="btn btn-primary" onClick={lagerplatzAnlegen}>
Anlegen
</button>
</div>
<ul className="card-list" style={{ marginTop: "0.75rem" }}>
{lagerplaetze.map((p) => (
<li key={p.id}>
{p.code} <span className="text-muted">({ortName(p.lagerort_id)}{p.bezeichnung ? `, ${p.bezeichnung}` : ""})</span>
</li>
))}
</ul>
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Bestand & Buchungen</h3>
<select
className="input"
style={{ marginBottom: "0.75rem" }}
value={ausgewaehlterPlatz}
onChange={(e) => setAusgewaehlterPlatz(Number(e.target.value) || "")}
>
<option value="">Lagerplatz wählen</option>
{lagerplaetze.map((p) => (
<option key={p.id} value={p.id}>
{p.code}
</option>
))}
</select>
{ausgewaehlterPlatz !== "" && (
<>
<div className="row" style={{ flexWrap: "wrap", marginBottom: "0.75rem" }}>
<select
className="input"
value={buchungMaterial}
onChange={(e) => setBuchungMaterial(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
className="input"
type="number"
placeholder="Menge"
style={{ width: "6rem" }}
value={buchungMenge}
onChange={(e) => setBuchungMenge(e.target.value)}
/>
<input
className="input"
placeholder="Grund (optional)"
value={buchungGrund}
onChange={(e) => setBuchungGrund(e.target.value)}
/>
<button className="btn btn-primary" onClick={() => buchen("einlagern")}>
Einlagern
</button>
<button className="btn btn-secondary" onClick={() => buchen("auslagern")}>
Auslagern
</button>
</div>
<h4>Bestand</h4>
{bestand.length === 0 && <p className="text-muted">Kein Bestand an diesem Lagerplatz.</p>}
<ul>
{bestand.map((b) => (
<li key={b.id}>
{materialName(b.material_id)}: {b.menge}
</li>
))}
</ul>
<h4>Bewegungen</h4>
{bewegungen.length === 0 && <p className="text-muted">Noch keine Bewegungen.</p>}
<ul>
{bewegungen.map((bw) => (
<li key={bw.id}>
{new Date(bw.zeitpunkt).toLocaleString("de-DE")} {materialName(bw.material_id)}: {bw.menge}{" "}
({platzCode(bw.von_lagerplatz_id)} {platzCode(bw.nach_lagerplatz_id)})
{bw.grund && <span className="text-muted"> · {bw.grund}</span>}
</li>
))}
</ul>
</>
)}
</div>
</section>
);
}