feat: link growth dashboard metrics to details
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user