feat: add growth detail search and sort
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s

This commit is contained in:
ogt
2026-06-24 20:23:25 +08:00
parent 7180c0f817
commit 7cfca93754
4 changed files with 137 additions and 3 deletions

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.645"
SYSTEM_VERSION = "V10.646"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -81,6 +81,7 @@
- V10.643 起 `/ai_intelligence` 的商品明細上方必須提供「商品策略分流」視覺摘要,至少包含價格壓力、價格優勢、待確認、缺比價四類;每一類需顯示件數、近 7 天業績與比例條,且可點擊切換明細。舊 KPI 卡也不得是靜態數字,需可導向全部商品、可處理商品、高風險比價或處理紀錄。
- V10.644 起 `/ai_intelligence` 的商品明細列不得只用句子描述比價;每列必須顯示 PChome 價格、MOMO 參考價、差距、可信度四格價格證據,並保留下一步按鈕。單位價候選需顯示單位價與單位,候選待確認或缺資料則以「待補 / 候選待確認」呈現,不得捏造價格。
- V10.645 起 `/ai_intelligence` 的商品明細分流切換後,必須顯示「這類商品怎麼處理」的行動摘要,包含件數、近 7 天業績、平均可信度、最大價差、代表商品與主按鈕;使用者不得只能看到商品列表而不知道下一步。
- V10.646 起 `/ai_intelligence` 的商品明細必須提供搜尋與排序;搜尋至少涵蓋商品、分類、商品編號與 MOMO 候選資訊,排序至少支援優先級、近 7 天業績、價差、下滑幅度與可信度。搜尋/排序後的行動摘要與明細列表必須使用同一批結果。
## 零之一、12 Agent 決策信封2026-05-24

View File

