This commit is contained in:
@@ -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
4
app.py
@@ -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 防護函數
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 挑品與待比對優先清單。
|
||||
|
||||
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|
||||
|------|------|------|------|---------|
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user