From 2d9acfdc5c9b7a4ae2f5cf9934d38110121e12fe Mon Sep 17 00:00:00 2001
From: ogt
Date: Wed, 24 Jun 2026 19:59:46 +0800
Subject: [PATCH] feat: show price evidence in growth details
---
config.py | 2 +-
docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 +
templates/ai_intelligence.html | 149 ++++++++++++++++++--
tests/test_pchome_revenue_growth_service.py | 4 +
4 files changed, 147 insertions(+), 9 deletions(-)
diff --git a/config.py b/config.py
index 4c1dafa..d8a7d61 100644
--- a/config.py
+++ b/config.py
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.643"
+SYSTEM_VERSION = "V10.644"
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 e72d85d..727f760 100644
--- a/docs/AI_INTELLIGENCE_MODULE_SOT.md
+++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md
@@ -79,6 +79,7 @@
- V10.641 起 `/ai_intelligence` 的摘要數字不可只是靜態文字;第一屏 KPI、商品處理進度、待確認數字都必須可點擊並導向對應明細。今日清單若已有 MOMO 待確認候選,下一步必須顯示「確認候選」並跳到候選面板,不得再只顯示「補齊比價」。
- V10.642 起 `/ai_intelligence` 的摘要卡與商品處理數字不可只跳到大區塊;點擊後必須開啟商品明細面板,列出商品名稱、分類、近 7 天業績、業績變化、MOMO 比價狀態與下一步按鈕。明細需至少支援全部、價格壓力、價格優勢、待確認、缺比價與有外部價切換;外部價格風險分佈也必須能一鍵篩選下方表格。
- V10.643 起 `/ai_intelligence` 的商品明細上方必須提供「商品策略分流」視覺摘要,至少包含價格壓力、價格優勢、待確認、缺比價四類;每一類需顯示件數、近 7 天業績與比例條,且可點擊切換明細。舊 KPI 卡也不得是靜態數字,需可導向全部商品、可處理商品、高風險比價或處理紀錄。
+- V10.644 起 `/ai_intelligence` 的商品明細列不得只用句子描述比價;每列必須顯示 PChome 價格、MOMO 參考價、差距、可信度四格價格證據,並保留下一步按鈕。單位價候選需顯示單位價與單位,候選待確認或缺資料則以「待補 / 候選待確認」呈現,不得捏造價格。
## 零之一、12 Agent 決策信封(2026-05-24)
diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html
index 4f329ca..bbfa910 100644
--- a/templates/ai_intelligence.html
+++ b/templates/ai_intelligence.html
@@ -798,7 +798,7 @@
.growth-detail-row {
display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(118px, auto);
+ grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
gap: 12px;
border-bottom: 1px solid rgba(42, 37, 32, 0.08);
padding: 11px 12px;
@@ -827,7 +827,61 @@
display: grid;
gap: 6px;
align-content: start;
- justify-items: end;
+ justify-items: stretch;
+ }
+
+ .growth-detail-action .table-row-action {
+ justify-self: end;
+ }
+
+ .growth-detail-price-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 7px;
+ }
+
+ .growth-price-chip {
+ border: 1px solid rgba(42, 37, 32, 0.1);
+ border-radius: 8px;
+ background: rgba(250, 247, 240, 0.62);
+ padding: 7px 8px;
+ }
+
+ .growth-price-chip.is-risk {
+ border-color: rgba(185, 79, 58, 0.22);
+ background: rgba(255, 244, 239, 0.86);
+ }
+
+ .growth-price-chip.is-good {
+ border-color: rgba(47, 143, 102, 0.2);
+ background: rgba(238, 249, 241, 0.88);
+ }
+
+ .growth-price-label {
+ display: block;
+ color: var(--momo-text-muted);
+ font-size: 0.68rem;
+ font-weight: 900;
+ line-height: 1.2;
+ }
+
+ .growth-price-value {
+ display: block;
+ margin-top: 3px;
+ color: var(--momo-text-strong);
+ font-family: var(--momo-font-mono);
+ font-size: 0.9rem;
+ font-weight: 900;
+ line-height: 1.15;
+ }
+
+ .growth-price-note {
+ display: block;
+ margin-top: 2px;
+ color: var(--momo-text-muted);
+ font-size: 0.66rem;
+ font-weight: 760;
+ line-height: 1.25;
}
.growth-source-list {
@@ -1326,6 +1380,14 @@
.growth-detail-action {
justify-items: stretch;
}
+
+ .growth-detail-action .table-row-action {
+ justify-self: stretch;
+ }
+
+ .growth-detail-price-grid {
+ grid-template-columns: 1fr;
+ }
}
{% endblock %}
@@ -1924,6 +1986,37 @@ function formatMoney(value) {
return 'NT$ ' + Math.round(num).toLocaleString();
}
+function formatPriceAmount(value, options = {}) {
+ if (value === null || value === undefined || value === '') return '待補';
+ const num = Number(value);
+ if (!Number.isFinite(num) || num <= 0) return '待補';
+ const decimals = options.decimals ?? (num < 100 ? 2 : 0);
+ return 'NT$ ' + num.toLocaleString('zh-TW', {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ });
+}
+
+function formatGrowthDetailPrice(price, side) {
+ if (!price) return '待補';
+ const isUnit = price.price_basis === 'unit_price';
+ const unit = price.unit_label ? ` / ${price.unit_label}` : '';
+ if (isUnit) {
+ const value = side === 'pchome' ? price.pchome_unit_price : price.momo_unit_price;
+ return `${formatPriceAmount(value, { decimals: 2 })}${unit}`;
+ }
+ const value = side === 'pchome' ? price.pchome_price : price.momo_price;
+ return formatPriceAmount(value);
+}
+
+function formatGapDisplay(gap) {
+ if (gap === null || gap === undefined || !Number.isFinite(Number(gap))) return '待判斷';
+ const num = Number(gap);
+ if (num < 0) return `PChome 貴 ${Math.abs(num).toFixed(1)}%`;
+ if (num > 0) return `PChome 便宜 ${num.toFixed(1)}%`;
+ return '價格接近';
+}
+
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&',
@@ -2457,14 +2550,33 @@ function renderGrowthDetail(kind = activeGrowthDetailKind) {
? '候選待確認'
: gap === null
? '缺 MOMO 參考'
- : gap < 0
- ? `PChome 貴 ${Math.abs(gap).toFixed(1)}%`
- : gap > 0
- ? `PChome 便宜 ${gap.toFixed(1)}%`
- : '價格差不多';
+ : formatGapDisplay(gap);
const delta = row.sales_delta_pct === null || row.sales_delta_pct === undefined
? '前期不足'
: `${Number(row.sales_delta_pct).toFixed(1)}%`;
+ const qualityScore = row.data_quality?.score !== null && row.data_quality?.score !== undefined
+ ? Math.round(Number(row.data_quality.score || 0))
+ : reviewCandidate?.quality_score !== null && reviewCandidate?.quality_score !== undefined
+ ? Math.round(Number(reviewCandidate.quality_score || 0))
+ : price?.match_score
+ ? Math.round(Number(price.match_score || 0) * 100)
+ : 0;
+ const pchomeDisplay = price
+ ? formatGrowthDetailPrice(price, 'pchome')
+ : reviewCandidate
+ ? formatPriceAmount(reviewCandidate.pchome_price)
+ : '待補';
+ const momoDisplay = price
+ ? formatGrowthDetailPrice(price, 'momo')
+ : reviewCandidate
+ ? formatPriceAmount(reviewCandidate.momo_price)
+ : '待補';
+ const priceBasis = price?.price_basis_label || (reviewCandidate ? '候選價' : '待補資料');
+ const gapChipClass = gap !== null && gap < 0
+ ? ' is-risk'
+ : gap !== null && gap > 0
+ ? ' is-good'
+ : '';
const productKey = escapeHtml(row.pchome_product_id || row.product_name || '');
const nextAction = action.code === 'review_external_candidate'
? 'review-candidate'
@@ -2487,7 +2599,28 @@ function renderGrowthDetail(kind = activeGrowthDetailKind) {
-
${escapeHtml(action.label || '待判斷')}
+
+
+ PChome
+ ${escapeHtml(pchomeDisplay)}
+ ${escapeHtml(priceBasis)}
+
+
+ MOMO
+ ${escapeHtml(momoDisplay)}
+ ${escapeHtml(price?.data_source_label || (reviewCandidate ? '候選待確認' : '等待補抓'))}
+
+
+ 差距
+ ${escapeHtml(gapText)}
+ ${escapeHtml(action.label || '待判斷')}
+
+
+ 可信度
+ ${qualityScore ? `${qualityScore}%` : '待補'}
+ ${escapeHtml(row.data_quality?.label || '資料待確認')}
+
+
`;
diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py
index 35c7dc1..c9efcf5 100644
--- a/tests/test_pchome_revenue_growth_service.py
+++ b/tests/test_pchome_revenue_growth_service.py
@@ -457,6 +457,10 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
assert "商品策略分流" in template
assert "growth-strategy-card" in template
assert "aiRecsPanel" in template
+ assert "growth-detail-price-grid" in template
+ assert "growth-price-chip" in template
+ assert "formatGrowthDetailPrice" in template
+ assert "formatGapDisplay" in template
assert "scrollToPanel('externalPricePanel')" in template
assert "備援資料檢查" in template
assert "外部報價預檢" not in template