From 5b9f712abe6d5eb7c0f957d8271a3ee2d363d5ab Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 20 May 2026 10:41:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=B7=E5=8C=96=20PChome=20=E8=A6=86?= =?UTF-8?q?=E6=A0=B8=E8=88=87=E5=80=99=E9=81=B8=E6=B1=BA=E7=AD=96=E9=96=89?= =?UTF-8?q?=E7=92=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 2 +- routes/dashboard_routes.py | 15 +- services/competitor_match_review_service.py | 23 ++ services/competitor_price_feeder.py | 70 +++-- templates/dashboard_v2.html | 10 + tests/test_competitor_identity_revalidator.py | 6 +- ...t_competitor_match_attempts_persistence.py | 254 ++++++++++++++++++ web/static/css/ewoooc-v3-page-guard.css | 13 +- web/static/js/page-dashboard-v2.js | 7 +- 11 files changed, 367 insertions(+), 36 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index fa47ada..8874a78 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.310 強化 MOMO/PChome 核心比價閉環:PChome feeder 搜尋候選只有強同款 `0.90` 才提前停止,避免第一個 0.76 次佳候選卡掉後續精準搜尋詞;人工否決的候選會被跳過並改挑下一個候選,不再讓已否決商品長期阻塞同 SKU。人工 `reject_identity`、`unit_price_required`、`needs_research` 會立即讓同候選正式 `competitor_prices` 過期,Dashboard 即使尚有舊價也不再顯示正式總價差;手機版比價覆核欄位標籤、覆核按鈕冒泡與候選證據顯示同步修正。 - V10.308 修正商品列表 PChome 比價閉環狀態:`manual_rejected`、`manual_unit_price_required`、`manual_needs_research` 不再掉回籠統「待比對」,改顯示「人工已否決 / 人工標記單位價 / 人工要求補搜尋」與後續 feeder 行為說明,避免人工覆核後 UI 看起來像沒有處理。 - V10.307 將 PChome 人工覆核成效接進 daily/growth/PPT 共用資料出口:`fetch_competitor_coverage()` 讀取 `competitor_match_reviews` 最新決策,輸出人工採用、人工否決、人工單位價與採用率;`daily_sales` 與 `growth_analysis` 的比價資料品質區塊直接顯示這些閉環指標,讓報表與簡報不只看待審數,也能看人工處理成效。 - V10.305 將 PChome 人工覆核回饋接回 feeder:下一輪搜尋若命中已被 `reject_identity` 否決的同一候選,會記錄 `manual_rejected` 並跳過正式寫入;已被標記 `unit_price_required` 的候選只保留單位價比較,不寫入正式總價差;人工 `accept_identity` 可保守覆蓋低分門檻但會打 `manual_review/manual_accept` 標籤,讓核心比價閉環可被後續報表與簡報追蹤。 diff --git a/config.py b/config.py index 62cdf94..acf84b9 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.309" +SYSTEM_VERSION = "V10.310" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index c31ec60..ccf6d2f 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -56,7 +56,7 @@ SQL漏斗(~300筆) - 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。 - 排程閉環:`run_pchome_match_backfill_task` 每日 10:30 執行,補抓 PChome 待比對商品、寫入歷史價格,再重算 `strategy='product_pick'` 清單。 - PChome / MOMO 競價摘要出口 `services/competitor_intel_repository.py` 使用 30 分鐘共享快取(`COMPETITOR_INTEL_CACHE_TTL_SECONDS` 可調),避免 `/growth_analysis`、`/daily_sales`、PPT/AI 報表每次請求重跑昂貴覆蓋率與價差趨勢查詢;`run_competitor_price_feeder_task` 與 PChome backfill 完成後會主動清除快取。快取只包摘要輸出,不改 matcher 的高信心門檻與 identity_v2 準確性規則。 -- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`competitor_match_attempts`、`competitor_match_reviews`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品、待比對優先清單與 PChome 覆核隊列;`filter=ai_picks` 可查看 50 品 AI 挑品列表,`filter=pchome_review` 可直接查看需人工處理的比價覆核 SKU,並以 DB 分頁支援 search/category/status 後的完整隊列,不得只截前 50 筆。覆核狀態篩選必須至少包含全部、需單位價、身份否決、低信心、價格過期與找不到同款,讓人工可依 matcher 診斷類型分批處理。列內顯示候選 PChome 商品、候選價、match score、單位價換算摘要、人工動作與 matcher 診斷原因標籤(品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等),不得只顯示籠統「待比對」。`/api/export/excel/pchome-review` 必須匯出同一套覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,讓人工覆核、簡報與後續 AI 分析共用同一份證據。`/api/pchome-review//decision` 是人工閉環入口:`accept_identity` 才可把候選寫入 `competitor_prices` 與 `competitor_price_history` 並打上 `manual_review/manual_accept/identity_v2`;`reject_identity` 與 `unit_price_required` 只寫 `competitor_match_reviews` 並追加 manual attempt,不得把不同販售組合或否決候選灌入正式價差。PChome feeder 後續搜尋同一候選時必須讀取 `competitor_match_reviews`:已否決候選寫 `manual_rejected` 並跳過正式寫入;已標記單位價候選寫 `manual_unit_price_required`;已採用候選可保守補到最低門檻並保留 `manual_review/manual_accept` 標籤。商品列表必須將 `manual_rejected`、`manual_unit_price_required`、`manual_needs_research` 顯示為明確人工閉環狀態,不可回落成籠統「待比對」。`fetch_competitor_coverage()` 必須輸出人工採用、人工否決、人工單位價與採用率,daily/growth/PPT 共用 payload 必須顯示人工閉環成效,避免只呈現待審數。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 +- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`competitor_match_attempts`、`competitor_match_reviews`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品、待比對優先清單與 PChome 覆核隊列;`filter=ai_picks` 可查看 50 品 AI 挑品列表,`filter=pchome_review` 可直接查看需人工處理的比價覆核 SKU,並以 DB 分頁支援 search/category/status 後的完整隊列,不得只截前 50 筆。覆核狀態篩選必須至少包含全部、需單位價、身份否決、低信心、價格過期與找不到同款,讓人工可依 matcher 診斷類型分批處理。列內顯示候選 PChome 商品、候選價、match score、單位價換算摘要、人工動作與 matcher 診斷原因標籤(品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等),不得只顯示籠統「待比對」。`/api/export/excel/pchome-review` 必須匯出同一套覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,讓人工覆核、簡報與後續 AI 分析共用同一份證據。`/api/pchome-review//decision` 是人工閉環入口:`accept_identity` 才可把候選寫入 `competitor_prices` 與 `competitor_price_history` 並打上 `manual_review/manual_accept/identity_v2`;`reject_identity` 與 `unit_price_required` 只寫 `competitor_match_reviews` 並追加 manual attempt,不得把不同販售組合或否決候選灌入正式價差。PChome feeder 後續搜尋同一候選時必須讀取 `competitor_match_reviews`:已否決候選寫 `manual_rejected` 並跳過正式寫入,且必須繼續評估下一個候選,不能讓已否決候選長期阻塞同 SKU;已標記單位價候選寫 `manual_unit_price_required`;已採用候選可保守補到最低門檻並保留 `manual_review/manual_accept` 標籤。搜尋候選池只有強同款分數達 `0.90` 才可提前停止,避免 0.76 灰區候選卡掉後續更精準搜尋詞。人工 `reject_identity`、`unit_price_required`、`needs_research` 若命中當前正式候選,必須將同候選 `competitor_prices` 過期,不得繼續顯示正式總價差。商品列表必須將 `manual_rejected`、`manual_unit_price_required`、`manual_needs_research` 顯示為明確人工閉環狀態,不可回落成籠統「待比對」。`fetch_competitor_coverage()` 必須輸出人工採用、人工否決、人工單位價與採用率,daily/growth/PPT 共用 payload 必須顯示人工閉環成效,避免只呈現待審數。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | |------|------|------|------|---------| diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 799f725..954b9eb 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -124,6 +124,7 @@ def _build_pchome_match_status(attempt=None, ineligible=None): return { 'label': '人工已否決', 'tone': 'neutral', + 'blocks_price_gap': True, 'summary': '人工已否決這筆 PChome 候選;後續 feeder 命中同一候選時會跳過正式價差寫入', 'detail': score_text, } @@ -133,6 +134,7 @@ def _build_pchome_match_status(attempt=None, ineligible=None): return { 'label': '人工標記單位價', 'tone': 'watch', + 'blocks_price_gap': True, 'summary': '人工已判定總價不可直接比較,需以每 ml / 每 g / 每入單位價與檔期條件判讀', 'detail': score_text, } @@ -142,6 +144,7 @@ def _build_pchome_match_status(attempt=None, ineligible=None): return { 'label': '人工要求補搜尋', 'tone': 'neutral', + 'blocks_price_gap': True, 'summary': '人工要求補搜尋詞或重新抓取,不會把目前候選寫入正式 PChome 價差', 'detail': score_text, } @@ -241,7 +244,7 @@ def _build_pchome_match_status(attempt=None, ineligible=None): candidate_count = int(attempt.get('candidate_count') or 0) score_text = f"最佳候選 {round(score * 100)}%" if score is not None else "尚無候選分數" - if status == 'low_score': + if status in {'low_score', 'refresh_low_score'}: diagnostic_text = attempt.get('error_message') or '' label, summary = _diagnostic_match_rejection_label( diagnostic_text, @@ -254,21 +257,21 @@ def _build_pchome_match_status(attempt=None, ineligible=None): 'summary': summary, 'detail': f'{candidate_count} 筆候選', } - if status == 'needs_review': + if status in {'needs_review', 'refresh_needs_review'}: return { 'label': '配對衝突待審', 'tone': 'neutral', 'summary': '新候選與既有配對不同,需人工確認後再覆蓋', 'detail': f'{score_text} / {candidate_count} 筆候選', } - if status in {'no_result', 'no_match'}: + if status in {'no_result', 'no_match', 'refresh_no_match'}: return { 'label': '找不到同款', 'tone': 'neutral', 'summary': 'PChome 搜尋無可信候選,需補關鍵字或人工覆核', 'detail': f'{candidate_count} 筆候選', } - if status == 'error': + if status in {'error', 'refresh_error'}: return { 'label': '抓取異常', 'tone': 'risk', @@ -284,8 +287,8 @@ def _build_pchome_match_status(attempt=None, ineligible=None): def _build_competitor_decision(momo_price, pchome_price, match_status=None): - if not pchome_price: - status = match_status or _build_pchome_match_status() + status = match_status or _build_pchome_match_status() + if status.get('blocks_price_gap') or not pchome_price: return { 'label': status['label'], 'tone': status['tone'], diff --git a/services/competitor_match_review_service.py b/services/competitor_match_review_service.py index 2a609d3..d6ee897 100644 --- a/services/competitor_match_review_service.py +++ b/services/competitor_match_review_service.py @@ -278,6 +278,27 @@ def _promote_manual_match(conn, attempt: dict[str, Any], source: str) -> None: }) +def _expire_current_manual_candidate(conn, attempt: dict[str, Any], source: str) -> None: + """Expire a current official match when the operator rejects its candidate.""" + candidate_id = str(attempt.get("best_competitor_product_id") or "").strip() + sku = str(attempt.get("sku") or "").strip() + if not sku or not candidate_id: + return + + conn.execute(text(""" + UPDATE competitor_prices + SET expires_at = CURRENT_TIMESTAMP, + crawled_at = CURRENT_TIMESTAMP + WHERE sku = :sku + AND source = :source + AND competitor_product_id = :candidate_id + """), { + "sku": sku, + "source": source, + "candidate_id": candidate_id, + }) + + def record_competitor_match_review( engine, sku: str, @@ -307,6 +328,8 @@ def record_competitor_match_review( if review_action == "accept_identity": _promote_manual_match(conn, attempt, source) + else: + _expire_current_manual_candidate(conn, attempt, source) _insert_manual_attempt(conn, attempt, action_meta, source) conn.execute(text(""" diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 5fa5484..e127729 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -37,6 +37,7 @@ logger = logging.getLogger(__name__) # ── 比對參數 ───────────────────────────────────────── MIN_MATCH_SCORE = 0.76 # 低於此分數不寫入;核心比價寧可待審也不能錯配 REPLACE_DIFFERENT_PRODUCT_SCORE = 0.84 # 已有不同 PChome 商品時,需超高信心才覆蓋 +EARLY_STOP_MATCH_SCORE = 0.90 # 搜尋候選池只有強同款才提前停止,避免次佳候選卡住後續精準搜尋詞 SEARCH_LIMIT = 12 # 每個搜尋詞取 PChome 前 N 筆 MAX_SEARCH_TERMS = 3 # 每個 MOMO 商品最多嘗試幾組搜尋詞 BATCH_SIZE = 30 # 每批 DB 寫入筆數 @@ -178,9 +179,19 @@ def _find_best_match_detail( Returns: (PChomeProduct, score, diagnostics) or None """ + ranked = _rank_match_details(momo_name, pchome_products, momo_price=momo_price) + return ranked[0] if ranked else None + + +def _rank_match_details( + momo_name: str, + pchome_products: list, + momo_price: float = None, +) -> list[tuple]: + """Score all PChome candidates and return them from strongest to weakest.""" from services.marketplace_product_matcher import score_marketplace_match - best, best_score, best_diagnostics = None, 0.0, None + ranked = [] for p in pchome_products: diagnostics = score_marketplace_match( momo_name, @@ -188,11 +199,8 @@ def _find_best_match_detail( momo_price=momo_price, competitor_price=getattr(p, "price", None), ) - score = diagnostics.score - if score > best_score: - best, best_score, best_diagnostics = p, score, diagnostics - - return (best, best_score, best_diagnostics) if best else None + ranked.append((p, diagnostics.score, diagnostics)) + return sorted(ranked, key=lambda item: item[1], reverse=True) def _find_best_match(momo_name: str, pchome_products: list) -> Optional[tuple]: @@ -205,7 +213,7 @@ def _find_best_match(momo_name: str, pchome_products: list) -> Optional[tuple]: def _search_pchome_candidates(crawler, momo_name: str, keywords: list = None, momo_price: float = None) -> list: - """以多組搜尋詞擴大 PChome 候選池,找到可信候選後提早停止。""" + """以多組搜尋詞擴大 PChome 候選池,只在強同款時提前停止。""" candidates = [] seen_ids = set() for keyword in keywords or _build_search_keywords(momo_name): @@ -218,7 +226,7 @@ def _search_pchome_candidates(crawler, momo_name: str, keywords: list = None, mo seen_ids.add(product.product_id) candidates.append(product) best = _find_best_match_detail(momo_name, candidates, momo_price=momo_price) - if best and best[1] >= 0.76: + if best and best[1] >= EARLY_STOP_MATCH_SCORE: break return candidates @@ -791,8 +799,8 @@ class CompetitorPriceFeeder: skipped_no += 1 continue - result = _find_best_match_detail(momo_name, products, momo_price=momo_price) - if not result: + ranked_matches = _rank_match_details(momo_name, products, momo_price=momo_price) + if not ranked_matches: self._record_match_attempt( sku, momo_name, @@ -807,17 +815,31 @@ class CompetitorPriceFeeder: skipped_no += 1 continue - best_product, score, diagnostics = result - manual_review = self._fetch_latest_manual_review_for_candidate( - sku, - getattr(best_product, "product_id", None), - source=source, - ) - manual_action = (manual_review or {}).get("review_action") - if manual_action == "reject_identity": + selected_match = None + manually_rejected_ids: list[str] = [] + for candidate_product, candidate_score, candidate_diagnostics in ranked_matches: + candidate_review = self._fetch_latest_manual_review_for_candidate( + sku, + getattr(candidate_product, "product_id", None), + source=source, + ) + if (candidate_review or {}).get("review_action") == "reject_identity": + manually_rejected_ids.append(str(getattr(candidate_product, "product_id", "") or "")) + continue + selected_match = ( + candidate_product, + candidate_score, + candidate_diagnostics, + candidate_review, + ) + break + + if not selected_match: + best_product, score, diagnostics = ranked_matches[0] + rejected_note = ",".join(product_id for product_id in manually_rejected_ids if product_id) logger.info( - f"[Feeder] {sku} 候選已被人工否決,跳過正式寫入 | " - f"candidate={getattr(best_product, 'product_id', None)}" + f"[Feeder] {sku} 所有可信候選都已被人工否決,跳過正式寫入 | " + f"rejected_candidates={rejected_note}" ) self._record_match_attempt( sku, @@ -829,12 +851,18 @@ class CompetitorPriceFeeder: attempt_status="manual_rejected", best_product=best_product, best_score=score, - error_message=f"manual_review_rejected; {_format_match_diagnostics(diagnostics)}", + error_message=( + f"manual_review_rejected; rejected_candidates={rejected_note}; " + f"{_format_match_diagnostics(diagnostics)}" + ), source=source, ) attempts_written += 1 skipped_low += 1 continue + + best_product, score, diagnostics, manual_review = selected_match + manual_action = (manual_review or {}).get("review_action") if manual_action == "unit_price_required": logger.info( f"[Feeder] {sku} 候選已被人工標記為單位價比較,不寫正式總價差 | " diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index df431de..8ff7095 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -519,6 +519,16 @@
{{ review.action_label if review else decision.summary }}
{% if review %} + {% if review.candidate_pc_name or review.candidate_pc_price %} +
+ {% if review.candidate_pc_name %} + {{ review.candidate_pc_name[:42] }} + {% endif %} + {% if review.candidate_pc_price %} + 候選 ${{ review.candidate_pc_price | number_format }} + {% endif %} +
+ {% endif %} {% if review.diagnostic_reasons %}
{% for reason in review.diagnostic_reasons[:4] %} diff --git a/tests/test_competitor_identity_revalidator.py b/tests/test_competitor_identity_revalidator.py index acf6165..cde50f7 100644 --- a/tests/test_competitor_identity_revalidator.py +++ b/tests/test_competitor_identity_revalidator.py @@ -104,7 +104,7 @@ def test_dashboard_match_status_explains_unit_comparable_bundle(): def test_dashboard_match_status_shows_manual_review_closure_states(): - from routes.dashboard_routes import _build_pchome_match_status + from routes.dashboard_routes import _build_competitor_decision, _build_pchome_match_status rejected = _build_pchome_match_status({ "attempt_status": "manual_rejected", @@ -125,3 +125,7 @@ def test_dashboard_match_status_shows_manual_review_closure_states(): assert "總價不可直接比較" in unit_price["summary"] assert needs_research["label"] == "人工要求補搜尋" assert "重新抓取" in needs_research["summary"] + + decision = _build_competitor_decision(980, 899, match_status=rejected) + assert decision["label"] == "人工已否決" + assert decision["gap_amount"] is None diff --git a/tests/test_competitor_match_attempts_persistence.py b/tests/test_competitor_match_attempts_persistence.py index 519e076..06a4ee9 100644 --- a/tests/test_competitor_match_attempts_persistence.py +++ b/tests/test_competitor_match_attempts_persistence.py @@ -1,6 +1,7 @@ from pathlib import Path import logging from datetime import datetime +from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[1] @@ -71,6 +72,97 @@ def test_competitor_match_review_service_closes_human_review_loop(): assert "/api/pchome-review/" in dashboard_js +def test_reject_review_expires_current_formal_price(): + from sqlalchemy import create_engine, text + from services.competitor_match_review_service import record_competitor_match_review + + engine = create_engine("sqlite:///:memory:") + with engine.begin() as conn: + conn.execute(text("CREATE TABLE products (id INTEGER PRIMARY KEY, i_code TEXT, name TEXT)")) + conn.execute(text("CREATE TABLE price_records (id INTEGER PRIMARY KEY, product_id INTEGER, price NUMERIC, timestamp TEXT)")) + conn.execute(text(""" + CREATE TABLE competitor_match_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sku TEXT, + source TEXT, + momo_product_id INTEGER, + momo_product_name TEXT, + momo_price NUMERIC, + search_terms TEXT, + candidate_count INTEGER, + attempt_status TEXT, + best_competitor_product_id TEXT, + best_competitor_product_name TEXT, + best_competitor_price NUMERIC, + best_match_score NUMERIC, + error_message TEXT, + attempted_at TEXT + ) + """)) + conn.execute(text(""" + CREATE TABLE competitor_prices ( + sku TEXT, + source TEXT, + price NUMERIC, + original_price NUMERIC, + discount_pct INTEGER, + competitor_product_id TEXT, + competitor_product_name TEXT, + match_score NUMERIC, + tags TEXT, + crawled_at TEXT, + expires_at TEXT + ) + """)) + conn.execute(text("INSERT INTO products VALUES (1, 'A005', '舒特膚 AD 乳液 200ml')")) + conn.execute(text("INSERT INTO price_records VALUES (1, 1, 980, '2026-05-20 09:00:00')")) + conn.execute(text(""" + INSERT INTO competitor_match_attempts + (sku, source, momo_product_id, momo_product_name, momo_price, + search_terms, candidate_count, attempt_status, + best_competitor_product_id, best_competitor_product_name, + best_competitor_price, best_match_score, error_message, attempted_at) + VALUES + ('A005', 'pchome', 1, '舒特膚 AD 乳液 200ml', 980, + '[]', 1, 'needs_review', + 'DDAB01-REJECT', '舒特膚 AD 乳液 200ml', 899, 0.84, + 'score=0.84', '2026-05-20 09:10:00') + """)) + conn.execute(text(""" + INSERT INTO competitor_prices + (sku, source, price, competitor_product_id, competitor_product_name, + match_score, tags, crawled_at, expires_at) + VALUES + ('A005', 'pchome', 899, 'DDAB01-REJECT', '舒特膚 AD 乳液 200ml', + 0.84, '["identity_v2"]', '2026-05-20 09:10:00', '2099-01-01 00:00:00') + """)) + + result = record_competitor_match_review( + engine, + sku="A005", + review_action="reject_identity", + reviewer_identity="pytest", + ) + + assert result["success"] is True + with engine.connect() as conn: + expires_at = conn.execute(text(""" + SELECT expires_at + FROM competitor_prices + WHERE sku = 'A005' AND source = 'pchome' + """)).scalar() + manual_status = conn.execute(text(""" + SELECT attempt_status + FROM competitor_match_attempts + WHERE sku = 'A005' + ORDER BY id DESC + LIMIT 1 + """)).scalar() + + assert expires_at != "2099-01-01 00:00:00" + assert manual_status == "manual_rejected" + + def test_competitor_feeder_respects_manual_rejected_candidate(monkeypatch): from services.competitor_price_feeder import CompetitorPriceFeeder from services.pchome_crawler import PChomeProduct @@ -132,6 +224,168 @@ def test_competitor_feeder_respects_manual_rejected_candidate(monkeypatch): assert "manual_review_rejected" in attempts[0]["error_message"] +def test_competitor_feeder_skips_rejected_candidate_and_uses_next_best(monkeypatch): + from services.competitor_price_feeder import CompetitorPriceFeeder + from services.pchome_crawler import PChomeProduct + + rejected = PChomeProduct( + product_id="DDAB01-REJECTED", + name="舒特膚 AD 乳液 200ml 舊候選", + price=899, + original_price=999, + discount=10, + image_url="", + product_url="https://24h.pchome.com.tw/prod/DDAB01-REJECTED", + stock=20, + store="24h", + rating=4.7, + review_count=8, + is_on_sale=True, + crawled_at=datetime.now(), + ) + accepted = PChomeProduct( + product_id="DDAB01-ACCEPTABLE", + name="舒特膚 AD 乳液 200ml 新候選", + price=909, + original_price=999, + discount=9, + image_url="", + product_url="https://24h.pchome.com.tw/prod/DDAB01-ACCEPTABLE", + stock=20, + store="24h", + rating=4.6, + review_count=8, + is_on_sale=True, + crawled_at=datetime.now(), + ) + + class FakeCrawler: + def __init__(self, *_args, **_kwargs): + pass + + def search_products(self, *_args, **_kwargs): + return True, "ok", [rejected, accepted] + + def fake_score(_momo_name, competitor_name, **_kwargs): + score = 0.95 if "舊候選" in competitor_name else 0.84 + return SimpleNamespace( + score=score, + brand_score=1.0, + token_score=0.9, + spec_score=1.0, + sequence_score=0.8, + type_score=1.0, + price_penalty=0.0, + hard_veto=False, + reasons=(), + comparison_mode="exact_identity", + tags=["identity_v2", "comparison_exact_identity"], + ) + + monkeypatch.setattr("services.pchome_crawler.PChomeCrawler", FakeCrawler) + monkeypatch.setattr("services.marketplace_product_matcher.score_marketplace_match", fake_score) + feeder = CompetitorPriceFeeder(engine=object()) + attempts = [] + writes = [] + monkeypatch.setattr( + feeder, + "_fetch_latest_manual_review_for_candidate", + lambda _sku, candidate_id, **_kwargs: ( + {"review_action": "reject_identity"} if candidate_id == "DDAB01-REJECTED" else None + ), + ) + monkeypatch.setattr( + feeder, + "_should_upsert_competitor_price", + lambda *_args, **_kwargs: (True, "new_match"), + ) + monkeypatch.setattr( + feeder, + "_record_match_attempt", + lambda *args, **kwargs: attempts.append(kwargs), + ) + monkeypatch.setattr( + feeder, + "_upsert_competitor_price", + lambda *args, **kwargs: writes.append((args, kwargs)), + ) + + result = feeder._run_sku_items([{ + "sku": "A004", + "name": "舒特膚 AD 乳液 200ml", + "product_id": 4, + "momo_price": 980, + }]) + + assert result.matched == 1 + assert writes[0][0][1].product_id == "DDAB01-ACCEPTABLE" + assert attempts[0]["attempt_status"] == "matched" + assert attempts[0]["best_product"].product_id == "DDAB01-ACCEPTABLE" + + +def test_search_candidates_does_not_stop_on_merely_acceptable_match(monkeypatch): + from services.competitor_price_feeder import _search_pchome_candidates + from services.pchome_crawler import PChomeProduct + + first = PChomeProduct( + product_id="DDAB01-FIRST", + name="理膚寶水 B5 修復霜 40ml 普通候選", + price=679, + original_price=799, + discount=15, + image_url="", + product_url="https://24h.pchome.com.tw/prod/DDAB01-FIRST", + stock=20, + store="24h", + rating=4.7, + review_count=8, + is_on_sale=True, + crawled_at=datetime.now(), + ) + second = PChomeProduct( + product_id="DDAB01-SECOND", + name="理膚寶水 B5 修復霜 40ml 強同款", + price=689, + original_price=799, + discount=14, + image_url="", + product_url="https://24h.pchome.com.tw/prod/DDAB01-SECOND", + stock=20, + store="24h", + rating=4.8, + review_count=8, + is_on_sale=True, + crawled_at=datetime.now(), + ) + + class FakeCrawler: + def __init__(self): + self.calls = [] + + def search_products(self, keyword, **_kwargs): + self.calls.append(keyword) + if keyword == "broad": + return True, "ok", [first] + return True, "ok", [second] + + def fake_score(_momo_name, competitor_name, **_kwargs): + score = 0.80 if "普通候選" in competitor_name else 0.95 + return SimpleNamespace(score=score) + + crawler = FakeCrawler() + monkeypatch.setattr("services.marketplace_product_matcher.score_marketplace_match", fake_score) + + candidates = _search_pchome_candidates( + crawler, + "理膚寶水 B5 修復霜 40ml", + keywords=["broad", "precise", "unused"], + momo_price=699, + ) + + assert crawler.calls == ["broad", "precise"] + assert [candidate.product_id for candidate in candidates] == ["DDAB01-FIRST", "DDAB01-SECOND"] + + def test_competitor_feeder_logs_keyword_parser_fallback(monkeypatch, caplog): from services import competitor_price_feeder from services import marketplace_product_matcher diff --git a/web/static/css/ewoooc-v3-page-guard.css b/web/static/css/ewoooc-v3-page-guard.css index 4d80e65..b677627 100644 --- a/web/static/css/ewoooc-v3-page-guard.css +++ b/web/static/css/ewoooc-v3-page-guard.css @@ -440,15 +440,20 @@ .momo-app:not(.momo-observability-mode) .dashboard-table td:nth-child(3)::before { content: "MOMO"; } .momo-app:not(.momo-observability-mode) .dashboard-table td:nth-child(4)::before { content: "PChome"; } .momo-app:not(.momo-observability-mode) .dashboard-table td:nth-child(5)::before { content: "競價"; } - .momo-app:not(.momo-observability-mode) .dashboard-table:not(.is-ai-picks) td:nth-child(6)::before { content: "昨日"; } - .momo-app:not(.momo-observability-mode) .dashboard-table:not(.is-ai-picks) td:nth-child(7)::before { content: "週"; } - .momo-app:not(.momo-observability-mode) .dashboard-table:not(.is-ai-picks) td:nth-child(8)::before { content: "更新"; } - .momo-app:not(.momo-observability-mode) .dashboard-table:not(.is-ai-picks) td:nth-child(9)::before { content: "上架"; } + .momo-app:not(.momo-observability-mode) .dashboard-table:not(.is-ai-picks):not(.is-review) td:nth-child(6)::before { content: "昨日"; } + .momo-app:not(.momo-observability-mode) .dashboard-table:not(.is-ai-picks):not(.is-review) td:nth-child(7)::before { content: "週"; } + .momo-app:not(.momo-observability-mode) .dashboard-table:not(.is-ai-picks):not(.is-review) td:nth-child(8)::before { content: "更新"; } + .momo-app:not(.momo-observability-mode) .dashboard-table:not(.is-ai-picks):not(.is-review) td:nth-child(9)::before { content: "上架"; } .momo-app:not(.momo-observability-mode) .dashboard-table.is-ai-picks td:nth-child(6)::before { content: "AI"; } .momo-app:not(.momo-observability-mode) .dashboard-table.is-ai-picks td:nth-child(7)::before { content: "昨日"; } .momo-app:not(.momo-observability-mode) .dashboard-table.is-ai-picks td:nth-child(8)::before { content: "週"; } .momo-app:not(.momo-observability-mode) .dashboard-table.is-ai-picks td:nth-child(9)::before { content: "更新"; } .momo-app:not(.momo-observability-mode) .dashboard-table.is-ai-picks td:nth-child(10)::before { content: "上架"; } + .momo-app:not(.momo-observability-mode) .dashboard-table.is-review td:nth-child(6)::before { content: "覆核"; } + .momo-app:not(.momo-observability-mode) .dashboard-table.is-review td:nth-child(7)::before { content: "昨日"; } + .momo-app:not(.momo-observability-mode) .dashboard-table.is-review td:nth-child(8)::before { content: "週"; } + .momo-app:not(.momo-observability-mode) .dashboard-table.is-review td:nth-child(9)::before { content: "更新"; } + .momo-app:not(.momo-observability-mode) .dashboard-table.is-review td:nth-child(10)::before { content: "上架"; } .momo-app:not(.momo-observability-mode) .edm-page .campaign-table td:nth-child(1)::before { content: "分類"; } .momo-app:not(.momo-observability-mode) .edm-page .campaign-table td:nth-child(2)::before { content: "商品"; } diff --git a/web/static/js/page-dashboard-v2.js b/web/static/js/page-dashboard-v2.js index 1d802a4..e89ddd2 100644 --- a/web/static/js/page-dashboard-v2.js +++ b/web/static/js/page-dashboard-v2.js @@ -226,7 +226,7 @@ let priceChartInstance = null; document.querySelectorAll('.dashboard-table tbody tr[data-product-id]').forEach(row => { row.addEventListener('click', event => { - if (event.target.closest('a')) return; + if (event.target.closest('a, button, [data-pchome-review-action]')) return; showHistory(row.dataset.productId, row.dataset.productName); }); }); @@ -314,7 +314,10 @@ let priceChartInstance = null; } document.querySelectorAll('[data-pchome-review-action]').forEach(button => { - button.addEventListener('click', () => runPchomeReviewDecision(button)); + button.addEventListener('click', event => { + event.stopPropagation(); + runPchomeReviewDecision(button); + }); }); function trackMomoLinkClick(event) {