187 lines
6.7 KiB
Python
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)
|
|
)
|
|
)
|