feat(frontend): 保存 PChome 競品價格歷史
All checks were successful
CD Pipeline / deploy (push) Successful in 1m39s

This commit is contained in:
OoO
2026-05-01 00:53:37 +08:00
parent 6b8e511246
commit 55855ef508
10 changed files with 602 additions and 45 deletions

View File

@@ -2,7 +2,7 @@
> 本文件定義專案開發的核心準則與不可違反的規範
> **建立日期**: 2026-01-12
> **當前版本**: V10.42 (Campaign V2 filters and ranged price history charts)
> **當前版本**: V10.44 (Persist PChome competitor price history)
> **最後更新**: 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.42: Campaign v2 filters and ranged price history charts
SYSTEM_VERSION = "V10.42"
# 🚩 2026-05-01 V10.44: Persist PChome competitor price history
SYSTEM_VERSION = "V10.44"
# ==========================================
# 🔒 SQL Injection 防護函數

View File

@@ -0,0 +1,50 @@
-- =============================================================================
-- Migration 022: 競品價格歷史快照表
-- MOMO PRO — Dashboard PChome competitor history
-- 2026-05-01 台北
-- =============================================================================
-- 說明:
-- competitor_prices 只保存每個 SKU + source 的最新快取。
-- competitor_price_history 採 append-only 快照,保存每次 PChome feeder 實際抓到的價格、
-- 對應商品 ID、商品名稱、比對分數以及當下 MOMO 最新價格,供週/月/季/年歷史圖表使用。
-- =============================================================================
CREATE TABLE IF NOT EXISTS competitor_price_history (
id BIGSERIAL PRIMARY KEY,
-- MOMO 側商品識別
sku VARCHAR(50) NOT NULL,
source VARCHAR(30) NOT NULL DEFAULT 'pchome',
momo_product_id INTEGER,
momo_price NUMERIC(10,2),
-- 競品側價格快照
price NUMERIC(10,2) NOT NULL,
original_price NUMERIC(10,2),
discount_pct INTEGER,
-- 競品側商品識別
competitor_product_id VARCHAR(100),
competitor_product_name TEXT,
-- 比對品質與語意標籤
match_score NUMERIC(4,3),
tags JSONB DEFAULT '[]'::jsonb,
-- 每次 feeder 寫入一筆,保留完整歷史
crawled_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comp_price_history_sku_source_time
ON competitor_price_history (sku, source, crawled_at DESC);
CREATE INDEX IF NOT EXISTS idx_comp_price_history_competitor_id
ON competitor_price_history (competitor_product_id);
GRANT ALL PRIVILEGES ON competitor_price_history TO momo;
GRANT USAGE, SELECT ON SEQUENCE competitor_price_history_id_seq TO momo;
DO $$
BEGIN
RAISE NOTICE '✅ Migration 022 完成 — competitor_price_history 歷史快照表已建立';
END $$;

View File

@@ -10,7 +10,7 @@ import threading
import importlib
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify
from sqlalchemy import func, desc
from sqlalchemy import func, desc, text
from auth import login_required
from config import BASE_DIR
@@ -315,6 +315,41 @@ def _build_price_history_payload(session, product):
'p': r.price
} for r in records]
competitor_data = []
competitor_latest = None
try:
competitor_rows = session.execute(text("""
SELECT
price,
momo_price,
competitor_product_id,
competitor_product_name,
match_score,
crawled_at
FROM competitor_price_history
WHERE sku = :sku
AND source = 'pchome'
AND crawled_at >= :start_date
ORDER BY crawled_at
"""), {
"sku": str(product.i_code),
"start_date": start_date.replace(tzinfo=None),
}).mappings().all()
competitor_data = [{
't': r['crawled_at'].strftime('%Y-%m-%d %H:%M'),
'p': float(r['price']),
'momo_price': float(r['momo_price']) if r['momo_price'] is not None else None,
'product_id': r['competitor_product_id'],
'product_name': r['competitor_product_name'],
'match_score': float(r['match_score']) if r['match_score'] is not None else None,
} for r in competitor_rows]
if competitor_data:
competitor_latest = competitor_data[-1]
except Exception as exc:
sys_log.warning(
f"[Web] [History] PChome 競品歷史資料讀取略過 | ICode: {product.i_code} | Error: {exc}"
)
return {
'range': range_key,
'range_label': range_meta['label'],
@@ -323,7 +358,12 @@ def _build_price_history_payload(session, product):
'i_code': product.i_code,
'name': product.name,
},
'data': data
'data': data,
'series': {
'momo': data,
'pchome': competitor_data,
},
'competitor': competitor_latest,
}

