Add Polaris FastAPI backend: models/schemas, pricing+scoring+order router, importer, Celery tasks, API routers, seed script
This commit is contained in:
160
backend/app/routers/admin.py
Normal file
160
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Admin router — JWT-protected operational endpoints.
|
||||
|
||||
GET /api/admin/dashboard
|
||||
GET /api/admin/orders
|
||||
POST /api/admin/orders/{id}/status
|
||||
POST /api/admin/import
|
||||
GET /api/admin/price-rules
|
||||
POST /api/admin/recalc (recalc_prices task)
|
||||
POST /api/admin/sync-inventory (sync_inventory task)
|
||||
"""
|
||||
from datetime import datetime, time, timezone
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import AuditLog, Order, PriceRule, Product, Supplier
|
||||
from app.routers.auth import require_admin
|
||||
from app.schemas import ImportRequest, OrderOut
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
|
||||
ORDER_STATUSES = {"new", "paid", "fraud_check", "supplier_order", "confirmed",
|
||||
"shipped", "delivered", "cancelled", "refunded"}
|
||||
|
||||
|
||||
class StatusUpdate(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
def _f(value) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def dashboard(db: Session = Depends(get_db)):
|
||||
today_start = datetime.combine(datetime.now(timezone.utc).date(), time.min)
|
||||
excluded = ["cancelled", "refunded"]
|
||||
|
||||
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 {
|
||||
"orders": count,
|
||||
"revenue": round(revenue, 2),
|
||||
"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,
|
||||
"refunds": sum(1 for o in orders if o.status == "refunded"),
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
return {
|
||||
"today": kpis(today_q),
|
||||
"all_time": kpis(all_q),
|
||||
"products_total": db.query(Product).count(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/orders", response_model=List[OrderOut])
|
||||
def admin_orders(status: Optional[str] = None, db: Session = Depends(get_db)):
|
||||
q = db.query(Order)
|
||||
if status:
|
||||
q = q.filter(Order.status == status)
|
||||
return q.order_by(Order.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/status", response_model=OrderOut)
|
||||
def set_order_status(order_id, body: StatusUpdate, db: Session = Depends(get_db)):
|
||||
if body.status not in ORDER_STATUSES:
|
||||
raise HTTPException(status_code=400, detail=f"invalid status; allowed: {sorted(ORDER_STATUSES)}")
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if order is None:
|
||||
raise HTTPException(status_code=404, detail="order not found")
|
||||
old = order.status
|
||||
order.status = body.status
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor="admin",
|
||||
action="order_status_changed",
|
||||
entity="order",
|
||||
entity_id=order.order_number,
|
||||
detail={"from": old, "to": body.status},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
def admin_import(body: ImportRequest, db: Session = Depends(get_db)):
|
||||
from app.engines.importer import import_feed
|
||||
|
||||
supplier = db.query(Supplier).filter(Supplier.id == body.supplier_id).first()
|
||||
if supplier is None:
|
||||
raise HTTPException(status_code=404, detail="supplier not found")
|
||||
summary = import_feed(db, supplier, feed_type=body.feed_type, data_or_url=body.data_or_url)
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor="admin",
|
||||
action="import_requested",
|
||||
entity="supplier",
|
||||
entity_id=str(supplier.id),
|
||||
detail={"feed_type": body.feed_type},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return summary
|
||||
|
||||
|
||||
@router.get("/price-rules")
|
||||
def price_rules(db: Session = Depends(get_db)):
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"min_cost": float(r.min_cost),
|
||||
"max_cost": float(r.max_cost) if r.max_cost is not None else None,
|
||||
"markup_pct": float(r.markup_pct),
|
||||
"min_margin_pct": float(r.min_margin_pct or 0),
|
||||
"active": r.active,
|
||||
}
|
||||
for r in db.query(PriceRule).order_by(PriceRule.min_cost).all()
|
||||
]
|
||||
|
||||
|
||||
def _dispatch(task_name, *args):
|
||||
"""Prefer Celery; fall back to running inline when the broker is unreachable."""
|
||||
from app.tasks import recalc_prices, sync_inventory
|
||||
|
||||
fn = {"recalc_prices": recalc_prices, "sync_inventory": sync_inventory}[task_name]
|
||||
try:
|
||||
result = fn.delay(*args)
|
||||
return {"dispatched": True, "task_id": result.id}
|
||||
except Exception:
|
||||
# broker down (e.g. running on host without redis DNS) — run inline
|
||||
return {"dispatched": False, "inline_result": fn.run(*args)}
|
||||
|
||||
|
||||
@router.post("/recalc")
|
||||
def admin_recalc():
|
||||
return _dispatch("recalc_prices")
|
||||
|
||||
|
||||
@router.post("/sync-inventory")
|
||||
def admin_sync_inventory(supplier_id: Optional[str] = None):
|
||||
return _dispatch("sync_inventory", supplier_id)
|
||||
Reference in New Issue
Block a user