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