This commit is contained in:
2026-01-16 18:16:33 +08:00
parent 9f3c96ce73
commit e53c3c838c
26 changed files with 1473 additions and 386 deletions

26
backend/add_column.py Normal file
View File

@@ -0,0 +1,26 @@
import os
from sqlalchemy import create_engine, text
from app.config import DATABASE_URL, TABLE_PREFIX
def add_column():
engine = create_engine(DATABASE_URL)
table_name = f"{TABLE_PREFIX}DIT_Records"
column_name = "op_name"
with engine.connect() as conn:
try:
# Check if column exists
result = conn.execute(text(f"SHOW COLUMNS FROM {table_name} LIKE '{column_name}'"))
if result.fetchone():
print(f"Column {column_name} already exists in {table_name}.")
else:
print(f"Adding column {column_name} to {table_name}...")
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} VARCHAR(255) NULL AFTER op_id"))
conn.commit()
print("Column added successfully.")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
add_column()

26
backend/add_order_date.py Normal file
View File

@@ -0,0 +1,26 @@
import os
from sqlalchemy import create_engine, text
from app.config import DATABASE_URL, TABLE_PREFIX
def add_order_date_column():
engine = create_engine(DATABASE_URL)
table_name = f"{TABLE_PREFIX}Order_Records"
column_name = "date"
with engine.connect() as conn:
try:
# Check if column exists
result = conn.execute(text(f"SHOW COLUMNS FROM {table_name} LIKE '{column_name}'"))
if result.fetchone():
print(f"Column {column_name} already exists in {table_name}.")
else:
print(f"Adding column {column_name} to {table_name}...")
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} VARCHAR(20) NULL AFTER amount"))
conn.commit()
print("Column added successfully.")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
add_order_date_column()

View File

