20260123
This commit is contained in:
@@ -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 sqlalchemy.sql import func
|
||||||
from app.models import Base
|
from app.models import Base
|
||||||
from app.config import TABLE_PREFIX
|
from app.config import TABLE_PREFIX
|
||||||
@@ -16,7 +16,7 @@ class DitRecord(Base):
|
|||||||
customer = Column(String(255), nullable=False, index=True)
|
customer = Column(String(255), nullable=False, index=True)
|
||||||
customer_normalized = Column(String(255), index=True)
|
customer_normalized = Column(String(255), index=True)
|
||||||
pn = Column(String(100), nullable=False, index=True)
|
pn = Column(String(100), nullable=False, index=True)
|
||||||
eau = Column(Integer, default=0)
|
eau = Column(BigInteger, default=0)
|
||||||
stage = Column(String(50))
|
stage = Column(String(50))
|
||||||
date = Column(String(20))
|
date = Column(String(20))
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -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.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.models import Base
|
from app.models import Base
|
||||||
@@ -21,6 +21,9 @@ class ReviewAction(str, enum.Enum):
|
|||||||
|
|
||||||
class MatchResult(Base):
|
class MatchResult(Base):
|
||||||
__tablename__ = f"{TABLE_PREFIX}Match_Results"
|
__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)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
dit_id = Column(Integer, ForeignKey(f"{TABLE_PREFIX}DIT_Records.id"), nullable=False)
|
dit_id = Column(Integer, ForeignKey(f"{TABLE_PREFIX}DIT_Records.id"), nullable=False)
|
||||||
|
|||||||
@@ -161,11 +161,17 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)):
|
|||||||
erp_account = clean_value(row.get('erp_account'), '')
|
erp_account = clean_value(row.get('erp_account'), '')
|
||||||
customer = clean_value(row.get('customer'))
|
customer = clean_value(row.get('customer'))
|
||||||
pn = clean_value(row.get('pn'))
|
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}"
|
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(
|
record = 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')),
|
||||||
@@ -183,13 +189,24 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)):
|
|||||||
cust_id = clean_value(row.get('cust_id'), '')
|
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'))
|
||||||
# 跳過重複的 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:
|
if sample_id in seen_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
seen_ids.add(sample_id)
|
seen_ids.add(sample_id)
|
||||||
|
|
||||||
record = SampleRecord(
|
record = SampleRecord(
|
||||||
sample_id=sample_id,
|
sample_id=sample_id,
|
||||||
order_no=clean_value(row.get('order_no')),
|
order_no=order_no,
|
||||||
oppy_no=oppy_no,
|
oppy_no=oppy_no,
|
||||||
cust_id=cust_id,
|
cust_id=cust_id,
|
||||||
customer=customer,
|
customer=customer,
|
||||||
@@ -203,10 +220,19 @@ def import_data(request: ImportRequest, db: Session = Depends(get_db)):
|
|||||||
cust_id = clean_value(row.get('cust_id'), '')
|
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_id
|
order_no = clean_value(row.get('order_no'))
|
||||||
if order_id in seen_ids:
|
|
||||||
|
# Skip empty PN
|
||||||
|
if not pn:
|
||||||
continue
|
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(
|
record = OrderRecord(
|
||||||
order_id=order_id,
|
order_id=order_id,
|
||||||
order_no=clean_value(row.get('order_no')),
|
order_no=clean_value(row.get('order_no')),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from pydantic import BaseModel
|
|||||||
from app.models import get_db
|
from app.models import get_db
|
||||||
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.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name
|
||||||
|
|
||||||
router = APIRouter(prefix="/lab", tags=["Lab"])
|
router = APIRouter(prefix="/lab", tags=["Lab"])
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ class LabKPI(BaseModel):
|
|||||||
avg_velocity: float # 平均轉換時間 (天)
|
avg_velocity: float # 平均轉換時間 (天)
|
||||||
conversion_rate: float # 轉換比例 (%)
|
conversion_rate: float # 轉換比例 (%)
|
||||||
orphan_count: int # 孤兒樣品總數
|
orphan_count: int # 孤兒樣品總數
|
||||||
|
no_dit_count: int # 未歸因大額樣品數
|
||||||
|
|
||||||
class ConversionRecord(BaseModel):
|
class ConversionRecord(BaseModel):
|
||||||
customer: str
|
customer: str
|
||||||
@@ -22,10 +25,10 @@ class ConversionRecord(BaseModel):
|
|||||||
sample_date: str
|
sample_date: str
|
||||||
sample_qty: int
|
sample_qty: int
|
||||||
order_date: str
|
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
|
days_to_convert: int
|
||||||
|
|
||||||
# ... (ScatterPoint and OrphanSample classes remain same)
|
|
||||||
class ScatterPoint(BaseModel):
|
class ScatterPoint(BaseModel):
|
||||||
customer: str
|
customer: str
|
||||||
pn: str
|
pn: str
|
||||||
@@ -36,19 +39,113 @@ class OrphanSample(BaseModel):
|
|||||||
customer: str
|
customer: str
|
||||||
pn: str
|
pn: str
|
||||||
days_since_sent: int
|
days_since_sent: int
|
||||||
order_no: str
|
order_no: Optional[str] = None
|
||||||
date: str
|
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
|
def normalize_id(val: any) -> str:
|
||||||
from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name
|
"""正規化 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_id = {}
|
||||||
order_lookup_by_name = {}
|
order_lookup_by_name = {}
|
||||||
|
orders_by_cust_name = {} # For prefix matching: name -> list of {clean_pn, date, qty, ...}
|
||||||
|
|
||||||
for o in orders:
|
for o in orders:
|
||||||
clean_pn = normalize_pn_for_matching(o.pn)
|
clean_pn = normalize_pn_for_matching(o.pn)
|
||||||
@@ -60,7 +157,8 @@ def build_order_lookups(orders):
|
|||||||
data = {
|
data = {
|
||||||
"date": o_date,
|
"date": o_date,
|
||||||
"qty": o.qty or 0,
|
"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:
|
if clean_cust_id:
|
||||||
@@ -71,86 +169,49 @@ def build_order_lookups(orders):
|
|||||||
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(data)
|
order_lookup_by_name[key_name].append(data)
|
||||||
|
|
||||||
return order_lookup_by_id, order_lookup_by_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(data)
|
||||||
@router.get("/conversions", response_model=List[ConversionRecord])
|
|
||||||
def get_conversions(db: Session = Depends(get_db)):
|
|
||||||
# 找出所有樣品
|
|
||||||
samples = db.query(SampleRecord).all()
|
|
||||||
# 找出所有訂單
|
|
||||||
orders = db.query(OrderRecord).all()
|
|
||||||
|
|
||||||
order_lookup_by_id, order_lookup_by_name = build_order_lookups(orders)
|
|
||||||
|
|
||||||
conversions = []
|
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:
|
for s in samples:
|
||||||
clean_pn = normalize_pn_for_matching(s.pn)
|
matched_orders = find_matched_orders(s, order_lookup_by_id, order_lookup_by_name, orders_by_cust_name)
|
||||||
norm_cust_name = normalize_customer_name(s.customer)
|
|
||||||
clean_cust_id = s.cust_id.strip().upper() if s.cust_id else ""
|
|
||||||
s_date = parse_date(s.date)
|
s_date = parse_date(s.date)
|
||||||
|
|
||||||
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:
|
if matched_orders and s_date:
|
||||||
# Sort orders by date
|
# STRICT FILTER: Only consider orders AFTER or ON sample date
|
||||||
matched_orders.sort(key=lambda x: x["date"])
|
valid_orders = [o for o in matched_orders if o["date"] >= s_date]
|
||||||
first_order = matched_orders[0]
|
|
||||||
|
|
||||||
# Simple aggregations if multiple orders? User asked for "their order qty".
|
if valid_orders:
|
||||||
# showing total order qty for this PN/Cust might be better
|
# Sort orders by date
|
||||||
total_order_qty = sum(o["qty"] for o in matched_orders)
|
valid_orders.sort(key=lambda x: x["date"])
|
||||||
|
|
||||||
days_diff = (first_order["date"] - s_date).days
|
# Identify First Order Date & Aggregate Qty for that date
|
||||||
|
first_order = valid_orders[0]
|
||||||
# Filter unrealistic past orders?
|
first_date = first_order["date"]
|
||||||
# if days_diff < 0: continue # Optional
|
|
||||||
|
# Sum qty of ALL orders that match the first order date
|
||||||
conversions.append(ConversionRecord(
|
first_date_qty = sum(o["qty"] for o in valid_orders if o["date"] == first_date)
|
||||||
customer=s.customer,
|
|
||||||
pn=s.pn,
|
|
||||||
sample_date=s.date,
|
|
||||||
sample_qty=s.qty or 0,
|
|
||||||
order_date=first_order["date"].strftime("%Y-%m-%d"), # First order date
|
|
||||||
order_qty=total_order_qty,
|
|
||||||
days_to_convert=days_diff
|
|
||||||
))
|
|
||||||
|
|
||||||
# Sort by recent sample date
|
|
||||||
return sorted(conversions, key=lambda x: x.sample_date, reverse=True)
|
|
||||||
|
|
||||||
def parse_date(date_str: str) -> Optional[datetime]:
|
# Total Order Qty (Cumulative for all valid post-sample orders)
|
||||||
if not date_str:
|
total_order_qty = sum(o["qty"] for o in valid_orders)
|
||||||
return None
|
|
||||||
val = str(date_str).strip()
|
days_diff = (first_date - s_date).days
|
||||||
# Try parsing YYYYMMDD
|
s_date_str = s_date.strftime("%Y-%m-%d")
|
||||||
if len(val) == 8 and val.isdigit():
|
|
||||||
try:
|
conversions.append(ConversionRecord(
|
||||||
return datetime.strptime(val, "%Y%m%d")
|
customer=s.customer,
|
||||||
except ValueError:
|
pn=s.pn,
|
||||||
pass
|
sample_date=s_date_str,
|
||||||
|
sample_qty=s.qty or 0,
|
||||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%d-%b-%y"):
|
order_date=first_date.strftime("%Y-%m-%d"),
|
||||||
try:
|
order_qty=first_date_qty, # Show First Order Qty ONLY
|
||||||
return datetime.strptime(str(date_str).split(' ')[0], fmt.split(' ')[0])
|
total_order_qty=total_order_qty, # Show Total Qty
|
||||||
except ValueError:
|
days_to_convert=days_diff
|
||||||
continue
|
))
|
||||||
return None
|
|
||||||
|
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)
|
@router.get("/kpi", response_model=LabKPI)
|
||||||
def get_lab_kpi(
|
def get_lab_kpi(
|
||||||
@@ -158,14 +219,13 @@ def get_lab_kpi(
|
|||||||
end_date: Optional[str] = Query(None),
|
end_date: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
# 1. 取得所有樣品與訂單
|
# Fetch Data
|
||||||
samples_query = db.query(SampleRecord)
|
samples_query = db.query(SampleRecord)
|
||||||
orders_query = db.query(OrderRecord)
|
orders_query = db.query(OrderRecord)
|
||||||
|
|
||||||
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)
|
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)
|
orders_query = orders_query.filter(OrderRecord.date <= end_date)
|
||||||
@@ -173,40 +233,8 @@ def get_lab_kpi(
|
|||||||
samples = samples_query.all()
|
samples = samples_query.all()
|
||||||
orders = orders_query.all()
|
orders = orders_query.all()
|
||||||
|
|
||||||
# 建立群組 (ERP Code + PN)
|
# Build Lookups (Same as conversions)
|
||||||
# ERP Code correspond to cust_id
|
orders_by_cust_name = {}
|
||||||
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
|
|
||||||
|
|
||||||
order_lookup_by_id = {}
|
order_lookup_by_id = {}
|
||||||
order_lookup_by_name = {}
|
order_lookup_by_name = {}
|
||||||
|
|
||||||
@@ -214,9 +242,9 @@ def get_lab_kpi(
|
|||||||
clean_pn = normalize_pn_for_matching(o.pn)
|
clean_pn = normalize_pn_for_matching(o.pn)
|
||||||
clean_cust_id = o.cust_id.strip().upper() if o.cust_id else ""
|
clean_cust_id = o.cust_id.strip().upper() if o.cust_id else ""
|
||||||
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
|
||||||
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] = []
|
||||||
@@ -225,80 +253,115 @@ def get_lab_kpi(
|
|||||||
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(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 Project Count
|
||||||
# Group Samples by (CustName, PN) for calculation to avoid double counting if multiple samples -> same order
|
unique_sample_groups = {}
|
||||||
# Actually, "Conversion Rate" is usually "Percentage of Sample Records that resulted in Order".
|
|
||||||
# Or "Percentage of Projects". Let's stick to "Sample Groups" (Unique trials).
|
|
||||||
|
|
||||||
unique_sample_groups = {} # (norm_cust_name, clean_pn) -> list of sample dates
|
|
||||||
|
|
||||||
for s in samples:
|
for s in samples:
|
||||||
clean_pn = normalize_pn_for_matching(s.pn)
|
clean_pn = normalize_pn_for_matching(s.pn)
|
||||||
norm_cust_name = normalize_customer_name(s.customer)
|
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:
|
if key not in unique_sample_groups:
|
||||||
unique_sample_groups[key] = {
|
unique_sample_groups[key] = {
|
||||||
"dates": [],
|
"dates": [],
|
||||||
"cust_ids": set()
|
"cust_ids": set(),
|
||||||
|
"raw_pns": set()
|
||||||
}
|
}
|
||||||
s_date = parse_date(s.date)
|
s_date = parse_date(s.date)
|
||||||
if s_date: unique_sample_groups[key]["dates"].append(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
|
# Calculate
|
||||||
total_samples_count = len(unique_sample_groups) # Total "Projects"
|
total_samples_count = len(unique_sample_groups)
|
||||||
converted_count = 0
|
converted_count = 0
|
||||||
|
|
||||||
orphan_count = 0
|
orphan_count = 0
|
||||||
|
velocities = []
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
|
||||||
for key, data in unique_sample_groups.items():
|
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 = []
|
matched_dates = []
|
||||||
|
|
||||||
# 1. Try via ID
|
# 1. Try ID Match
|
||||||
for cid in data["cust_ids"]:
|
for cid in data["cust_ids"]:
|
||||||
if (cid, 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, clean_pn)])
|
matched_dates.extend(order_lookup_by_id[(cid, group_clean_pn)])
|
||||||
|
|
||||||
# 2. Try via Name
|
# 2. Try Name Match
|
||||||
if not matched_dates:
|
if not matched_dates:
|
||||||
if key in order_lookup_by_name:
|
if key in order_lookup_by_name:
|
||||||
matched_dates.extend(order_lookup_by_name[key])
|
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:
|
if matched_dates:
|
||||||
converted_count += 1
|
|
||||||
# Velocity
|
|
||||||
earliest_sample = min(data["dates"]) if data["dates"] else None
|
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:
|
# STRICT FILTER: Post-Sample Orders Only
|
||||||
diff = (first_order - earliest_sample).days
|
valid_dates = []
|
||||||
if diff >= 0:
|
if earliest_sample:
|
||||||
velocities.append(diff)
|
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:
|
else:
|
||||||
# Check Orphan (No Order)
|
# Orphan Check
|
||||||
# Use earliest sample date
|
|
||||||
earliest_sample = min(data["dates"]) if data["dates"] else None
|
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:
|
if earliest_sample and (now - earliest_sample).days > 90:
|
||||||
orphan_count += 1
|
orphan_count += 1
|
||||||
|
|
||||||
avg_velocity = sum(velocities) / len(velocities) if velocities else 0
|
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
|
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(
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/scatter", response_model=List[ScatterPoint])
|
@router.get("/scatter", response_model=List[ScatterPoint])
|
||||||
@@ -318,80 +381,31 @@ def get_scatter_data(
|
|||||||
samples = samples_query.all()
|
samples = samples_query.all()
|
||||||
orders = orders_query.all()
|
orders = orders_query.all()
|
||||||
|
|
||||||
# 聚合資料
|
# Build Lookups (simplified for aggregation)
|
||||||
from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name
|
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:
|
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)
|
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 norm_cust_name not in orders_by_cust_name:
|
||||||
if clean_cust_id:
|
orders_by_cust_name[norm_cust_name] = []
|
||||||
key_id = (clean_cust_id, clean_pn)
|
orders_by_cust_name[norm_cust_name].append({
|
||||||
if key_id not in order_lookup_by_id:
|
"clean_pn": clean_pn,
|
||||||
order_lookup_by_id[key_id] = {"qty": 0, "dates": []}
|
"qty": o.qty or 0,
|
||||||
order_lookup_by_id[key_id]["qty"] += (o.qty or 0)
|
"date": o_date
|
||||||
if o.date: order_lookup_by_id[key_id]["dates"].append(parse_date(o.date) or datetime.max)
|
})
|
||||||
elif o.created_at: order_lookup_by_id[key_id]["dates"].append(o.created_at.replace(tzinfo=None))
|
|
||||||
|
|
||||||
# Aggregate by Cust Name (Fallback)
|
# Group by (Display Cust, Display PN) - but we need to match broadly
|
||||||
key_name = (norm_cust_name, clean_pn)
|
# Strategy: Group by Display Keys first, then try to find match for that group
|
||||||
if key_name not in order_lookup_by_name:
|
|
||||||
order_lookup_by_name[key_name] = {"qty": 0, "dates": []}
|
unique_groups = {} # (norm_cust, clean_pn) -> {display_cust, display_pn, sample_qty, order_qty, min_sample_date}
|
||||||
order_lookup_by_name[key_name]["qty"] += (o.qty or 0)
|
|
||||||
if o.date: order_lookup_by_name[key_name]["dates"].append(parse_date(o.date) or datetime.max)
|
|
||||||
elif o.created_at: order_lookup_by_name[key_name]["dates"].append(o.created_at.replace(tzinfo=None))
|
|
||||||
|
|
||||||
|
|
||||||
final_data_map = {} # Key (Display Customer, Original PN) -> Data
|
|
||||||
|
|
||||||
for s in samples:
|
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)
|
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)
|
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)
|
key = (norm_cust_name, clean_pn)
|
||||||
|
|
||||||
if key not in unique_groups:
|
if key not in unique_groups:
|
||||||
@@ -400,31 +414,38 @@ def get_scatter_data(
|
|||||||
"display_pn": s.pn,
|
"display_pn": s.pn,
|
||||||
"sample_qty": 0,
|
"sample_qty": 0,
|
||||||
"order_qty": 0,
|
"order_qty": 0,
|
||||||
"matched": False
|
"min_sample_date": s_date
|
||||||
}
|
}
|
||||||
unique_groups[key]["sample_qty"] += (s.qty or 0)
|
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():
|
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
|
matched_qty = 0
|
||||||
# Note: We rely on Name match here primarily since we grouped by Name.
|
|
||||||
# Ideally we should also check CustID if available on the samples in this group, but grouping by Name is safer for visual scatter plot.
|
|
||||||
|
|
||||||
matched_order = order_lookup_by_name.get((norm_cust_name, clean_pn))
|
if norm_cust_name in orders_by_cust_name:
|
||||||
|
candidates = orders_by_cust_name[norm_cust_name]
|
||||||
# If no name match, maybe check if any sample in this group had a CustId that matches?
|
for o_dat in candidates:
|
||||||
# For simplicity, let's stick to Name+PN for the Scatter Plot aggregation
|
o_pn = o_dat['clean_pn']
|
||||||
|
o_date = o_dat['date']
|
||||||
if matched_order:
|
|
||||||
data["order_qty"] = matched_order["qty"]
|
# Check Date Causality first
|
||||||
data["matched"] = True
|
if min_s_date and o_date < min_s_date:
|
||||||
|
continue
|
||||||
data_map = unique_groups # Replace old data_map logic
|
|
||||||
|
|
||||||
# 如果有訂單但沒樣品,我們在 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 [
|
return [
|
||||||
ScatterPoint(
|
ScatterPoint(
|
||||||
@@ -433,7 +454,7 @@ def get_scatter_data(
|
|||||||
sample_qty=v["sample_qty"],
|
sample_qty=v["sample_qty"],
|
||||||
order_qty=v["order_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])
|
@router.get("/orphans", response_model=List[OrphanSample])
|
||||||
@@ -441,54 +462,117 @@ def get_orphans(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 = 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()
|
orders = db.query(OrderRecord).all()
|
||||||
|
|
||||||
# Build Order Lookups (ID and Name)
|
# Build Lookup for Fast Checking
|
||||||
from app.services.fuzzy_matcher import normalize_pn_for_matching, normalize_customer_name
|
orders_by_cust_name = {}
|
||||||
|
|
||||||
order_keys_id = set()
|
|
||||||
order_keys_name = set()
|
|
||||||
|
|
||||||
for o in orders:
|
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)
|
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:
|
if norm_cust_name not in orders_by_cust_name: orders_by_cust_name[norm_cust_name] = []
|
||||||
order_keys_id.add((clean_cust_id, clean_pn))
|
orders_by_cust_name[norm_cust_name].append({
|
||||||
|
"clean_pn": clean_pn,
|
||||||
order_keys_name.add((norm_cust_name, 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:
|
for s in samples:
|
||||||
clean_pn = normalize_pn_for_matching(s.pn)
|
|
||||||
norm_cust_name = normalize_customer_name(s.customer)
|
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)
|
s_date = parse_date(s.date)
|
||||||
|
s_date_str = s_date.strftime("%Y-%m-%d") if s_date else "Unknown"
|
||||||
# Check match
|
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
|
matched = False
|
||||||
if clean_cust_id:
|
|
||||||
if (clean_cust_id, clean_pn) in order_keys_id:
|
if s_date and norm_cust_name in orders_by_cust_name:
|
||||||
matched = True
|
candidates = orders_by_cust_name[norm_cust_name]
|
||||||
|
for o_dat in candidates:
|
||||||
if not matched:
|
o_pn = o_dat['clean_pn']
|
||||||
if (norm_cust_name, clean_pn) in order_keys_name:
|
o_date = o_dat['date']
|
||||||
matched = True
|
|
||||||
|
# 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:
|
if not matched:
|
||||||
|
# Only consider old enough samples
|
||||||
if s_date and s_date < threshold_date:
|
if s_date and s_date < threshold_date:
|
||||||
orphans.append(OrphanSample(
|
# Add to group
|
||||||
customer=s.customer,
|
# We use the FIRST raw customer/pn encountered for display, or could be smarter.
|
||||||
pn=s.pn,
|
# Group Key: (norm_cust, clean_pn, order_no, date)
|
||||||
days_since_sent=(now - s_date).days,
|
key = (norm_cust_name, clean_pn, s_order_no, s_date_str)
|
||||||
order_no=s.order_no,
|
|
||||||
date=s.date
|
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)
|
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)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class DitInfo(BaseModel):
|
|||||||
|
|
||||||
class TargetInfo(BaseModel):
|
class TargetInfo(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
sample_id: Optional[str] = None
|
||||||
customer: str
|
customer: str
|
||||||
pn: str
|
pn: str
|
||||||
order_no: Optional[str]
|
order_no: Optional[str]
|
||||||
@@ -83,6 +84,7 @@ def get_results(db: Session = Depends(get_db)):
|
|||||||
if sample:
|
if sample:
|
||||||
target_info = TargetInfo(
|
target_info = TargetInfo(
|
||||||
id=sample.id,
|
id=sample.id,
|
||||||
|
sample_id=sample.sample_id,
|
||||||
customer=sample.customer,
|
customer=sample.customer,
|
||||||
pn=sample.pn,
|
pn=sample.pn,
|
||||||
order_no=sample.order_no,
|
order_no=sample.order_no,
|
||||||
@@ -93,6 +95,7 @@ def get_results(db: Session = Depends(get_db)):
|
|||||||
if order:
|
if order:
|
||||||
target_info = TargetInfo(
|
target_info = TargetInfo(
|
||||||
id=order.id,
|
id=order.id,
|
||||||
|
sample_id=order.order_id,
|
||||||
customer=order.customer,
|
customer=order.customer,
|
||||||
pn=order.pn,
|
pn=order.pn,
|
||||||
order_no=order.order_no,
|
order_no=order.order_no,
|
||||||
@@ -142,6 +145,7 @@ def review_match(match_id: int, request: ReviewRequest, db: Session = Depends(ge
|
|||||||
if sample:
|
if sample:
|
||||||
target_info = TargetInfo(
|
target_info = TargetInfo(
|
||||||
id=sample.id,
|
id=sample.id,
|
||||||
|
sample_id=sample.sample_id,
|
||||||
customer=sample.customer,
|
customer=sample.customer,
|
||||||
pn=sample.pn,
|
pn=sample.pn,
|
||||||
order_no=sample.order_no,
|
order_no=sample.order_no,
|
||||||
@@ -152,6 +156,7 @@ def review_match(match_id: int, request: ReviewRequest, db: Session = Depends(ge
|
|||||||
if order:
|
if order:
|
||||||
target_info = TargetInfo(
|
target_info = TargetInfo(
|
||||||
id=order.id,
|
id=order.id,
|
||||||
|
sample_id=order.order_id,
|
||||||
customer=order.customer,
|
customer=order.customer,
|
||||||
pn=order.pn,
|
pn=order.pn,
|
||||||
order_no=order.order_no,
|
order_no=order.order_no,
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ COLUMN_MAPPING = {
|
|||||||
'date': ['created date', '日期', 'date', '建立日期', 'create date']
|
'date': ['created date', '日期', 'date', '建立日期', 'create date']
|
||||||
},
|
},
|
||||||
'sample': {
|
'sample': {
|
||||||
'sample_id': ['樣品訂單號碼', 'item', '樣品編號', 'sample_id', 'sample id', '編號'],
|
'sample_id': ['sample_id', 'sample id', '樣品ID'],
|
||||||
'order_no': ['樣品訂單號碼', '單號', 'order_no', 'order no', '樣品單號', '申請單號'],
|
'order_no': ['樣品訂單號碼', '單號', 'order_no', 'order no', '樣品單號', '申請單號', '樣品訂單號'],
|
||||||
'oppy_no': ['oppy no', 'oppy_no', '案號', '案件編號', 'opportunity no'],
|
'oppy_no': ['oppy no', 'oppy_no', '案號', '案件編號', 'opportunity no'],
|
||||||
'cust_id': ['cust id', 'cust_id', '客戶編號', '客戶代碼', '客戶代號'],
|
'cust_id': ['cust id', 'cust_id', '客戶編號', '客戶代碼', '客戶代號'],
|
||||||
'customer': ['客戶名稱', '客戶簡稱', '客戶', 'customer', 'customer name'],
|
'customer': ['客戶名稱', '客戶簡稱', '客戶', 'customer', 'customer name'],
|
||||||
'pn': ['item', 'type', '料號', 'part number', 'pn', 'part no', '產品料號', '索樣數量'],
|
'pn': ['item', '料號', 'part number', 'pn', 'part no', '產品料號', '索樣數量', 'type'],
|
||||||
'qty': ['索樣數量pcs', '索樣數量 k', '數量', 'qty', 'quantity', '申請數量'],
|
'qty': ['索樣數量pcs', '索樣數量 k', '數量', 'qty', 'quantity', '申請數量', '索樣數量'],
|
||||||
'date': ['出貨日', '需求日', '日期', 'date', '申請日期']
|
'date': ['出貨日', '需求日', '日期', 'date', '申請日期']
|
||||||
},
|
},
|
||||||
'order': {
|
'order': {
|
||||||
|
|||||||
@@ -66,22 +66,40 @@ def normalize_customer_name(name: str) -> str:
|
|||||||
# 全形轉半形
|
# 全形轉半形
|
||||||
normalized = normalized.replace(' ', ' ')
|
normalized = normalized.replace(' ', ' ')
|
||||||
|
|
||||||
|
|
||||||
# 移除特殊結尾字符 that might remain (like "Co.,") if suffix list didn't catch it
|
# 移除特殊結尾字符 that might remain (like "Co.,") if suffix list didn't catch it
|
||||||
# Remove trailing "Co." or "Co.,"
|
# Remove trailing "Co." or "Co.,"
|
||||||
normalized = re.sub(r'[,.\s]+Co[.,]*$', '', normalized, flags=re.IGNORECASE)
|
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()
|
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
|
# Final aggressive strip of trailing punctuation
|
||||||
normalized = normalized.strip("., ")
|
normalized = normalized.strip("., ")
|
||||||
|
|
||||||
return normalized.upper()
|
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]:
|
def calculate_similarity(name1: str, name2: str) -> Tuple[float, str]:
|
||||||
"""計算兩個名稱的相似度"""
|
"""計算兩個名稱的相似度"""
|
||||||
# 正規化
|
# 正規化
|
||||||
@@ -212,7 +230,10 @@ class FuzzyMatcher:
|
|||||||
# Priority 2 & 3 則限制在相同 PN (Ignored symbols)
|
# Priority 2 & 3 則限制在相同 PN (Ignored symbols)
|
||||||
elif dit_norm_pn == normalize_pn_for_matching(sample.pn):
|
elif dit_norm_pn == normalize_pn_for_matching(sample.pn):
|
||||||
# Priority 2: 客戶代碼比對 (Silver Key)
|
# Priority 2: 客戶代碼比對 (Silver Key)
|
||||||
if dit.erp_account and sample.cust_id and dit.erp_account == sample.cust_id:
|
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_priority = 2
|
||||||
match_source = f"Matched via ERP Account: {dit.erp_account}"
|
match_source = f"Matched via ERP Account: {dit.erp_account}"
|
||||||
score = 99.0
|
score = 99.0
|
||||||
@@ -254,7 +275,10 @@ class FuzzyMatcher:
|
|||||||
reason = ""
|
reason = ""
|
||||||
|
|
||||||
# Priority 2: 客戶代碼比對 (Silver Key)
|
# 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_priority = 2
|
||||||
match_source = f"Matched via ERP Account: {dit.erp_account}"
|
match_source = f"Matched via ERP Account: {dit.erp_account}"
|
||||||
score = 99.0
|
score = 99.0
|
||||||
@@ -267,6 +291,7 @@ class FuzzyMatcher:
|
|||||||
match_priority = 3
|
match_priority = 3
|
||||||
match_source = f"Matched via Name Similarity ({reason})"
|
match_source = f"Matched via Name Similarity ({reason})"
|
||||||
|
|
||||||
|
|
||||||
if match_priority > 0:
|
if match_priority > 0:
|
||||||
status = MatchStatus.auto_matched if score >= MATCH_THRESHOLD_AUTO else MatchStatus.pending
|
status = MatchStatus.auto_matched if score >= MATCH_THRESHOLD_AUTO else MatchStatus.pending
|
||||||
match = MatchResult(
|
match = MatchResult(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models.dit import DitRecord
|
from app.models.dit import DitRecord
|
||||||
from app.models.sample import SampleRecord
|
from app.models.sample import SampleRecord
|
||||||
from app.models.order import OrderRecord
|
from app.models.order import OrderRecord
|
||||||
from app.models.match import MatchResult, MatchStatus
|
from app.models.match import MatchResult, MatchStatus, TargetType
|
||||||
|
|
||||||
class ReportGenerator:
|
class ReportGenerator:
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session):
|
||||||
@@ -40,7 +40,7 @@ class ReportGenerator:
|
|||||||
# 找到已接受的樣品匹配
|
# 找到已接受的樣品匹配
|
||||||
sample_match = self.db.query(MatchResult).filter(
|
sample_match = self.db.query(MatchResult).filter(
|
||||||
MatchResult.dit_id == dit.id,
|
MatchResult.dit_id == dit.id,
|
||||||
MatchResult.target_type == 'SAMPLE',
|
MatchResult.target_type == TargetType.SAMPLE,
|
||||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ class ReportGenerator:
|
|||||||
# 找到已接受的訂單匹配
|
# 找到已接受的訂單匹配
|
||||||
order_match = self.db.query(MatchResult).filter(
|
order_match = self.db.query(MatchResult).filter(
|
||||||
MatchResult.dit_id == dit.id,
|
MatchResult.dit_id == dit.id,
|
||||||
MatchResult.target_type == 'ORDER',
|
MatchResult.target_type == TargetType.ORDER,
|
||||||
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
MatchResult.status.in_([MatchStatus.accepted, MatchStatus.auto_matched])
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ class ReportGenerator:
|
|||||||
).first()
|
).first()
|
||||||
if order:
|
if order:
|
||||||
row['order_no'] = order.order_no
|
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
|
row['order_amount'] = order.amount
|
||||||
|
|
||||||
result.append(row)
|
result.append(row)
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|||||||
password_bytes = plain_password.encode('utf-8')
|
password_bytes = plain_password.encode('utf-8')
|
||||||
if len(password_bytes) > 72:
|
if len(password_bytes) > 72:
|
||||||
plain_password = password_bytes[:72].decode('utf-8', errors='ignore')
|
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:
|
def get_password_hash(password: str) -> str:
|
||||||
password_bytes = password.encode('utf-8')
|
password_bytes = password.encode('utf-8')
|
||||||
|
|||||||
53
backend/check_log.txt
Normal file
53
backend/check_log.txt
Normal file
@@ -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':
|
||||||
21
backend/debug_lab_orders.py
Normal file
21
backend/debug_lab_orders.py
Normal file
@@ -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}")
|
||||||
47
backend/debug_lab_v2.py
Normal file
47
backend/debug_lab_v2.py
Normal file
@@ -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 ---")
|
||||||
5
backend/debug_log.txt
Normal file
5
backend/debug_log.txt
Normal file
@@ -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 ---
|
||||||
7
backend/debug_log_v2.txt
Normal file
7
backend/debug_log_v2.txt
Normal file
@@ -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 ---
|
||||||
42
backend/debug_match_specific_v2.py
Normal file
42
backend/debug_match_specific_v2.py
Normal file
@@ -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()
|
||||||
BIN
backend/debug_out.txt
Normal file
BIN
backend/debug_out.txt
Normal file
Binary file not shown.
24
backend/verify_lab_v3.py
Normal file
24
backend/verify_lab_v3.py
Normal file
@@ -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 ---")
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { Filter, Activity, Download, Info, CheckCircle, HelpCircle, XCircle } from 'lucide-react';
|
import { Filter, Activity, Download, Info, CheckCircle, HelpCircle, XCircle } from 'lucide-react';
|
||||||
import { Card } from './common/Card';
|
import { Card } from './common/Card';
|
||||||
|
import { Tooltip } from './common/Tooltip';
|
||||||
import { dashboardApi, reportApi } from '../services/api';
|
import { dashboardApi, reportApi } from '../services/api';
|
||||||
import type { DashboardKPI, FunnelData, AttributionRow } from '../types';
|
import type { DashboardKPI, FunnelData, AttributionRow } from '../types';
|
||||||
|
|
||||||
@@ -127,22 +128,42 @@ export const DashboardView: React.FC = () => {
|
|||||||
<div className="text-[10px] text-slate-400 mt-1">Total Pipeline</div>
|
<div className="text-[10px] text-slate-400 mt-1">Total Pipeline</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 border-l-4 border-l-purple-500">
|
<Card className="p-4 border-l-4 border-l-purple-500">
|
||||||
<div className="text-xs text-slate-500 mb-1">送樣轉換率</div>
|
<div className="text-xs text-slate-500 mb-1 flex items-center gap-1">
|
||||||
|
送樣轉換率
|
||||||
|
<Tooltip content={`送樣轉換率 = (成功送樣的專案數 / DIT 總專案數) * 100%\n反映有多少比例的 DIT 案件成功進入送樣階段。`}>
|
||||||
|
<HelpCircle size={12} className="cursor-help text-slate-400 hover:text-purple-600" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<div className="text-2xl font-bold text-slate-800">{kpi.sample_rate}%</div>
|
<div className="text-2xl font-bold text-slate-800">{kpi.sample_rate}%</div>
|
||||||
<div className="text-[10px] text-purple-600 mt-1">Sample Rate</div>
|
<div className="text-[10px] text-purple-600 mt-1">Sample Rate</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 border-l-4 border-l-emerald-500">
|
<Card className="p-4 border-l-4 border-l-emerald-500">
|
||||||
<div className="text-xs text-slate-500 mb-1">訂單命中率</div>
|
<div className="text-xs text-slate-500 mb-1 flex items-center gap-1">
|
||||||
|
訂單命中率
|
||||||
|
<Tooltip content={`訂單命中率 = (取得訂單的專案數 / DIT 總專案數) * 100%\n反映多少比例的 DIT 案件最終成功取得訂單。`}>
|
||||||
|
<HelpCircle size={12} className="cursor-help text-slate-400 hover:text-emerald-600" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<div className="text-2xl font-bold text-slate-800">{kpi.hit_rate}%</div>
|
<div className="text-2xl font-bold text-slate-800">{kpi.hit_rate}%</div>
|
||||||
<div className="text-[10px] text-emerald-600 mt-1">Hit Rate (Binary)</div>
|
<div className="text-[10px] text-emerald-600 mt-1">Hit Rate (Binary)</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 border-l-4 border-l-amber-500">
|
<Card className="p-4 border-l-4 border-l-amber-500">
|
||||||
<div className="text-xs text-slate-500 mb-1">EAU 達成率</div>
|
<div className="text-xs text-slate-500 mb-1 flex items-center gap-1">
|
||||||
|
EAU 達成率
|
||||||
|
<Tooltip content={`EAU 達成率 = (歸因訂單總量 / DIT 預估總用量 EAU) * 100%\n反映實際取得的訂單量佔全體潛在商機 (EAU) 的成交比例。`}>
|
||||||
|
<HelpCircle size={12} className="cursor-help text-slate-400 hover:text-amber-600" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<div className="text-2xl font-bold text-slate-800">{kpi.fulfillment_rate}%</div>
|
<div className="text-2xl font-bold text-slate-800">{kpi.fulfillment_rate}%</div>
|
||||||
<div className="text-[10px] text-amber-600 mt-1">Fulfillment (LIFO)</div>
|
<div className="text-[10px] text-amber-600 mt-1">Fulfillment (LIFO)</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 border-l-4 border-l-rose-500">
|
<Card className="p-4 border-l-4 border-l-rose-500">
|
||||||
<div className="text-xs text-slate-500 mb-1">無訂單樣品率</div>
|
<div className="text-xs text-slate-500 mb-1 flex items-center gap-1">
|
||||||
|
無訂單樣品率
|
||||||
|
<Tooltip content={`無訂單樣品率 = (有送樣但無訂單的專案數 / 成功送樣專案數) * 100%\n反映已送樣但尚未轉單的 DIT 專案比例。`}>
|
||||||
|
<HelpCircle size={12} className="cursor-help text-slate-400 hover:text-rose-600" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<div className="text-2xl font-bold text-rose-600">{kpi.no_order_sample_rate}%</div>
|
<div className="text-2xl font-bold text-rose-600">{kpi.no_order_sample_rate}%</div>
|
||||||
<div className="text-[10px] text-rose-400 mt-1">No-Order Sample</div>
|
<div className="text-[10px] text-rose-400 mt-1">No-Order Sample</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -9,23 +9,25 @@ 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 } from '../types';
|
import type { LabKPI, ScatterPoint, OrphanSample, NoDitSample } from '../types';
|
||||||
|
|
||||||
export const LabView: React.FC = () => {
|
export const LabView: React.FC = () => {
|
||||||
const [kpi, setKpi] = useState<LabKPI>({
|
const [kpi, setKpi] = useState<LabKPI>({
|
||||||
converted_count: 0,
|
converted_count: 0,
|
||||||
avg_velocity: 0,
|
avg_velocity: 0,
|
||||||
conversion_rate: 0,
|
conversion_rate: 0,
|
||||||
orphan_count: 0
|
orphan_count: 0,
|
||||||
|
no_dit_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 [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'>('orphans');
|
const [viewMode, setViewMode] = useState<'orphans' | 'conversions' | 'no_dit'>('orphans');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLabData();
|
loadLabData();
|
||||||
@@ -46,16 +48,18 @@ export const LabView: React.FC = () => {
|
|||||||
|
|
||||||
const params = start_date ? { start_date } : {};
|
const params = start_date ? { start_date } : {};
|
||||||
|
|
||||||
const [kpiData, scatterRes, orphanRes, conversionRes] = await Promise.all([
|
const [kpiData, scatterRes, orphanRes, noDitRes, conversionRes] = await Promise.all([
|
||||||
labApi.getKPI(params),
|
labApi.getKPI(params),
|
||||||
labApi.getScatter(params),
|
labApi.getScatter(params),
|
||||||
labApi.getOrphans(),
|
labApi.getOrphans(),
|
||||||
|
labApi.getNoDitSamples(),
|
||||||
labApi.getConversions()
|
labApi.getConversions()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setKpi(kpiData);
|
setKpi(kpiData);
|
||||||
setScatterData(scatterRes);
|
setScatterData(scatterRes);
|
||||||
setOrphans(orphanRes);
|
setOrphans(orphanRes);
|
||||||
|
setNoDitSamples(noDitRes);
|
||||||
setConversions(conversionRes);
|
setConversions(conversionRes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading lab data:', error);
|
console.error('Error loading lab data:', error);
|
||||||
@@ -77,7 +81,7 @@ export const LabView: React.FC = () => {
|
|||||||
const groupInfo = React.useMemo(() => {
|
const groupInfo = React.useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
orphans.forEach(o => {
|
orphans.forEach(o => {
|
||||||
const key = `${o.customer}|${o.pn}`;
|
const key = `${o.customer?.trim()?.toUpperCase()}|${o.pn?.trim()?.toUpperCase()}`;
|
||||||
counts[key] = (counts[key] || 0) + 1;
|
counts[key] = (counts[key] || 0) + 1;
|
||||||
});
|
});
|
||||||
return counts;
|
return counts;
|
||||||
@@ -100,7 +104,7 @@ export const LabView: React.FC = () => {
|
|||||||
送樣成效分析戰情室 (Sample Conversion Lab)
|
送樣成效分析戰情室 (Sample Conversion Lab)
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-500 mt-1">
|
<p className="text-slate-500 mt-1">
|
||||||
焦點分析:樣品投資報酬率 (ROI) 與 轉換速度 | 邏輯:ERP Code + PN 直接比對
|
焦點分析:樣品投資報酬率 (ROI) | 核心邏輯:ERP Code 歸戶 + 時間因果濾網 (僅計算送樣後訂單)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -121,73 +125,92 @@ export const LabView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
<Card
|
<Card
|
||||||
onClick={() => setViewMode('conversions')}
|
onClick={() => setViewMode('conversions')}
|
||||||
className={`p-6 border-b-4 border-b-blue-500 bg-gradient-to-br from-white to-blue-50/30 cursor-pointer transition-all hover:shadow-md ${viewMode === 'conversions' ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
|
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' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-slate-500 font-medium mb-1">成功收單總數</div>
|
<div className="text-xs text-slate-500 font-medium mb-1">成功收單總數</div>
|
||||||
<div className="text-3xl font-bold text-slate-800">{kpi.converted_count} 筆</div>
|
<div className="text-2xl font-bold text-slate-800">{kpi.converted_count} 筆</div>
|
||||||
<div className="text-xs text-blue-600 mt-2 flex items-center gap-1 font-bold">
|
<div className="text-[10px] text-blue-600 mt-1 flex items-center gap-1 font-bold">
|
||||||
<Check size={12} />
|
<Check size={10} />
|
||||||
Converted Samples
|
Converted
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-blue-100 text-blue-600 rounded-xl">
|
<div className="p-2 bg-blue-100 text-blue-600 rounded-lg">
|
||||||
<TrendingUp size={24} />
|
<TrendingUp size={20} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6 border-b-4 border-b-indigo-500 bg-gradient-to-br from-white to-indigo-50/30">
|
<Card className="p-4 border-b-4 border-b-indigo-500 bg-gradient-to-br from-white to-indigo-50/30">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-slate-500 font-medium mb-1">平均轉換速度</div>
|
<div className="text-xs text-slate-500 font-medium mb-1">平均轉換速度</div>
|
||||||
<div className="text-3xl font-bold text-slate-800">{kpi.avg_velocity} 天</div>
|
<div className="text-2xl font-bold text-slate-800">{kpi.avg_velocity} 天</div>
|
||||||
<div className="text-xs text-indigo-600 mt-2 flex items-center gap-1 font-bold">
|
<div className="text-[10px] text-indigo-600 mt-1 flex items-center gap-1 font-bold">
|
||||||
<Clock size={12} />
|
<Clock size={10} />
|
||||||
Conversion Velocity
|
Avg Velocity
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-xl">
|
<div className="p-2 bg-indigo-100 text-indigo-600 rounded-lg">
|
||||||
<Clock size={24} />
|
<Clock size={20} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6 border-b-4 border-b-emerald-500 bg-gradient-to-br from-white to-emerald-50/30">
|
<Card className="p-4 border-b-4 border-b-emerald-500 bg-gradient-to-br from-white to-emerald-50/30">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-slate-500 font-medium mb-1">整體轉換倍率 (ROI)</div>
|
<div className="text-xs text-slate-500 font-medium mb-1">整體轉換倍率</div>
|
||||||
<div className="text-3xl font-bold text-slate-800">{kpi.conversion_rate}%</div>
|
<div className="text-2xl font-bold text-slate-800">{kpi.conversion_rate}%</div>
|
||||||
<div className="text-xs text-emerald-600 mt-2 flex items-center gap-1 font-bold">
|
<div className="text-[10px] text-emerald-600 mt-1 flex items-center gap-1 font-bold">
|
||||||
<Target size={12} />
|
<Target size={10} />
|
||||||
Sample to Order Ratio
|
ROI Rate
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-emerald-100 text-emerald-600 rounded-xl">
|
<div className="p-2 bg-emerald-100 text-emerald-600 rounded-lg">
|
||||||
<FlaskConical size={24} />
|
<FlaskConical size={20} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
onClick={() => setViewMode('orphans')}
|
onClick={() => setViewMode('orphans')}
|
||||||
className={`p-6 border-b-4 border-b-rose-500 bg-gradient-to-br from-white to-rose-50/30 cursor-pointer transition-all hover:shadow-md ${viewMode === 'orphans' ? 'ring-2 ring-rose-500 ring-offset-2' : ''}`}
|
className={`p-4 border-b-4 border-b-rose-500 bg-gradient-to-br from-white to-rose-50/30 cursor-pointer transition-all hover:shadow-md ${viewMode === 'orphans' ? 'ring-2 ring-rose-500 ring-offset-2' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-slate-500 font-medium mb-1">無訂單樣品總數</div>
|
<div className="text-xs text-slate-500 font-medium mb-1">無訂單樣品</div>
|
||||||
<div className="text-3xl font-bold text-rose-600">{kpi.orphan_count} 筆</div>
|
<div className="text-2xl font-bold text-rose-600">{kpi.orphan_count} 筆</div>
|
||||||
<div className="text-xs text-rose-400 mt-2 flex items-center gap-1 font-bold">
|
<div className="text-[10px] text-rose-400 mt-1 flex items-center gap-1 font-bold">
|
||||||
<AlertTriangle size={12} />
|
<AlertTriangle size={10} />
|
||||||
Wait-time > 90 Days
|
> 90 Days
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-rose-100 text-rose-600 rounded-xl">
|
<div className="p-2 bg-rose-100 text-rose-600 rounded-lg">
|
||||||
<AlertTriangle size={24} />
|
<AlertTriangle size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
onClick={() => setViewMode('no_dit')}
|
||||||
|
className={`p-4 border-b-4 border-b-amber-500 bg-gradient-to-br from-white to-amber-50/30 cursor-pointer transition-all hover:shadow-md ${viewMode === 'no_dit' ? 'ring-2 ring-amber-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-amber-600">{kpi.no_dit_count} 筆</div>
|
||||||
|
<div className="text-[10px] text-amber-600 mt-1 flex items-center gap-1 font-bold">
|
||||||
|
<HelpCircle size={10} />
|
||||||
|
> 1000 pcs (No DIT)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-amber-100 text-amber-600 rounded-lg">
|
||||||
|
<HelpCircle size={20} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -297,7 +320,7 @@ export const LabView: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Insight Card */}
|
{/* Insight Card */}
|
||||||
<Card className="p-6 bg-slate-900 text-white flex flex-col justify-between">
|
<Card className="p-6 !bg-slate-900 !border-slate-700 text-white flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-slate-100 mb-4 flex items-center gap-2">
|
<h3 className="font-bold text-slate-100 mb-4 flex items-center gap-2">
|
||||||
<Info size={18} className="text-indigo-400" />
|
<Info size={18} className="text-indigo-400" />
|
||||||
@@ -305,17 +328,17 @@ export const LabView: React.FC = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="p-3 bg-slate-800/50 rounded-lg border border-slate-700">
|
<div className="p-3 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||||
<p className="text-xs text-slate-400 mb-1">高效轉換客戶</p>
|
<p className="text-xs text-indigo-300 mb-1 font-bold">高效轉換客戶</p>
|
||||||
<p className="text-sm font-medium">識別散佈圖中「左上角」點位,代表投入少量樣品即獲得大量訂單。</p>
|
<p className="text-sm font-medium text-slate-100">識別散佈圖中「左上角」點位,代表投入少量樣品即獲得大量訂單。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-slate-800/50 rounded-lg border border-slate-700">
|
<div className="p-3 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||||
<p className="text-xs text-slate-400 mb-1">風險警示</p>
|
<p className="text-xs text-rose-300 mb-1 font-bold">風險警示</p>
|
||||||
<p className="text-sm font-medium">右下角點位代表送樣頻繁但轉換效率低,需檢視應用場景或產品適配度。</p>
|
<p className="text-sm font-medium text-slate-100">右下角點位代表送樣頻繁但轉換效率低,需檢視應用場景或產品適配度。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 p-4 bg-indigo-600/20 rounded-xl border border-indigo-500/30">
|
<div className="mt-8 p-4 bg-indigo-600/20 rounded-xl border border-indigo-500/30">
|
||||||
<p className="text-[11px] text-indigo-300 leading-relaxed italic">
|
<p className="text-[11px] text-indigo-100 leading-relaxed italic">
|
||||||
"本模組直接比對 ERP 編號,確保不因專案名稱模糊而漏失任何實際營收數據。"
|
"本模組直接比對 ERP 編號,確保不因專案名稱模糊而漏失任何實際營收數據。"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -324,13 +347,18 @@ 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' : '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' : viewMode === 'no_dit' ? 'bg-amber-50 border-amber-200' : 'bg-rose-50 border-rose-200'}`}>
|
||||||
<h3 className={`font-bold flex items-center gap-2 ${viewMode === 'conversions' ? 'text-blue-700' : 'text-rose-700'}`}>
|
<h3 className={`font-bold flex items-center gap-2 ${viewMode === 'conversions' ? 'text-blue-700' : viewMode === 'no_dit' ? 'text-amber-700' : 'text-rose-700'}`}>
|
||||||
{viewMode === 'conversions' ? (
|
{viewMode === 'conversions' ? (
|
||||||
<>
|
<>
|
||||||
<Check size={18} />
|
<Check size={18} />
|
||||||
Successful Conversions List
|
Successful Conversions List
|
||||||
</>
|
</>
|
||||||
|
) : viewMode === 'no_dit' ? (
|
||||||
|
<>
|
||||||
|
<HelpCircle size={18} />
|
||||||
|
Unattributed High-Qty Samples
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AlertTriangle size={18} />
|
<AlertTriangle size={18} />
|
||||||
@@ -345,7 +373,9 @@ export const LabView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-[10px] text-slate-400 font-medium">
|
<div className="text-[10px] text-slate-400 font-medium">
|
||||||
{viewMode === 'conversions' ? `共 ${conversions.length} 筆成功轉換` : `共 ${orphans.length} 筆待追蹤案件`}
|
{viewMode === 'conversions' ? `共 ${conversions.length} 筆成功轉換`
|
||||||
|
: viewMode === 'no_dit' ? `共 ${noDitSamples.length} 筆未歸因大單`
|
||||||
|
: `共 ${orphans.length} 筆待追蹤案件`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,11 +390,19 @@ 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">首張訂單 (Date/Qty)</th>
|
<th className="px-6 py-3">首張訂單 (Date/Qty)</th>
|
||||||
|
<th className="px-6 py-3">訂單總數 (Total Order Qty)</th>
|
||||||
<th className="px-6 py-3 text-center">轉換天數</th>
|
<th className="px-6 py-3 text-center">轉換天數</th>
|
||||||
</>
|
</>
|
||||||
|
) : viewMode === 'no_dit' ? (
|
||||||
|
<>
|
||||||
|
<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">送樣日期</th>
|
<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 text-center">狀態</th>
|
<th className="px-6 py-3 text-center">狀態</th>
|
||||||
<th className="px-6 py-3 text-right">操作</th>
|
<th className="px-6 py-3 text-right">操作</th>
|
||||||
@@ -390,6 +428,9 @@ export const LabView: React.FC = () => {
|
|||||||
<span className="font-bold text-emerald-600">{row.order_qty.toLocaleString()} pcs</span>
|
<span className="font-bold text-emerald-600">{row.order_qty.toLocaleString()} pcs</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="font-bold text-emerald-700">{row.total_order_qty ? row.total_order_qty.toLocaleString() : '-'} pcs</span>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">
|
||||||
{row.days_to_convert} 天
|
{row.days_to_convert} 天
|
||||||
@@ -397,9 +438,28 @@ export const LabView: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
|
) : viewMode === 'no_dit' ? (
|
||||||
|
noDitSamples.map((row, i) => (
|
||||||
|
<tr key={i} className="hover:bg-amber-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-amber-600">{row.qty?.toLocaleString()} pcs</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-bold bg-amber-100 text-amber-700">
|
||||||
|
請檢查 DIT 歸因
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
orphans.map((row, i) => {
|
orphans.map((row, i) => {
|
||||||
const groupKey = `${row.customer}|${row.pn}`;
|
const groupKey = `${row.customer?.trim()?.toUpperCase()}|${row.pn?.trim()?.toUpperCase()}`;
|
||||||
const isRepeated = (groupInfo[groupKey] || 0) > 1;
|
const isRepeated = (groupInfo[groupKey] || 0) > 1;
|
||||||
const isSelected = selectedGroup === groupKey;
|
const isSelected = selectedGroup === groupKey;
|
||||||
|
|
||||||
@@ -425,7 +485,15 @@ export const LabView: React.FC = () => {
|
|||||||
<td className="px-6 py-4 font-mono text-xs text-slate-600">
|
<td className="px-6 py-4 font-mono text-xs text-slate-600">
|
||||||
{row.pn}
|
{row.pn}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-slate-500">{row.date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}</td>
|
<td className="px-6 py-4 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-slate-700">{row.sample_qty?.toLocaleString() || 0} pcs</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
<span className={`font-bold ${row.days_since_sent > 180 ? 'text-rose-600' : 'text-amber-600'}`}>
|
<span className={`font-bold ${row.days_since_sent > 180 ? 'text-rose-600' : 'text-amber-600'}`}>
|
||||||
{row.days_since_sent} 天
|
{row.days_since_sent} 天
|
||||||
@@ -461,6 +529,13 @@ export const LabView: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
{viewMode === 'no_dit' && noDitSamples.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-10 text-center text-slate-400">
|
||||||
|
目前沒有 1000pcs 以上且未歸因的樣品。
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
{viewMode === 'conversions' && conversions.length === 0 && (
|
{viewMode === 'conversions' && conversions.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">
|
||||||
|
|||||||
70
frontend/src/components/common/Tooltip.tsx
Normal file
70
frontend/src/components/common/Tooltip.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { useState, useRef, ReactNode, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
content: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tooltip: React.FC<TooltipProps> = ({ content, children }) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [style, setStyle] = useState<React.CSSProperties>({});
|
||||||
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (triggerRef.current) {
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
setStyle({
|
||||||
|
left: `${rect.left + rect.width / 2}px`,
|
||||||
|
top: `${rect.top - 8}px`,
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: 99999,
|
||||||
|
transform: 'translate(-50%, -100%)',
|
||||||
|
minWidth: '200px',
|
||||||
|
maxWidth: '280px',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
updatePosition();
|
||||||
|
setIsVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional: Re-calculate on scroll/resize if visible
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
window.addEventListener('scroll', updatePosition);
|
||||||
|
window.addEventListener('resize', updatePosition);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', updatePosition);
|
||||||
|
window.removeEventListener('resize', updatePosition);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isVisible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative inline-flex items-center cursor-help"
|
||||||
|
ref={triggerRef}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={() => setIsVisible(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{isVisible && createPortal(
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 bg-slate-800 text-white text-xs rounded-md shadow-xl whitespace-pre-line text-center"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
{/* Bottom Arrow */}
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 top-full transform -translate-x-1/2 border-4 border-transparent border-t-slate-800"
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,23 +3,49 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
from { opacity: 0; }
|
from {
|
||||||
to { opacity: 1; }
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-in-from-bottom-4 {
|
@keyframes slide-in-from-bottom-4 {
|
||||||
from { transform: translateY(1rem); opacity: 0; }
|
from {
|
||||||
to { transform: translateY(0); opacity: 1; }
|
transform: translateY(1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-in-from-right-4 {
|
@keyframes slide-in-from-right-4 {
|
||||||
from { transform: translateX(1rem); opacity: 0; }
|
from {
|
||||||
to { transform: translateX(0); opacity: 1; }
|
transform: translateX(1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes zoom-in-95 {
|
@keyframes zoom-in-95 {
|
||||||
from { transform: scale(0.95); opacity: 0; }
|
from {
|
||||||
to { transform: scale(1); opacity: 1; }
|
transform: scale(0.95);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-in {
|
.animate-in {
|
||||||
@@ -55,3 +81,8 @@
|
|||||||
.duration-300 {
|
.duration-300 {
|
||||||
animation-duration: 0.3s;
|
animation-duration: 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure tooltips are not clipped by cards */
|
||||||
|
.card-tooltip-container {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
@@ -13,11 +13,13 @@ import type {
|
|||||||
LabKPI,
|
LabKPI,
|
||||||
ScatterPoint,
|
ScatterPoint,
|
||||||
OrphanSample,
|
OrphanSample,
|
||||||
ConversionRecord
|
ConversionRecord,
|
||||||
|
NoDitSample
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
|
timeout: 15000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -178,6 +180,11 @@ export const labApi = {
|
|||||||
const response = await api.get<ConversionRecord[]>('/lab/conversions');
|
const response = await api.get<ConversionRecord[]>('/lab/conversions');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getNoDitSamples: async (): Promise<NoDitSample[]> => {
|
||||||
|
const response = await api.get<NoDitSample[]>('/lab/no_dit_samples');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export interface LabKPI {
|
|||||||
avg_velocity: number;
|
avg_velocity: number;
|
||||||
conversion_rate: number;
|
conversion_rate: number;
|
||||||
orphan_count: number;
|
orphan_count: number;
|
||||||
|
no_dit_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScatterPoint {
|
export interface ScatterPoint {
|
||||||
@@ -137,6 +138,16 @@ export interface OrphanSample {
|
|||||||
days_since_sent: number;
|
days_since_sent: number;
|
||||||
order_no: string;
|
order_no: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
sample_qty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoDitSample {
|
||||||
|
sample_id: string;
|
||||||
|
customer: string;
|
||||||
|
pn: string;
|
||||||
|
order_no: string;
|
||||||
|
date: string;
|
||||||
|
qty: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConversionRecord {
|
export interface ConversionRecord {
|
||||||
|
|||||||
Reference in New Issue
Block a user