From 2aa1ae04ed95ffc2e9bd80da1835ae4b0653b70d Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 24 Jun 2026 20:46:15 +0800 Subject: [PATCH] feat: add growth category strategy board --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 + templates/ai_intelligence.html | 256 +++++++++++++++++++- tests/test_pchome_revenue_growth_service.py | 4 + 4 files changed, 259 insertions(+), 4 deletions(-) diff --git a/config.py b/config.py index 4206f15..3ae8011 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.647" +SYSTEM_VERSION = "V10.648" 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 297e0a9..417fcc5 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -83,6 +83,7 @@ - V10.645 起 `/ai_intelligence` 的商品明細分流切換後,必須顯示「這類商品怎麼處理」的行動摘要,包含件數、近 7 天業績、平均可信度、最大價差、代表商品與主按鈕;使用者不得只能看到商品列表而不知道下一步。 - V10.646 起 `/ai_intelligence` 的商品明細必須提供搜尋與排序;搜尋至少涵蓋商品、分類、商品編號與 MOMO 候選資訊,排序至少支援優先級、近 7 天業績、價差、下滑幅度與可信度。搜尋/排序後的行動摘要與明細列表必須使用同一批結果。 - V10.647 起 `/ai_intelligence` 的商品明細每一筆都必須能打開單品作戰詳情,詳情需顯示商品、建議動作、近 7 天業績、業績變化、PChome/MOMO 價格證據、價差、可信度、判斷原因與下一步操作;不得只讓使用者看一排文字後自行猜測。 +- V10.648 起 `/ai_intelligence` 的商品明細上方必須提供分類策略看板,把商品依分類彙總成可點擊的數據條列;每列至少顯示分類、近 7 天業績、商品數、價格壓力、價格優勢、缺比價、待確認與建議下一步。點擊分類後必須切到該分類商品明細。 ## 零之一、12 Agent 決策信封(2026-05-24) diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index 6090561..8a80ba7 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -787,6 +787,126 @@ background: #d8a13a; } + .growth-category-board { + border: 1px solid rgba(42, 37, 32, 0.1); + border-radius: 8px; + background: rgba(255, 255, 255, 0.68); + margin-bottom: 10px; + padding: 10px; + } + + .growth-category-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; + } + + .growth-category-title { + color: var(--momo-text-strong); + font-size: 0.84rem; + font-weight: 950; + margin: 0; + } + + .growth-category-note { + color: var(--momo-text-muted); + font-size: 0.68rem; + font-weight: 820; + text-align: right; + } + + .growth-category-list { + display: grid; + gap: 7px; + } + + .growth-category-row { + border: 1px solid rgba(42, 37, 32, 0.08); + border-radius: 8px; + background: rgba(250, 247, 240, 0.58); + color: var(--momo-text-strong); + display: grid; + grid-template-columns: minmax(160px, 1fr) minmax(260px, 1.25fr) minmax(88px, auto); + gap: 10px; + align-items: center; + padding: 9px 10px; + text-align: left; + width: 100%; + } + + .growth-category-row:hover, + .growth-category-row:focus { + border-color: rgba(172, 92, 58, 0.3); + background: rgba(255, 248, 232, 0.8); + outline: 0; + } + + .growth-category-row.is-active { + border-color: rgba(172, 92, 58, 0.38); + background: rgba(255, 248, 232, 0.9); + box-shadow: inset 3px 0 0 rgba(172, 92, 58, 0.48); + } + + .growth-category-name { + display: block; + font-size: 0.8rem; + font-weight: 950; + line-height: 1.25; + } + + .growth-category-meta, + .growth-category-action { + color: var(--momo-text-muted); + display: block; + font-size: 0.68rem; + font-weight: 850; + line-height: 1.3; + margin-top: 3px; + } + + .growth-category-bars { + display: grid; + gap: 5px; + } + + .growth-category-track { + background: rgba(42, 37, 32, 0.08); + border-radius: 999px; + height: 7px; + overflow: hidden; + } + + .growth-category-bar { + background: #ac5c3a; + border-radius: inherit; + display: block; + height: 100%; + } + + .growth-category-stats { + color: var(--momo-text-muted); + display: flex; + flex-wrap: wrap; + gap: 5px; + font-size: 0.66rem; + font-weight: 850; + line-height: 1.25; + } + + .growth-category-stats strong { + color: var(--momo-text-strong); + font-family: var(--momo-font-mono); + } + + .growth-category-cta { + color: #8f4d33; + font-size: 0.72rem; + font-weight: 950; + text-align: right; + } + .growth-decision-panel { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -1631,6 +1751,23 @@ flex: 1 1 100%; } + .growth-category-head { + align-items: flex-start; + flex-direction: column; + } + + .growth-category-note { + text-align: left; + } + + .growth-category-row { + grid-template-columns: 1fr; + } + + .growth-category-cta { + text-align: left; + } + .growth-detail-price-grid { grid-template-columns: 1fr; } @@ -1826,6 +1963,15 @@ +
+
+

