From e6deaa47113d69d246fbe5c8d2f0b9c1fefd319d Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 24 Jun 2026 14:28:17 +0800 Subject: [PATCH] feat: link growth dashboard metrics to details --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 + services/pchome_revenue_growth_service.py | 130 +++++++++++++++++- templates/ai_intelligence.html | 144 +++++++++++++++++--- tests/test_pchome_revenue_growth_service.py | 7 + 5 files changed, 258 insertions(+), 26 deletions(-) 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 @@
-
+
今日任務
整理中
-
正在讀取 PChome 業績與 MOMO 外部價格。
+
正在讀取 PChome 業績與 MOMO 外部價格。看明細
-
+
可立即處理
-
已有可用比價資料
+
已有可用比價資料看清單
-
+
待補比價
-
有業績但缺外部參考
+
有業績但缺外部參考看商品
-
+
最新業績日
-
等待資料
+
等待資料看清單
@@ -1167,7 +1204,7 @@
-
+
商品處理進度 —% @@ -1187,7 +1224,7 @@
-
+
外部價格來源 @@ -1241,19 +1278,19 @@
-
+
追蹤商品
-
+
可立即處理
-
+
無法比價
-
+
待確認
@@ -1542,6 +1579,10 @@ function bindActionDelegation() { backfillPchomeMatches(); return; } + if (growthButton.dataset.growthAction === 'review-candidate') { + focusReviewCandidate(growthButton.dataset.productKey || ''); + return; + } focusPriceTable(growthButton.dataset.productKey || ''); }); } @@ -1555,6 +1596,22 @@ function focusPriceTable(keyword) { scrollToPanel('externalPricePanel'); } +function focusReviewCandidate(productKey) { + scrollToPanel('growthReviewPanel'); + const key = String(productKey || '').trim(); + if (!key) return; + setTimeout(() => { + document.querySelectorAll('.review-candidate-row.is-highlight') + .forEach((row) => row.classList.remove('is-highlight')); + const target = Array.from(document.querySelectorAll('.review-candidate-row')) + .find((row) => row.dataset.pchomeId === key); + if (target) { + target.classList.add('is-highlight'); + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 220); +} + async function loadDashboard() { try { const res = await fetch('/api/ai/icaim/dashboard'); @@ -1627,6 +1684,12 @@ function scrollToPanel(panelId) { panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); } +function handleDrilldownKey(event, panelId) { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + scrollToPanel(panelId); +} + function clampPercent(value) { return Math.max(0, Math.min(100, Number(value || 0))); } @@ -1680,6 +1743,7 @@ function renderOpsCommandDashboard(stats, scope = {}) { const candidateCount = Number(stats.candidate_count || 0); const mappedCount = Number(stats.mapped_count || 0); const needsMapping = Number(stats.needs_mapping_count || 0); + const reviewCandidateCount = Number(stats.review_candidate_count || 0); const readyRate = candidateCount ? Math.round((mappedCount / candidateCount) * 100) : 0; document.getElementById('opsReadyRate').textContent = `${readyRate}%`; @@ -1689,7 +1753,7 @@ function renderOpsCommandDashboard(stats, scope = {}) { document.getElementById('opsDashboardStatus').textContent = candidateCount ? `資料就緒率 ${readyRate}%` : '等待 PChome 業績資料'; - renderNextAction(candidateCount, mappedCount, needsMapping); + renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandidateCount); setWidth('opsFunnelCandidateBar', candidateCount ? 100 : 0); setWidth('opsFunnelMappedBar', candidateCount ? (mappedCount / candidateCount) * 100 : 0); @@ -1698,7 +1762,7 @@ function renderOpsCommandDashboard(stats, scope = {}) { renderGrowthExecutiveSummary(stats); } -function renderNextAction(candidateCount, mappedCount, needsMapping) { +function renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandidateCount = 0) { const title = document.getElementById('nextActionTitle'); const reason = document.getElementById('nextActionReason'); const button = document.getElementById('nextActionButton'); @@ -1714,6 +1778,14 @@ function renderNextAction(candidateCount, mappedCount, needsMapping) { } if (needsMapping > 0 && mappedCount === 0) { + if (reviewCandidateCount > 0) { + title.textContent = `今天先做:確認 ${formatCount(reviewCandidateCount)} 筆 MOMO 候選`; + reason.textContent = '候選已找到,先確認同款或排除,確認後才會進入價格判斷。'; + button.textContent = '確認候選'; + delete button.dataset.action; + button.onclick = () => scrollToPanel('growthReviewPanel'); + return; + } title.textContent = `今天先做:補齊 ${formatCount(needsMapping)} 件比價資料`; reason.textContent = '這些商品有業績,但目前還看不到 MOMO 參考價,請先補齊比價資料。'; button.textContent = '補齊比價資料'; @@ -1723,6 +1795,14 @@ function renderNextAction(candidateCount, mappedCount, needsMapping) { } if (needsMapping > mappedCount) { + if (reviewCandidateCount > 0) { + title.textContent = `今天先做:確認 ${formatCount(reviewCandidateCount)} 筆 MOMO 候選`; + reason.textContent = '先把已找到的候選變成可用資料,再補剩下找不到同款的商品。'; + button.textContent = '確認候選'; + delete button.dataset.action; + button.onclick = () => scrollToPanel('growthReviewPanel'); + return; + } title.textContent = `今天先做:補齊 ${formatCount(needsMapping)} 件比價資料`; reason.textContent = '目前無法比價的商品比可處理商品多,請先補齊商品對應。'; button.textContent = '補齊比價資料'; @@ -1742,6 +1822,7 @@ function renderGrowthExecutiveSummary(stats = {}) { const candidateCount = Number(stats.candidate_count || 0); const mappedCount = Number(stats.mapped_count || 0); const needsMapping = Number(stats.needs_mapping_count || 0); + const reviewCandidateCount = Number(stats.review_candidate_count || 0); const latestSalesDate = String(stats.latest_sales_date || '').slice(0, 10); document.getElementById('growthExecReady').textContent = formatCount(mappedCount); @@ -1762,6 +1843,12 @@ function renderGrowthExecutiveSummary(stats = {}) { } if (needsMapping > 0 && mappedCount === 0) { + if (reviewCandidateCount > 0) { + task.textContent = `先確認 ${formatCount(reviewCandidateCount)} 筆候選`; + detail.textContent = '候選商品已找到,確認同款後才能進入價格判斷。'; + gapCard?.classList.add('is-gap'); + return; + } task.textContent = `先補 ${formatCount(needsMapping)} 件 MOMO 參考`; detail.textContent = '高業績商品還不能比價,先補對應資料才會有可行動建議。'; gapCard?.classList.add('is-gap'); @@ -1769,6 +1856,12 @@ function renderGrowthExecutiveSummary(stats = {}) { } if (needsMapping > mappedCount) { + if (reviewCandidateCount > 0) { + task.textContent = `先確認 ${formatCount(reviewCandidateCount)} 筆候選`; + detail.textContent = '先處理候選清單,再補剩下找不到同款的商品。'; + gapCard?.classList.add('is-gap'); + return; + } task.textContent = `先補比價,再處理 ${formatCount(mappedCount)} 件`; detail.textContent = '待補比價比可處理商品多,先擴大 MOMO 對應覆蓋率。'; gapCard?.classList.add('is-gap'); @@ -1962,9 +2055,12 @@ function renderGrowthOps(rows) { const action = row.recommended_action || {}; const reason = (row.reason_lines || []).slice(0, 2).join(' '); const price = row.external_price; + const reviewCandidate = row.review_candidate || null; const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null; const basisLabel = price?.price_basis_label || '商品總價'; - const priceText = gap === null + const priceText = reviewCandidate + ? '候選待確認' + : gap === null ? '資料不足,先補比價' : gap < 0 ? `${basisLabel} PChome 貴 ${Math.abs(gap).toFixed(1)}%` @@ -1980,8 +2076,16 @@ function renderGrowthOps(rows) { const qualityLabel = quality.label || (price ? '可直接參考' : '資料不足'); const qualityIssues = Array.isArray(quality.issues) ? quality.issues.join('、') : ''; const productKey = escapeHtml(row.pchome_product_id || row.product_name || ''); - const nextLabel = action.code === 'map_external_product' ? '補齊比價' : '檢查價格'; - const nextAction = action.code === 'map_external_product' ? 'backfill' : 'focus-price'; + const nextLabel = action.code === 'review_external_candidate' + ? '確認候選' + : action.code === 'map_external_product' + ? '補齊比價' + : '檢查價格'; + const nextAction = action.code === 'review_external_candidate' + ? 'review-candidate' + : action.code === 'map_external_product' + ? 'backfill' + : 'focus-price'; return ` ${priority} @@ -2073,7 +2177,7 @@ function renderGrowthReviewCandidates(rows) { const pchomePrice = row.pchome_price ? formatMoney(row.pchome_price) : '未取得 PChome 價格'; const safeUrl = safeHttpUrl(row.product_url); const url = safeUrl ? `看 MOMO` : ''; - return `
+ return `

${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}

diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index 49b3cb3..e105549 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -426,6 +426,13 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint(): assert "MOMO 待確認候選" in template assert "確認同款" in template assert "不是同款" in template + assert "review_external_candidate" in template + assert "focusReviewCandidate" in template + assert "handleDrilldownKey" in template + assert "drilldown-hint" in template + assert "候選待確認" in template + assert "看明細" in template + assert "data-pchome-id" in template assert "今日重點總覽" in template assert "nextActionTitle" in template assert "商品處理進度" in template