feat: add growth category strategy board
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user