View File

@@ -12,7 +12,7 @@ import time
import hashlib
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, render_template
from sqlalchemy import func, and_
from sqlalchemy import func, and_, text, bindparam
from sqlalchemy.orm import joinedload
from auth import login_required
@@ -32,6 +32,108 @@ sys_log = SystemLogger("DashboardRoutes").get_logger()
dashboard_bp = Blueprint('dashboard', __name__)
def _build_pchome_product_url(product_id):
if not product_id:
return None
return f"https://24h.pchome.com.tw/prod/{str(product_id).strip()}"
def _to_float(value):
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _build_competitor_decision(momo_price, pchome_price):
if not pchome_price:
return {
'label': '待比對',
'tone': 'neutral',
'gap_amount': None,
'gap_pct': None,
'summary': '尚無 PChome 對應商品或價格快取'
}
momo_price = float(momo_price or 0)
pchome_price = float(pchome_price)
gap_amount = momo_price - pchome_price
gap_pct = (gap_amount / pchome_price * 100) if pchome_price else 0
if gap_pct >= 5:
return {
'label': 'PChome 優勢',
'tone': 'win',
'gap_amount': gap_amount,
'gap_pct': gap_pct,
'summary': 'PChome 較便宜,可加強曝光與轉換'
}
if gap_pct <= -5:
return {
'label': 'MOMO 威脅',
'tone': 'risk',
'gap_amount': gap_amount,
'gap_pct': gap_pct,
'summary': 'MOMO 較便宜,需評估價格或促銷因應'
}
return {
'label': '價格接近',
'tone': 'watch',
'gap_amount': gap_amount,
'gap_pct': gap_pct,
'summary': '價差有限,建議主打服務、到貨或回饋'
}
def _load_pchome_competitor_map(session, skus):
sku_list = [str(sku) for sku in skus if sku]
if not sku_list:
return {}
try:
stmt = text("""
SELECT
sku,
price,
original_price,
discount_pct,
competitor_product_id,
competitor_product_name,
match_score,
tags,
crawled_at,
expires_at
FROM competitor_prices
WHERE source = 'pchome'
AND sku IN :skus
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
""").bindparams(bindparam("skus", expanding=True))
rows = session.execute(stmt, {"skus": sku_list}).mappings().all()
except Exception as exc:
sys_log.warning(f"[Dashboard] PChome 競品價格資料讀取略過: {exc}")
return {}
result = {}
for row in rows:
competitor_product_id = row.get('competitor_product_id')
result[str(row.get('sku'))] = {
'source': 'pchome',
'price': _to_float(row.get('price')),
'original_price': _to_float(row.get('original_price')),
'discount_pct': row.get('discount_pct'),
'product_id': competitor_product_id,
'product_name': row.get('competitor_product_name'),
'product_url': _build_pchome_product_url(competitor_product_id),
'match_score': _to_float(row.get('match_score')),
'tags': row.get('tags'),
'crawled_at': row.get('crawled_at'),
'expires_at': row.get('expires_at'),
}
return result
# ==========================================
# 快取與監控變數
# ==========================================
@@ -619,6 +721,19 @@ def index():
category_name = item['record'].product.category
item['category_color'] = get_color_for_string(category_name)
pchome_map = _load_pchome_competitor_map(
session,
[item['record'].product.i_code for item in paged_items]
)
for item in paged_items:
product = item['record'].product
competitor = pchome_map.get(str(product.i_code))
item['pchome_competitor'] = competitor
item['competitor_decision'] = _build_competitor_decision(
item['record'].price,
competitor.get('price') if competitor else None
)
template_name = 'dashboard.html' if request.args.get('ui') == 'legacy' else 'dashboard_v2.html'
return render_template(template_name,

View File

@@ -2004,7 +2004,8 @@ def run_auto_import_task():
def run_competitor_price_feeder_task():
"""
競品價格補給線排程任務(每 4 小時執行一次)
從 PChome 抓取競品價格寫入 competitor_prices 表,供 HermesAnalystService 使用。
從 PChome 抓取競品價格,同步寫入 competitor_prices 最新快取與
competitor_price_history 歷史快照,供 HermesAnalystService 與前端歷史圖表使用。
與 AI Pipeline 完全解耦:本任務失敗不影響核心分析流程。
ADR-ICAIM-2026-04-17 新增
"""
@@ -2027,6 +2028,7 @@ def run_competitor_price_feeder_task():
"skipped_low_score": result.skipped_low_score,
"errors": result.errors,
"duration_sec": result.duration_sec,
"history_written": result.history_written,
"status": "Success",
}
logging.info(
@@ -2034,6 +2036,7 @@ def run_competitor_price_feeder_task():
f"matched={result.matched}/{result.total_skus} "
f"skip_no={result.skipped_no_result} "
f"skip_low={result.skipped_low_score} "
f"history_written={result.history_written} "
f"errors={result.errors} "
f"耗時={result.duration_sec}s"
)

