This commit is contained in:
2026-01-16 18:16:33 +08:00
parent 9f3c96ce73
commit e53c3c838c
26 changed files with 1473 additions and 386 deletions

View File

@@ -1,225 +1,238 @@
from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func
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, MatchStatus, TargetType
from app.models.match import MatchResult, TargetType, MatchStatus
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
class KPIResponse(BaseModel):
# --- Pydantic Models ---
class DashboardKPI(BaseModel):
total_dit: int
sample_rate: float # 送樣轉換率
hit_rate: float # 訂單命中率
fulfillment_rate: float # EAU 達成率
orphan_sample_rate: float # 無效送樣率
sample_rate: float
hit_rate: float
fulfillment_rate: float
no_order_sample_rate: float
total_revenue: float
class FunnelItem(BaseModel):
class FunnelData(BaseModel):
name: str
value: int
fill: str
class AttributionDit(BaseModel):
op_id: str
class DitSchema(BaseModel):
id: int
op_id: Optional[str] = None
customer: str
pn: str
eau: int
stage: str
date: str
eau: float = 0
stage: Optional[str] = None
date: Optional[str] = None
class AttributionSample(BaseModel):
order_no: str
date: str
class Config:
from_attributes = True
class AttributionOrder(BaseModel):
order_no: str
status: str
qty: int
amount: float
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: AttributionDit
sample: AttributionSample | None
order: AttributionOrder | None
match_source: str | None
dit: DitSchema
sample: Optional[SampleSchema] = None
order: Optional[OrderSchema] = None
match_source: Optional[str] = None
attributed_qty: int
fulfillment_rate: float
def get_lifo_attribution(db: Session):
"""執行 LIFO 業績分配邏輯"""
# 1. 取得所有 DIT按日期由新到舊排序 (LIFO)
dits = db.query(DitRecord).order_by(DitRecord.date.desc()).all()
# 2. 取得所有已匹配且接受的訂單
matched_orders = db.query(MatchResult, OrderRecord).join(
OrderRecord, MatchResult.target_id == OrderRecord.id
).filter(
MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
).all()
# 3. 建立業績池 (Revenue Pool) - 按 (客戶, 料號) 分組
order_pools = {}
for match, order in matched_orders:
key = (order.customer_normalized, order.pn)
if key not in order_pools:
order_pools[key] = 0
order_pools[key] += (order.qty or 0)
# 4. 進行分配
attribution_map = {} # dit_id -> {qty, total_eau}
for dit in dits:
key = (dit.customer_normalized, dit.pn)
eau = dit.eau or 0
allocated = 0
if key in order_pools and order_pools[key] > 0:
allocated = min(eau, order_pools[key])
order_pools[key] -= allocated
attribution_map[dit.id] = {
"qty": allocated,
"eau": eau
}
return attribution_map
# --- Routes ---
@router.get("/kpi", response_model=KPIResponse)
@router.get("/kpi", response_model=DashboardKPI)
def get_kpi(db: Session = Depends(get_db)):
"""取得 KPI 統計 (符合規格書 v1.0)"""
total_dit = db.query(DitRecord).count()
if total_dit == 0:
return KPIResponse(total_dit=0, sample_rate=0, hit_rate=0, fulfillment_rate=0, orphan_sample_rate=0, total_revenue=0)
return DashboardKPI(
total_dit=0, sample_rate=0, hit_rate=0,
fulfillment_rate=0, no_order_sample_rate=0, total_revenue=0
)
# 1. 送樣轉換率 (Sample Rate): (有匹配到樣品的 DIT 數) / (總 DIT 數)
dits_with_sample = db.query(func.count(func.distinct(MatchResult.dit_id))).filter(
# 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_([MatchStatus.accepted, MatchStatus.auto_matched])
).scalar() or 0
sample_rate = (dits_with_sample / total_dit * 100)
MatchResult.status.in_(valid_statuses)
).distinct().count()
# 2. 訂單命中率 (Hit Rate): (有匹配到訂單的 DIT 數) / (總 DIT 數)
dits_with_order = db.query(func.count(func.distinct(MatchResult.dit_id))).filter(
# 2. Matches for Orders
order_matches = db.query(MatchResult.dit_id).filter(
MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
).scalar() or 0
hit_rate = (dits_with_order / total_dit * 100)
MatchResult.status.in_(valid_statuses)
).distinct().count()
# 3. EAU 達成率 (Fulfillment Rate): (歸因之訂單總量) / (DIT 預估 EAU)
attribution_map = get_lifo_attribution(db)
total_attributed_qty = sum(item['qty'] for item in attribution_map.values())
total_eau = sum(item['eau'] for item in attribution_map.values())
fulfillment_rate = (total_attributed_qty / total_eau * 100) if total_eau > 0 else 0
# 4. 無效送樣率 (Orphan Sample Rate): (未匹配到 DIT 的送樣數) / (總送樣數)
total_samples = db.query(SampleRecord).count()
matched_sample_ids = db.query(func.distinct(MatchResult.target_id)).filter(
MatchResult.target_type == TargetType.SAMPLE
).all()
matched_sample_count = len(matched_sample_ids)
orphan_sample_rate = ((total_samples - matched_sample_count) / total_samples * 100) if total_samples > 0 else 0
# 5. 總營收
total_revenue = db.query(func.sum(OrderRecord.amount)).join(
# 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_([MatchStatus.accepted, MatchStatus.auto_matched])
).scalar() or 0
MatchResult.status.in_(valid_statuses)
).scalar() or 0.0
return KPIResponse(
# 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=round(sample_rate, 1),
hit_rate=round(hit_rate, 1),
fulfillment_rate=round(fulfillment_rate, 1),
orphan_sample_rate=round(orphan_sample_rate, 1),
total_revenue=total_revenue
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[FunnelItem])
@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()
dits_with_sample = db.query(func.count(func.distinct(MatchResult.dit_id))).filter(
MatchResult.target_type == TargetType.SAMPLE,
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
).scalar() or 0
dits_with_order = db.query(func.count(func.distinct(MatchResult.dit_id))).filter(
MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
).scalar() or 0
# 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 [
FunnelItem(name='DIT 案件', value=total_dit, fill='#6366f1'),
FunnelItem(name='成功送樣', value=dits_with_sample, fill='#8b5cf6'),
FunnelItem(name='取得訂單', value=dits_with_order, fill='#10b981'),
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)):
"""取得歸因明細 (含 LIFO 分配與追溯資訊)"""
dit_records = db.query(DitRecord).order_by(DitRecord.date.desc()).all()
attribution_map = get_lifo_attribution(db)
result = []
valid_statuses = [MatchStatus.auto_matched, MatchStatus.accepted]
matches = db.query(MatchResult).filter(MatchResult.status.in_(valid_statuses)).all()
if not matches:
return []
for dit in dit_records:
# 找到樣品匹配 (取分數最高的一個)
sample_match = db.query(MatchResult).filter(
MatchResult.dit_id == dit.id,
MatchResult.target_type == TargetType.SAMPLE,
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
).order_by(MatchResult.score.desc()).first()
sample_info = None
if sample_match:
sample = db.query(SampleRecord).filter(SampleRecord.id == sample_match.target_id).first()
if sample:
sample_info = AttributionSample(order_no=sample.order_no, date=sample.date or '')
# 找到訂單匹配 (取分數最高的一個)
order_match = db.query(MatchResult).filter(
MatchResult.dit_id == dit.id,
MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
).order_by(MatchResult.score.desc()).first()
order_info = None
match_source = None
if order_match:
order = db.query(OrderRecord).filter(OrderRecord.id == order_match.target_id).first()
if order:
order_info = AttributionOrder(
order_no=order.order_no,
status=order.status or 'Unknown',
qty=order.qty or 0,
amount=order.amount or 0
)
match_source = order_match.match_source
attr_data = attribution_map.get(dit.id, {"qty": 0, "eau": dit.eau or 0})
fulfillment = (attr_data['qty'] / attr_data['eau'] * 100) if attr_data['eau'] > 0 else 0
result.append(AttributionRow(
dit=AttributionDit(
op_id=dit.op_id,
customer=dit.customer,
pn=dit.pn,
eau=dit.eau,
stage=dit.stage or '',
date=dit.date or ''
),
sample=sample_info,
order=order_info,
match_source=match_source,
attributed_qty=attr_data['qty'],
fulfillment_rate=round(fulfillment, 1)
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 result
return results