182 lines
6.0 KiB
Python
182 lines
6.0 KiB
Python
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)
|