From 111d8d12df2d94d94d47c462fca6fc3ec2470177 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sun, 5 Jul 2026 02:00:44 +0200 Subject: [PATCH 01/10] feat(repairs): add repair estimates --- backend/hermes/app/schemas/repair_estimate.py | 14 +++ .../repairs/RepairEstimatesSection.tsx | 104 +++++++++++++++--- 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/backend/hermes/app/schemas/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py index f2c6f44..3410a04 100644 --- a/backend/hermes/app/schemas/repair_estimate.py +++ b/backend/hermes/app/schemas/repair_estimate.py @@ -31,6 +31,13 @@ class RepairEstimateItemPayload(BaseModel): return None return normalize_text(value) + @field_validator("quantity", mode="before") + @classmethod + def normalize_quantity(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().replace(",", ".") + return value + class RepairEstimatePayload(BaseModel): title: str = Field(min_length=1, max_length=255) @@ -53,6 +60,13 @@ class RepairEstimatePayload(BaseModel): def normalize_currency(cls, value: str) -> str: return value.upper() or "EUR" + @field_validator("tax_rate_percent", mode="before") + @classmethod + def normalize_tax_rate(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().replace(",", ".") + return value + @model_validator(mode="after") def validate_items(self): if not self.items: diff --git a/frontend/athena/components/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx index 6e00342..b924bfa 100644 --- a/frontend/athena/components/repairs/RepairEstimatesSection.tsx +++ b/frontend/athena/components/repairs/RepairEstimatesSection.tsx @@ -19,7 +19,6 @@ import { Input } from "@/components/ui/input"; import { api } from "@/lib/api"; import type { RepairEstimate, - RepairEstimateItemPayload, RepairEstimateItemType, RepairEstimatePayload, } from "@/types/repair"; @@ -41,10 +40,40 @@ const statusLabels: Record = { cancelled: "Storniert", }; +function humanizeValidationDetail(detail: unknown): string | null { + if (!Array.isArray(detail)) { + return null; + } + + const messages = detail + .map((item) => { + if (typeof item !== "object" || item === null) { + return null; + } + const record = item as { loc?: unknown[]; msg?: unknown }; + const field = Array.isArray(record.loc) ? record.loc.filter((part) => part !== "body").join(".") : ""; + const message = typeof record.msg === "string" ? record.msg : "Ungültiger Wert"; + return field ? `${field}: ${message}` : message; + }) + .filter((message): message is string => Boolean(message)); + + return messages.length ? messages.join(" · ") : null; +} + function getErrorMessage(error: unknown) { if (typeof error === "object" && error !== null && "response" in error) { - const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response; - return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden"; + const response = (error as { response?: { data?: { detail?: unknown; message?: unknown } } }).response; + const detailMessage = humanizeValidationDetail(response?.data?.detail); + if (detailMessage) { + return detailMessage; + } + if (typeof response?.data?.detail === "string") { + return response.data.detail; + } + if (typeof response?.data?.message === "string") { + return response.data.message; + } + return "Bitte prüfe die Eingaben. Mindestens eine gültige Position mit Titel, Menge und Preis ist erforderlich."; } return "Aktion konnte nicht abgeschlossen werden"; } @@ -58,18 +87,46 @@ function dateTime(value: string | null) { return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); } -function emptyItem(): RepairEstimateItemPayload { +type EstimateFormItem = { + item_type: RepairEstimateItemType; + title: string; + description: string | null; + quantity: string; + unit: string; + unit_price_euros: string; +}; + +type EstimateFormPayload = Omit & { + items: EstimateFormItem[]; +}; + +function parseEuroToCents(value: string): number | null { + const normalized = value.trim().replace(/\s/g, "").replace(",", "."); + if (!normalized) { + return null; + } + if (!/^\d+(\.\d{1,2})?$/.test(normalized)) { + return null; + } + return Math.round(Number(normalized) * 100); +} + +function centsToEuroInput(cents: number): string { + return (cents / 100).toFixed(2).replace(".", ","); +} + +function emptyItem(): EstimateFormItem { return { item_type: "labor", title: "", description: "", quantity: "1.00", unit: "Std.", - unit_price_cents: 0, + unit_price_euros: "", }; } -function emptyPayload(): RepairEstimatePayload { +function emptyPayload(): EstimateFormPayload { return { title: "Kostenvoranschlag", customer_message: "Bitte prüfen Sie den Kostenvoranschlag und geben Sie uns über den Statuslink Rückmeldung.", @@ -106,7 +163,7 @@ export default function RepairEstimatesSection({ const [error, setError] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); const [editing, setEditing] = useState(null); - const [payload, setPayload] = useState(emptyPayload()); + const [payload, setPayload] = useState(emptyPayload()); const [saving, setSaving] = useState(false); const [pendingId, setPendingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -136,7 +193,8 @@ export default function RepairEstimatesSection({ const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => { const quantity = Number(item.quantity.replace(",", ".")) || 0; - return sum + Math.round(quantity * item.unit_price_cents); + const unitPriceCents = parseEuroToCents(item.unit_price_euros) ?? 0; + return sum + Math.round(quantity * unitPriceCents); }, 0), [payload.items]); const clientTax = Math.round(clientSubtotal * (Number(payload.tax_rate_percent.replace(",", ".")) || 0) / 100); @@ -161,13 +219,13 @@ export default function RepairEstimatesSection({ description: item.description ?? "", quantity: String(item.quantity), unit: item.unit, - unit_price_cents: item.unit_price_cents, + unit_price_euros: centsToEuroInput(item.unit_price_cents), })), }); setDialogOpen(true); } - function updateItem(index: number, update: Partial) { + function updateItem(index: number, update: Partial) { setPayload((current) => ({ ...current, items: current.items.map((item, itemIndex) => itemIndex === index ? { ...item, ...update } : item), @@ -177,14 +235,30 @@ export default function RepairEstimatesSection({ async function saveEstimate() { setSaving(true); try { + const invalidPriceIndex = payload.items.findIndex((item) => parseEuroToCents(item.unit_price_euros) === null); + if (invalidPriceIndex >= 0) { + showToast({ + type: "error", + title: "Preisangabe ungültig", + description: `Bitte gib den Einzelpreis in Position ${invalidPriceIndex + 1} als Eurobetrag ein, z. B. 100,00.`, + }); + return; + } + const normalizedPayload = { - ...payload, - internal_note: payload.internal_note || null, + title: payload.title.trim(), + customer_message: payload.customer_message.trim(), + internal_note: payload.internal_note?.trim() || null, + tax_rate_percent: payload.tax_rate_percent.replace(",", "."), + currency: payload.currency.trim().toUpperCase() || "EUR", valid_until: payload.valid_until || null, items: payload.items.map((item) => ({ - ...item, - description: item.description || null, + item_type: item.item_type, + title: item.title.trim(), + description: item.description?.trim() || null, quantity: item.quantity.replace(",", "."), + unit: item.unit.trim() || "Stk.", + unit_price_cents: parseEuroToCents(item.unit_price_euros) ?? 0, })), }; if (editing) { @@ -346,7 +420,7 @@ export default function RepairEstimatesSection({ updateItem(index, { title: event.target.value })} /> updateItem(index, { quantity: event.target.value })} /> updateItem(index, { unit: event.target.value })} /> - updateItem(index, { unit_price_cents: Number(event.target.value) || 0 })} /> + updateItem(index, { unit_price_euros: event.target.value })} /> updateItem(index, { description: event.target.value })} /> From abc6e308ddf1c2d3184b86e3fdfd0bd9ae445756 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sun, 5 Jul 2026 02:24:02 +0200 Subject: [PATCH 02/10] feat(estimates): add repair estimates and customer approval workflow --- funktechnik-schubert-website | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/funktechnik-schubert-website b/funktechnik-schubert-website index efdc4d5..6f68911 160000 --- a/funktechnik-schubert-website +++ b/funktechnik-schubert-website @@ -1 +1 @@ -Subproject commit efdc4d5be10e921f15eaa6a8de7547e0d8893dc4 +Subproject commit 6f6891134e798ecd93917e5440e80b1e8ca5c316 From fb5c2cc26a726f40a60213aa4bc7f56d3293b321 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sun, 5 Jul 2026 11:32:06 +0200 Subject: [PATCH 03/10] feat(inventory): add spare parts management --- ARCHITECTURE.md | 54 +++ README-DEV.md | 42 +++ ROADMAP.md | 24 +- backend/hermes/alembic/env.py | 1 + .../versions/ef8a2d5c9017_create_inventory.py | 214 ++++++++++++ .../app/__pycache__/main.cpython-314.pyc | Bin 844 -> 7652 bytes backend/hermes/app/api/audit.py | 2 + backend/hermes/app/api/dashboard.py | 11 + backend/hermes/app/api/inventory.py | 307 +++++++++++++++++ .../core/__pycache__/config.cpython-314.pyc | Bin 1002 -> 3397 bytes .../db/__pycache__/database.cpython-314.pyc | Bin 1091 -> 1307 bytes backend/hermes/app/main.py | 2 + .../models/__pycache__/user.cpython-314.pyc | Bin 1319 -> 2654 bytes backend/hermes/app/models/inventory.py | 110 ++++++ backend/hermes/app/rbac/defaults.py | 26 ++ .../app/repositories/inventory_repository.py | 187 ++++++++++ backend/hermes/app/schemas/dashboard.py | 1 + backend/hermes/app/schemas/inventory.py | 231 +++++++++++++ backend/hermes/app/services/audit_service.py | 17 + .../hermes/app/services/inventory_service.py | 318 +++++++++++++++++ .../api/inventory/categories/[id]/route.ts | 33 ++ .../app/api/inventory/categories/route.ts | 18 + .../inventory/items/[id]/movements/route.ts | 12 + .../app/api/inventory/items/[id]/route.ts | 37 ++ .../items/[id]/stock/adjust/route.ts | 19 ++ .../items/[id]/stock/consume/route.ts | 19 ++ .../items/[id]/stock/release/route.ts | 19 ++ .../items/[id]/stock/reserve/route.ts | 19 ++ .../athena/app/api/inventory/items/route.ts | 18 + .../app/api/inventory/locations/[id]/route.ts | 33 ++ .../app/api/inventory/locations/route.ts | 18 + .../app/api/inventory/suppliers/[id]/route.ts | 33 ++ .../app/api/inventory/suppliers/route.ts | 18 + frontend/athena/app/dashboard/page.tsx | 2 +- .../athena/app/inventory/items/[id]/page.tsx | 153 +++++++++ frontend/athena/app/inventory/page.tsx | 322 ++++++++++++++++++ frontend/athena/components/Sidebar.tsx | 1 + .../inventory/InventoryItemDialog.tsx | 192 +++++++++++ .../inventory/MasterDataSection.tsx | 148 ++++++++ .../inventory/StockActionDialog.tsx | 107 ++++++ .../components/inventory/inventory-utils.ts | 62 ++++ frontend/athena/types/dashboard.ts | 1 + frontend/athena/types/inventory.ts | 121 +++++++ 43 files changed, 2950 insertions(+), 2 deletions(-) create mode 100644 backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py create mode 100644 backend/hermes/app/api/inventory.py create mode 100644 backend/hermes/app/models/inventory.py create mode 100644 backend/hermes/app/repositories/inventory_repository.py create mode 100644 backend/hermes/app/schemas/inventory.py create mode 100644 backend/hermes/app/services/inventory_service.py create mode 100644 frontend/athena/app/api/inventory/categories/[id]/route.ts create mode 100644 frontend/athena/app/api/inventory/categories/route.ts create mode 100644 frontend/athena/app/api/inventory/items/[id]/movements/route.ts create mode 100644 frontend/athena/app/api/inventory/items/[id]/route.ts create mode 100644 frontend/athena/app/api/inventory/items/[id]/stock/adjust/route.ts create mode 100644 frontend/athena/app/api/inventory/items/[id]/stock/consume/route.ts create mode 100644 frontend/athena/app/api/inventory/items/[id]/stock/release/route.ts create mode 100644 frontend/athena/app/api/inventory/items/[id]/stock/reserve/route.ts create mode 100644 frontend/athena/app/api/inventory/items/route.ts create mode 100644 frontend/athena/app/api/inventory/locations/[id]/route.ts create mode 100644 frontend/athena/app/api/inventory/locations/route.ts create mode 100644 frontend/athena/app/api/inventory/suppliers/[id]/route.ts create mode 100644 frontend/athena/app/api/inventory/suppliers/route.ts create mode 100644 frontend/athena/app/inventory/items/[id]/page.tsx create mode 100644 frontend/athena/app/inventory/page.tsx create mode 100644 frontend/athena/components/inventory/InventoryItemDialog.tsx create mode 100644 frontend/athena/components/inventory/MasterDataSection.tsx create mode 100644 frontend/athena/components/inventory/StockActionDialog.tsx create mode 100644 frontend/athena/components/inventory/inventory-utils.ts create mode 100644 frontend/athena/types/inventory.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fdac253..8134159 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -219,6 +219,60 @@ Tabellen: - `repair_public_access_tokens` - `repair_notification_events` +## Lager / Ersatzteile + +Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Es ist in v0.8.7 als eigenstaendige Lagerbasis umgesetzt und fuer die spaetere Verknuepfung mit Reparaturen und Kostenvoranschlaegen vorbereitet. + +Datenfluss: + +```text +Browser -> Athena /api/inventory... -> Hermes /inventory... -> PostgreSQL +``` + +Kernregeln: + +- Browser greifen nie direkt auf Hermes zu. +- Artikelnummern/SKUs sind eindeutig. +- Leere SKU bei Erstellung wird automatisch im Format `ET--000001` vergeben. +- Preise werden intern in cents gespeichert und in Athena als Euro eingegeben. +- `quantity_available` wird konsistent als `quantity_on_hand - quantity_reserved` gespeichert. +- Bestandsaenderungen erfolgen ueber Bewegungen, nicht ueber freie Client-Manipulation. +- Artikel mit Bewegungen werden bei Delete deaktiviert statt hart geloescht. + +Tabellen: + +- `inventory_items` +- `inventory_categories` +- `inventory_locations` +- `inventory_suppliers` +- `inventory_stock_movements` + +Bestandsbewegungen: + +- `initial` +- `purchase` +- `adjustment` +- `reservation` +- `release` +- `consumption` +- `return` +- `correction` + +RBAC-Permissions: + +- `inventory.read` +- `inventory.create` +- `inventory.update` +- `inventory.delete` +- `inventory.stock.adjust` +- `inventory.stock.reserve` +- `inventory.stock.consume` +- `inventory.manage.masterdata` + +Dashboard-Kennzahlen fuer Lagerdaten werden nur mit `inventory.read` ausgeliefert. Audit Logs und Activity Feed enthalten Artikel- und Bestandsaktionen, aber keine unnoetigen sensiblen Lieferantendaten. + +Die Integration von Lagerartikeln in Kostenvoranschlagspositionen folgt in v0.8.8. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures. + Hermes-Endpunkte: - `GET /repairs` diff --git a/README-DEV.md b/README-DEV.md index 896ad69..70c613f 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -214,6 +214,48 @@ Kostenvoranschlaege: - Die oeffentliche Website zeigt diese Daten erst nach einer spaeteren Website-Version an. Olympus stellt die API dafuer bereit. - PDF-Erzeugung, Lexoffice und Rechnungen sind Folgefeatures. +## Lager / Ersatzteile + +Das Lagermodul ist ab v0.8.7 aktiv. + +Migration lokal ausfuehren: + +```bash +cd backend/hermes +SECRET_KEY=local-check uv run alembic upgrade head +``` + +Athena erreicht Lagerdaten ausschliesslich ueber BFF-Routen: + +- `/api/inventory/items` +- `/api/inventory/items/[id]` +- `/api/inventory/items/[id]/stock/adjust` +- `/api/inventory/items/[id]/stock/reserve` +- `/api/inventory/items/[id]/stock/release` +- `/api/inventory/items/[id]/stock/consume` +- `/api/inventory/items/[id]/movements` +- `/api/inventory/categories` +- `/api/inventory/categories/[id]` +- `/api/inventory/locations` +- `/api/inventory/locations/[id]` +- `/api/inventory/suppliers` +- `/api/inventory/suppliers/[id]` + +Hermes-Endpunkte liegen unter `/inventory/...` und sind mit `inventory.*` Permissions geschuetzt. + +Bestandslogik: + +- SKU optional; bei leerer SKU erzeugt Hermes `ET--000001`. +- `quantity_available = quantity_on_hand - quantity_reserved`. +- Bestandsanpassungen, Reservierungen, Freigaben und Verbrauch erzeugen `inventory_stock_movements`. +- Artikel mit Bewegungen werden deaktiviert statt hart geloescht. +- Preise werden intern in cents gespeichert; Athena akzeptiert deutsche Euro-Eingaben wie `1`, `1,50` und `1.50`. + +Folgefeatures: + +- Integration in Kostenvoranschlaege folgt in v0.8.8. +- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.7 nicht aktiv. + Vorbereitete Website-/Portal-Routen fuer spaeter: - `/status/` diff --git a/ROADMAP.md b/ROADMAP.md index 8248ce1..3b10ef2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,7 +147,29 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Keine PDF-Erzeugung, keine Lexoffice-Anbindung und keine Rechnungserstellung in dieser Version - Website-Anzeige/Freigabe wird spaeter in `funktechnik-schubert-website` nachgezogen -## v0.8.7 - Kundenportal, geplant +## v0.8.7 - Lager / Ersatzteile + +- Ersatzteil- und Lagerartikelverwaltung +- Kategorien, Lagerorte und Lieferanten +- SKU-Format `ET--000001` +- Bestandslogik mit Lagerbestand, Reservierung und Verfuegbarkeit +- Bestandsbewegungen fuer Anfangsbestand, Anpassung, Reservierung, Freigabe und Verbrauch +- Preise intern in cents, UI-Eingabe in Euro +- RBAC-Permissions fuer Lagerlesen, Artikelpflege, Bestandsbuchungen und Stammdaten +- Dashboard-Kennzahlen fuer aktive Ersatzteile, niedrigen Bestand, Lagerwert und Bewegungen +- Audit Logs und Activity Feed fuer Lageraktionen +- Athena-BFF-Routen und UI fuer `/inventory` und `/inventory/items/[id]` +- Vorbereitung fuer Kostenvoranschlagsintegration in v0.8.8 +- Keine Lexoffice-Anbindung, keine automatische Bestellung, keine Barcode-/QR-Funktion + +## v0.8.8 - Lagerintegration in Kostenvoranschläge, geplant + +- Lagerartikel als KV-Position ueber Suche auswaehlen +- Verkaufspreis aus `selling_price_cents` uebernehmen +- Reservierungen aus KV-/Reparaturprozess vorbereiten +- CSV-Template und Exportkonzept fuer Lagerdaten vorbereiten + +## v0.8.9 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden diff --git a/backend/hermes/alembic/env.py b/backend/hermes/alembic/env.py index 474acef..3217c11 100644 --- a/backend/hermes/alembic/env.py +++ b/backend/hermes/alembic/env.py @@ -14,6 +14,7 @@ import app.models.audit import app.models.rbac import app.models.repair import app.models.repair_estimate +import app.models.inventory import app.models.system_setting config = context.config diff --git a/backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py b/backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py new file mode 100644 index 0000000..e422b5a --- /dev/null +++ b/backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py @@ -0,0 +1,214 @@ +"""create inventory + +Revision ID: ef8a2d5c9017 +Revises: b4e8f1c7a205 +Create Date: 2026-07-05 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "ef8a2d5c9017" +down_revision: Union[str, Sequence[str], None] = "b4e8f1c7a205" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +INVENTORY_PERMISSIONS = [ + ("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"), + ("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"), + ("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"), + ("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"), + ("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"), + ("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"), + ("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"), + ("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"), +] + + +def upgrade() -> None: + op.create_table( + "inventory_categories", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("slug", sa.String(length=180), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + sa.UniqueConstraint("slug"), + ) + op.create_index(op.f("ix_inventory_categories_name"), "inventory_categories", ["name"], unique=True) + op.create_index(op.f("ix_inventory_categories_slug"), "inventory_categories", ["slug"], unique=True) + + op.create_table( + "inventory_locations", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index(op.f("ix_inventory_locations_name"), "inventory_locations", ["name"], unique=True) + + op.create_table( + "inventory_suppliers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=180), nullable=False), + sa.Column("contact_name", sa.String(length=180), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("phone", sa.String(length=80), nullable=True), + sa.Column("website", sa.String(length=255), nullable=True), + sa.Column("customer_number", sa.String(length=120), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index(op.f("ix_inventory_suppliers_name"), "inventory_suppliers", ["name"], unique=True) + + op.create_table( + "inventory_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("sku", sa.String(length=40), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("category_id", sa.Integer(), nullable=True), + sa.Column("manufacturer", sa.String(length=180), nullable=True), + sa.Column("manufacturer_part_number", sa.String(length=180), nullable=True), + sa.Column("supplier_id", sa.Integer(), nullable=True), + sa.Column("supplier_part_number", sa.String(length=180), nullable=True), + sa.Column("compatible_devices", sa.Text(), nullable=True), + sa.Column("location_id", sa.Integer(), nullable=True), + sa.Column("quantity_on_hand", sa.Integer(), server_default="0", nullable=False), + sa.Column("quantity_reserved", sa.Integer(), server_default="0", nullable=False), + sa.Column("quantity_available", sa.Integer(), server_default="0", nullable=False), + sa.Column("reorder_level", sa.Integer(), server_default="0", nullable=False), + sa.Column("unit", sa.String(length=40), server_default="Stk.", nullable=False), + sa.Column("purchase_price_cents", sa.Integer(), nullable=True), + sa.Column("selling_price_cents", sa.Integer(), nullable=True), + sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False), + sa.Column("tax_rate_percent", sa.Numeric(5, 2), server_default="19.00", nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), server_default="true", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["category_id"], ["inventory_categories.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["location_id"], ["inventory_locations.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["supplier_id"], ["inventory_suppliers.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("sku", name="uq_inventory_items_sku"), + ) + op.create_index(op.f("ix_inventory_items_category_id"), "inventory_items", ["category_id"], unique=False) + op.create_index(op.f("ix_inventory_items_is_active"), "inventory_items", ["is_active"], unique=False) + op.create_index(op.f("ix_inventory_items_location_id"), "inventory_items", ["location_id"], unique=False) + op.create_index(op.f("ix_inventory_items_manufacturer"), "inventory_items", ["manufacturer"], unique=False) + op.create_index(op.f("ix_inventory_items_manufacturer_part_number"), "inventory_items", ["manufacturer_part_number"], unique=False) + op.create_index(op.f("ix_inventory_items_name"), "inventory_items", ["name"], unique=False) + op.create_index(op.f("ix_inventory_items_quantity_available"), "inventory_items", ["quantity_available"], unique=False) + op.create_index(op.f("ix_inventory_items_sku"), "inventory_items", ["sku"], unique=True) + op.create_index(op.f("ix_inventory_items_supplier_id"), "inventory_items", ["supplier_id"], unique=False) + op.create_index(op.f("ix_inventory_items_supplier_part_number"), "inventory_items", ["supplier_part_number"], unique=False) + + op.create_table( + "inventory_stock_movements", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("item_id", sa.Integer(), nullable=False), + sa.Column("movement_type", sa.String(length=40), nullable=False), + sa.Column("quantity", sa.Integer(), nullable=False), + sa.Column("reason", sa.String(length=255), server_default="", nullable=False), + sa.Column("reference_type", sa.String(length=80), nullable=True), + sa.Column("reference_id", sa.Integer(), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["item_id"], ["inventory_items.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_inventory_stock_movements_actor_user_id"), "inventory_stock_movements", ["actor_user_id"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_item_id"), "inventory_stock_movements", ["item_id"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_movement_type"), "inventory_stock_movements", ["movement_type"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_reference_id"), "inventory_stock_movements", ["reference_id"], unique=False) + op.create_index(op.f("ix_inventory_stock_movements_reference_type"), "inventory_stock_movements", ["reference_type"], unique=False) + + for name, display_name, description, module in INVENTORY_PERMISSIONS: + op.execute( + sa.text( + """ + INSERT INTO permissions (name, display_name, description, module) + VALUES (:name, :display_name, :description, :module) + ON CONFLICT (name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + module = excluded.module + """ + ).bindparams(name=name, display_name=display_name, description=description, module=module) + ) + + role_permissions = { + "administrator": [item[0] for item in INVENTORY_PERMISSIONS], + "management": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"], + "technician": ["inventory.read", "inventory.stock.reserve", "inventory.stock.consume"], + "support": ["inventory.read"], + "warehouse": ["inventory.read", "inventory.create", "inventory.update", "inventory.stock.adjust", "inventory.stock.reserve", "inventory.stock.consume", "inventory.manage.masterdata"], + } + for role_name, permission_names in role_permissions.items(): + for permission_name in permission_names: + op.execute( + sa.text( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles, permissions + WHERE roles.name = :role_name + AND permissions.name = :permission_name + ON CONFLICT DO NOTHING + """ + ).bindparams(role_name=role_name, permission_name=permission_name) + ) + + +def downgrade() -> None: + op.execute( + sa.text( + """ + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE module = 'inventory' + ) + """ + ) + ) + op.execute("DELETE FROM permissions WHERE module = 'inventory'") + op.drop_index(op.f("ix_inventory_stock_movements_reference_type"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_reference_id"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_movement_type"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_item_id"), table_name="inventory_stock_movements") + op.drop_index(op.f("ix_inventory_stock_movements_actor_user_id"), table_name="inventory_stock_movements") + op.drop_table("inventory_stock_movements") + op.drop_index(op.f("ix_inventory_items_supplier_part_number"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_supplier_id"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_sku"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_quantity_available"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_name"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_manufacturer_part_number"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_manufacturer"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_location_id"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_is_active"), table_name="inventory_items") + op.drop_index(op.f("ix_inventory_items_category_id"), table_name="inventory_items") + op.drop_table("inventory_items") + op.drop_index(op.f("ix_inventory_suppliers_name"), table_name="inventory_suppliers") + op.drop_table("inventory_suppliers") + op.drop_index(op.f("ix_inventory_locations_name"), table_name="inventory_locations") + op.drop_table("inventory_locations") + op.drop_index(op.f("ix_inventory_categories_slug"), table_name="inventory_categories") + op.drop_index(op.f("ix_inventory_categories_name"), table_name="inventory_categories") + op.drop_table("inventory_categories") diff --git a/backend/hermes/app/__pycache__/main.cpython-314.pyc b/backend/hermes/app/__pycache__/main.cpython-314.pyc index 1be2f4b8297a920a42ba6aa82a4973ff5c5ac09f..fe05feca006e806f2d5e6a4a90952d8a888bb3d3 100644 GIT binary patch literal 7652 zcmcgwTWlN06`ke#C5aL#QEyRCn|_c^qQFY*I*}qdWUNx^T2_%%&SFijrFEz!JG-=F zwMFHofL%0cTaG zGLE!UbM|1F6R=BjG3?5?(;m&kush>T`!pZJo{T>o&;ks5Gr@F73o-1=)TQgSdWQX( zhICkKG;tH)h>4?sn@!vTIA-Ejz-=aO2i#%ePQYCz?grdr;yB=56ZZk`H}L@AK@$%F z-p6ntvp=2C5)2132hs<%gA9ijl?2*VATN7IjKkM)qV zhkMDeIh~Mm_7XnKH}Xw<ahNl5&~A4|WKYJvJsJIbGP?F; zH1El1+mq3;C!=RiM(>`C*q)5REetYUsC^F$^^&tkEsizf;ag&) z5Vlr<_9P#JvQ{k9jzRm&wRRTb?fK%{WA^&dz69)VS)AJatjm5{2lU*z?P*Uvq>aaJ z(*i=t9KFXl90Q5EX_ zte&0|bC8LhD;70Xqhd*r6Wo0Rlmgu6S#^J@6+((j+B#$gb#>Cu9l9G>K zEGqG-!sW$MSxq=~wqNu6Zai2btI-|k> zQfh9Yd`_a;d5OZrr_5od;B9f}sRi~{DX~;aEsC;|EM3+^f*>kN5k7?^2(%M=8-c$H zZVHC?4^qcP?_u)ZJiFb8Vv@rFSyvFiEAYQR4kPI!Jo~-Gt4%s~rnCfA3JSEqh<#)+6PUwz{ zcks?xGQFiIi@>{}JY?l+_v!i?wh-^0bxwz^T%acriaWr`-zuKEIVfD z1O>X(-SAAR={{=enDzld#Yr9-ipS%$1NrUd$Sqk4i*i0+kS>X!!6XolVw+bX`4m>m zpF`pGP}hf{uGLV_^28sTyWhOD+PrVQVc&B4PE+j1(e<_iAGRG>Z9DXC`J=Ys-?~0( zJ^ov9wdv{QQ+Hf}tEaAZbt_yp|&?i);m(S zJ5mUbRsHUUK-Ep$;Z5Rmd#X(&6kVRW7bD^Q|5EWweW!OU&i#}d>vsKY^ccWx+I0Z| zJ`A~k1d_e9%RA=m#h-G3j-9j>nz4BWD<^?LQW6f`DJz=p1QV3tXgky!dS=te#K@8~ zR$whE(#H+n!{ldb6>smUEJwSbnvE9j?7~ILMtcIlGDwT(a9Q&%-pzY>FYn|1e1H$8 zJkyqKGTO0pmhd4ujz&Us0Lx#@*rvAG#CmRPfdo`x-b0T!=dNP!*)8k?;r04$TczTma96-Zvh z| z%D5nCBPO`gX%i&6J69+&U+T!8W7BIYxY2xf!S;%CRGzbx}Q~mXHp=1qzCXhaZGAYM19gnF*$_^S{V9eNNzfqbEM;dRa_lIrfF)~ z{szK=sDSgMbQ(A>BKb#9%Zl*7oLOKY8k{r>b6;;kY83#OwBK zhDoTk5{=!Mx;}L`)Vxxzgqq(_f%@cIPrm-`N~m?MJ@vlxHw_;&thPT5bt|+29x`|1 z!u1Q)prQRf3{MuW^+uDg4mVHo+xZIc+`~I(DOo1=mB03C%*)?Yehr5P^+q~}_T4Q#B zJZza+WyIc$+7b)?w=Gg`K4gv3lvte}y=d>(b`=_Gj6Ms=Ns7L7sh#cbWQmrQ`S^Kh zp)e0yoP`3&PN5m(qO+yXVS*9?VW8aTBqWIdQy9%+!47pXVWFARyO_rxe)Lh{Om>=| zdS>GE>Cv&t31K2TlbLy*;=$cdki#otNjp2 zYYkdDe-*eH_+@A{_BhCFt!3zK^%qO;EdBJGt1ZJ!ZkujDs?1+gS>0H}|43!Jf6EU2 z;UVQUoD(agw6yl#C8o9CJ^v7?*?#X2(E9)9_r3_Jfo}@(-d*JoDJzDF&VMd7G-q%R zrARb>TB5M)M?t{aL?P(Sne5C2pBGvDZYjtwkwRIIMpzyjaIR>l~*ZR zWH=y$9X=d4z#?bw)nw7b48Kn^{@>eAj!%_2=0pXvL3eK10?%kP!HSCB1Nn* z!C}dw%nlz_y=hCvA~sk=DwRYThF)(1i_p5AAix=^42N}+s>cjwt;A|#&lW8ffEQvn zt1@Qwr(RYyX%Tj1ng*+1)mwHHSPksn%6KfOhuF=kWW&#&OWNxcLl)z6L8-YuQH**R zpPMUEDQT@f+&1V_(kH)$3&? zS19KtYqWki0GKoms(gZ=h>LhmxgUED>^N3pvAOOmDuRS7f$doJMq3WH8N@)GVqid@ z#{{h+J%$Nd4JOQ;qV*kLEauAv=>)w26o@m4>Vc5I>f|`?V^aSy3I2t2Y>?g!^7sZh zyg?3Zkj@P<^cRxcAV)UH$d=#n4KlnzPO|cGmhRsm@B`+t4bs0s(w~sVH4^#6Q&$PJ zR6>zTcyQC}@;a8C)ezxa>!j&6X<9k9MtUm5Y37cvk@)Az-HHsZks+IUE7HG420mwY z<-{84+rmAtMhANt$h99i`bEPKGnIowyfuXL|?R=L=w1J0H2 zJBg#Uisv+~!(VzO~8?ZaQF-j@3g| zMAbuf^{v?9RW7;dfWQXpQ&mLOKD&P9@+#MBSNGMbug0##u0>wyyytKkP3xGe zjEW3F%t0(ctU+w%%pjc%3?(eFECJjQHZmE+Zo&xR2XTlqm_VgU*l?)k1go|LDhuL5 zQqCU4t<0dw^Ae<8lkpb2TVipEV}R#M##?;J8L7$H@hOQViAjmYsgpOcdQ27&x0Suc zR$P);Qd)eAy{I%VFEcNFB|{NEQ1@q$5tEbHthrf=^7BiIgn(S0$ra*?i~^HqiMw0! z0{OStGg1?CN-`>Malp(iVgZVS4JZ--u>?WN*dWS^#DE-;$x;$3^&mMvO^#daX+Q@i z7GxHILgyB5VnKmkN|GK_IV(tv87Ov(IXxBP0f8d+ELe_!> bool: return "knowledge.read" in permissions if action.startswith("repairs."): return "repairs.read" in permissions + if action.startswith("inventory."): + return "inventory.read" in permissions if action.startswith("audit_logs."): return "audit_logs.read" in permissions if action.startswith("auth."): diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 2ef835a..c3201b4 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -10,6 +10,7 @@ from app.models.rbac import Role from app.models.audit import AuditLog from app.models.user import User from app.repositories.customer_repository import CustomerRepository +from app.repositories.inventory_repository import InventoryRepository from app.repositories.repair_repository import RepairRepository from app.repositories.repair_estimate_repository import RepairEstimateRepository from app.repositories.user_repository import UserRepository @@ -35,6 +36,7 @@ def get_dashboard_summary( users: list[MetricCard] = [] roles: list[MetricCard] = [] repairs: list[MetricCard] = [] + inventory: list[MetricCard] = [] system_status: list[SystemStatusItem] = [] if "customers.read" in permissions: @@ -82,6 +84,14 @@ def get_dashboard_summary( MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)), ]) + if "inventory.read" in permissions: + inventory = [ + MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)), + MetricCard(label="Niedriger Bestand", value=InventoryRepository.count_low_stock_items(db)), + MetricCard(label="Lagerwert Einkauf", value=InventoryRepository.total_stock_value_cents(db)), + MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))), + ] + if "system_settings.manage" in permissions: smtp_config = SystemSettingsService.get_smtp_runtime_config(db) public_links = SystemSettingsService.get_public_links_settings(db) @@ -127,6 +137,7 @@ def get_dashboard_summary( users=users, roles=roles, repairs=repairs, + inventory=inventory, system_status=system_status, activities=EmptyWidget( title="Letzte Aktivitäten", diff --git a/backend/hermes/app/api/inventory.py b/backend/hermes/app/api/inventory.py new file mode 100644 index 0000000..b09f534 --- /dev/null +++ b/backend/hermes/app/api/inventory.py @@ -0,0 +1,307 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.rbac import require_permission +from app.db.database import get_db +from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier +from app.models.user import User +from app.repositories.inventory_repository import InventoryRepository +from app.schemas.inventory import ( + InventoryCategoryPayload, + InventoryCategoryResponse, + InventoryItemCreate, + InventoryItemListResponse, + InventoryItemResponse, + InventoryItemUpdate, + InventoryLocationPayload, + InventoryLocationResponse, + InventoryStockAction, + InventoryStockMovementResponse, + InventorySupplierPayload, + InventorySupplierResponse, +) +from app.services.inventory_service import InventoryService + +router = APIRouter(prefix="/inventory", tags=["Inventory"]) + + +def get_item_or_404(db: Session, item_id: int) -> InventoryItem: + item = InventoryRepository.get_item(db, item_id) + if item is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden") + return item + + +def get_category_or_404(db: Session, category_id: int) -> InventoryCategory: + category = InventoryRepository.get_category(db, category_id) + if category is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kategorie nicht gefunden") + return category + + +def get_location_or_404(db: Session, location_id: int) -> InventoryLocation: + location = InventoryRepository.get_location(db, location_id) + if location is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerort nicht gefunden") + return location + + +def get_supplier_or_404(db: Session, supplier_id: int) -> InventorySupplier: + supplier = InventoryRepository.get_supplier(db, supplier_id) + if supplier is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lieferant nicht gefunden") + return supplier + + +@router.get("/items", response_model=InventoryItemListResponse) +def list_items( + q: str | None = None, + category_id: int | None = None, + supplier_id: int | None = None, + location_id: int | None = None, + low_stock: bool | None = None, + active: bool | None = True, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + items, total = InventoryRepository.list_items( + db, + q=q, + category_id=category_id, + supplier_id=supplier_id, + location_id=location_id, + low_stock=low_stock, + active=active, + limit=limit, + offset=offset, + ) + return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset) + + +@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED) +def create_item( + payload: InventoryItemCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.create")), +): + return InventoryService.create_item(db, payload, actor=current_user, request=request) + + +@router.get("/items/{item_id}", response_model=InventoryItemResponse) +def get_item( + item_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return get_item_or_404(db, item_id) + + +@router.put("/items/{item_id}", response_model=InventoryItemResponse) +def update_item( + item_id: int, + payload: InventoryItemUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.update")), +): + item = get_item_or_404(db, item_id) + return InventoryService.update_item(db, item, payload, actor=current_user, request=request) + + +@router.delete("/items/{item_id}", response_model=InventoryItemResponse | None) +def delete_item( + item_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.delete")), +): + item = get_item_or_404(db, item_id) + return InventoryService.delete_item(db, item, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/adjust", response_model=InventoryItemResponse) +def adjust_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.adjust")), +): + item = get_item_or_404(db, item_id) + return InventoryService.adjust_stock(db, item, payload, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/reserve", response_model=InventoryItemResponse) +def reserve_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.reserve")), +): + item = get_item_or_404(db, item_id) + return InventoryService.reserve_stock(db, item, payload, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/release", response_model=InventoryItemResponse) +def release_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.reserve")), +): + item = get_item_or_404(db, item_id) + return InventoryService.release_stock(db, item, payload, actor=current_user, request=request) + + +@router.post("/items/{item_id}/stock/consume", response_model=InventoryItemResponse) +def consume_stock( + item_id: int, + payload: InventoryStockAction, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.stock.consume")), +): + item = get_item_or_404(db, item_id) + return InventoryService.consume_stock(db, item, payload, actor=current_user, request=request) + + +@router.get("/items/{item_id}/movements", response_model=list[InventoryStockMovementResponse]) +def list_movements( + item_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + get_item_or_404(db, item_id) + return InventoryRepository.list_movements(db, item_id) + + +@router.get("/categories", response_model=list[InventoryCategoryResponse]) +def list_categories( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.list_categories(db) + + +@router.post("/categories", response_model=InventoryCategoryResponse, status_code=status.HTTP_201_CREATED) +def create_category( + payload: InventoryCategoryPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + return InventoryService.create_category(db, payload, actor=current_user, request=request) + + +@router.put("/categories/{category_id}", response_model=InventoryCategoryResponse) +def update_category( + category_id: int, + payload: InventoryCategoryPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + category = get_category_or_404(db, category_id) + return InventoryService.update_category(db, category, payload, actor=current_user, request=request) + + +@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_category( + category_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + category = get_category_or_404(db, category_id) + InventoryService.delete_master(db, category, action="inventory.categories.delete", actor=current_user, request=request) + return None + + +@router.get("/locations", response_model=list[InventoryLocationResponse]) +def list_locations( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.list_locations(db) + + +@router.post("/locations", response_model=InventoryLocationResponse, status_code=status.HTTP_201_CREATED) +def create_location( + payload: InventoryLocationPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + return InventoryService.create_location(db, payload, actor=current_user, request=request) + + +@router.put("/locations/{location_id}", response_model=InventoryLocationResponse) +def update_location( + location_id: int, + payload: InventoryLocationPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + location = get_location_or_404(db, location_id) + return InventoryService.update_location(db, location, payload, actor=current_user, request=request) + + +@router.delete("/locations/{location_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_location( + location_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + location = get_location_or_404(db, location_id) + InventoryService.delete_master(db, location, action="inventory.locations.delete", actor=current_user, request=request) + return None + + +@router.get("/suppliers", response_model=list[InventorySupplierResponse]) +def list_suppliers( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.list_suppliers(db) + + +@router.post("/suppliers", response_model=InventorySupplierResponse, status_code=status.HTTP_201_CREATED) +def create_supplier( + payload: InventorySupplierPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + return InventoryService.create_supplier(db, payload, actor=current_user, request=request) + + +@router.put("/suppliers/{supplier_id}", response_model=InventorySupplierResponse) +def update_supplier( + supplier_id: int, + payload: InventorySupplierPayload, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + supplier = get_supplier_or_404(db, supplier_id) + return InventoryService.update_supplier(db, supplier, payload, actor=current_user, request=request) + + +@router.delete("/suppliers/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_supplier( + supplier_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.manage.masterdata")), +): + supplier = get_supplier_or_404(db, supplier_id) + InventoryService.delete_master(db, supplier, action="inventory.suppliers.delete", actor=current_user, request=request) + return None diff --git a/backend/hermes/app/core/__pycache__/config.cpython-314.pyc b/backend/hermes/app/core/__pycache__/config.cpython-314.pyc index c828751b860cd78a66d001c011d760d477f62488..c789c9b8e7eee45bf84816e59812ef690bc07108 100644 GIT binary patch literal 3397 zcmaJ^O>7&-6`m!R6h%=YDgH^K{zXxtER*_koH}k=H-#ZNiPWxBA!s8lR^&?BXt~SI z4rNQ|flUk4KoOvq0J@~`p#hR(jy?2PphvP$Phh7IYZZl=wBX~BA1Ic3Uu8@)_ANf>QE!^Yp0@u(t<1`uC7t2I;WJ7Qi@G)5jG0syr=0Z52z0rRd{e$-9weYMclwL#@UILh(Kcr zoaX^<3k|YGc6_h>nve=eN6C+c13?n*3qON$LM-_pM*r8CJ065OsH+waQkB)IL2(&) zeGuMNRT%G=<+83=Rt1VcmMxqz-QeFV{lrqKRkAwn@2ONZ%B4;HxLT`QrCp_bs2Y`$ zo2HUdtCh;&Isn#Fv3ATOvaA?}$rPr_GEaHQE_REr!Mm(1z&8!@oP+~u8{vzUr#Sp^ z#o^9DP0F1jK-x=1==}GUzp>=Iry%E$m5+_k$2$$$~A=@@bpE!$ez-X zr#!BzJe2D--Bc=ab(d$pHNT{QJq&A>W>`!y$|@Ie13~65>lRIc1I||F^K=+!J_cK) zAIrMBuau83UTb*`A%C$y|JSa7%kBU)$g?1cjhyEHko#M7?yu3gc4GL^J5Tz?AMc&@ zEi{+b|FU(qbiX~n2;`UZ_a5DCCi1Pw+>^fP&+=z|i_IGwP3gU}8}GG?t3ZEQ+(e7{ zR^-}~XzEcv9feoCy!@N6b1yId62ZcM-6si{{SNKyM2Gfw$;A#WfPJ7#9_*rfJX#7Z zKu7m}Fr)WMP54SppQnNOkuJKwiyr8rqa7MOjCIN5UGhOs{y!cjuGA#2)TFvrPG4!2 zxl%K9rDoXEKrAC2+6QzNX*eaL6wV^y5$vtbF{x9Xhb^@X9ZNn`k9i2HQZ|&T%KMNy zg3hFw2G1(xvT9i}GocyD>cg5wRk^Aebq4J&{J{ezYnD}4Deu+IeW?8-Ro~!g&Cr;p z=(17)Q@IXp&RIEeS*}(UO@Gn227U5@Nh^HlvhkiqE#^9yxvYWC+EK*3(_N$HkycSD z9fzRlB*Fy36adefZikhrT2nMCYX(yeRX0LDR;%yoTG`XV!Dn^LZ7i6vUaQJ#@_}iw z4p}oP^mj*k5vI#t1QK<$d(^DTZo~uM3Y@e=yy-I2t%9HXEk?N?+~VS{Y3cHWMU+iXo*=nj?5q#n|z%6ZYNNXBqL*|?30m} zIB%y=knXa`pkRnhOt-|mJ&eo<8J}v2vvwAlQIdgqGxiuV<76n?5*O?VWG2bz1Zt*` znT8cx;)*?kOs=!QEHZg;`Z4jISVUMtSVmYuz{}UmN}Jw5#j6N65ne;UPcOZN@H)b6gzq9?)$oXWk998< z6fdQ`&$+8qRd!%j=qyU&T{nuox>3g42Ha!dY`{rT7^B)Q_5%=u<;6{>tm?Wf7X-I( zp*qR;-MZ}sk|H>D`)44aZX4ujh@_?(YwgT<wQT*kE)SDWHgdvp>OOAP@` z<{Iy~Fr9B~wnr8l8|~q-#@k;dX4~;hdm#NR;7-YIiEE?K%Y*E=27d&o2;uQi?YN>CkS#aEagUX~+W`$?PB0(N z0z7n~xPvnMSi6^u&~3M@-k_Tx!=zd{fTw;z5dJ~Z|0cPAk~f|Oi$eO*FP;$?>|KFG MlE1z6VaQqKzlL{YZU6uP delta 624 zcmYjN&ubGw6n?Y6^P|DVO*cqe6AI!YG>AnZmC%Ehh&953y=_Cfnba-YP1%_QDuSUN z6g-tZd-3K;{3HAWa*KNrFG4*T8_=U~wiFzg_vV}TzVCZ8U#|VsOSei3dB9cFw+?QT z(^BPO1JM|E!6lENML20mT)GZrsDUfl;L09ZmjwfW%br|=^$P%8ag|kY)H8uk-#6MV z%w3f7fD;$Ew5xD=5e6uXtrAx~4JBn)an*a$g$CDm;0|#$8$yE{uFi8i!1Jzwvbp3l zS>AY|tkGz+2Te!=5)GIZ&H96jIKFS>ovx ztJv%E?w()|)4l&Dcyr}olZ zl`U<&{bvDvUqftNQrnK2nn%aIZo~s0|Bdq?+Gnx&rfiJNBsE0gNmeO~N=6qmq{e+5 oa~RTQTtz5`W6KzR$%K$^(41>IqP=}N2TZdH6s>oS6Cmb&!qtjBt{@EPM`deIY9n4gVb#X>DvtAw;9B4Gf3QKP`u5c@P&<)QMgDLs0sid Cg>feU delta 157 zcmbQub(n)sn~#@^0SI{5-Lif$PUMqdoH0>-B3lr*I712V#&7J5%0Bv`#i>Qb`e~^} zsd>qI#mO0^NvTC8`Yx$usX6%txv6<2n}wM~85yM}YchK=@=Y#b&NC_nYPrRnSWr+T v2{gQj7eoky2oWH0i^C>2KczG$)vibp$YliL;-JZESOU1du(2`CzBNRa)3W(S$MI8{0q@7H6r*vR;c6;Y8wAF`= znt&lqEJ)BdAN9?@!#5))%Op)Ch6mnQOVl?#=g#i3DW)V&)6?JmopaBed(ZFQ>Apl< zBk)}R>Gb6f6+(W;LGuYUz}D9Qs$`z%!VH-vjEo=_XM|~yi5yFSB_?q!1D2W0aR_jT zg*a9KD@@@yJQJQ)naZ&WIKmoZBVTeVMNBK7G(9UHR1#R&I{s`u76>t6v*rjBbi%~> zB$Ikc3AT>Ob0JuWs7r?-1|Oy%GZ!8s^M$Dfr_1mYI^0~Q`rL>}E8;*ahX`*`S~;{< z#6N8j;lpC1is-ZcW*m#Pd{48hy4qG@oJS;JWs&AAc)Qlho}5cz`}CL|Z*d)bPA9}A z+A?)TI`TWU4XLQ?0D&|1p=L*<&y1kZOV?hw(84_R#5sX9)Y!z z3z&96ct+%Ze6{3O%6LTbD6@vthzuBowsjVmV?vc&AoJppF9<0BJ4oIamV_KRCtSo| zVShhFHlCxRF6;)}@QK@e;toD(2cNuyFSLVC*})fX^KD;)+UAqCts&CplehV_9eh#r zfh1y&@Ob&6UGTL7U7Ng7-OSA|x;)aoj zD!hW7XNKWnnd4Re^&BeL%X#^w%7R7NqD3t?pL^F?EtV@@Zo$l7vD`v#$)ZKe%fWq^ zE0zkDNb*Z>J^HhJs2(PQ zP}fLDdc(UDtrIz<)T2oCl2q?Te*KexN+UH+l06&e)~^KA1X5WN>)e=FKUyaeOw1v5 zgmm|RJ8=gn5vW&?IxdiC$41xszF_7F0m(B0Npx?#v3@a_JIl$B1$=E`{nH@wA}1FG zEN|u4)MyZTiL>Q47OMVZ8QCpZBl&@x%?$dg!MhIop&173R|Q&W;7%=9Ob1&JqoiSQ zeK>HS-4YPcA7(B%R^X>m$VNxdgwc$m8ACIUW&+J5nitS0Xi$MP0>+OuZy^*B#p6!* zpurD2RWU+xOV{XL9ApfIG`Cxe8eKm2zyBV#eI&0~^Ct-O+ zslHv0k!KylN_7StLoebCdQ+<w3|yCsR^ zN_DoblD^%|45{8)dI&P~Db-VuVK8%F?fmN1U({ht9Fhi=>RXVad-OZy`;YE*y<9y5 z_14s6E!7>&gIckq$(Qb{sc$ClsUx-Yz}=NwE4SBR_9QMu`!8p$qv!6fTf1)e!>cjs zFD{Ejy-@U$Tm;K@)lZc(1u8cqbE2gR*eYxd8Zmz!l^t;mBqS z3t9LGm<#aV3yn2jLk}Y2Z^fqh>ajqnuhA1Q#BO@XKb8bR*d#*_$?zr_*(76tH_5;z Y*~9VPhh+4La!gQeO#KPp5``!H7twoEg#Z8m delta 677 zcmZWlO=}ZD7=CAFcXpaj8`7l2hGdDNtB@QF6!C+CS`#c4w%Cg#ut`=M&6l%NK`$y^ zq|iecJb3da{sjNZLV6SItx?gV&a4Cvox?lN^UOQX`|uuRKgCj)Q^^>pldv|QU_4E& zdRvOUhBmmU2@NDrNn_l^4I)T{iNc03B5X9x1{JgfD`10b=E0>cS|{2OKv=F-0f!xH z;-HZ32;Lt(maFJ$=A)84HKzpvxgc&^50&>y!Y2K0 zE9r+afX}e)q-4vwQ!o|dX str: + return self.actor.username if self.actor is not None else "" diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 12490a9..1191da6 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -90,6 +90,14 @@ STANDARD_PERMISSIONS = [ ("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"), ("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"), ("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"), + ("inventory.read", "Lager lesen", "Ersatzteile und Lagerdaten anzeigen", "inventory"), + ("inventory.create", "Lagerartikel erstellen", "Ersatzteile anlegen", "inventory"), + ("inventory.update", "Lagerartikel bearbeiten", "Ersatzteile aktualisieren", "inventory"), + ("inventory.delete", "Lagerartikel deaktivieren", "Ersatzteile entfernen oder deaktivieren", "inventory"), + ("inventory.stock.adjust", "Bestand anpassen", "Lagerbestand korrigieren", "inventory"), + ("inventory.stock.reserve", "Bestand reservieren", "Lagerbestand reservieren oder freigeben", "inventory"), + ("inventory.stock.consume", "Bestand verbrauchen", "Lagerbestand verbuchen", "inventory"), + ("inventory.manage.masterdata", "Lagerstammdaten verwalten", "Kategorien, Lagerorte und Lieferanten verwalten", "inventory"), ] ROLE_PERMISSION_NAMES = { @@ -111,6 +119,13 @@ ROLE_PERMISSION_NAMES = { "repair_estimates.create", "repair_estimates.update", "repair_estimates.send", + "inventory.read", + "inventory.create", + "inventory.update", + "inventory.stock.adjust", + "inventory.stock.reserve", + "inventory.stock.consume", + "inventory.manage.masterdata", }, "sales": { "dashboard.read", @@ -138,6 +153,9 @@ ROLE_PERMISSION_NAMES = { "repair_estimates.create", "repair_estimates.update", "repair_estimates.send", + "inventory.read", + "inventory.stock.reserve", + "inventory.stock.consume", }, "support": { "dashboard.read", @@ -156,9 +174,17 @@ ROLE_PERMISSION_NAMES = { "repairs.public_link.manage", "repair_estimates.read", "repair_estimates.send", + "inventory.read", }, "warehouse": { "dashboard.read", + "inventory.read", + "inventory.create", + "inventory.update", + "inventory.stock.adjust", + "inventory.stock.reserve", + "inventory.stock.consume", + "inventory.manage.masterdata", }, "guest": { "dashboard.read", diff --git a/backend/hermes/app/repositories/inventory_repository.py b/backend/hermes/app/repositories/inventory_repository.py new file mode 100644 index 0000000..ff2fe8e --- /dev/null +++ b/backend/hermes/app/repositories/inventory_repository.py @@ -0,0 +1,187 @@ +from datetime import UTC, datetime + +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session, selectinload + +from app.models.inventory import ( + InventoryCategory, + InventoryItem, + InventoryLocation, + InventoryStockMovement, + InventorySupplier, +) + + +class InventoryRepository: + @staticmethod + def item_query() -> Select[tuple[InventoryItem]]: + return select(InventoryItem).options( + selectinload(InventoryItem.category), + selectinload(InventoryItem.supplier), + selectinload(InventoryItem.location), + ) + + @staticmethod + def list_items( + db: Session, + *, + q: str | None = None, + category_id: int | None = None, + supplier_id: int | None = None, + location_id: int | None = None, + low_stock: bool | None = None, + active: bool | None = True, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[InventoryItem], int]: + query = InventoryRepository.item_query() + if q: + pattern = f"%{q.strip()}%" + query = query.where(or_( + InventoryItem.sku.ilike(pattern), + InventoryItem.name.ilike(pattern), + InventoryItem.manufacturer.ilike(pattern), + InventoryItem.manufacturer_part_number.ilike(pattern), + InventoryItem.supplier_part_number.ilike(pattern), + )) + if category_id is not None: + query = query.where(InventoryItem.category_id == category_id) + if supplier_id is not None: + query = query.where(InventoryItem.supplier_id == supplier_id) + if location_id is not None: + query = query.where(InventoryItem.location_id == location_id) + if low_stock is not None: + if low_stock: + query = query.where(InventoryItem.quantity_available <= InventoryItem.reorder_level) + else: + query = query.where(InventoryItem.quantity_available > InventoryItem.reorder_level) + if active is not None: + query = query.where(InventoryItem.is_active == active) + + total = db.scalar(select(func.count()).select_from(query.subquery())) or 0 + items = list( + db.scalars( + query + .order_by(InventoryItem.name.asc(), InventoryItem.id.asc()) + .offset(offset) + .limit(limit) + ) + ) + return items, total + + @staticmethod + def get_item(db: Session, item_id: int) -> InventoryItem | None: + return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id)) + + @staticmethod + def get_item_by_sku(db: Session, sku: str) -> InventoryItem | None: + return db.scalar(select(InventoryItem).where(InventoryItem.sku == sku)) + + @staticmethod + def get_next_sku(db: Session, year: int | None = None) -> str: + current_year = year or datetime.now(UTC).year + prefix = f"ET-{current_year}-" + latest = db.scalar( + select(InventoryItem.sku) + .where(InventoryItem.sku.like(f"{prefix}%")) + .order_by(InventoryItem.sku.desc()) + .limit(1) + ) + next_number = 1 + if latest: + next_number = int(latest.split("-")[-1]) + 1 + return f"{prefix}{next_number:06d}" + + @staticmethod + def list_categories(db: Session) -> list[InventoryCategory]: + return list(db.scalars(select(InventoryCategory).order_by(InventoryCategory.name.asc()))) + + @staticmethod + def get_category(db: Session, category_id: int) -> InventoryCategory | None: + return db.get(InventoryCategory, category_id) + + @staticmethod + def list_locations(db: Session) -> list[InventoryLocation]: + return list(db.scalars(select(InventoryLocation).order_by(InventoryLocation.name.asc()))) + + @staticmethod + def get_location(db: Session, location_id: int) -> InventoryLocation | None: + return db.get(InventoryLocation, location_id) + + @staticmethod + def list_suppliers(db: Session) -> list[InventorySupplier]: + return list(db.scalars(select(InventorySupplier).order_by(InventorySupplier.name.asc()))) + + @staticmethod + def get_supplier(db: Session, supplier_id: int) -> InventorySupplier | None: + return db.get(InventorySupplier, supplier_id) + + @staticmethod + def has_movements(db: Session, item_id: int) -> bool: + return bool(db.scalar(select(InventoryStockMovement.id).where(InventoryStockMovement.item_id == item_id).limit(1))) + + @staticmethod + def list_movements(db: Session, item_id: int) -> list[InventoryStockMovement]: + return list( + db.scalars( + select(InventoryStockMovement) + .where(InventoryStockMovement.item_id == item_id) + .order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc()) + ) + ) + + @staticmethod + def create_movement( + db: Session, + *, + item_id: int, + movement_type: str, + quantity: int, + reason: str, + reference_type: str | None, + reference_id: int | None, + note: str | None, + actor_user_id: int | None, + ) -> InventoryStockMovement: + movement = InventoryStockMovement( + item_id=item_id, + movement_type=movement_type, + quantity=quantity, + reason=reason, + reference_type=reference_type, + reference_id=reference_id, + note=note, + actor_user_id=actor_user_id, + ) + db.add(movement) + return movement + + @staticmethod + def count_active_items(db: Session) -> int: + return db.scalar(select(func.count(InventoryItem.id)).where(InventoryItem.is_active.is_(True))) or 0 + + @staticmethod + def count_low_stock_items(db: Session) -> int: + return db.scalar( + select(func.count(InventoryItem.id)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.quantity_available <= InventoryItem.reorder_level) + ) or 0 + + @staticmethod + def total_stock_value_cents(db: Session) -> int: + return db.scalar( + select(func.coalesce(func.sum(InventoryItem.quantity_on_hand * InventoryItem.purchase_price_cents), 0)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.purchase_price_cents.is_not(None)) + ) or 0 + + @staticmethod + def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]: + return list( + db.scalars( + select(InventoryStockMovement) + .order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc()) + .limit(limit) + ) + ) diff --git a/backend/hermes/app/schemas/dashboard.py b/backend/hermes/app/schemas/dashboard.py index 047f18e..8eae677 100644 --- a/backend/hermes/app/schemas/dashboard.py +++ b/backend/hermes/app/schemas/dashboard.py @@ -25,6 +25,7 @@ class DashboardSummary(BaseModel): users: list[MetricCard] = [] roles: list[MetricCard] = [] repairs: list[MetricCard] = [] + inventory: list[MetricCard] = [] system_status: list[SystemStatusItem] = [] activities: EmptyWidget tasks: EmptyWidget diff --git a/backend/hermes/app/schemas/inventory.py b/backend/hermes/app/schemas/inventory.py new file mode 100644 index 0000000..093fb55 --- /dev/null +++ b/backend/hermes/app/schemas/inventory.py @@ -0,0 +1,231 @@ +from datetime import datetime +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +InventoryMovementType = Literal[ + "initial", + "purchase", + "adjustment", + "reservation", + "release", + "consumption", + "return", + "correction", +] + + +def normalize_text(value: object) -> str: + if value is None: + return "" + return str(value).strip() + + +def normalize_optional_text(value: object) -> str | None: + text = normalize_text(value) + return text or None + + +class InventoryCategoryPayload(BaseModel): + name: str = Field(min_length=1, max_length=160) + description: str | None = None + + @field_validator("name", "description", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class InventoryLocationPayload(BaseModel): + name: str = Field(min_length=1, max_length=160) + description: str | None = None + + @field_validator("name", "description", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class InventorySupplierPayload(BaseModel): + name: str = Field(min_length=1, max_length=180) + contact_name: str | None = None + email: str | None = None + phone: str | None = None + website: str | None = None + customer_number: str | None = None + notes: str | None = None + + @field_validator("*", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_optional_text(value) + + +class InventoryItemPayload(BaseModel): + sku: str | None = Field(default=None, max_length=40) + name: str = Field(min_length=1, max_length=255) + description: str | None = None + category_id: int | None = None + manufacturer: str | None = None + manufacturer_part_number: str | None = None + supplier_id: int | None = None + supplier_part_number: str | None = None + compatible_devices: str | None = None + location_id: int | None = None + quantity_on_hand: int = Field(default=0, ge=0) + quantity_reserved: int = Field(default=0, ge=0) + reorder_level: int = Field(default=0, ge=0) + unit: str = Field(default="Stk.", max_length=40) + purchase_price_cents: int | None = Field(default=None, ge=0) + selling_price_cents: int | None = Field(default=None, ge=0) + currency: str = Field(default="EUR", max_length=3) + tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0")) + notes: str | None = None + is_active: bool = True + + @field_validator( + "sku", + "name", + "description", + "manufacturer", + "manufacturer_part_number", + "supplier_part_number", + "compatible_devices", + "unit", + "currency", + "notes", + mode="before", + ) + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + @field_validator("currency") + @classmethod + def normalize_currency(cls, value: str) -> str: + return value.upper() or "EUR" + + @field_validator("tax_rate_percent", mode="before") + @classmethod + def normalize_tax_rate(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().replace(",", ".") + return value + + @model_validator(mode="after") + def validate_reserved_quantity(self): + if self.quantity_reserved > self.quantity_on_hand: + raise ValueError("Reservierter Bestand darf den Lagerbestand nicht überschreiten") + return self + + +class InventoryItemCreate(InventoryItemPayload): + pass + + +class InventoryItemUpdate(InventoryItemPayload): + pass + + +class InventoryStockAction(BaseModel): + quantity: int = Field(ge=0) + reason: str = Field(min_length=1, max_length=255) + note: str | None = None + reference_type: str | None = Field(default=None, max_length=80) + reference_id: int | None = None + + @field_validator("reason", "note", "reference_type", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class InventoryCategoryResponse(InventoryCategoryPayload): + id: int + slug: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class InventoryLocationResponse(InventoryLocationPayload): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class InventorySupplierResponse(InventorySupplierPayload): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class InventoryItemResponse(BaseModel): + id: int + sku: str + name: str + description: str | None + category_id: int | None + manufacturer: str | None + manufacturer_part_number: str | None + supplier_id: int | None + supplier_part_number: str | None + compatible_devices: str | None + location_id: int | None + quantity_on_hand: int + quantity_reserved: int + quantity_available: int + reorder_level: int + unit: str + purchase_price_cents: int | None + selling_price_cents: int | None + currency: str + tax_rate_percent: Decimal + notes: str | None + is_active: bool + created_at: datetime + updated_at: datetime + category: InventoryCategoryResponse | None + supplier: InventorySupplierResponse | None + location: InventoryLocationResponse | None + + model_config = ConfigDict(from_attributes=True) + + +class InventoryItemListResponse(BaseModel): + items: list[InventoryItemResponse] + total: int + limit: int + offset: int + + +class InventoryStockMovementResponse(BaseModel): + id: int + item_id: int + movement_type: str + quantity: int + reason: str + reference_type: str | None + reference_id: int | None + note: str | None + actor_user_id: int | None + actor_username: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 204e17c..d46ef66 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -180,6 +180,23 @@ def action_title(action: str) -> str: "repair_estimates.question": "Rückfrage zum Kostenvoranschlag", "repair_estimates.cancel": "Kostenvoranschlag storniert", "repair_estimates.delete": "Kostenvoranschlag gelöscht", + "inventory.items.create": "Lagerartikel erstellt", + "inventory.items.update": "Lagerartikel geändert", + "inventory.items.deactivate": "Lagerartikel deaktiviert", + "inventory.items.delete": "Lagerartikel gelöscht", + "inventory.stock.adjust": "Bestand angepasst", + "inventory.stock.reserve": "Bestand reserviert", + "inventory.stock.release": "Reservierung aufgehoben", + "inventory.stock.consume": "Bestand verbraucht", + "inventory.categories.create": "Lagerkategorie erstellt", + "inventory.categories.update": "Lagerkategorie geändert", + "inventory.categories.delete": "Lagerkategorie gelöscht", + "inventory.locations.create": "Lagerort erstellt", + "inventory.locations.update": "Lagerort geändert", + "inventory.locations.delete": "Lagerort gelöscht", + "inventory.suppliers.create": "Lieferant erstellt", + "inventory.suppliers.update": "Lieferant geändert", + "inventory.suppliers.delete": "Lieferant gelöscht", "system_settings.smtp.update": "SMTP-Konfiguration geändert", "system_settings.smtp.test_sent": "SMTP-Testmail versendet", "system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen", diff --git a/backend/hermes/app/services/inventory_service.py b/backend/hermes/app/services/inventory_service.py new file mode 100644 index 0000000..4960557 --- /dev/null +++ b/backend/hermes/app/services/inventory_service.py @@ -0,0 +1,318 @@ +import re +import unicodedata +from typing import TypeVar + +from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier +from app.models.user import User +from app.repositories.inventory_repository import InventoryRepository +from app.schemas.inventory import ( + InventoryCategoryPayload, + InventoryItemCreate, + InventoryItemUpdate, + InventoryLocationPayload, + InventoryStockAction, + InventorySupplierPayload, +) +from app.services.audit_service import write_audit_log + +MasterModel = TypeVar("MasterModel", InventoryCategory, InventoryLocation, InventorySupplier) + + +def _slugify(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[^a-zA-Z0-9]+", "-", normalized).strip("-").lower() + return slug or "eintrag" + + +def _item_label(item: InventoryItem) -> str: + return f"{item.sku} · {item.name}" + + +def _audit_item_data(item: InventoryItem) -> dict: + return { + "id": item.id, + "sku": item.sku, + "name": item.name, + "category_id": item.category_id, + "supplier_id": item.supplier_id, + "location_id": item.location_id, + "quantity_on_hand": item.quantity_on_hand, + "quantity_reserved": item.quantity_reserved, + "quantity_available": item.quantity_available, + "reorder_level": item.reorder_level, + "selling_price_cents": item.selling_price_cents, + "currency": item.currency, + "is_active": item.is_active, + } + + +class InventoryService: + @staticmethod + def create_item(db: Session, payload: InventoryItemCreate, *, actor: User, request: Request) -> InventoryItem: + sku = (payload.sku or "").upper() or InventoryRepository.get_next_sku(db) + InventoryService._validate_sku_available(db, sku) + item = InventoryItem(sku=sku) + InventoryService._apply_item_payload(item, payload) + try: + db.add(item) + db.flush() + if item.quantity_on_hand > 0: + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="initial", + quantity=item.quantity_on_hand, + reason="Anfangsbestand", + reference_type=None, + reference_id=None, + note=None, + actor_user_id=actor.id, + ) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None + db.refresh(item) + item = InventoryRepository.get_item(db, item.id) or item + write_audit_log( + db, + action="inventory.items.create", + entity_type="inventory_items", + entity_id=item.id, + entity_label=_item_label(item), + actor=actor, + request=request, + after_data=_audit_item_data(item), + ) + return item + + @staticmethod + def update_item(db: Session, item: InventoryItem, payload: InventoryItemUpdate, *, actor: User, request: Request) -> InventoryItem: + before_data = _audit_item_data(item) + sku = (payload.sku or "").upper() or item.sku + InventoryService._validate_sku_available(db, sku, item_id=item.id) + item.sku = sku + InventoryService._apply_item_payload(item, payload) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") from None + db.refresh(item) + updated = InventoryRepository.get_item(db, item.id) or item + write_audit_log( + db, + action="inventory.items.update", + entity_type="inventory_items", + entity_id=updated.id, + entity_label=_item_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_item_data(updated), + ) + return updated + + @staticmethod + def delete_item(db: Session, item: InventoryItem, *, actor: User, request: Request) -> InventoryItem | None: + before_data = _audit_item_data(item) + label = _item_label(item) + if InventoryRepository.has_movements(db, item.id): + item.is_active = False + InventoryService._recalculate_available(item) + db.commit() + db.refresh(item) + write_audit_log( + db, + action="inventory.items.deactivate", + entity_type="inventory_items", + entity_id=item.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_item_data(item), + ) + return InventoryRepository.get_item(db, item.id) or item + + db.delete(item) + db.commit() + write_audit_log( + db, + action="inventory.items.delete", + entity_type="inventory_items", + entity_id=item.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + ) + return None + + @staticmethod + def adjust_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + before_data = _audit_item_data(item) + item.quantity_on_hand = payload.quantity + if item.quantity_reserved > item.quantity_on_hand: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Reservierter Bestand darf den Lagerbestand nicht überschreiten") + InventoryService._add_movement(db, item, "adjustment", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.adjust", actor, request) + + @staticmethod + def reserve_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + InventoryService._validate_positive_stock_quantity(payload) + if payload.quantity > item.quantity_available: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend verfügbarer Bestand") + before_data = _audit_item_data(item) + item.quantity_reserved += payload.quantity + InventoryService._add_movement(db, item, "reservation", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.reserve", actor, request) + + @staticmethod + def release_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + InventoryService._validate_positive_stock_quantity(payload) + if payload.quantity > item.quantity_reserved: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Mehr Bestand freizugeben als reserviert ist nicht möglich") + before_data = _audit_item_data(item) + item.quantity_reserved -= payload.quantity + InventoryService._add_movement(db, item, "release", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.release", actor, request) + + @staticmethod + def consume_stock(db: Session, item: InventoryItem, payload: InventoryStockAction, *, actor: User, request: Request) -> InventoryItem: + InventoryService._validate_positive_stock_quantity(payload) + if payload.quantity > item.quantity_on_hand: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genügend Lagerbestand") + before_data = _audit_item_data(item) + item.quantity_on_hand -= payload.quantity + item.quantity_reserved = max(0, item.quantity_reserved - payload.quantity) + InventoryService._add_movement(db, item, "consumption", payload, actor_user_id=actor.id) + return InventoryService._commit_stock_action(db, item, before_data, "inventory.stock.consume", actor, request) + + @staticmethod + def create_category(db: Session, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory: + category = InventoryCategory(name=payload.name, slug=_slugify(payload.name), description=payload.description) + return InventoryService._save_master(db, category, "inventory.categories.create", actor, request) + + @staticmethod + def update_category(db: Session, category: InventoryCategory, payload: InventoryCategoryPayload, *, actor: User, request: Request) -> InventoryCategory: + before_data = {"id": category.id, "name": category.name, "slug": category.slug} + category.name = payload.name + category.slug = _slugify(payload.name) + category.description = payload.description + return InventoryService._save_master(db, category, "inventory.categories.update", actor, request, before_data=before_data) + + @staticmethod + def create_location(db: Session, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation: + location = InventoryLocation(name=payload.name, description=payload.description) + return InventoryService._save_master(db, location, "inventory.locations.create", actor, request) + + @staticmethod + def update_location(db: Session, location: InventoryLocation, payload: InventoryLocationPayload, *, actor: User, request: Request) -> InventoryLocation: + before_data = {"id": location.id, "name": location.name} + location.name = payload.name + location.description = payload.description + return InventoryService._save_master(db, location, "inventory.locations.update", actor, request, before_data=before_data) + + @staticmethod + def create_supplier(db: Session, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier: + supplier = InventorySupplier(**payload.model_dump()) + return InventoryService._save_master(db, supplier, "inventory.suppliers.create", actor, request) + + @staticmethod + def update_supplier(db: Session, supplier: InventorySupplier, payload: InventorySupplierPayload, *, actor: User, request: Request) -> InventorySupplier: + before_data = {"id": supplier.id, "name": supplier.name} + for key, value in payload.model_dump().items(): + setattr(supplier, key, value) + return InventoryService._save_master(db, supplier, "inventory.suppliers.update", actor, request, before_data=before_data) + + @staticmethod + def delete_master(db: Session, entity: MasterModel, *, action: str, actor: User, request: Request) -> None: + label = getattr(entity, "name", "") + before_data = {"id": entity.id, "name": label} + db.delete(entity) + db.commit() + write_audit_log(db, action=action, entity_type=entity.__tablename__, entity_id=entity.id, entity_label=label, actor=actor, request=request, before_data=before_data) + + @staticmethod + def _apply_item_payload(item: InventoryItem, payload: InventoryItemCreate | InventoryItemUpdate) -> None: + for key, value in payload.model_dump(exclude={"sku"}).items(): + setattr(item, key, value) + InventoryService._recalculate_available(item) + + @staticmethod + def _recalculate_available(item: InventoryItem) -> None: + item.quantity_available = item.quantity_on_hand - item.quantity_reserved + + @staticmethod + def _validate_sku_available(db: Session, sku: str, item_id: int | None = None) -> None: + existing = InventoryRepository.get_item_by_sku(db, sku) + if existing is not None and existing.id != item_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="SKU ist bereits vergeben") + + @staticmethod + def _validate_positive_stock_quantity(payload: InventoryStockAction) -> None: + if payload.quantity <= 0: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein") + + @staticmethod + def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None: + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type=movement_type, + quantity=payload.quantity, + reason=payload.reason, + reference_type=payload.reference_type, + reference_id=payload.reference_id, + note=payload.note, + actor_user_id=actor_user_id, + ) + + @staticmethod + def _commit_stock_action(db: Session, item: InventoryItem, before_data: dict, action: str, actor: User, request: Request) -> InventoryItem: + InventoryService._recalculate_available(item) + db.commit() + db.refresh(item) + updated = InventoryRepository.get_item(db, item.id) or item + write_audit_log( + db, + action=action, + entity_type="inventory_items", + entity_id=updated.id, + entity_label=_item_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_item_data(updated), + ) + return updated + + @staticmethod + def _save_master(db: Session, entity: MasterModel, action: str, actor: User, request: Request, before_data: dict | None = None) -> MasterModel: + try: + db.add(entity) + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag ist bereits vorhanden") from None + db.refresh(entity) + label = getattr(entity, "name", "") + write_audit_log( + db, + action=action, + entity_type=entity.__tablename__, + entity_id=entity.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + after_data={"id": entity.id, "name": label}, + ) + return entity diff --git a/frontend/athena/app/api/inventory/categories/[id]/route.ts b/frontend/athena/app/api/inventory/categories/[id]/route.ts new file mode 100644 index 0000000..4c1288a --- /dev/null +++ b/frontend/athena/app/api/inventory/categories/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +async function proxyCategoryRequest(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/inventory/categories/${id}`); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyCategoryRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyCategoryRequest(request, context); +} diff --git a/frontend/athena/app/api/inventory/categories/route.ts b/frontend/athena/app/api/inventory/categories/route.ts new file mode 100644 index 0000000..568b58d --- /dev/null +++ b/frontend/athena/app/api/inventory/categories/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/inventory/categories"); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/inventory/categories"); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/movements/route.ts b/frontend/athena/app/api/inventory/items/[id]/movements/route.ts new file mode 100644 index 0000000..c3eade1 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/movements/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}/movements`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/route.ts b/frontend/athena/app/api/inventory/items/[id]/route.ts new file mode 100644 index 0000000..0cbc57b --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +async function proxyItemRequest(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}`); +} + +export async function GET(request: NextRequest, context: Params) { + return proxyItemRequest(request, context); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyItemRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyItemRequest(request, context); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/adjust/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/adjust/route.ts new file mode 100644 index 0000000..ccc0243 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/adjust/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}/stock/adjust`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/consume/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/consume/route.ts new file mode 100644 index 0000000..8ac6939 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/consume/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}/stock/consume`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/release/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/release/route.ts new file mode 100644 index 0000000..94c1db7 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/release/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}/stock/release`); +} diff --git a/frontend/athena/app/api/inventory/items/[id]/stock/reserve/route.ts b/frontend/athena/app/api/inventory/items/[id]/stock/reserve/route.ts new file mode 100644 index 0000000..f7c5752 --- /dev/null +++ b/frontend/athena/app/api/inventory/items/[id]/stock/reserve/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/inventory/items/${id}/stock/reserve`); +} diff --git a/frontend/athena/app/api/inventory/items/route.ts b/frontend/athena/app/api/inventory/items/route.ts new file mode 100644 index 0000000..1000e7f --- /dev/null +++ b/frontend/athena/app/api/inventory/items/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, `/inventory/items${request.nextUrl.search}`); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/inventory/items"); +} diff --git a/frontend/athena/app/api/inventory/locations/[id]/route.ts b/frontend/athena/app/api/inventory/locations/[id]/route.ts new file mode 100644 index 0000000..6823a7e --- /dev/null +++ b/frontend/athena/app/api/inventory/locations/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +async function proxyLocationRequest(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/inventory/locations/${id}`); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyLocationRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyLocationRequest(request, context); +} diff --git a/frontend/athena/app/api/inventory/locations/route.ts b/frontend/athena/app/api/inventory/locations/route.ts new file mode 100644 index 0000000..08efc26 --- /dev/null +++ b/frontend/athena/app/api/inventory/locations/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/inventory/locations"); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/inventory/locations"); +} diff --git a/frontend/athena/app/api/inventory/suppliers/[id]/route.ts b/frontend/athena/app/api/inventory/suppliers/[id]/route.ts new file mode 100644 index 0000000..c296251 --- /dev/null +++ b/frontend/athena/app/api/inventory/suppliers/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ id: string }>; +}; + +async function proxySupplierRequest(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/inventory/suppliers/${id}`); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxySupplierRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxySupplierRequest(request, context); +} diff --git a/frontend/athena/app/api/inventory/suppliers/route.ts b/frontend/athena/app/api/inventory/suppliers/route.ts new file mode 100644 index 0000000..6c9f421 --- /dev/null +++ b/frontend/athena/app/api/inventory/suppliers/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/inventory/suppliers"); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/inventory/suppliers"); +} diff --git a/frontend/athena/app/dashboard/page.tsx b/frontend/athena/app/dashboard/page.tsx index 4b56102..4f8cb10 100644 --- a/frontend/athena/app/dashboard/page.tsx +++ b/frontend/athena/app/dashboard/page.tsx @@ -62,7 +62,7 @@ export default function DashboardPage() { return
{error || "Keine Dashboarddaten verfügbar"}
; } - const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles]; + const cards = [...summary.customers, ...summary.repairs, ...summary.inventory, ...summary.users, ...summary.roles]; return (
diff --git a/frontend/athena/app/inventory/items/[id]/page.tsx b/frontend/athena/app/inventory/items/[id]/page.tsx new file mode 100644 index 0000000..cb76fe8 --- /dev/null +++ b/frontend/athena/app/inventory/items/[id]/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { use, useCallback, useEffect, useState } from "react"; +import Link from "next/link"; + +import { formatDate, formatMoney, getErrorMessage } from "@/components/inventory/inventory-utils"; +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import type { InventoryItem, InventoryStockMovement } from "@/types/inventory"; + +type PageProps = { + params: Promise<{ + id: string; + }>; +}; + +const movementLabels: Record = { + initial: "Anfangsbestand", + purchase: "Einkauf", + adjustment: "Anpassung", + reservation: "Reservierung", + release: "Freigabe", + consumption: "Verbrauch", + return: "Rückgabe", + correction: "Korrektur", +}; + +export default function InventoryItemDetailPage({ params }: PageProps) { + const { id } = use(params); + const [item, setItem] = useState(null); + const [movements, setMovements] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const loadItem = useCallback(async () => { + setLoading(true); + setError(""); + try { + const [itemResponse, movementResponse] = await Promise.all([ + api.get(`/inventory/items/${id}`), + api.get(`/inventory/items/${id}/movements`), + ]); + setItem(itemResponse.data); + setMovements(movementResponse.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + queueMicrotask(() => { + void loadItem(); + }); + }, [loadItem]); + + if (loading) { + return
Artikel wird geladen...
; + } + + if (error || !item) { + return
{error || "Artikel nicht gefunden"}
; + } + + return ( +
+
+
+

{item.sku}

+

{item.name}

+

{item.category?.name ?? "Keine Kategorie"} · {item.is_active ? "Aktiv" : "Inaktiv"}

+
+ +
+ +
+
+

Stammdaten

+
+ + + + + + + + +
+
+ +
+

Bestand

+
+ + + + +
+
+
+ +
+
+

Preise

+
+ + + +
+
+ +
+

Bewegungen

+ {movements.length === 0 ? ( +

Noch keine Bewegungen vorhanden.

+ ) : ( +
+ {movements.map((movement) => ( +
+
+

{movementLabels[movement.movement_type] ?? movement.movement_type} · {movement.quantity}

+ +
+

{movement.reason}{movement.note ? ` · ${movement.note}` : ""}

+ {movement.actor_username &&

Gebucht von {movement.actor_username}

} +
+ ))} +
+ )} +
+
+
+ ); +} + +function Info({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/athena/app/inventory/page.tsx b/frontend/athena/app/inventory/page.tsx new file mode 100644 index 0000000..ce588c3 --- /dev/null +++ b/frontend/athena/app/inventory/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Edit, Eye, PackagePlus, Plus, RotateCcw, Trash2, Wrench } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; +import EmptyState from "@/components/common/EmptyState"; +import SearchInput from "@/components/common/SearchInput"; +import { useToast } from "@/components/common/ToastProvider"; +import InventoryItemDialog from "@/components/inventory/InventoryItemDialog"; +import MasterDataSection from "@/components/inventory/MasterDataSection"; +import StockActionDialog, { type StockAction } from "@/components/inventory/StockActionDialog"; +import { formatMoney, getErrorMessage } from "@/components/inventory/inventory-utils"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import { hasPermission } from "@/lib/permissions"; +import type { + InventoryCategory, + InventoryItem, + InventoryItemListResponse, + InventoryItemPayload, + InventoryLocation, + InventoryStockPayload, + InventorySupplier, +} from "@/types/inventory"; +import type { CurrentUser } from "@/types/rbac"; + +const pageSize = 10; + +export default function InventoryPage() { + const { showToast } = useToast(); + const [items, setItems] = useState([]); + const [categories, setCategories] = useState([]); + const [locations, setLocations] = useState([]); + const [suppliers, setSuppliers] = useState([]); + const [currentUser, setCurrentUser] = useState(null); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [formError, setFormError] = useState(""); + const [stockError, setStockError] = useState(""); + const [deleteError, setDeleteError] = useState(""); + const [pending, setPending] = useState(false); + const [tab, setTab] = useState<"items" | "categories" | "locations" | "suppliers">("items"); + const [search, setSearch] = useState(""); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [locationFilter, setLocationFilter] = useState("all"); + const [supplierFilter, setSupplierFilter] = useState("all"); + const [stockFilter, setStockFilter] = useState<"all" | "low">("all"); + const [activeFilter, setActiveFilter] = useState<"true" | "false" | "all">("true"); + const [page, setPage] = useState(1); + const [sortKey, setSortKey] = useState("name"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [formOpen, setFormOpen] = useState(false); + const [stockOpen, setStockOpen] = useState(false); + const [stockAction, setStockAction] = useState("adjust"); + const [selectedItem, setSelectedItem] = useState(null); + const [deleteItem, setDeleteItem] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + setError(""); + try { + const params = new URLSearchParams(); + params.set("limit", String(pageSize)); + params.set("offset", String((page - 1) * pageSize)); + if (search.trim()) params.set("q", search.trim()); + if (categoryFilter !== "all") params.set("category_id", categoryFilter); + if (locationFilter !== "all") params.set("location_id", locationFilter); + if (supplierFilter !== "all") params.set("supplier_id", supplierFilter); + if (stockFilter === "low") params.set("low_stock", "true"); + if (activeFilter !== "all") params.set("active", activeFilter); + const [itemResponse, categoryResponse, locationResponse, supplierResponse, meResponse] = await Promise.all([ + api.get(`/inventory/items?${params.toString()}`), + api.get("/inventory/categories"), + api.get("/inventory/locations"), + api.get("/inventory/suppliers"), + api.get("/me"), + ]); + setItems(itemResponse.data.items); + setTotal(itemResponse.data.total); + setCategories(categoryResponse.data); + setLocations(locationResponse.data); + setSuppliers(supplierResponse.data); + setCurrentUser(meResponse.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [activeFilter, categoryFilter, locationFilter, page, search, stockFilter, supplierFilter]); + + useEffect(() => { + queueMicrotask(() => { + void loadData(); + }); + }, [loadData]); + + const canCreate = hasPermission(currentUser, "inventory.create"); + const canUpdate = hasPermission(currentUser, "inventory.update"); + const canDelete = hasPermission(currentUser, "inventory.delete"); + const canAdjust = hasPermission(currentUser, "inventory.stock.adjust"); + const canReserve = hasPermission(currentUser, "inventory.stock.reserve"); + const canConsume = hasPermission(currentUser, "inventory.stock.consume"); + const canManageMasterdata = hasPermission(currentUser, "inventory.manage.masterdata"); + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const currentPage = Math.min(page, pageCount); + + const sortedItems = useMemo(() => [...items].sort((first, second) => { + const firstValue = String(first[sortKey as keyof InventoryItem] ?? "").toLowerCase(); + const secondValue = String(second[sortKey as keyof InventoryItem] ?? "").toLowerCase(); + if (firstValue === secondValue) return 0; + const result = firstValue > secondValue ? 1 : -1; + return sortDirection === "asc" ? result : -result; + }), [items, sortDirection, sortKey]); + + const columns: DataTableColumn[] = [ + { key: "sku", label: "SKU", sortable: true, render: (item) => {item.sku} }, + { key: "name", label: "Name", sortable: true, render: (item) => item.name }, + { key: "category", label: "Kategorie", render: (item) => item.category?.name ?? "Keine" }, + { key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer ?? "Keine Angabe" }, + { key: "quantity_on_hand", label: "Bestand", sortable: true, render: (item) => item.quantity_on_hand }, + { key: "quantity_reserved", label: "Reserviert", sortable: true, render: (item) => item.quantity_reserved }, + { key: "quantity_available", label: "Verfügbar", sortable: true, render: (item) => {item.quantity_available} }, + { key: "reorder_level", label: "Mindestbestand", sortable: true, render: (item) => item.reorder_level }, + { key: "location", label: "Lagerort", render: (item) => item.location?.name ?? "Kein Lagerort" }, + { key: "selling_price_cents", label: "VK", sortable: true, render: (item) => formatMoney(item.selling_price_cents, item.currency) }, + { key: "is_active", label: "Status", sortable: true, render: (item) => item.is_active ? "Aktiv" : "Inaktiv" }, + { + key: "actions", + label: "Aktionen", + className: "px-4 py-3 text-right", + render: (item) => ( +
+ + {canUpdate && } + {canAdjust && } + {canReserve && } + {canConsume && } + {canDelete && } +
+ ), + }, + ]; + + function openStock(item: InventoryItem, action: StockAction) { + setSelectedItem(item); + setStockAction(action); + setStockError(""); + setStockOpen(true); + } + + function handleSort(key: string) { + if (sortKey === key) { + setSortDirection((current) => (current === "asc" ? "desc" : "asc")); + return; + } + setSortKey(key); + setSortDirection("asc"); + } + + async function saveItem(payload: InventoryItemPayload) { + setPending(true); + setFormError(""); + try { + if (selectedItem) { + await api.put(`/inventory/items/${selectedItem.id}`, payload); + showToast({ type: "success", title: "Artikel aktualisiert", description: selectedItem.sku }); + } else { + const response = await api.post("/inventory/items", payload); + showToast({ type: "success", title: "Artikel angelegt", description: response.data.sku }); + } + setFormOpen(false); + await loadData(); + } catch (err) { + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Artikel konnte nicht gespeichert werden", description: message }); + } finally { + setPending(false); + } + } + + async function saveStock(payload: InventoryStockPayload) { + if (!selectedItem) return; + setPending(true); + setStockError(""); + try { + await api.post(`/inventory/items/${selectedItem.id}/stock/${stockAction}`, payload); + showToast({ type: "success", title: "Bestand gebucht", description: selectedItem.sku }); + setStockOpen(false); + await loadData(); + } catch (err) { + const message = getErrorMessage(err); + setStockError(message); + showToast({ type: "error", title: "Bestand konnte nicht gebucht werden", description: message }); + } finally { + setPending(false); + } + } + + async function confirmDelete() { + if (!deleteItem) return; + setPending(true); + setDeleteError(""); + try { + await api.delete(`/inventory/items/${deleteItem.id}`); + showToast({ type: "success", title: "Artikel deaktiviert", description: deleteItem.sku }); + setDeleteItem(null); + await loadData(); + } catch (err) { + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Artikel konnte nicht deaktiviert werden", description: message }); + } finally { + setPending(false); + } + } + + return ( +
+
+
+

Lager / Ersatzteile

+

{total} Artikel im aktuellen Filter

+
+ {canCreate && } +
+ +
+ {(["items", "categories", "locations", "suppliers"] as const).map((value) => ( + + ))} +
+ + {tab === "items" ? ( + <> +
+ { setSearch(value); setPage(1); }} placeholder="Artikel suchen" /> + + + + + +
+ + {!loading && !error && total === 0 && !search ? ( + setFormOpen(true)}>Artikel anlegen : undefined} + /> + ) : ( + item.id} + sortKey={sortKey} + sortDirection={sortDirection} + loading={loading} + error={error} + emptyTitle="Keine Artikel gefunden." + emptyDescription="Passe Suche oder Filter an." + onSort={handleSort} + /> + )} + +
+ Seite {currentPage} von {pageCount} +
+ + +
+
+ + ) : ( +
+ {tab === "categories" && void loadData()} />} + {tab === "locations" && void loadData()} />} + {tab === "suppliers" && void loadData()} />} +
+ )} + + + + !open && setDeleteItem(null)} + onConfirm={confirmDelete} + > +
+

