feat(inventory): add spare parts management
This commit is contained in:
parent
abc6e308dd
commit
fb5c2cc26a
43 changed files with 2950 additions and 2 deletions
|
|
@ -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",
|
||||
|
|
|
|||
318
backend/hermes/app/services/inventory_service.py
Normal file
318
backend/hermes/app/services/inventory_service.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue