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 search_items( db: Session, *, q: str | None = None, category_id: int | None = None, manufacturer: str | None = None, limit: int = 20, ) -> list[InventoryItem]: query = InventoryRepository.item_query().where(InventoryItem.is_active.is_(True)) 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), InventoryItem.compatible_devices.ilike(pattern), )) if category_id is not None: query = query.where(InventoryItem.category_id == category_id) if manufacturer: query = query.where(InventoryItem.manufacturer.ilike(f"%{manufacturer.strip()}%")) return list(db.scalars(query.order_by(InventoryItem.name.asc(), InventoryItem.id.asc()).limit(limit))) @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 count_reserved_items(db: Session) -> int: return db.scalar( select(func.count(InventoryItem.id)) .where(InventoryItem.is_active.is_(True)) .where(InventoryItem.quantity_reserved > 0) ) or 0 @staticmethod def reserved_stock_value_cents(db: Session) -> int: return db.scalar( select(func.coalesce(func.sum(InventoryItem.quantity_reserved * InventoryItem.selling_price_cents), 0)) .where(InventoryItem.is_active.is_(True)) .where(InventoryItem.selling_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) ) )