164 lines
6 KiB
Python
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
|
|
}
|