161 lines
5.1 KiB
Python
161 lines
5.1 KiB
Python
"""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)
|