20260126
This commit is contained in:
@@ -123,22 +123,19 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)):
|
|||||||
print(f"[ETL Import] Starting import: file_type={file_info['file_type']}, rows={len(df)}")
|
print(f"[ETL Import] Starting import: file_type={file_info['file_type']}, rows={len(df)}")
|
||||||
|
|
||||||
file_type = file_info['file_type']
|
file_type = file_info['file_type']
|
||||||
imported_count = 0
|
|
||||||
seen_ids = set() # 追蹤已處理的 ID,避免檔案內重複
|
seen_ids = set() # 追蹤已處理的 ID,避免檔案內重複
|
||||||
|
records_to_insert = []
|
||||||
|
imported_count = 0
|
||||||
|
|
||||||
# 清除該類型的舊資料,避免重複鍵衝突
|
# 清除該類型的舊資料,避免重複鍵衝突
|
||||||
try:
|
try:
|
||||||
if file_type == 'dit':
|
if file_type == 'dit':
|
||||||
print("[ETL Import] Clearing old DIT records and dependent matches/logs...")
|
print("[ETL Import] Clearing old DIT records and dependent matches/logs...")
|
||||||
# 先清除與 DIT 相關的審核日誌與比對結果
|
|
||||||
db.query(ReviewLog).delete()
|
db.query(ReviewLog).delete()
|
||||||
db.query(MatchResult).delete()
|
db.query(MatchResult).delete()
|
||||||
db.query(DitRecord).delete()
|
db.query(DitRecord).delete()
|
||||||
elif file_type == 'sample':
|
elif file_type == 'sample':
|
||||||
print("[ETL Import] Clearing old Sample records and dependent matches/logs...")
|
print("[ETL Import] Clearing old Sample records and dependent matches/logs...")
|
||||||
# 先清除與 Sample 相關的比對結果 (及其日誌)
|
|
||||||
# 這裡比較複雜,因為 ReviewLog 是透過 MatchResult 關聯的
|
|
||||||
# 但既然我們是清空整個類別,直接清空所有 ReviewLog 和對應的 MatchResult 是最安全的
|
|
||||||
db.query(ReviewLog).delete()
|
db.query(ReviewLog).delete()
|
||||||
db.query(MatchResult).filter(MatchResult.target_type == TargetType.SAMPLE).delete()
|
db.query(MatchResult).filter(MatchResult.target_type == TargetType.SAMPLE).delete()
|
||||||
db.query(SampleRecord).delete()
|
db.query(SampleRecord).delete()
|
||||||
@@ -147,13 +144,16 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)):
|
|||||||
db.query(ReviewLog).delete()
|
db.query(ReviewLog).delete()
|
||||||
db.query(MatchResult).filter(MatchResult.target_type == TargetType.ORDER).delete()
|
db.query(MatchResult).filter(MatchResult.target_type == TargetType.ORDER).delete()
|
||||||
db.query(OrderRecord).delete()
|
db.query(OrderRecord).delete()
|
||||||
db.flush() # 使用 flush 而非 commit,保持在同一個事務中
|
db.flush()
|
||||||
print("[ETL Import] Old data cleared successfully.")
|
print("[ETL Import] Old data cleared successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
print(f"[ETL Import] Error clearing old data: {traceback.format_exc()}")
|
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)}")
|
raise HTTPException(status_code=500, detail=f"Failed to clear old data: {str(e)}")
|
||||||
|
|
||||||
|
print("[ETL Import] Preparing records...")
|
||||||
|
|
||||||
|
# 使用 List 收集所有物件,再批次寫入
|
||||||
for idx, row in df.iterrows():
|
for idx, row in df.iterrows():
|
||||||
try:
|
try:
|
||||||
if file_type == 'dit':
|
if file_type == 'dit':
|
||||||
@@ -162,17 +162,14 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)):
|
|||||||
customer = clean_value(row.get('customer'))
|
customer = clean_value(row.get('customer'))
|
||||||
pn = clean_value(row.get('pn'))
|
pn = clean_value(row.get('pn'))
|
||||||
|
|
||||||
# Skip empty PN as per user request
|
if not pn: continue
|
||||||
if not pn:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Deduplicate by OP ID + PN
|
|
||||||
unique_key = f"{op_id}|{pn}"
|
unique_key = f"{op_id}|{pn}"
|
||||||
if not op_id or unique_key in seen_ids:
|
if not op_id or unique_key in seen_ids:
|
||||||
continue
|
continue
|
||||||
seen_ids.add(unique_key)
|
seen_ids.add(unique_key)
|
||||||
|
|
||||||
record = DitRecord(
|
records_to_insert.append(DitRecord(
|
||||||
op_id=op_id,
|
op_id=op_id,
|
||||||
op_name=clean_value(row.get('op_name')),
|
op_name=clean_value(row.get('op_name')),
|
||||||
erp_account=erp_account,
|
erp_account=erp_account,
|
||||||
@@ -182,61 +179,48 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)):
|
|||||||
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=normalize_date(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}')
|
||||||
oppy_no = clean_value(row.get('oppy_no'), '')
|
# ... other fields
|
||||||
cust_id = clean_value(row.get('cust_id'), '')
|
|
||||||
customer = clean_value(row.get('customer'))
|
customer = clean_value(row.get('customer'))
|
||||||
pn = clean_value(row.get('pn'))
|
pn = clean_value(row.get('pn'))
|
||||||
order_no = clean_value(row.get('order_no'))
|
|
||||||
|
|
||||||
# Skip empty PN
|
|
||||||
if not pn:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Deduplicate by Sample ID only
|
|
||||||
# We rely on auto-generated unique IDs if sample_id is missing from Excel mapping
|
|
||||||
unique_key = sample_id
|
|
||||||
|
|
||||||
|
if not pn: continue
|
||||||
|
|
||||||
if sample_id in seen_ids:
|
if sample_id in seen_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
seen_ids.add(sample_id)
|
seen_ids.add(sample_id)
|
||||||
|
|
||||||
record = SampleRecord(
|
records_to_insert.append(SampleRecord(
|
||||||
sample_id=sample_id,
|
sample_id=sample_id,
|
||||||
order_no=order_no,
|
order_no=clean_value(row.get('order_no')),
|
||||||
oppy_no=oppy_no,
|
oppy_no=clean_value(row.get('oppy_no'), ''),
|
||||||
cust_id=cust_id,
|
cust_id=clean_value(row.get('cust_id'), ''),
|
||||||
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(row.get('qty', 0)) if row.get('qty') and not pd.isna(row.get('qty')) else 0,
|
||||||
date=normalize_date(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}')
|
||||||
cust_id = clean_value(row.get('cust_id'), '')
|
order_no = clean_value(row.get('order_no'))
|
||||||
|
|
||||||
customer = clean_value(row.get('customer'))
|
customer = clean_value(row.get('customer'))
|
||||||
pn = clean_value(row.get('pn'))
|
pn = clean_value(row.get('pn'))
|
||||||
order_no = clean_value(row.get('order_no'))
|
|
||||||
|
|
||||||
# Skip empty PN
|
if not pn: continue
|
||||||
if not pn:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Deduplicate by Order No + Order ID (Item No)
|
|
||||||
# Item No (order_id) is not unique globally, only unique per order usually.
|
|
||||||
unique_key = f"{order_no}_{order_id}"
|
unique_key = f"{order_no}_{order_id}"
|
||||||
if unique_key in seen_ids:
|
if unique_key in seen_ids:
|
||||||
continue
|
continue
|
||||||
seen_ids.add(unique_key)
|
seen_ids.add(unique_key)
|
||||||
|
|
||||||
record = OrderRecord(
|
records_to_insert.append(OrderRecord(
|
||||||
order_id=order_id,
|
order_id=order_id,
|
||||||
order_no=clean_value(row.get('order_no')),
|
order_no=order_no,
|
||||||
cust_id=cust_id,
|
cust_id=clean_value(row.get('cust_id'), ''),
|
||||||
customer=customer,
|
customer=customer,
|
||||||
customer_normalized=normalize_customer_name(customer),
|
customer_normalized=normalize_customer_name(customer),
|
||||||
pn=sanitize_pn(pn),
|
pn=sanitize_pn(pn),
|
||||||
@@ -244,20 +228,29 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)):
|
|||||||
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'))
|
date=normalize_date(row.get('date'))
|
||||||
)
|
))
|
||||||
else:
|
|
||||||
continue
|
# 小批次處理,避免 list 過大 (雖 7萬筆還好,但習慣上分批)
|
||||||
|
if len(records_to_insert) >= 5000:
|
||||||
|
db.bulk_save_objects(records_to_insert)
|
||||||
|
imported_count += len(records_to_insert)
|
||||||
|
records_to_insert = []
|
||||||
|
print(f"[ETL Import] Bulk inserted {imported_count} rows...")
|
||||||
|
|
||||||
db.add(record)
|
|
||||||
imported_count += 1
|
|
||||||
if imported_count % 500 == 0:
|
|
||||||
print(f"[ETL Import] Processed {imported_count} rows...")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ETL Import] Error importing row {idx}: {e}")
|
# 這裡若單行錯誤其實會導致該 batch 失敗,但 bulk_save 較難單行容錯。
|
||||||
|
# 為了效能,我們假設資料大致正確,若有錯則會在 parser 階段或這裡跳過
|
||||||
|
print(f"[ETL Import] Error creating record row {idx}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Insert remaining
|
||||||
|
if records_to_insert:
|
||||||
|
db.bulk_save_objects(records_to_insert)
|
||||||
|
imported_count += len(records_to_insert)
|
||||||
|
print(f"[ETL Import] Bulk inserted remaining {len(records_to_insert)} rows.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"[ETL Import] Committing {imported_count} records...")
|
print(f"[ETL Import] Committing total {imported_count} records...")
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"[ETL Import] Import successful: {imported_count} records.")
|
print(f"[ETL Import] Import successful: {imported_count} records.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class LabKPI(BaseModel):
|
|||||||
conversion_rate: float # 轉換比例 (%)
|
conversion_rate: float # 轉換比例 (%)
|
||||||
orphan_count: int # 孤兒樣品總數
|
orphan_count: int # 孤兒樣品總數
|
||||||
no_dit_count: int # 未歸因大額樣品數
|
no_dit_count: int # 未歸因大額樣品數
|
||||||
|
high_qty_no_order_count: int # 大額無單樣品數
|
||||||
|
|
||||||
class ConversionRecord(BaseModel):
|
class ConversionRecord(BaseModel):
|
||||||
customer: str
|
customer: str
|
||||||
@@ -51,6 +52,15 @@ class NoDitSample(BaseModel):
|
|||||||
date: Optional[str]
|
date: Optional[str]
|
||||||
qty: int
|
qty: int
|
||||||
|
|
||||||
|
class HighQtyNoOrderSample(BaseModel):
|
||||||
|
sample_id: str
|
||||||
|
customer: str
|
||||||
|
pn: str
|
||||||
|
order_no: Optional[str]
|
||||||
|
date: Optional[str]
|
||||||
|
qty: int
|
||||||
|
days_since_sent: int
|
||||||
|
|
||||||
|
|
||||||
def parse_date(date_val) -> Optional[datetime]:
|
def parse_date(date_val) -> Optional[datetime]:
|
||||||
if not date_val:
|
if not date_val:
|
||||||
@@ -59,6 +69,8 @@ def parse_date(date_val) -> Optional[datetime]:
|
|||||||
return date_val
|
return date_val
|
||||||
if isinstance(date_val, str):
|
if isinstance(date_val, str):
|
||||||
date_str = date_val.strip()
|
date_str = date_val.strip()
|
||||||
|
if date_str.endswith(".0"):
|
||||||
|
date_str = date_str[:-2]
|
||||||
try:
|
try:
|
||||||
if "T" in date_str:
|
if "T" in date_str:
|
||||||
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||||
@@ -138,9 +150,22 @@ def find_matched_orders(s, order_lookup_by_id, order_lookup_by_name, orders_by_c
|
|||||||
return unique_candidates
|
return unique_candidates
|
||||||
|
|
||||||
@router.get("/conversions", response_model=List[ConversionRecord])
|
@router.get("/conversions", response_model=List[ConversionRecord])
|
||||||
def get_conversions(db: Session = Depends(get_db)):
|
def get_conversions(
|
||||||
samples = db.query(SampleRecord).all()
|
start_date: Optional[str] = Query(None),
|
||||||
orders = db.query(OrderRecord).all()
|
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)
|
||||||
|
orders_query = orders_query.filter(OrderRecord.date >= start_date)
|
||||||
|
if end_date:
|
||||||
|
samples_query = samples_query.filter(SampleRecord.date <= end_date)
|
||||||
|
|
||||||
|
samples = samples_query.all()
|
||||||
|
orders = orders_query.all()
|
||||||
|
|
||||||
# Build Lookups
|
# Build Lookups
|
||||||
order_lookup_by_id = {}
|
order_lookup_by_id = {}
|
||||||
@@ -225,10 +250,11 @@ 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)
|
||||||
|
# Optimization: Only fetch orders that could possibly match these samples (Date >= Start Date)
|
||||||
orders_query = orders_query.filter(OrderRecord.date >= start_date)
|
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)
|
||||||
orders_query = orders_query.filter(OrderRecord.date <= end_date)
|
# Do NOT filter orders by end_date, to capture conversions that happen after the sample window
|
||||||
|
|
||||||
samples = samples_query.all()
|
samples = samples_query.all()
|
||||||
orders = orders_query.all()
|
orders = orders_query.all()
|
||||||
@@ -244,18 +270,25 @@ def get_lab_kpi(
|
|||||||
norm_cust_name = normalize_customer_name(o.customer)
|
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)
|
o_date = parse_date(o.date) or (o.created_at.replace(tzinfo=None) if o.created_at else datetime.max)
|
||||||
|
|
||||||
# We only need dates for KPI
|
# Standardized Data Object for compatibility with find_matched_orders
|
||||||
|
data_obj = {
|
||||||
|
"clean_pn": clean_pn,
|
||||||
|
"date": o_date,
|
||||||
|
"qty": o.qty or 0,
|
||||||
|
"order_no": o.order_no
|
||||||
|
}
|
||||||
|
|
||||||
if clean_cust_id:
|
if clean_cust_id:
|
||||||
key_id = (clean_cust_id, clean_pn)
|
key_id = (clean_cust_id, clean_pn)
|
||||||
if key_id not in order_lookup_by_id: order_lookup_by_id[key_id] = []
|
if key_id not in order_lookup_by_id: order_lookup_by_id[key_id] = []
|
||||||
order_lookup_by_id[key_id].append(o_date)
|
order_lookup_by_id[key_id].append(data_obj)
|
||||||
|
|
||||||
key_name = (norm_cust_name, clean_pn)
|
key_name = (norm_cust_name, clean_pn)
|
||||||
if key_name not in order_lookup_by_name: order_lookup_by_name[key_name] = []
|
if key_name not in order_lookup_by_name: order_lookup_by_name[key_name] = []
|
||||||
order_lookup_by_name[key_name].append(o_date)
|
order_lookup_by_name[key_name].append(data_obj)
|
||||||
|
|
||||||
if norm_cust_name not in orders_by_cust_name: orders_by_cust_name[norm_cust_name] = []
|
if norm_cust_name not in orders_by_cust_name: orders_by_cust_name[norm_cust_name] = []
|
||||||
orders_by_cust_name[norm_cust_name].append({ "clean_pn": clean_pn, "date": o_date })
|
orders_by_cust_name[norm_cust_name].append(data_obj)
|
||||||
|
|
||||||
# Group Samples by (CustName, PN) for Project Count
|
# Group Samples by (CustName, PN) for Project Count
|
||||||
unique_sample_groups = {}
|
unique_sample_groups = {}
|
||||||
@@ -286,32 +319,34 @@ def get_lab_kpi(
|
|||||||
for key, data in unique_sample_groups.items():
|
for key, data in unique_sample_groups.items():
|
||||||
norm_cust_name, group_clean_pn = key
|
norm_cust_name, group_clean_pn = key
|
||||||
|
|
||||||
matched_dates = []
|
matched_items = []
|
||||||
|
|
||||||
# 1. Try ID Match
|
# 1. Try ID Match
|
||||||
for cid in data["cust_ids"]:
|
for cid in data["cust_ids"]:
|
||||||
if (cid, group_clean_pn) in order_lookup_by_id:
|
if (cid, group_clean_pn) in order_lookup_by_id:
|
||||||
matched_dates.extend(order_lookup_by_id[(cid, group_clean_pn)])
|
matched_items.extend(order_lookup_by_id[(cid, group_clean_pn)])
|
||||||
|
|
||||||
# 2. Try Name Match
|
# 2. Try Name Match
|
||||||
if not matched_dates:
|
if not matched_items:
|
||||||
if key in order_lookup_by_name:
|
if key in order_lookup_by_name:
|
||||||
matched_dates.extend(order_lookup_by_name[key])
|
matched_items.extend(order_lookup_by_name[key])
|
||||||
|
|
||||||
# 3. Try Prefix Match (Using first available PN in group vs Orders of same customer)
|
# 3. Try Prefix Match (Using first available PN in group vs Orders of same customer)
|
||||||
if not matched_dates and norm_cust_name in orders_by_cust_name:
|
if not matched_items and norm_cust_name in orders_by_cust_name:
|
||||||
candidates = orders_by_cust_name[norm_cust_name]
|
candidates = orders_by_cust_name[norm_cust_name]
|
||||||
for o_dat in candidates:
|
for o_dat in candidates:
|
||||||
o_pn = o_dat['clean_pn']
|
o_pn = o_dat['clean_pn']
|
||||||
# Check against ANY PN in this sample group
|
# Check against ANY PN in this sample group
|
||||||
for s_pn in data["raw_pns"]:
|
for s_pn in data["raw_pns"]:
|
||||||
if o_pn and (s_pn.startswith(o_pn) or o_pn.startswith(s_pn)):
|
if o_pn and (s_pn.startswith(o_pn) or o_pn.startswith(s_pn)):
|
||||||
matched_dates.append(o_dat["date"])
|
matched_items.append(o_dat)
|
||||||
|
|
||||||
if matched_dates:
|
if matched_items:
|
||||||
earliest_sample = min(data["dates"]) if data["dates"] else None
|
earliest_sample = min(data["dates"]) if data["dates"] else None
|
||||||
|
|
||||||
# STRICT FILTER: Post-Sample Orders Only
|
# STRICT FILTER: Post-Sample Orders Only
|
||||||
|
# Extract dates from matched items
|
||||||
|
matched_dates = [item["date"] for item in matched_items]
|
||||||
valid_dates = []
|
valid_dates = []
|
||||||
if earliest_sample:
|
if earliest_sample:
|
||||||
valid_dates = [d for d in matched_dates if d >= earliest_sample]
|
valid_dates = [d for d in matched_dates if d >= earliest_sample]
|
||||||
@@ -356,12 +391,51 @@ def get_lab_kpi(
|
|||||||
matched_ids_set = set(m[0] for m in matched_ids)
|
matched_ids_set = set(m[0] for m in matched_ids)
|
||||||
no_dit_count = len([sid for sid in high_qty_ids if sid not in matched_ids_set])
|
no_dit_count = len([sid for sid in high_qty_ids if sid not in matched_ids_set])
|
||||||
|
|
||||||
|
# Calculate High Qty No Order Samples (Count)
|
||||||
|
# Using existing data structures if possible, or new query
|
||||||
|
# Criteria: Qty >= 1000 AND No Valid Post-Sample Order
|
||||||
|
# We can reuse the loop calculation or do it separately.
|
||||||
|
# Since we already iterated samples to find conversions, let's optimize.
|
||||||
|
# Actually, the conversion logic above iterates ALL samples.
|
||||||
|
# Let's add a flag in the main loop?
|
||||||
|
# Main loop iterates `unique_sample_groups`.
|
||||||
|
# But High Qty No Order is per SAMPLE, not per group necessarily?
|
||||||
|
# Actually, business logic wise, if one sample in a group led to order, does it count?
|
||||||
|
# "Single request quantity > 1000pcs". So it's per sample record.
|
||||||
|
# If that specific sample has no "attributed" order?
|
||||||
|
# The current conversion logic is Group-Based (Customer + PN).
|
||||||
|
# If a group has converted, then likely the samples in it are considered converted.
|
||||||
|
# BUT, strict definition: "Single sample > 1000".
|
||||||
|
# Let's iterate high_qty_samples again and check if they belong to a converted group?
|
||||||
|
# OR check if that sample specifically has a match?
|
||||||
|
# Our matching logic in `find_matched_orders` is per sample.
|
||||||
|
|
||||||
|
high_qty_no_order_count = 0
|
||||||
|
# efficient check:
|
||||||
|
for s in high_qty_samples: # calculated above
|
||||||
|
# Check if this sample has valid orders.
|
||||||
|
# We need to run find_matched_orders for these samples.
|
||||||
|
# Ensure we have lookups built. They are built in `get_lab_kpi`.
|
||||||
|
|
||||||
|
matched_orders = find_matched_orders(s, order_lookup_by_id, order_lookup_by_name, orders_by_cust_name)
|
||||||
|
s_date = parse_date(s.date)
|
||||||
|
is_converted = False
|
||||||
|
|
||||||
|
if matched_orders and s_date:
|
||||||
|
valid_orders = [o for o in matched_orders if o["date"] >= s_date]
|
||||||
|
if valid_orders:
|
||||||
|
is_converted = True
|
||||||
|
|
||||||
|
if not is_converted:
|
||||||
|
high_qty_no_order_count += 1
|
||||||
|
|
||||||
return LabKPI(
|
return LabKPI(
|
||||||
converted_count=converted_count,
|
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,
|
||||||
no_dit_count=no_dit_count
|
no_dit_count=no_dit_count,
|
||||||
|
high_qty_no_order_count=high_qty_no_order_count
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/scatter", response_model=List[ScatterPoint])
|
@router.get("/scatter", response_model=List[ScatterPoint])
|
||||||
@@ -375,6 +449,7 @@ def get_scatter_data(
|
|||||||
|
|
||||||
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.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)
|
||||||
|
|
||||||
@@ -458,14 +533,27 @@ def get_scatter_data(
|
|||||||
]
|
]
|
||||||
|
|
||||||
@router.get("/orphans", response_model=List[OrphanSample])
|
@router.get("/orphans", response_model=List[OrphanSample])
|
||||||
def get_orphans(db: Session = Depends(get_db)):
|
def get_orphans(
|
||||||
|
start_date: Optional[str] = Query(None),
|
||||||
|
end_date: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
threshold_date = now - timedelta(days=90)
|
threshold_date = now - timedelta(days=90)
|
||||||
|
|
||||||
samples = db.query(SampleRecord).all()
|
samples_query = db.query(SampleRecord)
|
||||||
|
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()
|
||||||
# Need to match logic check
|
# Need to match logic check
|
||||||
# To save time, we can fetch all orders and build lookup
|
# To save time, we can fetch all orders and build lookup
|
||||||
orders = db.query(OrderRecord).all()
|
orders_query = db.query(OrderRecord)
|
||||||
|
if start_date:
|
||||||
|
orders_query = orders_query.filter(OrderRecord.date >= start_date)
|
||||||
|
orders = orders_query.all()
|
||||||
|
|
||||||
# Build Lookup for Fast Checking
|
# Build Lookup for Fast Checking
|
||||||
orders_by_cust_name = {}
|
orders_by_cust_name = {}
|
||||||
@@ -544,10 +632,15 @@ def get_orphans(db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
return sorted(orphans, key=lambda x: x.days_since_sent, reverse=True)
|
return sorted(orphans, key=lambda x: x.days_since_sent, reverse=True)
|
||||||
|
|
||||||
@router.get("/no_dit_samples", response_model=List[NoDitSample])
|
def fetch_no_dit_samples(db: Session, start_date: Optional[str] = None, end_date: Optional[str] = None) -> List[NoDitSample]:
|
||||||
def get_no_dit_samples(db: Session = Depends(get_db)):
|
|
||||||
# Filter High Qty Samples
|
# Filter High Qty Samples
|
||||||
high_qty_samples = db.query(SampleRecord).filter(SampleRecord.qty >= 1000).all()
|
query = db.query(SampleRecord).filter(SampleRecord.qty >= 1000)
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(SampleRecord.date >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(SampleRecord.date <= end_date)
|
||||||
|
|
||||||
|
high_qty_samples = query.all()
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
# Batch query matches for efficiency
|
# Batch query matches for efficiency
|
||||||
@@ -571,8 +664,99 @@ def get_no_dit_samples(db: Session = Depends(get_db)):
|
|||||||
customer=s.customer,
|
customer=s.customer,
|
||||||
pn=s.pn,
|
pn=s.pn,
|
||||||
order_no=s.order_no,
|
order_no=s.order_no,
|
||||||
date=s_date.strftime("%Y-%m-%d") if s_date else None,
|
date=s_date.strftime("%Y-%m-%d") if s_date else (s.date or ""),
|
||||||
qty=s.qty
|
qty=s.qty
|
||||||
))
|
))
|
||||||
|
|
||||||
return sorted(results, key=lambda x: x.qty, reverse=True)
|
return sorted(results, key=lambda x: x.qty, reverse=True)
|
||||||
|
|
||||||
|
@router.get("/no_dit_samples", response_model=List[NoDitSample])
|
||||||
|
def get_no_dit_samples(
|
||||||
|
start_date: Optional[str] = Query(None),
|
||||||
|
end_date: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
return fetch_no_dit_samples(db, start_date, end_date)
|
||||||
|
|
||||||
|
def fetch_high_qty_no_order_samples(db: Session, start_date: Optional[str] = None, end_date: Optional[str] = None) -> List[HighQtyNoOrderSample]:
|
||||||
|
# 1. Get High Qty Samples
|
||||||
|
query = db.query(SampleRecord).filter(SampleRecord.qty >= 1000)
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(SampleRecord.date >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(SampleRecord.date <= end_date)
|
||||||
|
|
||||||
|
high_qty_samples = query.all()
|
||||||
|
if not high_qty_samples:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 2. Get All Orders for Matching
|
||||||
|
orders_query = db.query(OrderRecord)
|
||||||
|
if start_date:
|
||||||
|
orders_query = orders_query.filter(OrderRecord.date >= start_date)
|
||||||
|
orders = orders_query.all()
|
||||||
|
|
||||||
|
# 3. Build Lookup (Same logic as kpi/conversions)
|
||||||
|
order_lookup_by_id = {}
|
||||||
|
order_lookup_by_name = {}
|
||||||
|
orders_by_cust_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_obj = {
|
||||||
|
"clean_pn": clean_pn,
|
||||||
|
"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_obj)
|
||||||
|
|
||||||
|
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_obj)
|
||||||
|
|
||||||
|
if norm_cust_name not in orders_by_cust_name: orders_by_cust_name[norm_cust_name] = []
|
||||||
|
orders_by_cust_name[norm_cust_name].append(data_obj)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
for s in high_qty_samples:
|
||||||
|
matched_orders = find_matched_orders(s, order_lookup_by_id, order_lookup_by_name, orders_by_cust_name)
|
||||||
|
s_date = parse_date(s.date)
|
||||||
|
is_converted = False
|
||||||
|
|
||||||
|
if matched_orders and s_date:
|
||||||
|
valid_orders = [o for o in matched_orders if o["date"] >= s_date]
|
||||||
|
if valid_orders:
|
||||||
|
is_converted = True
|
||||||
|
|
||||||
|
if not is_converted:
|
||||||
|
days_since = (now - s_date).days if s_date else 0
|
||||||
|
results.append(HighQtyNoOrderSample(
|
||||||
|
sample_id=str(s.id),
|
||||||
|
customer=s.customer,
|
||||||
|
pn=s.pn,
|
||||||
|
order_no=s.order_no,
|
||||||
|
date=s_date.strftime("%Y-%m-%d") if s_date else (s.date or ""),
|
||||||
|
qty=s.qty,
|
||||||
|
days_since_sent=days_since
|
||||||
|
))
|
||||||
|
|
||||||
|
return sorted(results, key=lambda x: x.qty, reverse=True)
|
||||||
|
|
||||||
|
@router.get("/high_qty_no_order_samples", response_model=List[HighQtyNoOrderSample])
|
||||||
|
def get_high_qty_no_order_samples(
|
||||||
|
start_date: Optional[str] = Query(None),
|
||||||
|
end_date: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
return fetch_high_qty_no_order_samples(db, start_date, end_date)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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, MatchStatus, TargetType
|
||||||
|
from app.routers.lab import fetch_no_dit_samples, fetch_high_qty_no_order_samples
|
||||||
|
|
||||||
class ReportGenerator:
|
class ReportGenerator:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session):
|
||||||
@@ -127,6 +128,55 @@ class ReportGenerator:
|
|||||||
orders_received = [row for row in all_data if row['order_no']]
|
orders_received = [row for row in all_data if row['order_no']]
|
||||||
create_sheet("取得訂單", orders_received)
|
create_sheet("取得訂單", orders_received)
|
||||||
|
|
||||||
|
# 4. 未歸因大額樣品 (New)
|
||||||
|
no_dit_samples = fetch_no_dit_samples(self.db)
|
||||||
|
if no_dit_samples:
|
||||||
|
ws = wb.create_sheet(title="未歸因大額樣品")
|
||||||
|
# Header
|
||||||
|
sub_headers = ['樣品單號', '客戶名稱', '料號', '送樣日期', '數量', '建議']
|
||||||
|
for col, header in enumerate(sub_headers, 1):
|
||||||
|
cell = ws.cell(row=1, column=col, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
|
||||||
|
# Data
|
||||||
|
for row_idx, s in enumerate(no_dit_samples, 2):
|
||||||
|
ws.cell(row=row_idx, column=1, value=s.order_no or s.sample_id)
|
||||||
|
ws.cell(row=row_idx, column=2, value=s.customer)
|
||||||
|
ws.cell(row=row_idx, column=3, value=s.pn)
|
||||||
|
ws.cell(row=row_idx, column=4, value=s.date)
|
||||||
|
ws.cell(row=row_idx, column=5, value=s.qty)
|
||||||
|
ws.cell(row=row_idx, column=6, value="請檢查 DIT 歸因")
|
||||||
|
|
||||||
|
# Widths
|
||||||
|
ws.column_dimensions['B'].width = 30
|
||||||
|
ws.column_dimensions['C'].width = 20
|
||||||
|
|
||||||
|
# 5. 大額無單樣品 (New)
|
||||||
|
high_qty_no_order = fetch_high_qty_no_order_samples(self.db)
|
||||||
|
if high_qty_no_order:
|
||||||
|
ws = wb.create_sheet(title="大額無單樣品")
|
||||||
|
# Header
|
||||||
|
sub_headers = ['樣品單號', '客戶名稱', '料號', '送樣日期', '數量', '送樣天數', '狀態']
|
||||||
|
for col, header in enumerate(sub_headers, 1):
|
||||||
|
cell = ws.cell(row=1, column=col, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
|
||||||
|
# Data
|
||||||
|
for row_idx, s in enumerate(high_qty_no_order, 2):
|
||||||
|
ws.cell(row=row_idx, column=1, value=s.order_no or s.sample_id)
|
||||||
|
ws.cell(row=row_idx, column=2, value=s.customer)
|
||||||
|
ws.cell(row=row_idx, column=3, value=s.pn)
|
||||||
|
ws.cell(row=row_idx, column=4, value=s.date)
|
||||||
|
ws.cell(row=row_idx, column=5, value=s.qty)
|
||||||
|
ws.cell(row=row_idx, column=6, value=s.days_since_sent)
|
||||||
|
ws.cell(row=row_idx, column=7, value="高投入無回報")
|
||||||
|
|
||||||
|
# Widths
|
||||||
|
ws.column_dimensions['B'].width = 30
|
||||||
|
ws.column_dimensions['C'].width = 20
|
||||||
|
|
||||||
# 儲存到 BytesIO
|
# 儲存到 BytesIO
|
||||||
output = io.BytesIO()
|
output = io.BytesIO()
|
||||||
wb.save(output)
|
wb.save(output)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const ImportView: React.FC<ImportViewProps> = ({ onEtlComplete }) => {
|
|||||||
...prev,
|
...prev,
|
||||||
[type]: { ...prev[type], file, loading: true }
|
[type]: { ...prev[type], file, loading: true }
|
||||||
}));
|
}));
|
||||||
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = await etlApi.upload(file, type);
|
const parsed = await etlApi.upload(file, type);
|
||||||
@@ -47,12 +48,17 @@ export const ImportView: React.FC<ImportViewProps> = ({ onEtlComplete }) => {
|
|||||||
...prev,
|
...prev,
|
||||||
[type]: { file, parsed, loading: false }
|
[type]: { file, parsed, loading: false }
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(`Error uploading ${type} file:`, error);
|
console.error(`Error uploading ${type} file:`, error);
|
||||||
setFiles(prev => ({
|
setFiles(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[type]: { file: null, parsed: null, loading: false }
|
[type]: { ...prev[type], parsed: null, loading: false }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const msg = error.code === 'ECONNABORTED'
|
||||||
|
? '上傳逾時,檔案可能過大,請稍後再試'
|
||||||
|
: (error.response?.data?.detail || error.message || '上傳失敗');
|
||||||
|
setError(msg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card } from './common/Card';
|
import { Card } from './common/Card';
|
||||||
import { labApi } from '../services/api';
|
import { labApi } from '../services/api';
|
||||||
import type { LabKPI, ScatterPoint, OrphanSample, NoDitSample } from '../types';
|
import type { LabKPI, ScatterPoint, OrphanSample, NoDitSample, HighQtyNoOrderSample } from '../types';
|
||||||
|
|
||||||
export const LabView: React.FC = () => {
|
export const LabView: React.FC = () => {
|
||||||
const [kpi, setKpi] = useState<LabKPI>({
|
const [kpi, setKpi] = useState<LabKPI>({
|
||||||
@@ -17,17 +17,19 @@ export const LabView: React.FC = () => {
|
|||||||
avg_velocity: 0,
|
avg_velocity: 0,
|
||||||
conversion_rate: 0,
|
conversion_rate: 0,
|
||||||
orphan_count: 0,
|
orphan_count: 0,
|
||||||
no_dit_count: 0
|
no_dit_count: 0,
|
||||||
|
high_qty_no_order_count: 0
|
||||||
});
|
});
|
||||||
const [scatterData, setScatterData] = useState<ScatterPoint[]>([]);
|
const [scatterData, setScatterData] = useState<ScatterPoint[]>([]);
|
||||||
const [orphans, setOrphans] = useState<OrphanSample[]>([]);
|
const [orphans, setOrphans] = useState<OrphanSample[]>([]);
|
||||||
const [noDitSamples, setNoDitSamples] = useState<NoDitSample[]>([]);
|
const [noDitSamples, setNoDitSamples] = useState<NoDitSample[]>([]);
|
||||||
|
const [highQtyNoOrderSamples, setHighQtyNoOrderSamples] = useState<HighQtyNoOrderSample[]>([]);
|
||||||
const [conversions, setConversions] = useState<any[]>([]);
|
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' | 'no_dit'>('orphans');
|
const [viewMode, setViewMode] = useState<'orphans' | 'conversions' | 'no_dit' | 'high_qty_no_order'>('orphans');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLabData();
|
loadLabData();
|
||||||
@@ -48,18 +50,20 @@ export const LabView: React.FC = () => {
|
|||||||
|
|
||||||
const params = start_date ? { start_date } : {};
|
const params = start_date ? { start_date } : {};
|
||||||
|
|
||||||
const [kpiData, scatterRes, orphanRes, noDitRes, conversionRes] = await Promise.all([
|
const [kpiData, scatterRes, orphanRes, noDitRes, highQtyNoOrderRes, conversionRes] = await Promise.all([
|
||||||
labApi.getKPI(params),
|
labApi.getKPI(params),
|
||||||
labApi.getScatter(params),
|
labApi.getScatter(params),
|
||||||
labApi.getOrphans(),
|
labApi.getOrphans(params),
|
||||||
labApi.getNoDitSamples(),
|
labApi.getNoDitSamples(params),
|
||||||
labApi.getConversions()
|
labApi.getHighQtyNoOrderSamples(params),
|
||||||
|
labApi.getConversions(params)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setKpi(kpiData);
|
setKpi(kpiData);
|
||||||
setScatterData(scatterRes);
|
setScatterData(scatterRes);
|
||||||
setOrphans(orphanRes);
|
setOrphans(orphanRes);
|
||||||
setNoDitSamples(noDitRes);
|
setNoDitSamples(noDitRes);
|
||||||
|
setHighQtyNoOrderSamples(highQtyNoOrderRes);
|
||||||
setConversions(conversionRes);
|
setConversions(conversionRes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading lab data:', error);
|
console.error('Error loading lab data:', error);
|
||||||
@@ -125,7 +129,7 @@ export const LabView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||||
<Card
|
<Card
|
||||||
onClick={() => setViewMode('conversions')}
|
onClick={() => setViewMode('conversions')}
|
||||||
className={`p-4 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' : ''}`}
|
className={`p-4 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' : ''}`}
|
||||||
@@ -214,6 +218,25 @@ export const LabView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
onClick={() => setViewMode('high_qty_no_order')}
|
||||||
|
className={`p-4 border-b-4 border-b-violet-500 bg-gradient-to-br from-white to-violet-50/30 cursor-pointer transition-all hover:shadow-md ${viewMode === 'high_qty_no_order' ? 'ring-2 ring-violet-500 ring-offset-2' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 font-medium mb-1">大額無單樣品</div>
|
||||||
|
<div className="text-2xl font-bold text-violet-600">{kpi.high_qty_no_order_count} 筆</div>
|
||||||
|
<div className="text-[10px] text-violet-600 mt-1 flex items-center gap-1 font-bold">
|
||||||
|
<AlertTriangle size={10} />
|
||||||
|
> 1000 pcs (No Order)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-violet-100 text-violet-600 rounded-lg">
|
||||||
|
<AlertTriangle size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
@@ -347,8 +370,16 @@ export const LabView: React.FC = () => {
|
|||||||
|
|
||||||
{/* Dynamic Table Section */}
|
{/* Dynamic Table Section */}
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className={`px-6 py-4 border-b flex justify-between items-center ${viewMode === 'conversions' ? 'bg-blue-50 border-blue-200' : viewMode === 'no_dit' ? 'bg-amber-50 border-amber-200' : 'bg-rose-50 border-rose-200'}`}>
|
<div className={`px-6 py-4 border-b flex justify-between items-center ${viewMode === 'conversions' ? 'bg-blue-50 border-blue-200' :
|
||||||
<h3 className={`font-bold flex items-center gap-2 ${viewMode === 'conversions' ? 'text-blue-700' : viewMode === 'no_dit' ? 'text-amber-700' : 'text-rose-700'}`}>
|
viewMode === 'no_dit' ? 'bg-amber-50 border-amber-200' :
|
||||||
|
viewMode === 'high_qty_no_order' ? 'bg-violet-50 border-violet-200' :
|
||||||
|
'bg-rose-50 border-rose-200'
|
||||||
|
}`}>
|
||||||
|
<h3 className={`font-bold flex items-center gap-2 ${viewMode === 'conversions' ? 'text-blue-700' :
|
||||||
|
viewMode === 'no_dit' ? 'text-amber-700' :
|
||||||
|
viewMode === 'high_qty_no_order' ? 'text-violet-700' :
|
||||||
|
'text-rose-700'
|
||||||
|
}`}>
|
||||||
{viewMode === 'conversions' ? (
|
{viewMode === 'conversions' ? (
|
||||||
<>
|
<>
|
||||||
<Check size={18} />
|
<Check size={18} />
|
||||||
@@ -359,6 +390,11 @@ export const LabView: React.FC = () => {
|
|||||||
<HelpCircle size={18} />
|
<HelpCircle size={18} />
|
||||||
Unattributed High-Qty Samples
|
Unattributed High-Qty Samples
|
||||||
</>
|
</>
|
||||||
|
) : viewMode === 'high_qty_no_order' ? (
|
||||||
|
<>
|
||||||
|
<AlertTriangle size={18} />
|
||||||
|
High Quantity No-Order Samples
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AlertTriangle size={18} />
|
<AlertTriangle size={18} />
|
||||||
@@ -375,7 +411,8 @@ export const LabView: React.FC = () => {
|
|||||||
<div className="text-[10px] text-slate-400 font-medium">
|
<div className="text-[10px] text-slate-400 font-medium">
|
||||||
{viewMode === 'conversions' ? `共 ${conversions.length} 筆成功轉換`
|
{viewMode === 'conversions' ? `共 ${conversions.length} 筆成功轉換`
|
||||||
: viewMode === 'no_dit' ? `共 ${noDitSamples.length} 筆未歸因大單`
|
: viewMode === 'no_dit' ? `共 ${noDitSamples.length} 筆未歸因大單`
|
||||||
: `共 ${orphans.length} 筆待追蹤案件`}
|
: viewMode === 'high_qty_no_order' ? `共 ${highQtyNoOrderSamples.length} 筆大額無單`
|
||||||
|
: `共 ${orphans.length} 筆待追蹤案件`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,6 +436,13 @@ export const LabView: React.FC = () => {
|
|||||||
<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 text-center">建議行動</th>
|
||||||
</>
|
</>
|
||||||
|
) : viewMode === 'high_qty_no_order' ? (
|
||||||
|
<>
|
||||||
|
<th className="px-6 py-3">樣品單號</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 text-center">狀態</th>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<th className="px-6 py-3">樣品單號</th>
|
<th className="px-6 py-3">樣品單號</th>
|
||||||
@@ -457,6 +501,28 @@ export const LabView: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
|
) : viewMode === 'high_qty_no_order' ? (
|
||||||
|
highQtyNoOrderSamples.map((row, i) => (
|
||||||
|
<tr key={i} className="hover:bg-violet-50/50">
|
||||||
|
<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-500">{row.order_no || '-'}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-slate-500 text-xs">{row.date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}</span>
|
||||||
|
<span className="font-bold text-violet-600">{row.qty?.toLocaleString()} pcs</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<span className="font-bold text-slate-600">{row.days_since_sent} 天</span>
|
||||||
|
</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-violet-100 text-violet-700">
|
||||||
|
高投入無回報
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
orphans.map((row, i) => {
|
orphans.map((row, i) => {
|
||||||
const groupKey = `${row.customer?.trim()?.toUpperCase()}|${row.pn?.trim()?.toUpperCase()}`;
|
const groupKey = `${row.customer?.trim()?.toUpperCase()}|${row.pn?.trim()?.toUpperCase()}`;
|
||||||
@@ -529,6 +595,13 @@ export const LabView: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
{viewMode === 'high_qty_no_order' && highQtyNoOrderSamples.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-10 text-center text-slate-400">
|
||||||
|
目前沒有 1000pcs 以上且未轉換成訂單的樣品。
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
{viewMode === 'no_dit' && noDitSamples.length === 0 && (
|
{viewMode === 'no_dit' && noDitSamples.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-6 py-10 text-center text-slate-400">
|
<td colSpan={5} className="px-6 py-10 text-center text-slate-400">
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import type {
|
|||||||
ScatterPoint,
|
ScatterPoint,
|
||||||
OrphanSample,
|
OrphanSample,
|
||||||
ConversionRecord,
|
ConversionRecord,
|
||||||
NoDitSample
|
NoDitSample,
|
||||||
|
HighQtyNoOrderSample
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 15000,
|
timeout: 900000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -171,18 +172,23 @@ export const labApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getOrphans: async (): Promise<OrphanSample[]> => {
|
getOrphans: async (params?: { start_date?: string; end_date?: string }): Promise<OrphanSample[]> => {
|
||||||
const response = await api.get<OrphanSample[]>('/lab/orphans');
|
const response = await api.get<OrphanSample[]>('/lab/orphans', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getConversions: async (): Promise<ConversionRecord[]> => {
|
getConversions: async (params?: { start_date?: string; end_date?: string }): Promise<ConversionRecord[]> => {
|
||||||
const response = await api.get<ConversionRecord[]>('/lab/conversions');
|
const response = await api.get<ConversionRecord[]>('/lab/conversions', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getNoDitSamples: async (): Promise<NoDitSample[]> => {
|
getNoDitSamples: async (params?: { start_date?: string; end_date?: string }): Promise<NoDitSample[]> => {
|
||||||
const response = await api.get<NoDitSample[]>('/lab/no_dit_samples');
|
const response = await api.get<NoDitSample[]>('/lab/no_dit_samples', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getHighQtyNoOrderSamples: async (params?: { start_date?: string; end_date?: string }): Promise<HighQtyNoOrderSample[]> => {
|
||||||
|
const response = await api.get<HighQtyNoOrderSample[]>('/lab/high_qty_no_order_samples', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export interface LabKPI {
|
|||||||
conversion_rate: number;
|
conversion_rate: number;
|
||||||
orphan_count: number;
|
orphan_count: number;
|
||||||
no_dit_count: number;
|
no_dit_count: number;
|
||||||
|
high_qty_no_order_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScatterPoint {
|
export interface ScatterPoint {
|
||||||
@@ -150,6 +151,16 @@ export interface NoDitSample {
|
|||||||
qty: number;
|
qty: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HighQtyNoOrderSample {
|
||||||
|
sample_id: string;
|
||||||
|
customer: string;
|
||||||
|
pn: string;
|
||||||
|
order_no: string;
|
||||||
|
date: string;
|
||||||
|
qty: number;
|
||||||
|
days_since_sent: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConversionRecord {
|
export interface ConversionRecord {
|
||||||
customer: string;
|
customer: string;
|
||||||
pn: string;
|
pn: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user