This commit is contained in:
@@ -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` 標籤,讓核心比價閉環可被後續報表與簡報追蹤。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
|
||||
|
||||
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|
||||
|------|------|------|------|---------|
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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("""
|
||||
|
||||
@@ -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} 候選已被人工標記為單位價比較,不寫正式總價差 | "
|
||||
|
||||
@@ -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] %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "商品"; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user