From 0918f7ae5ae2013b2016200e72d702b649257583 Mon Sep 17 00:00:00 2001 From: violet75630 Date: Mon, 26 Jan 2026 18:51:33 +0800 Subject: [PATCH] 20260126 --- backend/app/routers/etl.py | 89 ++++----- backend/app/routers/lab.py | 230 ++++++++++++++++++++--- backend/app/services/report_generator.py | 50 +++++ frontend/src/components/ImportView.tsx | 10 +- frontend/src/components/LabView.tsx | 95 ++++++++-- frontend/src/services/api.ts | 22 ++- frontend/src/types/index.ts | 11 ++ 7 files changed, 415 insertions(+), 92 deletions(-) diff --git a/backend/app/routers/etl.py b/backend/app/routers/etl.py index 107a7f7..2691d0b 100644 --- a/backend/app/routers/etl.py +++ b/backend/app/routers/etl.py @@ -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)}") file_type = file_info['file_type'] - imported_count = 0 seen_ids = set() # 追蹤已處理的 ID,避免檔案內重複 + records_to_insert = [] + imported_count = 0 # 清除該類型的舊資料,避免重複鍵衝突 try: if file_type == 'dit': print("[ETL Import] Clearing old DIT records and dependent matches/logs...") - # 先清除與 DIT 相關的審核日誌與比對結果 db.query(ReviewLog).delete() db.query(MatchResult).delete() db.query(DitRecord).delete() elif file_type == 'sample': print("[ETL Import] Clearing old Sample records and dependent matches/logs...") - # 先清除與 Sample 相關的比對結果 (及其日誌) - # 這裡比較複雜,因為 ReviewLog 是透過 MatchResult 關聯的 - # 但既然我們是清空整個類別,直接清空所有 ReviewLog 和對應的 MatchResult 是最安全的 db.query(ReviewLog).delete() db.query(MatchResult).filter(MatchResult.target_type == TargetType.SAMPLE).delete() db.query(SampleRecord).delete() @@ -147,13 +144,16 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)): db.query(ReviewLog).delete() db.query(MatchResult).filter(MatchResult.target_type == TargetType.ORDER).delete() db.query(OrderRecord).delete() - db.flush() # 使用 flush 而非 commit,保持在同一個事務中 + db.flush() print("[ETL Import] Old data cleared successfully.") except Exception as e: db.rollback() print(f"[ETL Import] Error clearing old data: {traceback.format_exc()}") raise HTTPException(status_code=500, detail=f"Failed to clear old data: {str(e)}") + print("[ETL Import] Preparing records...") + + # 使用 List 收集所有物件,再批次寫入 for idx, row in df.iterrows(): try: if file_type == 'dit': @@ -162,17 +162,14 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)): customer = clean_value(row.get('customer')) 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}" if not op_id or unique_key in seen_ids: continue seen_ids.add(unique_key) - record = DitRecord( + records_to_insert.append(DitRecord( op_id=op_id, op_name=clean_value(row.get('op_name')), 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, stage=clean_value(row.get('stage')), date=normalize_date(row.get('date')) - ) + )) elif file_type == 'sample': sample_id = clean_value(row.get('sample_id'), f'S{idx}') - oppy_no = clean_value(row.get('oppy_no'), '') - cust_id = clean_value(row.get('cust_id'), '') + # ... other fields customer = clean_value(row.get('customer')) 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: continue - seen_ids.add(sample_id) - record = SampleRecord( + records_to_insert.append(SampleRecord( sample_id=sample_id, - order_no=order_no, - oppy_no=oppy_no, - cust_id=cust_id, + order_no=clean_value(row.get('order_no')), + oppy_no=clean_value(row.get('oppy_no'), ''), + cust_id=clean_value(row.get('cust_id'), ''), customer=customer, customer_normalized=normalize_customer_name(customer), pn=sanitize_pn(pn), qty=int(row.get('qty', 0)) if row.get('qty') and not pd.isna(row.get('qty')) else 0, date=normalize_date(row.get('date')) - ) + )) elif file_type == 'order': order_id = clean_value(row.get('order_id'), f'O{idx}') - cust_id = clean_value(row.get('cust_id'), '') + order_no = clean_value(row.get('order_no')) + customer = clean_value(row.get('customer')) 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}" if unique_key in seen_ids: continue seen_ids.add(unique_key) - record = OrderRecord( + records_to_insert.append(OrderRecord( order_id=order_id, - order_no=clean_value(row.get('order_no')), - cust_id=cust_id, + order_no=order_no, + cust_id=clean_value(row.get('cust_id'), ''), customer=customer, customer_normalized=normalize_customer_name(customer), 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'), 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: - 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: - print(f"[ETL Import] Error importing row {idx}: {e}") + # 這裡若單行錯誤其實會導致該 batch 失敗,但 bulk_save 較難單行容錯。 + # 為了效能,我們假設資料大致正確,若有錯則會在 parser 階段或這裡跳過 + print(f"[ETL Import] Error creating record row {idx}: {e}") 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: - print(f"[ETL Import] Committing {imported_count} records...") + print(f"[ETL Import] Committing total {imported_count} records...") db.commit() print(f"[ETL Import] Import successful: {imported_count} records.") except Exception as e: diff --git a/backend/app/routers/lab.py b/backend/app/routers/lab.py index 44fdd92..8dc7c8f 100644 --- a/backend/app/routers/lab.py +++ b/backend/app/routers/lab.py @@ -18,6 +18,7 @@ class LabKPI(BaseModel): conversion_rate: float # 轉換比例 (%) orphan_count: int # 孤兒樣品總數 no_dit_count: int # 未歸因大額樣品數 + high_qty_no_order_count: int # 大額無單樣品數 class ConversionRecord(BaseModel): customer: str @@ -51,6 +52,15 @@ class NoDitSample(BaseModel): date: Optional[str] 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]: if not date_val: @@ -59,6 +69,8 @@ def parse_date(date_val) -> Optional[datetime]: return date_val if isinstance(date_val, str): date_str = date_val.strip() + if date_str.endswith(".0"): + date_str = date_str[:-2] try: if "T" in date_str: 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 @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() +def get_conversions( + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + db: Session = Depends(get_db) +): + samples_query = db.query(SampleRecord) + orders_query = db.query(OrderRecord) + + if start_date: + samples_query = samples_query.filter(SampleRecord.date >= start_date) + 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 order_lookup_by_id = {} @@ -225,10 +250,11 @@ def get_lab_kpi( if 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) if 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() orders = orders_query.all() @@ -244,18 +270,25 @@ def get_lab_kpi( 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) - # 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: 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) + 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(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] = [] - 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 unique_sample_groups = {} @@ -286,32 +319,34 @@ def get_lab_kpi( for key, data in unique_sample_groups.items(): norm_cust_name, group_clean_pn = key - matched_dates = [] + matched_items = [] # 1. Try ID Match for cid in data["cust_ids"]: 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 - if not matched_dates: + if not matched_items: 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) - 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] for o_dat in candidates: o_pn = o_dat['clean_pn'] # Check against ANY PN in this sample group for s_pn in data["raw_pns"]: 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 # STRICT FILTER: Post-Sample Orders Only + # Extract dates from matched items + matched_dates = [item["date"] for item in matched_items] valid_dates = [] if 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) 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( converted_count=converted_count, avg_velocity=round(avg_velocity, 1), conversion_rate=round(conversion_rate, 1), 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]) @@ -375,6 +449,7 @@ def get_scatter_data( 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) @@ -458,14 +533,27 @@ def get_scatter_data( ] @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() 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 # 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 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) -@router.get("/no_dit_samples", response_model=List[NoDitSample]) -def get_no_dit_samples(db: Session = Depends(get_db)): +def fetch_no_dit_samples(db: Session, start_date: Optional[str] = None, end_date: Optional[str] = None) -> List[NoDitSample]: # 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 = [] # Batch query matches for efficiency @@ -571,8 +664,99 @@ def get_no_dit_samples(db: Session = Depends(get_db)): customer=s.customer, pn=s.pn, 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 )) 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) diff --git a/backend/app/services/report_generator.py b/backend/app/services/report_generator.py index 06f2f2c..6663369 100644 --- a/backend/app/services/report_generator.py +++ b/backend/app/services/report_generator.py @@ -14,6 +14,7 @@ from app.models.dit import DitRecord from app.models.sample import SampleRecord from app.models.order import OrderRecord from app.models.match import MatchResult, MatchStatus, TargetType +from app.routers.lab import fetch_no_dit_samples, fetch_high_qty_no_order_samples class ReportGenerator: def __init__(self, db: Session): @@ -127,6 +128,55 @@ class ReportGenerator: orders_received = [row for row in all_data if row['order_no']] 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 output = io.BytesIO() wb.save(output) diff --git a/frontend/src/components/ImportView.tsx b/frontend/src/components/ImportView.tsx index 3c0b70d..412eab8 100644 --- a/frontend/src/components/ImportView.tsx +++ b/frontend/src/components/ImportView.tsx @@ -40,6 +40,7 @@ export const ImportView: React.FC = ({ onEtlComplete }) => { ...prev, [type]: { ...prev[type], file, loading: true } })); + setError(null); try { const parsed = await etlApi.upload(file, type); @@ -47,12 +48,17 @@ export const ImportView: React.FC = ({ onEtlComplete }) => { ...prev, [type]: { file, parsed, loading: false } })); - } catch (error) { + } catch (error: any) { console.error(`Error uploading ${type} file:`, error); setFiles(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); } }; diff --git a/frontend/src/components/LabView.tsx b/frontend/src/components/LabView.tsx index 7c43874..4837bd2 100644 --- a/frontend/src/components/LabView.tsx +++ b/frontend/src/components/LabView.tsx @@ -9,7 +9,7 @@ import { } from 'lucide-react'; import { Card } from './common/Card'; 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 = () => { const [kpi, setKpi] = useState({ @@ -17,17 +17,19 @@ export const LabView: React.FC = () => { avg_velocity: 0, conversion_rate: 0, orphan_count: 0, - no_dit_count: 0 + no_dit_count: 0, + high_qty_no_order_count: 0 }); const [scatterData, setScatterData] = useState([]); const [orphans, setOrphans] = useState([]); const [noDitSamples, setNoDitSamples] = useState([]); + const [highQtyNoOrderSamples, setHighQtyNoOrderSamples] = useState([]); const [conversions, setConversions] = useState([]); const [loading, setLoading] = useState(true); const [dateRange, setDateRange] = useState<'all' | '12m' | '6m' | '3m'>('all'); const [useLogScale, setUseLogScale] = useState(false); const [copiedId, setCopiedId] = useState(null); - const [viewMode, setViewMode] = useState<'orphans' | 'conversions' | 'no_dit'>('orphans'); + const [viewMode, setViewMode] = useState<'orphans' | 'conversions' | 'no_dit' | 'high_qty_no_order'>('orphans'); useEffect(() => { loadLabData(); @@ -48,18 +50,20 @@ export const LabView: React.FC = () => { 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.getScatter(params), - labApi.getOrphans(), - labApi.getNoDitSamples(), - labApi.getConversions() + labApi.getOrphans(params), + labApi.getNoDitSamples(params), + labApi.getHighQtyNoOrderSamples(params), + labApi.getConversions(params) ]); setKpi(kpiData); setScatterData(scatterRes); setOrphans(orphanRes); setNoDitSamples(noDitRes); + setHighQtyNoOrderSamples(highQtyNoOrderRes); setConversions(conversionRes); } catch (error) { console.error('Error loading lab data:', error); @@ -125,7 +129,7 @@ export const LabView: React.FC = () => { {/* KPI Cards */} -
+
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' : ''}`} @@ -214,6 +218,25 @@ export const LabView: React.FC = () => {
+ + 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' : ''}`} + > +
+
+
大額無單樣品
+
{kpi.high_qty_no_order_count} 筆
+
+ + > 1000 pcs (No Order) +
+
+
+ +
+
+
@@ -347,8 +370,16 @@ export const LabView: React.FC = () => { {/* Dynamic Table Section */} -
-

+
+

{viewMode === 'conversions' ? ( <> @@ -359,6 +390,11 @@ export const LabView: React.FC = () => { Unattributed High-Qty Samples + ) : viewMode === 'high_qty_no_order' ? ( + <> + + High Quantity No-Order Samples + ) : ( <> @@ -375,7 +411,8 @@ export const LabView: React.FC = () => {
{viewMode === 'conversions' ? `共 ${conversions.length} 筆成功轉換` : viewMode === 'no_dit' ? `共 ${noDitSamples.length} 筆未歸因大單` - : `共 ${orphans.length} 筆待追蹤案件`} + : viewMode === 'high_qty_no_order' ? `共 ${highQtyNoOrderSamples.length} 筆大額無單` + : `共 ${orphans.length} 筆待追蹤案件`}

@@ -399,6 +436,13 @@ export const LabView: React.FC = () => { 送樣資訊 (Date/Qty) 建議行動 + ) : viewMode === 'high_qty_no_order' ? ( + <> + 樣品單號 + 送樣資訊 (Date/Qty) + 送樣天數 + 狀態 + ) : ( <> 樣品單號 @@ -457,6 +501,28 @@ export const LabView: React.FC = () => { )) + ) : viewMode === 'high_qty_no_order' ? ( + highQtyNoOrderSamples.map((row, i) => ( + + {row.customer} + {row.pn} + {row.order_no || '-'} + +
+ {row.date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')} + {row.qty?.toLocaleString()} pcs +
+ + + {row.days_since_sent} 天 + + + + 高投入無回報 + + + + )) ) : ( orphans.map((row, i) => { const groupKey = `${row.customer?.trim()?.toUpperCase()}|${row.pn?.trim()?.toUpperCase()}`; @@ -529,6 +595,13 @@ export const LabView: React.FC = () => { )} + {viewMode === 'high_qty_no_order' && highQtyNoOrderSamples.length === 0 && ( + + + 目前沒有 1000pcs 以上且未轉換成訂單的樣品。 + + + )} {viewMode === 'no_dit' && noDitSamples.length === 0 && ( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ba38209..bb0dfe3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -14,12 +14,13 @@ import type { ScatterPoint, OrphanSample, ConversionRecord, - NoDitSample + NoDitSample, + HighQtyNoOrderSample } from '../types'; const api = axios.create({ baseURL: '/api', - timeout: 15000, + timeout: 900000, headers: { 'Content-Type': 'application/json', }, @@ -171,18 +172,23 @@ export const labApi = { return response.data; }, - getOrphans: async (): Promise => { - const response = await api.get('/lab/orphans'); + getOrphans: async (params?: { start_date?: string; end_date?: string }): Promise => { + const response = await api.get('/lab/orphans', { params }); return response.data; }, - getConversions: async (): Promise => { - const response = await api.get('/lab/conversions'); + getConversions: async (params?: { start_date?: string; end_date?: string }): Promise => { + const response = await api.get('/lab/conversions', { params }); return response.data; }, - getNoDitSamples: async (): Promise => { - const response = await api.get('/lab/no_dit_samples'); + getNoDitSamples: async (params?: { start_date?: string; end_date?: string }): Promise => { + const response = await api.get('/lab/no_dit_samples', { params }); + return response.data; + }, + + getHighQtyNoOrderSamples: async (params?: { start_date?: string; end_date?: string }): Promise => { + const response = await api.get('/lab/high_qty_no_order_samples', { params }); return response.data; }, }; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 447b29e..f4410af 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -123,6 +123,7 @@ export interface LabKPI { conversion_rate: number; orphan_count: number; no_dit_count: number; + high_qty_no_order_count: number; } export interface ScatterPoint { @@ -150,6 +151,16 @@ export interface NoDitSample { 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 { customer: string; pn: string;