feat: link growth dashboard metrics to details
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
ogt
2026-06-24 14:28:17 +08:00
parent 06418878e0
commit e6deaa4711
5 changed files with 258 additions and 26 deletions

View File

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

View File

@@ -76,6 +76,7 @@
- V10.638 起 PChome 導向 MOMO 補抓會把「找到但不能自動比價」的候選以 `match_status='needs_review'``data_quality_status='needs_review'` 保存到 `external_offers`;這些候選不得進價格壓力判斷,也不得發告警,但 `/api/ai/pchome-growth/opportunities` 可回傳待確認候選數,讓 UI 顯示「已有候選待確認」而不是只顯示無法比價。
- V10.639 起待確認候選排序必須容忍缺少單位數量;沒有 `momo_total_quantity` / `competitor_total_quantity` 時仍可保存為 `needs_review`,不得中斷 PChome 導向 MOMO 回填。
- V10.640 起 `/ai_intelligence` 必須提供 MOMO 待確認候選操作佇列;使用者可直接確認同款或排除候選。確認後 `external_offers` 會轉為 `verified/verified` 並進入作戰清單,排除後轉為 `rejected/rejected`,兩者都必須清掉 PChome 成長作戰清單快取。
- V10.641 起 `/ai_intelligence` 的摘要數字不可只是靜態文字;第一屏 KPI、商品處理進度、待確認數字都必須可點擊並導向對應明細。今日清單若已有 MOMO 待確認候選,下一步必須顯示「確認候選」並跳到候選面板,不得再只顯示「補齊比價」。
## 零之一、12 Agent 決策信封2026-05-24

View File

