feat(dashboard): optimize cache and AI pick confidence
All checks were successful
CD Pipeline / deploy (push) Successful in 2m46s
All checks were successful
CD Pipeline / deploy (push) Successful in 2m46s
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user