first commit
This commit is contained in:
181
backend/app/routers/lab.py
Normal file
181
backend/app/routers/lab.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_
|
||||
from pydantic import BaseModel
|
||||
from app.models import get_db
|
||||
from app.models.sample import SampleRecord
|
||||
from app.models.order import OrderRecord
|
||||
|
||||
router = APIRouter(prefix="/lab", tags=["Lab"])
|
||||
|
||||
class LabKPI(BaseModel):
|
||||
avg_velocity: float # 平均轉換時間 (天)
|
||||
conversion_rate: float # 轉換比例 (%)
|
||||
orphan_count: int # 孤兒樣品總數
|
||||
|
||||
class ScatterPoint(BaseModel):
|
||||
customer: str
|
||||
pn: str
|
||||
sample_qty: int
|
||||
order_qty: int
|
||||
|
||||
class OrphanSample(BaseModel):
|
||||
customer: str
|
||||
pn: str
|
||||
days_since_sent: int
|
||||
order_no: str
|
||||
date: str
|
||||
|
||||
def parse_date(date_str: str) -> Optional[datetime]:
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except:
|
||||
return None
|
||||
|
||||
@router.get("/kpi", response_model=LabKPI)
|
||||
def get_lab_kpi(
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 1. 取得所有樣品與訂單
|
||||
samples_query = db.query(SampleRecord)
|
||||
orders_query = db.query(OrderRecord)
|
||||
|
||||
if start_date:
|
||||
samples_query = samples_query.filter(SampleRecord.date >= start_date)
|
||||
orders_query = orders_query.filter(OrderRecord.created_at >= start_date) # 訂單使用 created_at or date? OrderRecord 只有 created_at 欄位是 DateTime
|
||||
|
||||
if end_date:
|
||||
samples_query = samples_query.filter(SampleRecord.date <= end_date)
|
||||
# Note: OrderRecord 只有 created_at
|
||||
|
||||
samples = samples_query.all()
|
||||
orders = orders_query.all()
|
||||
|
||||
# 建立群組 (ERP Code + PN)
|
||||
# ERP Code correspond to cust_id
|
||||
sample_groups = {}
|
||||
for s in samples:
|
||||
key = (s.cust_id, s.pn)
|
||||
if key not in sample_groups:
|
||||
sample_groups[key] = []
|
||||
sample_groups[key].append(s)
|
||||
|
||||
order_groups = {}
|
||||
for o in orders:
|
||||
key = (o.cust_id, o.pn)
|
||||
if key not in order_groups:
|
||||
order_groups[key] = []
|
||||
order_groups[key].append(o)
|
||||
|
||||
# 計算 Velocity 與 轉換率
|
||||
velocities = []
|
||||
converted_samples_count = 0
|
||||
total_samples_count = len(samples)
|
||||
|
||||
for key, group_samples in sample_groups.items():
|
||||
if key in order_groups:
|
||||
# 轉換成功
|
||||
converted_samples_count += len(group_samples)
|
||||
|
||||
# 計算 Velocity: First Order Date - Earliest Sample Date
|
||||
earliest_sample_date = min([parse_date(s.date) for s in group_samples if s.date] or [datetime.max])
|
||||
first_order_date = min([o.created_at for o in order_groups[key] if o.created_at] or [datetime.max])
|
||||
|
||||
if earliest_sample_date != datetime.max and first_order_date != datetime.max:
|
||||
diff = (first_order_date - earliest_sample_date).days
|
||||
if diff >= 0:
|
||||
velocities.append(diff)
|
||||
|
||||
avg_velocity = sum(velocities) / len(velocities) if velocities else 0
|
||||
conversion_rate = (converted_samples_count / total_samples_count * 100) if total_samples_count > 0 else 0
|
||||
|
||||
# 孤兒樣品: > 90天且無訂單
|
||||
now = datetime.now()
|
||||
orphan_count = 0
|
||||
for key, group_samples in sample_groups.items():
|
||||
if key not in order_groups:
|
||||
for s in group_samples:
|
||||
s_date = parse_date(s.date)
|
||||
if s_date and (now - s_date).days > 90:
|
||||
orphan_count += 1
|
||||
|
||||
return LabKPI(
|
||||
avg_velocity=round(avg_velocity, 1),
|
||||
conversion_rate=round(conversion_rate, 1),
|
||||
orphan_count=orphan_count
|
||||
)
|
||||
|
||||
@router.get("/scatter", response_model=List[ScatterPoint])
|
||||
def get_scatter_data(
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
samples_query = db.query(SampleRecord)
|
||||
orders_query = db.query(OrderRecord)
|
||||
|
||||
if start_date:
|
||||
samples_query = samples_query.filter(SampleRecord.date >= start_date)
|
||||
if end_date:
|
||||
samples_query = samples_query.filter(SampleRecord.date <= end_date)
|
||||
|
||||
samples = samples_query.all()
|
||||
orders = orders_query.all()
|
||||
|
||||
# 聚合資料
|
||||
data_map = {} # (cust_id, pn) -> {sample_qty, order_qty, customer_name}
|
||||
|
||||
for s in samples:
|
||||
key = (s.cust_id, s.pn)
|
||||
if key not in data_map:
|
||||
data_map[key] = {"sample_qty": 0, "order_qty": 0, "customer": s.customer}
|
||||
data_map[key]["sample_qty"] += (s.qty or 0)
|
||||
|
||||
for o in orders:
|
||||
key = (o.cust_id, o.pn)
|
||||
if key in data_map:
|
||||
data_map[key]["order_qty"] += (o.qty or 0)
|
||||
# 如果有訂單但沒樣品,我們在 ROI 分析中可能不顯示,或者顯示在 Y 軸上 X=0。
|
||||
# 根據需求:分析「樣品寄送」與「訂單接收」的關聯,通常以有送樣的為基底。
|
||||
|
||||
return [
|
||||
ScatterPoint(
|
||||
customer=v["customer"],
|
||||
pn=key[1],
|
||||
sample_qty=v["sample_qty"],
|
||||
order_qty=v["order_qty"]
|
||||
)
|
||||
for key, v in data_map.items()
|
||||
]
|
||||
|
||||
@router.get("/orphans", response_model=List[OrphanSample])
|
||||
def get_orphans(db: Session = Depends(get_db)):
|
||||
now = datetime.now()
|
||||
threshold_date = now - timedelta(days=90)
|
||||
|
||||
# 找出所有樣品
|
||||
samples = db.query(SampleRecord).all()
|
||||
|
||||
# 找出有訂單的人 (cust_id, pn)
|
||||
orders_keys = set(db.query(OrderRecord.cust_id, OrderRecord.pn).distinct().all())
|
||||
|
||||
orphans = []
|
||||
for s in samples:
|
||||
key = (s.cust_id, s.pn)
|
||||
s_date = parse_date(s.date)
|
||||
|
||||
if key not in orders_keys:
|
||||
if s_date and s_date < threshold_date:
|
||||
orphans.append(OrphanSample(
|
||||
customer=s.customer,
|
||||
pn=s.pn,
|
||||
days_since_sent=(now - s_date).days,
|
||||
order_no=s.order_no,
|
||||
date=s.date
|
||||
))
|
||||
|
||||
return sorted(orphans, key=lambda x: x.days_since_sent, reverse=True)
|
||||
Reference in New Issue
Block a user