@@ -545,7 +545,104 @@ def _fetch_external_price_map(conn, pchome_product_ids: list[str]) -> dict[str,
return {**legacy_map, **normalized_map}
def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] | None) -> dict[str, Any]:
def _fetch_review_candidate_map(conn, pchome_product_ids: list[str]) -> dict[str, dict[str, Any]]:
inspector = inspect(conn)
if not inspector.has_table("external_offers"):
return {}
ids = [str(item).strip() for item in pchome_product_ids if str(item or "").strip()]
if not ids:
return {}
if conn.dialect.name == "postgresql":
sql = """
WITH latest_review AS (
SELECT DISTINCT ON (eo.pchome_product_id)
eo.id,
eo.pchome_product_id,
eo.source_product_id AS momo_sku,
eo.title AS momo_name,
eo.product_url,
eo.price AS momo_price,
eo.quality_score,
eo.raw_payload_json,
eo.observed_at
FROM external_offers eo
WHERE eo.source_code = 'momo_reference'
AND eo.ingestion_method = 'targeted_momo_review'
AND eo.pchome_product_id IS NOT NULL
AND eo.pchome_product_id IN :ids
AND (
eo.match_status = 'needs_review'
OR eo.data_quality_status = 'needs_review'
)
ORDER BY eo.pchome_product_id, eo.observed_at DESC NULLS LAST, eo.id DESC
)
SELECT * FROM latest_review
"""
else:
sql = """
WITH latest_review AS (
SELECT
eo.id,
eo.pchome_product_id,
eo.source_product_id AS momo_sku,
eo.title AS momo_name,
eo.product_url,
eo.price AS momo_price,
eo.quality_score,
eo.raw_payload_json,
eo.observed_at,
ROW_NUMBER() OVER (
PARTITION BY eo.pchome_product_id
ORDER BY eo.observed_at DESC, eo.id DESC
) AS rn
FROM external_offers eo
WHERE eo.source_code = 'momo_reference'
AND eo.ingestion_method = 'targeted_momo_review'
AND eo.pchome_product_id IS NOT NULL
AND eo.pchome_product_id IN :ids
AND (
eo.match_status = 'needs_review'
OR eo.data_quality_status = 'needs_review'
)
)
SELECT *
FROM latest_review
WHERE rn = 1
"""
stmt = text(sql).bindparams(bindparam("ids", expanding=True))
rows = conn.execute(stmt, {"ids": ids}).mappings().all()
result: dict[str, dict[str, Any]] = {}
for row in rows:
key = str(row.get("pchome_product_id") or "").strip()
if not key:
continue
raw_payload = _json_dict(row.get("raw_payload_json"))
reasons = raw_payload.get("match_reasons") if isinstance(raw_payload.get("match_reasons"), list) else []
result[key] = {
"id": row.get("id"),
"pchome_product_id": key,
"pchome_product_name": raw_payload.get("pchome_public_name"),
"pchome_price": raw_payload.get("pchome_public_price"),
"momo_sku": row.get("momo_sku"),
"momo_name": row.get("momo_name"),
"momo_price": row.get("momo_price"),
"product_url": row.get("product_url"),
"quality_score": round(_to_float(row.get("quality_score")), 2),
"match_score": _match_score_from_quality(row.get("quality_score")),
"match_reasons": [str(reason) for reason in reasons[:5]],
"gap_pct": raw_payload.get("target_gap_pct"),
"observed_at": str(row.get("observed_at") or ""),
}
return result
def _score_opportunity(
sales_row: dict[str, Any],
external_row: dict[str, Any] | None,
review_candidate: dict[str, Any] | None = None,
) -> dict[str, Any]:
sales_7d = _to_float(sales_row.get("sales_7d"))
sales_prev_7d = _to_float(sales_row.get("sales_prev_7d"))
qty_7d = _to_float(sales_row.get("qty_7d"))
@@ -558,6 +655,7 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
decline_score = min(24, abs(sales_delta_pct) / 45 * 24) if sales_delta_pct is not None and sales_delta_pct < 0 else 0
data_quality_score = 54
external_payload = None
review_candidate_payload = None
action_code = "map_external_product"
action_label = "先補商品對應"
action_message = "這項商品已有業績訊號,但還沒有可確認的 MOMO 對照商品。先補對應,後續才能判斷價格壓力。"
@@ -633,8 +731,26 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
else:
reason_lines.append(f"PChome 與 MOMO {basis_prefix}幾乎相同。")
else:
data_quality_score -= 12
reason_lines.append("尚未找到可確認的 MOMO 對照商品。")
if review_candidate:
review_candidate_payload = {
"id": review_candidate.get("id"),
"momo_sku": review_candidate.get("momo_sku"),
"momo_name": review_candidate.get("momo_name"),
"momo_price": review_candidate.get("momo_price"),
"pchome_price": review_candidate.get("pchome_price"),
"quality_score": review_candidate.get("quality_score"),
"gap_pct": review_candidate.get("gap_pct"),
"match_reasons": review_candidate.get("match_reasons") or [],
"product_url": review_candidate.get("product_url"),
}
data_quality_score = max(data_quality_score, 62)
action_code = "review_external_candidate"
action_label = "確認候選"
action_message = "已找到 MOMO 候選,但還要確認同款、色號或組合後才能進價格判斷。"
reason_lines.append("已找到 MOMO 候選,先確認同款、色號或組合。")
else:
data_quality_score -= 12
reason_lines.append("尚未找到可確認的 MOMO 對照商品。")
if sales_delta_pct is None:
reason_lines.append("前 7 天沒有可比基準,先看近 7 天表現。")
@@ -659,7 +775,7 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
issues = []
if not external_row:
issues.append("需要補商品對應")
issues.append("需要確認 MOMO 候選" if review_candidate_payload else "需要補商品對應")
if sales_delta_pct is None:
issues.append("前期業績不足")
@@ -677,6 +793,7 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
else None,
"last_sale_date": str(sales_row.get("last_sale_date") or ""),
"external_price": external_payload,
"review_candidate": review_candidate_payload,
"priority_score": round(priority_score, 1),
"recommended_action": {
"code": action_code,
@@ -690,6 +807,8 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
if external_payload and external_payload.get("price_basis") == "unit_price"
else "資料可直接判斷"
if external_row
else "候選待確認"
if review_candidate_payload
else "需要補資料"
),
"score": round(max(0, min(100, data_quality_score)), 1),
@@ -760,11 +879,12 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
}
sales_ids = [str(row.get("pchome_product_id") or "") for row in sales_rows]
external_map = _fetch_external_price_map(conn, sales_ids)
review_candidate_map = _fetch_review_candidate_map(conn, sales_ids)
opportunities = []
for row in sales_rows:
key = str(row.get("pchome_product_id") or "").strip()
opportunities.append(_score_opportunity(row, external_map.get(key)))
opportunities.append(_score_opportunity(row, external_map.get(key), review_candidate_map.get(key)))
opportunities.sort(key=lambda item: item["priority_score"], reverse=True)
opportunities = opportunities[:limit]

View File

