feat(dashboard): 顯示 PChome 比價決策總覽
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
OoO
2026-05-01 14:19:18 +08:00
parent fbc85fcedc
commit 1012d609d4
7 changed files with 439 additions and 48 deletions

View File

@@ -2,7 +2,7 @@
> 本文件定義專案開發的核心準則與不可違反的規範
> **建立日期**: 2026-01-12
> **當前版本**: V10.52 (Vendor stockout API query service extraction)
> **當前版本**: V10.53 (Dashboard competitor decision overview)
> **最後更新**: 2026-05-01
---

4
app.py
View File

@@ -95,8 +95,8 @@ except Exception as e:
sys_log.error(f"無法檢測磁碟空間: {e}")
# 🚩 系統版本定義 (備份與顯示用)
# 🚩 2026-05-01 V10.52: Vendor stockout API query service extraction
SYSTEM_VERSION = "V10.52"
# 🚩 2026-05-01 V10.53: Dashboard competitor decision overview
SYSTEM_VERSION = "V10.53"
# ==========================================
# 🔒 SQL Injection 防護函數

View File

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

View File

@@ -37,6 +37,7 @@ SQL漏斗(~300筆)
- 配對來源仍以 PChome crawler 真實搜尋結果為準;無競品資料時不生成挑品。
- 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。
- 排程閉環:`run_pchome_match_backfill_task` 每日 10:30 執行,補抓 PChome 待比對商品、寫入歷史價格,再重算 `strategy='product_pick'` 清單。
- 商品看板第一屏:`/` 的 V2 看板直接以 `products``price_records``competitor_prices``ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品與待比對優先清單。
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|------|------|------|------|---------|

View File

@@ -38,6 +38,12 @@ def _build_pchome_product_url(product_id):
return f"https://24h.pchome.com.tw/prod/{str(product_id).strip()}"
def _build_momo_product_url(i_code):
if not i_code:
return None
return f"https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code={str(i_code).strip()}"
def _to_float(value):
if value is None:
return None
@@ -134,6 +140,245 @@ def _load_pchome_competitor_map(session, skus):
return result
def _format_dashboard_dt(value):
if not value:
return None
if hasattr(value, "strftime"):
return value.strftime("%Y-%m-%d %H:%M")
return str(value)
def _dashboard_decision_row(row, tone):
sku = str(row.get('sku') or '')
pchome_id = row.get('competitor_product_id')
return {
'sku': sku,
'name': row.get('name') or '',
'category': row.get('category') or '',
'momo_price': _to_float(row.get('momo_price')) or 0,
'pchome_price': _to_float(row.get('pchome_price')) or 0,
'gap_pct': _to_float(row.get('gap_pct')) or 0,
'gap_amount': _to_float(row.get('gap_amount')) or 0,
'confidence': _to_float(row.get('confidence')),
'reason': row.get('reason') or '',
'tone': tone,
'momo_url': row.get('momo_url') or _build_momo_product_url(sku),
'pchome_id': pchome_id,
'pchome_name': row.get('competitor_product_name') or '',
'pchome_url': _build_pchome_product_url(pchome_id),
'crawled_at': _format_dashboard_dt(row.get('crawled_at') or row.get('created_at')),
}
def _load_competitor_decision_overview(session):
"""讀取商品看板第一屏使用的 PChome 比價決策摘要。全部來自正式 DB。"""
default = {
'total_active': 0,
'matched_count': 0,
'match_rate': 0,
'pchome_advantage_count': 0,
'momo_threat_count': 0,
'near_count': 0,
'pending_match_count': 0,
'ai_pick_count': 0,
'avg_advantage_gap': 0,
'last_pchome_crawled': None,
'top_picks': [],
'top_pchome_advantages': [],
'top_momo_threats': [],
'pending_priority': [],
}
latest_compared_cte = """
WITH latest_momo AS (
SELECT
p.id AS product_id,
p.i_code AS sku,
p.name,
p.url AS momo_url,
p.category,
pr.price AS momo_price,
ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC, pr.id DESC) AS rn
FROM products p
JOIN price_records pr ON pr.product_id = p.id
WHERE p.status = 'ACTIVE'
),
latest_products AS (
SELECT * FROM latest_momo WHERE rn = 1
),
valid_competitor AS (
SELECT DISTINCT ON (cp.sku)
cp.sku,
cp.price AS pchome_price,
cp.competitor_product_id,
cp.competitor_product_name,
cp.match_score,
cp.crawled_at
FROM competitor_prices cp
WHERE cp.source = 'pchome'
AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)
AND cp.price IS NOT NULL
AND cp.price > 0
AND COALESCE(cp.match_score, 0) >= 0.42
ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST
),
compared AS (
SELECT
lp.*,
vc.pchome_price,
vc.competitor_product_id,
vc.competitor_product_name,
vc.match_score,
vc.crawled_at,
(lp.momo_price - vc.pchome_price) AS gap_amount,
((lp.momo_price - vc.pchome_price) / vc.pchome_price * 100) AS gap_pct
FROM latest_products lp
JOIN valid_competitor vc ON vc.sku = lp.sku
)
"""
stats_sql = text(latest_compared_cte + """
SELECT
(SELECT COUNT(*) FROM products WHERE status = 'ACTIVE') AS total_active,
(SELECT COUNT(*) FROM compared) AS matched_count,
(SELECT COUNT(*) FROM compared WHERE gap_pct >= 5) AS pchome_advantage_count,
(SELECT COUNT(*) FROM compared WHERE gap_pct <= -5) AS momo_threat_count,
(SELECT COUNT(*) FROM compared WHERE gap_pct > -5 AND gap_pct < 5) AS near_count,
(SELECT COALESCE(ROUND(AVG(gap_pct)::numeric, 1), 0) FROM compared WHERE gap_pct >= 5) AS avg_advantage_gap,
(SELECT COUNT(*) FROM ai_price_recommendations WHERE strategy = 'product_pick' AND status = 'pending') AS ai_pick_count,
(SELECT MAX(crawled_at) FROM competitor_prices WHERE source = 'pchome') AS last_pchome_crawled
""")
advantage_sql = text(latest_compared_cte + """
SELECT *
FROM compared
WHERE gap_pct >= 5
ORDER BY gap_pct DESC NULLS LAST, crawled_at DESC NULLS LAST
LIMIT 3
""")
threat_sql = text(latest_compared_cte + """
SELECT *
FROM compared
WHERE gap_pct <= -5
ORDER BY gap_pct ASC NULLS LAST, crawled_at DESC NULLS LAST
LIMIT 3
""")
pending_sql = text("""
WITH latest_momo AS (
SELECT
p.i_code AS sku,
p.name,
p.url AS momo_url,
p.category,
pr.price AS momo_price,
ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC, pr.id DESC) AS rn
FROM products p
JOIN price_records pr ON pr.product_id = p.id
WHERE p.status = 'ACTIVE'
)
SELECT lm.*
FROM latest_momo lm
LEFT JOIN competitor_prices cp
ON cp.sku = lm.sku
AND cp.source = 'pchome'
AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)
AND cp.price IS NOT NULL
AND cp.price > 0
AND COALESCE(cp.match_score, 0) >= 0.42
WHERE lm.rn = 1
AND cp.sku IS NULL
ORDER BY lm.momo_price DESC NULLS LAST
LIMIT 3
""")
picks_sql = text("""
WITH valid_competitor AS (
SELECT DISTINCT ON (cp.sku)
cp.sku,
cp.competitor_product_id,
cp.competitor_product_name,
cp.crawled_at
FROM competitor_prices cp
WHERE cp.source = 'pchome'
AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)
AND cp.price IS NOT NULL
AND cp.price > 0
AND COALESCE(cp.match_score, 0) >= 0.42
ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST
)
SELECT
ar.sku,
ar.name,
ar.momo_price,
ar.pchome_price,
ar.gap_pct,
ar.confidence,
ar.reason,
ar.created_at,
vc.competitor_product_id,
vc.competitor_product_name,
vc.crawled_at
FROM ai_price_recommendations ar
LEFT JOIN valid_competitor vc ON vc.sku = ar.sku
WHERE ar.strategy = 'product_pick'
AND ar.status = 'pending'
ORDER BY ar.confidence DESC NULLS LAST, ar.gap_pct DESC NULLS LAST, ar.created_at DESC
LIMIT 3
""")
try:
stats = session.execute(stats_sql).mappings().first()
overview = dict(default)
if stats:
total_active = int(stats.get('total_active') or 0)
matched_count = int(stats.get('matched_count') or 0)
overview.update({
'total_active': total_active,
'matched_count': matched_count,
'match_rate': round(matched_count / max(total_active, 1) * 100, 1),
'pchome_advantage_count': int(stats.get('pchome_advantage_count') or 0),
'momo_threat_count': int(stats.get('momo_threat_count') or 0),
'near_count': int(stats.get('near_count') or 0),
'pending_match_count': max(total_active - matched_count, 0),
'ai_pick_count': int(stats.get('ai_pick_count') or 0),
'avg_advantage_gap': _to_float(stats.get('avg_advantage_gap')) or 0,
'last_pchome_crawled': _format_dashboard_dt(stats.get('last_pchome_crawled')),
})
overview['top_pchome_advantages'] = [
_dashboard_decision_row(row, 'win')
for row in session.execute(advantage_sql).mappings().all()
]
overview['top_momo_threats'] = [
_dashboard_decision_row(row, 'risk')
for row in session.execute(threat_sql).mappings().all()
]
overview['top_picks'] = [
_dashboard_decision_row(row, 'pick')
for row in session.execute(picks_sql).mappings().all()
]
overview['pending_priority'] = [
{
'sku': str(row.get('sku') or ''),
'name': row.get('name') or '',
'category': row.get('category') or '',
'momo_price': _to_float(row.get('momo_price')) or 0,
'momo_url': row.get('momo_url') or _build_momo_product_url(row.get('sku')),
}
for row in session.execute(pending_sql).mappings().all()
]
return overview
except Exception as exc:
sys_log.warning(f"[Dashboard] PChome 比價決策摘要讀取略過: {exc}")
try:
session.rollback()
except Exception:
pass
return default
# ==========================================
# 快取與監控變數
# ==========================================
@@ -734,6 +979,7 @@ def index():
competitor.get('price') if competitor else None
)
competitor_overview = _load_competitor_decision_overview(session)
template_name = 'dashboard.html' if request.args.get('ui') == 'legacy' else 'dashboard_v2.html'
return render_template(template_name,
@@ -769,6 +1015,7 @@ def index():
stable_count=stable_count,
most_active_category=most_active_category,
most_active_count=most_active_count,
competitor_overview=competitor_overview,
active_page='dashboard')
except Exception as e:
sys_log.error(f"[Web] [Dashboard] 渲染錯誤 | Error: {e}")

View File

@@ -38,7 +38,7 @@
.dashboard-kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
overflow: hidden;
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
@@ -77,12 +77,18 @@
.dashboard-kpi-value {
margin-bottom: 8px;
color: var(--momo-text-primary);
font-size: 44px;
font-size: 34px;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1;
}
.dashboard-kpi-value.is-small {
font-size: 20px;
letter-spacing: 0;
line-height: 1.15;
}
.dashboard-kpi-value.is-danger {
color: var(--momo-danger);
}
@@ -91,6 +97,10 @@
color: var(--momo-success);
}
.dashboard-kpi-value.is-warning {
color: var(--momo-warning-text);
}
.dashboard-kpi.is-accent .dashboard-kpi-value {
color: var(--momo-text-inverse);
}
@@ -150,6 +160,78 @@
font-size: 11px;
}
.dashboard-focus-list {
display: grid;
gap: 10px;
}
.dashboard-focus-row {
display: grid;
gap: 5px;
padding: 10px 0;
border-top: 1px solid var(--momo-border-light);
}
.dashboard-focus-row:first-child {
padding-top: 0;
border-top: 0;
}
.dashboard-focus-row-title {
display: -webkit-box;
overflow: hidden;
color: var(--momo-text-primary);
font-size: 13px;
font-weight: 800;
line-height: 1.35;
text-decoration: none;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.dashboard-focus-row-title:hover {
color: var(--momo-accent-strong);
}
.dashboard-focus-row-meta,
.dashboard-focus-row-links {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
color: var(--momo-text-secondary);
font-size: 11px;
}
.dashboard-focus-chip {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: var(--momo-radius-pill);
font-family: var(--momo-font-family-mono);
font-size: 10px;
font-weight: 800;
white-space: nowrap;
}
.dashboard-focus-chip.is-win {
color: var(--momo-success);
background: rgba(55, 136, 88, 0.10);
border: 1px solid rgba(55, 136, 88, 0.18);
}
.dashboard-focus-chip.is-risk {
color: var(--momo-danger);
background: rgba(191, 72, 61, 0.10);
border: 1px solid rgba(191, 72, 61, 0.18);
}
.dashboard-focus-chip.is-neutral {
color: var(--momo-text-secondary);
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
}
.dashboard-filter-card {
padding: 12px 16px;
}
@@ -597,12 +679,16 @@
}
@media (max-width: 980px) {
.dashboard-kpi-grid,
.dashboard-kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dashboard-focus-grid {
grid-template-columns: 1fr 1fr;
}
.dashboard-kpi:nth-child(2) {
.dashboard-kpi:nth-child(3),
.dashboard-kpi:nth-child(6) {
border-right: 0;
}
}
@@ -637,32 +723,43 @@
{% block ewooo_content %}
<div class="dashboard-v2-stack">
{% set overview = competitor_overview | default({}) %}
<section>
<div class="dashboard-section-label">
<span class="num momo-mono">01</span>
<span class="title">監控總覽</span>
<span class="title">比價監控總覽</span>
<span class="meta momo-mono">LIVE · 更新於 {{ datetime_now }}</span>
</div>
<div class="dashboard-kpi-grid">
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">監控總數</div>
<div class="dashboard-kpi-value momo-mono">{{ total_products | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">本週 +{{ week_new_products }}</div>
<div class="dashboard-kpi-label momo-mono">比對覆蓋率</div>
<div class="dashboard-kpi-value momo-mono">{{ overview.match_rate | default(0) }}%</div>
<div class="dashboard-kpi-sub momo-mono">{{ overview.matched_count | default(0) | number_format }} / {{ overview.total_active | default(total_products) | number_format }} ACTIVE</div>
</div>
<div class="dashboard-kpi is-accent">
<div class="dashboard-kpi-label momo-mono">今日變動</div>
<div class="dashboard-kpi-value momo-mono">{{ active_count | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">活躍度 {{ activity_rate | round(1) }}%</div>
<div class="dashboard-kpi-label momo-mono">PChome 優勢</div>
<div class="dashboard-kpi-value momo-mono">{{ overview.pchome_advantage_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">平均價差 +{{ overview.avg_advantage_gap | default(0) }}%</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">漲價</div>
<div class="dashboard-kpi-value momo-mono is-danger">{{ cnt_increase | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">平均 +${{ avg_increase | abs | int | number_format }}</div>
<div class="dashboard-kpi-label momo-mono">MOMO 威脅</div>
<div class="dashboard-kpi-value momo-mono is-danger">{{ overview.momo_threat_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">MOMO 價格低於 PChome</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">降價</div>
<div class="dashboard-kpi-value momo-mono is-success">{{ cnt_decrease | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">平均 -${{ avg_decrease | abs | int | number_format }}</div>
<div class="dashboard-kpi-label momo-mono">AI 挑品</div>
<div class="dashboard-kpi-value momo-mono is-success">{{ overview.ai_pick_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">pending product_pick</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">待比對</div>
<div class="dashboard-kpi-value momo-mono is-warning">{{ overview.pending_match_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">高價品項優先補抓</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">資料新鮮度</div>
<div class="dashboard-kpi-value momo-mono is-small">{{ '已更新' if overview.last_pchome_crawled else '待更新' }}</div>
<div class="dashboard-kpi-sub momo-mono">{{ overview.last_pchome_crawled or '尚無 PChome 抓取紀錄' }}</div>
</div>
</div>
</section>
@@ -670,50 +767,87 @@
<section>
<div class="dashboard-section-label">
<span class="num momo-mono">02</span>
<span class="title">焦點數據</span>
<span class="title">比價決策焦點</span>
<span class="meta momo-mono">{{ today_date }}</span>
</div>
<div class="dashboard-focus-grid">
<div class="dashboard-focus-card">
<div class="dashboard-focus-label momo-mono">最活躍分類</div>
{% if most_active_category %}
<div class="dashboard-focus-title">{{ most_active_category }}</div>
<div class="dashboard-focus-sub momo-mono">{{ most_active_count }} 件商品變動</div>
<div class="dashboard-focus-label momo-mono">今日優先銷售</div>
{% if overview.top_picks %}
<div class="dashboard-focus-list">
{% for pick in overview.top_picks %}
<div class="dashboard-focus-row">
<a class="dashboard-focus-row-title" href="{{ pick.momo_url }}" target="_blank" rel="noopener noreferrer">{{ pick.name }}</a>
<div class="dashboard-focus-row-meta momo-mono">
<span class="dashboard-focus-chip is-win">AI {{ (pick.confidence * 100) | round(0) | int if pick.confidence else 0 }}%</span>
<span>MOMO ${{ pick.momo_price | int | number_format }}</span>
<span>PChome ${{ pick.pchome_price | int | number_format }}</span>
<span>+{{ pick.gap_pct | round(1) }}%</span>
</div>
<div class="dashboard-focus-row-links">
<a class="dashboard-platform-link is-momo" href="{{ pick.momo_url }}" target="_blank" rel="noopener noreferrer">MOMO {{ pick.sku }}</a>
{% if pick.pchome_url %}
<a class="dashboard-platform-link is-pchome" href="{{ pick.pchome_url }}" target="_blank" rel="noopener noreferrer">PChome {{ pick.pchome_id }}</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="dashboard-focus-title">尚無分類變動</div>
<div class="dashboard-focus-sub momo-mono">今日沒有可彙整的分類異動</div>
<div class="dashboard-focus-title">尚無 AI 挑品</div>
<div class="dashboard-focus-sub momo-mono">請先讓 PChome 比對與挑品 Agent 累積資料</div>
{% endif %}
</div>
<div class="dashboard-focus-card">
<div class="dashboard-focus-label momo-mono">最大變動</div>
{% if max_change_item %}
<div class="dashboard-focus-number momo-mono">
{% if max_change_value > 0 %}+{% else %}-{% endif %}${{ max_change_value | abs | int | number_format }}
</div>
<div class="dashboard-focus-sub" title="{{ max_change_item.record.product.name }}">
{{ max_change_item.record.product.name }}
<div class="dashboard-focus-label momo-mono">價格威脅</div>
{% if overview.top_momo_threats %}
<div class="dashboard-focus-list">
{% for item in overview.top_momo_threats %}
<div class="dashboard-focus-row">
<a class="dashboard-focus-row-title" href="{{ item.momo_url }}" target="_blank" rel="noopener noreferrer">{{ item.name }}</a>
<div class="dashboard-focus-row-meta momo-mono">
<span class="dashboard-focus-chip is-risk">{{ item.gap_pct | round(1) }}%</span>
<span>MOMO ${{ item.momo_price | int | number_format }}</span>
<span>PChome ${{ item.pchome_price | int | number_format }}</span>
</div>
<div class="dashboard-focus-row-links">
<a class="dashboard-platform-link is-momo" href="{{ item.momo_url }}" target="_blank" rel="noopener noreferrer">MOMO {{ item.sku }}</a>
{% if item.pchome_url %}
<a class="dashboard-platform-link is-pchome" href="{{ item.pchome_url }}" target="_blank" rel="noopener noreferrer">PChome {{ item.pchome_id }}</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="dashboard-focus-title">尚無最大變動</div>
<div class="dashboard-focus-sub momo-mono">今日沒有價格異動</div>
<div class="dashboard-focus-title">尚無明顯威脅</div>
<div class="dashboard-focus-sub momo-mono">目前沒有 MOMO 低於 PChome 5% 以上的配對商品</div>
{% endif %}
</div>
<div class="dashboard-focus-card">
<div class="dashboard-focus-label momo-mono">爬蟲排程</div>
{% set momo_stats_list = scheduler_stats.get('momo_task', []) %}
{% if momo_stats_list %}
{% set latest_run = momo_stats_list[0] %}
<div class="dashboard-focus-title momo-mono">{{ latest_run.last_run }}</div>
<div class="dashboard-focus-sub momo-mono">
狀態 {{ latest_run.status | default('未標記') }}
{% if latest_run.scraped_count is defined %} · 掃描 {{ latest_run.scraped_count }} 筆{% endif %}
{% if latest_run.new_products is defined %} · 新增 +{{ latest_run.new_products }}{% endif %}
<div class="dashboard-focus-label momo-mono">補資料優先</div>
{% if overview.pending_priority %}
<div class="dashboard-focus-list">
{% for item in overview.pending_priority %}
<div class="dashboard-focus-row">
<a class="dashboard-focus-row-title" href="{{ item.momo_url }}" target="_blank" rel="noopener noreferrer">{{ item.name }}</a>
<div class="dashboard-focus-row-meta momo-mono">
<span class="dashboard-focus-chip is-neutral">待比對</span>
<span>MOMO ${{ item.momo_price | int | number_format }}</span>
<span>{{ item.category or '未分類' }}</span>
</div>
<div class="dashboard-focus-row-links">
<a class="dashboard-platform-link is-momo" href="{{ item.momo_url }}" target="_blank" rel="noopener noreferrer">MOMO {{ item.sku }}</a>
<span class="dashboard-platform-muted">PChome 待比對</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="dashboard-focus-title">尚無排程紀錄</div>
<div class="dashboard-focus-sub momo-mono">未讀到 scheduler_stats.json 的 momo_task 紀錄</div>
<div class="dashboard-focus-title">待比對清單已清空</div>
<div class="dashboard-focus-sub momo-mono">目前 ACTIVE 商品都有有效 PChome 配對或尚無最新 MOMO 價格</div>
{% endif %}
</div>
</div>

View File

@@ -47,8 +47,17 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "request.args.get('ui') == 'legacy'" in route_source
assert "template_name = 'dashboard.html' if request.args.get('ui') == 'legacy' else 'dashboard_v2.html'" in route_source
assert "get_full_dashboard_data()" in route_source
assert "_load_competitor_decision_overview(session)" in route_source
assert "ai_price_recommendations" in route_source
assert "pending_match_count" in route_source
assert "MockRecord" not in route_source
assert "{% for item in items %}" in dashboard
assert "比價監控總覽" in dashboard
assert "比價決策焦點" in dashboard
assert "overview.match_rate" in dashboard
assert "overview.top_picks" in dashboard
assert "overview.top_momo_threats" in dashboard
assert "overview.pending_priority" in dashboard
assert "ui='v2'" not in dashboard
assert 'name="ui" value="v2"' not in dashboard
assert "mockProducts" not in dashboard