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 @@