feat(frontend): 保存 PChome 競品價格歷史
All checks were successful
CD Pipeline / deploy (push) Successful in 1m39s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m39s
This commit is contained in:
@@ -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
4
app.py
@@ -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 防護函數
|
||||
|
||||
50
migrations/022_competitor_price_history.sql
Normal file
50
migrations/022_competitor_price_history.sql
Normal 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 $$;
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user