feat: show price evidence in growth details
All checks were successful
CD Pipeline / deploy (push) Successful in 1m12s

This commit is contained in:
ogt
2026-06-24 19:59:46 +08:00
parent 776a7dd4ea
commit 2d9acfdc5c
4 changed files with 147 additions and 9 deletions

View File

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

View File

@@ -79,6 +79,7 @@
- V10.641 起 `/ai_intelligence` 的摘要數字不可只是靜態文字;第一屏 KPI、商品處理進度、待確認數字都必須可點擊並導向對應明細。今日清單若已有 MOMO 待確認候選,下一步必須顯示「確認候選」並跳到候選面板,不得再只顯示「補齊比價」。
- V10.642 起 `/ai_intelligence` 的摘要卡與商品處理數字不可只跳到大區塊;點擊後必須開啟商品明細面板,列出商品名稱、分類、近 7 天業績、業績變化、MOMO 比價狀態與下一步按鈕。明細需至少支援全部、價格壓力、價格優勢、待確認、缺比價與有外部價切換;外部價格風險分佈也必須能一鍵篩選下方表格。
- V10.643 起 `/ai_intelligence` 的商品明細上方必須提供「商品策略分流」視覺摘要,至少包含價格壓力、價格優勢、待確認、缺比價四類;每一類需顯示件數、近 7 天業績與比例條,且可點擊切換明細。舊 KPI 卡也不得是靜態數字,需可導向全部商品、可處理商品、高風險比價或處理紀錄。
- V10.644 起 `/ai_intelligence` 的商品明細列不得只用句子描述比價;每列必須顯示 PChome 價格、MOMO 參考價、差距、可信度四格價格證據,並保留下一步按鈕。單位價候選需顯示單位價與單位,候選待確認或缺資料則以「待補 / 候選待確認」呈現,不得捏造價格。
## 零之一、12 Agent 決策信封2026-05-24

View File

