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