feat: backfill growth momo matches
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user