Files

125 lines
4.2 KiB
Python

"""Analytics router.
GET /api/analytics/summary -> today revenue/orders/profit/margin/aov/conversion
GET /api/analytics/top-products
GET /api/analytics/supplier-performance
"""
from collections import defaultdict
from datetime import datetime, time, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.routers.auth import require_admin
from app.models import Order, Supplier, SupplierPerformance
router = APIRouter(dependencies=[Depends(require_admin)])
EXCLUDED = ["cancelled", "refunded"]
def _f(value) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
@router.get("/summary")
def summary(db: Session = Depends(get_db)):
today_start = datetime.combine(datetime.now(timezone.utc).date(), time.min)
today_q = db.query(Order).filter(
Order.created_at >= today_start, ~Order.status.in_(EXCLUDED)
)
all_q = db.query(Order).filter(~Order.status.in_(EXCLUDED))
def _kpis(query):
orders = query.all()
revenue = sum(_f(o.retail_total) for o in orders)
profit = sum(_f(o.profit) for o in orders)
count = len(orders)
return {
"revenue": round(revenue, 2),
"orders": count,
"profit": round(profit, 2),
"gross_profit": round(revenue - profit, 2),
"margin": round(profit / revenue * 100, 2) if revenue else 0.0,
"aov": round(revenue / count, 2) if count else 0.0,
# no traffic/visitor tracking table yet — conversion is not measurable
"conversion": 0.0,
}
refunds_today = (
db.query(func.count(Order.id))
.filter(Order.created_at >= today_start, Order.status.in_(["cancelled", "refunded"]))
.scalar()
or 0
)
return {
"today": {**_kpis(today_q), "refunds": refunds_today},
"all_time": _kpis(all_q),
}
@router.get("/top-products")
def top_products(limit: int = 10, db: Session = Depends(get_db)):
orders = (
db.query(Order)
.filter(~Order.status.in_(EXCLUDED))
.order_by(Order.created_at.desc())
.limit(500)
.all()
)
stats = defaultdict(lambda: {"sku": None, "units": 0, "revenue": 0.0})
for order in orders:
for item in order.items or []:
pid = item.get("product_id")
if not pid:
continue
entry = stats[pid]
entry["sku"] = item.get("sku")
entry["units"] += int(item.get("qty") or 0)
entry["revenue"] += float(item.get("unit_price") or 0) * int(item.get("qty") or 0)
ranked = sorted(stats.items(), key=lambda kv: (-kv[1]["revenue"], -kv[1]["units"]))
return [
{"product_id": pid, "sku": s["sku"], "units": s["units"], "revenue": round(s["revenue"], 2)}
for pid, s in ranked[:limit]
]
@router.get("/supplier-performance")
def supplier_performance(db: Session = Depends(get_db)):
out = []
for supplier in db.query(Supplier).all():
perf = (
db.query(SupplierPerformance)
.filter(SupplierPerformance.supplier_id == supplier.id)
.first()
)
orders = (
db.query(Order)
.filter(Order.supplier_id == supplier.id, ~Order.status.in_(EXCLUDED))
.all()
)
out.append(
{
"supplier_id": str(supplier.id),
"name": supplier.name,
"adapter_type": supplier.adapter_type,
"status": supplier.status,
"reliability_score": _f(supplier.reliability_score),
"fulfillment_rate": _f(perf.fulfillment_rate) if perf else 100.0,
"avg_shipping_days": _f(perf.avg_shipping_days) if perf and perf.avg_shipping_days is not None else None,
"cancellation_rate": _f(perf.cancellation_rate) if perf else 0.0,
"stock_accuracy": _f(perf.stock_accuracy) if perf else 100.0,
"return_rate": _f(perf.return_rate) if perf else 0.0,
"orders": len(orders),
"profit": round(sum(_f(o.profit) for o in orders), 2),
}
)
return out