From 9ca8d4e43cece4fbd1b53c6301c7847f27797e86 Mon Sep 17 00:00:00 2001 From: OoO Date: Thu, 18 Jun 2026 16:02:02 +0800 Subject: [PATCH] feat: backfill growth momo matches --- config.py | 2 +- routes/README.md | 2 +- routes/ai_routes.py | 168 ++++++++++++++++++++ services/pchome_revenue_growth_service.py | 14 +- templates/dashboard_v2.html | 5 +- tests/test_frontend_v2_assets.py | 5 + tests/test_pchome_revenue_growth_service.py | 113 +++++++++++++ web/static/css/page-dashboard-v2.css | 34 ++++ web/static/js/page-dashboard-v2.js | 82 ++++++++++ 9 files changed, 421 insertions(+), 4 deletions(-) diff --git a/config.py b/config.py index c9c7c6a..5774365 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.633" +SYSTEM_VERSION = "V10.634" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/README.md b/routes/README.md index de6b375..53fd52f 100644 --- a/routes/README.md +++ b/routes/README.md @@ -25,7 +25,7 @@ | `market_intel_review_post_ai_routes.py` | 市場情報 AI summary persistence / Telegram dispatch 後續只讀延伸 API(掛在 `market_intel_review_bp`) | `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_gate`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_package`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_archive`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_archive_summary` | | `market_intel_review_report_routes.py` | 市場情報 report input / report run package / report run readiness / report run receipt / report closeout / report archive / report catalog handoff 後續只讀延伸 API(掛在 `market_intel_review_bp`) | `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_input`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_package`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_archive`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_archive_summary`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_catalog_handoff` | | `api_routes.py` | 通用任務與查詢 API | `/api/run_task`, `/api/history/*` | -| `ai_routes.py` | AI 推薦、競情儀表板與 PChome 成長作戰 API | `/ai_recommend`, `/ai_intelligence`, `/api/ai/status`, `/api/ai/icaim/dashboard`, `/api/ai/pchome-growth/opportunities`, `/api/ai/pchome-growth/source-contract`, `/api/ai/pchome-growth/external-offers/csv-dry-run` | +| `ai_routes.py` | AI 推薦、競情儀表板與 PChome 成長作戰 API | `/ai_recommend`, `/ai_intelligence`, `/api/ai/status`, `/api/ai/icaim/dashboard`, `/api/ai/pchome-growth/opportunities`, `/api/ai/pchome-growth/backfill-momo-candidates`, `/api/ai/pchome-growth/source-contract`, `/api/ai/pchome-growth/external-offers/csv-dry-run` | | `export_routes.py` | 匯出功能 | `/api/export/*` | | `import_routes.py` | 匯入功能 | `/api/import_excel`, `/api/import/monthly_summary` | diff --git a/routes/ai_routes.py b/routes/ai_routes.py index e3cb3db..643e449 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -1662,6 +1662,174 @@ def api_pchome_growth_opportunities(): }), 500 +def _growth_candidate_auto_compare_type(candidate): + auto_type = str(candidate.get("auto_compare_type") or "").strip() + if auto_type in {"total_price", "unit_price"}: + return auto_type + if candidate.get("can_auto_compare") is True: + return "total_price" + return "manual_review" + + +def _growth_momo_backfill_targets_from_payload(payload, limit): + opportunities = list((payload or {}).get("opportunities") or []) + targets = [] + for item in opportunities: + action = item.get("recommended_action") or {} + if item.get("external_price"): + continue + if action.get("code") != "map_external_product": + continue + product_id = str(item.get("pchome_product_id") or "").strip() + product_name = str(item.get("product_name") or "").strip() + if not product_id or not product_name: + continue + target = { + "product_id": product_id, + "name": product_name, + "price": item.get("pchome_price"), + "sales_7d": item.get("sales_7d"), + "priority_score": item.get("priority_score"), + } + targets.append(target) + if len(targets) >= limit: + break + return targets + + +def _build_pchome_growth_payload(engine, limit): + from services.pchome_revenue_growth_service import build_pchome_growth_opportunities + + return build_pchome_growth_opportunities(engine, limit=limit) + + +def _search_growth_momo_candidates(targets, limit): + from services.momo_crawler import search_momo_products_for_pchome_products + + return search_momo_products_for_pchome_products( + targets, + max_products=limit, + limit_per_product=6, + max_terms_per_product=4, + min_score=0.45, + ) + + +def _sync_growth_momo_candidates(engine, candidates): + from services.external_market_offer_service import sync_targeted_momo_candidates_to_external_offers + + return sync_targeted_momo_candidates_to_external_offers(engine, candidates, dry_run=False) + + +@ai_bp.route('/api/ai/pchome-growth/backfill-momo-candidates', methods=['POST']) +@login_required +def api_pchome_growth_backfill_momo_candidates(): + """用高業績 PChome 商品主動反查 MOMO 候選,不呼叫 LLM。""" + payload = request.get_json(silent=True) or {} + try: + limit = max(1, min(int(payload.get('limit', 12)), 20)) + except (TypeError, ValueError): + limit = 12 + + engine = None + try: + from config import DATABASE_PATH + + engine = _create_icaim_dashboard_engine(DATABASE_PATH) + before_payload = _build_pchome_growth_payload(engine, limit=max(limit, 16)) + targets = _growth_momo_backfill_targets_from_payload(before_payload, limit) + if not targets: + return jsonify({ + "success": True, + "message": "目前高業績清單沒有需要補 MOMO 對應的商品。", + "data": { + "scanned_products": 0, + "target_count": 0, + "candidate_count": 0, + "auto_compare_count": 0, + "review_count": 0, + "external_offer_sync": { + "success": True, + "status": "not_needed", + "written_count": 0, + "message": "沒有需要同步的自動候選。", + }, + "before_stats": before_payload.get("stats") or {}, + "after_stats": before_payload.get("stats") or {}, + "targets": [], + }, + }) + + search_success, search_message, candidates = _search_growth_momo_candidates(targets, limit) + candidates = list(candidates or []) + exact_candidates = [ + item for item in candidates + if _growth_candidate_auto_compare_type(item) == "total_price" + ] + unit_candidates = [ + item for item in candidates + if _growth_candidate_auto_compare_type(item) == "unit_price" + ] + review_candidates = [ + item for item in candidates + if _growth_candidate_auto_compare_type(item) not in {"total_price", "unit_price"} + ] + auto_candidates = [*exact_candidates, *unit_candidates] + external_offer_sync = { + "success": True, + "status": "not_found", + "written_count": 0, + "message": "已搜尋 MOMO,但尚未找到可自動寫入的同款或單位價候選。", + } + if auto_candidates: + external_offer_sync = _sync_growth_momo_candidates(engine, auto_candidates) + + after_payload = _build_pchome_growth_payload(engine, limit=max(limit, 16)) + _PCHOME_GROWTH_CACHE.update({ + "expires_at": 0.0, + "epoch": 0.0, + "payload": None, + }) + + written_count = int(external_offer_sync.get("written_count") or 0) + message = ( + f"已掃描 {len(targets)} 個高業績商品,找到 {len(candidates)} 筆 MOMO 候選," + f"自動寫入 {written_count} 筆。" + ) + if not search_success and not candidates: + message = search_message or "已搜尋 MOMO,但沒有找到可用候選。" + + return jsonify({ + "success": True, + "message": message, + "data": { + "search_success": bool(search_success), + "search_message": search_message, + "scanned_products": len(targets), + "target_count": len(targets), + "candidate_count": len(candidates), + "exact_compare_count": len(exact_candidates), + "unit_compare_count": len(unit_candidates), + "auto_compare_count": len(auto_candidates), + "review_count": len(review_candidates), + "external_offer_sync": external_offer_sync, + "before_stats": before_payload.get("stats") or {}, + "after_stats": after_payload.get("stats") or {}, + "targets": targets[:8], + "review_candidates": review_candidates[:8], + }, + }) + except Exception as exc: + logger.error("[PChomeGrowth] MOMO 對應補抓失敗: %s", exc, exc_info=True) + return jsonify({ + "success": False, + "error": "MOMO 對應補抓暫時無法執行,請稍後再試。", + }), 500 + finally: + if engine is not None: + engine.dispose() + + @ai_bp.route('/api/ai/pchome-growth/source-contract') @login_required def api_pchome_growth_source_contract(): diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py index 3949c72..ea58f16 100644 --- a/services/pchome_revenue_growth_service.py +++ b/services/pchome_revenue_growth_service.py @@ -79,6 +79,7 @@ def _daily_sales_columns(conn) -> dict[str, str | None]: "date": _first_available(columns, ["snapshot_date", "日期", "訂單日期", "交易日期", "Date"]), "revenue": _first_available(columns, ["總業績", "銷售金額", "業績", "金額", "Amount", "Sales", "Total"]), "qty": _first_available(columns, ["數量", "銷售數量", "銷量", "Qty", "Quantity"]), + "price": _first_available(columns, ["商品單位售價", "單價", "售價", "Price", "Unit Price"]), "category": _first_available(columns, ["商品館", "館別", "分類", "Category"]), "vendor": _first_available(columns, ["廠商名稱", "供應商", "Vendor"]), } @@ -110,6 +111,7 @@ def _fetch_sales_rows(conn, limit: int) -> tuple[list[dict[str, Any]], str | Non date_col = _quote_identifier(cols["date"]) revenue_expr = _numeric_expr(cols["revenue"], dialect) qty_expr = _numeric_expr(cols["qty"], dialect) if cols.get("qty") else "0" + price_expr = _numeric_expr(cols["price"], dialect) if cols.get("price") else "0" category_text = _as_text_expr(cols["category"], dialect) if cols.get("category") else "NULL" vendor_text = _as_text_expr(cols["vendor"], dialect) if cols.get("vendor") else "NULL" sku_text = _as_text_expr(cols["sku"], dialect) @@ -137,7 +139,8 @@ def _fetch_sales_rows(conn, limit: int) -> tuple[list[dict[str, Any]], str | Non NULLIF(TRIM({_as_text_expr(vendor_text, dialect, raw=True)}), '') AS vendor, {sale_date_expr} AS sale_date, {revenue_expr} AS revenue, - {qty_expr} AS qty + {qty_expr} AS qty, + {price_expr} AS unit_price FROM daily_sales_snapshot WHERE {sku_col} IS NOT NULL ), @@ -159,6 +162,12 @@ def _fetch_sales_rows(conn, limit: int) -> tuple[list[dict[str, Any]], str | Non THEN sr.revenue ELSE 0 END) AS sales_prev_7d, SUM(CASE WHEN sr.sale_date >= {curr_window} THEN sr.qty ELSE 0 END) AS qty_7d, + CASE + WHEN SUM(CASE WHEN sr.sale_date >= {curr_window} THEN sr.qty ELSE 0 END) > 0 + THEN SUM(CASE WHEN sr.sale_date >= {curr_window} THEN sr.revenue ELSE 0 END) + / NULLIF(SUM(CASE WHEN sr.sale_date >= {curr_window} THEN sr.qty ELSE 0 END), 0) + ELSE NULLIF(MAX(CASE WHEN sr.sale_date >= {curr_window} THEN sr.unit_price ELSE 0 END), 0) + END AS pchome_price, MAX(sr.sale_date) AS last_sale_date, MAX(lw.latest_date) AS latest_sales_date FROM sales_rows sr @@ -663,6 +672,9 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] | "sales_prev_7d": round(sales_prev_7d, 2), "sales_delta_pct": round(sales_delta_pct, 1) if sales_delta_pct is not None else None, "qty_7d": round(qty_7d, 2), + "pchome_price": round(_to_float(sales_row.get("pchome_price")), 2) + if _to_float(sales_row.get("pchome_price")) > 0 + else None, "last_sale_date": str(sales_row.get("last_sale_date") or ""), "external_price": external_payload, "priority_score": round(priority_score, 1), diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 6f0a4f0..a5ef37d 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -68,7 +68,7 @@
{% for task in growth.priority_tasks | default([]) %} {% if task.action == 'backfill' %} -
+
+ 按下後會優先補高業績商品的 MOMO 對應 +
diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index aed2d59..8bbe8ea 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -832,6 +832,7 @@ 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-growth/backfill-momo-candidates', 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/recover-stale', methods=['POST'])" in route_source assert 'PCHOME_STALE_RECOVERY_ENABLED' in route_source @@ -914,6 +915,8 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): assert "/api/ai/pchome-match/recover-stale" not in dashboard_template assert "/api/ai/pchome-match/backfill/status" in dashboard_template assert "PChome 比價補強" in dashboard_template + assert "data-pchome-growth-backfill-trigger" in dashboard_template + assert "data-pchome-growth-backfill-status" in dashboard_template assert "PCHOME MATCH BACKFILL" not in dashboard_template assert ">ACTIVE<" not in dashboard_template assert "目前 ACTIVE 商品" not in dashboard_template @@ -923,6 +926,8 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): assert "刷新過期 120 筆" in dashboard_template assert "救援過期 40 筆" not in dashboard_template dashboard_js = (ROOT / "web/static/js/page-dashboard-v2.js").read_text(encoding="utf-8") + assert "/api/ai/pchome-growth/backfill-momo-candidates" in dashboard_js + assert "backfillPchomeGrowthMomoCandidates" in dashboard_js assert "loadPchomeBackfillStatus" in dashboard_js assert "window.backfillPchomeMatches" in dashboard_js assert "window.refreshStalePchomeMatches" in dashboard_js diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index 8ef17e6..7198770 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -152,6 +152,7 @@ def test_pchome_growth_opportunities_use_plain_language_and_pause_shopee_coupang assert actions["PCH-1"] == "檢查售價與活動" assert actions["PCH-2"] == "先補商品對應" pchome_1 = next(item for item in payload["opportunities"] if item["pchome_product_id"] == "PCH-1") + assert pchome_1["pchome_price"] == 3488.37 assert pchome_1["external_price"]["data_source"] == "competitor_prices" assert payload["stats"]["external_data_source_counts"] == {"舊比價快取": 1} assert all("identity" not in " ".join(item["reason_lines"]).lower() for item in payload["opportunities"]) @@ -224,6 +225,118 @@ def test_pchome_growth_route_cache_respects_shared_invalidation_epoch(monkeypatc assert routes._get_cached_pchome_growth_payload() is None +def test_pchome_growth_momo_backfill_route_targets_unmapped_high_sales_items(monkeypatch): + from flask import Flask + from routes import ai_routes as routes + + captured = {} + + class FakeEngine: + def dispose(self): + captured["disposed"] = True + + before_payload = { + "success": True, + "stats": {"mapping_rate": 0, "candidate_count": 3, "mapped_count": 0}, + "opportunities": [ + { + "pchome_product_id": "PCH-NEEDS-1", + "product_name": "需要補對應商品一", + "pchome_price": 920, + "sales_7d": 120000, + "priority_score": 91, + "external_price": None, + "recommended_action": {"code": "map_external_product"}, + }, + { + "pchome_product_id": "PCH-MAPPED", + "product_name": "已有比價商品", + "pchome_price": 880, + "external_price": {"momo_sku": "MOMO-OLD"}, + "recommended_action": {"code": "review_price_or_promo"}, + }, + { + "pchome_product_id": "PCH-NEEDS-2", + "product_name": "需要補對應商品二", + "pchome_price": 760, + "sales_7d": 90000, + "priority_score": 82, + "external_price": None, + "recommended_action": {"code": "map_external_product"}, + }, + ], + } + after_payload = { + "success": True, + "stats": {"mapping_rate": 66.7, "candidate_count": 3, "mapped_count": 2}, + "opportunities": [], + } + + payload_calls = [] + + def fake_build_payload(engine, limit): + payload_calls.append(limit) + return before_payload if len(payload_calls) == 1 else after_payload + + def fake_search(targets, limit): + captured["targets"] = targets + captured["search_limit"] = limit + return True, "找到候選", [ + { + "product_id": "MOMO-AUTO", + "auto_compare_type": "total_price", + "can_auto_compare": True, + }, + { + "product_id": "MOMO-UNIT", + "auto_compare_type": "unit_price", + "can_auto_compare": True, + }, + { + "product_id": "MOMO-REVIEW", + "auto_compare_type": "manual_review", + "can_auto_compare": False, + }, + ] + + def fake_sync(engine, candidates): + captured["sync_candidates"] = candidates + return { + "success": True, + "status": "synced", + "written_count": len(candidates), + "total_price_count": 1, + "unit_price_count": 1, + } + + monkeypatch.setattr(routes, "_create_icaim_dashboard_engine", lambda database_path: FakeEngine()) + monkeypatch.setattr(routes, "_build_pchome_growth_payload", fake_build_payload) + monkeypatch.setattr(routes, "_search_growth_momo_candidates", fake_search) + monkeypatch.setattr(routes, "_sync_growth_momo_candidates", fake_sync) + + app = Flask(__name__) + with app.test_request_context( + "/api/ai/pchome-growth/backfill-momo-candidates", + method="POST", + json={"limit": 2}, + ): + response = routes.api_pchome_growth_backfill_momo_candidates.__wrapped__() + + payload = response.get_json() + assert payload["success"] is True + assert payload["data"]["scanned_products"] == 2 + assert payload["data"]["candidate_count"] == 3 + assert payload["data"]["auto_compare_count"] == 2 + assert payload["data"]["review_count"] == 1 + assert payload["data"]["external_offer_sync"]["written_count"] == 2 + assert payload["data"]["after_stats"]["mapping_rate"] == 66.7 + assert [item["product_id"] for item in captured["targets"]] == ["PCH-NEEDS-1", "PCH-NEEDS-2"] + assert [item["price"] for item in captured["targets"]] == [920, 760] + assert [item["product_id"] for item in captured["sync_candidates"]] == ["MOMO-AUTO", "MOMO-UNIT"] + assert captured["search_limit"] == 2 + assert captured["disposed"] is True + + def test_ai_product_pick_sales_join_by_sku_disabled_by_default(monkeypatch): from services.ai_product_pick_agent import _sales_join_by_momo_sku_enabled diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css index 354a40a..baacf9a 100644 --- a/web/static/css/page-dashboard-v2.css +++ b/web/static/css/page-dashboard-v2.css @@ -272,6 +272,40 @@ box-shadow: inset 3px 0 0 var(--momo-success); } + .growth-task.is-loading { + opacity: 0.78; + } + + .growth-backfill-status { + margin-top: 9px; + padding: 8px 10px; + color: var(--momo-text-secondary); + background: color-mix(in srgb, var(--momo-bg-paper) 72%, transparent); + border: 1px solid var(--momo-border-light); + border-radius: 8px; + font-size: 11px; + font-weight: 900; + line-height: 1.35; + } + + .growth-backfill-status.is-success { + color: var(--momo-success); + border-color: rgba(48, 133, 94, 0.24); + background: rgba(235, 248, 241, 0.72); + } + + .growth-backfill-status.is-warning { + color: var(--momo-warning-text); + border-color: rgba(210, 158, 58, 0.34); + background: rgba(255, 248, 231, 0.72); + } + + .growth-backfill-status.is-danger { + color: var(--momo-danger); + border-color: rgba(188, 75, 49, 0.32); + background: rgba(255, 244, 239, 0.72); + } + .growth-strategy-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/web/static/js/page-dashboard-v2.js b/web/static/js/page-dashboard-v2.js index a4673bd..2eeaa19 100644 --- a/web/static/js/page-dashboard-v2.js +++ b/web/static/js/page-dashboard-v2.js @@ -280,6 +280,84 @@ let priceChartInstance = null; button.addEventListener('click', () => runDashboardTask(button.dataset.dashboardTask)); }); + function getPchomeGrowthBackfillElements() { + return { + triggers: Array.from(document.querySelectorAll('[data-pchome-growth-backfill-trigger]')), + status: document.querySelector('[data-pchome-growth-backfill-status]'), + endpoint: '/api/ai/pchome-growth/backfill-momo-candidates' + }; + } + + function setGrowthBackfillStatus(message, tone) { + const elements = getPchomeGrowthBackfillElements(); + if (!elements.status) return; + elements.status.textContent = message; + elements.status.classList.remove('is-success', 'is-warning', 'is-danger'); + if (tone) { + elements.status.classList.add(`is-${tone}`); + } + } + + function setGrowthBackfillBusy(isBusy) { + const elements = getPchomeGrowthBackfillElements(); + elements.triggers.forEach(trigger => { + trigger.disabled = isBusy; + trigger.classList.toggle('is-loading', isBusy); + }); + } + + function renderGrowthBackfillResult(data) { + const payload = data && data.data ? data.data : {}; + const sync = payload.external_offer_sync || {}; + const written = Number(sync.written_count || 0); + const autoCount = Number(payload.auto_compare_count || 0); + const reviewCount = Number(payload.review_count || 0); + const candidateCount = Number(payload.candidate_count || 0); + const scanned = Number(payload.scanned_products || 0); + const tone = written > 0 ? 'success' : (candidateCount > 0 ? 'warning' : 'danger'); + const message = ( + `掃描 ${formatBackfillCount(scanned)} 個高業績品` + + ` · 候選 ${formatBackfillCount(candidateCount)}` + + ` · 可自動 ${formatBackfillCount(autoCount)}` + + ` · 寫入 ${formatBackfillCount(written)}` + + ` · 待覆核 ${formatBackfillCount(reviewCount)}` + ); + setGrowthBackfillStatus(message, tone); + if (written > 0) { + setTimeout(() => window.location.reload(), 1200); + } + } + + function backfillPchomeGrowthMomoCandidates(activeTrigger) { + const elements = getPchomeGrowthBackfillElements(); + if (!elements.triggers.length) return; + const trigger = activeTrigger && activeTrigger.dataset ? activeTrigger : elements.triggers[0]; + const limit = Number(trigger.dataset.limit || 12); + setGrowthBackfillBusy(true); + setGrowthBackfillStatus(`正在補 ${formatBackfillCount(limit)} 個高業績商品的 MOMO 對應`, ''); + fetch(elements.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCSRFToken() + }, + body: JSON.stringify({ limit }) + }) + .then(response => response.json().then(data => ({ ok: response.ok, data }))) + .then(({ ok, data }) => { + if (!ok || !data.success) { + throw new Error(data.error || data.message || 'MOMO 對應補抓失敗'); + } + renderGrowthBackfillResult(data); + }) + .catch(error => { + setGrowthBackfillStatus(error.message || 'MOMO 對應補抓失敗', 'danger'); + }) + .finally(() => { + setGrowthBackfillBusy(false); + }); + } + let pchomeBackfillPollTimer = null; const DEFAULT_PCHOME_BACKFILL_LABEL = '補強 60 筆'; const DEFAULT_PCHOME_REFRESH_STALE_LABEL = '刷新過期 120 筆'; @@ -529,6 +607,10 @@ let priceChartInstance = null; window.backfillPchomeMatches = backfillPchomeMatches; window.refreshStalePchomeMatches = refreshStalePchomeMatches; + window.backfillPchomeGrowthMomoCandidates = backfillPchomeGrowthMomoCandidates; + document.querySelectorAll('[data-pchome-growth-backfill-trigger]').forEach(button => { + button.addEventListener('click', () => backfillPchomeGrowthMomoCandidates(button)); + }); document.querySelectorAll('[data-pchome-backfill-trigger]').forEach(button => { button.addEventListener('click', () => backfillPchomeMatches(button)); });