{deleteItem?.sku} · {deleteItem?.name}

+ {deleteError &&

{deleteError}

} +
+
+
+ ); +} diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index 07d85b7..00ed94e 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -66,6 +66,7 @@ const menu = [ icon: Package, name: "Lager", href: "/inventory", + permission: "inventory.read", }, { icon: ShoppingCart, diff --git a/frontend/athena/components/inventory/InventoryItemDialog.tsx b/frontend/athena/components/inventory/InventoryItemDialog.tsx new file mode 100644 index 0000000..76776a2 --- /dev/null +++ b/frontend/athena/components/inventory/InventoryItemDialog.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import type { + InventoryCategory, + InventoryItem, + InventoryItemPayload, + InventoryLocation, + InventorySupplier, +} from "@/types/inventory"; +import { centsToEuroInput, emptyItemPayload, euroToCents } from "./inventory-utils"; + +type Props = { + open: boolean; + item: InventoryItem | null; + categories: InventoryCategory[]; + locations: InventoryLocation[]; + suppliers: InventorySupplier[]; + pending: boolean; + serverError: string; + onOpenChange: (open: boolean) => void; + onSubmit: (payload: InventoryItemPayload) => void; +}; + +function payloadFromItem(item: InventoryItem | null): InventoryItemPayload { + if (!item) { + return emptyItemPayload; + } + return { + sku: item.sku, + name: item.name, + description: item.description, + category_id: item.category_id, + manufacturer: item.manufacturer, + manufacturer_part_number: item.manufacturer_part_number, + supplier_id: item.supplier_id, + supplier_part_number: item.supplier_part_number, + compatible_devices: item.compatible_devices, + location_id: item.location_id, + quantity_on_hand: item.quantity_on_hand, + quantity_reserved: item.quantity_reserved, + reorder_level: item.reorder_level, + unit: item.unit, + purchase_price_cents: item.purchase_price_cents, + selling_price_cents: item.selling_price_cents, + currency: item.currency, + tax_rate_percent: item.tax_rate_percent, + notes: item.notes, + is_active: item.is_active, + }; +} + +export default function InventoryItemDialog({ + open, + item, + categories, + locations, + suppliers, + pending, + serverError, + onOpenChange, + onSubmit, +}: Props) { + const initialPayload = payloadFromItem(item); + const [payload, setPayload] = useState(initialPayload); + const [purchasePrice, setPurchasePrice] = useState(centsToEuroInput(initialPayload.purchase_price_cents)); + const [sellingPrice, setSellingPrice] = useState(centsToEuroInput(initialPayload.selling_price_cents)); + const [clientError, setClientError] = useState(""); + + function update(key: K, value: InventoryItemPayload[K]) { + setPayload((current) => ({ ...current, [key]: value })); + } + + function submit() { + const purchaseCents = euroToCents(purchasePrice); + const sellingCents = euroToCents(sellingPrice); + if (!payload.name.trim()) { + setClientError("Bitte einen Artikelnamen angeben."); + return; + } + if (payload.quantity_reserved > payload.quantity_on_hand) { + setClientError("Reservierter Bestand darf den Lagerbestand nicht überschreiten."); + return; + } + if ((purchasePrice.trim() && purchaseCents === null) || (sellingPrice.trim() && sellingCents === null)) { + setClientError("Bitte Preise als Eurobetrag eingeben, z. B. 1,50."); + return; + } + setClientError(""); + onSubmit({ + ...payload, + sku: payload.sku?.trim() || null, + name: payload.name.trim(), + purchase_price_cents: purchaseCents, + selling_price_cents: sellingCents, + description: payload.description?.trim() || null, + manufacturer: payload.manufacturer?.trim() || null, + manufacturer_part_number: payload.manufacturer_part_number?.trim() || null, + supplier_part_number: payload.supplier_part_number?.trim() || null, + compatible_devices: payload.compatible_devices?.trim() || null, + notes: payload.notes?.trim() || null, + }); + } + + return ( + + + + {item ? "Artikel bearbeiten" : "Artikel anlegen"} + Pflege Stammdaten, Lagerbestand, Preise und Bezugsdaten. + + +
+
+

Stammdaten

+
+ + +