From 55855ef50845825f73abdcd2af330610520cf836 Mon Sep 17 00:00:00 2001 From: OoO Date: Fri, 1 May 2026 00:53:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E4=BF=9D=E5=AD=98=20PChome?= =?UTF-8?q?=20=E7=AB=B6=E5=93=81=E5=83=B9=E6=A0=BC=E6=AD=B7=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONSTITUTION.md | 2 +- app.py | 4 +- migrations/022_competitor_price_history.sql | 50 ++++ routes/api_routes.py | 44 +++- routes/dashboard_routes.py | 117 +++++++++- scheduler.py | 5 +- services/chart_generator_service.py | 4 +- services/competitor_price_feeder.py | 137 ++++++++++- templates/dashboard_v2.html | 238 +++++++++++++++++--- tests/test_frontend_v2_assets.py | 46 ++++ 10 files changed, 602 insertions(+), 45 deletions(-) create mode 100644 migrations/022_competitor_price_history.sql diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 9d4682b..3b698fb 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -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 --- diff --git a/app.py b/app.py index 87038cb..df6b906 100644 --- a/app.py +++ b/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 防護函數 diff --git a/migrations/022_competitor_price_history.sql b/migrations/022_competitor_price_history.sql new file mode 100644 index 0000000..d11f363 --- /dev/null +++ b/migrations/022_competitor_price_history.sql @@ -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 $$; diff --git a/routes/api_routes.py b/routes/api_routes.py index 9f995e7..86e04fe 100644 --- a/routes/api_routes.py +++ b/routes/api_routes.py @@ -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, } diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index cd5b891..f2413ba 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -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, diff --git a/scheduler.py b/scheduler.py index 645ef97..bbc96cb 100644 --- a/scheduler.py +++ b/scheduler.py @@ -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" ) diff --git a/services/chart_generator_service.py b/services/chart_generator_service.py index 8416012..cdc8d34 100644 --- a/services/chart_generator_service.py +++ b/services/chart_generator_service.py @@ -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 diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 2e77b95..a10b656 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -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, ) diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 1ca4f1b..888c4b6 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -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 @@ 分類 商品名稱 - 當天價格 + MOMO 價格 + PChome 價格 + 競價判讀 昨日漲跌 @@ -701,6 +799,8 @@ {% 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') %} {{ product.category or '未分類' }} @@ -709,7 +809,23 @@ {{ product.name }}
{{ product.name }} -
ID {{ product.i_code }}
+ + {% if competitor and competitor.product_name %} +
PChome:{{ competitor.product_name }}
+ {% endif %}
@@ -727,6 +843,29 @@ + + {% if competitor and competitor.price %} +
${{ competitor.price | int | number_format }}
+ {% if competitor.match_score %} +
match {{ (competitor.match_score * 100) | round(0) | int }}%
+ {% endif %} + {% else %} + 待比對 + {% endif %} + + +
+ {{ decision.label }} + {% if decision.gap_pct is not none %} + + 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) }}% + + {% endif %} + {{ decision.summary }} +
+ {% if item.yesterday_diff > 0 %} ▲ +{{ item.yesterday_diff | abs | int | number_format }} @@ -755,7 +894,7 @@ {% else %} - +
{% 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)}` } } }, diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index ad8af4c..5a5b8f3 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -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")