feat: add growth category strategy board
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s

This commit is contained in:
ogt
2026-06-24 20:46:15 +08:00
parent b87931c911
commit 2aa1ae04ed
4 changed files with 259 additions and 4 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -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

View File

@@ -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 @@
<span class="growth-strategy-track"><span class="growth-strategy-bar"></span></span>
</button>
</div>
<div class="growth-category-board" id="growthCategoryBoard" aria-label="分類策略看板">
<div class="growth-category-head">
<h3 class="growth-category-title">分類策略看板</h3>
<span class="growth-category-note">依近 7 天業績排序,點分類看商品</span>
</div>
<div class="growth-category-list">
<div class="text-center py-3 text-muted">整理分類中...</div>
</div>
</div>
<div class="growth-decision-panel" id="growthDecisionSummary">
<div>
<h3 class="growth-decision-title">正在整理處理建議</h3>
@@ -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 = `<div class="text-center py-3 text-muted">還沒有可整理的分類資料。</div>`;
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 `<button type="button" class="growth-category-row${activeClass}" data-growth-action="show-category-detail" data-category-name="${escapeHtml(item.category)}">
<span>
<span class="growth-category-name">${escapeHtml(item.category)}</span>
<span class="growth-category-meta">${formatCount(item.count)} 件 · 近 7 天 ${escapeHtml(formatMoney(item.sales))}</span>
</span>
<span class="growth-category-bars">
<span class="growth-category-track"><span class="growth-category-bar" style="width:${pct}%"></span></span>
<span class="growth-category-stats">
<span>壓力 <strong>${formatCount(item.risk)}</strong></span>
<span>優勢 <strong>${formatCount(item.advantage)}</strong></span>
<span>缺比價 <strong>${formatCount(item.needs)}</strong></span>
<span>待確認 <strong>${formatCount(item.review)}</strong></span>
</span>
</span>
<span class="growth-category-cta">${escapeHtml(categoryActionText(item))}</span>
</button>`;
}).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);

View File

@@ -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