feat(inventory): add spare parts management
This commit is contained in:
parent
abc6e308dd
commit
fb5c2cc26a
43 changed files with 2950 additions and 2 deletions
|
|
@ -219,6 +219,60 @@ Tabellen:
|
||||||
- `repair_public_access_tokens`
|
- `repair_public_access_tokens`
|
||||||
- `repair_notification_events`
|
- `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-<jahr>-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:
|
Hermes-Endpunkte:
|
||||||
|
|
||||||
- `GET /repairs`
|
- `GET /repairs`
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,48 @@ Kostenvoranschlaege:
|
||||||
- Die oeffentliche Website zeigt diese Daten erst nach einer spaeteren Website-Version an. Olympus stellt die API dafuer bereit.
|
- 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.
|
- 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-<jahr>-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:
|
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
||||||
|
|
||||||
- `/status/<token>`
|
- `/status/<token>`
|
||||||
|
|
|
||||||
24
ROADMAP.md
24
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
|
- Keine PDF-Erzeugung, keine Lexoffice-Anbindung und keine Rechnungserstellung in dieser Version
|
||||||
- Website-Anzeige/Freigabe wird spaeter in `funktechnik-schubert-website` nachgezogen
|
- 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-<jahr>-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
|
- `/portal/login` fuer spaeteren Kundenlogin
|
||||||
- Separates Authentifizierungsmodell fuer Kunden
|
- Separates Authentifizierungsmodell fuer Kunden
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import app.models.audit
|
||||||
import app.models.rbac
|
import app.models.rbac
|
||||||
import app.models.repair
|
import app.models.repair
|
||||||
import app.models.repair_estimate
|
import app.models.repair_estimate
|
||||||
|
import app.models.inventory
|
||||||
import app.models.system_setting
|
import app.models.system_setting
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
|
||||||
214
backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py
Normal file
214
backend/hermes/alembic/versions/ef8a2d5c9017_create_inventory.py
Normal file
|
|
@ -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")
|
||||||
Binary file not shown.
|
|
@ -58,6 +58,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
|
||||||
return "knowledge.read" in permissions
|
return "knowledge.read" in permissions
|
||||||
if action.startswith("repairs."):
|
if action.startswith("repairs."):
|
||||||
return "repairs.read" in permissions
|
return "repairs.read" in permissions
|
||||||
|
if action.startswith("inventory."):
|
||||||
|
return "inventory.read" in permissions
|
||||||
if action.startswith("audit_logs."):
|
if action.startswith("audit_logs."):
|
||||||
return "audit_logs.read" in permissions
|
return "audit_logs.read" in permissions
|
||||||
if action.startswith("auth."):
|
if action.startswith("auth."):
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from app.models.rbac import Role
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.customer_repository import CustomerRepository
|
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_repository import RepairRepository
|
||||||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||||
from app.repositories.user_repository import UserRepository
|
from app.repositories.user_repository import UserRepository
|
||||||
|
|
@ -35,6 +36,7 @@ def get_dashboard_summary(
|
||||||
users: list[MetricCard] = []
|
users: list[MetricCard] = []
|
||||||
roles: list[MetricCard] = []
|
roles: list[MetricCard] = []
|
||||||
repairs: list[MetricCard] = []
|
repairs: list[MetricCard] = []
|
||||||
|
inventory: list[MetricCard] = []
|
||||||
system_status: list[SystemStatusItem] = []
|
system_status: list[SystemStatusItem] = []
|
||||||
|
|
||||||
if "customers.read" in permissions:
|
if "customers.read" in permissions:
|
||||||
|
|
@ -82,6 +84,14 @@ def get_dashboard_summary(
|
||||||
MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)),
|
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:
|
if "system_settings.manage" in permissions:
|
||||||
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
|
||||||
public_links = SystemSettingsService.get_public_links_settings(db)
|
public_links = SystemSettingsService.get_public_links_settings(db)
|
||||||
|
|
@ -127,6 +137,7 @@ def get_dashboard_summary(
|
||||||
users=users,
|
users=users,
|
||||||
roles=roles,
|
roles=roles,
|
||||||
repairs=repairs,
|
repairs=repairs,
|
||||||
|
inventory=inventory,
|
||||||
system_status=system_status,
|
system_status=system_status,
|
||||||
activities=EmptyWidget(
|
activities=EmptyWidget(
|
||||||
title="Letzte Aktivitäten",
|
title="Letzte Aktivitäten",
|
||||||
|
|
|
||||||
307
backend/hermes/app/api/inventory.py
Normal file
307
backend/hermes/app/api/inventory.py
Normal file
|
|
@ -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
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -13,6 +13,7 @@ from app.api.auth import router as auth_router
|
||||||
from app.api.audit import router as audit_router
|
from app.api.audit import router as audit_router
|
||||||
from app.api.customers import router as customers_router
|
from app.api.customers import router as customers_router
|
||||||
from app.api.dashboard import router as dashboard_router
|
from app.api.dashboard import router as dashboard_router
|
||||||
|
from app.api.inventory import router as inventory_router
|
||||||
from app.api.knowledge import router as knowledge_router
|
from app.api.knowledge import router as knowledge_router
|
||||||
from app.api.permissions import router as permissions_router
|
from app.api.permissions import router as permissions_router
|
||||||
from app.api.repairs import router as repairs_router
|
from app.api.repairs import router as repairs_router
|
||||||
|
|
@ -41,6 +42,7 @@ app.include_router(users_router)
|
||||||
app.include_router(roles_router)
|
app.include_router(roles_router)
|
||||||
app.include_router(permissions_router)
|
app.include_router(permissions_router)
|
||||||
app.include_router(customers_router)
|
app.include_router(customers_router)
|
||||||
|
app.include_router(inventory_router)
|
||||||
app.include_router(knowledge_router)
|
app.include_router(knowledge_router)
|
||||||
app.include_router(repairs_router)
|
app.include_router(repairs_router)
|
||||||
app.include_router(repair_estimates_router)
|
app.include_router(repair_estimates_router)
|
||||||
|
|
|
||||||
Binary file not shown.
110
backend/hermes/app/models/inventory.py
Normal file
110
backend/hermes/app/models/inventory.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryCategory(Base):
|
||||||
|
__tablename__ = "inventory_categories"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||||
|
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
items: Mapped[list["InventoryItem"]] = relationship(back_populates="category")
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryLocation(Base):
|
||||||
|
__tablename__ = "inventory_locations"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
items: Mapped[list["InventoryItem"]] = relationship(back_populates="location")
|
||||||
|
|
||||||
|
|
||||||
|
class InventorySupplier(Base):
|
||||||
|
__tablename__ = "inventory_suppliers"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(180), unique=True, index=True)
|
||||||
|
contact_name: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||||
|
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
customer_number: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
items: Mapped[list["InventoryItem"]] = relationship(back_populates="supplier")
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryItem(Base):
|
||||||
|
__tablename__ = "inventory_items"
|
||||||
|
__table_args__ = (UniqueConstraint("sku", name="uq_inventory_items_sku"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
sku: Mapped[str] = mapped_column(String(40), unique=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
category_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_categories.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||||
|
manufacturer_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||||
|
supplier_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_suppliers.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
supplier_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
|
||||||
|
compatible_devices: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
location_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_locations.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
quantity_on_hand: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
quantity_reserved: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
quantity_available: Mapped[int] = mapped_column(Integer, default=0, server_default="0", index=True)
|
||||||
|
reorder_level: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.")
|
||||||
|
purchase_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
selling_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR")
|
||||||
|
tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00")
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
category: Mapped[InventoryCategory | None] = relationship(back_populates="items", lazy="joined")
|
||||||
|
supplier: Mapped[InventorySupplier | None] = relationship(back_populates="items", lazy="joined")
|
||||||
|
location: Mapped[InventoryLocation | None] = relationship(back_populates="items", lazy="joined")
|
||||||
|
movements: Mapped[list["InventoryStockMovement"]] = relationship(
|
||||||
|
back_populates="item",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="InventoryStockMovement.created_at.desc()",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryStockMovement(Base):
|
||||||
|
__tablename__ = "inventory_stock_movements"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
item_id: Mapped[int] = mapped_column(ForeignKey("inventory_items.id", ondelete="CASCADE"), index=True)
|
||||||
|
movement_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||||
|
quantity: Mapped[int] = mapped_column(Integer)
|
||||||
|
reason: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||||
|
reference_type: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
|
||||||
|
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
item: Mapped[InventoryItem] = relationship(back_populates="movements")
|
||||||
|
actor = relationship("User", lazy="joined")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actor_username(self) -> str:
|
||||||
|
return self.actor.username if self.actor is not None else ""
|
||||||
|
|
@ -90,6 +90,14 @@ STANDARD_PERMISSIONS = [
|
||||||
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"),
|
("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.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"),
|
||||||
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "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 = {
|
ROLE_PERMISSION_NAMES = {
|
||||||
|
|
@ -111,6 +119,13 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"repair_estimates.create",
|
"repair_estimates.create",
|
||||||
"repair_estimates.update",
|
"repair_estimates.update",
|
||||||
"repair_estimates.send",
|
"repair_estimates.send",
|
||||||
|
"inventory.read",
|
||||||
|
"inventory.create",
|
||||||
|
"inventory.update",
|
||||||
|
"inventory.stock.adjust",
|
||||||
|
"inventory.stock.reserve",
|
||||||
|
"inventory.stock.consume",
|
||||||
|
"inventory.manage.masterdata",
|
||||||
},
|
},
|
||||||
"sales": {
|
"sales": {
|
||||||
"dashboard.read",
|
"dashboard.read",
|
||||||
|
|
@ -138,6 +153,9 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"repair_estimates.create",
|
"repair_estimates.create",
|
||||||
"repair_estimates.update",
|
"repair_estimates.update",
|
||||||
"repair_estimates.send",
|
"repair_estimates.send",
|
||||||
|
"inventory.read",
|
||||||
|
"inventory.stock.reserve",
|
||||||
|
"inventory.stock.consume",
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"dashboard.read",
|
"dashboard.read",
|
||||||
|
|
@ -156,9 +174,17 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"repairs.public_link.manage",
|
"repairs.public_link.manage",
|
||||||
"repair_estimates.read",
|
"repair_estimates.read",
|
||||||
"repair_estimates.send",
|
"repair_estimates.send",
|
||||||
|
"inventory.read",
|
||||||
},
|
},
|
||||||
"warehouse": {
|
"warehouse": {
|
||||||
"dashboard.read",
|
"dashboard.read",
|
||||||
|
"inventory.read",
|
||||||
|
"inventory.create",
|
||||||
|
"inventory.update",
|
||||||
|
"inventory.stock.adjust",
|
||||||
|
"inventory.stock.reserve",
|
||||||
|
"inventory.stock.consume",
|
||||||
|
"inventory.manage.masterdata",
|
||||||
},
|
},
|
||||||
"guest": {
|
"guest": {
|
||||||
"dashboard.read",
|
"dashboard.read",
|
||||||
|
|
|
||||||
187
backend/hermes/app/repositories/inventory_repository.py
Normal file
187
backend/hermes/app/repositories/inventory_repository.py
Normal file
|
|
@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -25,6 +25,7 @@ class DashboardSummary(BaseModel):
|
||||||
users: list[MetricCard] = []
|
users: list[MetricCard] = []
|
||||||
roles: list[MetricCard] = []
|
roles: list[MetricCard] = []
|
||||||
repairs: list[MetricCard] = []
|
repairs: list[MetricCard] = []
|
||||||
|
inventory: list[MetricCard] = []
|
||||||
system_status: list[SystemStatusItem] = []
|
system_status: list[SystemStatusItem] = []
|
||||||
activities: EmptyWidget
|
activities: EmptyWidget
|
||||||
tasks: EmptyWidget
|
tasks: EmptyWidget
|
||||||
|
|
|
||||||
231
backend/hermes/app/schemas/inventory.py
Normal file
231
backend/hermes/app/schemas/inventory.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -180,6 +180,23 @@ def action_title(action: str) -> str:
|
||||||
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
|
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
|
||||||
"repair_estimates.cancel": "Kostenvoranschlag storniert",
|
"repair_estimates.cancel": "Kostenvoranschlag storniert",
|
||||||
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
|
"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.update": "SMTP-Konfiguration geändert",
|
||||||
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
|
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
|
||||||
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
|
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
|
||||||
|
|
|
||||||
318
backend/hermes/app/services/inventory_service.py
Normal file
318
backend/hermes/app/services/inventory_service.py
Normal file
|
|
@ -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
|
||||||
33
frontend/athena/app/api/inventory/categories/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/categories/[id]/route.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
18
frontend/athena/app/api/inventory/categories/route.ts
Normal file
18
frontend/athena/app/api/inventory/categories/route.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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`);
|
||||||
|
}
|
||||||
37
frontend/athena/app/api/inventory/items/[id]/route.ts
Normal file
37
frontend/athena/app/api/inventory/items/[id]/route.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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`);
|
||||||
|
}
|
||||||
|
|
@ -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`);
|
||||||
|
}
|
||||||
|
|
@ -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`);
|
||||||
|
}
|
||||||
|
|
@ -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`);
|
||||||
|
}
|
||||||
18
frontend/athena/app/api/inventory/items/route.ts
Normal file
18
frontend/athena/app/api/inventory/items/route.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
33
frontend/athena/app/api/inventory/locations/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/locations/[id]/route.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
18
frontend/athena/app/api/inventory/locations/route.ts
Normal file
18
frontend/athena/app/api/inventory/locations/route.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
33
frontend/athena/app/api/inventory/suppliers/[id]/route.ts
Normal file
33
frontend/athena/app/api/inventory/suppliers/[id]/route.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
18
frontend/athena/app/api/inventory/suppliers/route.ts
Normal file
18
frontend/athena/app/api/inventory/suppliers/route.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ export default function DashboardPage() {
|
||||||
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Dashboarddaten verfügbar"}</div>;
|
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Keine Dashboarddaten verfügbar"}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = [...summary.customers, ...summary.repairs, ...summary.users, ...summary.roles];
|
const cards = [...summary.customers, ...summary.repairs, ...summary.inventory, ...summary.users, ...summary.roles];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
|
||||||
153
frontend/athena/app/inventory/items/[id]/page.tsx
Normal file
153
frontend/athena/app/inventory/items/[id]/page.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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<InventoryItem | null>(null);
|
||||||
|
const [movements, setMovements] = useState<InventoryStockMovement[]>([]);
|
||||||
|
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<InventoryItem>(`/inventory/items/${id}`),
|
||||||
|
api.get<InventoryStockMovement[]>(`/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 <div className="rounded-lg border bg-white p-8 text-slate-500">Artikel wird geladen...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !item) {
|
||||||
|
return <div className="rounded-lg border bg-white p-8 text-red-600">{error || "Artikel nicht gefunden"}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-500">{item.sku}</p>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-950">{item.name}</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{item.category?.name ?? "Keine Kategorie"} · {item.is_active ? "Aktiv" : "Inaktiv"}</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/inventory"><Button type="button" variant="outline">Zurück zum Lager</Button></Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
|
<section className="rounded-lg border bg-white p-5 xl:col-span-2">
|
||||||
|
<h2 className="font-semibold text-slate-950">Stammdaten</h2>
|
||||||
|
<dl className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
|
<Info label="Beschreibung" value={item.description ?? "Keine Angabe"} />
|
||||||
|
<Info label="Hersteller" value={item.manufacturer ?? "Keine Angabe"} />
|
||||||
|
<Info label="Hersteller-Teilenummer" value={item.manufacturer_part_number ?? "Keine Angabe"} />
|
||||||
|
<Info label="Lieferanten-Teilenummer" value={item.supplier_part_number ?? "Keine Angabe"} />
|
||||||
|
<Info label="Lieferant" value={item.supplier?.name ?? "Kein Lieferant"} />
|
||||||
|
<Info label="Lagerort" value={item.location?.name ?? "Kein Lagerort"} />
|
||||||
|
<Info label="Kompatible Geräte" value={item.compatible_devices ?? "Keine Angabe"} />
|
||||||
|
<Info label="Notizen" value={item.notes ?? "Keine Notizen"} />
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border bg-white p-5">
|
||||||
|
<h2 className="font-semibold text-slate-950">Bestand</h2>
|
||||||
|
<dl className="mt-4 grid gap-3">
|
||||||
|
<Metric label="Bestand" value={`${item.quantity_on_hand} ${item.unit}`} />
|
||||||
|
<Metric label="Reserviert" value={`${item.quantity_reserved} ${item.unit}`} />
|
||||||
|
<Metric label="Verfügbar" value={`${item.quantity_available} ${item.unit}`} />
|
||||||
|
<Metric label="Mindestbestand" value={`${item.reorder_level} ${item.unit}`} />
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
|
<section className="rounded-lg border bg-white p-5">
|
||||||
|
<h2 className="font-semibold text-slate-950">Preise</h2>
|
||||||
|
<dl className="mt-4 grid gap-3">
|
||||||
|
<Metric label="Einkaufspreis" value={formatMoney(item.purchase_price_cents, item.currency)} />
|
||||||
|
<Metric label="Verkaufspreis" value={formatMoney(item.selling_price_cents, item.currency)} />
|
||||||
|
<Metric label="MwSt." value={`${item.tax_rate_percent} %`} />
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border bg-white p-5 xl:col-span-2">
|
||||||
|
<h2 className="font-semibold text-slate-950">Bewegungen</h2>
|
||||||
|
{movements.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-slate-500">Noch keine Bewegungen vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 divide-y">
|
||||||
|
{movements.map((movement) => (
|
||||||
|
<div key={movement.id} className="py-3">
|
||||||
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="font-medium text-slate-950">{movementLabels[movement.movement_type] ?? movement.movement_type} · {movement.quantity}</p>
|
||||||
|
<time className="text-xs text-slate-500" dateTime={movement.created_at}>{formatDate(movement.created_at)}</time>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{movement.reason}{movement.note ? ` · ${movement.note}` : ""}</p>
|
||||||
|
{movement.actor_username && <p className="mt-1 text-xs text-slate-400">Gebucht von {movement.actor_username}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Info({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold uppercase text-slate-500">{label}</dt>
|
||||||
|
<dd className="mt-1 whitespace-pre-wrap text-sm text-slate-950">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-slate-50 p-3">
|
||||||
|
<dt className="text-xs font-semibold uppercase text-slate-500">{label}</dt>
|
||||||
|
<dd className="mt-1 text-lg font-semibold text-slate-950">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
frontend/athena/app/inventory/page.tsx
Normal file
322
frontend/athena/app/inventory/page.tsx
Normal file
|
|
@ -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<InventoryItem[]>([]);
|
||||||
|
const [categories, setCategories] = useState<InventoryCategory[]>([]);
|
||||||
|
const [locations, setLocations] = useState<InventoryLocation[]>([]);
|
||||||
|
const [suppliers, setSuppliers] = useState<InventorySupplier[]>([]);
|
||||||
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(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<StockAction>("adjust");
|
||||||
|
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
|
||||||
|
const [deleteItem, setDeleteItem] = useState<InventoryItem | null>(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<InventoryItemListResponse>(`/inventory/items?${params.toString()}`),
|
||||||
|
api.get<InventoryCategory[]>("/inventory/categories"),
|
||||||
|
api.get<InventoryLocation[]>("/inventory/locations"),
|
||||||
|
api.get<InventorySupplier[]>("/inventory/suppliers"),
|
||||||
|
api.get<CurrentUser>("/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<InventoryItem>[] = [
|
||||||
|
{ key: "sku", label: "SKU", sortable: true, render: (item) => <span className="font-medium text-slate-950">{item.sku}</span> },
|
||||||
|
{ 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) => <span className={item.quantity_available <= item.reorder_level ? "font-semibold text-amber-700" : ""}>{item.quantity_available}</span> },
|
||||||
|
{ 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) => (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link href={`/inventory/items/${item.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
|
||||||
|
{canUpdate && <Button type="button" variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelectedItem(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>}
|
||||||
|
{canAdjust && <Button type="button" variant="ghost" size="icon-sm" title="Bestand anpassen" onClick={() => openStock(item, "adjust")}><Wrench size={16} /></Button>}
|
||||||
|
{canReserve && <Button type="button" variant="ghost" size="icon-sm" title="Reservieren" onClick={() => openStock(item, "reserve")}><PackagePlus size={16} /></Button>}
|
||||||
|
{canConsume && <Button type="button" variant="ghost" size="icon-sm" title="Verbrauch buchen" onClick={() => openStock(item, "consume")}><RotateCcw size={16} /></Button>}
|
||||||
|
{canDelete && <Button type="button" variant="destructive" size="icon-sm" title="Deaktivieren" onClick={() => { setDeleteItem(item); setDeleteError(""); }}><Trash2 size={16} /></Button>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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<InventoryItem>(`/inventory/items/${selectedItem.id}`, payload);
|
||||||
|
showToast({ type: "success", title: "Artikel aktualisiert", description: selectedItem.sku });
|
||||||
|
} else {
|
||||||
|
const response = await api.post<InventoryItem>("/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<InventoryItem>(`/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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-950">Lager / Ersatzteile</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{total} Artikel im aktuellen Filter</p>
|
||||||
|
</div>
|
||||||
|
{canCreate && <Button type="button" onClick={() => { setSelectedItem(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Artikel anlegen</Button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["items", "categories", "locations", "suppliers"] as const).map((value) => (
|
||||||
|
<Button key={value} type="button" variant={tab === value ? "default" : "outline"} onClick={() => setTab(value)}>
|
||||||
|
{{ items: "Artikel", categories: "Kategorien", locations: "Lagerorte", suppliers: "Lieferanten" }[value]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "items" ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 xl:flex-row xl:items-center">
|
||||||
|
<SearchInput value={search} onChange={(value) => { setSearch(value); setPage(1); }} placeholder="Artikel suchen" />
|
||||||
|
<select value={categoryFilter} onChange={(event) => { setCategoryFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||||
|
<option value="all">Alle Kategorien</option>
|
||||||
|
{categories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={locationFilter} onChange={(event) => { setLocationFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||||
|
<option value="all">Alle Lagerorte</option>
|
||||||
|
{locations.map((location) => <option key={location.id} value={location.id}>{location.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={supplierFilter} onChange={(event) => { setSupplierFilter(event.target.value); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||||
|
<option value="all">Alle Lieferanten</option>
|
||||||
|
{suppliers.map((supplier) => <option key={supplier.id} value={supplier.id}>{supplier.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={stockFilter} onChange={(event) => { setStockFilter(event.target.value as "all" | "low"); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||||
|
<option value="all">Alle Bestände</option>
|
||||||
|
<option value="low">Niedriger Bestand</option>
|
||||||
|
</select>
|
||||||
|
<select value={activeFilter} onChange={(event) => { setActiveFilter(event.target.value as "true" | "false" | "all"); setPage(1); }} className="h-8 rounded-lg border px-2 text-sm">
|
||||||
|
<option value="true">Aktiv</option>
|
||||||
|
<option value="false">Inaktiv</option>
|
||||||
|
<option value="all">Alle</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && !error && total === 0 && !search ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Noch keine Ersatzteile vorhanden."
|
||||||
|
description="Lege den ersten Artikel an, um Bestand, Preise und Lieferanten zu pflegen."
|
||||||
|
action={canCreate ? <Button type="button" onClick={() => setFormOpen(true)}><Plus size={16} />Artikel anlegen</Button> : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={sortedItems}
|
||||||
|
rowKey={(item) => item.id}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
emptyTitle="Keine Artikel gefunden."
|
||||||
|
emptyDescription="Passe Suche oder Filter an."
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm text-slate-600">
|
||||||
|
<span>Seite {currentPage} von {pageCount}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" disabled={currentPage === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>Zurück</Button>
|
||||||
|
<Button type="button" variant="outline" disabled={currentPage === pageCount} onClick={() => setPage((current) => Math.min(pageCount, current + 1))}>Weiter</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
{tab === "categories" && <MasterDataSection title="Kategorien" kind="categories" entries={categories} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
|
||||||
|
{tab === "locations" && <MasterDataSection title="Lagerorte" kind="locations" entries={locations} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
|
||||||
|
{tab === "suppliers" && <MasterDataSection title="Lieferanten" kind="suppliers" entries={suppliers} canManage={canManageMasterdata} onChanged={() => void loadData()} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InventoryItemDialog key={`item-${selectedItem?.id ?? "new"}-${formOpen}`} open={formOpen} item={selectedItem} categories={categories} locations={locations} suppliers={suppliers} pending={pending} serverError={formError} onOpenChange={setFormOpen} onSubmit={saveItem} />
|
||||||
|
<StockActionDialog key={`stock-${selectedItem?.id ?? "none"}-${stockAction}-${stockOpen}`} open={stockOpen} item={selectedItem} action={stockAction} pending={pending} serverError={stockError} onOpenChange={setStockOpen} onSubmit={saveStock} />
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleteItem)}
|
||||||
|
title="Artikel deaktivieren"
|
||||||
|
description="Artikel mit Bewegungen werden nicht hart gelöscht, sondern deaktiviert."
|
||||||
|
confirmLabel="Deaktivieren"
|
||||||
|
pending={pending}
|
||||||
|
onOpenChange={(open) => !open && setDeleteItem(null)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>{deleteItem?.sku} · {deleteItem?.name}</p>
|
||||||
|
{deleteError && <p className="text-sm text-red-600">{deleteError}</p>}
|
||||||
|
</div>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,7 @@ const menu = [
|
||||||
icon: Package,
|
icon: Package,
|
||||||
name: "Lager",
|
name: "Lager",
|
||||||
href: "/inventory",
|
href: "/inventory",
|
||||||
|
permission: "inventory.read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: ShoppingCart,
|
icon: ShoppingCart,
|
||||||
|
|
|
||||||
192
frontend/athena/components/inventory/InventoryItemDialog.tsx
Normal file
192
frontend/athena/components/inventory/InventoryItemDialog.tsx
Normal file
|
|
@ -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<InventoryItemPayload>(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<K extends keyof InventoryItemPayload>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{item ? "Artikel bearbeiten" : "Artikel anlegen"}</DialogTitle>
|
||||||
|
<DialogDescription>Pflege Stammdaten, Lagerbestand, Preise und Bezugsdaten.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<h3 className="font-semibold text-slate-950">Stammdaten</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<label className="grid gap-1 text-sm">SKU optional<Input value={payload.sku ?? ""} onChange={(event) => update("sku", event.target.value || null)} placeholder="ET-2026-000001" /></label>
|
||||||
|
<label className="grid gap-1 text-sm md:col-span-2">Name<Input value={payload.name} onChange={(event) => update("name", event.target.value)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm md:col-span-3">Beschreibung<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<h3 className="font-semibold text-slate-950">Zuordnung</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<label className="grid gap-1 text-sm">Kategorie<select className="h-8 rounded-lg border px-2 text-sm" value={payload.category_id ?? ""} onChange={(event) => update("category_id", event.target.value ? Number(event.target.value) : null)}>
|
||||||
|
<option value="">Keine Kategorie</option>
|
||||||
|
{categories.map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}
|
||||||
|
</select></label>
|
||||||
|
<label className="grid gap-1 text-sm">Lagerort<select className="h-8 rounded-lg border px-2 text-sm" value={payload.location_id ?? ""} onChange={(event) => update("location_id", event.target.value ? Number(event.target.value) : null)}>
|
||||||
|
<option value="">Kein Lagerort</option>
|
||||||
|
{locations.map((location) => <option key={location.id} value={location.id}>{location.name}</option>)}
|
||||||
|
</select></label>
|
||||||
|
<label className="grid gap-1 text-sm">Lieferant<select className="h-8 rounded-lg border px-2 text-sm" value={payload.supplier_id ?? ""} onChange={(event) => update("supplier_id", event.target.value ? Number(event.target.value) : null)}>
|
||||||
|
<option value="">Kein Lieferant</option>
|
||||||
|
{suppliers.map((supplier) => <option key={supplier.id} value={supplier.id}>{supplier.name}</option>)}
|
||||||
|
</select></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<h3 className="font-semibold text-slate-950">Hersteller und Kompatibilität</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<label className="grid gap-1 text-sm">Hersteller<Input value={payload.manufacturer ?? ""} onChange={(event) => update("manufacturer", event.target.value || null)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Hersteller-Teilenummer<Input value={payload.manufacturer_part_number ?? ""} onChange={(event) => update("manufacturer_part_number", event.target.value || null)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Lieferanten-Teilenummer<Input value={payload.supplier_part_number ?? ""} onChange={(event) => update("supplier_part_number", event.target.value || null)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm md:col-span-3">Kompatible Geräte<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.compatible_devices ?? ""} onChange={(event) => update("compatible_devices", event.target.value || null)} /></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<h3 className="font-semibold text-slate-950">Bestand und Preise</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<label className="grid gap-1 text-sm">Bestand<Input type="number" min={0} value={payload.quantity_on_hand} onChange={(event) => update("quantity_on_hand", Number(event.target.value))} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Reserviert<Input type="number" min={0} value={payload.quantity_reserved} onChange={(event) => update("quantity_reserved", Number(event.target.value))} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Mindestbestand<Input type="number" min={0} value={payload.reorder_level} onChange={(event) => update("reorder_level", Number(event.target.value))} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Einheit<Input value={payload.unit} onChange={(event) => update("unit", event.target.value)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Einkaufspreis €<Input inputMode="decimal" value={purchasePrice} onChange={(event) => setPurchasePrice(event.target.value)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Verkaufspreis €<Input inputMode="decimal" value={sellingPrice} onChange={(event) => setSellingPrice(event.target.value)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Währung<Input value={payload.currency} onChange={(event) => update("currency", event.target.value.toUpperCase())} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">MwSt. %<Input inputMode="decimal" value={payload.tax_rate_percent} onChange={(event) => update("tax_rate_percent", event.target.value.replace(",", "."))} /></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<h3 className="font-semibold text-slate-950">Notizen</h3>
|
||||||
|
<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={payload.notes ?? ""} onChange={(event) => update("notes", event.target.value || null)} />
|
||||||
|
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={payload.is_active} onChange={(event) => update("is_active", event.target.checked)} /> Artikel ist aktiv</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(clientError || serverError) && <p className="text-sm text-red-600">{clientError || serverError}</p>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||||
|
<Button type="button" onClick={submit} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/athena/components/inventory/MasterDataSection.tsx
Normal file
148
frontend/athena/components/inventory/MasterDataSection.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Edit, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { InventoryCategory, InventoryLocation, InventorySupplier } from "@/types/inventory";
|
||||||
|
import { getErrorMessage } from "./inventory-utils";
|
||||||
|
|
||||||
|
type MasterKind = "categories" | "locations" | "suppliers";
|
||||||
|
type MasterEntry = InventoryCategory | InventoryLocation | InventorySupplier;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
kind: MasterKind;
|
||||||
|
entries: MasterEntry[];
|
||||||
|
canManage: boolean;
|
||||||
|
onChanged: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MasterDataSection({ title, kind, entries, canManage, onChanged }: Props) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [editing, setEditing] = useState<MasterEntry | null>(null);
|
||||||
|
const [deleteEntry, setDeleteEntry] = useState<MasterEntry | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function startCreate() {
|
||||||
|
setEditing({ id: 0, name: "", description: null, created_at: "", updated_at: "" } as MasterEntry);
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(entry: MasterEntry) {
|
||||||
|
setEditing(entry);
|
||||||
|
setName(entry.name);
|
||||||
|
setDescription("description" in entry && entry.description ? entry.description : "");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!editing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Bitte einen Namen angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const payload = kind === "suppliers"
|
||||||
|
? { name: name.trim(), notes: description.trim() || null }
|
||||||
|
: { name: name.trim(), description: description.trim() || null };
|
||||||
|
try {
|
||||||
|
if (editing.id) {
|
||||||
|
await api.put(`/inventory/${kind}/${editing.id}`, payload);
|
||||||
|
} else {
|
||||||
|
await api.post(`/inventory/${kind}`, payload);
|
||||||
|
}
|
||||||
|
showToast({ type: "success", title: `${title} gespeichert` });
|
||||||
|
setEditing(null);
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
setError(message);
|
||||||
|
showToast({ type: "error", title: `${title} konnte nicht gespeichert werden`, description: message });
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/inventory/${kind}/${deleteEntry.id}`);
|
||||||
|
showToast({ type: "success", title: `${title} gelöscht`, description: deleteEntry.name });
|
||||||
|
setDeleteEntry(null);
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showToast({ type: "error", title: `${title} konnte nicht gelöscht werden`, description: message });
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border bg-white p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<h2 className="font-semibold text-slate-950">{title}</h2>
|
||||||
|
{canManage && <Button type="button" size="sm" onClick={startCreate}><Plus size={14} />Neu</Button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="py-4 text-sm text-slate-500">Noch keine Einträge vorhanden.</p>
|
||||||
|
) : entries.map((entry) => (
|
||||||
|
<div key={entry.id} className="flex items-center justify-between gap-3 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-950">{entry.name}</p>
|
||||||
|
{"description" in entry && entry.description && <p className="text-sm text-slate-500">{entry.description}</p>}
|
||||||
|
{"notes" in entry && entry.notes && <p className="text-sm text-slate-500">{entry.notes}</p>}
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" onClick={() => startEdit(entry)} title="Bearbeiten"><Edit size={16} /></Button>
|
||||||
|
<Button type="button" variant="destructive" size="icon-sm" onClick={() => setDeleteEntry(entry)} title="Löschen"><Trash2 size={16} /></Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="mt-4 grid gap-3 rounded-lg border bg-slate-50 p-4">
|
||||||
|
<Input value={name} placeholder="Name" onChange={(event) => setName(event.target.value)} />
|
||||||
|
<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={description} placeholder={kind === "suppliers" ? "Notizen" : "Beschreibung"} onChange={(event) => setDescription(event.target.value)} />
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" onClick={save} disabled={pending}>{pending ? "Speichert..." : "Speichern"}</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setEditing(null)} disabled={pending}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleteEntry)}
|
||||||
|
title={`${title} löschen`}
|
||||||
|
description="Der Eintrag wird entfernt. Verknüpfte Artikel behalten ihre sonstigen Daten."
|
||||||
|
pending={pending}
|
||||||
|
onOpenChange={(open) => !open && setDeleteEntry(null)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
{deleteEntry?.name}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
frontend/athena/components/inventory/StockActionDialog.tsx
Normal file
107
frontend/athena/components/inventory/StockActionDialog.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"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 { InventoryItem, InventoryStockPayload } from "@/types/inventory";
|
||||||
|
|
||||||
|
export type StockAction = "adjust" | "reserve" | "release" | "consume";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
item: InventoryItem | null;
|
||||||
|
action: StockAction;
|
||||||
|
pending: boolean;
|
||||||
|
serverError: string;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: InventoryStockPayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels: Record<StockAction, { title: string; description: string; reason: string }> = {
|
||||||
|
adjust: {
|
||||||
|
title: "Bestand anpassen",
|
||||||
|
description: "Setzt den physischen Bestand auf den angegebenen Wert.",
|
||||||
|
reason: "Inventurkorrektur",
|
||||||
|
},
|
||||||
|
reserve: {
|
||||||
|
title: "Bestand reservieren",
|
||||||
|
description: "Reserviert verfügbaren Bestand für einen späteren Vorgang.",
|
||||||
|
reason: "Reservierung",
|
||||||
|
},
|
||||||
|
release: {
|
||||||
|
title: "Reservierung aufheben",
|
||||||
|
description: "Gibt reservierten Bestand wieder frei.",
|
||||||
|
reason: "Reservierung aufgehoben",
|
||||||
|
},
|
||||||
|
consume: {
|
||||||
|
title: "Verbrauch buchen",
|
||||||
|
description: "Bucht Bestand als verbraucht aus.",
|
||||||
|
reason: "Verbrauch",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StockActionDialog({ open, item, action, pending, serverError, onOpenChange, onSubmit }: Props) {
|
||||||
|
const [quantity, setQuantity] = useState(action === "adjust" ? item?.quantity_on_hand ?? 0 : 1);
|
||||||
|
const [reason, setReason] = useState(labels[action].reason);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [clientError, setClientError] = useState("");
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (quantity <= 0 && action !== "adjust") {
|
||||||
|
setClientError("Bitte eine Menge größer 0 angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (quantity < 0) {
|
||||||
|
setClientError("Bitte keine negative Menge eingeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reason.trim()) {
|
||||||
|
setClientError("Bitte einen Grund angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClientError("");
|
||||||
|
onSubmit({
|
||||||
|
quantity,
|
||||||
|
reason: reason.trim(),
|
||||||
|
note: note.trim() || null,
|
||||||
|
reference_type: null,
|
||||||
|
reference_id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{labels[action].title}</DialogTitle>
|
||||||
|
<DialogDescription>{item ? `${item.sku} · ${item.name}` : labels[action].description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<p className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-600">
|
||||||
|
Bestand {item?.quantity_on_hand ?? 0} · Reserviert {item?.quantity_reserved ?? 0} · Verfügbar {item?.quantity_available ?? 0}
|
||||||
|
</p>
|
||||||
|
<label className="grid gap-1 text-sm">Menge<Input type="number" min={action === "adjust" ? 0 : 1} value={quantity} onChange={(event) => setQuantity(Number(event.target.value))} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Grund<Input value={reason} onChange={(event) => setReason(event.target.value)} /></label>
|
||||||
|
<label className="grid gap-1 text-sm">Notiz<textarea className="min-h-20 rounded-lg border p-2 text-sm" value={note} onChange={(event) => setNote(event.target.value)} /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(clientError || serverError) && <p className="text-sm text-red-600">{clientError || serverError}</p>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||||
|
<Button type="button" onClick={submit} disabled={pending}>{pending ? "Speichert..." : "Buchen"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/athena/components/inventory/inventory-utils.ts
Normal file
62
frontend/athena/components/inventory/inventory-utils.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { InventoryItemPayload } from "@/types/inventory";
|
||||||
|
|
||||||
|
export 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";
|
||||||
|
}
|
||||||
|
return "Aktion konnte nicht abgeschlossen werden";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(cents: number | null, currency = "EUR") {
|
||||||
|
if (cents === null) {
|
||||||
|
return "Keine Angabe";
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("de-DE", { style: "currency", currency }).format(cents / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function euroToCents(value: string) {
|
||||||
|
const normalized = value.trim().replace(",", ".");
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const amount = Number(normalized);
|
||||||
|
if (!Number.isFinite(amount) || amount < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.round(amount * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function centsToEuroInput(cents: number | null) {
|
||||||
|
if (cents === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return (cents / 100).toFixed(2).replace(".", ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyItemPayload: InventoryItemPayload = {
|
||||||
|
sku: null,
|
||||||
|
name: "",
|
||||||
|
description: null,
|
||||||
|
category_id: null,
|
||||||
|
manufacturer: null,
|
||||||
|
manufacturer_part_number: null,
|
||||||
|
supplier_id: null,
|
||||||
|
supplier_part_number: null,
|
||||||
|
compatible_devices: null,
|
||||||
|
location_id: null,
|
||||||
|
quantity_on_hand: 0,
|
||||||
|
quantity_reserved: 0,
|
||||||
|
reorder_level: 0,
|
||||||
|
unit: "Stk.",
|
||||||
|
purchase_price_cents: null,
|
||||||
|
selling_price_cents: null,
|
||||||
|
currency: "EUR",
|
||||||
|
tax_rate_percent: "19.00",
|
||||||
|
notes: null,
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
@ -17,6 +17,7 @@ export interface DashboardSummary {
|
||||||
users: MetricCard[];
|
users: MetricCard[];
|
||||||
roles: MetricCard[];
|
roles: MetricCard[];
|
||||||
repairs: MetricCard[];
|
repairs: MetricCard[];
|
||||||
|
inventory: MetricCard[];
|
||||||
system_status: SystemStatusItem[];
|
system_status: SystemStatusItem[];
|
||||||
activities: EmptyWidget;
|
activities: EmptyWidget;
|
||||||
tasks: EmptyWidget;
|
tasks: EmptyWidget;
|
||||||
|
|
|
||||||
121
frontend/athena/types/inventory.ts
Normal file
121
frontend/athena/types/inventory.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
export type InventoryMovementType =
|
||||||
|
| "initial"
|
||||||
|
| "purchase"
|
||||||
|
| "adjustment"
|
||||||
|
| "reservation"
|
||||||
|
| "release"
|
||||||
|
| "consumption"
|
||||||
|
| "return"
|
||||||
|
| "correction";
|
||||||
|
|
||||||
|
export interface InventoryCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryLocation {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventorySupplier {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
contact_name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
website: string | null;
|
||||||
|
customer_number: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: number;
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
category_id: number | null;
|
||||||
|
manufacturer: string | null;
|
||||||
|
manufacturer_part_number: string | null;
|
||||||
|
supplier_id: number | null;
|
||||||
|
supplier_part_number: string | null;
|
||||||
|
compatible_devices: string | null;
|
||||||
|
location_id: number | null;
|
||||||
|
quantity_on_hand: number;
|
||||||
|
quantity_reserved: number;
|
||||||
|
quantity_available: number;
|
||||||
|
reorder_level: number;
|
||||||
|
unit: string;
|
||||||
|
purchase_price_cents: number | null;
|
||||||
|
selling_price_cents: number | null;
|
||||||
|
currency: string;
|
||||||
|
tax_rate_percent: string;
|
||||||
|
notes: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
category: InventoryCategory | null;
|
||||||
|
supplier: InventorySupplier | null;
|
||||||
|
location: InventoryLocation | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryItemListResponse {
|
||||||
|
items: InventoryItem[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryStockMovement {
|
||||||
|
id: number;
|
||||||
|
item_id: number;
|
||||||
|
movement_type: InventoryMovementType;
|
||||||
|
quantity: number;
|
||||||
|
reason: string;
|
||||||
|
reference_type: string | null;
|
||||||
|
reference_id: number | null;
|
||||||
|
note: string | null;
|
||||||
|
actor_user_id: number | null;
|
||||||
|
actor_username: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InventoryItemPayload = {
|
||||||
|
sku: string | null;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
category_id: number | null;
|
||||||
|
manufacturer: string | null;
|
||||||
|
manufacturer_part_number: string | null;
|
||||||
|
supplier_id: number | null;
|
||||||
|
supplier_part_number: string | null;
|
||||||
|
compatible_devices: string | null;
|
||||||
|
location_id: number | null;
|
||||||
|
quantity_on_hand: number;
|
||||||
|
quantity_reserved: number;
|
||||||
|
reorder_level: number;
|
||||||
|
unit: string;
|
||||||
|
purchase_price_cents: number | null;
|
||||||
|
selling_price_cents: number | null;
|
||||||
|
currency: string;
|
||||||
|
tax_rate_percent: string;
|
||||||
|
notes: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InventoryStockPayload = {
|
||||||
|
quantity: number;
|
||||||
|
reason: string;
|
||||||
|
note: string | null;
|
||||||
|
reference_type: string | null;
|
||||||
|
reference_id: number | null;
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue