Olympus/backend/hermes/app/repositories/inventory_repository.py
2026-07-05 11:32:06 +02:00

187 lines
6.7 KiB
Python

from datetime import UTC, datetime
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.inventory import (
InventoryCategory,
InventoryItem,
InventoryLocation,
InventoryStockMovement,
InventorySupplier,
)
class InventoryRepository:
@staticmethod
def item_query() -> Select[tuple[InventoryItem]]:
return select(InventoryItem).options(
selectinload(InventoryItem.category),
selectinload(InventoryItem.supplier),
selectinload(InventoryItem.location),
)
@staticmethod
def list_items(
db: Session,
*,
q: str | None = None,
category_id: int | None = None,
supplier_id: int | None = None,
location_id: int | None = None,
low_stock: bool | None = None,
active: bool | None = True,
limit: int = 50,
offset: int = 0,
) -> tuple[list[InventoryItem], int]:
query = InventoryRepository.item_query()
if q:
pattern = f"%{q.strip()}%"
query = query.where(or_(
InventoryItem.sku.ilike(pattern),
InventoryItem.name.ilike(pattern),
InventoryItem.manufacturer.ilike(pattern),
InventoryItem.manufacturer_part_number.ilike(pattern),
InventoryItem.supplier_part_number.ilike(pattern),
))
if category_id is not None:
query = query.where(InventoryItem.category_id == category_id)
if supplier_id is not None:
query = query.where(InventoryItem.supplier_id == supplier_id)
if location_id is not None:
query = query.where(InventoryItem.location_id == location_id)
if low_stock is not None:
if low_stock:
query = query.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
else:
query = query.where(InventoryItem.quantity_available > InventoryItem.reorder_level)
if active is not None:
query = query.where(InventoryItem.is_active == active)
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
items = list(
db.scalars(
query
.order_by(InventoryItem.name.asc(), InventoryItem.id.asc())
.offset(offset)
.limit(limit)
)
)
return items, total
@staticmethod
def get_item(db: Session, item_id: int) -> InventoryItem | None:
return db.scalar(InventoryRepository.item_query().where(InventoryItem.id == item_id))
@staticmethod
def get_item_by_sku(db: Session, sku: str) -> InventoryItem | None:
return db.scalar(select(InventoryItem).where(InventoryItem.sku == sku))
@staticmethod
def get_next_sku(db: Session, year: int | None = None) -> str:
current_year = year or datetime.now(UTC).year
prefix = f"ET-{current_year}-"
latest = db.scalar(
select(InventoryItem.sku)
.where(InventoryItem.sku.like(f"{prefix}%"))
.order_by(InventoryItem.sku.desc())
.limit(1)
)
next_number = 1
if latest:
next_number = int(latest.split("-")[-1]) + 1
return f"{prefix}{next_number:06d}"
@staticmethod
def list_categories(db: Session) -> list[InventoryCategory]:
return list(db.scalars(select(InventoryCategory).order_by(InventoryCategory.name.asc())))
@staticmethod
def get_category(db: Session, category_id: int) -> InventoryCategory | None:
return db.get(InventoryCategory, category_id)
@staticmethod
def list_locations(db: Session) -> list[InventoryLocation]:
return list(db.scalars(select(InventoryLocation).order_by(InventoryLocation.name.asc())))
@staticmethod
def get_location(db: Session, location_id: int) -> InventoryLocation | None:
return db.get(InventoryLocation, location_id)
@staticmethod
def list_suppliers(db: Session) -> list[InventorySupplier]:
return list(db.scalars(select(InventorySupplier).order_by(InventorySupplier.name.asc())))
@staticmethod
def get_supplier(db: Session, supplier_id: int) -> InventorySupplier | None:
return db.get(InventorySupplier, supplier_id)
@staticmethod
def has_movements(db: Session, item_id: int) -> bool:
return bool(db.scalar(select(InventoryStockMovement.id).where(InventoryStockMovement.item_id == item_id).limit(1)))
@staticmethod
def list_movements(db: Session, item_id: int) -> list[InventoryStockMovement]:
return list(
db.scalars(
select(InventoryStockMovement)
.where(InventoryStockMovement.item_id == item_id)
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
)
)
@staticmethod
def create_movement(
db: Session,
*,
item_id: int,
movement_type: str,
quantity: int,
reason: str,
reference_type: str | None,
reference_id: int | None,
note: str | None,
actor_user_id: int | None,
) -> InventoryStockMovement:
movement = InventoryStockMovement(
item_id=item_id,
movement_type=movement_type,
quantity=quantity,
reason=reason,
reference_type=reference_type,
reference_id=reference_id,
note=note,
actor_user_id=actor_user_id,
)
db.add(movement)
return movement
@staticmethod
def count_active_items(db: Session) -> int:
return db.scalar(select(func.count(InventoryItem.id)).where(InventoryItem.is_active.is_(True))) or 0
@staticmethod
def count_low_stock_items(db: Session) -> int:
return db.scalar(
select(func.count(InventoryItem.id))
.where(InventoryItem.is_active.is_(True))
.where(InventoryItem.quantity_available <= InventoryItem.reorder_level)
) or 0
@staticmethod
def total_stock_value_cents(db: Session) -> int:
return db.scalar(
select(func.coalesce(func.sum(InventoryItem.quantity_on_hand * InventoryItem.purchase_price_cents), 0))
.where(InventoryItem.is_active.is_(True))
.where(InventoryItem.purchase_price_cents.is_not(None))
) or 0
@staticmethod
def latest_movements(db: Session, limit: int = 5) -> list[InventoryStockMovement]:
return list(
db.scalars(
select(InventoryStockMovement)
.order_by(InventoryStockMovement.created_at.desc(), InventoryStockMovement.id.desc())
.limit(limit)
)
)