This commit is contained in:
2026-01-26 18:51:33 +08:00
parent fd15ec296b
commit 0918f7ae5a
7 changed files with 415 additions and 92 deletions

View File

@@ -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:

View File

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

View File

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

View File

@@ -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);
} }
}; };

View File

@@ -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} />
&gt; 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">

View File

@@ -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;
}, },
}; };

View File

@@ -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;