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