feat(inventory): add spare parts management

This commit is contained in:
Schubert Ferenc 2026-07-05 11:32:06 +02:00
parent abc6e308dd
commit fb5c2cc26a
43 changed files with 2950 additions and 2 deletions

View file

@ -180,6 +180,23 @@ def action_title(action: str) -> str:
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
"repair_estimates.cancel": "Kostenvoranschlag storniert",
"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.test_sent": "SMTP-Testmail versendet",
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",

View 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