分類策略看板

+ 依近 7 天業績排序,點分類看商品 +
+
+
整理分類中...
+
+

正在整理處理建議

@@ -2158,6 +2304,7 @@ let activeGrowthDetailKind = 'all'; let growthDetailSearchText = ''; let activeGrowthDetailSort = 'priority'; let activeGrowthProductKey = ''; +let activeGrowthCategoryFilter = ''; // ── 頁面載入 ──────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { @@ -2184,6 +2331,10 @@ function bindActionDelegation() { showGrowthProductDetail(growthButton.dataset.productKey || ''); return; } + if (growthButton.dataset.growthAction === 'show-category-detail') { + showGrowthCategoryDetail(growthButton.dataset.categoryName || ''); + return; + } if (growthButton.dataset.growthAction === 'review-candidate') { focusReviewCandidate(growthButton.dataset.productKey || ''); return; @@ -2705,6 +2856,7 @@ function resolveGrowthDetailKind(kind) { function growthDetailConfig(kind) { const topCategory = latestGrowthStats.top_category || ''; + const selectedCategory = activeGrowthCategoryFilter || topCategory; const configs = { all: ['今日商品明細', '依優先級排序,先看高業績、下滑或價格有壓力的商品。'], ready: ['可立即處理商品', '已有 MOMO 參考價,可以直接檢查售價、活動或曝光。'], @@ -2714,14 +2866,14 @@ function growthDetailConfig(kind) { advantage: ['PChome 價格優勢商品', 'PChome 目前較有價格優勢,適合檢查曝光與主推位置。'], source: ['外部價格來源明細', '只列出已接到 MOMO 外部參考價的商品。'], decline: ['業績下滑商品', '近 7 天比前 7 天下滑的商品。'], - category: [topCategory ? `${topCategory} 商品明細` : '最大業績分類明細', '目前最大業績分類內的商品。'], + category: [selectedCategory ? `${selectedCategory} 商品明細` : '分類商品明細', '這個分類內的商品與價格狀態。'], }; return configs[kind] || configs.all; } function growthDetailRows(kind) { const rows = Array.isArray(latestGrowthRows) ? [...latestGrowthRows] : []; - const topCategory = latestGrowthStats.top_category || ''; + const selectedCategory = activeGrowthCategoryFilter || latestGrowthStats.top_category || ''; let filtered = rows.filter((row) => { const actionCode = row.recommended_action?.code || ''; const price = row.external_price || null; @@ -2733,7 +2885,7 @@ function growthDetailRows(kind) { if (kind === 'advantage') return Boolean(price) && (actionCode === 'amplify_price_advantage' || (gap !== null && gap > 5)); if (kind === 'source') return Boolean(price); if (kind === 'decline') return Number(row.sales_delta_pct || 0) < 0; - if (kind === 'category') return topCategory && row.category === topCategory; + if (kind === 'category') return selectedCategory && row.category === selectedCategory; return true; }); @@ -2788,6 +2940,9 @@ function growthDetailRows(kind) { function showGrowthDetail(kind, shouldScroll = true) { activeGrowthDetailKind = resolveGrowthDetailKind(kind); + if (activeGrowthDetailKind !== 'category') { + activeGrowthCategoryFilter = ''; + } renderGrowthDetail(activeGrowthDetailKind); if (shouldScroll) scrollToPanel('growthDrilldownPanel'); } @@ -2933,6 +3088,100 @@ function showGrowthProductDetail(productKey) { panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } +function classifyGrowthRow(row) { + const actionCode = row?.recommended_action?.code || ''; + const price = row?.external_price || null; + const candidate = row?.review_candidate || null; + const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null; + return { + hasPrice: Boolean(price), + needs: !price && !candidate, + review: Boolean(candidate) || actionCode === 'review_external_candidate', + risk: Boolean(price) && (actionCode === 'review_price_or_promo' || (gap !== null && gap < -5)), + advantage: Boolean(price) && (actionCode === 'amplify_price_advantage' || (gap !== null && gap > 5)), + }; +} + +function summarizeGrowthCategories() { + const groups = new Map(); + (Array.isArray(latestGrowthRows) ? latestGrowthRows : []).forEach((row) => { + const category = String(row.category || '未分類').trim() || '未分類'; + if (!groups.has(category)) { + groups.set(category, { + category, + count: 0, + sales: 0, + risk: 0, + advantage: 0, + needs: 0, + review: 0, + }); + } + const item = groups.get(category); + const flags = classifyGrowthRow(row); + item.count += 1; + item.sales += Number(row.sales_7d || 0); + if (flags.risk) item.risk += 1; + if (flags.advantage) item.advantage += 1; + if (flags.needs) item.needs += 1; + if (flags.review) item.review += 1; + }); + return Array.from(groups.values()) + .sort((a, b) => b.sales - a.sales || b.count - a.count) + .slice(0, 6); +} + +function categoryActionText(item) { + if (item.risk > 0) return `先檢查 ${formatCount(item.risk)} 件價格壓力`; + if (item.advantage > 0) return `放大 ${formatCount(item.advantage)} 件價格優勢`; + if (item.review > 0) return `先確認 ${formatCount(item.review)} 筆候選`; + if (item.needs > 0) return `補齊 ${formatCount(item.needs)} 件比價`; + return '維持追蹤'; +} + +function renderGrowthCategoryBoard() { + const board = document.getElementById('growthCategoryBoard'); + if (!board) return; + const list = board.querySelector('.growth-category-list'); + if (!list) return; + + const categories = summarizeGrowthCategories(); + if (!categories.length) { + list.innerHTML = `
還沒有可整理的分類資料。
`; + return; + } + + const maxSales = Math.max(1, ...categories.map((item) => item.sales)); + list.innerHTML = categories.map((item) => { + const pct = clampPercent((item.sales / maxSales) * 100); + const activeClass = activeGrowthDetailKind === 'category' && item.category === activeGrowthCategoryFilter + ? ' is-active' + : ''; + return ``; + }).join(''); +} + +function showGrowthCategoryDetail(category) { + activeGrowthCategoryFilter = String(category || '').trim(); + if (!activeGrowthCategoryFilter) return; + showGrowthDetail('category'); +} + function renderGrowthStrategySummary() { const box = document.getElementById('growthStrategyGrid'); if (!box) return; @@ -3109,6 +3358,7 @@ function renderGrowthDetail(kind = activeGrowthDetailKind) { tab.classList.toggle('is-active', tab.dataset.detailKind === activeGrowthDetailKind); }); renderGrowthStrategySummary(); + renderGrowthCategoryBoard(); const [title, subtitle] = growthDetailConfig(activeGrowthDetailKind); const rows = growthDetailRows(activeGrowthDetailKind); diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index b2db10d..d594a34 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -475,6 +475,10 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint(): assert "showGrowthProductDetail" in template assert "growth-product-evidence-grid" in template assert "查看判斷" in template + assert "growthCategoryBoard" in template + assert "分類策略看板" in template + assert "showGrowthCategoryDetail" in template + assert "data-growth-action=\"show-category-detail\"" in template assert "scrollToPanel('externalPricePanel')" in template assert "備援資料檢查" in template assert "外部報價預檢" not in template