ifixkart-backend/app/api/v1/routers/InventoryRouter.py

164 lines
6 KiB
Python

"""
@router InventoryRouter (Backend/app/api/v1/routers/InventoryRouter.py)
@purpose Database-driven inventory management controller querying stock metrics from the append-only inventory ledger.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import select, func
from pydantic import BaseModel
from typing import Optional
from app.core.database.db_session import get_db
from app.core.permissions.RoleChecker import RoleChecker
from app.models.UserModel import User
from app.models.ProductModel import Product, ProductVariant
from app.services.InventoryService import get_available_stock_map, get_stock_metrics, record_ledger_entry
from app.services.CatalogSearchService import sku_search_clause
router = APIRouter(prefix="/api/v1/inventory", tags=["Inventory Ledger & Balances"])
class StockAdjustRequest(BaseModel):
variant_id: str
event_type: str # RECEIPT, DAMAGE, RETURN...
qty: int
notes: Optional[str] = None
def clean_variant_suffix(attributes, product_name: str) -> str:
clean_vals = []
seen = set()
p_name_lower = product_name.lower()
for attr in attributes:
val = (getattr(attr, "attribute_value", "") or "").strip()
if not val:
continue
val_lower = val.lower()
# Skip numeric values (prices, internal counts, IDs)
try:
float(val)
continue
except ValueError:
pass
# Skip hyphenated slugs (e.g. apple-back-covers-starlight-white)
if "-" in val and " " not in val and len(val) > 8:
continue
# Skip values already in product name
if val_lower in p_name_lower:
continue
# Skip generic meta tags / regions / packaging
if val_lower in ("accessories", "device only", "india", "taiwan", "china", "device, cable, manual", "accessories", "new", "refurbished"):
continue
if val_lower not in seen:
seen.add(val_lower)
clean_vals.append(val)
return " / ".join(clean_vals[:3])
@router.get("/skus")
def list_sellable_skus(
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=500),
q: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
):
"""
Paginated sellable product variants with live ledger quantities.
Never loads the full catalog (safe at 500k+ SKUs).
"""
stmt = select(ProductVariant, Product).join(Product, Product.product_id == ProductVariant.product_id).options(selectinload(ProductVariant.attributes))
clause = sku_search_clause(q)
if clause is not None:
stmt = stmt.where(clause)
total = db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
rows = db.execute(
stmt.order_by(Product.name.asc(), ProductVariant.sku.asc())
.offset((page - 1) * limit)
.limit(limit)
).all()
variant_ids = [variant.variant_id for variant, _product in rows]
stock_map = get_available_stock_map(variant_ids, db)
from app.services.InventoryService import get_pending_confirmation_units_map, get_confirmed_units_map
pending_map = get_pending_confirmation_units_map(variant_ids, db)
confirmed_map = get_confirmed_units_map(variant_ids, db)
items = []
for variant, product in rows:
available = stock_map.get(variant.variant_id, 0)
# Build clean product name suffix specifying key variant attributes (e.g. Color, Storage, Model)
variant_suffix = clean_variant_suffix(variant.attributes, product.name)
display_name = f"{product.name} ({variant_suffix})" if variant_suffix else product.name
items.append({
"variant_id": variant.variant_id,
"product_id": product.product_id,
"product_name": display_name,
"sku": variant.sku,
"barcode": variant.barcode,
"price": float(variant.price),
"cost_price": float(variant.cost_price),
"available_stock": available,
"pending_confirmation_units": pending_map.get(variant.variant_id, 0),
"confirmed_units": confirmed_map.get(variant.variant_id, 0),
"low_stock_threshold": variant.low_stock_threshold,
"status": variant.status,
"is_low": available <= (variant.low_stock_threshold or 0),
})
return {
"total": total,
"page": page,
"limit": limit,
"items": items,
}
@router.get("/stock/{variant_id}")
def get_variant_stock(variant_id: str, db: Session = Depends(get_db)):
"""
Get dynamic stock metrics (physical, reserved, available) for a specific product variant.
"""
return get_stock_metrics(variant_id, db)
@router.post("/adjust")
def adjust_inventory(
payload: StockAdjustRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
):
"""
Manually append a stock adjustment entry in the inventory ledger.
"""
variant = db.execute(
select(ProductVariant).where(ProductVariant.variant_id == payload.variant_id)
).scalar_one_or_none()
if not variant:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Variant not found")
entry = record_ledger_entry(
variant_id=payload.variant_id,
event_type=payload.event_type,
qty=payload.qty,
reference_id="MANUAL_ADJUST",
db=db,
notes=payload.notes
)
from app.core.database.cache_manager import cache
cache.invalidate_prefix("catalog:products:")
cache.invalidate_prefix("catalog:product_detail:")
return {
"message": "Inventory ledger entry recorded successfully",
"ledger_id": entry.ledger_id,
"variant_id": entry.variant_id,
"event_type": entry.event_type,
"qty": entry.qty
}