first commit
This commit is contained in:
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routers package
|
||||
84
backend/app/routers/auth.py
Normal file
84
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from app.models import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.utils.security import (
|
||||
get_password_hash, verify_password,
|
||||
create_access_token, get_current_user
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
role: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
user: UserResponse
|
||||
|
||||
def get_role_value(role) -> str:
|
||||
"""取得 role 的字串值,相容 Enum 和字串"""
|
||||
if hasattr(role, 'value'):
|
||||
return role.value
|
||||
return str(role) if role else 'user'
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
"""註冊新使用者"""
|
||||
existing_user = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
password_hash=get_password_hash(user_data.password),
|
||||
role=UserRole.user
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return UserResponse(id=user.id, email=user.email, role=get_role_value(user.role))
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
"""登入取得 JWT Token"""
|
||||
user = db.query(User).filter(User.email == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
user=UserResponse(id=user.id, email=user.email, role=get_role_value(user.role))
|
||||
)
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""取得當前使用者資訊"""
|
||||
return UserResponse(
|
||||
id=current_user.id,
|
||||
email=current_user.email,
|
||||
role=get_role_value(current_user.role)
|
||||
)
|
||||
225
backend/app/routers/dashboard.py
Normal file
225
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
class KPIResponse(BaseModel):
|
||||
total_dit: int
|
||||
sample_rate: float # 送樣轉換率
|
||||
hit_rate: float # 訂單命中率
|
||||
fulfillment_rate: float # EAU 達成率
|
||||
orphan_sample_rate: float # 無效送樣率
|
||||
total_revenue: float
|
||||
|
||||
class FunnelItem(BaseModel):
|
||||
name: str
|
||||
value: int
|
||||
fill: str
|
||||
|
||||
class AttributionDit(BaseModel):
|
||||
op_id: str
|
||||
customer: str
|
||||
pn: str
|
||||
eau: int
|
||||
stage: str
|
||||
date: str
|
||||
|
||||
class AttributionSample(BaseModel):
|
||||
order_no: str
|
||||
date: str
|
||||
|
||||
class AttributionOrder(BaseModel):
|
||||
order_no: str
|
||||
status: str
|
||||
qty: int
|
||||
amount: float
|
||||
|
||||
class AttributionRow(BaseModel):
|
||||
dit: AttributionDit
|
||||
sample: AttributionSample | None
|
||||
order: AttributionOrder | None
|
||||
match_source: 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
|
||||
|
||||
@router.get("/kpi", response_model=KPIResponse)
|
||||
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)
|
||||
|
||||
# 1. 送樣轉換率 (Sample Rate): (有匹配到樣品的 DIT 數) / (總 DIT 數)
|
||||
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
|
||||
sample_rate = (dits_with_sample / total_dit * 100)
|
||||
|
||||
# 2. 訂單命中率 (Hit Rate): (有匹配到訂單的 DIT 數) / (總 DIT 數)
|
||||
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
|
||||
hit_rate = (dits_with_order / total_dit * 100)
|
||||
|
||||
# 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(
|
||||
MatchResult, MatchResult.target_id == OrderRecord.id
|
||||
).filter(
|
||||
MatchResult.target_type == TargetType.ORDER,
|
||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||
).scalar() or 0
|
||||
|
||||
return KPIResponse(
|
||||
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
|
||||
)
|
||||
|
||||
@router.get("/funnel", response_model=List[FunnelItem])
|
||||
def get_funnel(db: Session = Depends(get_db)):
|
||||
"""取得漏斗數據"""
|
||||
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
|
||||
|
||||
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'),
|
||||
]
|
||||
|
||||
@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 = []
|
||||
|
||||
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)
|
||||
))
|
||||
|
||||
return result
|
||||
246
backend/app/routers/etl.py
Normal file
246
backend/app/routers/etl.py
Normal file
@@ -0,0 +1,246 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from sqlalchemy.orm import Session
|
||||
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, ReviewLog
|
||||
from app.config import UPLOAD_DIR
|
||||
from app.services.excel_parser import excel_parser
|
||||
from app.services.fuzzy_matcher import normalize_customer_name, sanitize_pn
|
||||
|
||||
router = APIRouter(prefix="/etl", tags=["ETL"])
|
||||
|
||||
class ParsedFileResponse(BaseModel):
|
||||
file_id: str
|
||||
file_type: str
|
||||
filename: str
|
||||
header_row: int
|
||||
row_count: int
|
||||
preview: List[dict]
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
file_id: str
|
||||
|
||||
class ImportResponse(BaseModel):
|
||||
imported_count: int
|
||||
|
||||
@router.post("/upload", response_model=ParsedFileResponse)
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
file_type: str = Form(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""上傳並解析 Excel 檔案"""
|
||||
if file_type not in ['dit', 'sample', 'order']:
|
||||
raise HTTPException(status_code=400, detail="Invalid file type")
|
||||
|
||||
# 儲存檔案
|
||||
file_path = UPLOAD_DIR / file.filename
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
try:
|
||||
# 解析檔案
|
||||
file_id, file_info = excel_parser.parse_file(file_path, file_type)
|
||||
|
||||
return ParsedFileResponse(
|
||||
file_id=file_id,
|
||||
file_type=file_info['file_type'],
|
||||
filename=file_info['filename'],
|
||||
header_row=file_info['header_row'],
|
||||
row_count=file_info['row_count'],
|
||||
preview=file_info['preview']
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {str(e)}")
|
||||
|
||||
@router.get("/preview/{file_id}", response_model=ParsedFileResponse)
|
||||
def get_preview(file_id: str):
|
||||
"""取得檔案預覽"""
|
||||
file_info = excel_parser.get_file_info(file_id)
|
||||
if not file_info:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return ParsedFileResponse(
|
||||
file_id=file_info['file_id'],
|
||||
file_type=file_info['file_type'],
|
||||
filename=file_info['filename'],
|
||||
header_row=file_info['header_row'],
|
||||
row_count=file_info['row_count'],
|
||||
preview=file_info['preview']
|
||||
)
|
||||
|
||||
def clean_value(val, default=''):
|
||||
"""清理欄位值,處理 nan 和空值"""
|
||||
if val is None or (isinstance(val, float) and pd.isna(val)):
|
||||
return default
|
||||
str_val = str(val).strip()
|
||||
if str_val.lower() in ('nan', 'none', 'null', ''):
|
||||
return default
|
||||
return str_val
|
||||
|
||||
|
||||
@router.post("/import", response_model=ImportResponse)
|
||||
def import_data(request: ImportRequest, db: Session = Depends(get_db)):
|
||||
"""匯入資料到資料庫"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
file_info = excel_parser.get_file_info(request.file_id)
|
||||
if not file_info:
|
||||
print(f"[ETL Import] Error: File not found for file_id={request.file_id}")
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
df = excel_parser.get_parsed_data(request.file_id)
|
||||
if df is None:
|
||||
print(f"[ETL Import] Error: Parsed data not found for file_id={request.file_id}")
|
||||
raise HTTPException(status_code=404, detail="Parsed data not found")
|
||||
|
||||
print(f"[ETL Import] Starting import: file_type={file_info['file_type']}, rows={len(df)}")
|
||||
|
||||
file_type = file_info['file_type']
|
||||
imported_count = 0
|
||||
seen_ids = set() # 追蹤已處理的 ID,避免檔案內重複
|
||||
|
||||
# 清除該類型的舊資料,避免重複鍵衝突
|
||||
try:
|
||||
if file_type == 'dit':
|
||||
print("[ETL Import] Clearing old DIT records and dependent matches/logs...")
|
||||
# 先清除與 DIT 相關的審核日誌與比對結果
|
||||
db.query(ReviewLog).delete()
|
||||
db.query(MatchResult).delete()
|
||||
db.query(DitRecord).delete()
|
||||
elif file_type == 'sample':
|
||||
print("[ETL Import] Clearing old Sample records and dependent matches/logs...")
|
||||
# 先清除與 Sample 相關的比對結果 (及其日誌)
|
||||
# 這裡比較複雜,因為 ReviewLog 是透過 MatchResult 關聯的
|
||||
# 但既然我們是清空整個類別,直接清空所有 ReviewLog 和對應的 MatchResult 是最安全的
|
||||
db.query(ReviewLog).delete()
|
||||
db.query(MatchResult).filter(MatchResult.target_type == TargetType.SAMPLE).delete()
|
||||
db.query(SampleRecord).delete()
|
||||
elif file_type == 'order':
|
||||
print("[ETL Import] Clearing old Order records and dependent matches/logs...")
|
||||
db.query(ReviewLog).delete()
|
||||
db.query(MatchResult).filter(MatchResult.target_type == TargetType.ORDER).delete()
|
||||
db.query(OrderRecord).delete()
|
||||
db.flush() # 使用 flush 而非 commit,保持在同一個事務中
|
||||
print("[ETL Import] Old data cleared successfully.")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"[ETL Import] Error clearing old data: {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to clear old data: {str(e)}")
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
try:
|
||||
if file_type == 'dit':
|
||||
op_id = clean_value(row.get('op_id'), '')
|
||||
erp_account = clean_value(row.get('erp_account'), '')
|
||||
customer = clean_value(row.get('customer'))
|
||||
pn = clean_value(row.get('pn'))
|
||||
# 跳過無效資料列或重複的 op_id + pn 組合
|
||||
unique_key = f"{op_id}|{pn}"
|
||||
if not op_id or unique_key in seen_ids:
|
||||
continue
|
||||
seen_ids.add(unique_key)
|
||||
record = DitRecord(
|
||||
op_id=op_id,
|
||||
erp_account=erp_account,
|
||||
customer=customer,
|
||||
customer_normalized=normalize_customer_name(customer),
|
||||
pn=sanitize_pn(pn),
|
||||
eau=int(row.get('eau', 0)) if row.get('eau') and not pd.isna(row.get('eau')) else 0,
|
||||
stage=clean_value(row.get('stage')),
|
||||
date=clean_value(row.get('date'))
|
||||
)
|
||||
elif file_type == 'sample':
|
||||
sample_id = clean_value(row.get('sample_id'), f'S{idx}')
|
||||
oppy_no = clean_value(row.get('oppy_no'), '')
|
||||
cust_id = clean_value(row.get('cust_id'), '')
|
||||
customer = clean_value(row.get('customer'))
|
||||
pn = clean_value(row.get('pn'))
|
||||
# 跳過重複的 sample_id
|
||||
if sample_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(sample_id)
|
||||
record = SampleRecord(
|
||||
sample_id=sample_id,
|
||||
order_no=clean_value(row.get('order_no')),
|
||||
oppy_no=oppy_no,
|
||||
cust_id=cust_id,
|
||||
customer=customer,
|
||||
customer_normalized=normalize_customer_name(customer),
|
||||
pn=sanitize_pn(pn),
|
||||
qty=int(row.get('qty', 0)) if row.get('qty') and not pd.isna(row.get('qty')) else 0,
|
||||
date=clean_value(row.get('date'))
|
||||
)
|
||||
elif file_type == 'order':
|
||||
order_id = clean_value(row.get('order_id'), f'O{idx}')
|
||||
cust_id = clean_value(row.get('cust_id'), '')
|
||||
customer = clean_value(row.get('customer'))
|
||||
pn = clean_value(row.get('pn'))
|
||||
# 跳過重複的 order_id
|
||||
if order_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(order_id)
|
||||
record = OrderRecord(
|
||||
order_id=order_id,
|
||||
order_no=clean_value(row.get('order_no')),
|
||||
cust_id=cust_id,
|
||||
customer=customer,
|
||||
customer_normalized=normalize_customer_name(customer),
|
||||
pn=sanitize_pn(pn),
|
||||
qty=int(row.get('qty', 0)) if row.get('qty') and not pd.isna(row.get('qty')) else 0,
|
||||
status=clean_value(row.get('status'), 'Backlog'),
|
||||
amount=float(row.get('amount', 0)) if row.get('amount') and not pd.isna(row.get('amount')) else 0
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
db.add(record)
|
||||
imported_count += 1
|
||||
if imported_count % 500 == 0:
|
||||
print(f"[ETL Import] Processed {imported_count} rows...")
|
||||
except Exception as e:
|
||||
print(f"[ETL Import] Error importing row {idx}: {e}")
|
||||
continue
|
||||
|
||||
try:
|
||||
print(f"[ETL Import] Committing {imported_count} records...")
|
||||
db.commit()
|
||||
print(f"[ETL Import] Import successful: {imported_count} records.")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"[ETL Import] Commit Error: {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to commit data: {str(e)}")
|
||||
|
||||
return ImportResponse(imported_count=imported_count)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[ETL Import] Unhandled Exception: {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}")
|
||||
|
||||
@router.get("/data/{data_type}")
|
||||
def get_data(data_type: str, db: Session = Depends(get_db)):
|
||||
"""取得已匯入的資料"""
|
||||
if data_type == 'dit':
|
||||
records = db.query(DitRecord).all()
|
||||
elif data_type == 'sample':
|
||||
records = db.query(SampleRecord).all()
|
||||
elif data_type == 'order':
|
||||
records = db.query(OrderRecord).all()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid data type")
|
||||
|
||||
return [
|
||||
{
|
||||
**{c.name: getattr(record, c.name) for c in record.__table__.columns}
|
||||
}
|
||||
for record in records
|
||||
]
|
||||
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)
|
||||
171
backend/app/routers/match.py
Normal file
171
backend/app/routers/match.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
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.services.fuzzy_matcher import FuzzyMatcher
|
||||
|
||||
router = APIRouter(prefix="/match", tags=["Matching"])
|
||||
|
||||
class MatchRunResponse(BaseModel):
|
||||
match_count: int
|
||||
auto_matched: int
|
||||
pending_review: int
|
||||
|
||||
class DitInfo(BaseModel):
|
||||
id: int
|
||||
op_id: str
|
||||
customer: str
|
||||
pn: str
|
||||
eau: int
|
||||
stage: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TargetInfo(BaseModel):
|
||||
id: int
|
||||
customer: str
|
||||
pn: str
|
||||
order_no: Optional[str]
|
||||
qty: Optional[int]
|
||||
|
||||
class MatchResultResponse(BaseModel):
|
||||
id: int
|
||||
dit_id: int
|
||||
target_type: str
|
||||
target_id: int
|
||||
score: float
|
||||
reason: str
|
||||
status: str
|
||||
dit: Optional[DitInfo]
|
||||
target: Optional[TargetInfo]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
action: str # 'accept' or 'reject'
|
||||
|
||||
@router.post("/run", response_model=MatchRunResponse)
|
||||
def run_matching(db: Session = Depends(get_db)):
|
||||
"""執行模糊比對"""
|
||||
matcher = FuzzyMatcher(db)
|
||||
result = matcher.run_matching()
|
||||
return MatchRunResponse(**result)
|
||||
|
||||
@router.get("/results", response_model=List[MatchResultResponse])
|
||||
def get_results(db: Session = Depends(get_db)):
|
||||
"""取得所有比對結果"""
|
||||
matches = db.query(MatchResult).all()
|
||||
|
||||
results = []
|
||||
for match in matches:
|
||||
# 取得 DIT 資訊
|
||||
dit = db.query(DitRecord).filter(DitRecord.id == match.dit_id).first()
|
||||
dit_info = DitInfo(
|
||||
id=dit.id,
|
||||
op_id=dit.op_id,
|
||||
customer=dit.customer,
|
||||
pn=dit.pn,
|
||||
eau=dit.eau,
|
||||
stage=dit.stage
|
||||
) if dit else None
|
||||
|
||||
# 取得目標資訊
|
||||
target_info = None
|
||||
if match.target_type == TargetType.SAMPLE:
|
||||
sample = db.query(SampleRecord).filter(SampleRecord.id == match.target_id).first()
|
||||
if sample:
|
||||
target_info = TargetInfo(
|
||||
id=sample.id,
|
||||
customer=sample.customer,
|
||||
pn=sample.pn,
|
||||
order_no=sample.order_no,
|
||||
qty=sample.qty
|
||||
)
|
||||
elif match.target_type == TargetType.ORDER:
|
||||
order = db.query(OrderRecord).filter(OrderRecord.id == match.target_id).first()
|
||||
if order:
|
||||
target_info = TargetInfo(
|
||||
id=order.id,
|
||||
customer=order.customer,
|
||||
pn=order.pn,
|
||||
order_no=order.order_no,
|
||||
qty=order.qty
|
||||
)
|
||||
|
||||
results.append(MatchResultResponse(
|
||||
id=match.id,
|
||||
dit_id=match.dit_id,
|
||||
target_type=match.target_type.value,
|
||||
target_id=match.target_id,
|
||||
score=match.score,
|
||||
reason=match.reason,
|
||||
status=match.status.value,
|
||||
dit=dit_info,
|
||||
target=target_info
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
@router.put("/{match_id}/review", response_model=MatchResultResponse)
|
||||
def review_match(match_id: int, request: ReviewRequest, db: Session = Depends(get_db)):
|
||||
"""審核比對結果"""
|
||||
if request.action not in ['accept', 'reject']:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
matcher = FuzzyMatcher(db)
|
||||
match = matcher.review_match(match_id, request.action)
|
||||
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
|
||||
# 取得相關資訊
|
||||
dit = db.query(DitRecord).filter(DitRecord.id == match.dit_id).first()
|
||||
dit_info = DitInfo(
|
||||
id=dit.id,
|
||||
op_id=dit.op_id,
|
||||
customer=dit.customer,
|
||||
pn=dit.pn,
|
||||
eau=dit.eau,
|
||||
stage=dit.stage
|
||||
) if dit else None
|
||||
|
||||
target_info = None
|
||||
if match.target_type == TargetType.SAMPLE:
|
||||
sample = db.query(SampleRecord).filter(SampleRecord.id == match.target_id).first()
|
||||
if sample:
|
||||
target_info = TargetInfo(
|
||||
id=sample.id,
|
||||
customer=sample.customer,
|
||||
pn=sample.pn,
|
||||
order_no=sample.order_no,
|
||||
qty=sample.qty
|
||||
)
|
||||
elif match.target_type == TargetType.ORDER:
|
||||
order = db.query(OrderRecord).filter(OrderRecord.id == match.target_id).first()
|
||||
if order:
|
||||
target_info = TargetInfo(
|
||||
id=order.id,
|
||||
customer=order.customer,
|
||||
pn=order.pn,
|
||||
order_no=order.order_no,
|
||||
qty=order.qty
|
||||
)
|
||||
|
||||
return MatchResultResponse(
|
||||
id=match.id,
|
||||
dit_id=match.dit_id,
|
||||
target_type=match.target_type.value,
|
||||
target_id=match.target_id,
|
||||
score=match.score,
|
||||
reason=match.reason,
|
||||
status=match.status.value,
|
||||
dit=dit_info,
|
||||
target=target_info
|
||||
)
|
||||
32
backend/app/routers/report.py
Normal file
32
backend/app/routers/report.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import get_db
|
||||
from app.services.report_generator import ReportGenerator
|
||||
|
||||
router = APIRouter(prefix="/report", tags=["Report"])
|
||||
|
||||
@router.get("/export")
|
||||
def export_report(format: str = "xlsx", db: Session = Depends(get_db)):
|
||||
"""匯出報表"""
|
||||
if format not in ['xlsx', 'pdf']:
|
||||
raise HTTPException(status_code=400, detail="Invalid format. Use 'xlsx' or 'pdf'")
|
||||
|
||||
generator = ReportGenerator(db)
|
||||
|
||||
if format == 'xlsx':
|
||||
output = generator.generate_excel()
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
filename = "dit_attribution_report.xlsx"
|
||||
else:
|
||||
output = generator.generate_pdf()
|
||||
media_type = "application/pdf"
|
||||
filename = "dit_attribution_report.pdf"
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}"
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user