Files
SalesPipeline/backend/app/routers/dashboard.py
2026-01-16 18:16:33 +08:00

239 lines
8.2 KiB
Python

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func, distinct
from typing import List, Optional
from pydantic import BaseModel
from app.models import get_db
from app.models.dit import DitRecord
from app.models.sample import SampleRecord
from app.models.order import OrderRecord
from app.models.match import MatchResult, TargetType, MatchStatus
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
# --- Pydantic Models ---
class DashboardKPI(BaseModel):
total_dit: int
sample_rate: float
hit_rate: float
fulfillment_rate: float
no_order_sample_rate: float
total_revenue: float
class FunnelData(BaseModel):
name: str
value: int
fill: str
class DitSchema(BaseModel):
id: int
op_id: Optional[str] = None
customer: str
pn: str
eau: float = 0
stage: Optional[str] = None
date: Optional[str] = None
class Config:
from_attributes = True
class SampleSchema(BaseModel):
id: int
order_no: Optional[str] = None
customer: str
pn: str
qty: int = 0
date: Optional[str] = None
class Config:
from_attributes = True
class OrderSchema(BaseModel):
id: int
order_no: Optional[str] = None
customer: str
pn: str
qty: int = 0
amount: float = 0
status: Optional[str] = None
class Config:
from_attributes = True
class AttributionRow(BaseModel):
dit: DitSchema
sample: Optional[SampleSchema] = None
order: Optional[OrderSchema] = None
match_source: Optional[str] = None
attributed_qty: int
fulfillment_rate: float
# --- Routes ---
@router.get("/kpi", response_model=DashboardKPI)
def get_kpi(db: Session = Depends(get_db)):
total_dit = db.query(DitRecord).count()
if total_dit == 0:
return DashboardKPI(
total_dit=0, sample_rate=0, hit_rate=0,
fulfillment_rate=0, no_order_sample_rate=0, total_revenue=0
)
# Get valid matches
valid_statuses = [MatchStatus.auto_matched, MatchStatus.accepted]
# 1. Matches for Samples
sample_matches = db.query(MatchResult.dit_id).filter(
MatchResult.target_type == TargetType.SAMPLE,
MatchResult.status.in_(valid_statuses)
).distinct().count()
# 2. Matches for Orders
order_matches = db.query(MatchResult.dit_id).filter(
MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_(valid_statuses)
).distinct().count()
# 3. Revenue
# Join MatchResult -> OrderRecord to sum amount
revenue = db.query(func.sum(OrderRecord.amount)).join(
MatchResult, MatchResult.target_id == OrderRecord.id
).filter(
MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_(valid_statuses)
).scalar() or 0.0
# 4. Fulfillment (Total Matched Order Qty / Total Matched DIT EAU)
total_order_qty = db.query(func.sum(OrderRecord.qty)).join(
MatchResult, MatchResult.target_id == OrderRecord.id
).filter(
MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_(valid_statuses)
).scalar() or 0
total_eau = db.query(func.sum(DitRecord.eau)).scalar() or 1 # Avoid div/0
sample_rate = round((sample_matches / total_dit) * 100, 1)
hit_rate = round((order_matches / total_dit) * 100, 1)
fulfillment_rate = round((total_order_qty / total_eau) * 100, 1) if total_eau > 0 else 0
# No Order Sample Rate
dit_with_samples = set(x[0] for x in db.query(MatchResult.dit_id).filter(
MatchResult.target_type == TargetType.SAMPLE,
MatchResult.status.in_(valid_statuses)
).distinct().all())
dit_with_orders = set(x[0] for x in db.query(MatchResult.dit_id).filter(
MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_(valid_statuses)
).distinct().all())
dit_sample_no_order = len(dit_with_samples - dit_with_orders)
no_order_sample_rate = round((dit_sample_no_order / len(dit_with_samples) * 100), 1) if dit_with_samples else 0.0
return DashboardKPI(
total_dit=total_dit,
sample_rate=sample_rate,
hit_rate=hit_rate,
fulfillment_rate=fulfillment_rate,
no_order_sample_rate=no_order_sample_rate,
total_revenue=revenue
)
@router.get("/funnel", response_model=List[FunnelData])
def get_funnel(db: Session = Depends(get_db)):
valid_statuses = [MatchStatus.auto_matched, MatchStatus.accepted]
# Stage 1: DIT
total_dit = db.query(DitRecord).count()
# Stage 2: Sample
dit_with_samples = db.query(MatchResult.dit_id).filter(
MatchResult.target_type == TargetType.SAMPLE,
MatchResult.status.in_(valid_statuses)
).distinct().count()
# Stage 3: Order
dit_with_orders = db.query(MatchResult.dit_id).filter(
MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_(valid_statuses)
).distinct().count()
return [
FunnelData(name="DIT 總案", value=total_dit, fill="#6366f1"),
FunnelData(name="成功送樣", value=dit_with_samples, fill="#a855f7"),
FunnelData(name="取得訂單", value=dit_with_orders, fill="#10b981"),
]
@router.get("/attribution", response_model=List[AttributionRow])
def get_attribution(db: Session = Depends(get_db)):
valid_statuses = [MatchStatus.auto_matched, MatchStatus.accepted]
matches = db.query(MatchResult).filter(MatchResult.status.in_(valid_statuses)).all()
if not matches:
return []
dit_ids = set(m.dit_id for m in matches)
dits = db.query(DitRecord).filter(DitRecord.id.in_(dit_ids)).all()
dit_map = {d.id: d for d in dits}
sample_match_rows = [m for m in matches if m.target_type == TargetType.SAMPLE]
order_match_rows = [m for m in matches if m.target_type == TargetType.ORDER]
sample_ids = [m.target_id for m in sample_match_rows]
order_ids = [m.target_id for m in order_match_rows]
samples = db.query(SampleRecord).filter(SampleRecord.id.in_(sample_ids)).all()
orders = db.query(OrderRecord).filter(OrderRecord.id.in_(order_ids)).all()
sample_lookup = {s.id: s for s in samples}
order_lookup = {o.id: o for o in orders}
results = []
for dit_id, dit in dit_map.items():
s_matches = [m for m in matches if m.dit_id == dit_id and m.target_type == TargetType.SAMPLE]
best_sample_match = max(s_matches, key=lambda x: x.score) if s_matches else None
sample_obj = sample_lookup.get(best_sample_match.target_id) if best_sample_match else None
o_matches = [m for m in matches if m.dit_id == dit_id and m.target_type == TargetType.ORDER]
best_order_match = max(o_matches, key=lambda x: x.score) if o_matches else None
order_obj = order_lookup.get(best_order_match.target_id) if best_order_match else None
attributed_qty = 0
for om in o_matches:
o = order_lookup.get(om.target_id)
if o:
attributed_qty += o.qty
fulfillment_rate = round((attributed_qty / dit.eau * 100), 1) if dit.eau > 0 else 0
dit_schema = DitSchema.model_validate(dit)
# Handle date to string conversion if needed, Pydantic often handles date -> string automatically in JSON response
# checking earlier 'test_server_login' response showed JSON string for 'created_at'.
# But here I set it manually to safe string just in case
dit_schema.date = str(dit.date) if dit.date else None
sample_schema = None
if sample_obj:
sample_schema = SampleSchema.model_validate(sample_obj)
sample_schema.date = str(sample_obj.date) if sample_obj.date else None
order_schema = None
if order_obj:
order_schema = OrderSchema.model_validate(order_obj)
results.append(AttributionRow(
dit=dit_schema,
sample=sample_schema,
order=order_schema,
match_source=best_order_match.match_source if best_order_match else (best_order_match.reason if best_order_match else None),
attributed_qty=attributed_qty,
fulfillment_rate=fulfillment_rate
))
return results