From 663bdc41b14509f63ee40e89a1c68a4d9f9085a4 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sun, 5 Jul 2026 11:54:23 +0200 Subject: [PATCH] feat(estimates): integrate inventory items --- ARCHITECTURE.md | 16 +- README-DEV.md | 14 +- ROADMAP.md | 13 +- ..._extend_repair_estimate_items_inventory.py | 44 ++++ backend/hermes/app/api/dashboard.py | 3 + backend/hermes/app/api/inventory.py | 12 ++ backend/hermes/app/models/repair_estimate.py | 6 + .../app/repositories/inventory_repository.py | 42 ++++ backend/hermes/app/schemas/repair_estimate.py | 19 +- backend/hermes/app/services/audit_service.py | 5 + .../hermes/app/services/inventory_service.py | 91 ++++++++ .../app/services/repair_estimate_service.py | 201 ++++++++++++++++-- .../app/api/inventory/items/search/route.ts | 7 + .../repairs/RepairEstimatesSection.tsx | 178 +++++++++++++++- frontend/athena/types/repair.ts | 6 + 15 files changed, 629 insertions(+), 28 deletions(-) create mode 100644 backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py create mode 100644 frontend/athena/app/api/inventory/items/search/route.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8134159..58f6fea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -221,7 +221,7 @@ Tabellen: ## 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. +Das Modul `Lager / Ersatzteile` verwaltet Bauteile und Verbrauchsmaterial fuer Werkstattprozesse. Seit v0.8.8 ist es mit Kostenvoranschlaegen verbunden: Mitarbeiter koennen aktive Lagerartikel als KV-Position uebernehmen. Datenfluss: @@ -238,6 +238,9 @@ Kernregeln: - `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. +- Lagerartikel in KV-Positionen werden ueber `inventory_item_id` verknuepft. +- KV-Positionen speichern Snapshot-Felder fuer Name, SKU, Hersteller und Teilenummer, damit ein KV unveraendert bleibt, wenn der Lagerartikel spaeter geaendert wird. +- Verkaufspreis, Einheit, Artikelname und SKU werden serverseitig aus dem Lager uebernommen. Manuelle Preisueberschreibung ist moeglich und wird auditiert. Tabellen: @@ -271,7 +274,16 @@ RBAC-Permissions: 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. +Reservierungslogik: + +- Entwurf: keine Reservierung. +- Senden: verknuepfte Lagerartikel werden reserviert. +- Freigabe: Reservierung bleibt bestehen. +- Ablehnung: Reservierung wird freigegeben. +- Storno: Reservierung wird freigegeben. +- Loeschung eines gesendeten KV: Reservierung wird freigegeben. + +Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, wird aber noch nicht automatisch ausgefuehrt. CSV-Import/Export und Barcode-/QR-Funktionen bleiben vorbereitete Folgefeatures. Hermes-Endpunkte: diff --git a/README-DEV.md b/README-DEV.md index 70c613f..3e515cd 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -228,6 +228,7 @@ SECRET_KEY=local-check uv run alembic upgrade head Athena erreicht Lagerdaten ausschliesslich ueber BFF-Routen: - `/api/inventory/items` +- `/api/inventory/items/search` - `/api/inventory/items/[id]` - `/api/inventory/items/[id]/stock/adjust` - `/api/inventory/items/[id]/stock/reserve` @@ -251,10 +252,19 @@ Bestandslogik: - 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`. +KV-Integration ab v0.8.8: + +- Lagerartikel koennen im KV-Dialog gesucht und als Position uebernommen werden. +- Hermes uebernimmt Verkaufspreis, Einheit, Artikelname, SKU und Hersteller serverseitig. +- `repair_estimate_items` speichert Snapshot-Felder, damit spaetere Lageraenderungen alte KVs nicht veraendern. +- Nur gesendete KVs reservieren Bestand. +- Ablehnung, Storno und Loeschung eines gesendeten KV geben Reservierungen frei. +- Freigegebene KVs behalten die Reservierung. +- Automatischer Verbrauch beim Reparaturabschluss ist vorbereitet, aber noch nicht aktiv. + 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. +- CSV-Template, Import/Export und Barcode-/QR-Funktionen bleiben vorbereitet, sind aber in v0.8.8 nicht aktiv. Vorbereitete Website-/Portal-Routen fuer spaeter: diff --git a/ROADMAP.md b/ROADMAP.md index 3b10ef2..e92efb2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -162,12 +162,19 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - 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 +## v0.8.8 - Lagerintegration in Kostenvoranschläge - 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 +- Snapshot-Konzept fuer Name, SKU, Hersteller und Teilenummer in KV-Positionen +- Reservierung beim Senden eines KV +- Freigabe behaelt Reservierung +- Ablehnung, Storno und Loeschung geben Reservierungen frei +- Bestandsbewegungen mit `reference_type=repair_estimate` +- Dashboard-Kennzahlen fuer reservierte Artikel, reservierten Warenwert und Mindestbestand +- Audit-/Activity-Eintraege fuer Reservierung, Freigabe, Verbrauchsvorbereitung und manuelle Preisueberschreibung +- Automatische Verbrauchsbuchung beim Reparaturabschluss vorbereitet, aber noch nicht aktiv +- CSV-Template und Exportkonzept fuer Lagerdaten bleiben Folgefeatures ## v0.8.9 - Kundenportal, geplant diff --git a/backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py b/backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py new file mode 100644 index 0000000..27dac33 --- /dev/null +++ b/backend/hermes/alembic/versions/f4a9c2d7e118_extend_repair_estimate_items_inventory.py @@ -0,0 +1,44 @@ +"""extend repair estimate items inventory + +Revision ID: f4a9c2d7e118 +Revises: ef8a2d5c9017 +Create Date: 2026-07-05 13:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "f4a9c2d7e118" +down_revision: Union[str, Sequence[str], None] = "ef8a2d5c9017" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("repair_estimate_items", sa.Column("inventory_item_id", sa.Integer(), nullable=True)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_name", sa.String(length=255), server_default="", nullable=False)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_sku", sa.String(length=40), server_default="", nullable=False)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_manufacturer", sa.String(length=180), nullable=True)) + op.add_column("repair_estimate_items", sa.Column("inventory_snapshot_part_number", sa.String(length=180), nullable=True)) + op.create_index(op.f("ix_repair_estimate_items_inventory_item_id"), "repair_estimate_items", ["inventory_item_id"], unique=False) + op.create_foreign_key( + "fk_repair_estimate_items_inventory_item_id", + "repair_estimate_items", + "inventory_items", + ["inventory_item_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_repair_estimate_items_inventory_item_id", "repair_estimate_items", type_="foreignkey") + op.drop_index(op.f("ix_repair_estimate_items_inventory_item_id"), table_name="repair_estimate_items") + op.drop_column("repair_estimate_items", "inventory_snapshot_part_number") + op.drop_column("repair_estimate_items", "inventory_snapshot_manufacturer") + op.drop_column("repair_estimate_items", "inventory_snapshot_sku") + op.drop_column("repair_estimate_items", "inventory_snapshot_name") + op.drop_column("repair_estimate_items", "inventory_item_id") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index c3201b4..a63f72d 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -89,6 +89,9 @@ def get_dashboard_summary( 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="Reservierte Artikel", value=InventoryRepository.count_reserved_items(db)), + MetricCard(label="Reservierter Warenwert", value=InventoryRepository.reserved_stock_value_cents(db)), + MetricCard(label="Artikel unter Mindestbestand", value=InventoryRepository.count_low_stock_items(db)), MetricCard(label="Letzte Bewegungen", value=len(InventoryRepository.latest_movements(db, limit=5))), ] diff --git a/backend/hermes/app/api/inventory.py b/backend/hermes/app/api/inventory.py index b09f534..8fd3466 100644 --- a/backend/hermes/app/api/inventory.py +++ b/backend/hermes/app/api/inventory.py @@ -81,6 +81,18 @@ def list_items( return InventoryItemListResponse(items=items, total=total, limit=limit, offset=offset) +@router.get("/items/search", response_model=list[InventoryItemResponse]) +def search_items( + q: str | None = None, + category: int | None = None, + manufacturer: str | None = None, + limit: int = Query(default=20, ge=1, le=50), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("inventory.read")), +): + return InventoryRepository.search_items(db, q=q, category_id=category, manufacturer=manufacturer, limit=limit) + + @router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED) def create_item( payload: InventoryItemCreate, diff --git a/backend/hermes/app/models/repair_estimate.py b/backend/hermes/app/models/repair_estimate.py index 81ec866..696a060 100644 --- a/backend/hermes/app/models/repair_estimate.py +++ b/backend/hermes/app/models/repair_estimate.py @@ -52,6 +52,11 @@ class RepairEstimateItem(Base): id: Mapped[int] = mapped_column(primary_key=True) estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True) + inventory_item_id: Mapped[int | None] = mapped_column(ForeignKey("inventory_items.id", ondelete="SET NULL"), nullable=True, index=True) + inventory_snapshot_name: Mapped[str] = mapped_column(String(255), default="", server_default="") + inventory_snapshot_sku: Mapped[str] = mapped_column(String(40), default="", server_default="") + inventory_snapshot_manufacturer: Mapped[str | None] = mapped_column(String(180), nullable=True) + inventory_snapshot_part_number: Mapped[str | None] = mapped_column(String(180), nullable=True) position: Mapped[int] = mapped_column(Integer) item_type: Mapped[str] = mapped_column(String(40), index=True) title: Mapped[str] = mapped_column(String(255)) @@ -64,6 +69,7 @@ class RepairEstimateItem(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) estimate: Mapped[RepairEstimate] = relationship(back_populates="items") + inventory_item = relationship("InventoryItem", lazy="joined") class RepairEstimateEvent(Base): diff --git a/backend/hermes/app/repositories/inventory_repository.py b/backend/hermes/app/repositories/inventory_repository.py index ff2fe8e..19c2db2 100644 --- a/backend/hermes/app/repositories/inventory_repository.py +++ b/backend/hermes/app/repositories/inventory_repository.py @@ -69,6 +69,32 @@ class InventoryRepository: ) return items, total + @staticmethod + def search_items( + db: Session, + *, + q: str | None = None, + category_id: int | None = None, + manufacturer: str | None = None, + limit: int = 20, + ) -> list[InventoryItem]: + query = InventoryRepository.item_query().where(InventoryItem.is_active.is_(True)) + 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), + InventoryItem.compatible_devices.ilike(pattern), + )) + if category_id is not None: + query = query.where(InventoryItem.category_id == category_id) + if manufacturer: + query = query.where(InventoryItem.manufacturer.ilike(f"%{manufacturer.strip()}%")) + return list(db.scalars(query.order_by(InventoryItem.name.asc(), InventoryItem.id.asc()).limit(limit))) + @staticmethod def get_item(db: Session, item_id: int) -> InventoryItem | None: return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id)) @@ -176,6 +202,22 @@ class InventoryRepository: .where(InventoryItem.purchase_price_cents.is_not(None)) ) or 0 + @staticmethod + def count_reserved_items(db: Session) -> int: + return db.scalar( + select(func.count(InventoryItem.id)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.quantity_reserved > 0) + ) or 0 + + @staticmethod + def reserved_stock_value_cents(db: Session) -> int: + return db.scalar( + select(func.coalesce(func.sum(InventoryItem.quantity_reserved * InventoryItem.selling_price_cents), 0)) + .where(InventoryItem.is_active.is_(True)) + .where(InventoryItem.selling_price_cents.is_not(None)) + ) or 0 + @staticmethod def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]: return list( diff --git a/backend/hermes/app/schemas/repair_estimate.py b/backend/hermes/app/schemas/repair_estimate.py index 3410a04..75b942d 100644 --- a/backend/hermes/app/schemas/repair_estimate.py +++ b/backend/hermes/app/schemas/repair_estimate.py @@ -18,7 +18,9 @@ def normalize_text(value: object) -> str: class RepairEstimateItemPayload(BaseModel): item_type: EstimateItemType = "other" - title: str = Field(min_length=1, max_length=255) + inventory_item_id: int | None = None + inventory_price_overridden: bool = False + title: str = Field(default="", max_length=255) description: str | None = None quantity: Decimal = Field(gt=Decimal("0")) unit: str = Field(default="Stk.", max_length=40) @@ -38,6 +40,12 @@ class RepairEstimateItemPayload(BaseModel): return value.strip().replace(",", ".") return value + @model_validator(mode="after") + def validate_inventory_or_title(self): + if self.inventory_item_id is None and not self.title.strip(): + raise ValueError("Titel ist erforderlich") + return self + class RepairEstimatePayload(BaseModel): title: str = Field(min_length=1, max_length=255) @@ -85,6 +93,11 @@ class RepairEstimateUpdate(RepairEstimatePayload): class RepairEstimateItemResponse(BaseModel): id: int estimate_id: int + inventory_item_id: int | None + inventory_snapshot_name: str + inventory_snapshot_sku: str + inventory_snapshot_manufacturer: str | None + inventory_snapshot_part_number: str | None position: int item_type: EstimateItemType title: str @@ -139,6 +152,10 @@ class RepairEstimateResponse(BaseModel): class PublicEstimateItemResponse(BaseModel): item_type: EstimateItemType + inventory_snapshot_name: str + inventory_snapshot_sku: str + inventory_snapshot_manufacturer: str | None + inventory_snapshot_part_number: str | None title: str description: str | None quantity: Decimal diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index d46ef66..d986106 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -188,6 +188,11 @@ def action_title(action: str) -> str: "inventory.stock.reserve": "Bestand reserviert", "inventory.stock.release": "Reservierung aufgehoben", "inventory.stock.consume": "Bestand verbraucht", + "inventory.estimate.reserve": "KV reserviert Lagerbestand", + "inventory.estimate.release": "KV-Reservierung aufgehoben", + "inventory.estimate.consume": "Reservierung verbraucht", + "inventory.estimate.price_override": "Lagerartikelpreis manuell überschrieben", + "inventory.stock.low": "Lagerbestand knapp", "inventory.categories.create": "Lagerkategorie erstellt", "inventory.categories.update": "Lagerkategorie geändert", "inventory.categories.delete": "Lagerkategorie gelöscht", diff --git a/backend/hermes/app/services/inventory_service.py b/backend/hermes/app/services/inventory_service.py index 4960557..01b082c 100644 --- a/backend/hermes/app/services/inventory_service.py +++ b/backend/hermes/app/services/inventory_service.py @@ -1,5 +1,6 @@ import re import unicodedata +from decimal import Decimal from typing import TypeVar from fastapi import HTTPException, status @@ -8,6 +9,7 @@ from sqlalchemy.orm import Session from starlette.requests import Request from app.models.inventory import InventoryCategory, InventoryItem, InventoryLocation, InventorySupplier +from app.models.repair_estimate import RepairEstimate from app.models.user import User from app.repositories.inventory_repository import InventoryRepository from app.schemas.inventory import ( @@ -194,6 +196,83 @@ class InventoryService: 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 reserve_for_estimate(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None: + for estimate_item in estimate.items: + if estimate_item.inventory_item_id is None: + continue + item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) + if item is None or not item.is_active: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Lagerartikel für Position {estimate_item.position} ist nicht mehr aktiv") + quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position) + if quantity > item.quantity_available: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Nicht genügend verfügbarer Bestand für {item.sku} · {item.name}", + ) + item.quantity_reserved += quantity + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="reservation", + quantity=quantity, + reason="estimate_reserved", + reference_type="repair_estimate", + reference_id=estimate.id, + note=f"Kostenvoranschlag {estimate.estimate_number}", + actor_user_id=actor_user_id, + ) + + @staticmethod + def release_estimate_reservation(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None: + for estimate_item in estimate.items: + if estimate_item.inventory_item_id is None: + continue + item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) + if item is None: + continue + quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position) + item.quantity_reserved = max(0, item.quantity_reserved - quantity) + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="release", + quantity=quantity, + reason="estimate_released", + reference_type="repair_estimate", + reference_id=estimate.id, + note=f"Kostenvoranschlag {estimate.estimate_number}", + actor_user_id=actor_user_id, + ) + + @staticmethod + def consume_reserved_stock(db: Session, estimate: RepairEstimate, *, actor_user_id: int | None) -> None: + for estimate_item in estimate.items: + if estimate_item.inventory_item_id is None: + continue + item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) + if item is None: + continue + quantity = InventoryService._estimate_quantity_to_int(estimate_item.quantity, estimate_item.position) + if quantity > item.quantity_reserved or quantity > item.quantity_on_hand: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"Reservierter Bestand für {item.sku} reicht nicht aus") + item.quantity_reserved -= quantity + item.quantity_on_hand -= quantity + InventoryService._recalculate_available(item) + InventoryRepository.create_movement( + db, + item_id=item.id, + movement_type="consumption", + quantity=quantity, + reason="estimate_consumed", + reference_type="repair_estimate", + reference_id=estimate.id, + note=f"Kostenvoranschlag {estimate.estimate_number}", + actor_user_id=actor_user_id, + ) + @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) @@ -260,6 +339,18 @@ class InventoryService: if payload.quantity <= 0: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Menge muss größer 0 sein") + @staticmethod + def _estimate_quantity_to_int(quantity: Decimal, position: int) -> int: + if quantity != quantity.to_integral_value(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Lagerposition {position} muss eine ganze Menge verwenden", + ) + normalized = int(quantity) + if normalized <= 0: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Lagerposition {position} benötigt eine Menge größer 0") + return normalized + @staticmethod def _add_movement(db: Session, item: InventoryItem, movement_type: str, payload: InventoryStockAction, *, actor_user_id: int) -> None: InventoryService._recalculate_available(item) diff --git a/backend/hermes/app/services/repair_estimate_service.py b/backend/hermes/app/services/repair_estimate_service.py index e933587..b9b4ee8 100644 --- a/backend/hermes/app/services/repair_estimate_service.py +++ b/backend/hermes/app/services/repair_estimate_service.py @@ -10,6 +10,7 @@ from starlette.requests import Request from app.models.repair import Repair from app.models.repair_estimate import RepairEstimate, RepairEstimateItem from app.models.user import User +from app.repositories.inventory_repository import InventoryRepository from app.repositories.repair_estimate_repository import RepairEstimateRepository from app.repositories.repair_repository import RepairRepository from app.schemas.repair import RepairStatusUpdate @@ -24,6 +25,7 @@ from app.schemas.repair_estimate import ( calculate_tax, ) from app.services.audit_service import write_audit_log +from app.services.inventory_service import InventoryService from app.services.repair_public_link_service import RepairPublicLinkService from app.services.system_settings_service import SystemSettingsService @@ -53,6 +55,7 @@ def _audit_estimate_data(estimate: RepairEstimate) -> dict: "approved_at": estimate.approved_at, "declined_at": estimate.declined_at, "created_by_user_id": estimate.created_by_user_id, + "inventory_item_ids": [item.inventory_item_id for item in estimate.items if item.inventory_item_id is not None], } @@ -66,7 +69,8 @@ class RepairEstimateService: status="draft", created_by_user_id=actor.id, ) - RepairEstimateService._apply_payload(estimate, payload) + price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items) + RepairEstimateService._apply_payload(db, estimate, payload) try: db.add(estimate) db.flush() @@ -92,6 +96,7 @@ class RepairEstimateService: after_data=_audit_estimate_data(estimate), metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, ) + RepairEstimateService._write_price_override_audits(db, estimate, price_overrides, actor=actor, request=request) return estimate @staticmethod @@ -99,7 +104,24 @@ class RepairEstimateService: if estimate.status not in {"draft", "sent"}: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden") before_data = _audit_estimate_data(estimate) - RepairEstimateService._apply_payload(estimate, payload) + price_overrides = RepairEstimateService._collect_price_overrides(db, payload.items) + was_sent = estimate.status == "sent" + if was_sent: + InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id) + RepairEstimateService._apply_payload(db, estimate, payload) + if was_sent: + InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id) + write_audit_log( + db, + action="inventory.estimate.reserve", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + actor=actor, + request=request, + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request) RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="updated", actor_type="user", actor_user_id=actor.id, commit=False) db.commit() db.refresh(estimate) @@ -116,14 +138,27 @@ class RepairEstimateService: after_data=_audit_estimate_data(updated), metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, ) + RepairEstimateService._write_price_override_audits(db, updated, price_overrides, actor=actor, request=request) return updated @staticmethod def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None: - if estimate.status not in {"draft", "cancelled"}: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe oder stornierte Kostenvoranschläge können gelöscht werden") + if estimate.status not in {"draft", "sent", "cancelled"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe, gesendete oder stornierte Kostenvoranschläge können gelöscht werden") before_data = _audit_estimate_data(estimate) label = _estimate_label(estimate) + if estimate.status == "sent": + InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id) + write_audit_log( + db, + action="inventory.estimate.release", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=label, + actor=actor, + request=request, + metadata={"reason": "estimate_deleted", "repair_id": repair.id, "repair_number": repair.repair_number}, + ) RepairEstimateRepository.delete(db, estimate) write_audit_log( db, @@ -145,6 +180,10 @@ class RepairEstimateService: if not repair.customer_email: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Für diese Reparatur ist keine Kunden-E-Mail hinterlegt") + was_sent = estimate.status == "sent" + if not was_sent: + InventoryService.reserve_for_estimate(db, estimate, actor_user_id=actor.id) + link = RepairPublicLinkService.create_with_audit( db, repair, @@ -155,6 +194,18 @@ class RepairEstimateService: public_status_url = link.public_status_path estimate = RepairEstimateRepository.mark_sent(db, estimate) RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="sent", actor_type="user", actor_user_id=actor.id) + if not was_sent: + write_audit_log( + db, + action="inventory.estimate.reserve", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + actor=actor, + request=request, + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + RepairEstimateService._write_low_stock_audits(db, estimate, actor=actor, request=request) smtp_config = SystemSettingsService.get_smtp_runtime_config(db) mail_sent = False if smtp_config.is_configured: @@ -200,6 +251,9 @@ class RepairEstimateService: def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate: if estimate.status in {"approved", "declined", "cancelled"}: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden") + was_sent = estimate.status == "sent" + if was_sent: + InventoryService.release_estimate_reservation(db, estimate, actor_user_id=actor.id) estimate.status = "cancelled" RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False) db.commit() @@ -214,6 +268,17 @@ class RepairEstimateService: request=request, metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, ) + if was_sent: + write_audit_log( + db, + action="inventory.estimate.release", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + actor=actor, + request=request, + metadata={"reason": "estimate_cancelled", "repair_id": repair.id, "repair_number": repair.repair_number}, + ) return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate @staticmethod @@ -233,6 +298,10 @@ class RepairEstimateService: items=[ PublicEstimateItemResponse( item_type=item.item_type, + inventory_snapshot_name=item.inventory_snapshot_name, + inventory_snapshot_sku=item.inventory_snapshot_sku, + inventory_snapshot_manufacturer=item.inventory_snapshot_manufacturer, + inventory_snapshot_part_number=item.inventory_snapshot_part_number, title=item.title, description=item.description, quantity=item.quantity, @@ -272,6 +341,7 @@ class RepairEstimateService: repair_status = "waiting_for_customer" note = "Kunde hat den Kostenvoranschlag abgelehnt" audit_action = "repair_estimates.decline" + InventoryService.release_estimate_reservation(db, estimate, actor_user_id=None) else: event_type = "question" repair_status = "waiting_for_customer" @@ -291,17 +361,27 @@ class RepairEstimateService: request=request, metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"}, ) + if decision == "decline": + write_audit_log( + db, + action="inventory.estimate.release", + entity_type="repair_estimates", + entity_id=estimate.id, + entity_label=_estimate_label(estimate), + request=request, + metadata={"reason": "estimate_declined", "repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"}, + ) return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate @staticmethod - def _apply_payload(estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None: + def _apply_payload(db: Session, estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None: estimate.title = payload.title estimate.customer_message = payload.customer_message estimate.internal_note = payload.internal_note estimate.tax_rate_percent = payload.tax_rate_percent estimate.currency = payload.currency estimate.valid_until = payload.valid_until - items, subtotal = RepairEstimateService._build_items(payload.items) + items, subtotal = RepairEstimateService._build_items(db, payload.items) tax_cents = calculate_tax(subtotal, payload.tax_rate_percent) estimate.subtotal_cents = subtotal estimate.tax_cents = tax_cents @@ -309,26 +389,121 @@ class RepairEstimateService: estimate.items = items @staticmethod - def _build_items(payload_items: list[RepairEstimateItemPayload]) -> tuple[list[RepairEstimateItem], int]: + def _build_items( + db: Session, + payload_items: list[RepairEstimateItemPayload], + ) -> tuple[list[RepairEstimateItem], int]: items: list[RepairEstimateItem] = [] subtotal = 0 for index, payload in enumerate(payload_items, start=1): - total = calculate_item_total(payload.quantity, payload.unit_price_cents) + item_type = payload.item_type + title = payload.title + description = payload.description + unit = payload.unit + unit_price_cents = payload.unit_price_cents + inventory_item_id = payload.inventory_item_id + inventory_snapshot_name = "" + inventory_snapshot_sku = "" + inventory_snapshot_manufacturer = None + inventory_snapshot_part_number = None + if inventory_item_id is not None: + inventory_item = InventoryRepository.get_item(db, inventory_item_id) + if inventory_item is None or not inventory_item.is_active: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Lagerartikel in Position {index} nicht gefunden") + item_type = "part" + title = inventory_item.name + unit = inventory_item.unit + inventory_snapshot_name = inventory_item.name + inventory_snapshot_sku = inventory_item.sku + inventory_snapshot_manufacturer = inventory_item.manufacturer + inventory_snapshot_part_number = inventory_item.manufacturer_part_number + if not payload.inventory_price_overridden: + unit_price_cents = inventory_item.selling_price_cents or 0 + total = calculate_item_total(payload.quantity, unit_price_cents) subtotal += total items.append( RepairEstimateItem( + inventory_item_id=inventory_item_id, + inventory_snapshot_name=inventory_snapshot_name, + inventory_snapshot_sku=inventory_snapshot_sku, + inventory_snapshot_manufacturer=inventory_snapshot_manufacturer, + inventory_snapshot_part_number=inventory_snapshot_part_number, position=index, - item_type=payload.item_type, - title=payload.title, - description=payload.description, + item_type=item_type, + title=title, + description=description, quantity=payload.quantity, - unit=payload.unit, - unit_price_cents=payload.unit_price_cents, + unit=unit, + unit_price_cents=unit_price_cents, total_cents=total, ) ) return items, subtotal + @staticmethod + def _collect_price_overrides(db: Session, payload_items: list[RepairEstimateItemPayload]) -> list[dict]: + overrides: list[dict] = [] + for payload in payload_items: + if payload.inventory_item_id is None or not payload.inventory_price_overridden: + continue + inventory_item = InventoryRepository.get_item(db, payload.inventory_item_id) + if inventory_item is None: + continue + default_price = inventory_item.selling_price_cents or 0 + if payload.unit_price_cents != default_price: + overrides.append({ + "inventory_item_id": inventory_item.id, + "label": f"{inventory_item.sku} · {inventory_item.name}", + "default_price_cents": default_price, + "override_price_cents": payload.unit_price_cents, + }) + return overrides + + @staticmethod + def _write_price_override_audits(db: Session, estimate: RepairEstimate, overrides: list[dict], *, actor: User, request: Request) -> None: + for override in overrides: + write_audit_log( + db, + action="inventory.estimate.price_override", + entity_type="inventory_items", + entity_id=override["inventory_item_id"], + entity_label=override["label"], + actor=actor, + request=request, + metadata={ + "estimate_id": estimate.id, + "estimate_number": estimate.estimate_number, + "default_price_cents": override["default_price_cents"], + "override_price_cents": override["override_price_cents"], + }, + ) + + @staticmethod + def _write_low_stock_audits(db: Session, estimate: RepairEstimate, *, actor: User, request: Request) -> None: + seen_item_ids: set[int] = set() + for estimate_item in estimate.items: + if estimate_item.inventory_item_id is None or estimate_item.inventory_item_id in seen_item_ids: + continue + seen_item_ids.add(estimate_item.inventory_item_id) + inventory_item = InventoryRepository.get_item(db, estimate_item.inventory_item_id) + if inventory_item is None or inventory_item.quantity_available > inventory_item.reorder_level: + continue + write_audit_log( + db, + action="inventory.stock.low", + entity_type="inventory_items", + entity_id=inventory_item.id, + entity_label=f"{inventory_item.sku} · {inventory_item.name}", + actor=actor, + request=request, + metadata={ + "estimate_id": estimate.id, + "estimate_number": estimate.estimate_number, + "quantity_available": inventory_item.quantity_available, + "reorder_level": inventory_item.reorder_level, + }, + ) + @staticmethod def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str: return ( diff --git a/frontend/athena/app/api/inventory/items/search/route.ts b/frontend/athena/app/api/inventory/items/search/route.ts new file mode 100644 index 0000000..7ce787d --- /dev/null +++ b/frontend/athena/app/api/inventory/items/search/route.ts @@ -0,0 +1,7 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, `/inventory/items/search${request.nextUrl.search}`); +} diff --git a/frontend/athena/components/repairs/RepairEstimatesSection.tsx b/frontend/athena/components/repairs/RepairEstimatesSection.tsx index b924bfa..94de38e 100644 --- a/frontend/athena/components/repairs/RepairEstimatesSection.tsx +++ b/frontend/athena/components/repairs/RepairEstimatesSection.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; -import { FileCheck2, Plus, Send, Trash2, XCircle } from "lucide-react"; +import { AlertTriangle, FileCheck2, PackageSearch, Plus, Send, Trash2, XCircle } from "lucide-react"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import { useToast } from "@/components/common/ToastProvider"; @@ -17,6 +17,10 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { api } from "@/lib/api"; +import type { + InventoryCategory, + InventoryItem, +} from "@/types/inventory"; import type { RepairEstimate, RepairEstimateItemType, @@ -89,6 +93,12 @@ function dateTime(value: string | null) { type EstimateFormItem = { item_type: RepairEstimateItemType; + inventory_item_id: number | null; + inventory_snapshot_name: string; + inventory_snapshot_sku: string; + inventory_snapshot_manufacturer: string | null; + inventory_snapshot_part_number: string | null; + inventory_price_overridden: boolean; title: string; description: string | null; quantity: string; @@ -118,6 +128,12 @@ function centsToEuroInput(cents: number): string { function emptyItem(): EstimateFormItem { return { item_type: "labor", + inventory_item_id: null, + inventory_snapshot_name: "", + inventory_snapshot_sku: "", + inventory_snapshot_manufacturer: null, + inventory_snapshot_part_number: null, + inventory_price_overridden: false, title: "", description: "", quantity: "1.00", @@ -167,6 +183,14 @@ export default function RepairEstimatesSection({ const [saving, setSaving] = useState(false); const [pendingId, setPendingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [inventoryDialogOpen, setInventoryDialogOpen] = useState(false); + const [inventoryItems, setInventoryItems] = useState([]); + const [inventoryCategories, setInventoryCategories] = useState([]); + const [inventorySearch, setInventorySearch] = useState(""); + const [inventoryCategory, setInventoryCategory] = useState("all"); + const [inventoryManufacturer, setInventoryManufacturer] = useState(""); + const [inventoryLoading, setInventoryLoading] = useState(false); + const [inventoryError, setInventoryError] = useState(""); const loadEstimates = useCallback(async () => { if (!canRead) { @@ -191,6 +215,38 @@ export default function RepairEstimatesSection({ }); }, [loadEstimates]); + const searchInventoryItems = useCallback(async () => { + setInventoryLoading(true); + setInventoryError(""); + try { + const params = new URLSearchParams(); + params.set("limit", "20"); + if (inventorySearch.trim()) params.set("q", inventorySearch.trim()); + if (inventoryCategory !== "all") params.set("category", inventoryCategory); + if (inventoryManufacturer.trim()) params.set("manufacturer", inventoryManufacturer.trim()); + const [itemsResponse, categoriesResponse] = await Promise.all([ + api.get(`/inventory/items/search?${params.toString()}`), + api.get("/inventory/categories"), + ]); + setInventoryItems(itemsResponse.data); + setInventoryCategories(categoriesResponse.data); + } catch (err) { + setInventoryError(getErrorMessage(err)); + } finally { + setInventoryLoading(false); + } + }, [inventoryCategory, inventoryManufacturer, inventorySearch]); + + useEffect(() => { + if (!inventoryDialogOpen) { + return; + } + const id = window.setTimeout(() => { + void searchInventoryItems(); + }, 200); + return () => window.clearTimeout(id); + }, [inventoryDialogOpen, searchInventoryItems]); + const clientSubtotal = useMemo(() => payload.items.reduce((sum, item) => { const quantity = Number(item.quantity.replace(",", ".")) || 0; const unitPriceCents = parseEuroToCents(item.unit_price_euros) ?? 0; @@ -215,6 +271,12 @@ export default function RepairEstimatesSection({ valid_until: estimate.valid_until, items: estimate.items.map((item) => ({ item_type: item.item_type, + inventory_item_id: item.inventory_item_id ?? null, + inventory_snapshot_name: item.inventory_snapshot_name, + inventory_snapshot_sku: item.inventory_snapshot_sku, + inventory_snapshot_manufacturer: item.inventory_snapshot_manufacturer, + inventory_snapshot_part_number: item.inventory_snapshot_part_number, + inventory_price_overridden: false, title: item.title, description: item.description ?? "", quantity: String(item.quantity), @@ -232,6 +294,25 @@ export default function RepairEstimatesSection({ })); } + function addInventoryItem(item: InventoryItem) { + const formItem: EstimateFormItem = { + item_type: "part", + inventory_item_id: item.id, + inventory_snapshot_name: item.name, + inventory_snapshot_sku: item.sku, + inventory_snapshot_manufacturer: item.manufacturer, + inventory_snapshot_part_number: item.manufacturer_part_number, + inventory_price_overridden: false, + title: item.name, + description: item.description ?? "", + quantity: "1", + unit: item.unit, + unit_price_euros: centsToEuroInput(item.selling_price_cents ?? 0), + }; + setPayload((current) => ({ ...current, items: [...current.items, formItem] })); + setInventoryDialogOpen(false); + } + async function saveEstimate() { setSaving(true); try { @@ -254,6 +335,8 @@ export default function RepairEstimatesSection({ valid_until: payload.valid_until || null, items: payload.items.map((item) => ({ item_type: item.item_type, + inventory_item_id: item.inventory_item_id, + inventory_price_overridden: item.inventory_price_overridden, title: item.title.trim(), description: item.description?.trim() || null, quantity: item.quantity.replace(",", "."), @@ -371,7 +454,15 @@ export default function RepairEstimatesSection({

