first commit

This commit is contained in:
2026-01-09 19:14:41 +08:00
commit 9f3c96ce73
67 changed files with 9636 additions and 0 deletions

View File

@@ -0,0 +1 @@
# Routers package

View 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)
)

View 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
View 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
View 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)

View 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
)

View 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}"
}
)