125 lines
4.2 KiB
Python
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
|