@@ -298,6 +298,25 @@
line-height: 1;
}
.growth-exec-card.is-clickable,
.ops-dashboard-tile.is-clickable,
.growth-metric.is-clickable {
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.growth-exec-card.is-clickable:hover,
.growth-exec-card.is-clickable:focus,
.ops-dashboard-tile.is-clickable:hover,
.ops-dashboard-tile.is-clickable:focus,
.growth-metric.is-clickable:hover,
.growth-metric.is-clickable:focus {
border-color: rgba(172, 92, 58, 0.34);
box-shadow: 0 0 0 3px rgba(172, 92, 58, 0.1), var(--momo-shadow-soft);
outline: none;
transform: translateY(-1px);
}
.growth-exec-detail {
margin-top: 7px;
color: var(--momo-text-muted);
@@ -306,6 +325,17 @@
line-height: 1.4;
}
.drilldown-hint {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 6px;
color: var(--momo-warm-rust);
font-size: 0.7rem;
font-weight: 900;
white-space: nowrap;
}
.ops-flow {
border: 1px solid var(--momo-border-subtle);
border-radius: 8px;
@@ -878,6 +908,13 @@
border-bottom: 0;
}
.review-candidate-row.is-highlight {
border-radius: 8px;
background: rgba(242, 178, 90, 0.16);
box-shadow: inset 3px 0 0 var(--momo-warm-caramel);
padding-left: 10px;
}
.review-candidate-title {
margin: 0;
color: var(--momo-text-strong);
@@ -1114,37 +1151,37 @@
</section>
<section class="growth-executive-strip" aria-label="今日任務摘要">
<article class="growth-exec-card is-primary" id="growthExecTaskCard">
<article class="growth-exec-card is-primary is-clickable" id="growthExecTaskCard" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="growth-exec-label">
<span>今日任務</span>
<i class="fas fa-location-arrow"></i>
</div>
<div class="growth-exec-value" id="growthExecTask">整理中</div>
<div class="growth-exec-detail" id="growthExecTaskDetail">正在讀取 PChome 業績與 MOMO 外部價格。</div>
<div class="growth-exec-detail"><span id="growthExecTaskDetail">正在讀取 PChome 業績與 MOMO 外部價格。</span><span class="drilldown-hint">看明細</span></div>
</article>
<article class="growth-exec-card is-ready">
<article class="growth-exec-card is-ready is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="growth-exec-label">
<span>可立即處理</span>
<i class="fas fa-circle-check"></i>
</div>
<div class="growth-exec-value" id="growthExecReady"></div>
<div class="growth-exec-detail">已有可用比價資料</div>
<div class="growth-exec-detail">已有可用比價資料<span class="drilldown-hint">看清單</span></div>
</article>
<article class="growth-exec-card is-gap" id="growthExecGapCard">
<article class="growth-exec-card is-gap is-clickable" id="growthExecGapCard" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="growth-exec-label">
<span>待補比價</span>
<i class="fas fa-link-slash"></i>
</div>
<div class="growth-exec-value" id="growthExecGap"></div>
<div class="growth-exec-detail">有業績但缺外部參考</div>
<div class="growth-exec-detail">有業績但缺外部參考<span class="drilldown-hint">看商品</span></div>
</article>
<article class="growth-exec-card">
<article class="growth-exec-card is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="growth-exec-label">
<span>最新業績日</span>
<i class="fas fa-calendar-day"></i>
</div>
<div class="growth-exec-value" id="growthExecLatestDate"></div>
<div class="growth-exec-detail" id="growthExecLatestDetail">等待資料</div>
<div class="growth-exec-detail"><span id="growthExecLatestDetail">等待資料</span><span class="drilldown-hint">看清單</span></div>
</article>
</section>
@@ -1167,7 +1204,7 @@
</button>
</div>
<div class="ops-flow-grid">
<div class="ops-dashboard-tile">
<div class="ops-dashboard-tile is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<div class="ops-dashboard-title">
<span>商品處理進度</span>
<strong class="ops-dashboard-value" id="opsReadyRate">—%</strong>
@@ -1187,7 +1224,7 @@
</div>
</div>
</div>
<div class="ops-dashboard-tile">
<div class="ops-dashboard-tile is-clickable" role="button" tabindex="0" onclick="scrollToPanel('externalPricePanel')" onkeydown="handleDrilldownKey(event, 'externalPricePanel')">
<div class="ops-dashboard-title">
<span>外部價格來源</span>
<strong class="ops-dashboard-value" id="opsSourceTotal"></strong>
@@ -1241,19 +1278,19 @@
<div class="growth-ops-grid">
<div>
<div class="growth-metric-row">
<div class="growth-metric">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<strong id="growthCandidateCount"></strong>
<span>追蹤商品</span>
</div>
<div class="growth-metric">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<strong id="growthMappedCount"></strong>
<span>可立即處理</span>
</div>
<div class="growth-metric">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<strong id="growthNeedsMapping"></strong>
<span>無法比價</span>
</div>
<div class="growth-metric">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthReviewPanel')" onkeydown="handleDrilldownKey(event, 'growthReviewPanel')">
<strong id="growthReviewCandidateCount"></strong>
<span>待確認</span>
</div>
@@ -1542,6 +1579,10 @@ function bindActionDelegation() {
backfillPchomeMatches();
return;
}
if (growthButton.dataset.growthAction === 'review-candidate') {
focusReviewCandidate(growthButton.dataset.productKey || '');
return;
}
focusPriceTable(growthButton.dataset.productKey || '');
});
}
@@ -1555,6 +1596,22 @@ function focusPriceTable(keyword) {
scrollToPanel('externalPricePanel');
}
function focusReviewCandidate(productKey) {
scrollToPanel('growthReviewPanel');
const key = String(productKey || '').trim();
if (!key) return;
setTimeout(() => {
document.querySelectorAll('.review-candidate-row.is-highlight')
.forEach((row) => row.classList.remove('is-highlight'));
const target = Array.from(document.querySelectorAll('.review-candidate-row'))
.find((row) => row.dataset.pchomeId === key);
if (target) {
target.classList.add('is-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 220);
}
async function loadDashboard() {
try {
const res = await fetch('/api/ai/icaim/dashboard');
@@ -1627,6 +1684,12 @@ function scrollToPanel(panelId) {
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function handleDrilldownKey(event, panelId) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
scrollToPanel(panelId);
}
function clampPercent(value) {
return Math.max(0, Math.min(100, Number(value || 0)));
}
@@ -1680,6 +1743,7 @@ function renderOpsCommandDashboard(stats, scope = {}) {
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;
document.getElementById('opsReadyRate').textContent = `${readyRate}%`;
@@ -1689,7 +1753,7 @@ function renderOpsCommandDashboard(stats, scope = {}) {
document.getElementById('opsDashboardStatus').textContent = candidateCount
? `資料就緒率 ${readyRate}%`
: '等待 PChome 業績資料';
renderNextAction(candidateCount, mappedCount, needsMapping);
renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandidateCount);
setWidth('opsFunnelCandidateBar', candidateCount ? 100 : 0);
setWidth('opsFunnelMappedBar', candidateCount ? (mappedCount / candidateCount) * 100 : 0);
@@ -1698,7 +1762,7 @@ function renderOpsCommandDashboard(stats, scope = {}) {
renderGrowthExecutiveSummary(stats);
}
function renderNextAction(candidateCount, mappedCount, needsMapping) {
function renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandidateCount = 0) {
const title = document.getElementById('nextActionTitle');
const reason = document.getElementById('nextActionReason');
const button = document.getElementById('nextActionButton');
@@ -1714,6 +1778,14 @@ function renderNextAction(candidateCount, mappedCount, needsMapping) {
}
if (needsMapping > 0 && mappedCount === 0) {
if (reviewCandidateCount > 0) {
title.textContent = `今天先做:確認 ${formatCount(reviewCandidateCount)} 筆 MOMO 候選`;
reason.textContent = '候選已找到,先確認同款或排除,確認後才會進入價格判斷。';
button.textContent = '確認候選';
delete button.dataset.action;
button.onclick = () => scrollToPanel('growthReviewPanel');
return;
}
title.textContent = `今天先做:補齊 ${formatCount(needsMapping)} 件比價資料`;
reason.textContent = '這些商品有業績,但目前還看不到 MOMO 參考價,請先補齊比價資料。';
button.textContent = '補齊比價資料';
@@ -1723,6 +1795,14 @@ function renderNextAction(candidateCount, mappedCount, needsMapping) {
}
if (needsMapping > mappedCount) {
if (reviewCandidateCount > 0) {
title.textContent = `今天先做:確認 ${formatCount(reviewCandidateCount)} 筆 MOMO 候選`;
reason.textContent = '先把已找到的候選變成可用資料,再補剩下找不到同款的商品。';
button.textContent = '確認候選';
delete button.dataset.action;
button.onclick = () => scrollToPanel('growthReviewPanel');
return;
}
title.textContent = `今天先做:補齊 ${formatCount(needsMapping)} 件比價資料`;
reason.textContent = '目前無法比價的商品比可處理商品多,請先補齊商品對應。';
button.textContent = '補齊比價資料';
@@ -1742,6 +1822,7 @@ 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 latestSalesDate = String(stats.latest_sales_date || '').slice(0, 10);
document.getElementById('growthExecReady').textContent = formatCount(mappedCount);
@@ -1762,6 +1843,12 @@ function renderGrowthExecutiveSummary(stats = {}) {
}
if (needsMapping > 0 && mappedCount === 0) {
if (reviewCandidateCount > 0) {
task.textContent = `先確認 ${formatCount(reviewCandidateCount)} 筆候選`;
detail.textContent = '候選商品已找到,確認同款後才能進入價格判斷。';
gapCard?.classList.add('is-gap');
return;
}
task.textContent = `先補 ${formatCount(needsMapping)} 件 MOMO 參考`;
detail.textContent = '高業績商品還不能比價,先補對應資料才會有可行動建議。';
gapCard?.classList.add('is-gap');
@@ -1769,6 +1856,12 @@ function renderGrowthExecutiveSummary(stats = {}) {
}
if (needsMapping > mappedCount) {
if (reviewCandidateCount > 0) {
task.textContent = `先確認 ${formatCount(reviewCandidateCount)} 筆候選`;
detail.textContent = '先處理候選清單,再補剩下找不到同款的商品。';
gapCard?.classList.add('is-gap');
return;
}
task.textContent = `先補比價,再處理 ${formatCount(mappedCount)}`;
detail.textContent = '待補比價比可處理商品多,先擴大 MOMO 對應覆蓋率。';
gapCard?.classList.add('is-gap');
@@ -1962,9 +2055,12 @@ function renderGrowthOps(rows) {
const action = row.recommended_action || {};
const reason = (row.reason_lines || []).slice(0, 2).join(' ');
const price = row.external_price;
const reviewCandidate = row.review_candidate || null;
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
const basisLabel = price?.price_basis_label || '商品總價';
const priceText = gap === null
const priceText = reviewCandidate
? '候選待確認'
: gap === null
? '資料不足,先補比價'
: gap < 0
? `${basisLabel} PChome 貴 ${Math.abs(gap).toFixed(1)}%`
@@ -1980,8 +2076,16 @@ function renderGrowthOps(rows) {
const qualityLabel = quality.label || (price ? '可直接參考' : '資料不足');
const qualityIssues = Array.isArray(quality.issues) ? quality.issues.join('、') : '';
const productKey = escapeHtml(row.pchome_product_id || row.product_name || '');
const nextLabel = action.code === 'map_external_product' ? '補齊比價' : '檢查價格';
const nextAction = action.code === 'map_external_product' ? 'backfill' : 'focus-price';
const nextLabel = action.code === 'review_external_candidate'
? '確認候選'
: action.code === 'map_external_product'
? '補齊比價'
: '檢查價格';
const nextAction = action.code === 'review_external_candidate'
? 'review-candidate'
: action.code === 'map_external_product'
? 'backfill'
: 'focus-price';
return `<tr>
<td data-label="優先級">
<span class="badge ${priorityClass}">${priority}</span>
@@ -2073,7 +2177,7 @@ function renderGrowthReviewCandidates(rows) {
const pchomePrice = row.pchome_price ? formatMoney(row.pchome_price) : '未取得 PChome 價格';
const safeUrl = safeHttpUrl(row.product_url);
const url = safeUrl ? `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener">看 MOMO</a>` : '';
return `<article class="review-candidate-row">
return `<article class="review-candidate-row" data-pchome-id="${escapeHtml(row.pchome_product_id || '')}">
<div>
<h3 class="review-candidate-title">${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}</h3>
<p class="review-candidate-meta">

View File

@@ -426,6 +426,13 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
assert "MOMO 待確認候選" in template
assert "確認同款" in template
assert "不是同款" in template
assert "review_external_candidate" in template
assert "focusReviewCandidate" in template
assert "handleDrilldownKey" in template
assert "drilldown-hint" in template
assert "候選待確認" in template
assert "看明細" in template
assert "data-pchome-id" in template
assert "今日重點總覽" in template
assert "nextActionTitle" in template
assert "商品處理進度" in template