Olympus/backend/hermes/app/services/inventory_service.py
2026-07-05 12:26:45 +02:00

415 lines
19 KiB
Python

import re
import unicodedata
from decimal import Decimal
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.repair_estimate import RepairEstimate
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 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,
reason: str = "estimate_released",
) -> 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=reason,
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)
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 _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)
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