Add Polaris FastAPI backend: models/schemas, pricing+scoring+order router, importer, Celery tasks, API routers, seed script
This commit is contained in:
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routers."""
|
||||
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)
|
||||
123
backend/app/routers/analytics.py
Normal file
123
backend/app/routers/analytics.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""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.models import Order, Supplier, SupplierPerformance
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
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
|
||||
84
backend/app/routers/auth.py
Normal file
84
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Auth router — simple JWT for admin login + customer registration.
|
||||
|
||||
POST /api/auth/login (admin, env ADMIN_EMAIL / ADMIN_PASSWORD)
|
||||
POST /api/auth/register (customer)
|
||||
|
||||
TODO(security): hash ADMIN_PASSWORD (bcrypt/argon2) instead of plain compare —
|
||||
acceptable for MVP per task instructions. Customers table has no password
|
||||
column yet, so registration is identity-only.
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Customer
|
||||
from app.schemas import CustomerRegister, LoginRequest, TokenResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "polaris-dev-secret-change-me")
|
||||
ALGORITHM = "HS256"
|
||||
TOKEN_TTL_HOURS = 24
|
||||
|
||||
|
||||
def _make_token(email: str, role: str) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": email,
|
||||
"role": role,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=TOKEN_TTL_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(body: LoginRequest):
|
||||
admin_email = os.getenv("ADMIN_EMAIL", "admin@polaris.local")
|
||||
# contract mentions ADMIN_PASSWORD_HASH; MVP uses plain compare (see TODO above)
|
||||
admin_password = os.getenv("ADMIN_PASSWORD") or os.getenv("ADMIN_PASSWORD_HASH", "admin")
|
||||
|
||||
if body.email.strip().lower() != admin_email.strip().lower():
|
||||
raise HTTPException(status_code=401, detail="invalid credentials")
|
||||
if body.password != admin_password:
|
||||
raise HTTPException(status_code=401, detail="invalid credentials")
|
||||
|
||||
return TokenResponse(access_token=_make_token(body.email, "admin"))
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(body: CustomerRegister, db: Session = Depends(get_db)):
|
||||
email = body.email.strip().lower()
|
||||
existing = db.query(Customer).filter(Customer.email == email).first()
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="email already registered")
|
||||
|
||||
customer = Customer(email=email, name=body.name, shipping_addr=body.shipping_addr or {})
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
return {
|
||||
"id": str(customer.id),
|
||||
"email": customer.email,
|
||||
"name": customer.name,
|
||||
"access_token": _make_token(customer.email, "customer"),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
def require_admin(authorization: str = Header(default="")):
|
||||
"""FastAPI dependency — requires a valid admin JWT in the Authorization header."""
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="missing bearer token")
|
||||
try:
|
||||
payload = jwt.decode(authorization[7:], SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=401, detail="invalid token")
|
||||
if payload.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="admin role required")
|
||||
return payload
|
||||
42
backend/app/routers/customers.py
Normal file
42
backend/app/routers/customers.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Customers router.
|
||||
|
||||
GET /api/customers
|
||||
GET /api/customers/{id}
|
||||
POST /api/customers
|
||||
"""
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Customer
|
||||
from app.schemas import CustomerOut, CustomerRegister
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=List[CustomerOut])
|
||||
def list_customers(db: Session = Depends(get_db)):
|
||||
return db.query(Customer).order_by(Customer.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.get("/{customer_id}", response_model=CustomerOut)
|
||||
def get_customer(customer_id, db: Session = Depends(get_db)):
|
||||
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||
if customer is None:
|
||||
raise HTTPException(status_code=404, detail="customer not found")
|
||||
return customer
|
||||
|
||||
|
||||
@router.post("", response_model=CustomerOut)
|
||||
def create_customer(body: CustomerRegister, db: Session = Depends(get_db)):
|
||||
email = body.email.strip().lower()
|
||||
existing = db.query(Customer).filter(Customer.email == email).first()
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="email already registered")
|
||||
customer = Customer(email=email, name=body.name, shipping_addr=body.shipping_addr or {})
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
return customer
|
||||
23
backend/app/routers/health.py
Normal file
23
backend/app/routers/health.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""GET /api/health -> {status, version}"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.version import VERSION
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health(db: Session = Depends(get_db)):
|
||||
db_status = "ok"
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
except Exception: # noqa: BLE001
|
||||
db_status = "error"
|
||||
return {
|
||||
"status": "ok" if db_status == "ok" else "degraded",
|
||||
"version": VERSION,
|
||||
"db": db_status,
|
||||
}
|
||||
113
backend/app/routers/orders.py
Normal file
113
backend/app/routers/orders.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Orders router.
|
||||
|
||||
POST /api/orders (body: {customer_email, items:[{product_id,qty}]})
|
||||
-> creates order, routes to highest-scoring supplier, records profit
|
||||
GET /api/orders
|
||||
GET /api/orders/{id}
|
||||
POST /api/orders/{id}/tracking (simulate supplier tracking push)
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.engines.order_router import route_order
|
||||
from app.models import AuditLog, Customer, Order, Product
|
||||
from app.schemas import OrderCreate, OrderOut, TrackingIn
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TRACKING_STATUSES = {"new", "paid", "fraud_check", "supplier_order", "confirmed"}
|
||||
|
||||
|
||||
def _get_order_or_404(db, order_id):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if order is None:
|
||||
raise HTTPException(status_code=404, detail="order not found")
|
||||
return order
|
||||
|
||||
|
||||
def _new_order_number() -> str:
|
||||
return f"POL-{datetime.utcnow():%Y%m%d}-{uuid.uuid4().hex[:6].upper()}"
|
||||
|
||||
|
||||
@router.post("", response_model=OrderOut)
|
||||
def create_order(body: OrderCreate, db: Session = Depends(get_db)):
|
||||
email = body.customer_email.strip().lower()
|
||||
customer = db.query(Customer).filter(Customer.email == email).first()
|
||||
if customer is None:
|
||||
customer = Customer(email=email, name=email.split("@")[0])
|
||||
db.add(customer)
|
||||
db.flush()
|
||||
|
||||
items = []
|
||||
retail_total = Decimal("0")
|
||||
for item in body.items:
|
||||
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail=f"product {item.product_id} not found")
|
||||
price = product.retail_price
|
||||
if price is None or Decimal(str(price)) <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"product {product.sku} has no retail price")
|
||||
items.append(
|
||||
{
|
||||
"product_id": str(product.id),
|
||||
"sku": product.sku,
|
||||
"qty": item.qty,
|
||||
"unit_price": float(price),
|
||||
}
|
||||
)
|
||||
retail_total += Decimal(str(price)) * item.qty
|
||||
|
||||
order = Order(
|
||||
order_number=_new_order_number(),
|
||||
customer_id=customer.id,
|
||||
items=items,
|
||||
retail_total=retail_total,
|
||||
status="new",
|
||||
)
|
||||
db.add(order)
|
||||
db.flush()
|
||||
|
||||
result = route_order(db, order)
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
@router.get("", response_model=List[OrderOut])
|
||||
def list_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.get("/{order_id}", response_model=OrderOut)
|
||||
def get_order(order_id, db: Session = Depends(get_db)):
|
||||
return _get_order_or_404(db, order_id)
|
||||
|
||||
|
||||
@router.post("/{order_id}/tracking", response_model=OrderOut)
|
||||
def add_tracking(order_id, body: TrackingIn, db: Session = Depends(get_db)):
|
||||
order = _get_order_or_404(db, order_id)
|
||||
order.tracking = body.tracking
|
||||
if body.carrier:
|
||||
order.carrier = body.carrier
|
||||
if order.status in TRACKING_STATUSES:
|
||||
order.status = "shipped"
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor="api",
|
||||
action="tracking_received",
|
||||
entity="order",
|
||||
entity_id=order.order_number,
|
||||
detail={"tracking": body.tracking, "carrier": body.carrier},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
132
backend/app/routers/products.py
Normal file
132
backend/app/routers/products.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Products router.
|
||||
|
||||
GET /api/products (filters: status, category, search, limit, offset)
|
||||
GET /api/products/{id}
|
||||
POST /api/products/{id}/publish
|
||||
POST /api/products/{id}/pause
|
||||
GET /api/products/{id}/price-history
|
||||
POST /api/products/import (body: {supplier_id, feed_type, data_or_url})
|
||||
|
||||
NOTE: /import is declared before /{id} so it isn't captured by the path param.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.engines.importer import import_feed
|
||||
from app.models import AuditLog, PriceHistory, Product, Supplier
|
||||
from app.schemas import ImportRequest, PriceHistoryOut, ProductOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PUBLISHABLE_FROM = {"imported", "price_calculated", "content_generated", "quality_check", "paused"}
|
||||
|
||||
|
||||
def _get_product_or_404(db, product_id):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="product not found")
|
||||
return product
|
||||
|
||||
|
||||
@router.get("", response_model=List[ProductOut])
|
||||
def list_products(
|
||||
status: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = db.query(Product)
|
||||
if status:
|
||||
q = q.filter(Product.status == status)
|
||||
if category:
|
||||
q = q.filter(Product.category == category)
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
q = q.filter(or_(Product.title.ilike(like), Product.sku.ilike(like)))
|
||||
return q.order_by(Product.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
def import_products(body: ImportRequest, db: Session = Depends(get_db)):
|
||||
supplier = db.query(Supplier).filter(Supplier.id == body.supplier_id).first()
|
||||
if supplier is None:
|
||||
raise HTTPException(status_code=404, detail="supplier not found")
|
||||
try:
|
||||
summary = import_feed(db, supplier, feed_type=body.feed_type, data_or_url=body.data_or_url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor="api",
|
||||
action="import_requested",
|
||||
entity="supplier",
|
||||
entity_id=str(supplier.id),
|
||||
detail={"feed_type": body.feed_type, "received": summary["received"]},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return summary
|
||||
|
||||
|
||||
@router.get("/{product_id}", response_model=ProductOut)
|
||||
def get_product(product_id, db: Session = Depends(get_db)):
|
||||
return _get_product_or_404(db, product_id)
|
||||
|
||||
|
||||
@router.post("/{product_id}/publish", response_model=ProductOut)
|
||||
def publish_product(product_id, db: Session = Depends(get_db)):
|
||||
product = _get_product_or_404(db, product_id)
|
||||
if product.status not in PUBLISHABLE_FROM:
|
||||
raise HTTPException(status_code=400, detail=f"cannot publish product in status '{product.status}'")
|
||||
if product.retail_price is None or Decimal(str(product.retail_price)) <= 0:
|
||||
raise HTTPException(status_code=400, detail="product has no calculated retail price")
|
||||
product.status = "published"
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor="api",
|
||||
action="product_published",
|
||||
entity="product",
|
||||
entity_id=str(product.id),
|
||||
detail={"sku": product.sku, "retail_price": float(product.retail_price)},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.post("/{product_id}/pause", response_model=ProductOut)
|
||||
def pause_product(product_id, db: Session = Depends(get_db)):
|
||||
product = _get_product_or_404(db, product_id)
|
||||
product.status = "paused"
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor="api",
|
||||
action="product_paused",
|
||||
entity="product",
|
||||
entity_id=str(product.id),
|
||||
detail={"sku": product.sku},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.get("/{product_id}/price-history", response_model=List[PriceHistoryOut])
|
||||
def price_history(product_id, db: Session = Depends(get_db)):
|
||||
_get_product_or_404(db, product_id)
|
||||
rows = (
|
||||
db.query(PriceHistory)
|
||||
.filter(PriceHistory.product_id == product_id)
|
||||
.order_by(PriceHistory.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return rows
|
||||
76
backend/app/routers/suppliers.py
Normal file
76
backend/app/routers/suppliers.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Suppliers router.
|
||||
|
||||
GET /api/suppliers
|
||||
POST /api/suppliers
|
||||
GET /api/suppliers/{id}/performance
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Supplier, SupplierPerformance
|
||||
from app.schemas import SupplierCreate, SupplierOut, SupplierPerformanceOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=List[SupplierOut])
|
||||
def list_suppliers(status: Optional[str] = None, db: Session = Depends(get_db)):
|
||||
q = db.query(Supplier)
|
||||
if status:
|
||||
q = q.filter(Supplier.status == status)
|
||||
return q.order_by(Supplier.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.post("", response_model=SupplierOut)
|
||||
def create_supplier(body: SupplierCreate, db: Session = Depends(get_db)):
|
||||
supplier = Supplier(
|
||||
name=body.name,
|
||||
adapter_type=body.adapter_type,
|
||||
api_endpoint=body.api_endpoint,
|
||||
account_id=body.account_id,
|
||||
fulfillment_caps=body.fulfillment_caps or {},
|
||||
shipping_regions=body.shipping_regions or [],
|
||||
reliability_score=body.reliability_score,
|
||||
status=body.status,
|
||||
)
|
||||
db.add(supplier)
|
||||
db.commit()
|
||||
db.refresh(supplier)
|
||||
return supplier
|
||||
|
||||
|
||||
@router.get("/{supplier_id}/performance", response_model=SupplierPerformanceOut)
|
||||
def supplier_performance(supplier_id, db: Session = Depends(get_db)):
|
||||
supplier = db.query(Supplier).filter(Supplier.id == supplier_id).first()
|
||||
if supplier is None:
|
||||
raise HTTPException(status_code=404, detail="supplier not found")
|
||||
|
||||
perf = (
|
||||
db.query(SupplierPerformance)
|
||||
.filter(SupplierPerformance.supplier_id == supplier_id)
|
||||
.first()
|
||||
)
|
||||
if perf is None:
|
||||
# synthesize defaults; nothing measured yet
|
||||
return SupplierPerformanceOut(
|
||||
supplier_id=supplier_id,
|
||||
supplier_name=supplier.name,
|
||||
fulfillment_rate=100.0,
|
||||
avg_shipping_days=None,
|
||||
cancellation_rate=0.0,
|
||||
stock_accuracy=100.0,
|
||||
return_rate=0.0,
|
||||
)
|
||||
return SupplierPerformanceOut(
|
||||
supplier_id=supplier_id,
|
||||
supplier_name=supplier.name,
|
||||
fulfillment_rate=float(perf.fulfillment_rate or 0),
|
||||
avg_shipping_days=float(perf.avg_shipping_days) if perf.avg_shipping_days is not None else None,
|
||||
cancellation_rate=float(perf.cancellation_rate or 0),
|
||||
stock_accuracy=float(perf.stock_accuracy or 0),
|
||||
return_rate=float(perf.return_rate or 0),
|
||||
updated_at=perf.updated_at,
|
||||
)
|
||||
Reference in New Issue
Block a user