diff --git a/config.py b/config.py
index 4d69a8d..af1d4b5 100644
--- a/config.py
+++ b/config.py
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.640"
+SYSTEM_VERSION = "V10.641"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示
diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md
index 4cf351c..2c2355e 100644
--- a/docs/AI_INTELLIGENCE_MODULE_SOT.md
+++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md
@@ -76,6 +76,7 @@
- V10.638 起 PChome 導向 MOMO 補抓會把「找到但不能自動比價」的候選以 `match_status='needs_review'`、`data_quality_status='needs_review'` 保存到 `external_offers`;這些候選不得進價格壓力判斷,也不得發告警,但 `/api/ai/pchome-growth/opportunities` 可回傳待確認候選數,讓 UI 顯示「已有候選待確認」而不是只顯示無法比價。
- V10.639 起待確認候選排序必須容忍缺少單位數量;沒有 `momo_total_quantity` / `competitor_total_quantity` 時仍可保存為 `needs_review`,不得中斷 PChome 導向 MOMO 回填。
- V10.640 起 `/ai_intelligence` 必須提供 MOMO 待確認候選操作佇列;使用者可直接確認同款或排除候選。確認後 `external_offers` 會轉為 `verified/verified` 並進入作戰清單,排除後轉為 `rejected/rejected`,兩者都必須清掉 PChome 成長作戰清單快取。
+- V10.641 起 `/ai_intelligence` 的摘要數字不可只是靜態文字;第一屏 KPI、商品處理進度、待確認數字都必須可點擊並導向對應明細。今日清單若已有 MOMO 待確認候選,下一步必須顯示「確認候選」並跳到候選面板,不得再只顯示「補齊比價」。
## 零之一、12 Agent 決策信封(2026-05-24)
diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py
index 15c75d1..f5672cc 100644
--- a/services/pchome_revenue_growth_service.py
+++ b/services/pchome_revenue_growth_service.py
@@ -545,7 +545,104 @@ def _fetch_external_price_map(conn, pchome_product_ids: list[str]) -> dict[str,
return {**legacy_map, **normalized_map}
-def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] | None) -> dict[str, Any]:
+def _fetch_review_candidate_map(conn, pchome_product_ids: list[str]) -> dict[str, dict[str, Any]]:
+ inspector = inspect(conn)
+ if not inspector.has_table("external_offers"):
+ return {}
+ ids = [str(item).strip() for item in pchome_product_ids if str(item or "").strip()]
+ if not ids:
+ return {}
+
+ if conn.dialect.name == "postgresql":
+ sql = """
+ WITH latest_review AS (
+ SELECT DISTINCT ON (eo.pchome_product_id)
+ eo.id,
+ eo.pchome_product_id,
+ eo.source_product_id AS momo_sku,
+ eo.title AS momo_name,
+ eo.product_url,
+ eo.price AS momo_price,
+ eo.quality_score,
+ eo.raw_payload_json,
+ eo.observed_at
+ FROM external_offers eo
+ WHERE eo.source_code = 'momo_reference'
+ AND eo.ingestion_method = 'targeted_momo_review'
+ AND eo.pchome_product_id IS NOT NULL
+ AND eo.pchome_product_id IN :ids
+ AND (
+ eo.match_status = 'needs_review'
+ OR eo.data_quality_status = 'needs_review'
+ )
+ ORDER BY eo.pchome_product_id, eo.observed_at DESC NULLS LAST, eo.id DESC
+ )
+ SELECT * FROM latest_review
+ """
+ else:
+ sql = """
+ WITH latest_review AS (
+ SELECT
+ eo.id,
+ eo.pchome_product_id,
+ eo.source_product_id AS momo_sku,
+ eo.title AS momo_name,
+ eo.product_url,
+ eo.price AS momo_price,
+ eo.quality_score,
+ eo.raw_payload_json,
+ eo.observed_at,
+ ROW_NUMBER() OVER (
+ PARTITION BY eo.pchome_product_id
+ ORDER BY eo.observed_at DESC, eo.id DESC
+ ) AS rn
+ FROM external_offers eo
+ WHERE eo.source_code = 'momo_reference'
+ AND eo.ingestion_method = 'targeted_momo_review'
+ AND eo.pchome_product_id IS NOT NULL
+ AND eo.pchome_product_id IN :ids
+ AND (
+ eo.match_status = 'needs_review'
+ OR eo.data_quality_status = 'needs_review'
+ )
+ )
+ SELECT *
+ FROM latest_review
+ WHERE rn = 1
+ """
+
+ stmt = text(sql).bindparams(bindparam("ids", expanding=True))
+ rows = conn.execute(stmt, {"ids": ids}).mappings().all()
+ result: dict[str, dict[str, Any]] = {}
+ for row in rows:
+ key = str(row.get("pchome_product_id") or "").strip()
+ if not key:
+ continue
+ raw_payload = _json_dict(row.get("raw_payload_json"))
+ reasons = raw_payload.get("match_reasons") if isinstance(raw_payload.get("match_reasons"), list) else []
+ result[key] = {
+ "id": row.get("id"),
+ "pchome_product_id": key,
+ "pchome_product_name": raw_payload.get("pchome_public_name"),
+ "pchome_price": raw_payload.get("pchome_public_price"),
+ "momo_sku": row.get("momo_sku"),
+ "momo_name": row.get("momo_name"),
+ "momo_price": row.get("momo_price"),
+ "product_url": row.get("product_url"),
+ "quality_score": round(_to_float(row.get("quality_score")), 2),
+ "match_score": _match_score_from_quality(row.get("quality_score")),
+ "match_reasons": [str(reason) for reason in reasons[:5]],
+ "gap_pct": raw_payload.get("target_gap_pct"),
+ "observed_at": str(row.get("observed_at") or ""),
+ }
+ return result
+
+
+def _score_opportunity(
+ sales_row: dict[str, Any],
+ external_row: dict[str, Any] | None,
+ review_candidate: dict[str, Any] | None = None,
+) -> dict[str, Any]:
sales_7d = _to_float(sales_row.get("sales_7d"))
sales_prev_7d = _to_float(sales_row.get("sales_prev_7d"))
qty_7d = _to_float(sales_row.get("qty_7d"))
@@ -558,6 +655,7 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
decline_score = min(24, abs(sales_delta_pct) / 45 * 24) if sales_delta_pct is not None and sales_delta_pct < 0 else 0
data_quality_score = 54
external_payload = None
+ review_candidate_payload = None
action_code = "map_external_product"
action_label = "先補商品對應"
action_message = "這項商品已有業績訊號,但還沒有可確認的 MOMO 對照商品。先補對應,後續才能判斷價格壓力。"
@@ -633,8 +731,26 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
else:
reason_lines.append(f"PChome 與 MOMO {basis_prefix}幾乎相同。")
else:
- data_quality_score -= 12
- reason_lines.append("尚未找到可確認的 MOMO 對照商品。")
+ if review_candidate:
+ review_candidate_payload = {
+ "id": review_candidate.get("id"),
+ "momo_sku": review_candidate.get("momo_sku"),
+ "momo_name": review_candidate.get("momo_name"),
+ "momo_price": review_candidate.get("momo_price"),
+ "pchome_price": review_candidate.get("pchome_price"),
+ "quality_score": review_candidate.get("quality_score"),
+ "gap_pct": review_candidate.get("gap_pct"),
+ "match_reasons": review_candidate.get("match_reasons") or [],
+ "product_url": review_candidate.get("product_url"),
+ }
+ data_quality_score = max(data_quality_score, 62)
+ action_code = "review_external_candidate"
+ action_label = "確認候選"
+ action_message = "已找到 MOMO 候選,但還要確認同款、色號或組合後才能進價格判斷。"
+ reason_lines.append("已找到 MOMO 候選,先確認同款、色號或組合。")
+ else:
+ data_quality_score -= 12
+ reason_lines.append("尚未找到可確認的 MOMO 對照商品。")
if sales_delta_pct is None:
reason_lines.append("前 7 天沒有可比基準,先看近 7 天表現。")
@@ -659,7 +775,7 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
issues = []
if not external_row:
- issues.append("需要補商品對應")
+ issues.append("需要確認 MOMO 候選" if review_candidate_payload else "需要補商品對應")
if sales_delta_pct is None:
issues.append("前期業績不足")
@@ -677,6 +793,7 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
else None,
"last_sale_date": str(sales_row.get("last_sale_date") or ""),
"external_price": external_payload,
+ "review_candidate": review_candidate_payload,
"priority_score": round(priority_score, 1),
"recommended_action": {
"code": action_code,
@@ -690,6 +807,8 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
if external_payload and external_payload.get("price_basis") == "unit_price"
else "資料可直接判斷"
if external_row
+ else "候選待確認"
+ if review_candidate_payload
else "需要補資料"
),
"score": round(max(0, min(100, data_quality_score)), 1),
@@ -760,11 +879,12 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
}
sales_ids = [str(row.get("pchome_product_id") or "") for row in sales_rows]
external_map = _fetch_external_price_map(conn, sales_ids)
+ review_candidate_map = _fetch_review_candidate_map(conn, sales_ids)
opportunities = []
for row in sales_rows:
key = str(row.get("pchome_product_id") or "").strip()
- opportunities.append(_score_opportunity(row, external_map.get(key)))
+ opportunities.append(_score_opportunity(row, external_map.get(key), review_candidate_map.get(key)))
opportunities.sort(key=lambda item: item["priority_score"], reverse=True)
opportunities = opportunities[:limit]
diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html
index 7bd7a52..a281cfb 100644
--- a/templates/ai_intelligence.html
+++ b/templates/ai_intelligence.html
@@ -298,6 +298,25 @@
line-height: 1;
}
+ .growth-exec-card.is-clickable,
+ .ops-dashboard-tile.is-clickable,
+ .growth-metric.is-clickable {
+ cursor: pointer;
+ transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
+ }
+
+ .growth-exec-card.is-clickable:hover,
+ .growth-exec-card.is-clickable:focus,
+ .ops-dashboard-tile.is-clickable:hover,
+ .ops-dashboard-tile.is-clickable:focus,
+ .growth-metric.is-clickable:hover,
+ .growth-metric.is-clickable:focus {
+ border-color: rgba(172, 92, 58, 0.34);
+ box-shadow: 0 0 0 3px rgba(172, 92, 58, 0.1), var(--momo-shadow-soft);
+ outline: none;
+ transform: translateY(-1px);
+ }
+
.growth-exec-detail {
margin-top: 7px;
color: var(--momo-text-muted);
@@ -306,6 +325,17 @@
line-height: 1.4;
}
+ .drilldown-hint {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: 6px;
+ color: var(--momo-warm-rust);
+ font-size: 0.7rem;
+ font-weight: 900;
+ white-space: nowrap;
+ }
+
.ops-flow {
border: 1px solid var(--momo-border-subtle);
border-radius: 8px;
@@ -878,6 +908,13 @@
border-bottom: 0;
}
+ .review-candidate-row.is-highlight {
+ border-radius: 8px;
+ background: rgba(242, 178, 90, 0.16);
+ box-shadow: inset 3px 0 0 var(--momo-warm-caramel);
+ padding-left: 10px;
+ }
+
.review-candidate-title {
margin: 0;
color: var(--momo-text-strong);
@@ -1114,37 +1151,37 @@