feat(dashboard): optimize cache and AI pick confidence
All checks were successful
CD Pipeline / deploy (push) Successful in 2m46s

This commit is contained in:
OoO
2026-05-01 16:01:52 +08:00
parent 0334051aa7
commit 3920701e1a
9 changed files with 198 additions and 27 deletions

View File

@@ -85,6 +85,8 @@ def _daily_sales_columns(conn) -> Dict[str, str]:
"date": first_available(["snapshot_date", "日期", "訂單日期", "交易日期", "Date"]),
"revenue": first_available(["總業績", "銷售金額", "業績", "金額", "Amount", "Sales", "Total"]),
"qty": first_available(["數量", "銷售數量", "銷量", "Qty", "Quantity"]),
"profit": first_available(["毛利", "Profit", "利潤"]),
"cost": first_available(["總成本", "成本", "Cost", "進價"]),
}
@@ -96,7 +98,7 @@ def _fetch_candidates(conn, limit: int) -> List[Dict[str, Any]]:
from sqlalchemy import text
sales_join = ""
sales_select = "0 AS sales_7d, 0 AS sales_prev_7d, 0 AS qty_7d"
sales_select = "0 AS sales_7d, 0 AS sales_prev_7d, 0 AS qty_7d, 0 AS profit_7d, 0 AS cost_7d"
sales_cols = {}
if _has_daily_sales_snapshot(conn):
sales_cols = _daily_sales_columns(conn)
@@ -108,6 +110,10 @@ def _fetch_candidates(conn, limit: int) -> List[Dict[str, Any]]:
date_col = _quote_identifier(sales_cols["date"])
revenue_col = _quote_identifier(sales_cols["revenue"])
qty_col = _quote_identifier(sales_cols["qty"])
profit_col = _quote_identifier(sales_cols["profit"]) if sales_cols.get("profit") else None
cost_col = _quote_identifier(sales_cols["cost"]) if sales_cols.get("cost") else None
profit_expr = f"COALESCE({profit_col}::numeric, 0)" if profit_col else "0"
cost_expr = f"COALESCE({cost_col}::numeric, 0)" if cost_col else "0"
sales_join = """
LEFT JOIN (
SELECT
@@ -118,7 +124,11 @@ def _fetch_candidates(conn, limit: int) -> List[Dict[str, Any]]:
AND {date_col}::date < CURRENT_DATE - 7
THEN COALESCE({revenue_col}::numeric, 0) ELSE 0 END) AS sales_prev_7d,
SUM(CASE WHEN {date_col}::date >= CURRENT_DATE - 7
THEN COALESCE({qty_col}::numeric, 0) ELSE 0 END) AS qty_7d
THEN COALESCE({qty_col}::numeric, 0) ELSE 0 END) AS qty_7d,
SUM(CASE WHEN {date_col}::date >= CURRENT_DATE - 7
THEN {profit_expr} ELSE 0 END) AS profit_7d,
SUM(CASE WHEN {date_col}::date >= CURRENT_DATE - 7
THEN {cost_expr} ELSE 0 END) AS cost_7d
FROM daily_sales_snapshot
GROUP BY {sku_col}
) sales ON sales.sku = lm.sku
@@ -127,11 +137,15 @@ def _fetch_candidates(conn, limit: int) -> List[Dict[str, Any]]:
date_col=date_col,
revenue_col=revenue_col,
qty_col=qty_col,
profit_expr=profit_expr,
cost_expr=cost_expr,
)
sales_select = """
COALESCE(sales.sales_7d, 0) AS sales_7d,
COALESCE(sales.sales_prev_7d, 0) AS sales_prev_7d,
COALESCE(sales.qty_7d, 0) AS qty_7d
COALESCE(sales.qty_7d, 0) AS qty_7d,
COALESCE(sales.profit_7d, 0) AS profit_7d,
COALESCE(sales.cost_7d, 0) AS cost_7d
"""
sql = text(f"""
@@ -236,7 +250,9 @@ def _fetch_candidates(conn, limit: int) -> List[Dict[str, Any]]:
NULL AS max_pchome_price,
0 AS sales_7d,
0 AS sales_prev_7d,
0 AS qty_7d
0 AS qty_7d,
0 AS profit_7d,
0 AS cost_7d
FROM latest_momo lm
JOIN competitor_prices cp
ON cp.sku = lm.sku
@@ -257,24 +273,60 @@ def _score_candidate(row: Dict[str, Any]) -> Dict[str, Any]:
sales_7d = _to_float(row.get("sales_7d"))
sales_prev_7d = _to_float(row.get("sales_prev_7d"))
qty_7d = _to_float(row.get("qty_7d"))
profit_7d = _to_float(row.get("profit_7d"))
cost_7d = _to_float(row.get("cost_7d"))
history_points = int(_to_float(row.get("history_points")))
min_pchome_price = _to_float(row.get("min_pchome_price"))
tags = _load_json_tags(row.get("tags"))
gap_pct = ((momo_price - pchome_price) / pchome_price * 100) if pchome_price else 0
sales_delta = ((sales_7d - sales_prev_7d) / sales_prev_7d * 100) if sales_prev_7d else None
if not profit_7d and cost_7d and sales_7d:
profit_7d = sales_7d - cost_7d
margin_rate = (profit_7d / sales_7d * 100) if sales_7d and profit_7d else None
price_score = max(0, min(38, gap_pct * 1.8 + 8))
match_component = max(0, min(24, match_score * 24))
price_score = max(0, min(40, gap_pct * 1.9 + 8))
match_component = max(0, min(30, match_score * 30))
sales_component = 0
if sales_7d > 0:
sales_component += min(10, sales_7d / 30000 * 10)
sales_component += min(9, sales_7d / 30000 * 9)
if qty_7d > 0:
sales_component += min(5, qty_7d / 20 * 5)
sales_component += min(4, qty_7d / 20 * 4)
if sales_delta is not None and sales_delta > 0:
sales_component += min(8, sales_delta / 40 * 8)
history_component = min(10, history_points * 2)
promo_component = 5 if any(tag in tags for tag in ["on_sale", "discount_10pct", "discount_20pct", "discount_30pct"]) else 0
score = round(min(100, price_score + match_component + sales_component + history_component + promo_component), 1)
sales_component += min(7, sales_delta / 40 * 7)
margin_component = 0
if margin_rate is not None:
margin_component = max(0, min(10, margin_rate / 35 * 10))
history_component = min(12, history_points * 2.4)
promo_component = 0
if any(tag in tags for tag in ["on_sale", "discount_10pct", "discount_20pct", "discount_30pct"]):
promo_component += 5
if "high_rating" in tags:
promo_component += 3
if "low_stock" in tags:
promo_component -= 4
price_position_component = 0
if min_pchome_price and pchome_price:
if pchome_price <= min_pchome_price * 1.03:
price_position_component = 6
elif pchome_price <= min_pchome_price * 1.08:
price_position_component = 3
opportunity_score = min(
100,
price_score + sales_component + margin_component + promo_component + price_position_component,
)
evidence_quality = min(
100,
match_component
+ history_component
+ (12 if sales_7d > 0 else 0)
+ (8 if margin_rate is not None else 0)
+ (8 if row.get("competitor_product_id") and row.get("competitor_product_name") else 0)
+ (6 if row.get("crawled_at") else 0),
)
score = round(min(100, opportunity_score + evidence_quality * 0.35), 1)
confidence = round(max(0.45, min(0.98, (score * 0.65 + evidence_quality * 0.35) / 100)), 3)
if gap_pct >= 10:
angle = "PChome 價格優勢明顯"
@@ -292,15 +344,26 @@ def _score_candidate(row: Dict[str, Any]) -> Dict[str, Any]:
]
if sales_7d > 0:
reason_parts.append(f"近 7 天銷售額 ${sales_7d:,.0f}")
if margin_rate is not None:
reason_parts.append(f"近 7 天毛利率 {margin_rate:.1f}%")
if history_points:
reason_parts.append(f"已有 {history_points} 筆 PChome 歷史快照")
if price_position_component:
reason_parts.append("目前 PChome 價格接近 30 天低點")
if "high_rating" in tags:
reason_parts.append("PChome 商品評價訊號佳")
if "low_stock" in tags:
reason_parts.append("PChome 庫存偏低,需留意供貨")
return {
**row,
"gap_pct": round(gap_pct, 1),
"sales_7d_delta": round(sales_delta, 1) if sales_delta is not None else 0,
"pick_score": score,
"confidence": round(max(0.45, min(0.98, score / 100)), 3),
"confidence": confidence,
"evidence_quality": round(evidence_quality, 1),
"opportunity_score": round(opportunity_score, 1),
"margin_rate": round(margin_rate, 1) if margin_rate is not None else None,
"reason": "".join(reason_parts),
}
@@ -315,6 +378,9 @@ def _write_pick(conn, pick: Dict[str, Any]) -> None:
"generated_at": datetime.now().isoformat(timespec="seconds"),
"inputs": ["products", "price_records", "competitor_prices", "competitor_price_history", "daily_sales_snapshot"],
"score": pick["pick_score"],
"opportunity_score": pick.get("opportunity_score"),
"evidence_quality": pick.get("evidence_quality"),
"margin_rate": pick.get("margin_rate"),
},
"competitor": {
"source": "pchome",

View File

@@ -7,7 +7,9 @@ ADR-017 Phase 3f-2: 將 sales/import/export/daily 會共同碰到的
module-level cache 收斂到這裡,避免各 route 各自持有一份 dict。
"""
import os
import time
from pathlib import Path
class FingerprintCache:
@@ -59,6 +61,8 @@ _DASHBOARD_DATA_CACHE = {
'full_timestamp': None,
}
_DASHBOARD_CACHE_TTL = 1800
_BASE_DIR = Path(__file__).resolve().parents[1]
_DASHBOARD_SHARED_CACHE_FILE = _BASE_DIR / "data" / "dashboard_full_cache.pkl"
def cleanup_sales_cache():
@@ -123,3 +127,9 @@ def clear_dashboard_cache():
'full_data': None,
'full_timestamp': None,
})
try:
os.remove(_DASHBOARD_SHARED_CACHE_FILE)
except FileNotFoundError:
pass
except OSError:
pass