From 87d47f171c21944dea8bfcbadd954fc3da17945a Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 1 Jun 2026 00:29:20 +0800 Subject: [PATCH] =?UTF-8?q?V10.524=20=E8=A3=9C=20PChome=20=E9=81=8E?= =?UTF-8?q?=E6=9C=9F=E5=83=B9=E6=A0=BC=E5=88=B7=E6=96=B0=E5=85=A5=E5=8F=A3?= 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 | 1 + docs/memory/history_logs.md | 1 + routes/ai_routes.py | 188 ++++++++++++++++++++++----- services/pchome_backfill_status.py | 6 +- templates/dashboard_v2.html | 21 ++- tests/test_frontend_v2_assets.py | 7 + tests/test_pchome_backfill_status.py | 15 +++ web/static/css/page-dashboard-v2.css | 12 ++ web/static/js/page-dashboard-v2.js | 51 +++++++- 11 files changed, 259 insertions(+), 46 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index bfd7750..59e122f 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.524 將「待刷新」變成可操作入口:商品看板 PChome 補抓產線新增「刷新過期 120 筆」按鈕,呼叫 `/api/ai/pchome-match/refresh-stale` 背景執行 `run_expired_identity_refresh()`,只刷新既有 `identity_v2` 的 PChome product_id,不跑 fresh search recovery、不呼叫 LLM,完成後重算 AI 挑品並清除 Dashboard / 競價快取。 - V10.523 補一批高分真同款 exact identity 比價規則:Beauty Foot 足膜、KAMERIA 積雪草足膜、TS6 蜜愛潤滑液 / 蜜桃煥白凝膠 / 極淨白+煥白組合、Vaseline 嬰兒高純修護凝膠在規格、入數、品牌與品線完全對齊時可進 `exact / total_price / price_alert_exact`,讓可用比價覆蓋增加;同時保留 TS6 香味衣物手洗精等 variant-sensitive 款式在 `manual_review`,不放寬全域門檻。 - V10.522 將 PChome 補抓狀態 API 接上 read-only coverage snapshot:`/api/ai/pchome-match/backfill/status` 會同步回傳身份覆蓋、新鮮率、待刷新與待補抓數,Dashboard 補抓產線即使沒有最近任務結果,也能直接判讀下一步該刷新過期價格或補抓未搜尋商品。 - V10.521 將比價新鮮度 stale 指標上屏:首頁 KPI / PChome 補抓產線 / daily / growth 都顯示價格過期數,讓操作員分清「已確認同款但價格待刷新」與「尚未找到身份配對」;過期 identity refresh 也優先刷新 `total_price / price_alert_exact` 的正式價差配對。 diff --git a/config.py b/config.py index 079f661..c3b17d1 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.523" +SYSTEM_VERSION = "V10.524" 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 4de9b1e..9e8949c 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -89,6 +89,7 @@ SQL漏斗(~300筆) - 後台入口:`POST /api/ai/product-picks/generate`,`/ai_intelligence` 可手動產生清單。 - 配對來源仍以 PChome crawler 真實搜尋結果為準;無競品資料時不生成挑品。 - 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。 +- 過期價格刷新入口:`POST /api/ai/pchome-match/refresh-stale`,只針對已建立 `identity_v2` 但 `expires_at` 過期的 PChome product_id 執行 `run_expired_identity_refresh()`;不得跑 fresh search recovery,不得呼叫 LLM,完成後重算 AI 挑品並清除 Dashboard / competitor intel cache。 - 補抓狀態入口:`GET /api/ai/pchome-match/backfill/status` 除背景任務狀態外,必須回傳 read-only coverage snapshot:`active_with_price` / `valid_matches` / `match_rate` / `fresh_matches` / `fresh_match_rate` / `stale_matches` / `pending` / `actionable_review_count`,供 Dashboard 顯示目前該刷新過期價格或補抓未搜尋商品;此端點不寫 DB、不呼叫 LLM、不抓外站。 - 排程閉環:`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 準確性規則。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 895759c..4237ca0 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.524 PChome 過期價格刷新手動入口**: 商品看板 PChome 補抓產線新增「刷新過期 120 筆」按鈕與 `/api/ai/pchome-match/refresh-stale`,背景執行既有 `run_expired_identity_refresh()`,只刷新已建立 `identity_v2` 的 PChome product_id,不跑 fresh search recovery、不呼叫 LLM;完成後重算 AI 挑品並清除 Dashboard / competitor intel cache,讓 `stale_matches` 從觀測指標變成可直接操作的任務。 - **V10.523 高分真同款 exact identity 比價規則補強**: 針對正式環境反覆出現、分數已達 1.0 但因 `multi_component_pair` 或 variant review gate 被留在人工審核的真同款,補 Beauty Foot 足膜、KAMERIA 積雪草足膜、TS6 蜜愛潤滑液 / 蜜桃煥白凝膠 / 極淨白+煥白組合、Vaseline 嬰兒高純修護凝膠 focused exact identity。這些案例只有在品牌、品線、容量 / 重量與入數完全對齊時才進 `exact / total_price / price_alert_exact`;TS6 香味衣物手洗精等款式敏感商品仍維持 `manual_review`,全域 `MIN_MATCH_SCORE` 與 overwrite 保護不變。 - **V10.522 PChome backfill status 附帶 coverage snapshot**: `/api/ai/pchome-match/backfill/status` 在背景任務狀態外同步回傳 read-only `fetch_competitor_coverage()` 摘要,包含身份覆蓋、新鮮率、過期價格與待補抓數;Dashboard 補抓產線即使尚無最近 run result,也會顯示「身份覆蓋 / 新鮮 / 待刷新 / 待補抓」,讓操作員能分辨下一步該跑 expired identity refresh 還是 unmatched backfill。此狀態端點不寫 DB、不呼叫 LLM、不抓外站。 diff --git a/routes/ai_routes.py b/routes/ai_routes.py index d3fcd96..e2f181f 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -1725,6 +1725,44 @@ def api_generate_product_picks(): return jsonify({'success': False, 'error': str(e)}), 500 +def _feeder_result_payload(result): + return { + 'total_skus': int(getattr(result, 'total_skus', 0) or 0), + 'matched': int(getattr(result, 'matched', 0) or 0), + 'skipped_no_result': int(getattr(result, 'skipped_no_result', 0) or 0), + 'skipped_low_score': int(getattr(result, 'skipped_low_score', 0) or 0), + 'errors': int(getattr(result, 'errors', 0) or 0), + 'history_written': int(getattr(result, 'history_written', 0) or 0), + 'attempts_written': int(getattr(result, 'attempts_written', 0) or 0), + 'duration_sec': round(float(getattr(result, 'duration_sec', 0) or 0), 2), + } + + +def _pick_result_payload(result): + return { + 'candidates': int(getattr(result, 'candidates', 0) or 0), + 'written': int(getattr(result, 'written', 0) or 0), + 'generated_at': getattr(result, 'generated_at', None), + } + + +def _combined_feeder_payload(revalidation_result, feeder_result): + revalidation_payload = _feeder_result_payload(revalidation_result) + feeder_payload = _feeder_result_payload(feeder_result) + return { + 'total_skus': revalidation_payload['total_skus'] + feeder_payload['total_skus'], + 'matched': revalidation_payload['matched'] + feeder_payload['matched'], + 'skipped_no_result': revalidation_payload['skipped_no_result'] + feeder_payload['skipped_no_result'], + 'skipped_low_score': revalidation_payload['skipped_low_score'] + feeder_payload['skipped_low_score'], + 'errors': revalidation_payload['errors'] + feeder_payload['errors'], + 'history_written': revalidation_payload['history_written'] + feeder_payload['history_written'], + 'attempts_written': revalidation_payload['attempts_written'] + feeder_payload['attempts_written'], + 'duration_sec': round(revalidation_payload['duration_sec'] + feeder_payload['duration_sec'], 2), + 'retryable_candidate_revalidation': revalidation_payload, + 'unmatched_priority_backfill': feeder_payload, + } + + @ai_bp.route('/api/ai/pchome-match/backfill', methods=['POST']) @login_required def api_pchome_match_backfill(): @@ -1745,41 +1783,6 @@ def api_pchome_match_backfill(): update_pchome_backfill_run, ) - def _feeder_result_payload(result): - return { - 'total_skus': int(getattr(result, 'total_skus', 0) or 0), - 'matched': int(getattr(result, 'matched', 0) or 0), - 'skipped_no_result': int(getattr(result, 'skipped_no_result', 0) or 0), - 'skipped_low_score': int(getattr(result, 'skipped_low_score', 0) or 0), - 'errors': int(getattr(result, 'errors', 0) or 0), - 'history_written': int(getattr(result, 'history_written', 0) or 0), - 'attempts_written': int(getattr(result, 'attempts_written', 0) or 0), - 'duration_sec': round(float(getattr(result, 'duration_sec', 0) or 0), 2), - } - - def _pick_result_payload(result): - return { - 'candidates': int(getattr(result, 'candidates', 0) or 0), - 'written': int(getattr(result, 'written', 0) or 0), - 'generated_at': getattr(result, 'generated_at', None), - } - - def _combined_feeder_payload(revalidation_result, feeder_result): - revalidation_payload = _feeder_result_payload(revalidation_result) - feeder_payload = _feeder_result_payload(feeder_result) - return { - 'total_skus': revalidation_payload['total_skus'] + feeder_payload['total_skus'], - 'matched': revalidation_payload['matched'] + feeder_payload['matched'], - 'skipped_no_result': revalidation_payload['skipped_no_result'] + feeder_payload['skipped_no_result'], - 'skipped_low_score': revalidation_payload['skipped_low_score'] + feeder_payload['skipped_low_score'], - 'errors': revalidation_payload['errors'] + feeder_payload['errors'], - 'history_written': revalidation_payload['history_written'] + feeder_payload['history_written'], - 'attempts_written': revalidation_payload['attempts_written'] + feeder_payload['attempts_written'], - 'duration_sec': round(revalidation_payload['duration_sec'] + feeder_payload['duration_sec'], 2), - 'retryable_candidate_revalidation': revalidation_payload, - 'unmatched_priority_backfill': feeder_payload, - } - try: run = start_pchome_backfill_run( limit=limit, @@ -1789,12 +1792,13 @@ def api_pchome_match_backfill(): return jsonify({ 'success': False, 'message': 'PChome 補抓已在執行中,請稍後查看進度', - 'data': exc.status, + 'data': _get_pchome_backfill_status_payload(), }), 409 run_id = run['run_id'] def _run_backfill(): + engine = None try: from config import DATABASE_PATH from sqlalchemy import create_engine @@ -1864,6 +1868,9 @@ def api_pchome_match_backfill(): except Exception as exc: fail_pchome_backfill_run(run_id, str(exc)) logger.error(f"[PChomeBackfill] 背景補抓失敗: {exc}", exc_info=True) + finally: + if engine is not None: + engine.dispose() thread = threading.Thread(target=_run_backfill, daemon=True) thread.start() @@ -1876,6 +1883,115 @@ def api_pchome_match_backfill(): }), 202 +@ai_bp.route('/api/ai/pchome-match/refresh-stale', methods=['POST']) +@login_required +def api_pchome_match_refresh_stale(): + """背景刷新已建立 identity_v2 但價格過期的 PChome 商品。""" + import threading + + payload = request.get_json(silent=True) or {} + try: + limit = max(5, min(int(payload.get('limit', 120)), 300)) + except (TypeError, ValueError): + limit = 120 + + from services.pchome_backfill_status import ( + PchomeBackfillAlreadyRunning, + fail_pchome_backfill_run, + finish_pchome_backfill_run, + start_pchome_backfill_run, + update_pchome_backfill_run, + ) + + try: + run = start_pchome_backfill_run( + limit=limit, + operator=session.get('username') or 'web', + ) + except PchomeBackfillAlreadyRunning: + return jsonify({ + 'success': False, + 'message': 'PChome 產線已有任務執行中,請稍後查看進度', + 'data': _get_pchome_backfill_status_payload(), + }), 409 + + run_id = run['run_id'] + + def _run_refresh_stale(): + engine = None + try: + from config import DATABASE_PATH + from sqlalchemy import create_engine + from services.ai_product_pick_agent import generate_product_pick_list + from services.cache_manager import clear_dashboard_cache + from services.competitor_intel_repository import clear_competitor_intel_cache + from services.competitor_price_feeder import CompetitorPriceFeeder + + update_pchome_backfill_run( + run_id, + stage='refreshing_stale', + message=f'正在刷新 {limit} 筆過期 identity_v2 PChome 價格', + ) + engine = create_engine(DATABASE_PATH) + feeder = CompetitorPriceFeeder(engine=engine) + result = feeder.run_expired_identity_refresh(limit=limit) + result_payload = _feeder_result_payload(result) + update_pchome_backfill_run( + run_id, + stage='generating_picks', + message='過期價格刷新完成,正在重算 AI 挑品清單', + result=result_payload, + ) + pick_result = generate_product_pick_list(engine, limit=50) + pick_payload = _pick_result_payload(pick_result) + update_pchome_backfill_run( + run_id, + stage='clearing_cache', + message='AI 挑品已重算,正在清除看板快取', + result=result_payload, + pick_result=pick_payload, + ) + clear_dashboard_cache() + clear_competitor_intel_cache() + finish_pchome_backfill_run( + run_id, + result=result_payload, + pick_result=pick_payload, + message=( + f"PChome 過期價格刷新完成:檢查 {result_payload['total_skus']} 筆、" + f"更新 {result_payload['matched']} 筆、" + f"AI 挑品寫入 {pick_payload['written']} 筆" + ), + ) + logger.info( + "[PChomeRefreshStale] done total=%s matched=%s no=%s low=%s errors=%s history=%s duration=%ss pick_written=%s", + result_payload['total_skus'], + result_payload['matched'], + result_payload['skipped_no_result'], + result_payload['skipped_low_score'], + result_payload['errors'], + result_payload['history_written'], + result_payload['duration_sec'], + pick_result.written, + ) + except Exception as exc: + fail_pchome_backfill_run(run_id, str(exc)) + logger.error(f"[PChomeRefreshStale] 背景刷新失敗: {exc}", exc_info=True) + finally: + if engine is not None: + engine.dispose() + + thread = threading.Thread(target=_run_refresh_stale, daemon=True) + thread.start() + + return jsonify({ + 'success': True, + 'message': f'已啟動 PChome 過期價格刷新,優先處理 {limit} 筆已建立 identity_v2 的舊價格', + 'limit': limit, + 'data': _get_pchome_backfill_status_payload(), + }), 202 + + def _build_pchome_backfill_coverage_payload(): """讀取目前 PChome 身份覆蓋與價格新鮮度,供補抓狀態卡判斷下一步。""" engine = None diff --git a/services/pchome_backfill_status.py b/services/pchome_backfill_status.py index 98d6c3e..8b2a39c 100644 --- a/services/pchome_backfill_status.py +++ b/services/pchome_backfill_status.py @@ -26,6 +26,7 @@ ACTIVE_TTL_SECONDS = int(os.getenv("PCHOME_BACKFILL_ACTIVE_TTL_SECONDS", "7200") STAGE_ORDER = ( "queued", + "refreshing_stale", "revalidating", "matching", "generating_picks", @@ -36,12 +37,13 @@ STAGE_ORDER = ( STAGE_LABELS = { "idle": "尚未執行", "queued": "已排入背景補抓", + "refreshing_stale": "刷新過期 PChome 價格", "revalidating": "重新評分近門檻候選", "matching": "比對高優先未配對商品", "generating_picks": "重算 AI 挑品清單", "clearing_cache": "清除看板與競價快取", - "completed": "補抓完成", - "failed": "補抓失敗", + "completed": "產線完成", + "failed": "產線失敗", "stale": "執行狀態逾時", } diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 5dc89a2..248b11a 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -64,6 +64,7 @@
@@ -80,12 +81,20 @@ 讀取狀態中 --
- +
+ + +
diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 256da57..4b71d33 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -516,11 +516,14 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): assert "@ai_bp.route('/api/ai/product-picks/generate', methods=['POST'])" in route_source assert "generate_product_pick_list(engine" in route_source assert "@ai_bp.route('/api/ai/pchome-match/backfill', methods=['POST'])" in route_source + assert "@ai_bp.route('/api/ai/pchome-match/refresh-stale', methods=['POST'])" in route_source assert "@ai_bp.route('/api/ai/pchome-match/backfill/status', methods=['GET'])" in route_source assert "_build_pchome_backfill_coverage_payload" in route_source assert "fetch_competitor_coverage" in route_source assert "status['coverage'] = _build_pchome_backfill_coverage_payload()" in route_source assert "run_unmatched_priority(limit=unmatched_limit)" in route_source + assert "run_expired_identity_refresh(limit=limit)" in route_source + assert "stage='refreshing_stale'" in route_source assert "run_retryable_candidate_revalidation" in route_source assert "generate_product_pick_list(engine, limit=50)" in route_source assert "start_pchome_backfill_run" in route_source @@ -556,12 +559,16 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): assert "backfillPchomeMatches" in template assert "/api/ai/product-picks/generate" in template assert "/api/ai/pchome-match/backfill" in template + assert "/api/ai/pchome-match/refresh-stale" in dashboard_template assert "/api/ai/pchome-match/backfill/status" in dashboard_template assert "PCHOME MATCH BACKFILL" in dashboard_template assert "data-pchome-backfill-trigger" in dashboard_template + assert "data-pchome-refresh-stale-trigger" in dashboard_template + assert "刷新過期 120 筆" in dashboard_template dashboard_js = (ROOT / "web/static/js/page-dashboard-v2.js").read_text(encoding="utf-8") assert "loadPchomeBackfillStatus" in dashboard_js assert "window.backfillPchomeMatches" in dashboard_js + assert "window.refreshStalePchomeMatches" in dashboard_js assert "formatBackfillCoverageSummary" in dashboard_js assert "status.coverage" in dashboard_js assert "待刷新 ${formatBackfillCount(coverage.stale_matches)}" in dashboard_js diff --git a/tests/test_pchome_backfill_status.py b/tests/test_pchome_backfill_status.py index adb0476..417ffa0 100644 --- a/tests/test_pchome_backfill_status.py +++ b/tests/test_pchome_backfill_status.py @@ -55,3 +55,18 @@ def test_pchome_backfill_status_records_failure(tmp_path, monkeypatch): assert failed["status"] == "failed" assert failed["last_error"] == "crawler timeout" assert failed["recent_runs"][0]["last_error"] == "crawler timeout" + + +def test_pchome_backfill_status_supports_stale_refresh_stage(tmp_path, monkeypatch): + monkeypatch.setenv("PCHOME_BACKFILL_STATUS_PATH", str(tmp_path / "status.json")) + + run = start_pchome_backfill_run(limit=120, operator="tester") + refreshing = update_pchome_backfill_run( + run["run_id"], + stage="refreshing_stale", + message="正在刷新過期價格", + ) + + assert refreshing["stage"] == "refreshing_stale" + assert refreshing["stage_label"] == "刷新過期 PChome 價格" + assert refreshing["progress_pct"] > run["progress_pct"] diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css index ea33dc9..83e44af 100644 --- a/web/static/css/page-dashboard-v2.css +++ b/web/static/css/page-dashboard-v2.css @@ -244,6 +244,14 @@ gap: 2px; } + .dashboard-backfill-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + min-width: 0; + } + .dashboard-focus-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1276,6 +1284,10 @@ width: 100%; } + .dashboard-backfill-actions { + width: 100%; + } + .dashboard-search, .dashboard-select, .dashboard-segmented { diff --git a/web/static/js/page-dashboard-v2.js b/web/static/js/page-dashboard-v2.js index 80d8e76..06c77e0 100644 --- a/web/static/js/page-dashboard-v2.js +++ b/web/static/js/page-dashboard-v2.js @@ -287,10 +287,12 @@ let priceChartInstance = null; return { card, trigger: document.querySelector('[data-pchome-backfill-trigger]'), + refreshStaleTrigger: document.querySelector('[data-pchome-refresh-stale-trigger]'), status: document.querySelector('[data-pchome-backfill-status]'), result: document.querySelector('[data-pchome-backfill-result]'), progress: document.querySelector('[data-pchome-backfill-progress]'), backfillEndpoint: card ? card.dataset.backfillEndpoint : '/api/ai/pchome-match/backfill', + refreshStaleEndpoint: card ? card.dataset.refreshStaleEndpoint : '/api/ai/pchome-match/refresh-stale', statusEndpoint: card ? card.dataset.statusEndpoint : '/api/ai/pchome-match/backfill/status' }; } @@ -366,9 +368,16 @@ let priceChartInstance = null; elements.trigger.disabled = running; elements.trigger.classList.toggle('is-loading', running); elements.trigger.innerHTML = running - ? ' 補抓中' + ? ' 執行中' : ' 補抓 60 筆'; } + if (elements.refreshStaleTrigger) { + elements.refreshStaleTrigger.disabled = running; + elements.refreshStaleTrigger.classList.toggle('is-loading', running); + elements.refreshStaleTrigger.innerHTML = running + ? ' 執行中' + : ' 刷新過期 120 筆'; + } if (running) { schedulePchomeBackfillPoll(); @@ -430,10 +439,50 @@ let priceChartInstance = null; }); } + function refreshStalePchomeMatches() { + const elements = getPchomeBackfillElements(); + if (!elements.card || !elements.refreshStaleTrigger) return; + const limit = Number(elements.refreshStaleTrigger.dataset.limit || 120); + if (!confirm(`啟動 PChome 過期價格刷新 ${limit} 筆?`)) return; + + elements.refreshStaleTrigger.disabled = true; + if (elements.status) { + elements.status.textContent = '正在送出過期價格刷新任務'; + } + fetch(elements.refreshStaleEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCSRFToken() + }, + body: JSON.stringify({ limit }) + }) + .then(response => response.json().then(data => ({ ok: response.ok, status: response.status, data }))) + .then(({ ok, status, data }) => { + renderPchomeBackfillStatus(data); + if (!ok && status !== 409) { + throw new Error(data.message || data.error || 'PChome 過期價格刷新啟動失敗'); + } + schedulePchomeBackfillPoll(); + }) + .catch(error => { + if (elements.status) { + elements.status.textContent = error.message || 'PChome 過期價格刷新啟動失敗'; + } + if (elements.refreshStaleTrigger) { + elements.refreshStaleTrigger.disabled = false; + } + }); + } + window.backfillPchomeMatches = backfillPchomeMatches; + window.refreshStalePchomeMatches = refreshStalePchomeMatches; document.querySelectorAll('[data-pchome-backfill-trigger]').forEach(button => { button.addEventListener('click', backfillPchomeMatches); }); + document.querySelectorAll('[data-pchome-refresh-stale-trigger]').forEach(button => { + button.addEventListener('click', refreshStalePchomeMatches); + }); loadPchomeBackfillStatus(); function runPchomeReviewDecision(button) {