feat: backfill growth momo matches
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s

This commit is contained in:
OoO
2026-06-18 16:02:02 +08:00
parent 6d6f3b473f
commit 9ca8d4e43c
9 changed files with 421 additions and 4 deletions

View File

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

View File

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

View File

@@ -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():

View File

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

View File

@@ -68,7 +68,7 @@
<div class="growth-task-list">
{% for task in growth.priority_tasks | default([]) %}
{% if task.action == 'backfill' %}
<button class="growth-task is-{{ task.tone | default('neutral') }}" type="button" data-pchome-backfill-trigger data-preserve-label="true" data-limit="80">
<button class="growth-task is-{{ task.tone | default('neutral') }}" type="button" data-pchome-growth-backfill-trigger data-limit="16">
<span class="momo-mono">{{ '%02d'|format(task.rank) }}</span>
<strong>{{ task.title }}</strong>
<em>{{ task.metric }}</em>
@@ -98,6 +98,9 @@
{% endif %}
{% endfor %}
</div>
<div class="growth-backfill-status momo-mono" data-pchome-growth-backfill-status>
按下後會優先補高業績商品的 MOMO 對應
</div>
</div>
<div class="growth-strategy-panel">

View File

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

View File

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

View File

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

View File

@@ -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));
});