@@ -798,7 +798,7 @@
.growth-detail-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(118px, auto);
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
gap: 12px;
border-bottom: 1px solid rgba(42, 37, 32, 0.08);
padding: 11px 12px;
@@ -827,7 +827,61 @@
display: grid;
gap: 6px;
align-content: start;
justify-items: end;
justify-items: stretch;
}
.growth-detail-action .table-row-action {
justify-self: end;
}
.growth-detail-price-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.growth-price-chip {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(250, 247, 240, 0.62);
padding: 7px 8px;
}
.growth-price-chip.is-risk {
border-color: rgba(185, 79, 58, 0.22);
background: rgba(255, 244, 239, 0.86);
}
.growth-price-chip.is-good {
border-color: rgba(47, 143, 102, 0.2);
background: rgba(238, 249, 241, 0.88);
}
.growth-price-label {
display: block;
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 900;
line-height: 1.2;
}
.growth-price-value {
display: block;
margin-top: 3px;
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 0.9rem;
font-weight: 900;
line-height: 1.15;
}
.growth-price-note {
display: block;
margin-top: 2px;
color: var(--momo-text-muted);
font-size: 0.66rem;
font-weight: 760;
line-height: 1.25;
}
.growth-source-list {
@@ -1326,6 +1380,14 @@
.growth-detail-action {
justify-items: stretch;
}
.growth-detail-action .table-row-action {
justify-self: stretch;
}
.growth-detail-price-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
@@ -1924,6 +1986,37 @@ function formatMoney(value) {
return 'NT$ ' + Math.round(num).toLocaleString();
}
function formatPriceAmount(value, options = {}) {
if (value === null || value === undefined || value === '') return '待補';
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) return '待補';
const decimals = options.decimals ?? (num < 100 ? 2 : 0);
return 'NT$ ' + num.toLocaleString('zh-TW', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
function formatGrowthDetailPrice(price, side) {
if (!price) return '待補';
const isUnit = price.price_basis === 'unit_price';
const unit = price.unit_label ? ` / ${price.unit_label}` : '';
if (isUnit) {
const value = side === 'pchome' ? price.pchome_unit_price : price.momo_unit_price;
return `${formatPriceAmount(value, { decimals: 2 })}${unit}`;
}
const value = side === 'pchome' ? price.pchome_price : price.momo_price;
return formatPriceAmount(value);
}
function formatGapDisplay(gap) {
if (gap === null || gap === undefined || !Number.isFinite(Number(gap))) return '待判斷';
const num = Number(gap);
if (num < 0) return `PChome 貴 ${Math.abs(num).toFixed(1)}%`;
if (num > 0) return `PChome 便宜 ${num.toFixed(1)}%`;
return '價格接近';
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;',
@@ -2457,14 +2550,33 @@ function renderGrowthDetail(kind = activeGrowthDetailKind) {
? '候選待確認'
: gap === null
? '缺 MOMO 參考'
: gap < 0
? `PChome 貴 ${Math.abs(gap).toFixed(1)}%`
: gap > 0
? `PChome 便宜 ${gap.toFixed(1)}%`
: '價格差不多';
: formatGapDisplay(gap);
const delta = row.sales_delta_pct === null || row.sales_delta_pct === undefined
? '前期不足'
: `${Number(row.sales_delta_pct).toFixed(1)}%`;
const qualityScore = row.data_quality?.score !== null && row.data_quality?.score !== undefined
? Math.round(Number(row.data_quality.score || 0))
: reviewCandidate?.quality_score !== null && reviewCandidate?.quality_score !== undefined
? Math.round(Number(reviewCandidate.quality_score || 0))
: price?.match_score
? Math.round(Number(price.match_score || 0) * 100)
: 0;
const pchomeDisplay = price
? formatGrowthDetailPrice(price, 'pchome')
: reviewCandidate
? formatPriceAmount(reviewCandidate.pchome_price)
: '待補';
const momoDisplay = price
? formatGrowthDetailPrice(price, 'momo')
: reviewCandidate
? formatPriceAmount(reviewCandidate.momo_price)
: '待補';
const priceBasis = price?.price_basis_label || (reviewCandidate ? '候選價' : '待補資料');
const gapChipClass = gap !== null && gap < 0
? ' is-risk'
: gap !== null && gap > 0
? ' is-good'
: '';
const productKey = escapeHtml(row.pchome_product_id || row.product_name || '');
const nextAction = action.code === 'review_external_candidate'
? 'review-candidate'
@@ -2487,7 +2599,28 @@ function renderGrowthDetail(kind = activeGrowthDetailKind) {
</p>
</div>
<div class="growth-detail-action">
<span class="growth-action-pill">${escapeHtml(action.label || '待判斷')}</span>
<div class="growth-detail-price-grid">
<span class="growth-price-chip">
<span class="growth-price-label">PChome</span>
<span class="growth-price-value">${escapeHtml(pchomeDisplay)}</span>
<span class="growth-price-note">${escapeHtml(priceBasis)}</span>
</span>
<span class="growth-price-chip">
<span class="growth-price-label">MOMO</span>
<span class="growth-price-value">${escapeHtml(momoDisplay)}</span>
<span class="growth-price-note">${escapeHtml(price?.data_source_label || (reviewCandidate ? '候選待確認' : '等待補抓'))}</span>
</span>
<span class="growth-price-chip${gapChipClass}">
<span class="growth-price-label">差距</span>
<span class="growth-price-value">${escapeHtml(gapText)}</span>
<span class="growth-price-note">${escapeHtml(action.label || '待判斷')}</span>
</span>
<span class="growth-price-chip">
<span class="growth-price-label">可信度</span>
<span class="growth-price-value">${qualityScore ? `${qualityScore}%` : '待補'}</span>
<span class="growth-price-note">${escapeHtml(row.data_quality?.label || '資料待確認')}</span>
</span>
</div>
<button type="button" class="btn btn-sm btn-outline-primary table-row-action" data-growth-action="${nextAction}" data-product-key="${productKey}">${nextLabel}</button>
</div>
</article>`;

View File

@@ -457,6 +457,10 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
assert "商品策略分流" in template
assert "growth-strategy-card" in template
assert "aiRecsPanel" in template
assert "growth-detail-price-grid" in template
assert "growth-price-chip" in template
assert "formatGrowthDetailPrice" in template
assert "formatGapDisplay" in template
assert "scrollToPanel('externalPricePanel')" in template
assert "備援資料檢查" in template
assert "外部報價預檢" not in template