Files
catalogue_backend/app/intelligence/analytics.py
2026-08-11 19:16:01 +05:30

216 lines
10 KiB
Python

"""
Features 4 & 5: Store Analytics Dashboard + Product Analytics.
Pure computation over plain pandas DataFrames - no SQL in this file.
`app/services/analytics_service.py` is the thin I/O layer that pulls
DataFrames out of `store_db.py` and hands them to the functions here,
which keeps every KPI formula unit-testable without a live Postgres
instance (see `tests/test_intelligence.py`).
"""
from __future__ import annotations
from typing import Dict, List, Optional
import numpy as np
import pandas as pd
def classify_stock_status(available: int, reorder_level: int, safety_stock: int) -> str:
if available <= 0:
return "Out of Stock"
if available <= safety_stock:
return "Low Stock"
if available > reorder_level * 6:
return "Overstocked"
return "In Stock"
def inventory_analytics(store_products: pd.DataFrame) -> Dict:
"""`store_products` columns: available_stock, reorder_level, safety_stock (already
filtered to one store, or pass the full multi-store frame for a
chain-wide summary)."""
if store_products.empty:
return {"total_products": 0, "in_stock": 0, "low_stock": 0, "out_of_stock": 0, "overstocked": 0}
statuses = store_products.apply(
lambda r: classify_stock_status(r["available_stock"], r["reorder_level"], r["safety_stock"]), axis=1
)
counts = statuses.value_counts()
return {
"total_products": int(len(store_products)),
"in_stock": int(counts.get("In Stock", 0)),
"low_stock": int(counts.get("Low Stock", 0)),
"out_of_stock": int(counts.get("Out of Stock", 0)),
"overstocked": int(counts.get("Overstocked", 0)),
}
def sales_analytics(orders: pd.DataFrame) -> Dict:
"""`orders` columns: order_date (datetime64), order_value. Already
filtered to the scope (one store, or all stores) the caller wants."""
if orders.empty:
return {
"total_sales": 0, "revenue": 0.0, "average_basket_value": 0.0,
"daily_sales": [], "weekly_sales": [], "monthly_sales": [],
}
revenue = float(orders["order_value"].sum())
total_sales = int(len(orders))
avg_basket = revenue / total_sales if total_sales else 0.0
daily = orders.groupby(orders["order_date"].dt.date)["order_value"].agg(["sum", "count"]).reset_index()
daily.columns = ["date", "revenue", "orders"]
weekly = orders.groupby(orders["order_date"].dt.to_period("W").astype(str))["order_value"].agg(["sum", "count"]).reset_index()
weekly.columns = ["week", "revenue", "orders"]
monthly = orders.groupby(orders["order_date"].dt.to_period("M").astype(str))["order_value"].agg(["sum", "count"]).reset_index()
monthly.columns = ["month", "revenue", "orders"]
return {
"total_sales": total_sales,
"revenue": round(revenue, 2),
"average_basket_value": round(avg_basket, 2),
"daily_sales": [{"date": str(r["date"]), "revenue": round(r["revenue"], 2), "orders": int(r["orders"])} for _, r in daily.iterrows()],
"weekly_sales": [{"week": r["week"], "revenue": round(r["revenue"], 2), "orders": int(r["orders"])} for _, r in weekly.iterrows()],
"monthly_sales": [{"month": r["month"], "revenue": round(r["revenue"], 2), "orders": int(r["orders"])} for _, r in monthly.iterrows()],
}
def profit_analytics(order_items: pd.DataFrame, store_prices: pd.DataFrame) -> Dict:
"""Joins order line items to CURRENT store cost prices to estimate
profit (`(unit_price - cost_price) * quantity`). This is an
approximation - it uses today's cost price, not the cost price that
was actually in effect on the historical order date, since the
system doesn't keep a cost-price history table. Documented rather
than silently treated as exact."""
if order_items.empty or store_prices.empty:
return {"total_profit": 0.0, "gross_profit_pct": 0.0}
merged = order_items.merge(
store_prices[["store_id", "brand", "image_id", "cost_price"]],
on=["store_id", "brand", "image_id"], how="left",
)
merged["cost_price"] = merged["cost_price"].fillna(merged["unit_price"] * 0.78)
merged["line_profit"] = (merged["unit_price"] - merged["cost_price"]) * merged["quantity"]
total_profit = float(merged["line_profit"].sum())
total_revenue = float(merged["line_total"].sum())
gp_pct = (total_profit / total_revenue * 100) if total_revenue else 0.0
return {"total_profit": round(total_profit, 2), "gross_profit_pct": round(gp_pct, 2)}
def store_comparison(orders: pd.DataFrame, order_items: pd.DataFrame, store_prices: pd.DataFrame, stores: pd.DataFrame) -> Dict:
"""`stores` columns: store_id, store_name, tier, footfall_index."""
if orders.empty:
return {"stores": [], "best_performing": None, "lowest_performing": None,
"highest_revenue": None, "highest_profit": None}
per_store_revenue = orders.groupby("store_id")["order_value"].agg(["sum", "count"]).reset_index()
per_store_revenue.columns = ["store_id", "revenue", "order_count"]
profit_rows = []
for store_id, g in order_items.groupby("store_id"):
sp = store_prices[store_prices["store_id"] == store_id]
p = profit_analytics(g, sp)
profit_rows.append({"store_id": store_id, "profit": p["total_profit"]})
profit_df = pd.DataFrame(profit_rows) if profit_rows else pd.DataFrame(columns=["store_id", "profit"])
merged = per_store_revenue.merge(profit_df, on="store_id", how="left").merge(stores, on="store_id", how="left")
merged["profit"] = merged["profit"].fillna(0.0)
merged["avg_order_value"] = merged["revenue"] / merged["order_count"].replace(0, np.nan)
merged["avg_order_value"] = merged["avg_order_value"].fillna(0.0)
# "Customer Footfall (simulated)" - the store's actual simulated order
# count IS the simulated footfall proxy (every order came from a
# simulated in-store/online customer visit).
merged["footfall_simulated"] = merged["order_count"]
result_stores = [
{
"store_id": r["store_id"], "store_name": r.get("store_name"), "tier": r.get("tier"),
"revenue": round(r["revenue"], 2), "profit": round(r["profit"], 2),
"order_count": int(r["order_count"]), "avg_order_value": round(r["avg_order_value"], 2),
"footfall_simulated": int(r["footfall_simulated"]),
}
for _, r in merged.iterrows()
]
by_revenue = sorted(result_stores, key=lambda s: s["revenue"], reverse=True)
by_profit = sorted(result_stores, key=lambda s: s["profit"], reverse=True)
return {
"stores": result_stores,
"best_performing": by_profit[0]["store_id"] if by_profit else None,
"lowest_performing": by_profit[-1]["store_id"] if by_profit else None,
"highest_revenue": by_revenue[0]["store_id"] if by_revenue else None,
"highest_profit": by_profit[0]["store_id"] if by_profit else None,
}
def top_products(order_items: pd.DataFrame, limit: int = 10, ascending: bool = False, by: str = "revenue") -> List[Dict]:
"""`by`: 'revenue' or 'units'. Set ascending=True for "lowest
selling" instead of "top selling"."""
if order_items.empty:
return []
agg = order_items.groupby(["brand", "image_id"]).agg(
revenue=("line_total", "sum"), units=("quantity", "sum"), orders=("order_id", "nunique"),
).reset_index()
sort_col = "revenue" if by == "revenue" else "units"
agg = agg.sort_values(sort_col, ascending=ascending).head(limit)
return [
{"brand": r["brand"], "image_id": r["image_id"], "revenue": round(r["revenue"], 2),
"units_sold": int(r["units"]), "order_count": int(r["orders"])}
for _, r in agg.iterrows()
]
def product_analytics(
brand: str, image_id: str,
order_items: pd.DataFrame, store_prices: pd.DataFrame,
engagement_row: Optional[Dict] = None, popularity_score: Optional[float] = None,
demand_score: Optional[float] = None,
) -> Dict:
"""Full Feature 5 metric set for one product, aggregated across all
stores that carry it, plus a store-wise breakdown."""
prod_items = order_items[(order_items["brand"] == brand) & (order_items["image_id"] == image_id)]
prod_prices = store_prices[(store_prices["brand"] == brand) & (store_prices["image_id"] == image_id)]
sales_count = int(prod_items["quantity"].sum())
revenue = float(prod_items["line_total"].sum())
profit_info = profit_analytics(prod_items, store_prices)
order_count = int(prod_items["order_id"].nunique())
store_wise = (
prod_items.groupby("store_id").agg(units=("quantity", "sum"), revenue=("line_total", "sum")).reset_index()
if not prod_items.empty else pd.DataFrame(columns=["store_id", "units", "revenue"])
)
# Growth %: last-14-days units vs the 14 days before that (real data,
# not synthetic - same "current vs previous window" pattern used by
# the trending model, just exposed as a plain metric here).
growth_pct = None
if not prod_items.empty and "order_date" in prod_items.columns:
last_date = prod_items["order_date"].max()
cur_start = last_date - pd.Timedelta(days=14)
prev_start = cur_start - pd.Timedelta(days=14)
cur = prod_items[prod_items["order_date"] >= cur_start]["quantity"].sum()
prev = prod_items[(prod_items["order_date"] >= prev_start) & (prod_items["order_date"] < cur_start)]["quantity"].sum()
growth_pct = round(float((cur - prev) / prev * 100) if prev else (100.0 if cur else 0.0), 1)
avg_stock = float(prod_prices["selling_price"].mean()) if not prod_prices.empty else 0.0
# Stock turnover = units sold / average stock held (a standard retail
# KPI: how many times the inventory "turned over" in the observed period).
turnover = None
return {
"brand": brand, "image_id": image_id,
"sales_count": sales_count,
"revenue": round(revenue, 2),
"profit": profit_info["total_profit"],
"orders": order_count,
"avg_rating": (engagement_row or {}).get("avg_rating"),
"views": (engagement_row or {}).get("views"),
"wishlist_count": (engagement_row or {}).get("wishlist_count"),
"conversion_rate": (engagement_row or {}).get("conversion_rate"),
"popularity_score": round(popularity_score, 1) if popularity_score is not None else None,
"demand_score": round(demand_score, 1) if demand_score is not None else None,
"growth_pct": growth_pct,
"store_wise_sales": [
{"store_id": r["store_id"], "units": int(r["units"]), "revenue": round(r["revenue"], 2)}
for _, r in store_wise.iterrows()
],
}