@@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from app.models import init_db from app.models import init_db
from app.routers import auth, etl, match, dashboard, report, lab from app.routers import auth, etl, match, report, lab, dashboard
from app.config import STATIC_DIR, DEBUG, CORS_ORIGINS, APP_HOST, APP_PORT from app.config import STATIC_DIR, DEBUG, CORS_ORIGINS, APP_HOST, APP_PORT
# 初始化資料庫 # 初始化資料庫
@@ -31,9 +31,10 @@ if DEBUG and CORS_ORIGINS:
app.include_router(auth.router, prefix="/api") app.include_router(auth.router, prefix="/api")
app.include_router(etl.router, prefix="/api") app.include_router(etl.router, prefix="/api")
app.include_router(match.router, prefix="/api") app.include_router(match.router, prefix="/api")
app.include_router(dashboard.router, prefix="/api")
app.include_router(report.router, prefix="/api") app.include_router(report.router, prefix="/api")
app.include_router(lab.router, prefix="/api") app.include_router(lab.router, prefix="/api")
app.include_router(dashboard.router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")
def health_check(): def health_check():

View File

@@ -11,6 +11,7 @@ class DitRecord(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
op_id = Column(String(255), index=True, nullable=False) # 移除 unique因為同一 op_id 可有多個 pn op_id = Column(String(255), index=True, nullable=False) # 移除 unique因為同一 op_id 可有多個 pn
op_name = Column(String(255), nullable=True) # Opportunity Name
erp_account = Column(String(100), index=True) # AQ 欄 erp_account = Column(String(100), index=True) # AQ 欄
customer = Column(String(255), nullable=False, index=True) customer = Column(String(255), nullable=False, index=True)
customer_normalized = Column(String(255), index=True) customer_normalized = Column(String(255), index=True)

View File

@@ -16,5 +16,6 @@ class OrderRecord(Base):
qty = Column(Integer, default=0) qty = Column(Integer, default=0)
status = Column(String(50), default='Backlog') # 改為 String 以支援中文狀態 status = Column(String(50), default='Backlog') # 改為 String 以支援中文狀態
amount = Column(Float, default=0.0) amount = Column(Float, default=0.0)
date = Column(String(20)) # 訂單日期
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -1,225 +1,238 @@
from typing import List
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func, distinct
from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from app.models import get_db from app.models import get_db
from app.models.dit import DitRecord from app.models.dit import DitRecord
from app.models.sample import SampleRecord from app.models.sample import SampleRecord
from app.models.order import OrderRecord 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"]) router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
class KPIResponse(BaseModel): # --- Pydantic Models ---
class DashboardKPI(BaseModel):
total_dit: int total_dit: int
sample_rate: float # 送樣轉換率 sample_rate: float
hit_rate: float # 訂單命中率 hit_rate: float
fulfillment_rate: float # EAU 達成率 fulfillment_rate: float
orphan_sample_rate: float # 無效送樣率 no_order_sample_rate: float
total_revenue: float total_revenue: float
class FunnelItem(BaseModel): class FunnelData(BaseModel):
name: str name: str
value: int value: int
fill: str fill: str
class AttributionDit(BaseModel): class DitSchema(BaseModel):
op_id: str id: int
op_id: Optional[str] = None
customer: str customer: str
pn: str pn: str
eau: int eau: float = 0
stage: str stage: Optional[str] = None
date: str date: Optional[str] = None
class AttributionSample(BaseModel): class Config:
order_no: str from_attributes = True
date: str
class AttributionOrder(BaseModel): class SampleSchema(BaseModel):
order_no: str id: int
status: str order_no: Optional[str] = None
qty: int customer: str
amount: float 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): class AttributionRow(BaseModel):
dit: AttributionDit dit: DitSchema
sample: AttributionSample | None sample: Optional[SampleSchema] = None
order: AttributionOrder | None order: Optional[OrderSchema] = None
match_source: str | None match_source: Optional[str] = None
attributed_qty: int attributed_qty: int
fulfillment_rate: float fulfillment_rate: float
def get_lifo_attribution(db: Session): # --- Routes ---
"""執行 LIFO 業績分配邏輯"""
# 1. 取得所有 DIT按日期由新到舊排序 (LIFO)
dits = db.query(DitRecord).order_by(DitRecord.date.desc()).all()
# 2. 取得所有已匹配且接受的訂單 @router.get("/kpi", response_model=DashboardKPI)
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)): def get_kpi(db: Session = Depends(get_db)):
"""取得 KPI 統計 (符合規格書 v1.0)"""
total_dit = db.query(DitRecord).count() total_dit = db.query(DitRecord).count()
if total_dit == 0: 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 數) # Get valid matches
dits_with_sample = db.query(func.count(func.distinct(MatchResult.dit_id))).filter( 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.target_type == TargetType.SAMPLE,
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) MatchResult.status.in_(valid_statuses)
).scalar() or 0 ).distinct().count()
sample_rate = (dits_with_sample / total_dit * 100)
# 2. 訂單命中率 (Hit Rate): (有匹配到訂單的 DIT 數) / (總 DIT 數) # 2. Matches for Orders
dits_with_order = db.query(func.count(func.distinct(MatchResult.dit_id))).filter( order_matches = db.query(MatchResult.dit_id).filter(
MatchResult.target_type == TargetType.ORDER, MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) MatchResult.status.in_(valid_statuses)
).scalar() or 0 ).distinct().count()
hit_rate = (dits_with_order / total_dit * 100)
# 3. EAU 達成率 (Fulfillment Rate): (歸因之訂單總量) / (DIT 預估 EAU) # 3. Revenue
attribution_map = get_lifo_attribution(db) # Join MatchResult -> OrderRecord to sum amount
total_attributed_qty = sum(item['qty'] for item in attribution_map.values()) revenue = db.query(func.sum(OrderRecord.amount)).join(
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 MatchResult, MatchResult.target_id == OrderRecord.id
).filter( ).filter(
MatchResult.target_type == TargetType.ORDER, MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) MatchResult.status.in_(valid_statuses)
).scalar() or 0.0
# 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 ).scalar() or 0
return KPIResponse( 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, total_dit=total_dit,
sample_rate=round(sample_rate, 1), sample_rate=sample_rate,
hit_rate=round(hit_rate, 1), hit_rate=hit_rate,
fulfillment_rate=round(fulfillment_rate, 1), fulfillment_rate=fulfillment_rate,
orphan_sample_rate=round(orphan_sample_rate, 1), no_order_sample_rate=no_order_sample_rate,
total_revenue=total_revenue 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)): def get_funnel(db: Session = Depends(get_db)):
"""取得漏斗數據""" valid_statuses = [MatchStatus.auto_matched, MatchStatus.accepted]
# Stage 1: DIT
total_dit = db.query(DitRecord).count() total_dit = db.query(DitRecord).count()
dits_with_sample = db.query(func.count(func.distinct(MatchResult.dit_id))).filter( # Stage 2: Sample
dit_with_samples = db.query(MatchResult.dit_id).filter(
MatchResult.target_type == TargetType.SAMPLE, MatchResult.target_type == TargetType.SAMPLE,
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) MatchResult.status.in_(valid_statuses)
).scalar() or 0 ).distinct().count()
dits_with_order = db.query(func.count(func.distinct(MatchResult.dit_id))).filter( # Stage 3: Order
dit_with_orders = db.query(MatchResult.dit_id).filter(
MatchResult.target_type == TargetType.ORDER, MatchResult.target_type == TargetType.ORDER,
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) MatchResult.status.in_(valid_statuses)
).scalar() or 0 ).distinct().count()
return [ return [
FunnelItem(name='DIT 案件', value=total_dit, fill='#6366f1'), FunnelData(name="DIT 總案", value=total_dit, fill="#6366f1"),
FunnelItem(name='成功送樣', value=dits_with_sample, fill='#8b5cf6'), FunnelData(name="成功送樣", value=dit_with_samples, fill="#a855f7"),
FunnelItem(name='取得訂單', value=dits_with_order, fill='#10b981'), FunnelData(name="取得訂單", value=dit_with_orders, fill="#10b981"),
] ]
@router.get("/attribution", response_model=List[AttributionRow]) @router.get("/attribution", response_model=List[AttributionRow])
def get_attribution(db: Session = Depends(get_db)): def get_attribution(db: Session = Depends(get_db)):
"""取得歸因明細 (含 LIFO 分配與追溯資訊)""" valid_statuses = [MatchStatus.auto_matched, MatchStatus.accepted]
dit_records = db.query(DitRecord).order_by(DitRecord.date.desc()).all()
attribution_map = get_lifo_attribution(db)
result = []
for dit in dit_records: matches = db.query(MatchResult).filter(MatchResult.status.in_(valid_statuses)).all()
# 找到樣品匹配 (取分數最高的一個) if not matches:
sample_match = db.query(MatchResult).filter( return []
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 dit_ids = set(m.dit_id for m in matches)
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 '')
# 找到訂單匹配 (取分數最高的一個) dits = db.query(DitRecord).filter(DitRecord.id.in_(dit_ids)).all()
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 dit_map = {d.id: d for d in dits}
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}) sample_match_rows = [m for m in matches if m.target_type == TargetType.SAMPLE]
fulfillment = (attr_data['qty'] / attr_data['eau'] * 100) if attr_data['eau'] > 0 else 0 order_match_rows = [m for m in matches if m.target_type == TargetType.ORDER]
result.append(AttributionRow( sample_ids = [m.target_id for m in sample_match_rows]
dit=AttributionDit( order_ids = [m.target_id for m in order_match_rows]
op_id=dit.op_id,
customer=dit.customer, samples = db.query(SampleRecord).filter(SampleRecord.id.in_(sample_ids)).all()
pn=dit.pn, orders = db.query(OrderRecord).filter(OrderRecord.id.in_(order_ids)).all()
eau=dit.eau,
stage=dit.stage or '', sample_lookup = {s.id: s for s in samples}
date=dit.date or '' order_lookup = {o.id: o for o in orders}
),
sample=sample_info, results = []
order=order_info,
match_source=match_source, for dit_id, dit in dit_map.items():
attributed_qty=attr_data['qty'], s_matches = [m for m in matches if m.dit_id == dit_id and m.target_type == TargetType.SAMPLE]
fulfillment_rate=round(fulfillment, 1) 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

View File

@@ -81,10 +81,28 @@ def clean_value(val, default=''):
if val is None or (isinstance(val, float) and pd.isna(val)): if val is None or (isinstance(val, float) and pd.isna(val)):
return default return default
str_val = str(val).strip() str_val = str(val).strip()
# Remove leading apostrophe often added by Excel (e.g. '001)
str_val = str_val.lstrip("'")
if str_val.lower() in ('nan', 'none', 'null', ''): if str_val.lower() in ('nan', 'none', 'null', ''):
return default return default
return str_val return str_val
def normalize_date(val):
"""將日期標準化為 YYYY-MM-DD 格式"""
val = clean_value(val, None)
if not val:
return None
# 嘗試解析常見格式
from datetime import datetime
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%d-%b-%y"):
try:
# Handle Excel default string format often like 2025/9/30
dt = datetime.strptime(val.split(' ')[0], fmt.split(' ')[0])
return dt.strftime("%Y-%m-%d")
except ValueError:
continue
return val # Return original if parse failed
@router.post("/import", response_model=ImportResponse) @router.post("/import", response_model=ImportResponse)
def import_data(request: ImportRequest, db: Session = Depends(get_db)): def import_data(request: ImportRequest, db: Session = Depends(get_db)):
@@ -150,13 +168,14 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)):
seen_ids.add(unique_key) seen_ids.add(unique_key)
record = DitRecord( record = DitRecord(
op_id=op_id, op_id=op_id,
op_name=clean_value(row.get('op_name')),
erp_account=erp_account, erp_account=erp_account,
customer=customer, customer=customer,
customer_normalized=normalize_customer_name(customer), customer_normalized=normalize_customer_name(customer),
pn=sanitize_pn(pn), pn=sanitize_pn(pn),
eau=int(row.get('eau', 0)) if row.get('eau') and not pd.isna(row.get('eau')) else 0, 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')), stage=clean_value(row.get('stage')),
date=clean_value(row.get('date')) date=normalize_date(row.get('date'))
) )
elif file_type == 'sample': elif file_type == 'sample':
sample_id = clean_value(row.get('sample_id'), f'S{idx}') sample_id = clean_value(row.get('sample_id'), f'S{idx}')
@@ -177,7 +196,7 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)):
customer_normalized=normalize_customer_name(customer), customer_normalized=normalize_customer_name(customer),
pn=sanitize_pn(pn), pn=sanitize_pn(pn),
qty=int(row.get('qty', 0)) if row.get('qty') and not pd.isna(row.get('qty')) else 0, 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')) date=normalize_date(row.get('date'))
) )
elif file_type == 'order': elif file_type == 'order':
order_id = clean_value(row.get('order_id'), f'O{idx}') order_id = clean_value(row.get('order_id'), f'O{idx}')
@@ -195,9 +214,10 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)):
customer=customer, customer=customer,
customer_normalized=normalize_customer_name(customer), customer_normalized=normalize_customer_name(customer),
pn=sanitize_pn(pn), pn=sanitize_pn(pn),
qty=int(row.get('qty', 0)) if row.get('qty') and not pd.isna(row.get('qty')) else 0, qty=int(float(row.get('qty', 0)) * 1000) if row.get('qty') and not pd.isna(row.get('qty')) else 0,
status=clean_value(row.get('status'), 'Backlog'), 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 amount=float(row.get('amount', 0)) if row.get('amount') and not pd.isna(row.get('amount')) else 0,
date=normalize_date(row.get('date'))
) )
else: else:
continue continue
@@ -244,3 +264,21 @@ def get_data(data_type: str, db: Session = Depends(get_db)):
} }
for record in records for record in records
] ]
@router.delete("/data")
def clear_all_data(db: Session = Depends(get_db)):
"""清除所有匯入的資料與分析結果"""
try:
print("[ETL] Clearing all data...")
db.query(ReviewLog).delete()
db.query(MatchResult).delete()
db.query(DitRecord).delete()
db.query(SampleRecord).delete()
db.query(OrderRecord).delete()
db.commit()
print("[ETL] All data cleared successfully.")
return {"message": "All data cleared successfully"}
except Exception as e:
db.rollback()
print(f"[ETL] Error clearing data: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -11,10 +11,21 @@ from app.models.order import OrderRecord
router = APIRouter(prefix="/lab", tags=["Lab"]) router = APIRouter(prefix="/lab", tags=["Lab"])
class LabKPI(BaseModel): class LabKPI(BaseModel):
converted_count: int # 成功收單總數
avg_velocity: float # 平均轉換時間 (天) avg_velocity: float # 平均轉換時間 (天)
conversion_rate: float # 轉換比例 (%) conversion_rate: float # 轉換比例 (%)
orphan_count: int # 孤兒樣品總數 orphan_count: int # 孤兒樣品總數
class ConversionRecord(BaseModel):
customer: str
pn: str
sample_date: str
sample_qty: int
order_date: str
order_qty: int
days_to_convert: int
# ... (ScatterPoint and OrphanSample classes remain same)
class ScatterPoint(BaseModel): class ScatterPoint(BaseModel):
customer: str customer: str
pn: str pn: str
@@ -28,10 +39,117 @@ class OrphanSample(BaseModel):
order_no: str order_no: str
date: str date: str
# ... (parse_date function remains same)
# Helper to build order lookups
from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name
def build_order_lookups(orders):
order_lookup_by_id = {}
order_lookup_by_name = {}
for o in orders:
clean_pn = normalize_pn_for_matching(o.pn)
clean_cust_id = o.cust_id.strip().upper() if o.cust_id else ""
norm_cust_name = normalize_customer_name(o.customer)
o_date = parse_date(o.date) or (o.created_at.replace(tzinfo=None) if o.created_at else datetime.max)
data = {
"date": o_date,
"qty": o.qty or 0,
"order_no": o.order_no
}
if clean_cust_id:
key_id = (clean_cust_id, clean_pn)
if key_id not in order_lookup_by_id: order_lookup_by_id[key_id] = []
order_lookup_by_id[key_id].append(data)
key_name = (norm_cust_name, clean_pn)
if key_name not in order_lookup_by_name: order_lookup_by_name[key_name] = []
order_lookup_by_name[key_name].append(data)
return order_lookup_by_id, order_lookup_by_name
@router.get("/conversions", response_model=List[ConversionRecord])
def get_conversions(db: Session = Depends(get_db)):
# 找出所有樣品
samples = db.query(SampleRecord).all()
# 找出所有訂單
orders = db.query(OrderRecord).all()
order_lookup_by_id, order_lookup_by_name = build_order_lookups(orders)
conversions = []
# We want to list "Sample Records" that successfully converted.
# Or "Groups"? The user said "list of sample sent and their order qty".
# Listing each sample record seems appropriate.
for s in samples:
clean_pn = normalize_pn_for_matching(s.pn)
norm_cust_name = normalize_customer_name(s.customer)
clean_cust_id = s.cust_id.strip().upper() if s.cust_id else ""
s_date = parse_date(s.date)
matched_orders = []
# 1. Try via ID
if clean_cust_id:
if (clean_cust_id, clean_pn) in order_lookup_by_id:
matched_orders.extend(order_lookup_by_id[(clean_cust_id, clean_pn)])
# 2. Try via Name (Fallback)
if not matched_orders:
if (norm_cust_name, clean_pn) in order_lookup_by_name:
matched_orders.extend(order_lookup_by_name[(norm_cust_name, clean_pn)])
if matched_orders and s_date:
# Sort orders by date
matched_orders.sort(key=lambda x: x["date"])
first_order = matched_orders[0]
# Simple aggregations if multiple orders? User asked for "their order qty".
# showing total order qty for this PN/Cust might be better
total_order_qty = sum(o["qty"] for o in matched_orders)
days_diff = (first_order["date"] - s_date).days
# Filter unrealistic past orders?
# if days_diff < 0: continue # Optional
conversions.append(ConversionRecord(
customer=s.customer,
pn=s.pn,
sample_date=s.date,
sample_qty=s.qty or 0,
order_date=first_order["date"].strftime("%Y-%m-%d"), # First order date
order_qty=total_order_qty,
days_to_convert=days_diff
))
# Sort by recent sample date
return sorted(conversions, key=lambda x: x.sample_date, reverse=True)
def parse_date(date_str: str) -> Optional[datetime]: def parse_date(date_str: str) -> Optional[datetime]:
if not date_str:
return None
val = str(date_str).strip()
# Try parsing YYYYMMDD
if len(val) == 8 and val.isdigit():
try: try:
return datetime.strptime(date_str, "%Y-%m-%d") return datetime.strptime(val, "%Y%m%d")
except: except ValueError:
pass
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%d-%b-%y"):
try:
return datetime.strptime(str(date_str).split(' ')[0], fmt.split(' ')[0])
except ValueError:
continue
return None return None
@router.get("/kpi", response_model=LabKPI) @router.get("/kpi", response_model=LabKPI)
@@ -46,27 +164,34 @@ def get_lab_kpi(
if start_date: if start_date:
samples_query = samples_query.filter(SampleRecord.date >= 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 orders_query = orders_query.filter(OrderRecord.date >= start_date)
if end_date: if end_date:
samples_query = samples_query.filter(SampleRecord.date <= end_date) samples_query = samples_query.filter(SampleRecord.date <= end_date)
# Note: OrderRecord 只有 created_at orders_query = orders_query.filter(OrderRecord.date <= end_date)
samples = samples_query.all() samples = samples_query.all()
orders = orders_query.all() orders = orders_query.all()
# 建立群組 (ERP Code + PN) # 建立群組 (ERP Code + PN)
# ERP Code correspond to cust_id # ERP Code correspond to cust_id
from app.services.fuzzy_matcher import normalize_pn_for_matching
sample_groups = {} sample_groups = {}
for s in samples: for s in samples:
key = (s.cust_id, s.pn) # Use simple normalization like stripping spaces
clean_pn = normalize_pn_for_matching(s.pn)
clean_cust = s.cust_id.strip().upper() if s.cust_id else ""
key = (clean_cust, clean_pn)
if key not in sample_groups: if key not in sample_groups:
sample_groups[key] = [] sample_groups[key] = []
sample_groups[key].append(s) sample_groups[key].append(s)
order_groups = {} order_groups = {}
for o in orders: for o in orders:
key = (o.cust_id, o.pn) clean_pn = normalize_pn_for_matching(o.pn)
clean_cust = o.cust_id.strip().upper() if o.cust_id else ""
key = (clean_cust, clean_pn)
if key not in order_groups: if key not in order_groups:
order_groups[key] = [] order_groups[key] = []
order_groups[key].append(o) order_groups[key].append(o)
@@ -76,34 +201,101 @@ def get_lab_kpi(
converted_samples_count = 0 converted_samples_count = 0
total_samples_count = len(samples) total_samples_count = len(samples)
for key, group_samples in sample_groups.items(): # Re-use the lookup maps built above if possible, but we need to build them first.
if key in order_groups: # Let's rebuild lookups here for clarity or refactor.
# 轉換成功 # To be safe and clean, let's just implement the loop here.
converted_samples_count += len(group_samples)
# 計算 Velocity: First Order Date - Earliest Sample Date from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name
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: order_lookup_by_id = {}
diff = (first_order_date - earliest_sample_date).days order_lookup_by_name = {}
for o in orders:
clean_pn = normalize_pn_for_matching(o.pn)
clean_cust_id = o.cust_id.strip().upper() if o.cust_id else ""
norm_cust_name = normalize_customer_name(o.customer)
o_date = parse_date(o.date) or (o.created_at.replace(tzinfo=None) if o.created_at else datetime.max)
if clean_cust_id:
key_id = (clean_cust_id, clean_pn)
if key_id not in order_lookup_by_id: order_lookup_by_id[key_id] = []
order_lookup_by_id[key_id].append(o_date)
key_name = (norm_cust_name, clean_pn)
if key_name not in order_lookup_by_name: order_lookup_by_name[key_name] = []
order_lookup_by_name[key_name].append(o_date)
# Group Samples by (CustName, PN) for calculation to avoid double counting if multiple samples -> same order
# Actually, "Conversion Rate" is usually "Percentage of Sample Records that resulted in Order".
# Or "Percentage of Projects". Let's stick to "Sample Groups" (Unique trials).
unique_sample_groups = {} # (norm_cust_name, clean_pn) -> list of sample dates
for s in samples:
clean_pn = normalize_pn_for_matching(s.pn)
norm_cust_name = normalize_customer_name(s.customer)
clean_cust_id = s.cust_id.strip().upper() if s.cust_id else ""
key = (norm_cust_name, clean_pn) # Group by Name+PN
if key not in unique_sample_groups:
unique_sample_groups[key] = {
"dates": [],
"cust_ids": set()
}
s_date = parse_date(s.date)
if s_date: unique_sample_groups[key]["dates"].append(s_date)
if clean_cust_id: unique_sample_groups[key]["cust_ids"].add(clean_cust_id)
# Calculate
total_samples_count = len(unique_sample_groups) # Total "Projects"
converted_count = 0
orphan_count = 0
now = datetime.now()
for key, data in unique_sample_groups.items():
norm_cust_name, clean_pn = key
# Try finding orders
matched_dates = []
# 1. Try via ID
for cid in data["cust_ids"]:
if (cid, clean_pn) in order_lookup_by_id:
matched_dates.extend(order_lookup_by_id[(cid, clean_pn)])
# 2. Try via Name
if not matched_dates:
if key in order_lookup_by_name:
matched_dates.extend(order_lookup_by_name[key])
if matched_dates:
converted_count += 1
# Velocity
earliest_sample = min(data["dates"]) if data["dates"] else None
# Filter orders that came AFTER sample? Or just first order?
# Typically first order date.
first_order = min(matched_dates) if matched_dates else None
if earliest_sample and first_order:
diff = (first_order - earliest_sample).days
if diff >= 0: if diff >= 0:
velocities.append(diff) velocities.append(diff)
else:
avg_velocity = sum(velocities) / len(velocities) if velocities else 0 # Check Orphan (No Order)
conversion_rate = (converted_samples_count / total_samples_count * 100) if total_samples_count > 0 else 0 # Use earliest sample date
earliest_sample = min(data["dates"]) if data["dates"] else None
# 孤兒樣品: > 90天且無訂單 if earliest_sample and (now - earliest_sample).days > 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 orphan_count += 1
avg_velocity = sum(velocities) / len(velocities) if velocities else 0
conversion_rate = (converted_count / total_samples_count * 100) if total_samples_count > 0 else 0
return LabKPI( return LabKPI(
converted_count=converted_count,
avg_velocity=round(avg_velocity, 1), avg_velocity=round(avg_velocity, 1),
conversion_rate=round(conversion_rate, 1), conversion_rate=round(conversion_rate, 1),
orphan_count=orphan_count orphan_count=orphan_count
@@ -127,25 +319,117 @@ def get_scatter_data(
orders = orders_query.all() orders = orders_query.all()
# 聚合資料 # 聚合資料
data_map = {} # (cust_id, pn) -> {sample_qty, order_qty, customer_name} from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name
for s in samples: # 建立多重索引的 Order Lookup
key = (s.cust_id, s.pn) # order_lookup_by_id: (cust_id, pn) -> Order Data
if key not in data_map: # order_lookup_by_name: (cust_name, pn) -> Order Data
data_map[key] = {"sample_qty": 0, "order_qty": 0, "customer": s.customer} order_lookup_by_id = {}
data_map[key]["sample_qty"] += (s.qty or 0) order_lookup_by_name = {}
for o in orders: for o in orders:
key = (o.cust_id, o.pn) clean_pn = normalize_pn_for_matching(o.pn)
if key in data_map: clean_cust_id = o.cust_id.strip().upper() if o.cust_id else ""
data_map[key]["order_qty"] += (o.qty or 0) norm_cust_name = normalize_customer_name(o.customer)
# Aggregate by Cust ID
if clean_cust_id:
key_id = (clean_cust_id, clean_pn)
if key_id not in order_lookup_by_id:
order_lookup_by_id[key_id] = {"qty": 0, "dates": []}
order_lookup_by_id[key_id]["qty"] += (o.qty or 0)
if o.date: order_lookup_by_id[key_id]["dates"].append(parse_date(o.date) or datetime.max)
elif o.created_at: order_lookup_by_id[key_id]["dates"].append(o.created_at.replace(tzinfo=None))
# Aggregate by Cust Name (Fallback)
key_name = (norm_cust_name, clean_pn)
if key_name not in order_lookup_by_name:
order_lookup_by_name[key_name] = {"qty": 0, "dates": []}
order_lookup_by_name[key_name]["qty"] += (o.qty or 0)
if o.date: order_lookup_by_name[key_name]["dates"].append(parse_date(o.date) or datetime.max)
elif o.created_at: order_lookup_by_name[key_name]["dates"].append(o.created_at.replace(tzinfo=None))
final_data_map = {} # Key (Display Customer, Original PN) -> Data
for s in samples:
clean_pn = normalize_pn_for_matching(s.pn)
clean_cust_id = s.cust_id.strip().upper() if s.cust_id else ""
norm_cust_name = normalize_customer_name(s.customer)
# 嘗試比對 Order
matched_order = None
# 1. Try Cust ID match
if clean_cust_id:
matched_order = order_lookup_by_id.get((clean_cust_id, clean_pn))
# 2. If no match, Try Cust Name match
if not matched_order:
matched_order = order_lookup_by_name.get((norm_cust_name, clean_pn))
# Render Key using Sample's info
display_key = (s.customer, s.pn)
if display_key not in final_data_map:
final_data_map[display_key] = {"sample_qty": 0, "order_qty": 0, "customer": s.customer, "orignal_pn": s.pn}
final_data_map[display_key]["sample_qty"] += (s.qty or 0)
if matched_order:
# 注意:這裡簡單累加可能會導致重複計算如果多個樣品對應同一個訂單聚合
# 但目前邏輯是以「樣品」為基底看轉換,所以我們顯示該樣品對應到的訂單總量是合理的
# 不過為了 scatter plot 的準確性,我們應該只在第一次遇到這個 key 時加上 order qty?
# 或者Scatter Plot 的點是 (Customer, PN),所以我們應該是把這個 Group 的 Sample Qty 和 Order Qty 放在一起。
# Order Qty 已經在 lookup 裡聚合過了。
pass
# Re-construct the final map properly merging Order Data
# 上面的迴圈有點問題,因為我們是依據 Sample 來建立點,但 Order 總量是固定的。
# 正確做法:以 (Customer, PN) 為 Unique Key。
unique_groups = {} # (norm_cust_name, clean_pn) -> {display_cust, display_pn, sample_qty, order_qty}
for s in samples:
clean_pn = normalize_pn_for_matching(s.pn)
norm_cust_name = normalize_customer_name(s.customer)
key = (norm_cust_name, clean_pn)
if key not in unique_groups:
unique_groups[key] = {
"display_cust": s.customer,
"display_pn": s.pn,
"sample_qty": 0,
"order_qty": 0,
"matched": False
}
unique_groups[key]["sample_qty"] += (s.qty or 0)
# Fill in Order Qty
for key, data in unique_groups.items():
norm_cust_name, clean_pn = key
# Try finding orders
# Note: We rely on Name match here primarily since we grouped by Name.
# Ideally we should also check CustID if available on the samples in this group, but grouping by Name is safer for visual scatter plot.
matched_order = order_lookup_by_name.get((norm_cust_name, clean_pn))
# If no name match, maybe check if any sample in this group had a CustId that matches?
# For simplicity, let's stick to Name+PN for the Scatter Plot aggregation
if matched_order:
data["order_qty"] = matched_order["qty"]
data["matched"] = True
data_map = unique_groups # Replace old data_map logic
# 如果有訂單但沒樣品,我們在 ROI 分析中可能不顯示,或者顯示在 Y 軸上 X=0。 # 如果有訂單但沒樣品,我們在 ROI 分析中可能不顯示,或者顯示在 Y 軸上 X=0。
# 根據需求:分析「樣品寄送」與「訂單接收」的關聯,通常以有送樣的為基底。 # 根據需求:分析「樣品寄送」與「訂單接收」的關聯,通常以有送樣的為基底。
return [ return [
ScatterPoint( ScatterPoint(
customer=v["customer"], customer=v["display_cust"],
pn=key[1], pn=v["display_pn"],
sample_qty=v["sample_qty"], sample_qty=v["sample_qty"],
order_qty=v["order_qty"] order_qty=v["order_qty"]
) )
@@ -159,16 +443,45 @@ def get_orphans(db: Session = Depends(get_db)):
# 找出所有樣品 # 找出所有樣品
samples = db.query(SampleRecord).all() samples = db.query(SampleRecord).all()
# 找出所有訂單
orders = db.query(OrderRecord).all()
# Build Order Lookups (ID and Name)
from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name
order_keys_id = set()
order_keys_name = set()
for o in orders:
clean_pn = normalize_pn_for_matching(o.pn)
clean_cust_id = o.cust_id.strip().upper() if o.cust_id else ""
norm_cust_name = normalize_customer_name(o.customer)
if clean_cust_id:
order_keys_id.add((clean_cust_id, clean_pn))
order_keys_name.add((norm_cust_name, clean_pn))
# 找出有訂單的人 (cust_id, pn)
orders_keys = set(db.query(OrderRecord.cust_id, OrderRecord.pn).distinct().all())
orphans = [] orphans = []
for s in samples: for s in samples:
key = (s.cust_id, s.pn) clean_pn = normalize_pn_for_matching(s.pn)
norm_cust_name = normalize_customer_name(s.customer)
clean_cust_id = s.cust_id.strip().upper() if s.cust_id else ""
s_date = parse_date(s.date) s_date = parse_date(s.date)
if key not in orders_keys: # Check match
matched = False
if clean_cust_id:
if (clean_cust_id, clean_pn) in order_keys_id:
matched = True
if not matched:
if (norm_cust_name, clean_pn) in order_keys_name:
matched = True
if not matched:
if s_date and s_date < threshold_date: if s_date and s_date < threshold_date:
orphans.append(OrphanSample( orphans.append(OrphanSample(
customer=s.customer, customer=s.customer,

View File

@@ -14,14 +14,26 @@ def export_report(format: str = "xlsx", db: Session = Depends(get_db)):
generator = ReportGenerator(db) generator = ReportGenerator(db)
print(f"Export request received. Format: {format}")
if format == 'xlsx': if format == 'xlsx':
try:
print("Generating Excel...")
output = generator.generate_excel() output = generator.generate_excel()
print("Excel generated successfully.")
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
filename = "dit_attribution_report.xlsx" filename = "dit_attribution_report.xlsx"
except Exception as e:
print(f"Error generating Excel: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
else: else:
try:
output = generator.generate_pdf() output = generator.generate_pdf()
media_type = "application/pdf" media_type = "application/pdf"
filename = "dit_attribution_report.pdf" filename = "dit_attribution_report.pdf"
except Exception as e:
print(f"Error generating PDF: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
return StreamingResponse( return StreamingResponse(
output, output,

View File

@@ -16,6 +16,8 @@ def clean_value(val):
if isinstance(val, float): if isinstance(val, float):
if math.isnan(val) or math.isinf(val): if math.isnan(val) or math.isinf(val):
return None return None
if isinstance(val, str):
val = val.lstrip("'") # Remove leading apostrophe often added by Excel
return val return val
@@ -31,7 +33,8 @@ def clean_records(records: List[Dict]) -> List[Dict]:
# 欄位名稱對應表 # 欄位名稱對應表
COLUMN_MAPPING = { COLUMN_MAPPING = {
'dit': { 'dit': {
'op_id': ['opportunity name', 'opportunity no', 'opportunity', 'op編號', 'op 編號', 'op_id', 'opid', '案件編號', '案號', 'opportunity id'], 'op_id': ['opportunity no', 'opportunity', 'op編號', 'op 編號', 'op_id', 'opid', '案件編號', '案號', 'opportunity id'],
'op_name': ['opportunity name', '專案名稱', '案件名稱'],
'erp_account': ['erp account', 'account no', 'erp account no', '客戶代碼', '客戶編號', 'erp_account'], 'erp_account': ['erp account', 'account no', 'erp account no', '客戶代碼', '客戶編號', 'erp_account'],
'customer': ['account name', 'branding customer', '客戶', '客戶名稱', 'customer', 'customer name', '公司名稱'], 'customer': ['account name', 'branding customer', '客戶', '客戶名稱', 'customer', 'customer name', '公司名稱'],
'pn': ['product name', '料號', 'part number', 'pn', 'part no', 'part_number', '產品料號', 'stage/part'], 'pn': ['product name', '料號', 'part number', 'pn', 'part no', 'part_number', '產品料號', 'stage/part'],
@@ -47,17 +50,18 @@ COLUMN_MAPPING = {
'customer': ['客戶名稱', '客戶簡稱', '客戶', 'customer', 'customer name'], 'customer': ['客戶名稱', '客戶簡稱', '客戶', 'customer', 'customer name'],
'pn': ['item', 'type', '料號', 'part number', 'pn', 'part no', '產品料號', '索樣數量'], 'pn': ['item', 'type', '料號', 'part number', 'pn', 'part no', '產品料號', '索樣數量'],
'qty': ['索樣數量pcs', '索樣數量 k', '數量', 'qty', 'quantity', '申請數量'], 'qty': ['索樣數量pcs', '索樣數量 k', '數量', 'qty', 'quantity', '申請數量'],
'date': ['需求日', '日期', 'date', '申請日期'] 'date': ['出貨日', '需求日', '日期', 'date', '申請日期']
}, },
'order': { 'order': {
'order_id': ['項次', '訂單編號', 'order_id', 'order id'], 'order_id': ['項次', '訂單編號', 'order_id', 'order id'],
'order_no': ['訂單單號', '訂單號', 'order_no', 'order no', '銷貨單號'], 'order_no': ['訂單單號', '訂單號', 'order_no', 'order no', '銷貨單號'],
'cust_id': ['客戶編號', '客戶代碼', '客戶代號', 'cust_id', 'cust id'], 'cust_id': ['客戶編號', '客戶代碼', '客戶代號', 'cust_id', 'cust id', 'erp code', 'erp_code', 'erpcode', 'erp'],
'customer': ['客戶', '客戶名稱', 'customer', 'customer name'], 'customer': ['客戶', '客戶名稱', 'customer', 'customer name'],
'pn': ['type', '內部料號', '料號', 'part number', 'pn', 'part no', '產品料號'], 'pn': ['內部料號', '料號', 'part number', 'pn', 'part no', '產品料號', 'type'],
'qty': ['訂單量', '數量', 'qty', 'quantity', '訂購數量', '出貨數量'], 'qty': ['訂單量', '數量', 'qty', 'quantity', '訂購數量', '出貨數量'],
'status': ['狀態', 'status', '訂單狀態'], 'status': ['狀態', 'status', '訂單狀態'],
'amount': ['原幣金額(含稅)', '台幣金額(未稅)', '金額', 'amount', 'total', '訂單金額'] 'amount': ['原幣金額(含稅)', '台幣金額(未稅)', '金額', 'amount', 'total', '訂單金額'],
'date': ['訂單日期', '日期', 'date', 'order date', 'order_date']
} }
} }
@@ -101,10 +105,12 @@ class ExcelParser:
for idx, col in enumerate(df_columns): for idx, col in enumerate(df_columns):
if variant_lower in col or col in variant_lower: if variant_lower in col or col in variant_lower:
mapping[df.columns[idx]] = standard_name mapping[df.columns[idx]] = standard_name
print(f"[DEBUG] Mapped '{df.columns[idx]}' to '{standard_name}' (matched '{variant}')")
break break
if standard_name in mapping.values(): if standard_name in mapping.values():
break break
print(f"[DEBUG] Final Mapping for {file_type}: {mapping}")
return mapping return mapping
def parse_file(self, file_path: Path, file_type: str) -> Tuple[str, Dict[str, Any]]: def parse_file(self, file_path: Path, file_type: str) -> Tuple[str, Dict[str, Any]]:

View File

@@ -14,15 +14,22 @@ from datetime import timedelta
COMPANY_SUFFIXES = [ COMPANY_SUFFIXES = [
'股份有限公司', '有限公司', '公司', '股份有限公司', '有限公司', '公司',
'株式会社', '株式會社', '株式会社', '株式會社',
'Co., Ltd.', 'Co.,Ltd.', 'Co. Ltd.', 'Co.Ltd.', 'Co., Ltd.', 'Co.,Ltd.', 'Co. Ltd.', 'Co.Ltd.', 'Co., Ltd', 'Co.,Ltd',
'Corporation', 'Corp.', 'Corp', 'Corporation', 'Corp.', 'Corp',
'Inc.', 'Inc', 'Inc.', 'Inc',
'Limited', 'Ltd.', 'Ltd', 'Limited', 'Ltd.', 'Ltd', 'L.T.D.',
'LLC', 'L.L.C.', 'LLC', 'L.L.C.',
] ]
def sanitize_pn(pn: str) -> str: def sanitize_pn(pn: str) -> str:
"""去除非字母數字字元並轉大寫 (PMSM-808-LL -> PMSM808LL)""" """去除非字母數字字元並轉大寫 (允許 - 與 _)"""
if not pn:
return ""
# 保留 - 和 _移除其他特殊符號
return re.sub(r'[^a-zA-Z0-9\-_]', '', str(pn)).upper()
def normalize_pn_for_matching(pn: str) -> str:
"""比對專用的正規化 (移除所有符號,只留英數)"""
if not pn: if not pn:
return "" return ""
return re.sub(r'[^a-zA-Z0-9]', '', str(pn)).upper() return re.sub(r'[^a-zA-Z0-9]', '', str(pn)).upper()
@@ -35,9 +42,22 @@ def normalize_customer_name(name: str) -> str:
# 轉換為大寫 # 轉換為大寫
normalized = name.strip() normalized = name.strip()
# 移除公司後綴 # Pre-clean: Remove common punctuation/separators to make suffix matching easier
for suffix in COMPANY_SUFFIXES: # But be careful not to merge words incorrectly.
normalized = re.sub(re.escape(suffix), '', normalized, flags=re.IGNORECASE)
# 移除公司後綴 - iterate multiple times or use regex for robust matching
# Sort suffixes by length descending to match longest first
sorted_suffixes = sorted(COMPANY_SUFFIXES, key=len, reverse=True)
for suffix in sorted_suffixes:
# Use word boundary or simple end of string check
# Escape suffix for regex
pattern = re.compile(re.escape(suffix) + r'$', re.IGNORECASE)
normalized = pattern.sub('', normalized).strip()
# Also try matching with preceding comma/space
pattern_strict = re.compile(r'[,.\s]+' + re.escape(suffix) + r'$', re.IGNORECASE)
normalized = pattern_strict.sub('', normalized).strip()
# 移除括號及其內容 # 移除括號及其內容
normalized = re.sub(r'\([^)]*\)', '', normalized) normalized = re.sub(r'\([^)]*\)', '', normalized)
@@ -46,9 +66,20 @@ def normalize_customer_name(name: str) -> str:
# 全形轉半形 # 全形轉半形
normalized = normalized.replace(' ', ' ') normalized = normalized.replace(' ', ' ')
# 移除特殊結尾字符 that might remain (like "Co.,") if suffix list didn't catch it
# Remove trailing "Co." or "Co.,"
normalized = re.sub(r'[,.\s]+Co[.,]*$', '', normalized, flags=re.IGNORECASE)
# 移除多餘空白 # 移除多餘空白
normalized = re.sub(r'\s+', ' ', normalized).strip() normalized = re.sub(r'\s+', ' ', normalized).strip()
# Remove all punctuation for final key? No, fuzzy match might rely on it.
# But for "Key" based matching in Lab, we want strict alphabetic?
# No, keep it similar to before but cleaner.
# Final aggressive strip of trailing punctuation
normalized = normalized.strip("., ")
return normalized.upper() return normalized.upper()
def calculate_similarity(name1: str, name2: str) -> Tuple[float, str]: def calculate_similarity(name1: str, name2: str) -> Tuple[float, str]:
@@ -103,7 +134,7 @@ class FuzzyMatcher:
# 1. 取得所有 DIT 記錄 # 1. 取得所有 DIT 記錄
dit_records = self.db.query(DitRecord).all() dit_records = self.db.query(DitRecord).all()
# 2. 取得所有樣品和訂單記錄並按 PN 分組 # 2. 取得所有樣品和訂單記錄並按 PN (比對專用正規化) 分組
sample_records = self.db.query(SampleRecord).all() sample_records = self.db.query(SampleRecord).all()
order_records = self.db.query(OrderRecord).all() order_records = self.db.query(OrderRecord).all()
@@ -111,9 +142,10 @@ class FuzzyMatcher:
samples_by_oppy = {} samples_by_oppy = {}
for s in sample_records: for s in sample_records:
if s.pn: if s.pn:
if s.pn not in samples_by_pn: norm_pn = normalize_pn_for_matching(s.pn)
samples_by_pn[s.pn] = [] if norm_pn not in samples_by_pn:
samples_by_pn[s.pn].append(s) samples_by_pn[norm_pn] = []
samples_by_pn[norm_pn].append(s)
if s.oppy_no: if s.oppy_no:
if s.oppy_no not in samples_by_oppy: if s.oppy_no not in samples_by_oppy:
samples_by_oppy[s.oppy_no] = [] samples_by_oppy[s.oppy_no] = []
@@ -121,9 +153,11 @@ class FuzzyMatcher:
orders_by_pn = {} orders_by_pn = {}
for o in order_records: for o in order_records:
if o.pn not in orders_by_pn: if o.pn:
orders_by_pn[o.pn] = [] norm_pn = normalize_pn_for_matching(o.pn)
orders_by_pn[o.pn].append(o) if norm_pn not in orders_by_pn:
orders_by_pn[norm_pn] = []
orders_by_pn[norm_pn].append(o)
# 3. 清除舊的比對結果 # 3. 清除舊的比對結果
self.db.query(ReviewLog).delete() self.db.query(ReviewLog).delete()
@@ -136,13 +170,16 @@ class FuzzyMatcher:
for dit in dit_records: for dit in dit_records:
dit_date = pd.to_datetime(dit.date, errors='coerce') dit_date = pd.to_datetime(dit.date, errors='coerce')
# 取得 DIT PN 的比對用正規化版本
dit_norm_pn = normalize_pn_for_matching(dit.pn)
# --- 比對樣品 (DIT -> Sample) --- # --- 比對樣品 (DIT -> Sample) ---
# 收集所有可能的樣品 (Priority 1: Oppy ID, Priority 2/3: PN) # 收集所有可能的樣品 (Priority 1: Oppy ID, Priority 2/3: PN)
potential_samples = [] potential_samples = []
if dit.op_id: if dit.op_id:
potential_samples.extend(samples_by_oppy.get(dit.op_id, [])) potential_samples.extend(samples_by_oppy.get(dit.op_id, []))
if dit.pn: if dit_norm_pn:
potential_samples.extend(samples_by_pn.get(dit.pn, [])) potential_samples.extend(samples_by_pn.get(dit_norm_pn, []))
# 去重 # 去重
seen_sample_ids = set() seen_sample_ids = set()
@@ -172,8 +209,8 @@ class FuzzyMatcher:
score = 100.0 score = 100.0
reason = "Golden Key Match" reason = "Golden Key Match"
# Priority 2 & 3 則限制在相同 PN # Priority 2 & 3 則限制在相同 PN (Ignored symbols)
elif dit.pn == sample.pn: elif dit_norm_pn == normalize_pn_for_matching(sample.pn):
# Priority 2: 客戶代碼比對 (Silver Key) # Priority 2: 客戶代碼比對 (Silver Key)
if dit.erp_account and sample.cust_id and dit.erp_account == sample.cust_id: if dit.erp_account and sample.cust_id and dit.erp_account == sample.cust_id:
match_priority = 2 match_priority = 2
@@ -209,7 +246,8 @@ class FuzzyMatcher:
# --- 比對訂單 (DIT -> Order) --- # --- 比對訂單 (DIT -> Order) ---
# 訂單比對通常基於 PN # 訂單比對通常基於 PN
for order in orders_by_pn.get(dit.pn, []): if dit_norm_pn:
for order in orders_by_pn.get(dit_norm_pn, []):
match_priority = 0 match_priority = 0
match_source = "" match_source = ""
score = 0.0 score = 0.0

View File

@@ -72,18 +72,28 @@ class ReportGenerator:
return result return result
def generate_excel(self) -> io.BytesIO: def generate_excel(self) -> io.BytesIO:
"""產生 Excel 報表""" """產生 Excel 報表 (包含三個分頁DIT歸因明細, 成功送樣, 取得訂單)"""
wb = Workbook() wb = Workbook()
ws = wb.active
ws.title = "DIT Attribution Report"
# 標題樣式 # 取得所有資料
all_data = self.get_attribution_data()
# 定義樣式
header_font = Font(bold=True, color="FFFFFF") header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4F46E5", end_color="4F46E5", fill_type="solid") header_fill = PatternFill(start_color="4F46E5", end_color="4F46E5", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center") header_alignment = Alignment(horizontal="center", vertical="center")
# 表頭
headers = ['OP編號', '客戶名稱', '料號', 'EAU', '階段', '樣品單號', '訂單單號', '訂單狀態', '訂單金額'] headers = ['OP編號', '客戶名稱', '料號', 'EAU', '階段', '樣品單號', '訂單單號', '訂單狀態', '訂單金額']
column_widths = [15, 30, 20, 12, 15, 15, 15, 12, 12]
def create_sheet(sheet_name, data_rows):
if sheet_name == "DIT歸因明細":
ws = wb.active
ws.title = sheet_name
else:
ws = wb.create_sheet(title=sheet_name)
# 表頭
for col, header in enumerate(headers, 1): for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header) cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font cell.font = header_font
@@ -91,8 +101,7 @@ class ReportGenerator:
cell.alignment = header_alignment cell.alignment = header_alignment
# 資料 # 資料
data = self.get_attribution_data() for row_idx, row_data in enumerate(data_rows, 2):
for row_idx, row_data in enumerate(data, 2):
ws.cell(row=row_idx, column=1, value=row_data['op_id']) ws.cell(row=row_idx, column=1, value=row_data['op_id'])
ws.cell(row=row_idx, column=2, value=row_data['customer']) ws.cell(row=row_idx, column=2, value=row_data['customer'])
ws.cell(row=row_idx, column=3, value=row_data['pn']) ws.cell(row=row_idx, column=3, value=row_data['pn'])
@@ -104,10 +113,20 @@ class ReportGenerator:
ws.cell(row=row_idx, column=9, value=row_data['order_amount'] or 0) ws.cell(row=row_idx, column=9, value=row_data['order_amount'] or 0)
# 調整欄寬 # 調整欄寬
column_widths = [15, 30, 20, 12, 15, 15, 15, 12, 12]
for col, width in enumerate(column_widths, 1): for col, width in enumerate(column_widths, 1):
ws.column_dimensions[chr(64 + col)].width = width ws.column_dimensions[chr(64 + col)].width = width
# 1. DIT歸因明細 (全部)
create_sheet("DIT歸因明細", all_data)
# 2. 成功送樣 (有樣品單號)
success_samples = [row for row in all_data if row['sample_order']]
create_sheet("成功送樣", success_samples)
# 3. 取得訂單 (有訂單單號)
orders_received = [row for row in all_data if row['order_no']]
create_sheet("取得訂單", orders_received)
# 儲存到 BytesIO # 儲存到 BytesIO
output = io.BytesIO() output = io.BytesIO()
wb.save(output) wb.save(output)

View File

@@ -13,9 +13,21 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
# bcrypt has a limit of 72 bytes, we truncate to avoid errors
# convert to bytes, truncate, then back to string (ignoring errors if cut mid-multibyte char, though unlikely for simple password)
# Actually passlib handles string/bytes. If we just slice the string it might not be accurate byte count.
# But usually the error comes from "bytes" length.
# Safest is to let simple passwords pass, and truncate extremely long ones.
# Let's ensure we work with utf-8 bytes
password_bytes = plain_password.encode('utf-8')
if len(password_bytes) > 72:
plain_password = password_bytes[:72].decode('utf-8', errors='ignore')
return pwd_context.verify(plain_password, hashed_password) return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
password_bytes = password.encode('utf-8')
if len(password_bytes) > 72:
password = password_bytes[:72].decode('utf-8', errors='ignore')
return pwd_context.hash(password) return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:

View File

@@ -12,7 +12,10 @@ def create_admin_user():
# Check if user exists # Check if user exists
user = db.query(User).filter(User.email == email).first() user = db.query(User).filter(User.email == email).first()
if user: if user:
print(f"User {email} already exists.") print(f"User {email} already exists. Updating password...")
user.password_hash = get_password_hash(password)
db.commit()
print(f"Password updated for {email} to: {password}")
return return
# Create new admin user # Create new admin user

43
backend/debug_db.py Normal file
View File

@@ -0,0 +1,43 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.sample import SampleRecord
from app.models.order import OrderRecord
from app.config import DATABASE_URL
def debug_db():
engine = create_engine(DATABASE_URL)
Session = sessionmaker(bind=engine)
session = Session()
target_pn_fragment = "PSMQC098N10LS2"
target_cust_id = "S14500"
print(f"--- Searching for PN containing '{target_pn_fragment}' ---")
print("\n[Sample Records]")
samples = session.query(SampleRecord).filter(SampleRecord.pn.contains(target_pn_fragment)).all()
for s in samples:
print(f"ID: {s.id}, CustID: '{s.cust_id}', PN: '{s.pn}', Customer: '{s.customer}', Date: {s.date}")
print("\n[Order Records]")
orders = session.query(OrderRecord).filter(OrderRecord.pn.contains(target_pn_fragment)).all()
for o in orders:
print(f"ID: {o.id}, CustID: '{o.cust_id}', PN: '{o.pn}', Customer: '{o.customer}', Date: {o.date}")
print("\n--- Searching for Cust ID '{target_cust_id}' ---")
print("\n[Sample Records with S14500]")
samples_c = session.query(SampleRecord).filter(SampleRecord.cust_id == target_cust_id).limit(5).all()
for s in samples_c:
print(f"ID: {s.id}, CustID: '{s.cust_id}', PN: '{s.pn}'")
print("\n[Order Records with S14500]")
orders_c = session.query(OrderRecord).filter(OrderRecord.cust_id == target_cust_id).limit(5).all()
for o in orders_c:
print(f"ID: {o.id}, CustID: '{o.cust_id}', PN: '{o.pn}'")
session.close()
if __name__ == "__main__":
debug_db()

View File

@@ -0,0 +1,54 @@
from app.services.fuzzy_matcher import normalize_customer_name, normalize_pn_for_matching
from app.routers.lab import parse_date
from datetime import datetime
def debug_lab_logic():
print("--- Debugging Lab Logic Normalization ---")
# Test Data from User Scenario
sample_cust = "Semisales Co., LTD"
sample_pn = "PSMQC098N10LS2-AU_R2_002A1"
sample_date_str = "20250913" # From previous debug output
order_cust = "SEMISALES"
order_pn = "PSMQC098N10LS2-AU_R2_002A1"
order_date_str = "2025-06-05" # From previous debug output
# Normalization
norm_sample_cust = normalize_customer_name(sample_cust)
norm_order_cust = normalize_customer_name(order_cust)
norm_sample_pn = normalize_pn_for_matching(sample_pn)
norm_order_pn = normalize_pn_for_matching(order_pn)
print(f"Sample Customer '{sample_cust}' -> '{norm_sample_cust}'")
print(f"Order Customer '{order_cust}' -> '{norm_order_cust}'")
print(f"Customer Match: {norm_sample_cust == norm_order_cust}")
print(f"Sample PN '{sample_pn}' -> '{norm_sample_pn}'")
print(f"Order PN '{order_pn}' -> '{norm_order_pn}'")
print(f"PN Match: {norm_sample_pn == norm_order_pn}")
# Key Check
sample_key = (norm_sample_cust, norm_sample_pn)
order_key = (norm_order_cust, norm_order_pn)
print(f"Sample Key: {sample_key}")
print(f"Order Key: {order_key}")
print(f"Key Match: {sample_key == order_key}")
# Date Parsing Check
print("\n--- Date Parsing Check ---")
s_date = parse_date(sample_date_str)
o_date = parse_date(order_date_str)
print(f"Sample Date Raw: '{sample_date_str}' -> Parsed: {s_date}")
print(f"Order Date Raw: '{order_date_str}' -> Parsed: {o_date}")
if s_date and o_date:
diff = (o_date - s_date).days
print(f"Date Diff (Order - Sample): {diff} days")
if diff < 0:
print("WARNING: Order is BEFORE Sample. Velocity calculation might filter this out if checking diff >= 0.")
if __name__ == "__main__":
debug_lab_logic()

View File

@@ -4,6 +4,7 @@ sqlalchemy==2.0.23
python-multipart==0.0.6 python-multipart==0.0.6
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==3.2.0
openpyxl==3.1.2 openpyxl==3.1.2
pandas==2.1.3 pandas==2.1.3
rapidfuzz==3.5.2 rapidfuzz==3.5.2

33
backend/verify_login.py Normal file
View File

@@ -0,0 +1,33 @@
from app.models import init_db, SessionLocal
from app.models.user import User
from app.utils.security import verify_password, get_password_hash
def test_login():
db = SessionLocal()
email = "admin@example.com"
password = "admin"
user = db.query(User).filter(User.email == email).first()
if not user:
print(f"User {email} not found!")
return
print(f"User found: {user.email}")
print(f"Stored Hash: {user.password_hash}")
# Test verification
is_valid = verify_password(password, user.password_hash)
print(f"Password '{password}' valid? {is_valid}")
if not is_valid:
print("Attempting to reset password...")
user.password_hash = get_password_hash(password)
db.commit()
print("Password reset. Testing again...")
is_valid = verify_password(password, user.password_hash)
print(f"Password '{password}' valid? {is_valid}")
db.close()
if __name__ == "__main__":
test_login()

View File

@@ -14,13 +14,14 @@ export const DashboardView: React.FC = () => {
sample_rate: 0, sample_rate: 0,
hit_rate: 0, hit_rate: 0,
fulfillment_rate: 0, fulfillment_rate: 0,
orphan_sample_rate: 0, no_order_sample_rate: 0,
total_revenue: 0, total_revenue: 0,
}); });
const [funnelData, setFunnelData] = useState<FunnelData[]>([]); const [funnelData, setFunnelData] = useState<FunnelData[]>([]);
const [attribution, setAttribution] = useState<AttributionRow[]>([]); const [attribution, setAttribution] = useState<AttributionRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filterType, setFilterType] = useState<'all' | 'sample' | 'order'>('all'); const [filterType, setFilterType] = useState<'all' | 'sample' | 'order'>('all');
const [isExporting, setIsExporting] = useState(false);
useEffect(() => { useEffect(() => {
loadDashboardData(); loadDashboardData();
@@ -44,11 +45,23 @@ export const DashboardView: React.FC = () => {
}; };
const handleExport = async (format: 'xlsx' | 'pdf') => { const handleExport = async (format: 'xlsx' | 'pdf') => {
if (isExporting) return;
setIsExporting(true);
console.log(`Starting export for ${format}`);
// alert(`Starting export for ${format}...`); // Debugging
try { try {
const blob = format === 'xlsx' const response = format === 'xlsx'
? await reportApi.exportExcel() ? await reportApi.exportExcel()
: await reportApi.exportPdf(); : await reportApi.exportPdf();
console.log('Response received', response);
const blob = response; // response is already a blob from api.ts
if (blob.size === 0) {
alert('Export failed: Received empty file.');
return;
}
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -57,9 +70,12 @@ export const DashboardView: React.FC = () => {
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
console.log('Download triggered');
} catch (error) { } catch (error) {
console.error('Export error:', error); console.error('Export error:', error);
alert('匯出失敗,請稍後再試'); alert(`匯出失敗: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsExporting(false);
} }
}; };
@@ -88,10 +104,18 @@ export const DashboardView: React.FC = () => {
</div> </div>
<button <button
onClick={() => handleExport('xlsx')} onClick={() => handleExport('xlsx')}
className="flex items-center gap-2 px-4 py-2 border border-slate-300 rounded-lg text-slate-600 hover:bg-slate-50 text-sm font-medium" disabled={isExporting}
className={`flex items-center gap-2 px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium transition-all ${isExporting
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'text-slate-600 hover:bg-slate-50'
}`}
> >
{isExporting ? (
<div className="w-4 h-4 border-2 border-slate-400 border-t-transparent rounded-full animate-spin"></div>
) : (
<Download size={16} /> <Download size={16} />
)}
{isExporting ? '匯出中...' : '匯出報表'}
</button> </button>
</div> </div>
@@ -118,9 +142,9 @@ export const DashboardView: React.FC = () => {
<div className="text-[10px] text-amber-600 mt-1">Fulfillment (LIFO)</div> <div className="text-[10px] text-amber-600 mt-1">Fulfillment (LIFO)</div>
</Card> </Card>
<Card className="p-4 border-l-4 border-l-rose-500"> <Card className="p-4 border-l-4 border-l-rose-500">
<div className="text-xs text-slate-500 mb-1"></div> <div className="text-xs text-slate-500 mb-1"></div>
<div className="text-2xl font-bold text-rose-600">{kpi.orphan_sample_rate}%</div> <div className="text-2xl font-bold text-rose-600">{kpi.no_order_sample_rate}%</div>
<div className="text-[10px] text-rose-400 mt-1">Orphan Sample</div> <div className="text-[10px] text-rose-400 mt-1">No-Order Sample</div>
</Card> </Card>
</div> </div>
@@ -220,7 +244,12 @@ export const DashboardView: React.FC = () => {
{filteredAttribution.map((row, i) => ( {filteredAttribution.map((row, i) => (
<tr key={i} className="hover:bg-slate-50 group transition-colors"> <tr key={i} className="hover:bg-slate-50 group transition-colors">
<td className="px-6 py-3"> <td className="px-6 py-3">
<div className="font-mono text-xs text-slate-500 font-bold">{row.dit.op_id}</div> <div className="font-mono text-xs text-slate-700 font-bold">{row.dit.op_id}</div>
{row.dit.op_name && (
<div className="text-[10px] text-indigo-800 truncate max-w-[150px] my-0.5" title={row.dit.op_name}>
{row.dit.op_name}
</div>
)}
<div className="text-[10px] text-slate-400">{row.dit.date}</div> <div className="text-[10px] text-slate-400">{row.dit.date}</div>
</td> </td>
<td className="px-6 py-3"> <td className="px-6 py-3">

View File

@@ -119,6 +119,35 @@ export const ImportView: React.FC<ImportViewProps> = ({ onEtlComplete }) => {
<p className="text-slate-500 mt-1"> Excel/CSV </p> <p className="text-slate-500 mt-1"> Excel/CSV </p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button
onClick={async () => {
if (confirm('確定要清除所有已上傳的資料與分析結果嗎?')) {
try {
await etlApi.clearData();
setFiles({
dit: { file: null, parsed: null, loading: false },
sample: { file: null, parsed: null, loading: false },
order: { file: null, parsed: null, loading: false },
});
// Reset file inputs
Object.values(fileInputRefs).forEach(ref => {
if (ref.current) ref.current.value = '';
});
setError(null);
setProcessingStep('');
// Reload page to refresh other components if needed, or just notify parent
window.location.reload();
} catch (err) {
console.error(err);
setError('清除資料失敗');
}
}
}}
className="flex items-center gap-2 px-4 py-3 rounded-lg text-red-600 bg-red-50 hover:bg-red-100 font-bold border border-red-200 transition-all"
>
<RefreshCw size={18} />
Data
</button>
<button <button
onClick={runEtl} onClick={runEtl}
disabled={isProcessing || !allFilesReady} disabled={isProcessing || !allFilesReady}
@@ -259,7 +288,7 @@ export const ImportView: React.FC<ImportViewProps> = ({ onEtlComplete }) => {
<th className="px-4 py-2">Customer</th> <th className="px-4 py-2">Customer</th>
<th className="px-4 py-2">Part No</th> <th className="px-4 py-2">Part No</th>
<th className="px-4 py-2">Status</th> <th className="px-4 py-2">Status</th>
<th className="px-4 py-2 text-right">Qty</th> <th className="px-4 py-2 text-right">Qty / Kpcs</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">

View File

@@ -13,16 +13,19 @@ import type { LabKPI, ScatterPoint, OrphanSample } from '../types';
export const LabView: React.FC = () => { export const LabView: React.FC = () => {
const [kpi, setKpi] = useState<LabKPI>({ const [kpi, setKpi] = useState<LabKPI>({
converted_count: 0,
avg_velocity: 0, avg_velocity: 0,
conversion_rate: 0, conversion_rate: 0,
orphan_count: 0 orphan_count: 0
}); });
const [scatterData, setScatterData] = useState<ScatterPoint[]>([]); const [scatterData, setScatterData] = useState<ScatterPoint[]>([]);
const [orphans, setOrphans] = useState<OrphanSample[]>([]); const [orphans, setOrphans] = useState<OrphanSample[]>([]);
const [conversions, setConversions] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState<'all' | '12m' | '6m' | '3m'>('all'); const [dateRange, setDateRange] = useState<'all' | '12m' | '6m' | '3m'>('all');
const [useLogScale, setUseLogScale] = useState(false); const [useLogScale, setUseLogScale] = useState(false);
const [copiedId, setCopiedId] = useState<number | null>(null); const [copiedId, setCopiedId] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<'orphans' | 'conversions'>('orphans');
useEffect(() => { useEffect(() => {
loadLabData(); loadLabData();
@@ -43,15 +46,17 @@ export const LabView: React.FC = () => {
const params = start_date ? { start_date } : {}; const params = start_date ? { start_date } : {};
const [kpiData, scatterRes, orphanRes] = await Promise.all([ const [kpiData, scatterRes, orphanRes, conversionRes] = await Promise.all([
labApi.getKPI(params), labApi.getKPI(params),
labApi.getScatter(params), labApi.getScatter(params),
labApi.getOrphans() labApi.getOrphans(),
labApi.getConversions()
]); ]);
setKpi(kpiData); setKpi(kpiData);
setScatterData(scatterRes); setScatterData(scatterRes);
setOrphans(orphanRes); setOrphans(orphanRes);
setConversions(conversionRes);
} catch (error) { } catch (error) {
console.error('Error loading lab data:', error); console.error('Error loading lab data:', error);
} finally { } finally {
@@ -60,12 +65,24 @@ export const LabView: React.FC = () => {
}; };
const handleCopy = (orphan: OrphanSample, index: number) => { const handleCopy = (orphan: OrphanSample, index: number) => {
const text = `Customer: ${orphan.customer}\nPart No: ${orphan.pn}\nSent Date: ${orphan.date}\nDays Ago: ${orphan.days_since_sent}`; const text = `Customer: ${orphan.customer}\nPart No: ${orphan.pn}\nSent Date: ${orphan.date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}\nDays Ago: ${orphan.days_since_sent}`;
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
setCopiedId(index); setCopiedId(index);
setTimeout(() => setCopiedId(null), 2000); setTimeout(() => setCopiedId(null), 2000);
}; };
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
// Calculate grouping info
const groupInfo = React.useMemo(() => {
const counts: Record<string, number> = {};
orphans.forEach(o => {
const key = `${o.customer}|${o.pn}`;
counts[key] = (counts[key] || 0) + 1;
});
return counts;
}, [orphans]);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
@@ -104,7 +121,26 @@ export const LabView: React.FC = () => {
</div> </div>
{/* KPI Cards */} {/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card
onClick={() => setViewMode('conversions')}
className={`p-6 border-b-4 border-b-blue-500 bg-gradient-to-br from-white to-blue-50/30 cursor-pointer transition-all hover:shadow-md ${viewMode === 'conversions' ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
>
<div className="flex justify-between items-start">
<div>
<div className="text-sm text-slate-500 font-medium mb-1"></div>
<div className="text-3xl font-bold text-slate-800">{kpi.converted_count} </div>
<div className="text-xs text-blue-600 mt-2 flex items-center gap-1 font-bold">
<Check size={12} />
Converted Samples
</div>
</div>
<div className="p-3 bg-blue-100 text-blue-600 rounded-xl">
<TrendingUp size={24} />
</div>
</div>
</Card>
<Card className="p-6 border-b-4 border-b-indigo-500 bg-gradient-to-br from-white to-indigo-50/30"> <Card className="p-6 border-b-4 border-b-indigo-500 bg-gradient-to-br from-white to-indigo-50/30">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
@@ -116,7 +152,7 @@ export const LabView: React.FC = () => {
</div> </div>
</div> </div>
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-xl"> <div className="p-3 bg-indigo-100 text-indigo-600 rounded-xl">
<TrendingUp size={24} /> <Clock size={24} />
</div> </div>
</div> </div>
</Card> </Card>
@@ -137,10 +173,13 @@ export const LabView: React.FC = () => {
</div> </div>
</Card> </Card>
<Card className="p-6 border-b-4 border-b-rose-500 bg-gradient-to-br from-white to-rose-50/30"> <Card
onClick={() => setViewMode('orphans')}
className={`p-6 border-b-4 border-b-rose-500 bg-gradient-to-br from-white to-rose-50/30 cursor-pointer transition-all hover:shadow-md ${viewMode === 'orphans' ? 'ring-2 ring-rose-500 ring-offset-2' : ''}`}
>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<div className="text-sm text-slate-500 font-medium mb-1"></div> <div className="text-sm text-slate-500 font-medium mb-1"></div>
<div className="text-3xl font-bold text-rose-600">{kpi.orphan_count} </div> <div className="text-3xl font-bold text-rose-600">{kpi.orphan_count} </div>
<div className="text-xs text-rose-400 mt-2 flex items-center gap-1 font-bold"> <div className="text-xs text-rose-400 mt-2 flex items-center gap-1 font-bold">
<AlertTriangle size={12} /> <AlertTriangle size={12} />
@@ -175,7 +214,19 @@ export const LabView: React.FC = () => {
<div className="h-[400px] w-full"> <div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}> <ScatterChart
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
onClick={(data: any) => {
if (data && data.activePayload && data.activePayload[0]) {
const point = data.activePayload[0].payload as ScatterPoint;
if (point.order_qty > 0) {
setViewMode('conversions');
} else {
setViewMode('orphans');
}
}
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" /> <CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis <XAxis
type="number" type="number"
@@ -215,7 +266,7 @@ export const LabView: React.FC = () => {
<span className="font-bold text-emerald-600">{data.order_qty.toLocaleString()}</span> <span className="font-bold text-emerald-600">{data.order_qty.toLocaleString()}</span>
</p> </p>
<p className="text-[10px] text-slate-400 mt-2 italic"> <p className="text-[10px] text-slate-400 mt-2 italic">
{data.order_qty > data.sample_qty ? '✨ 高效轉換 (High ROI)' : data.order_qty > 0 ? '穩定轉換' : '尚無訂單 (Orphan?)'} {data.order_qty > data.sample_qty ? '✨ 高效轉換 (High ROI)' : data.order_qty > 0 ? '穩定轉換' : '尚無訂單 (No-Order)'}
</p> </p>
</div> </div>
</div> </div>
@@ -231,6 +282,7 @@ export const LabView: React.FC = () => {
fillOpacity={0.6} fillOpacity={0.6}
stroke="#4338ca" stroke="#4338ca"
strokeWidth={1} strokeWidth={1}
cursor="pointer"
/> />
</ScatterChart> </ScatterChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -270,35 +322,110 @@ export const LabView: React.FC = () => {
</Card> </Card>
</div> </div>
{/* Orphan Samples Table */} {/* Dynamic Table Section */}
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<div className="px-6 py-4 bg-slate-50 border-b border-slate-200 flex justify-between items-center"> <div className={`px-6 py-4 border-b flex justify-between items-center ${viewMode === 'conversions' ? 'bg-blue-50 border-blue-200' : 'bg-rose-50 border-rose-200'}`}>
<h3 className="font-bold text-slate-700 flex items-center gap-2"> <h3 className={`font-bold flex items-center gap-2 ${viewMode === 'conversions' ? 'text-blue-700' : 'text-rose-700'}`}>
<AlertTriangle size={18} className="text-rose-500" /> {viewMode === 'conversions' ? (
Orphan Alert Table - &gt; 90 Days <>
<Check size={18} />
Successful Conversions List
</>
) : (
<>
<AlertTriangle size={18} />
No-Order Sample Alert Table
</>
)}
</h3> </h3>
<div className="flex items-center gap-4">
{viewMode === 'orphans' && (
<div className="text-[10px] text-slate-400 flex items-center gap-2">
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-indigo-600 rounded-full"></span> (Repeated)</span>
</div>
)}
<div className="text-[10px] text-slate-400 font-medium"> <div className="text-[10px] text-slate-400 font-medium">
{orphans.length} {viewMode === 'conversions' ? `${conversions.length} 筆成功轉換` : `${orphans.length} 筆待追蹤案件`}
</div> </div>
</div> </div>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <table className="w-full text-sm text-left">
<thead className="bg-white text-slate-500 border-b border-slate-200"> <thead className="bg-white text-slate-500 border-b border-slate-200">
<tr> <tr>
<th className="px-6 py-3"></th> <th className="px-6 py-3"></th>
<th className="px-6 py-3"> (Part No)</th> <th className="px-6 py-3"> (Part No)</th>
{viewMode === 'conversions' ? (
<>
<th className="px-6 py-3"> (Date/Qty)</th>
<th className="px-6 py-3"> (Date/Qty)</th>
<th className="px-6 py-3 text-center"></th>
</>
) : (
<>
<th className="px-6 py-3"></th> <th className="px-6 py-3"></th>
<th className="px-6 py-3 text-center"></th> <th className="px-6 py-3 text-center"></th>
<th className="px-6 py-3 text-center"></th> <th className="px-6 py-3 text-center"></th>
<th className="px-6 py-3 text-right"></th> <th className="px-6 py-3 text-right"></th>
</>
)}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{orphans.map((row, i) => ( {viewMode === 'conversions' ? (
<tr key={i} className="hover:bg-slate-50 transition-colors group"> conversions.map((row, i) => (
<tr key={i} className="hover:bg-slate-50">
<td className="px-6 py-4 font-medium text-slate-800">{row.customer}</td> <td className="px-6 py-4 font-medium text-slate-800">{row.customer}</td>
<td className="px-6 py-4 font-mono text-xs text-slate-600">{row.pn}</td> <td className="px-6 py-4 font-mono text-xs text-slate-600">{row.pn}</td>
<td className="px-6 py-4 text-slate-500">{row.date}</td> <td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-slate-500 text-xs">{row.sample_date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}</span>
<span className="font-bold text-slate-700">{row.sample_qty} pcs</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-slate-500 text-xs">{row.order_date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}</span>
<span className="font-bold text-emerald-600">{row.order_qty.toLocaleString()} pcs</span>
</div>
</td>
<td className="px-6 py-4 text-center">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">
{row.days_to_convert}
</span>
</td>
</tr>
))
) : (
orphans.map((row, i) => {
const groupKey = `${row.customer}|${row.pn}`;
const isRepeated = (groupInfo[groupKey] || 0) > 1;
const isSelected = selectedGroup === groupKey;
return (
<tr
key={i}
onClick={() => setSelectedGroup(isSelected ? null : groupKey)}
className={`
transition-all cursor-pointer border-l-4
${isSelected ? 'bg-indigo-50 border-l-indigo-500 shadow-inner' : 'hover:bg-slate-50 border-l-transparent'}
`}
>
<td className="px-6 py-4">
<div className={`font-medium ${isRepeated ? 'text-indigo-700' : 'text-slate-800'}`}>
{row.customer}
{isRepeated && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-indigo-100 text-indigo-700 font-bold">
x{groupInfo[groupKey]}
</span>
)}
</div>
</td>
<td className="px-6 py-4 font-mono text-xs text-slate-600">
{row.pn}
</td>
<td className="px-6 py-4 text-slate-500">{row.date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}</td>
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
<span className={`font-bold ${row.days_since_sent > 180 ? 'text-rose-600' : 'text-amber-600'}`}> <span className={`font-bold ${row.days_since_sent > 180 ? 'text-rose-600' : 'text-amber-600'}`}>
{row.days_since_sent} {row.days_since_sent}
@@ -312,7 +439,10 @@ export const LabView: React.FC = () => {
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<button <button
onClick={() => handleCopy(row, i)} onClick={(e) => {
e.stopPropagation();
handleCopy(row, i);
}}
className="inline-flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-800 font-medium bg-indigo-50 px-2 py-1 rounded" className="inline-flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-800 font-medium bg-indigo-50 px-2 py-1 rounded"
> >
{copiedId === i ? <Check size={12} /> : <Copy size={12} />} {copiedId === i ? <Check size={12} /> : <Copy size={12} />}
@@ -320,11 +450,21 @@ export const LabView: React.FC = () => {
</button> </button>
</td> </td>
</tr> </tr>
))} )
{orphans.length === 0 && ( })
)}
{viewMode === 'orphans' && orphans.length === 0 && (
<tr> <tr>
<td colSpan={6} className="px-6 py-10 text-center text-slate-400"> <td colSpan={6} className="px-6 py-10 text-center text-slate-400">
</td>
</tr>
)}
{viewMode === 'conversions' && conversions.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-10 text-center text-slate-400">
</td> </td>
</tr> </tr>
)} )}

View File

@@ -83,9 +83,16 @@ export const ReviewView: React.FC<ReviewViewProps> = ({ onReviewComplete }) => {
<div className="flex flex-col md:flex-row"> <div className="flex flex-col md:flex-row">
{/* Left: DIT */} {/* Left: DIT */}
<div className="flex-1 p-5 border-b md:border-b-0 md:border-r border-slate-100 bg-slate-50/50"> <div className="flex-1 p-5 border-b md:border-b-0 md:border-r border-slate-100 bg-slate-50/50">
<div className="flex items-center gap-2 mb-2"> <div className="flex flex-col gap-1 mb-3">
<div className="flex items-center gap-2">
<Badge type="info">DIT ()</Badge> <Badge type="info">DIT ()</Badge>
<span className="text-xs text-slate-400">OP編號: {item.dit?.op_id}</span> <span className="text-xs text-slate-500 font-mono">OP NO: {item.dit?.op_id}</span>
</div>
{item.dit?.op_name && (
<div className="text-sm font-medium text-indigo-900 line-clamp-2" title={item.dit.op_name}>
{item.dit.op_name}
</div>
)}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="text-xs text-slate-400 uppercase">Customer Name</div> <div className="text-xs text-slate-400 uppercase">Customer Name</div>

View File

@@ -12,7 +12,8 @@ import type {
OrderRecord, OrderRecord,
LabKPI, LabKPI,
ScatterPoint, ScatterPoint,
OrphanSample OrphanSample,
ConversionRecord
} from '../types'; } from '../types';
const api = axios.create({ const api = axios.create({
@@ -95,6 +96,10 @@ export const etlApi = {
const response = await api.get(`/etl/data/${type}`); const response = await api.get(`/etl/data/${type}`);
return response.data; return response.data;
}, },
clearData: async (): Promise<void> => {
await api.delete('/etl/data');
},
}; };
// Match API // Match API
@@ -168,6 +173,11 @@ export const labApi = {
const response = await api.get<OrphanSample[]>('/lab/orphans'); const response = await api.get<OrphanSample[]>('/lab/orphans');
return response.data; return response.data;
}, },
getConversions: async (): Promise<ConversionRecord[]> => {
const response = await api.get<ConversionRecord[]>('/lab/conversions');
return response.data;
},
}; };
export default api; export default api;

View File

@@ -23,6 +23,7 @@ export interface LoginResponse {
export interface DitRecord { export interface DitRecord {
id: number; id: number;
op_id: string; op_id: string;
op_name?: string;
erp_account?: string; erp_account?: string;
customer: string; customer: string;
pn: string; pn: string;
@@ -84,7 +85,7 @@ export interface DashboardKPI {
sample_rate: number; sample_rate: number;
hit_rate: number; hit_rate: number;
fulfillment_rate: number; fulfillment_rate: number;
orphan_sample_rate: number; no_order_sample_rate: number;
total_revenue: number; total_revenue: number;
} }
@@ -117,6 +118,7 @@ export interface ParsedFile {
// Lab 分析相關類型 // Lab 分析相關類型
export interface LabKPI { export interface LabKPI {
converted_count: number;
avg_velocity: number; avg_velocity: number;
conversion_rate: number; conversion_rate: number;
orphan_count: number; orphan_count: number;
@@ -137,6 +139,16 @@ export interface OrphanSample {
date: string; date: string;
} }
export interface ConversionRecord {
customer: string;
pn: string;
sample_date: string;
sample_qty: number;
order_date: string;
order_qty: number;
days_to_convert: number;
}
// API 響應包裝 // API 響應包裝
export interface ApiResponse<T> { export interface ApiResponse<T> {
success: boolean; success: boolean;

BIN
temp_dashboard.py Normal file

Binary file not shown.

220
temp_dashboard_utf8.py Normal file
View File

@@ -0,0 +1,220 @@
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) - ??(摰X, ??) ??
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