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