@@ -837,6 +837,43 @@
font-family: var(--momo-font-mono);
}
.growth-detail-controls {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.42fr) auto;
gap: 8px;
align-items: end;
margin-bottom: 10px;
}
.growth-detail-field {
display: grid;
gap: 5px;
}
.growth-detail-field span {
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 900;
}
.growth-detail-control {
width: 100%;
border: 1px solid rgba(42, 37, 32, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.86);
color: var(--momo-text-strong);
font-size: 0.78rem;
font-weight: 800;
min-height: 34px;
padding: 7px 10px;
}
.growth-detail-control:focus {
border-color: rgba(172, 92, 58, 0.36);
box-shadow: 0 0 0 3px rgba(172, 92, 58, 0.1);
outline: 0;
}
.growth-detail-result {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
@@ -1443,6 +1480,10 @@
width: 100%;
}
.growth-detail-controls {
grid-template-columns: 1fr;
}
.growth-detail-price-grid {
grid-template-columns: 1fr;
}
@@ -1645,6 +1686,23 @@
</div>
<button type="button" class="btn btn-sm btn-outline-primary table-row-action" onclick="showGrowthDetail('all')">查看明細</button>
</div>
<div class="growth-detail-controls" aria-label="商品明細工具">
<label class="growth-detail-field">
<span>搜尋商品</span>
<input type="search" class="growth-detail-control" id="growthDetailSearch" placeholder="商品、分類、編號" oninput="setGrowthDetailSearch(this.value)">
</label>
<label class="growth-detail-field">
<span>排序</span>
<select class="growth-detail-control" id="growthDetailSort" onchange="setGrowthDetailSort(this.value)">
<option value="priority">優先級高到低</option>
<option value="sales">近 7 天業績高到低</option>
<option value="gap">價差大到小</option>
<option value="decline">下滑多到少</option>
<option value="quality">可信度高到低</option>
</select>
</label>
<button type="button" class="btn btn-sm btn-outline-secondary table-row-action" onclick="clearGrowthDetailFilters()">清除</button>
</div>
<div class="growth-detail-result" id="growthDrilldownResult">
<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>整理商品明細中...
@@ -1949,6 +2007,8 @@ let allCompetitors = [];
let latestGrowthStats = {};
let latestGrowthRows = [];
let activeGrowthDetailKind = 'all';
let growthDetailSearchText = '';
let activeGrowthDetailSort = 'priority';
// ── 頁面載入 ────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
@@ -2509,7 +2569,7 @@ function growthDetailConfig(kind) {
function growthDetailRows(kind) {
const rows = Array.isArray(latestGrowthRows) ? [...latestGrowthRows] : [];
const topCategory = latestGrowthStats.top_category || '';
const filtered = rows.filter((row) => {
let filtered = rows.filter((row) => {
const actionCode = row.recommended_action?.code || '';
const price = row.external_price || null;
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
@@ -2524,7 +2584,53 @@ function growthDetailRows(kind) {
return true;
});
return filtered.sort((a, b) => Number(b.priority_score || 0) - Number(a.priority_score || 0));
const search = String(growthDetailSearchText || '').trim().toLowerCase();
if (search) {
filtered = filtered.filter((row) => {
const price = row.external_price || {};
const candidate = row.review_candidate || {};
const haystack = [
row.product_name,
row.category,
row.vendor,
row.pchome_product_id,
row.recommended_action?.label,
price.momo_name,
price.momo_sku,
candidate.momo_name,
candidate.momo_sku,
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(search);
});
}
const gapValue = (row) => {
const gap = row.external_price?.gap_pct;
return gap === null || gap === undefined || !Number.isFinite(Number(gap)) ? null : Number(gap);
};
const declineValue = (row) => {
const value = row.sales_delta_pct;
return value === null || value === undefined || !Number.isFinite(Number(value)) ? null : Number(value);
};
const nullLast = (a, b, getter, direction = 'desc') => {
const av = getter(a);
const bv = getter(b);
if (av === null && bv === null) return Number(b.priority_score || 0) - Number(a.priority_score || 0);
if (av === null) return 1;
if (bv === null) return -1;
return direction === 'asc' ? av - bv : bv - av;
};
return filtered.sort((a, b) => {
if (activeGrowthDetailSort === 'sales') return Number(b.sales_7d || 0) - Number(a.sales_7d || 0);
if (activeGrowthDetailSort === 'gap') return nullLast(a, b, (row) => {
const gap = gapValue(row);
return gap === null ? null : Math.abs(gap);
});
if (activeGrowthDetailSort === 'decline') return nullLast(a, b, declineValue, 'asc');
if (activeGrowthDetailSort === 'quality') return rowQualityScore(b) - rowQualityScore(a);
return Number(b.priority_score || 0) - Number(a.priority_score || 0);
});
}
function showGrowthDetail(kind, shouldScroll = true) {
@@ -2533,6 +2639,27 @@ function showGrowthDetail(kind, shouldScroll = true) {
if (shouldScroll) scrollToPanel('growthDrilldownPanel');
}
function setGrowthDetailSearch(value) {
growthDetailSearchText = String(value || '').trim().toLowerCase();
renderGrowthDetail(activeGrowthDetailKind);
}
function setGrowthDetailSort(value) {
const allowed = new Set(['priority', 'sales', 'gap', 'decline', 'quality']);
activeGrowthDetailSort = allowed.has(value) ? value : 'priority';
renderGrowthDetail(activeGrowthDetailKind);
}
function clearGrowthDetailFilters() {
growthDetailSearchText = '';
activeGrowthDetailSort = 'priority';
const search = document.getElementById('growthDetailSearch');
const sort = document.getElementById('growthDetailSort');
if (search) search.value = '';
if (sort) sort.value = 'priority';
renderGrowthDetail(activeGrowthDetailKind);
}
function renderGrowthStrategySummary() {
const box = document.getElementById('growthStrategyGrid');
if (!box) return;

View File

@@ -465,6 +465,12 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
assert "renderGrowthDecisionSummary" in template
assert "growth-decision-metric" in template
assert "最大價差" in template
assert "growthDetailSearch" in template
assert "growthDetailSort" in template
assert "setGrowthDetailSearch" in template
assert "setGrowthDetailSort" in template
assert "clearGrowthDetailFilters" in template
assert "價差大到小" in template
assert "scrollToPanel('externalPricePanel')" in template
assert "備援資料檢查" in template
assert "外部報價預檢" not in template