View File

@@ -103,7 +103,7 @@ def _fetch_price_history(sku: str, days: int = 30) -> Dict[str, Any]:
comp_rows = session.execute(text("""
SELECT crawled_at::date AS dt, AVG(price) AS price
FROM competitor_prices
FROM competitor_price_history
WHERE sku = :sku
AND source = 'pchome'
AND crawled_at >= NOW() - INTERVAL ':days days'
@@ -433,7 +433,7 @@ def price_history_heatmap(days: int = 30) -> Optional[bytes]:
SELECT p.category,
cp.crawled_at::date AS dt,
AVG((cp.price - pr.price) / NULLIF(pr.price, 0) * 100) AS gap_pct
FROM competitor_prices cp
FROM competitor_price_history cp
JOIN products p ON p.i_code = cp.sku
JOIN (
SELECT DISTINCT ON (product_id) product_id, price

View File

@@ -5,7 +5,8 @@
角色:獨立背景 Worker生產者端
架構位置:
[本 Worker — 每 4 小時跑一次] → competitor_prices DB 表
[本 Worker — 每 4 小時跑一次] → competitor_prices DB 表(最新快取)
→ competitor_price_history DB 表(歷史快照)
[AI Pipeline] → fetch_candidates() LEFT JOIN competitor_prices消費者端
@@ -15,7 +16,7 @@
- 語意化標籤 (tags) 讓 Hermes 獲得更豐富的情境
爬取邏輯:
MOMO 商品名稱 → PChome 關鍵字搜尋 → 模糊比對最佳匹配 → 寫入 competitor_prices
MOMO 商品名稱 → PChome 關鍵字搜尋 → 模糊比對最佳匹配 → 寫入 competitor_prices + competitor_price_history
依賴:
services/pchome_crawler.py — 搜尋 + 批量 API
@@ -47,6 +48,7 @@ class FeederResult:
skipped_low_score: int
errors: int
duration_sec: float
history_written: int = 0
def _extract_tags(pchome_product) -> list:
@@ -183,6 +185,68 @@ class CompetitorPriceFeeder:
def __init__(self, engine=None):
self.engine = engine
self._history_table_ready = False
def _ensure_competitor_price_history_table(self, conn):
"""確保競品價格歷史表存在;排程可自癒補表,不依賴手動 migration。"""
if self._history_table_ready:
return
from sqlalchemy import text
if conn.dialect.name == "postgresql":
conn.execute(text("""
CREATE TABLE IF NOT EXISTS competitor_price_history (
id BIGSERIAL PRIMARY KEY,
sku VARCHAR(50) NOT NULL,
source VARCHAR(30) NOT NULL DEFAULT 'pchome',
momo_product_id INTEGER,
momo_price NUMERIC(10,2),
price NUMERIC(10,2) NOT NULL,
original_price NUMERIC(10,2),
discount_pct INTEGER,
competitor_product_id VARCHAR(100),
competitor_product_name TEXT,
match_score NUMERIC(4,3),
tags JSONB DEFAULT '[]'::jsonb,
crawled_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_comp_price_history_sku_source_time
ON competitor_price_history (sku, source, crawled_at DESC)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_comp_price_history_competitor_id
ON competitor_price_history (competitor_product_id)
"""))
else:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS competitor_price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku VARCHAR(50) NOT NULL,
source VARCHAR(30) NOT NULL DEFAULT 'pchome',
momo_product_id INTEGER,
momo_price NUMERIC(10,2),
price NUMERIC(10,2) NOT NULL,
original_price NUMERIC(10,2),
discount_pct INTEGER,
competitor_product_id VARCHAR(100),
competitor_product_name TEXT,
match_score NUMERIC(4,3),
tags TEXT DEFAULT '[]',
crawled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_comp_price_history_sku_source_time
ON competitor_price_history (sku, source, crawled_at DESC)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_comp_price_history_competitor_id
ON competitor_price_history (competitor_product_id)
"""))
self._history_table_ready = True
def _fetch_active_skus(self) -> list:
"""
@@ -196,10 +260,25 @@ class CompetitorPriceFeeder:
from sqlalchemy import text
sql = text("""
SELECT DISTINCT p.i_code AS sku, p.name, p.category
SELECT
p.id AS product_id,
p.i_code AS sku,
p.name,
p.category,
(
SELECT pr.price
FROM price_records pr
WHERE pr.product_id = p.id
ORDER BY pr.timestamp DESC
LIMIT 1
) AS momo_price
FROM products p
JOIN price_records pr ON pr.product_id = p.id
WHERE p.status = 'ACTIVE'
AND EXISTS (
SELECT 1
FROM price_records pr
WHERE pr.product_id = p.id
)
ORDER BY p.i_code
""")
with self.engine.connect() as conn:
@@ -212,13 +291,17 @@ class CompetitorPriceFeeder:
product, # PChomeProduct
match_score: float,
tags: list,
momo_product_id: int = None,
momo_price: float = None,
source: str = "pchome",
):
"""單筆寫入/更新 competitor_prices"""
"""單筆寫入/更新最新快取,並追加一筆歷史快照。"""
from sqlalchemy import text
_taipei = timezone(timedelta(hours=8))
expires_at = (datetime.now(_taipei) + timedelta(hours=TTL_HOURS)).strftime("%Y-%m-%d %H:%M:%S")
tags_json = json.dumps(tags, ensure_ascii=False)
with self.engine.begin() as conn:
self._ensure_competitor_price_history_table(conn)
conn.execute(text("""
INSERT INTO competitor_prices
(sku, source, price, original_price, discount_pct,
@@ -247,9 +330,33 @@ class CompetitorPriceFeeder:
"comp_id": product.product_id,
"comp_name": product.name[:200],
"match_score": match_score,
"tags": json.dumps(tags, ensure_ascii=False),
"tags": tags_json,
"expires_at": expires_at,
})
conn.execute(text("""
INSERT INTO competitor_price_history
(sku, source, momo_product_id, momo_price,
price, original_price, discount_pct,
competitor_product_id, competitor_product_name,
match_score, tags, crawled_at)
VALUES
(:sku, :source, :momo_product_id, :momo_price,
:price, :original_price, :discount_pct,
:comp_id, :comp_name,
:match_score, :tags, CURRENT_TIMESTAMP)
"""), {
"sku": sku,
"source": source,
"momo_product_id": momo_product_id,
"momo_price": momo_price,
"price": product.price,
"original_price": product.original_price,
"discount_pct": product.discount,
"comp_id": product.product_id,
"comp_name": product.name[:200],
"match_score": match_score,
"tags": tags_json,
})
def run(self, source: str = "pchome") -> FeederResult:
"""
@@ -283,10 +390,13 @@ class CompetitorPriceFeeder:
skipped_no = 0
skipped_low = 0
errors = 0
history_written = 0
for item in skus:
sku = item["sku"]
momo_name = item["name"]
momo_product_id = item.get("product_id")
momo_price = item.get("momo_price")
# 用商品名稱前 20 字搜尋(避免 query 過長)
keyword = momo_name[:20].strip()
@@ -313,8 +423,17 @@ class CompetitorPriceFeeder:
continue
tags = _extract_tags(best_product)
self._upsert_competitor_price(sku, best_product, score, tags, source)
self._upsert_competitor_price(
sku,
best_product,
score,
tags,
momo_product_id=momo_product_id,
momo_price=momo_price,
source=source,
)
matched += 1
history_written += 1
logger.debug(
f"[Feeder] {sku} → PChome ${best_product.price} "
f"score={score:.3f} tags={tags}"
@@ -327,7 +446,8 @@ class CompetitorPriceFeeder:
duration = round(time.time() - start, 2)
logger.info(
f"[Feeder] 完成 matched={matched} skipped_no={skipped_no} "
f"skipped_low={skipped_low} errors={errors} 耗時={duration}s"
f"skipped_low={skipped_low} errors={errors} "
f"history_written={history_written} 耗時={duration}s"
)
return FeederResult(
total_skus=len(skus),
@@ -336,6 +456,7 @@ class CompetitorPriceFeeder:
skipped_low_score=skipped_low,
errors=errors,
duration_sec=duration,
history_written=history_written,
)

View File

@@ -271,7 +271,7 @@
.dashboard-table {
width: 100%;
min-width: 980px;
min-width: 1260px;
border-collapse: collapse;
font-size: var(--momo-font-size-sm);
}
@@ -365,12 +365,108 @@
font-size: 11px;
}
.dashboard-platform-links {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.dashboard-platform-link,
.dashboard-platform-muted {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
border-radius: var(--momo-radius-pill);
font-family: var(--momo-font-family-mono);
font-size: 10px;
font-weight: 800;
text-decoration: none;
}
.dashboard-platform-link.is-momo {
color: var(--momo-text-primary);
background: var(--momo-bg-subtle);
border: 1px solid var(--momo-border-light);
}
.dashboard-platform-link.is-pchome {
color: var(--momo-accent-strong);
background: var(--momo-accent-soft);
border: 1px solid rgba(190, 106, 45, 0.24);
}
.dashboard-platform-muted {
color: var(--momo-text-tertiary);
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
}
.dashboard-price {
color: var(--momo-text-primary);
font-size: 16px;
font-weight: 800;
}
.dashboard-price-sub {
margin-top: 3px;
color: var(--momo-text-tertiary);
font-size: 10px;
}
.dashboard-pchome-price {
color: var(--momo-accent-strong);
font-size: 16px;
font-weight: 800;
}
.dashboard-competition-card {
display: grid;
gap: 4px;
min-width: 130px;
}
.dashboard-competition-badge {
display: inline-flex;
width: fit-content;
align-items: center;
padding: 3px 8px;
border-radius: var(--momo-radius-pill);
font-size: 11px;
font-weight: 800;
}
.dashboard-competition-badge.is-win {
color: var(--momo-success);
background: rgba(55, 136, 88, 0.10);
border: 1px solid rgba(55, 136, 88, 0.18);
}
.dashboard-competition-badge.is-risk {
color: var(--momo-danger);
background: rgba(191, 72, 61, 0.10);
border: 1px solid rgba(191, 72, 61, 0.18);
}
.dashboard-competition-badge.is-watch {
color: var(--momo-warning-text);
background: var(--momo-warning-bg);
border: 1px solid rgba(161, 111, 35, 0.18);
}
.dashboard-competition-badge.is-neutral {
color: var(--momo-text-secondary);
background: var(--momo-bg-paper);
border: 1px solid var(--momo-border-light);
}
.dashboard-competition-meta {
color: var(--momo-text-tertiary);
font-size: 10px;
line-height: 1.5;
}
.dashboard-history-button {
display: inline-flex;
align-items: center;
@@ -684,8 +780,10 @@
<th>分類</th>
<th>商品名稱</th>
<th class="text-end">
<a href="{{ url_for('dashboard.index', page=1, sort_by='price', order='asc' if current_sort == 'price' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">當天價格</a>
<a href="{{ url_for('dashboard.index', page=1, sort_by='price', order='asc' if current_sort == 'price' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">MOMO 價格</a>
</th>
<th class="text-end">PChome 價格</th>
<th>競價判讀</th>
<th class="text-end">
<a href="{{ url_for('dashboard.index', page=1, sort_by='yesterday_change', order='asc' if current_sort == 'yesterday_change' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">昨日漲跌</a>
</th>
@@ -701,6 +799,8 @@
<tbody>
{% for item in items %}
{% set product = item.record.product %}
{% set competitor = item.pchome_competitor %}
{% set decision = item.competitor_decision %}
{% set image_url = product.image_url or ('https://m.momoshop.com.tw/moscdn/goods/' ~ product.i_code ~ '_m.webp') %}
<tr class="is-history-enabled" data-product-id="{{ product.id }}" data-product-name="{{ product.name|e }}" title="點擊查看歷史價格圖表">
<td><span class="dashboard-category">{{ product.category or '未分類' }}</span></td>
@@ -709,7 +809,23 @@
<img class="dashboard-product-thumb" src="{{ image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer">
<div>
<a class="dashboard-product-name" href="{{ product.url }}" target="_blank" rel="noopener noreferrer">{{ product.name }}</a>
<div class="dashboard-product-id momo-mono">ID {{ product.i_code }}</div>
<div class="dashboard-platform-links">
<a class="dashboard-platform-link is-momo" href="{{ product.url }}" target="_blank" rel="noopener noreferrer">
MOMO {{ product.i_code }}
</a>
{% if competitor and competitor.product_url %}
<a class="dashboard-platform-link is-pchome" href="{{ competitor.product_url }}" target="_blank" rel="noopener noreferrer">
PChome {{ competitor.product_id }}
</a>
{% elif competitor and competitor.product_id %}
<span class="dashboard-platform-muted">PChome {{ competitor.product_id }}</span>
{% else %}
<span class="dashboard-platform-muted">PChome 待比對</span>
{% endif %}
</div>
{% if competitor and competitor.product_name %}
<div class="dashboard-product-id momo-mono" title="{{ competitor.product_name }}">PChome{{ competitor.product_name }}</div>
{% endif %}
</div>
</div>
</td>
@@ -727,6 +843,29 @@
<i class="fas fa-chart-line" aria-hidden="true"></i>
</button>
</td>
<td class="text-end momo-mono">
{% if competitor and competitor.price %}
<div class="dashboard-pchome-price">${{ competitor.price | int | number_format }}</div>
{% if competitor.match_score %}
<div class="dashboard-price-sub">match {{ (competitor.match_score * 100) | round(0) | int }}%</div>
{% endif %}
{% else %}
<span style="color:var(--momo-text-tertiary);">待比對</span>
{% endif %}
</td>
<td>
<div class="dashboard-competition-card">
<span class="dashboard-competition-badge is-{{ decision.tone }}">{{ decision.label }}</span>
{% if decision.gap_pct is not none %}
<span class="dashboard-competition-meta momo-mono">
MOMO - PChome
{% if decision.gap_amount > 0 %}+{% endif %}${{ decision.gap_amount | round(0) | int | number_format }}
/ {% if decision.gap_pct > 0 %}+{% endif %}{{ decision.gap_pct | round(1) }}%
</span>
{% endif %}
<span class="dashboard-competition-meta">{{ decision.summary }}</span>
</div>
</td>
<td class="text-end momo-mono">
{% if item.yesterday_diff > 0 %}
<span class="dashboard-change-up">▲ +{{ item.yesterday_diff | abs | int | number_format }}</span>
@@ -755,7 +894,7 @@
</tr>
{% else %}
<tr>
<td colspan="7">
<td colspan="9">
<div class="dashboard-empty">
{% if search_query %}
找不到與「{{ search_query }}」相關的商品
@@ -884,41 +1023,76 @@
return response.json();
})
.then(data => {
const points = Array.isArray(data) ? data : (data.data || []);
const series = Array.isArray(data) ? { momo: data, pchome: [] } : (data.series || {});
const momoPoints = Array.isArray(series.momo) ? series.momo : (Array.isArray(data.data) ? data.data : []);
const pchomePoints = Array.isArray(series.pchome) ? series.pchome : [];
const rangeLabel = Array.isArray(data) ? '' : (data.range_label || '');
if (rangeLabel) {
subtitle.textContent = `商品 ID ${productId} · ${rangeLabel}真實價格紀錄`;
const pchomeNote = pchomePoints.length > 0 ? ' · 含 PChome 歷史快照' : '';
subtitle.textContent = `商品 ID ${productId} · ${rangeLabel}真實價格紀錄${pchomeNote}`;
}
if (!Array.isArray(points) || points.length === 0) {
if (momoPoints.length === 0 && pchomePoints.length === 0) {
setHistoryChartState('目前沒有可顯示的歷史價格紀錄。');
return;
}
setHistoryChartState('', true);
const ctx = canvas.getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 0, 380);
gradient.addColorStop(0, 'rgba(190, 106, 45, 0.26)');
gradient.addColorStop(1, 'rgba(190, 106, 45, 0.04)');
const momoGradient = ctx.createLinearGradient(0, 0, 0, 380);
momoGradient.addColorStop(0, 'rgba(190, 106, 45, 0.26)');
momoGradient.addColorStop(1, 'rgba(190, 106, 45, 0.04)');
const pchomeGradient = ctx.createLinearGradient(0, 0, 0, 380);
pchomeGradient.addColorStop(0, 'rgba(70, 127, 181, 0.18)');
pchomeGradient.addColorStop(1, 'rgba(70, 127, 181, 0.03)');
const labels = Array.from(new Set([
...momoPoints.map(point => point.t),
...pchomePoints.map(point => point.t)
])).sort();
const toPriceMap = points => points.reduce((acc, point) => {
acc[point.t] = point.p;
return acc;
}, {});
const momoMap = toPriceMap(momoPoints);
const pchomeMap = toPriceMap(pchomePoints);
const datasets = [{
label: 'MOMO',
data: labels.map(label => momoMap[label] ?? null),
borderColor: '#be6a2d',
backgroundColor: momoGradient,
borderWidth: 3,
fill: true,
tension: 0.35,
spanGaps: true,
pointRadius: 3,
pointHoverRadius: 7,
pointBackgroundColor: '#be6a2d',
pointBorderColor: '#fff',
pointBorderWidth: 2
}];
if (pchomePoints.length > 0) {
datasets.push({
label: 'PChome',
data: labels.map(label => pchomeMap[label] ?? null),
borderColor: '#467fb5',
backgroundColor: pchomeGradient,
borderWidth: 2,
fill: false,
tension: 0.28,
spanGaps: true,
pointRadius: 3,
pointHoverRadius: 7,
pointBackgroundColor: '#467fb5',
pointBorderColor: '#fff',
pointBorderWidth: 2
});
}
priceChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: points.map(point => point.t),
datasets: [{
label: '價格',
data: points.map(point => point.p),
borderColor: '#be6a2d',
backgroundColor: gradient,
borderWidth: 3,
fill: true,
tension: 0.35,
pointRadius: 3,
pointHoverRadius: 7,
pointBackgroundColor: '#be6a2d',
pointBorderColor: '#fff',
pointBorderWidth: 2
}]
labels,
datasets
},
options: {
responsive: true,
@@ -928,17 +1102,25 @@
intersect: false
},
plugins: {
legend: { display: false },
legend: {
display: pchomePoints.length > 0,
labels: {
color: '#6d604f',
usePointStyle: true,
boxWidth: 8,
boxHeight: 8
}
},
tooltip: {
backgroundColor: 'rgba(55, 45, 35, 0.94)',
titleColor: '#faf7f0',
bodyColor: '#faf7f0',
borderColor: '#be6a2d',
borderWidth: 1,
displayColors: false,
displayColors: true,
padding: 12,
callbacks: {
label: context => '價格 ' + formatPriceTick(context.parsed.y)
label: context => `${context.dataset.label} ${formatPriceTick(context.parsed.y)}`
}
}
},

View File

@@ -82,6 +82,52 @@ def test_dashboard_v2_restores_real_price_history_chart():
assert "目前沒有可顯示的歷史價格紀錄" in dashboard
def test_dashboard_v2_shows_pchome_competitor_pricing_and_links():
route_source = (ROOT / "routes/dashboard_routes.py").read_text(encoding="utf-8")
api_source = (ROOT / "routes/api_routes.py").read_text(encoding="utf-8")
feeder_source = (ROOT / "services/competitor_price_feeder.py").read_text(encoding="utf-8")
scheduler_source = (ROOT / "scheduler.py").read_text(encoding="utf-8")
dashboard = (ROOT / "templates/dashboard_v2.html").read_text(encoding="utf-8")
migration = (ROOT / "migrations/022_competitor_price_history.sql").read_text(encoding="utf-8")
assert "_load_pchome_competitor_map(" in route_source
assert "FROM competitor_prices" in route_source
assert "competitor_product_id" in route_source
assert "https://24h.pchome.com.tw/prod/" in route_source
assert "_build_competitor_decision(" in route_source
assert "PChome 優勢" in route_source
assert "MOMO 威脅" in route_source
assert "item['pchome_competitor']" in route_source
assert "item['competitor_decision']" in route_source
assert "MOMO 價格" in dashboard
assert "PChome 價格" in dashboard
assert "競價判讀" in dashboard
assert "MOMO {{ product.i_code }}" in dashboard
assert "PChome {{ competitor.product_id }}" in dashboard
assert "competitor.product_url" in dashboard
assert "dashboard-competition-badge" in dashboard
assert "decision.summary" in dashboard
assert "PChome 待比對" in dashboard
assert "series.pchome" in dashboard
assert "label: 'PChome'" in dashboard
assert "含 PChome 歷史快照" in dashboard
assert "competitor_price_history" in migration
assert "momo_price" in migration
assert "competitor_product_id" in migration
assert "CREATE INDEX IF NOT EXISTS idx_comp_price_history_sku_source_time" in migration
assert "_ensure_competitor_price_history_table" in feeder_source
assert "INSERT INTO competitor_price_history" in feeder_source
assert "history_written" in feeder_source
assert "momo_price" in feeder_source
assert "competitor_price_history" in api_source
assert "'series': {" in api_source
assert "'pchome': competitor_data" in api_source
assert "history_written" in scheduler_source
def test_edm_dashboard_v2_is_production_default_and_uses_real_campaign_data():
route_source = (ROOT / "routes/edm_routes.py").read_text(encoding="utf-8")
template = (ROOT / "templates/edm_dashboard_v2.html").read_text(encoding="utf-8")