diff --git a/backend/app/models/dit.py b/backend/app/models/dit.py index 505b940..b7e5873 100644 --- a/backend/app/models/dit.py +++ b/backend/app/models/dit.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, DateTime, Float, UniqueConstraint +from sqlalchemy import Column, Integer, String, DateTime, Float, UniqueConstraint, BigInteger from sqlalchemy.sql import func from app.models import Base from app.config import TABLE_PREFIX @@ -16,7 +16,7 @@ class DitRecord(Base): customer = Column(String(255), nullable=False, index=True) customer_normalized = Column(String(255), index=True) pn = Column(String(100), nullable=False, index=True) - eau = Column(Integer, default=0) + eau = Column(BigInteger, default=0) stage = Column(String(50)) date = Column(String(20)) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/match.py b/backend/app/models/match.py index 61ba558..ec40b3a 100644 --- a/backend/app/models/match.py +++ b/backend/app/models/match.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, DateTime, Float, Enum, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, Float, Enum, ForeignKey, UniqueConstraint from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.models import Base @@ -21,6 +21,9 @@ class ReviewAction(str, enum.Enum): class MatchResult(Base): __tablename__ = f"{TABLE_PREFIX}Match_Results" + __table_args__ = ( + UniqueConstraint('dit_id', 'target_type', 'target_id', name='uix_match_dit_target'), + ) id = Column(Integer, primary_key=True, index=True) dit_id = Column(Integer, ForeignKey(f"{TABLE_PREFIX}DIT_Records.id"), nullable=False) diff --git a/backend/app/routers/etl.py b/backend/app/routers/etl.py index e5db1a7..107a7f7 100644 --- a/backend/app/routers/etl.py +++ b/backend/app/routers/etl.py @@ -161,11 +161,17 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)): erp_account = clean_value(row.get('erp_account'), '') customer = clean_value(row.get('customer')) pn = clean_value(row.get('pn')) - # 跳過無效資料列或重複的 op_id + pn 組合 + + # Skip empty PN as per user request + 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( op_id=op_id, op_name=clean_value(row.get('op_name')), @@ -183,13 +189,24 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)): cust_id = clean_value(row.get('cust_id'), '') customer = clean_value(row.get('customer')) pn = clean_value(row.get('pn')) - # 跳過重複的 sample_id + 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 sample_id in seen_ids: continue + seen_ids.add(sample_id) + record = SampleRecord( sample_id=sample_id, - order_no=clean_value(row.get('order_no')), + order_no=order_no, oppy_no=oppy_no, cust_id=cust_id, customer=customer, @@ -203,10 +220,19 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)): cust_id = clean_value(row.get('cust_id'), '') customer = clean_value(row.get('customer')) pn = clean_value(row.get('pn')) - # 跳過重複的 order_id - if order_id in seen_ids: + order_no = clean_value(row.get('order_no')) + + # Skip empty PN + if not pn: continue - seen_ids.add(order_id) + + # 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( order_id=order_id, order_no=clean_value(row.get('order_no')), diff --git a/backend/app/routers/lab.py b/backend/app/routers/lab.py index fc70d9e..44fdd92 100644 --- a/backend/app/routers/lab.py +++ b/backend/app/routers/lab.py @@ -7,6 +7,8 @@ from pydantic import BaseModel from app.models import get_db from app.models.sample import SampleRecord from app.models.order import OrderRecord +from app.models.match import MatchResult, MatchStatus, TargetType +from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name router = APIRouter(prefix="/lab", tags=["Lab"]) @@ -15,6 +17,7 @@ class LabKPI(BaseModel): avg_velocity: float # 平均轉換時間 (天) conversion_rate: float # 轉換比例 (%) orphan_count: int # 孤兒樣品總數 + no_dit_count: int # 未歸因大額樣品數 class ConversionRecord(BaseModel): customer: str @@ -22,10 +25,10 @@ class ConversionRecord(BaseModel): sample_date: str sample_qty: int order_date: str - order_qty: int + order_qty: int # First Order Qty + total_order_qty: int # Total Order Qty (Post-Sample) days_to_convert: int -# ... (ScatterPoint and OrphanSample classes remain same) class ScatterPoint(BaseModel): customer: str pn: str @@ -36,19 +39,113 @@ class OrphanSample(BaseModel): customer: str pn: str days_since_sent: int - order_no: str - date: str + order_no: Optional[str] = None + date: Optional[str] = None + sample_qty: int = 0 -# ... (parse_date function remains same) +class NoDitSample(BaseModel): + sample_id: str + customer: str + pn: str + order_no: Optional[str] + date: Optional[str] + qty: int +def parse_date(date_val) -> Optional[datetime]: + if not date_val: + return None + if isinstance(date_val, datetime): + return date_val + if isinstance(date_val, str): + date_str = date_val.strip() + try: + if "T" in date_str: + return datetime.fromisoformat(date_str.replace("Z", "+00:00")) + + # Try common formats + for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%d-%m-%Y", "%Y%m%d"]: + try: + return datetime.strptime(date_str, fmt) + except ValueError: + continue + + # Fallback: try parsing with pandas if simple strptime fails, + # but for now let's just stick to common formats to avoid heavy dependency inside loop if not needed. + return None + except ValueError: + return None + return None -# Helper to build order lookups -from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name +def normalize_id(val: any) -> str: + """正規化 ID (去除空白、單引號、轉字串)""" + if val is None: + return "" + s = str(val).strip() + s = s.lstrip("'") + if s.endswith(".0"): + s = s[:-2] + return s.upper() -def build_order_lookups(orders): +def find_matched_orders(s, order_lookup_by_id, order_lookup_by_name, orders_by_cust_name): + # Use a dictionary to deduplicate matches by a unique key (e.g. order's internal ID or file_id+row which we don't have, so object ID is best if in memory, or full content) + # Since we built lookups with `data` dicts that are created fresh in the loop, we can't rely on object identity of `data`. + # However, `data` might need a unique identifier from the source order. + # Let's add `order_db_id` to `data` in get_conversions first? + # Actually, simpler: just collect all and dedup by `(date, qty, order_no, clean_pn)` tuple? + # Or better, trust the strategy hierarchy but be more permissive? + + # Strategy change: Try to find ALL valid matches. + # Combine ID and Name matches. + + candidates = [] + clean_pn = normalize_pn_for_matching(s.pn) + norm_cust_name = normalize_customer_name(s.customer) + clean_cust_id = normalize_id(s.cust_id) + + # 1. Try ID Match + if clean_cust_id: + key_id = (clean_cust_id, clean_pn) + if key_id in order_lookup_by_id: + candidates.extend(order_lookup_by_id[key_id]) + + # 2. Try Name Match (ALWAYS check this too, in case ID is missing on some order rows) + key_name = (norm_cust_name, clean_pn) + if key_name in order_lookup_by_name: + candidates.extend(order_lookup_by_name[key_name]) + + # 3. Try Prefix Match (Only if we have relatively few candidates? Or always?) + # If we already have exact matches, prefix might introduce noise. + # Let's keep prefix as a fallback OR if the existing candidates count is low? + # Actually, let's keep it as fallback for now. Explicit matching is better. + if not candidates and norm_cust_name in orders_by_cust_name: + candidates_prefix = orders_by_cust_name[norm_cust_name] + for o_dat in candidates_prefix: + o_pn = o_dat['clean_pn'] + if o_pn and clean_pn and (clean_pn.startswith(o_pn) or o_pn.startswith(clean_pn)): + candidates.append(o_dat) + + # Deduplicate candidates based on a unique signature + # Signature: (date, qty, order_no) + unique_candidates = [] + seen = set() + for c in candidates: + sig = (c["date"], c["qty"], c["order_no"]) + if sig not in seen: + seen.add(sig) + unique_candidates.append(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() + + # Build Lookups order_lookup_by_id = {} order_lookup_by_name = {} + orders_by_cust_name = {} # For prefix matching: name -> list of {clean_pn, date, qty, ...} for o in orders: clean_pn = normalize_pn_for_matching(o.pn) @@ -60,7 +157,8 @@ def build_order_lookups(orders): data = { "date": o_date, "qty": o.qty or 0, - "order_no": o.order_no + "order_no": o.order_no, + "clean_pn": clean_pn # Store for prefix check } if clean_cust_id: @@ -71,86 +169,49 @@ def build_order_lookups(orders): key_name = (norm_cust_name, clean_pn) if key_name not in order_lookup_by_name: order_lookup_by_name[key_name] = [] order_lookup_by_name[key_name].append(data) - - return order_lookup_by_id, order_lookup_by_name - -@router.get("/conversions", response_model=List[ConversionRecord]) -def get_conversions(db: Session = Depends(get_db)): - # 找出所有樣品 - samples = db.query(SampleRecord).all() - # 找出所有訂單 - orders = db.query(OrderRecord).all() - - order_lookup_by_id, order_lookup_by_name = build_order_lookups(orders) + + 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) conversions = [] - # We want to list "Sample Records" that successfully converted. - # Or "Groups"? The user said "list of sample sent and their order qty". - # Listing each sample record seems appropriate. - for s in samples: - clean_pn = normalize_pn_for_matching(s.pn) - norm_cust_name = normalize_customer_name(s.customer) - clean_cust_id = s.cust_id.strip().upper() if s.cust_id else "" + matched_orders = find_matched_orders(s, order_lookup_by_id, order_lookup_by_name, orders_by_cust_name) s_date = parse_date(s.date) - matched_orders = [] - - # 1. Try via ID - if clean_cust_id: - if (clean_cust_id, clean_pn) in order_lookup_by_id: - matched_orders.extend(order_lookup_by_id[(clean_cust_id, clean_pn)]) - - # 2. Try via Name (Fallback) - if not matched_orders: - if (norm_cust_name, clean_pn) in order_lookup_by_name: - matched_orders.extend(order_lookup_by_name[(norm_cust_name, clean_pn)]) - if matched_orders and s_date: - # Sort orders by date - matched_orders.sort(key=lambda x: x["date"]) - first_order = matched_orders[0] + # STRICT FILTER: Only consider orders AFTER or ON sample date + valid_orders = [o for o in matched_orders if o["date"] >= s_date] - # Simple aggregations if multiple orders? User asked for "their order qty". - # showing total order qty for this PN/Cust might be better - total_order_qty = sum(o["qty"] for o in matched_orders) - - days_diff = (first_order["date"] - s_date).days - - # Filter unrealistic past orders? - # if days_diff < 0: continue # Optional - - conversions.append(ConversionRecord( - customer=s.customer, - pn=s.pn, - sample_date=s.date, - sample_qty=s.qty or 0, - order_date=first_order["date"].strftime("%Y-%m-%d"), # First order date - order_qty=total_order_qty, - days_to_convert=days_diff - )) - - # Sort by recent sample date - return sorted(conversions, key=lambda x: x.sample_date, reverse=True) + if valid_orders: + # Sort orders by date + valid_orders.sort(key=lambda x: x["date"]) + + # Identify First Order Date & Aggregate Qty for that date + first_order = valid_orders[0] + first_date = first_order["date"] + + # Sum qty of ALL orders that match the first order date + first_date_qty = sum(o["qty"] for o in valid_orders if o["date"] == first_date) -def parse_date(date_str: str) -> Optional[datetime]: - if not date_str: - return None - val = str(date_str).strip() - # Try parsing YYYYMMDD - if len(val) == 8 and val.isdigit(): - try: - return datetime.strptime(val, "%Y%m%d") - except ValueError: - pass - - for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%d-%b-%y"): - try: - return datetime.strptime(str(date_str).split(' ')[0], fmt.split(' ')[0]) - except ValueError: - continue - return None + # Total Order Qty (Cumulative for all valid post-sample orders) + total_order_qty = sum(o["qty"] for o in valid_orders) + + days_diff = (first_date - s_date).days + s_date_str = s_date.strftime("%Y-%m-%d") + + conversions.append(ConversionRecord( + customer=s.customer, + pn=s.pn, + sample_date=s_date_str, + sample_qty=s.qty or 0, + order_date=first_date.strftime("%Y-%m-%d"), + order_qty=first_date_qty, # Show First Order Qty ONLY + total_order_qty=total_order_qty, # Show Total Qty + days_to_convert=days_diff + )) + + return sorted(conversions, key=lambda x: x.sample_date if x.sample_date else "0000-00-00", reverse=True) @router.get("/kpi", response_model=LabKPI) def get_lab_kpi( @@ -158,14 +219,13 @@ def get_lab_kpi( end_date: Optional[str] = Query(None), db: Session = Depends(get_db) ): - # 1. 取得所有樣品與訂單 + # Fetch Data 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) orders_query = orders_query.filter(OrderRecord.date <= end_date) @@ -173,40 +233,8 @@ def get_lab_kpi( samples = samples_query.all() orders = orders_query.all() - # 建立群組 (ERP Code + PN) - # ERP Code correspond to cust_id - from app.services.fuzzy_matcher import normalize_pn_for_matching - - sample_groups = {} - for s in samples: - # Use simple normalization like stripping spaces - clean_pn = normalize_pn_for_matching(s.pn) - clean_cust = s.cust_id.strip().upper() if s.cust_id else "" - key = (clean_cust, clean_pn) - if key not in sample_groups: - sample_groups[key] = [] - sample_groups[key].append(s) - - order_groups = {} - for o in orders: - clean_pn = normalize_pn_for_matching(o.pn) - clean_cust = o.cust_id.strip().upper() if o.cust_id else "" - key = (clean_cust, clean_pn) - if key not in order_groups: - order_groups[key] = [] - order_groups[key].append(o) - - # 計算 Velocity 與 轉換率 - velocities = [] - converted_samples_count = 0 - total_samples_count = len(samples) - - # Re-use the lookup maps built above if possible, but we need to build them first. - # Let's rebuild lookups here for clarity or refactor. - # To be safe and clean, let's just implement the loop here. - - from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name - + # Build Lookups (Same as conversions) + orders_by_cust_name = {} order_lookup_by_id = {} order_lookup_by_name = {} @@ -214,9 +242,9 @@ def get_lab_kpi( 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) - + + # We only need dates for KPI 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] = [] @@ -225,80 +253,115 @@ def get_lab_kpi( 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) + + 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 }) - - # Group Samples by (CustName, PN) for calculation to avoid double counting if multiple samples -> same order - # Actually, "Conversion Rate" is usually "Percentage of Sample Records that resulted in Order". - # Or "Percentage of Projects". Let's stick to "Sample Groups" (Unique trials). - - unique_sample_groups = {} # (norm_cust_name, clean_pn) -> list of sample dates + # Group Samples by (CustName, PN) for Project Count + unique_sample_groups = {} for s in samples: clean_pn = normalize_pn_for_matching(s.pn) norm_cust_name = normalize_customer_name(s.customer) - clean_cust_id = s.cust_id.strip().upper() if s.cust_id else "" - key = (norm_cust_name, clean_pn) # Group by Name+PN + key = (norm_cust_name, clean_pn) if key not in unique_sample_groups: unique_sample_groups[key] = { "dates": [], - "cust_ids": set() + "cust_ids": set(), + "raw_pns": set() } s_date = parse_date(s.date) if s_date: unique_sample_groups[key]["dates"].append(s_date) - if clean_cust_id: unique_sample_groups[key]["cust_ids"].add(clean_cust_id) + if s.cust_id: unique_sample_groups[key]["cust_ids"].add(s.cust_id.strip().upper()) + unique_sample_groups[key]["raw_pns"].add(clean_pn) - # Calculate - total_samples_count = len(unique_sample_groups) # Total "Projects" + total_samples_count = len(unique_sample_groups) converted_count = 0 - orphan_count = 0 + velocities = [] now = datetime.now() for key, data in unique_sample_groups.items(): - norm_cust_name, clean_pn = key + norm_cust_name, group_clean_pn = key - # Try finding orders matched_dates = [] - # 1. Try via ID + # 1. Try ID Match for cid in data["cust_ids"]: - if (cid, clean_pn) in order_lookup_by_id: - matched_dates.extend(order_lookup_by_id[(cid, clean_pn)]) + if (cid, group_clean_pn) in order_lookup_by_id: + matched_dates.extend(order_lookup_by_id[(cid, group_clean_pn)]) - # 2. Try via Name + # 2. Try Name Match if not matched_dates: if key in order_lookup_by_name: matched_dates.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: + 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"]) + if matched_dates: - converted_count += 1 - # Velocity earliest_sample = min(data["dates"]) if data["dates"] else None - # Filter orders that came AFTER sample? Or just first order? - # Typically first order date. - first_order = min(matched_dates) if matched_dates else None - if earliest_sample and first_order: - diff = (first_order - earliest_sample).days - if diff >= 0: - velocities.append(diff) + # STRICT FILTER: Post-Sample Orders Only + valid_dates = [] + if earliest_sample: + valid_dates = [d for d in matched_dates if d >= earliest_sample] + + if valid_dates: + converted_count += 1 + first_order = min(valid_dates) + + diff = (first_order - earliest_sample).days + if diff >= 0: + velocities.append(diff) + else: + # No valid post-sample order -> Potential Orphan + if earliest_sample and (now - earliest_sample).days > 90: + orphan_count += 1 else: - # Check Orphan (No Order) - # Use earliest sample date + # Orphan Check earliest_sample = min(data["dates"]) if data["dates"] else None + # If no date, can't determine orphans strictly, but also definitely not converted. + # Only count as orphan if we know it's old enough. if earliest_sample and (now - earliest_sample).days > 90: orphan_count += 1 avg_velocity = sum(velocities) / len(velocities) if velocities else 0 conversion_rate = (converted_count / total_samples_count * 100) if total_samples_count > 0 else 0 + # Calculate No DIT High Qty Samples (Count) + kpi_samples_query = db.query(SampleRecord).filter(SampleRecord.qty >= 1000) + if start_date: kpi_samples_query = kpi_samples_query.filter(SampleRecord.date >= start_date) + if end_date: kpi_samples_query = kpi_samples_query.filter(SampleRecord.date <= end_date) + + high_qty_samples = kpi_samples_query.all() + high_qty_ids = [s.id for s in high_qty_samples] + + no_dit_count = 0 + if high_qty_ids: + matched_ids = db.query(MatchResult.target_id).filter( + MatchResult.target_id.in_(high_qty_ids), + MatchResult.target_type == TargetType.SAMPLE, + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).all() + 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]) + return LabKPI( converted_count=converted_count, avg_velocity=round(avg_velocity, 1), conversion_rate=round(conversion_rate, 1), - orphan_count=orphan_count + orphan_count=orphan_count, + no_dit_count=no_dit_count ) @router.get("/scatter", response_model=List[ScatterPoint]) @@ -318,80 +381,31 @@ def get_scatter_data( samples = samples_query.all() orders = orders_query.all() - # 聚合資料 - from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name + # Build Lookups (simplified for aggregation) + orders_by_cust_name = {} # name -> list of {clean_pn, qty, date} - # 建立多重索引的 Order Lookup - # order_lookup_by_id: (cust_id, pn) -> Order Data - # order_lookup_by_name: (cust_name, pn) -> Order Data - order_lookup_by_id = {} - order_lookup_by_name = {} - for o in orders: - clean_pn = normalize_pn_for_matching(o.pn) - clean_cust_id = o.cust_id.strip().upper() if o.cust_id else "" norm_cust_name = normalize_customer_name(o.customer) + clean_pn = normalize_pn_for_matching(o.pn) + o_date = parse_date(o.date) or (o.created_at.replace(tzinfo=None) if o.created_at else datetime.max) - # Aggregate by Cust ID - if clean_cust_id: - key_id = (clean_cust_id, clean_pn) - if key_id not in order_lookup_by_id: - order_lookup_by_id[key_id] = {"qty": 0, "dates": []} - order_lookup_by_id[key_id]["qty"] += (o.qty or 0) - if o.date: order_lookup_by_id[key_id]["dates"].append(parse_date(o.date) or datetime.max) - elif o.created_at: order_lookup_by_id[key_id]["dates"].append(o.created_at.replace(tzinfo=None)) + 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, + "qty": o.qty or 0, + "date": o_date + }) - # Aggregate by Cust Name (Fallback) - key_name = (norm_cust_name, clean_pn) - if key_name not in order_lookup_by_name: - order_lookup_by_name[key_name] = {"qty": 0, "dates": []} - order_lookup_by_name[key_name]["qty"] += (o.qty or 0) - if o.date: order_lookup_by_name[key_name]["dates"].append(parse_date(o.date) or datetime.max) - elif o.created_at: order_lookup_by_name[key_name]["dates"].append(o.created_at.replace(tzinfo=None)) + # Group by (Display Cust, Display PN) - but we need to match broadly + # Strategy: Group by Display Keys first, then try to find match for that group + + unique_groups = {} # (norm_cust, clean_pn) -> {display_cust, display_pn, sample_qty, order_qty, min_sample_date} - - final_data_map = {} # Key (Display Customer, Original PN) -> Data - for s in samples: - clean_pn = normalize_pn_for_matching(s.pn) - clean_cust_id = s.cust_id.strip().upper() if s.cust_id else "" norm_cust_name = normalize_customer_name(s.customer) - - # 嘗試比對 Order - matched_order = None - - # 1. Try Cust ID match - if clean_cust_id: - matched_order = order_lookup_by_id.get((clean_cust_id, clean_pn)) - - # 2. If no match, Try Cust Name match - if not matched_order: - matched_order = order_lookup_by_name.get((norm_cust_name, clean_pn)) - - # Render Key using Sample's info - display_key = (s.customer, s.pn) - if display_key not in final_data_map: - final_data_map[display_key] = {"sample_qty": 0, "order_qty": 0, "customer": s.customer, "orignal_pn": s.pn} - - final_data_map[display_key]["sample_qty"] += (s.qty or 0) - - if matched_order: - # 注意:這裡簡單累加可能會導致重複計算如果多個樣品對應同一個訂單聚合 - # 但目前邏輯是以「樣品」為基底看轉換,所以我們顯示該樣品對應到的訂單總量是合理的 - # 不過為了 scatter plot 的準確性,我們應該只在第一次遇到這個 key 時加上 order qty? - # 或者,Scatter Plot 的點是 (Customer, PN),所以我們應該是把這個 Group 的 Sample Qty 和 Order Qty 放在一起。 - # Order Qty 已經在 lookup 裡聚合過了。 - pass - - # Re-construct the final map properly merging Order Data - # 上面的迴圈有點問題,因為我們是依據 Sample 來建立點,但 Order 總量是固定的。 - # 正確做法:以 (Customer, PN) 為 Unique Key。 - - unique_groups = {} # (norm_cust_name, clean_pn) -> {display_cust, display_pn, sample_qty, order_qty} - - for s in samples: clean_pn = normalize_pn_for_matching(s.pn) - norm_cust_name = normalize_customer_name(s.customer) + s_date = parse_date(s.date) key = (norm_cust_name, clean_pn) if key not in unique_groups: @@ -400,31 +414,38 @@ def get_scatter_data( "display_pn": s.pn, "sample_qty": 0, "order_qty": 0, - "matched": False + "min_sample_date": s_date } unique_groups[key]["sample_qty"] += (s.qty or 0) + + # Update min date + current_min = unique_groups[key]["min_sample_date"] + if s_date: + if not current_min or s_date < current_min: + unique_groups[key]["min_sample_date"] = s_date - # Fill in Order Qty + # Fill Order Qty for key, data in unique_groups.items(): - norm_cust_name, clean_pn = key + norm_cust_name, sample_clean_pn = key + min_s_date = data["min_sample_date"] - # Try finding orders - # Note: We rely on Name match here primarily since we grouped by Name. - # Ideally we should also check CustID if available on the samples in this group, but grouping by Name is safer for visual scatter plot. + matched_qty = 0 - matched_order = order_lookup_by_name.get((norm_cust_name, clean_pn)) - - # If no name match, maybe check if any sample in this group had a CustId that matches? - # For simplicity, let's stick to Name+PN for the Scatter Plot aggregation - - if matched_order: - data["order_qty"] = matched_order["qty"] - data["matched"] = True - - data_map = unique_groups # Replace old data_map logic + if 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'] + o_date = o_dat['date'] + + # Check Date Causality first + if min_s_date and o_date < min_s_date: + continue - # 如果有訂單但沒樣品,我們在 ROI 分析中可能不顯示,或者顯示在 Y 軸上 X=0。 - # 根據需求:分析「樣品寄送」與「訂單接收」的關聯,通常以有送樣的為基底。 + # Exact or Prefix Match + if o_pn and (sample_clean_pn == o_pn or sample_clean_pn.startswith(o_pn) or o_pn.startswith(sample_clean_pn)): + matched_qty += o_dat['qty'] + + data["order_qty"] = matched_qty return [ ScatterPoint( @@ -433,7 +454,7 @@ def get_scatter_data( sample_qty=v["sample_qty"], order_qty=v["order_qty"] ) - for key, v in data_map.items() + for key, v in unique_groups.items() ] @router.get("/orphans", response_model=List[OrphanSample]) @@ -441,54 +462,117 @@ def get_orphans(db: Session = Depends(get_db)): now = datetime.now() threshold_date = now - timedelta(days=90) - # 找出所有樣品 samples = db.query(SampleRecord).all() - # 找出所有訂單 + # Need to match logic check + # To save time, we can fetch all orders and build lookup orders = db.query(OrderRecord).all() - # Build Order Lookups (ID and Name) - from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name - - order_keys_id = set() - order_keys_name = set() - + # Build Lookup for Fast Checking + 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) + clean_pn = normalize_pn_for_matching(o.pn) + o_date = parse_date(o.date) or (o.created_at.replace(tzinfo=None) if o.created_at else datetime.max) - if clean_cust_id: - order_keys_id.add((clean_cust_id, clean_pn)) - - order_keys_name.add((norm_cust_name, clean_pn)) - + 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 + }) - orphans = [] + # Aggregation Dictionary + # Key: (normalized_customer, normalized_pn, order_no, date_str) + # Value: { "raw_customer": str, "raw_pn": str, "qty": int, "date_obj": datetime } + orphan_groups = {} + for s in samples: - clean_pn = normalize_pn_for_matching(s.pn) norm_cust_name = normalize_customer_name(s.customer) - clean_cust_id = s.cust_id.strip().upper() if s.cust_id else "" - + clean_pn = normalize_pn_for_matching(s.pn) s_date = parse_date(s.date) - - # Check match + s_date_str = s_date.strftime("%Y-%m-%d") if s_date else "Unknown" + s_order_no = s.order_no.strip() if s.order_no else "" + + # Check if matched (Logic same as before, check against all orders) matched = False - if clean_cust_id: - if (clean_cust_id, clean_pn) in order_keys_id: - matched = True - - if not matched: - if (norm_cust_name, clean_pn) in order_keys_name: - matched = True + + if s_date 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'] + o_date = o_dat['date'] + + # Check Date Causality first + if o_date < s_date: + continue + + # Check PN Match (Exact or Prefix) + if o_pn and (clean_pn == o_pn or clean_pn.startswith(o_pn) or o_pn.startswith(clean_pn)): + matched = True + break if not matched: + # Only consider old enough samples if s_date and s_date < threshold_date: - orphans.append(OrphanSample( - customer=s.customer, - pn=s.pn, - days_since_sent=(now - s_date).days, - order_no=s.order_no, - date=s.date - )) + # Add to group + # We use the FIRST raw customer/pn encountered for display, or could be smarter. + # Group Key: (norm_cust, clean_pn, order_no, date) + key = (norm_cust_name, clean_pn, s_order_no, s_date_str) + + if key not in orphan_groups: + orphan_groups[key] = { + "customer": s.customer, + "pn": s.pn, + "order_no": s.order_no, + "date": s_date_str, + "qty": 0, + "days": (now - s_date).days + } + + orphan_groups[key]["qty"] += (s.qty or 0) + + # Convert groups to list + orphans = [] + for data in orphan_groups.values(): + orphans.append(OrphanSample( + customer=data["customer"], + pn=data["pn"], + days_since_sent=data["days"], + order_no=data["order_no"], + date=data["date"], + sample_qty=data["qty"] + )) 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)): + # Filter High Qty Samples + high_qty_samples = db.query(SampleRecord).filter(SampleRecord.qty >= 1000).all() + + results = [] + # Batch query matches for efficiency + sample_ids = [s.id for s in high_qty_samples] + if not sample_ids: + return [] + + matched_ids = db.query(MatchResult.target_id).filter( + MatchResult.target_id.in_(sample_ids), + MatchResult.target_type == TargetType.SAMPLE, + MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) + ).all() + + matched_ids_set = set(m[0] for m in matched_ids) + + for s in high_qty_samples: + if s.id not in matched_ids_set: + s_date = parse_date(s.date) + results.append(NoDitSample( + 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 None, + qty=s.qty + )) + + return sorted(results, key=lambda x: x.qty, reverse=True) diff --git a/backend/app/routers/match.py b/backend/app/routers/match.py index 951fda9..c39ecc2 100644 --- a/backend/app/routers/match.py +++ b/backend/app/routers/match.py @@ -29,6 +29,7 @@ class DitInfo(BaseModel): class TargetInfo(BaseModel): id: int + sample_id: Optional[str] = None customer: str pn: str order_no: Optional[str] @@ -83,6 +84,7 @@ def get_results(db: Session = Depends(get_db)): if sample: target_info = TargetInfo( id=sample.id, + sample_id=sample.sample_id, customer=sample.customer, pn=sample.pn, order_no=sample.order_no, @@ -93,6 +95,7 @@ def get_results(db: Session = Depends(get_db)): if order: target_info = TargetInfo( id=order.id, + sample_id=order.order_id, customer=order.customer, pn=order.pn, order_no=order.order_no, @@ -142,6 +145,7 @@ def review_match(match_id: int, request: ReviewRequest, db: Session = Depends(ge if sample: target_info = TargetInfo( id=sample.id, + sample_id=sample.sample_id, customer=sample.customer, pn=sample.pn, order_no=sample.order_no, @@ -152,6 +156,7 @@ def review_match(match_id: int, request: ReviewRequest, db: Session = Depends(ge if order: target_info = TargetInfo( id=order.id, + sample_id=order.order_id, customer=order.customer, pn=order.pn, order_no=order.order_no, diff --git a/backend/app/services/excel_parser.py b/backend/app/services/excel_parser.py index df6379a..3e189a1 100644 --- a/backend/app/services/excel_parser.py +++ b/backend/app/services/excel_parser.py @@ -43,13 +43,13 @@ COLUMN_MAPPING = { 'date': ['created date', '日期', 'date', '建立日期', 'create date'] }, 'sample': { - 'sample_id': ['樣品訂單號碼', 'item', '樣品編號', 'sample_id', 'sample id', '編號'], - 'order_no': ['樣品訂單號碼', '單號', 'order_no', 'order no', '樣品單號', '申請單號'], + 'sample_id': ['sample_id', 'sample id', '樣品ID'], + 'order_no': ['樣品訂單號碼', '單號', 'order_no', 'order no', '樣品單號', '申請單號', '樣品訂單號'], 'oppy_no': ['oppy no', 'oppy_no', '案號', '案件編號', 'opportunity no'], 'cust_id': ['cust id', 'cust_id', '客戶編號', '客戶代碼', '客戶代號'], 'customer': ['客戶名稱', '客戶簡稱', '客戶', 'customer', 'customer name'], - 'pn': ['item', 'type', '料號', 'part number', 'pn', 'part no', '產品料號', '索樣數量'], - 'qty': ['索樣數量pcs', '索樣數量 k', '數量', 'qty', 'quantity', '申請數量'], + 'pn': ['item', '料號', 'part number', 'pn', 'part no', '產品料號', '索樣數量', 'type'], + 'qty': ['索樣數量pcs', '索樣數量 k', '數量', 'qty', 'quantity', '申請數量', '索樣數量'], 'date': ['出貨日', '需求日', '日期', 'date', '申請日期'] }, 'order': { diff --git a/backend/app/services/fuzzy_matcher.py b/backend/app/services/fuzzy_matcher.py index 16112f4..994d666 100644 --- a/backend/app/services/fuzzy_matcher.py +++ b/backend/app/services/fuzzy_matcher.py @@ -66,22 +66,40 @@ def normalize_customer_name(name: str) -> str: # 全形轉半形 normalized = normalized.replace(' ', ' ') + # 移除特殊結尾字符 that might remain (like "Co.,") if suffix list didn't catch it # Remove trailing "Co." or "Co.," normalized = re.sub(r'[,.\s]+Co[.,]*$', '', normalized, flags=re.IGNORECASE) + # NEW: 移除連字號及其後面的內容 (僅針對包含中文字符的名稱,假設是分公司或地點) + # 例如: "廣達-桃園" -> "廣達" + has_chinese = bool(re.search(r'[\u4e00-\u9fff]', normalized)) + if has_chinese and '-' in normalized: + parts = normalized.split('-') + # 如果分割後的第一部分長度大於1 (避免 "A-Team" 變成 "A" 造成誤判) + if len(parts[0].strip()) > 1: + normalized = parts[0].strip() + # 移除多餘空白 normalized = re.sub(r'\s+', ' ', normalized).strip() - # Remove all punctuation for final key? No, fuzzy match might rely on it. - # But for "Key" based matching in Lab, we want strict alphabetic? - # No, keep it similar to before but cleaner. - # Final aggressive strip of trailing punctuation normalized = normalized.strip("., ") return normalized.upper() + + +def normalize_id(val: any) -> str: + """正規化 ID (去除空白、單引號、轉字串)""" + if val is None: + return "" + s = str(val).strip() + s = s.lstrip("'") # 去除 Excel 可能的文字格式引號 + if s.endswith(".0"): # 去除 float 轉 string 可能產生的 .0 + s = s[:-2] + return s.upper() + def calculate_similarity(name1: str, name2: str) -> Tuple[float, str]: """計算兩個名稱的相似度""" # 正規化 @@ -212,7 +230,10 @@ class FuzzyMatcher: # Priority 2 & 3 則限制在相同 PN (Ignored symbols) elif dit_norm_pn == normalize_pn_for_matching(sample.pn): # Priority 2: 客戶代碼比對 (Silver Key) - if dit.erp_account and sample.cust_id and dit.erp_account == sample.cust_id: + dit_erp = normalize_id(dit.erp_account) + sample_cust = normalize_id(sample.cust_id) + + if dit_erp and sample_cust and dit_erp == sample_cust: match_priority = 2 match_source = f"Matched via ERP Account: {dit.erp_account}" score = 99.0 @@ -254,7 +275,10 @@ class FuzzyMatcher: reason = "" # Priority 2: 客戶代碼比對 (Silver Key) - if dit.erp_account and order.cust_id and dit.erp_account == order.cust_id: + dit_erp = normalize_id(dit.erp_account) + order_cust = normalize_id(order.cust_id) + + if dit_erp and order_cust and dit_erp == order_cust: match_priority = 2 match_source = f"Matched via ERP Account: {dit.erp_account}" score = 99.0 @@ -267,6 +291,7 @@ class FuzzyMatcher: match_priority = 3 match_source = f"Matched via Name Similarity ({reason})" + if match_priority > 0: status = MatchStatus.auto_matched if score >= MATCH_THRESHOLD_AUTO else MatchStatus.pending match = MatchResult( diff --git a/backend/app/services/report_generator.py b/backend/app/services/report_generator.py index 7db5076..06f2f2c 100644 --- a/backend/app/services/report_generator.py +++ b/backend/app/services/report_generator.py @@ -13,7 +13,7 @@ from sqlalchemy.orm import Session 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 +from app.models.match import MatchResult, MatchStatus, TargetType class ReportGenerator: def __init__(self, db: Session): @@ -40,7 +40,7 @@ class ReportGenerator: # 找到已接受的樣品匹配 sample_match = self.db.query(MatchResult).filter( MatchResult.dit_id == dit.id, - MatchResult.target_type == 'SAMPLE', + MatchResult.target_type == TargetType.SAMPLE, MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) ).first() @@ -54,7 +54,7 @@ class ReportGenerator: # 找到已接受的訂單匹配 order_match = self.db.query(MatchResult).filter( MatchResult.dit_id == dit.id, - MatchResult.target_type == 'ORDER', + MatchResult.target_type == TargetType.ORDER, MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched]) ).first() @@ -64,7 +64,7 @@ class ReportGenerator: ).first() if order: row['order_no'] = order.order_no - row['order_status'] = order.status.value if order.status else None + row['order_status'] = order.status if order.status else None row['order_amount'] = order.amount result.append(row) diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py index 4234430..72292d0 100644 --- a/backend/app/utils/security.py +++ b/backend/app/utils/security.py @@ -22,7 +22,10 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: password_bytes = plain_password.encode('utf-8') if len(password_bytes) > 72: plain_password = password_bytes[:72].decode('utf-8', errors='ignore') - return pwd_context.verify(plain_password, hashed_password) + try: + return pwd_context.verify(plain_password, hashed_password) + except Exception: + return False def get_password_hash(password: str) -> str: password_bytes = password.encode('utf-8') diff --git a/backend/check_log.txt b/backend/check_log.txt new file mode 100644 index 0000000..ad64426 --- /dev/null +++ b/backend/check_log.txt @@ -0,0 +1,53 @@ +--- Checking DIT Records --- +Total DIT Records: 7498 +Duplicate DITs (same op_id + pn): 0 +DITs with empty PN: 1482 +Example Empty PN DIT: ID 61584, OP OP0000021791 + +--- Checking Sample Records --- +Total Sample Records: 14145 +Duplicate Sample IDs: 0 + +--- Checking Match Results --- +Total Match Results: 3844 +Duplicate Matches (same dit_id + target_type + target_id): 0 + +--- Investigating Screenshot Case --- +DIT Records with op_id 'OP0000021498': + ID: 63802, PN: '2N7002K-AU_R1_000A2', Cust: Magna Electronics, LLC. + ID: 63804, PN: 'BAS16-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63805, PN: 'BAT54TS-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63807, PN: 'BAV20WS-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63808, PN: 'BC817-40-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63810, PN: 'BC846BPN-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63811, PN: 'BC856BW-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63813, PN: 'BCP56-16-AU_R2_007A1', Cust: Magna Electronics, LLC. + ID: 63815, PN: 'BZT52-C3S-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63803, PN: 'BZT52-C4V3S-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63809, PN: 'BZX584C24-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63817, PN: 'BZX84C12-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63818, PN: 'BZX84C15-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63819, PN: 'BZX84C16-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63816, PN: 'BZX84C18-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63821, PN: 'BZX84C4V7-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63822, PN: 'MER1DMB-AU_R2_006A1', Cust: Magna Electronics, LLC. + ID: 63806, PN: 'MER2DMB-AU_R2_006A1', Cust: Magna Electronics, LLC. + ID: 63820, PN: 'MMBD4148TS-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63823, PN: 'MMBT3906-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63812, PN: 'MMSZ5245B-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63828, PN: 'PDZ18B-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63814, PN: 'PDZ51B-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63824, PN: 'PDZ56B-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63825, PN: 'PJA138K-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63801, PN: 'PJD60N06SA-AU_L2_006A1', Cust: Magna Electronics, LLC. + ID: 63826, PN: 'PJMBZ27C-AU_R1_005A1', Cust: Magna Electronics, LLC. + ID: 63827, PN: 'PJMBZ33A-AU_R1_007A1', Cust: Magna Electronics, LLC. + ID: 63829, PN: 'PJQ5465A-AU_R2_000A1', Cust: Magna Electronics, LLC. + ID: 63830, PN: 'PJQ5466A1-AU_R2_000A1', Cust: Magna Electronics, LLC. + ID: 63831, PN: 'PJQ5540S6C-AU_R2_002A1', Cust: Magna Electronics, LLC. + ID: 63832, PN: 'PJQ5948S6-AU_R2_002A1', Cust: Magna Electronics, LLC. + ID: 63834, PN: 'PZS5115BAS-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63833, PN: 'PZS516V2BAS-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63835, PN: 'SBA0840AS-AU_R1_000A1', Cust: Magna Electronics, LLC. + ID: 63836, PN: 'SK26-AU_R1_000A1', Cust: Magna Electronics, LLC. +Sample Records with sample_id 'S202509514': diff --git a/backend/debug_lab_orders.py b/backend/debug_lab_orders.py new file mode 100644 index 0000000..7b37da8 --- /dev/null +++ b/backend/debug_lab_orders.py @@ -0,0 +1,21 @@ +from app.models import get_db, init_db +from app.models.order import OrderRecord +from app.models.sample import SampleRecord +from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name + +init_db() +db = next(get_db()) + +pn = "PSMQC098N10LS2-AU_R2_002A1" +normalized_pn = normalize_pn_for_matching(pn) +customer_keyword = "SEMI" + +orders = db.query(OrderRecord).filter(OrderRecord.pn.like(f"%{pn}%")).all() +print(f"--- Querying Orders for PN: {pn} ---") +for o in orders: + print(f"ID: {o.id}, OrderNo: {o.order_no}, CustID: {o.cust_id}, Customer: {o.customer}, PN: {o.pn}, Qty: {o.qty}, Date: {o.date}") + +print(f"\n--- Checking Parsed Samples ---") +samples = db.query(SampleRecord).filter(SampleRecord.pn.like(f"%{pn}%")).all() +for s in samples: + print(f"ID: {s.id}, OrderNo: {s.order_no}, CustID: {s.cust_id}, Customer: {s.customer}, PN: {s.pn}, Qty: {s.qty}, Date: {s.date}") diff --git a/backend/debug_lab_v2.py b/backend/debug_lab_v2.py new file mode 100644 index 0000000..bbfaf51 --- /dev/null +++ b/backend/debug_lab_v2.py @@ -0,0 +1,47 @@ + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.models import get_db, SampleRecord, OrderRecord +from app.routers.lab import get_conversions, build_order_lookups, parse_date +from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name + +db = next(get_db()) + +print("--- DEBUG START ---") +# 1. Check Date Parsing +print(f"Date '45292' parses to: {parse_date('45292')}") + +# 2. Check Raw Data Count +s_count = db.query(SampleRecord).count() +o_count = db.query(OrderRecord).count() +print(f"Total Samples: {s_count}, Total Orders: {o_count}") + +# 3. Check Top Data +print("\n--- Top 3 Samples ---") +samples = db.query(SampleRecord).limit(3).all() +for s in samples: + print(f"S: {s.customer} (ID:{s.cust_id}) | PN: {s.pn} | Date: {s.date}") + +print("\n--- Top 3 Orders ---") +orders = db.query(OrderRecord).limit(3).all() +for o in orders: + print(f"O: {o.customer} (ID:{o.cust_id}) | PN: {o.pn} | Date: {o.date}") + +# 4. Check Lookups +lookup_id, lookup_name = build_order_lookups(db.query(OrderRecord).all()) +print(f"\nLookup ID Size: {len(lookup_id)}") +print(f"Lookup Name Size: {len(lookup_name)}") +if len(lookup_name) > 0: + first_key = list(lookup_name.keys())[0] + print(f"Example Name Key: {first_key} -> {lookup_name[first_key]}") + +# 5. Check Conversions +print("\n--- Run Logic ---") +conversions = get_conversions(db) +print(f"Total Conversions Found: {len(conversions)}") +for c in conversions[:3]: + print(c) + +print("--- DEBUG END ---") diff --git a/backend/debug_log.txt b/backend/debug_log.txt new file mode 100644 index 0000000..756ccda --- /dev/null +++ b/backend/debug_log.txt @@ -0,0 +1,5 @@ +--- Searching Orders for SEMISALES --- +Found 2 orders for SEMISALES +ID: 4820, OrderNo: 1125030196, Date: 2025-09-26, Qty: 36000, PN: PSMQC098N10LS2-AU_R2_002A1 [MATCH PN] +ID: 4821, OrderNo: 1125016840, Date: 2025-06-05, Qty: 3000, PN: PSMQC098N10LS2-AU_R2_002A1 [MATCH PN] +--- Done --- diff --git a/backend/debug_log_v2.txt b/backend/debug_log_v2.txt new file mode 100644 index 0000000..1682be1 --- /dev/null +++ b/backend/debug_log_v2.txt @@ -0,0 +1,7 @@ +--- Searching Orders for SEMISALES (or PN match) --- +Found 4 orders: +ID: 4832, OrderID: 1.1, OrderNo: 1125077715, Date: 2025-09-30, Qty: 36000, PN: PSMQC098N10LS2-AU_R2_002A1, Cust: 台湾强茂 [MATCH PN] +ID: 4833, OrderID: 2.1, OrderNo: 1125030196, Date: 2025-09-26, Qty: 36000, PN: PSMQC098N10LS2-AU_R2_002A1, Cust: SEMISALES [MATCH PN] +ID: 4834, OrderID: 2.2, OrderNo: 1125016840, Date: 2025-06-05, Qty: 3000, PN: PSMQC098N10LS2-AU_R2_002A1, Cust: SEMISALES [MATCH PN] +ID: 4835, OrderID: 3.1, OrderNo: 1125016840, Date: 2025-06-05, Qty: 12000, PN: PSMQC098N10LS2-AU_R2_002A1, Cust: SEMISALES [MATCH PN] +--- Done --- diff --git a/backend/debug_match_specific_v2.py b/backend/debug_match_specific_v2.py new file mode 100644 index 0000000..c204719 --- /dev/null +++ b/backend/debug_match_specific_v2.py @@ -0,0 +1,42 @@ +import sys +import os + +# Set up logging to file +f = open("debug_log_v2.txt", "w", encoding="utf-8") +def log(msg): + print(msg) + f.write(str(msg) + "\n") + +sys.path.append(os.getcwd()) +try: + from app.models import SessionLocal, OrderRecord + from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name +except ImportError as e: + log(f"Import Error: {e}") + sys.exit(1) + +pn1 = "PSMQC098N10LS2-AU_R2_002A1" +target_norm = normalize_pn_for_matching(pn1) + +db = SessionLocal() +log("--- Searching Orders for SEMISALES (or PN match) ---") + +orders = db.query(OrderRecord).all() +found = [] +for o in orders: + # Check customer name (fuzzy) or PN + norm_cust = normalize_customer_name(o.customer or "") + norm_pn = normalize_pn_for_matching(o.pn or "") + + if "SEMISALES" in norm_cust or norm_pn == target_norm: + found.append(o) + +log(f"Found {len(found)} orders:") +for o in found: + norm_o_pn = normalize_pn_for_matching(o.pn) + match_mark = "[MATCH PN]" if norm_o_pn == target_norm else "[NO MATCH]" + log(f"ID: {o.id}, OrderID: {o.order_id}, OrderNo: {o.order_no}, Date: {o.date}, Qty: {o.qty}, PN: {o.pn}, Cust: {o.customer} {match_mark}") + +log("--- Done ---") +f.close() +db.close() diff --git a/backend/debug_out.txt b/backend/debug_out.txt new file mode 100644 index 0000000..7d9e976 Binary files /dev/null and b/backend/debug_out.txt differ diff --git a/backend/verify_lab_v3.py b/backend/verify_lab_v3.py new file mode 100644 index 0000000..df36c17 --- /dev/null +++ b/backend/verify_lab_v3.py @@ -0,0 +1,24 @@ + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.models import get_db +from app.routers.lab import get_conversions, get_lab_kpi + +db = next(get_db()) + +print("--- VERIFY LAB LOGIC v3 ---") +# Check Conversions +res = get_conversions(db) +print(f"Total Conversions: {len(res)}") +if len(res) > 0: + print("Example Conversion:") + print(res[0]) + +# Check KPI +kpi = get_lab_kpi(db=db) +print("\nKPI:") +print(kpi) + +print("--- END ---") diff --git a/frontend/src/components/DashboardView.tsx b/frontend/src/components/DashboardView.tsx index f72ad70..53e5aef 100644 --- a/frontend/src/components/DashboardView.tsx +++ b/frontend/src/components/DashboardView.tsx @@ -5,6 +5,7 @@ import { } from 'recharts'; import { Filter, Activity, Download, Info, CheckCircle, HelpCircle, XCircle } from 'lucide-react'; import { Card } from './common/Card'; +import { Tooltip } from './common/Tooltip'; import { dashboardApi, reportApi } from '../services/api'; import type { DashboardKPI, FunnelData, AttributionRow } from '../types'; @@ -127,22 +128,42 @@ export const DashboardView: React.FC = () => {
- 焦點分析:樣品投資報酬率 (ROI) 與 轉換速度 | 邏輯:ERP Code + PN 直接比對 + 焦點分析:樣品投資報酬率 (ROI) | 核心邏輯:ERP Code 歸戶 + 時間因果濾網 (僅計算送樣後訂單)
@@ -121,73 +125,92 @@ export const LabView: React.FC = () => { {/* KPI Cards */} -高效轉換客戶
-識別散佈圖中「左上角」點位,代表投入少量樣品即獲得大量訂單。
+高效轉換客戶
+識別散佈圖中「左上角」點位,代表投入少量樣品即獲得大量訂單。
風險警示
-右下角點位代表送樣頻繁但轉換效率低,需檢視應用場景或產品適配度。
+風險警示
+右下角點位代表送樣頻繁但轉換效率低,需檢視應用場景或產品適配度。
+
"本模組直接比對 ERP 編號,確保不因專案名稱模糊而漏失任何實際營收數據。"