強化 PChome 覆核與候選決策閉環
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s

This commit is contained in:
OoO
2026-05-20 10:41:29 +08:00
parent 190695e546
commit 5b9f712abe
11 changed files with 367 additions and 36 deletions

View File

@@ -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` 標籤,讓核心比價閉環可被後續報表與簡報追蹤。

View File

@@ -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 # 用於模板顯示

View File

@@ -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/<sku>/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/<sku>/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 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|------|------|------|------|---------|

View File

@@ -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'],

View File

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

View File

@@ -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} 候選已被人工標記為單位價比較,不寫正式總價差 | "

View File

@@ -519,6 +519,16 @@
</div>
<div class="dashboard-ai-pick-reason">{{ review.action_label if review else decision.summary }}</div>
{% if review %}
{% if review.candidate_pc_name or review.candidate_pc_price %}
<div class="dashboard-review-note">
{% if review.candidate_pc_name %}
{{ review.candidate_pc_name[:42] }}
{% endif %}
{% if review.candidate_pc_price %}
<span class="momo-mono">候選 ${{ review.candidate_pc_price | number_format }}</span>
{% endif %}
</div>
{% endif %}
{% if review.diagnostic_reasons %}
<div class="dashboard-review-reasons" aria-label="比對診斷原因">
{% for reason in review.diagnostic_reasons[:4] %}

View File

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

View File

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

View File

@@ -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: "商品"; }

View File

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