20160116
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user