feat(governance): automate identity and comparison coverage

This commit is contained in:
ogt
2026-07-14 16:15:41 +08:00
parent 2dcc2122c4
commit 4bfb9dbefd
31 changed files with 2763 additions and 578 deletions

View File

@@ -88,6 +88,12 @@
padding: 7px 12px;
}
.growth-command-status-pill.is-degraded {
border-color: rgba(176, 93, 39, 0.28);
background: rgba(255, 241, 230, 0.96);
color: #8b421f;
}
.growth-command-action-group {
display: inline-flex;
flex-wrap: wrap;
@@ -150,6 +156,11 @@
padding: 13px;
}
.growth-command-card.is-degraded {
border-color: rgba(176, 93, 39, 0.34);
background: rgba(255, 249, 244, 0.96);
}
.growth-command-card.is-clickable {
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
@@ -195,6 +206,16 @@
display: none;
}
.growth-command-kpi-note.is-essential {
display: block;
min-height: 2.4em;
margin-top: 9px;
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 800;
line-height: 1.2;
}
.growth-command-spark {
display: block;
width: 100%;
@@ -2864,7 +2885,7 @@
</div>
<div class="growth-command-kpi-grid" aria-label="業績重點數字">
<article class="growth-command-card is-sales is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<article class="growth-command-card is-sales is-clickable" id="commandSalesCard" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<div class="growth-command-kpi-label">
<span>PChome 近 7 天業績</span>
<i class="fas fa-chart-line"></i>
@@ -2873,14 +2894,14 @@
<span class="growth-command-kpi-note" id="commandSalesFreshness">確認資料新鮮度</span>
<span class="growth-command-kpi-note" id="commandSalesDelta">等待資料</span>
</article>
<article class="growth-command-card is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('ready')" onkeydown="handleGrowthDetailKey(event, 'ready')">
<article class="growth-command-card is-clickable" id="commandCoverageCard" role="button" tabindex="0" onclick="showGrowthDetail('ready')" onkeydown="handleGrowthDetailKey(event, 'ready')">
<div class="growth-command-kpi-label">
<span>比價可用率</span>
<span>同款比價證據 · TOP 50</span>
<i class="fas fa-link"></i>
</div>
<strong class="growth-command-kpi-value" id="commandMatchRate">%</strong>
<strong class="growth-command-kpi-value" id="commandMatchRate"> / —</strong>
<div class="growth-command-progress"><span id="commandMatchRateBar"></span></div>
<span class="growth-command-kpi-note" id="commandMappedRatio">等待外部平台對應</span>
<span class="growth-command-kpi-note is-essential" id="commandMappedRatio">等待分子與分母</span>
</article>
<article class="growth-command-card is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<div class="growth-command-kpi-label">
@@ -2892,19 +2913,19 @@
</article>
<article class="growth-command-card is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('needs')" onkeydown="handleGrowthDetailKey(event, 'needs')">
<div class="growth-command-kpi-label">
<span>待補比價</span>
<span>尚未找到同款</span>
<i class="fas fa-magnifying-glass-chart"></i>
</div>
<strong class="growth-command-kpi-value" id="commandNeedMapping"></strong>
<span class="growth-command-kpi-note"><span id="commandReviewCount"></span> 筆候選待確認</span>
<span class="growth-command-kpi-note is-essential"><span id="commandReviewCount"></span> 筆候選 / 來源待驗證</span>
</article>
<article class="growth-command-card is-clickable" role="button" tabindex="0" onclick="showGrowthCategoryDetail('')" onkeydown="handleGrowthDetailKey(event, 'all')">
<article class="growth-command-card is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('source')" onkeydown="handleGrowthDetailKey(event, 'source')">
<div class="growth-command-kpi-label">
<span>最大業績分類</span>
<span>正式資料平台</span>
<i class="fas fa-layer-group"></i>
</div>
<strong class="growth-command-kpi-value" id="commandTopCategory"></strong>
<span class="growth-command-kpi-note" id="commandTopCategorySales">等待分類業績</span>
<strong class="growth-command-kpi-value" id="commandPlatformCoverage"> / —</strong>
<span class="growth-command-kpi-note is-essential" id="commandPlatformCoverageNote">僅計正式 runtime 資料</span>
</article>
</div>
@@ -2953,9 +2974,9 @@
</table>
</section>
<section class="growth-command-panel" aria-label="PChome 與外部平台價格狀態">
<section class="growth-command-panel" aria-label="TOP 50 同款比價覆蓋狀態">
<div class="growth-command-panel-head">
<h2 class="growth-command-panel-title">PChome 與外部平台</h2>
<h2 class="growth-command-panel-title">比價覆蓋 · TOP 50</h2>
<button class="growth-command-panel-link" type="button" onclick="showGrowthDetail('risk')">看價格壓力</button>
</div>
<div class="growth-command-donut-wrap">
@@ -2965,18 +2986,18 @@
<div class="growth-command-legend">
<div class="growth-command-legend-row">
<span class="growth-command-dot is-ready"></span>
<span>PChome 價格優勢</span>
<strong id="commandPriceAdvantage"></strong>
<span>具正式比價證據</span>
<strong id="commandComparisonReady"></strong>
</div>
<div class="growth-command-legend-row">
<span class="growth-command-dot is-review"></span>
<span>外部低價壓力</span>
<strong id="commandPricePressure"></strong>
<span>候選 / 來源驗證</span>
<strong id="commandCandidateValidation"></strong>
</div>
<div class="growth-command-legend-row">
<span class="growth-command-dot"></span>
<span>待確認 / 待補</span>
<strong id="commandPricePending"></strong>
<span>尚未找到同款</span>
<strong id="commandUnmatched"></strong>
</div>
</div>
</div>
@@ -2994,12 +3015,12 @@
<em>已有外部價格可判斷</em>
</div>
<div class="growth-command-mini-card">
<span>待確認</span>
<span>候選 / 來源驗證</span>
<strong id="commandStatusReview"></strong>
<em>先確認同款</em>
</div>
<div class="growth-command-mini-card">
<span>待補比價</span>
<span>尚未找到同款</span>
<strong id="commandStatusNeeds"></strong>
<em>需要補外部平台同款</em>
</div>
@@ -3896,26 +3917,52 @@ async function readJsonResponse(response) {
throw new Error(`伺服器回傳非預期內容,狀態碼 ${response.status || '未知'}`);
}
function growthComparisonState(stats = {}) {
const candidateCount = Number(stats.candidate_count || 0);
const comparisonReadyCount = Number(
stats.comparison_ready_count ?? stats.mapped_count ?? 0
);
const candidateValidationCount = Number(
stats.candidate_validation_count
?? stats.current_review_candidate_count
?? stats.review_candidate_count
?? 0
);
const unmatchedCount = Number(
stats.unmatched_count
?? Math.max(0, candidateCount - comparisonReadyCount - candidateValidationCount)
);
return {
candidateCount,
comparisonReadyCount,
candidateValidationCount,
unmatchedCount,
unresolvedCount: Math.max(0, candidateCount - comparisonReadyCount),
};
}
function renderOpsCommandDashboard(stats, scope = {}) {
latestGrowthStats = stats || {};
const candidateCount = Number(stats.candidate_count || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
const readyRate = candidateCount ? Math.round((mappedCount / candidateCount) * 100) : 0;
const state = growthComparisonState(stats);
const readyRate = state.candidateCount
? Math.round((state.comparisonReadyCount / state.candidateCount) * 100)
: 0;
const freshness = stats.sales_freshness || {};
document.getElementById('opsReadyRate').textContent = `${readyRate}%`;
document.getElementById('opsFunnelCandidateText').textContent = `${formatCount(candidateCount)}`;
document.getElementById('opsFunnelMappedText').textContent = `${formatCount(mappedCount)}`;
document.getElementById('opsFunnelNeedsText').textContent = `${formatCount(needsMapping)}`;
document.getElementById('opsDashboardStatus').textContent = candidateCount
? `資料就緒率 ${readyRate}%`
document.getElementById('opsFunnelCandidateText').textContent = `${formatCount(state.candidateCount)}`;
document.getElementById('opsFunnelMappedText').textContent = `${formatCount(state.comparisonReadyCount)}`;
document.getElementById('opsFunnelNeedsText').textContent = `${formatCount(state.unresolvedCount)}`;
document.getElementById('opsDashboardStatus').textContent = freshness.decision_ready === false
? '決策暫停 · 業績資料過期'
: state.candidateCount
? `比價證據 ${readyRate}%`
: '等待 PChome 業績資料';
renderNextAction(stats);
setWidth('opsFunnelCandidateBar', candidateCount ? 100 : 0);
setWidth('opsFunnelMappedBar', candidateCount ? (mappedCount / candidateCount) * 100 : 0);
setWidth('opsFunnelNeedsBar', candidateCount ? (needsMapping / candidateCount) * 100 : 0);
setWidth('opsFunnelCandidateBar', state.candidateCount ? 100 : 0);
setWidth('opsFunnelMappedBar', state.candidateCount ? (state.comparisonReadyCount / state.candidateCount) * 100 : 0);
setWidth('opsFunnelNeedsBar', state.candidateCount ? (state.unresolvedCount / state.candidateCount) * 100 : 0);
renderOpsSourceBars(stats.external_platform_counts || {}, scope);
renderGrowthExecutiveSummary(stats);
renderGrowthCommandCenter(stats, latestGrowthRows);
@@ -3937,10 +3984,14 @@ function setCommandTask(title, copy, label, action) {
function renderGrowthCommandCenter(stats = {}, rows = []) {
const safeRows = Array.isArray(rows) ? rows : [];
const candidateCount = Number(stats.candidate_count || safeRows.length || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
const state = growthComparisonState({
...stats,
candidate_count: Number(stats.candidate_count || safeRows.length || 0),
});
const candidateCount = state.candidateCount;
const mappedCount = state.comparisonReadyCount;
const candidateValidationCount = state.candidateValidationCount;
const unmatchedCount = state.unmatchedCount;
const decliningCount = Number(stats.declining_product_count || 0);
const activeCount = Number(stats.active_product_count || 0);
const sales7d = Number(stats.overall_sales_7d || stats.total_sales_7d || 0);
@@ -3948,13 +3999,14 @@ function renderGrowthCommandCenter(stats = {}, rows = []) {
const salesDeltaPct = Number(stats.overall_sales_delta_pct || 0);
const matchRate = Number(stats.mapping_rate || (candidateCount ? (mappedCount / candidateCount) * 100 : 0));
const latestSalesDate = String(stats.overall_latest_sales_date || stats.latest_sales_date || '').slice(0, 10);
const actionCounts = stats.action_code_counts || {};
const pressureCount = Number(actionCounts.review_price_or_promo || 0);
const advantageCount = Number(actionCounts.amplify_price_advantage || 0);
const pendingCount = Math.max(0, needsMapping + reviewCandidateCount);
const readyShare = candidateCount ? (advantageCount / candidateCount) * 100 : 0;
const pressureShare = candidateCount ? (pressureCount / candidateCount) * 100 : 0;
const readyShare = candidateCount ? (mappedCount / candidateCount) * 100 : 0;
const validationShare = candidateCount ? (candidateValidationCount / candidateCount) * 100 : 0;
const freshness = stats.sales_freshness || {};
const activeCatalogCount = Number(stats.active_catalog_product_count || stats.active_product_count || 0);
const activeMappedCount = Number(stats.active_mapped_product_count || 0);
const platformCount = Number(stats.registered_external_source_count || 0);
const runtimePlatformCount = Number(stats.platforms_with_runtime_data || 0);
const platformRate = Number(stats.platform_source_coverage_rate || 0);
setText('commandSales7d', sales7d ? formatMoney(sales7d) : '—');
setText('commandSalesDelta', prevSales7d
@@ -3963,21 +4015,26 @@ function renderGrowthCommandCenter(stats = {}, rows = []) {
setText('commandSalesFreshness', freshness.age_days === null || freshness.age_days === undefined
? (freshness.label || '尚無資料')
: `${freshness.label || '資料狀態'} · ${formatCount(freshness.age_days)}`);
setText('commandMatchRate', `${Math.round(matchRate * 10) / 10}%`);
setText('commandMappedRatio', `${formatCount(mappedCount)} / ${formatCount(candidateCount)}`);
setText('commandMatchRate', `${formatCount(mappedCount)} / ${formatCount(candidateCount)}`);
setText(
'commandMappedRatio',
`${Math.round(matchRate * 10) / 10}% · 活躍商品 ${formatCount(activeMappedCount)} / ${formatCount(activeCatalogCount)}`
);
setText('commandDecliningProducts', formatCount(decliningCount));
setText('commandActiveProducts', `${formatCount(activeCount)}`);
setText('commandNeedMapping', formatCount(needsMapping));
setText('commandReviewCount', formatCount(reviewCandidateCount));
setText('commandNeedMapping', formatCount(unmatchedCount));
setText('commandReviewCount', formatCount(candidateValidationCount));
setText('commandTopCategory', stats.top_category || '—');
setText('commandTopCategorySales', stats.top_category_sales_7d ? formatMoney(stats.top_category_sales_7d) : '等待分類業績');
setText('commandPlatformCoverage', `${formatCount(runtimePlatformCount)} / ${formatCount(platformCount)}`);
setText('commandPlatformCoverageNote', `${platformRate.toFixed(1)}% · 僅計正式 runtime 資料`);
setText('commandStatusReady', formatCount(mappedCount));
setText('commandStatusReview', formatCount(reviewCandidateCount));
setText('commandStatusNeeds', formatCount(needsMapping));
setText('commandStatusReview', formatCount(candidateValidationCount));
setText('commandStatusNeeds', formatCount(unmatchedCount));
setText('commandLatestSalesDate', latestSalesDate || '—');
setText('commandPriceAdvantage', `${formatCount(advantageCount)}`);
setText('commandPricePressure', `${formatCount(pressureCount)}`);
setText('commandPricePending', `${formatCount(pendingCount)}`);
setText('commandComparisonReady', `${formatCount(mappedCount)}`);
setText('commandCandidateValidation', `${formatCount(candidateValidationCount)}`);
setText('commandUnmatched', `${formatCount(unmatchedCount)}`);
setText('commandDonutText', `${Math.round(matchRate)}%`);
const status = document.getElementById('growthCommandStatus');
@@ -3987,13 +4044,16 @@ function renderGrowthCommandCenter(stats = {}, rows = []) {
: `${freshness.label || '待資料'}${latestSalesDate ? ` · ${latestSalesDate}` : ''}`;
const icon = freshness.decision_ready ? 'fa-circle-check' : 'fa-triangle-exclamation';
status.innerHTML = `<i class="fas ${icon}"></i><span>${escapeHtml(text)}</span>`;
status.classList.toggle('is-degraded', freshness.decision_ready === false);
}
document.getElementById('commandSalesCard')?.classList.toggle('is-degraded', freshness.decision_ready === false);
document.getElementById('commandCoverageCard')?.classList.toggle('is-degraded', freshness.decision_ready === false);
const rateBar = document.getElementById('commandMatchRateBar');
if (rateBar) rateBar.style.width = `${clampPercent(matchRate)}%`;
const donut = document.getElementById('commandPriceDonut');
if (donut) {
donut.style.setProperty('--ready', clampPercent(readyShare));
donut.style.setProperty('--review', clampPercent(pressureShare));
donut.style.setProperty('--review', clampPercent(validationShare));
}
if (freshness.decision_ready === false) {
@@ -4012,16 +4072,16 @@ function renderGrowthCommandCenter(stats = {}, rows = []) {
'業績',
() => { window.location.href = '/daily_sales'; }
);
} else if (reviewCandidateCount > 0) {
} else if (candidateValidationCount > 0) {
setCommandTask(
`確認候選 ${formatCount(reviewCandidateCount)}`,
`驗證候選 ${formatCount(candidateValidationCount)}`,
'待確認',
'確認候選',
() => scrollToPanel('growthReviewPanel')
);
} else if (needsMapping > mappedCount) {
} else if (unmatchedCount > mappedCount) {
setCommandTask(
`補比價 ${formatCount(needsMapping)}`,
`補比價 ${formatCount(unmatchedCount)}`,
'待補',
'補比價',
() => backfillPchomeMatches()
@@ -4078,10 +4138,11 @@ function renderNextAction(stats = {}) {
const reason = document.getElementById('nextActionReason');
const button = document.getElementById('nextActionButton');
if (!title || !reason || !button) return;
const candidateCount = Number(stats.candidate_count || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
const state = growthComparisonState(stats);
const candidateCount = state.candidateCount;
const mappedCount = state.comparisonReadyCount;
const needsMapping = state.unmatchedCount;
const reviewCandidateCount = state.candidateValidationCount;
const freshness = stats.sales_freshness || {};
if (freshness.decision_ready === false) {
@@ -4147,10 +4208,11 @@ function renderNextAction(stats = {}) {
}
function renderGrowthExecutiveSummary(stats = {}) {
const candidateCount = Number(stats.candidate_count || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
const state = growthComparisonState(stats);
const candidateCount = state.candidateCount;
const mappedCount = state.comparisonReadyCount;
const needsMapping = state.unmatchedCount;
const reviewCandidateCount = state.candidateValidationCount;
const latestSalesDate = String(stats.latest_sales_date || '').slice(0, 10);
const freshness = stats.sales_freshness || {};
@@ -4311,10 +4373,11 @@ function renderGrowthActionHint(stats) {
const hint = document.getElementById('growthActionHint');
if (!hint) return;
const candidateCount = Number(stats.candidate_count || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
const state = growthComparisonState(stats);
const candidateCount = state.candidateCount;
const mappedCount = state.comparisonReadyCount;
const needsMapping = state.unmatchedCount;
const reviewCandidateCount = state.candidateValidationCount;
const freshness = stats.sales_freshness || {};
if (freshness.decision_ready === false) {
@@ -4410,9 +4473,10 @@ function renderGrowthSourceReadiness(sources) {
function resolveGrowthDetailKind(kind) {
if (kind !== 'task') return kind || 'all';
const reviewCandidateCount = Number(latestGrowthStats.review_candidate_count || 0);
const needsMapping = Number(latestGrowthStats.needs_mapping_count || 0);
const mappedCount = Number(latestGrowthStats.mapped_count || 0);
const state = growthComparisonState(latestGrowthStats);
const reviewCandidateCount = state.candidateValidationCount;
const needsMapping = state.unmatchedCount;
const mappedCount = state.comparisonReadyCount;
if (reviewCandidateCount > 0) return 'review';
if (needsMapping > mappedCount) return 'needs';
return 'risk';