318 lines
14 KiB
Python
318 lines
14 KiB
Python
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
|