{item.position}. {item.title}

-

{itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)}

+

+ {item.inventory_item_id ? `Lagerartikel · ${item.inventory_snapshot_sku} · ` : ""} + {itemTypeLabels[item.item_type]} · {item.quantity} {item.unit} × {money(item.unit_price_cents, estimate.currency)} +

+ {item.inventory_item_id && ( +

+ {item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"} · {item.inventory_snapshot_name} +

+ )}

{money(item.total_cents, estimate.currency)}

@@ -409,18 +500,32 @@ export default function RepairEstimatesSection({

Positionen

- +
+ + +
{payload.items.map((item, index) => (
+ {item.inventory_item_id && ( +
+
+ Lagerartikel · {item.inventory_snapshot_sku} · {item.inventory_snapshot_manufacturer || "Hersteller nicht angegeben"} +
+ +
+ )}
- updateItem(index, { item_type: event.target.value as RepairEstimateItemType })}> {Object.entries(itemTypeLabels).map(([value, label]) => )} - updateItem(index, { title: event.target.value })} /> + updateItem(index, { title: event.target.value })} /> updateItem(index, { quantity: event.target.value })} /> - updateItem(index, { unit: event.target.value })} /> - updateItem(index, { unit_price_euros: event.target.value })} /> + updateItem(index, { unit: event.target.value })} /> + updateItem(index, { unit_price_euros: event.target.value })} />
updateItem(index, { description: event.target.value })} /> @@ -442,6 +547,65 @@ export default function RepairEstimatesSection({ + + + + Lagerartikel auswählen + Aktive Artikel werden als KV-Position übernommen. Preis und Stammdaten werden beim Speichern serverseitig geprüft. + + +
+
+ setInventorySearch(event.target.value)} /> + + setInventoryManufacturer(event.target.value)} /> +
+ + {inventoryError ? ( +

{inventoryError}

+ ) : inventoryLoading ? ( +

Lagerartikel werden geladen...

+ ) : inventoryItems.length === 0 ? ( +

Keine aktiven Lagerartikel gefunden.

+ ) : ( +
+ {inventoryItems.map((item) => { + const lowAfterSelection = item.quantity_available - 1 <= item.reorder_level; + return ( + + ); + })} +
+ )} +
+ + + + +
+
+