From 01c73e02a2bad740cb02ff9e31561d2f18223de0 Mon Sep 17 00:00:00 2001 From: OoO Date: Tue, 16 Jun 2026 11:41:34 +0800 Subject: [PATCH] V10.621 sync auto candidates to growth layer --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 3 +- .../current_execution_queue_20260524.md | 7 + routes/price_comparison_routes.py | 37 +++- services/external_market_offer_service.py | 189 +++++++++++++++++- services/momo_crawler.py | 1 + services/pchome_revenue_growth_service.py | 60 +++++- templates/ai_intelligence.html | 7 +- templates/price_comparison.html | 10 +- tests/test_external_market_offer_service.py | 70 +++++++ tests/test_frontend_v2_assets.py | 4 + tests/test_momo_crawler_targeted_search.py | 1 + tests/test_pchome_revenue_growth_service.py | 65 ++++++ tests/test_price_comparison_routes.py | 63 ++++++ 14 files changed, 504 insertions(+), 15 deletions(-) diff --git a/config.py b/config.py index b43c9ac..35c2cab 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.620" +SYSTEM_VERSION = "V10.621" 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 25b1356..dcaeb8e 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-06-16 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口與高可見頁面繁中化守門已建立 -> **適用版本**: V10.620 +> **適用版本**: V10.621 --- @@ -67,6 +67,7 @@ - V10.618 起 `/price_comparison` 也必須採「先給下一步」的比價決策 UI:首屏需顯示目前卡在哪一步、PChome / MOMO 資料準備狀態與下一個按鈕;比價結果需先呈現「需檢查價格 / 可主推曝光 / 價格接近」分佈,再用表格列出每筆商品的下一步,不得只呈現 Step 流程或原始價差表。 - V10.619 起 MOMO 比價候選來源新增「PChome 商品導向搜尋」:當比價 API 已有 PChome 商品但缺 MOMO 清單時,必須用每筆 PChome 商品名稱產生精準搜尋詞反查 MOMO,保留品牌、品名、容量與組合線索;新版 MOMO 搜尋頁需解析 Next.js `goodsInfoList` payload。此路徑只擴大候選池,不放寬同款 matcher 門檻。 - V10.620 起 `unit_comparable` 不再一律丟人工確認:若 `build_unit_price_comparison()` 可產生明確容量/數量、MOMO 單位價、PChome 單位價與差距百分比,候選需標為「自動單位價比較」並回傳 `auto_compare_type=unit_price`。此類候選可自動呈現價格壓力,但不得混入舊總價同款比價表,也不得直接寫入正式價差或自動改價;無法產生單位證據時才維持「需人工確認」。 +- V10.621 起 `/price_comparison` 的「自動找 MOMO 候選」會把可直接總價比價與自動單位價候選同步到 `external_offers`,`ingestion_method='targeted_momo_search'`,人工確認候選不得寫入。`external_offers.raw_payload_json.price_basis='unit_price'` 時,作戰清單必須使用 `unit_price_comparison` 的 MOMO / PChome 單位價與 `unit_gap_pct` 判斷價格壓力;不得把 MOMO 組合總價與 PChome 單品總價直接相減。此同步只影響外部價格參考與作戰清單,不寫 `competitor_prices`,也不自動改價。 ## 零之一、12 Agent 決策信封(2026-05-24) diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index 8a2a81a..152e42a 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -282,3 +282,10 @@ - `search_momo_products_for_pchome_products()` 會在 `unit_comparable` 時呼叫 `build_unit_price_comparison()`;只有能算出雙方總容量/數量、單位價與差距百分比時,才標成 `auto_compare_type=unit_price` 與「自動單位價比較」。 - `/api/price_comparison/fetch_momo_for_pchome` 回傳 `products`、`unit_compare_candidates`、`review_candidates` 三段;舊總價比價只吃 `products`,避免把組合包總價誤當同款價差。 - `/price_comparison` 顯示「同款 / 單位價 / 需確認」三個數量,並新增自動單位價面板;若只找到單位價候選,下一步會引導使用者查看單位價結果,而不是人工確認。 + +## 24. 2026-06-16 V10.621 自動候選接入外部價格參考 + +- `/price_comparison` 正常操作「自動找 MOMO 候選」時會帶 `sync_external_offers=true`,把可直接總價比價與自動單位價候選同步進 `external_offers`;仍需人工確認的候選不寫入。 +- 新增 `sync_targeted_momo_candidates_to_external_offers()`,只寫 `ingestion_method='targeted_momo_search'`、`match_status='verified'`、`data_quality_status='verified'` 的安全候選;`unit_price` 候選會在 `raw_payload_json.unit_price_comparison` 保留 MOMO / PChome 單位價、容量/數量與價差百分比。 +- `build_pchome_growth_opportunities()` 已能讀 `external_offers.raw_payload_json.price_basis='unit_price'`:作戰清單會顯示「資料可用單位價判斷」,並用單位價差距做「檢查售價與活動 / 放大價格優勢」判斷。 +- 此路徑只同步外部價格參考與作戰清單,不寫 `competitor_prices`,不自動改價;目標是減少人工補資料,而不是放寬正式價差寫入。 diff --git a/routes/price_comparison_routes.py b/routes/price_comparison_routes.py index 2172829..548e636 100644 --- a/routes/price_comparison_routes.py +++ b/routes/price_comparison_routes.py @@ -30,6 +30,18 @@ def _candidate_auto_compare_type(item: dict) -> str: return "manual_review" +def _sync_targeted_candidates_to_external_offers(candidates: list[dict]) -> dict: + from database.manager import DatabaseManager + from services.external_market_offer_service import sync_targeted_momo_candidates_to_external_offers + + db = DatabaseManager() + return sync_targeted_momo_candidates_to_external_offers( + db.engine, + candidates, + dry_run=False, + ) + + # ============================================ # 頁面路由 # ============================================ @@ -201,10 +213,13 @@ def fetch_pchome_products(): @price_comparison_bp.route('/api/price_comparison/fetch_momo_for_pchome', methods=['POST']) @login_required def fetch_momo_for_pchome_products(): - """用 PChome 商品清單反查 MOMO 候選;只讀、不寫 DB。""" + """用 PChome 商品清單反查 MOMO 候選;可選擇同步安全候選到外部報價層。""" try: data = request.get_json() or {} pchome_products = data.get('pchome_products') or [] + should_sync_external_offers = str( + data.get('sync_external_offers') or '' + ).strip().lower() in {'1', 'true', 'yes'} if not pchome_products: return jsonify({ 'success': False, @@ -228,6 +243,25 @@ def fetch_momo_for_pchome_products(): item for item in products if _candidate_auto_compare_type(item) not in {"total_price", "unit_price"} ] + external_offer_sync = { + "success": True, + "status": "not_requested", + "written_count": 0, + "message": "未要求同步外部價格參考。", + } + if should_sync_external_offers and (exact_products or unit_compare_candidates): + try: + external_offer_sync = _sync_targeted_candidates_to_external_offers( + [*exact_products, *unit_compare_candidates], + ) + except Exception as sync_exc: + logger.warning("[PriceComparison] 外部價格參考同步失敗: %s", sync_exc, exc_info=True) + external_offer_sync = { + "success": False, + "status": "failed", + "written_count": 0, + "message": "MOMO 候選已找到,但暫時無法同步到外部價格參考。", + } return jsonify({ 'success': success, @@ -242,6 +276,7 @@ def fetch_momo_for_pchome_products(): 'auto_compare_count': len(exact_products) + len(unit_compare_candidates), 'review_count': len(review_candidates), 'candidate_count': len(products), + 'external_offer_sync': external_offer_sync, } }) diff --git a/services/external_market_offer_service.py b/services/external_market_offer_service.py index 5a83183..762d2b6 100644 --- a/services/external_market_offer_service.py +++ b/services/external_market_offer_service.py @@ -12,7 +12,7 @@ import logging import csv import io from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timedelta from typing import Any from sqlalchemy import inspect, text @@ -216,6 +216,18 @@ def _load_json_list(value: Any) -> list[Any]: return [] +def _load_json_dict(value: Any) -> dict[str, Any]: + if not value: + return {} + if isinstance(value, dict): + return value + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + def _has_table(conn, table_name: str) -> bool: try: return inspect(conn).has_table(table_name) @@ -661,6 +673,181 @@ def _upsert_external_offer(conn, payload: dict[str, Any]) -> None: conn.execute(text(sql), payload) +def _targeted_candidate_auto_type(candidate: dict[str, Any]) -> str: + explicit_type = str(candidate.get("auto_compare_type") or "").strip() + if explicit_type: + return explicit_type + if candidate.get("can_auto_compare"): + return "total_price" + return "manual_review" + + +def _targeted_candidate_to_external_offer( + candidate: dict[str, Any], + *, + observed_at: datetime, +) -> tuple[dict[str, Any] | None, str]: + auto_type = _targeted_candidate_auto_type(candidate) + if auto_type not in {"total_price", "unit_price"}: + return None, "不是可自動使用的候選" + + momo_sku = str(candidate.get("product_id") or candidate.get("goodsCode") or candidate.get("id") or "").strip() + pchome_product_id = str(candidate.get("target_pchome_product_id") or "").strip() + momo_price = _to_float(candidate.get("price")) + pchome_price = _to_float(candidate.get("target_pchome_price")) + if not momo_sku: + return None, "缺少 MOMO 商品 ID" + if not pchome_product_id: + return None, "缺少 PChome 商品 ID" + if not momo_price or momo_price <= 0: + return None, "缺少 MOMO 售價" + + unit_price_comparison = ( + candidate.get("target_unit_price_comparison") + if isinstance(candidate.get("target_unit_price_comparison"), dict) + else {} + ) + is_unit_price = auto_type == "unit_price" + if is_unit_price and not unit_price_comparison.get("comparable"): + return None, "單位價證據不足" + + match_score = _quality_score_from_match(candidate.get("target_match_score")) + if is_unit_price: + quality_score = max(match_score, 82.0) + price_basis = "unit_price" + else: + quality_score = match_score + price_basis = "total_price" + if quality_score < 76: + return None, "同款分數低於自動同步門檻" + + title = str(candidate.get("name") or candidate.get("title") or momo_sku).strip() + notes = [ + "由 PChome 商品自動反查 MOMO 候選同步", + "自動單位價比較" if is_unit_price else "可直接總價比價", + ] + raw_payload = { + "source": "pchome_targeted_momo_search", + "auto_compare_type": auto_type, + "price_basis": price_basis, + "pchome_public_price": pchome_price, + "pchome_public_name": candidate.get("target_pchome_name"), + "match_score": candidate.get("target_match_score"), + "match_reasons": candidate.get("target_match_reasons") or [], + "comparison_mode": candidate.get("target_comparison_mode"), + "hard_veto": bool(candidate.get("target_hard_veto")), + "target_gap_pct": candidate.get("target_gap_pct"), + "unit_price_comparison": unit_price_comparison, + "search_term": candidate.get("target_search_term"), + "tags": [ + "identity_v2", + "source_targeted_momo_search", + f"price_basis_{price_basis}", + f"auto_compare_{auto_type}", + ], + } + return { + "source_code": "momo_reference", + "platform_code": "momo", + "source_product_id": momo_sku, + "source_offer_key": f"momo_reference:{momo_sku}:{pchome_product_id}:{price_basis}", + "title": title or momo_sku, + "brand": candidate.get("brand"), + "category_text": candidate.get("category") or candidate.get("category_text"), + "product_url": candidate.get("product_url") or candidate.get("url"), + "image_url": candidate.get("image_url"), + "price": momo_price, + "original_price": _to_float(candidate.get("original_price")), + "currency": "TWD", + "stock_status": None, + "sold_count": None, + "rating": None, + "review_count": None, + "observed_at": observed_at, + "expires_at": None, + "ingestion_method": "targeted_momo_search", + "connector_key": "pchome_targeted_momo_search", + "pchome_product_id": pchome_product_id, + "momo_sku": momo_sku, + "match_status": "verified", + "quality_score": round(quality_score, 2), + "data_quality_status": "verified", + "quality_notes_json": json.dumps(notes, ensure_ascii=False), + "raw_payload_json": json.dumps(raw_payload, ensure_ascii=False), + }, "" + + +def sync_targeted_momo_candidates_to_external_offers( + engine, + candidates: list[dict[str, Any]], + *, + dry_run: bool = False, +) -> dict[str, Any]: + """把頁面自動找到的安全 MOMO 候選同步進 external_offers。 + + 只接受 total_price 與 unit_price 自動候選;人工確認候選不寫入。 + """ + generated_at = datetime.now().isoformat(timespec="seconds") + candidates = list(candidates or []) + required_tables = {"external_market_sources", "external_offers"} + + with engine.begin() as conn: + missing_tables = sorted(table for table in required_tables if not _has_table(conn, table)) + if missing_tables: + return { + "success": False, + "status": "skipped", + "generated_at": generated_at, + "candidate_count": len(candidates), + "written_count": 0, + "dry_run": dry_run, + "message": "外部報價同步暫時無法執行,缺少必要資料表。", + "missing_tables": missing_tables, + } + + _ensure_external_market_source_seeds(conn) + base_observed_at = datetime.now() + offers: list[dict[str, Any]] = [] + skipped_reasons: dict[str, int] = {} + for index, candidate in enumerate(candidates): + offer, reason = _targeted_candidate_to_external_offer( + candidate, + observed_at=base_observed_at + timedelta(microseconds=index), + ) + if offer: + offers.append(offer) + else: + skipped_reasons[reason] = skipped_reasons.get(reason, 0) + 1 + + if not dry_run: + for offer in offers: + _upsert_external_offer(conn, offer) + + unit_count = sum( + 1 + for offer in offers + if _load_json_dict(offer.get("raw_payload_json")).get("price_basis") == "unit_price" + ) + total_count = len(offers) - unit_count + return { + "success": True, + "status": "dry_run" if dry_run else "synced", + "generated_at": generated_at, + "candidate_count": len(candidates), + "written_count": 0 if dry_run else len(offers), + "dry_run": dry_run, + "source_code": "momo_reference", + "total_price_count": total_count, + "unit_price_count": unit_count, + "skipped_reasons": skipped_reasons, + "message": ( + "已把自動比價候選同步到外部價格參考。" + if not dry_run + else "已完成自動比價候選同步預檢,尚未寫入資料。" + ), + } + + def sync_legacy_momo_reference_offers(engine, *, limit: int = 500, dry_run: bool = False) -> dict[str, Any]: """把既有已確認同款的比價快取自動同步到 external_offers。""" limit = max(1, min(int(limit or 500), 5000)) diff --git a/services/momo_crawler.py b/services/momo_crawler.py index ad8d7b3..3baa54a 100644 --- a/services/momo_crawler.py +++ b/services/momo_crawler.py @@ -706,6 +706,7 @@ def search_momo_products_for_pchome_products( "product_id": product_id, "target_pchome_product_id": pchome_id, "target_pchome_name": pchome_name, + "target_pchome_price": pchome_price, "target_match_score": round(score, 3), "target_search_term": term, "target_match_reasons": list(getattr(diagnostics, "reasons", ()) or ()), diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py index dc32392..bfb23f0 100644 --- a/services/pchome_revenue_growth_service.py +++ b/services/pchome_revenue_growth_service.py @@ -202,6 +202,15 @@ def _match_score_from_quality(value: Any) -> float: return max(0, min(1, score)) +def _external_price_basis(raw_payload: dict[str, Any]) -> str: + price_basis = str(raw_payload.get("price_basis") or "").strip() + return price_basis if price_basis in {"total_price", "unit_price"} else "total_price" + + +def _external_price_basis_label(price_basis: str) -> str: + return "單位價" if price_basis == "unit_price" else "商品總價" + + def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> dict[str, dict[str, Any]]: inspector = inspect(conn) if not inspector.has_table("external_offers"): @@ -274,6 +283,8 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> result: dict[str, dict[str, Any]] = {} for row in rows: raw_payload = _json_dict(row.get("raw_payload_json")) + price_basis = _external_price_basis(raw_payload) + unit_price_comparison = _json_dict(raw_payload.get("unit_price_comparison")) key = str(row.get("pchome_product_id") or "").strip() if not key: continue @@ -284,8 +295,16 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> "momo_name": row.get("momo_name"), "momo_price": row.get("momo_price"), "pchome_price": raw_payload.get("pchome_public_price"), + "price_basis": price_basis, + "price_basis_label": _external_price_basis_label(price_basis), + "unit_label": unit_price_comparison.get("unit_label"), + "momo_unit_price": unit_price_comparison.get("momo_unit_price"), + "pchome_unit_price": unit_price_comparison.get("competitor_unit_price"), + "unit_gap_pct": unit_price_comparison.get("unit_gap_pct"), + "momo_total_quantity": unit_price_comparison.get("momo_total_quantity"), + "pchome_total_quantity": unit_price_comparison.get("competitor_total_quantity"), "match_score": _match_score_from_quality(row.get("quality_score")), - "tags": ["external_offers", "verified"], + "tags": raw_payload.get("tags") or ["external_offers", "verified"], "crawled_at": row.get("observed_at"), "momo_price_at": row.get("observed_at"), "data_source": "external_offers", @@ -428,9 +447,24 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] | reason_lines = [] if external_row: + price_basis = str(external_row.get("price_basis") or "total_price") + price_basis_label = external_row.get("price_basis_label") or _external_price_basis_label(price_basis) pchome_price = _to_float(external_row.get("pchome_price")) momo_price = _to_float(external_row.get("momo_price")) - gap_pct = ((momo_price - pchome_price) / pchome_price * 100) if pchome_price else None + pchome_compare_price = pchome_price + momo_compare_price = momo_price + if price_basis == "unit_price": + pchome_unit_price = _to_float(external_row.get("pchome_unit_price")) + momo_unit_price = _to_float(external_row.get("momo_unit_price")) + pchome_compare_price = pchome_unit_price + momo_compare_price = momo_unit_price + gap_pct = _to_float(external_row.get("unit_gap_pct"), default=None) + if gap_pct is None and pchome_compare_price and momo_compare_price: + gap_pct = (momo_compare_price - pchome_compare_price) / pchome_compare_price * 100 + else: + pchome_unit_price = None + momo_unit_price = None + gap_pct = ((momo_price - pchome_price) / pchome_price * 100) if pchome_price else None tags = _load_json_tags(external_row.get("tags")) data_quality_score = 78 + min(12, _to_float(external_row.get("match_score")) * 12) external_payload = { @@ -443,6 +477,13 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] | "momo_name": external_row.get("momo_name"), "momo_price": round(momo_price, 2) if momo_price else None, "pchome_price": round(pchome_price, 2) if pchome_price else None, + "price_basis": price_basis, + "price_basis_label": price_basis_label, + "unit_label": external_row.get("unit_label") or "", + "momo_unit_price": round(momo_unit_price, 4) if momo_unit_price else None, + "pchome_unit_price": round(pchome_unit_price, 4) if pchome_unit_price else None, + "momo_total_quantity": external_row.get("momo_total_quantity"), + "pchome_total_quantity": external_row.get("pchome_total_quantity"), "gap_pct": round(gap_pct, 1) if gap_pct is not None else None, "match_score": round(_to_float(external_row.get("match_score")), 3), "tags": tags, @@ -467,12 +508,13 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] | action_message = "業績與外部價格暫無明顯異常,先保留在觀察清單。" if gap_pct is not None: + basis_prefix = "單位價" if price_basis == "unit_price" else "價格" if gap_pct > 0: - reason_lines.append(f"PChome 目前比 MOMO 低約 {abs(gap_pct):.1f}%。") + reason_lines.append(f"PChome {basis_prefix}目前比 MOMO 低約 {abs(gap_pct):.1f}%。") elif gap_pct < 0: - reason_lines.append(f"MOMO 目前比 PChome 低約 {abs(gap_pct):.1f}%。") + reason_lines.append(f"MOMO {basis_prefix}目前比 PChome 低約 {abs(gap_pct):.1f}%。") else: - reason_lines.append("PChome 與 MOMO 價格幾乎相同。") + reason_lines.append(f"PChome 與 MOMO {basis_prefix}幾乎相同。") else: data_quality_score -= 12 reason_lines.append("尚未找到可確認的 MOMO 對照商品。") @@ -523,7 +565,13 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] | }, "reason_lines": reason_lines[:4], "data_quality": { - "label": "資料可直接判斷" if external_row else "需要補資料", + "label": ( + "資料可用單位價判斷" + if external_payload and external_payload.get("price_basis") == "unit_price" + else "資料可直接判斷" + if external_row + else "需要補資料" + ), "score": round(max(0, min(100, data_quality_score)), 1), "issues": issues, }, diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index 08b894b..00dd2c1 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -1703,13 +1703,14 @@ function renderGrowthOps(rows) { const reason = (row.reason_lines || []).slice(0, 2).join(' '); const price = row.external_price; const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null; + const basisLabel = price?.price_basis_label || '商品總價'; const priceText = gap === null ? '資料不足,先補比價' : gap < 0 - ? `PChome 貴 ${Math.abs(gap).toFixed(1)}%` + ? `${basisLabel} PChome 貴 ${Math.abs(gap).toFixed(1)}%` : gap > 0 - ? `PChome 便宜 ${gap.toFixed(1)}%` - : '價格差不多'; + ? `${basisLabel} PChome 便宜 ${gap.toFixed(1)}%` + : `${basisLabel}差不多`; const priorityScore = Number(row.priority_score || 0); const priority = priorityScore >= 55 ? 'P1' : priorityScore >= 35 ? 'P2' : 'P3'; const priorityLabel = priority === 'P1' ? '今天先做' : priority === 'P2' ? '接著處理' : '排程追蹤'; diff --git a/templates/price_comparison.html b/templates/price_comparison.html index c8b0fbf..a0e4f85 100644 --- a/templates/price_comparison.html +++ b/templates/price_comparison.html @@ -812,7 +812,10 @@ La Roche-Posay 安得利防曬液 50ml,920 const response = await fetchWithCSRF('/api/price_comparison/fetch_momo_for_pchome', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pchome_products: pchomeProducts }) + body: JSON.stringify({ + pchome_products: pchomeProducts, + sync_external_offers: true + }) }); const data = await response.json(); hideProgress(); @@ -829,8 +832,11 @@ La Roche-Posay 安得利防曬液 50ml,920 updateCompareButton(); renderPriceCommandDashboard(); + const syncResult = payload.external_offer_sync || {}; + const syncedCount = Number(syncResult.written_count || 0); if (data.success) { - showToast(`MOMO 候選完成:同款 ${momoProducts.length} 筆、單位價 ${momoUnitCompareCandidates.length} 筆、需確認 ${momoReviewCandidates.length} 筆`, 'success'); + const syncText = syncedCount ? `,已同步 ${syncedCount} 筆到作戰清單` : ''; + showToast(`MOMO 候選完成:同款 ${momoProducts.length} 筆、單位價 ${momoUnitCompareCandidates.length} 筆、需確認 ${momoReviewCandidates.length} 筆${syncText}`, 'success'); } else if (momoUnitCompareCandidates.length) { showToast(`已自動換算 ${momoUnitCompareCandidates.length} 筆單位價候選`, 'success'); } else if (momoReviewCandidates.length) { diff --git a/tests/test_external_market_offer_service.py b/tests/test_external_market_offer_service.py index 7c29703..a14b205 100644 --- a/tests/test_external_market_offer_service.py +++ b/tests/test_external_market_offer_service.py @@ -247,6 +247,76 @@ def test_sync_legacy_momo_reference_offers_dry_run_does_not_write(): assert count == 0 +def test_sync_targeted_momo_candidates_writes_unit_price_offer(): + from services.external_market_offer_service import sync_targeted_momo_candidates_to_external_offers + + engine = create_engine("sqlite:///:memory:") + _seed_external_offer_sync_tables(engine) + + payload = sync_targeted_momo_candidates_to_external_offers(engine, [ + { + "product_id": "10833188", + "name": "MOMO B5 修復霜 40ml", + "price": 468, + "original_price": 835, + "product_url": "https://momo.test/10833188", + "image_url": "https://img.test/10833188.jpg", + "brand": "理膚寶水", + "target_pchome_product_id": "PCH-1", + "target_pchome_name": "PChome B5 修復霜 40ml", + "target_pchome_price": 920, + "target_match_score": 0.74, + "auto_compare_type": "unit_price", + "target_price_basis": "unit_price", + "target_match_reasons": ["unit_comparable"], + "target_comparison_mode": "unit_comparable", + "target_unit_price_comparison": { + "comparable": True, + "unit_label": "ml", + "momo_total_quantity": 40, + "competitor_total_quantity": 40, + "momo_unit_price": 11.7, + "competitor_unit_price": 23.0, + "unit_gap_pct": -49.13, + }, + }, + { + "product_id": "REVIEW-1", + "name": "仍需人工商品", + "price": 500, + "target_pchome_product_id": "PCH-2", + "auto_compare_type": "manual_review", + }, + ]) + + assert payload["success"] is True + assert payload["status"] == "synced" + assert payload["candidate_count"] == 2 + assert payload["written_count"] == 1 + assert payload["unit_price_count"] == 1 + assert payload["skipped_reasons"] == {"不是可自動使用的候選": 1} + + with engine.connect() as conn: + row = conn.execute(text(""" + SELECT source_product_id, price, pchome_product_id, match_status, + quality_score, data_quality_status, ingestion_method, + raw_payload_json + FROM external_offers + """)).mappings().one() + + raw_payload = __import__("json").loads(row["raw_payload_json"]) + assert row["source_product_id"] == "10833188" + assert row["price"] == 468 + assert row["pchome_product_id"] == "PCH-1" + assert row["match_status"] == "verified" + assert row["quality_score"] == 82 + assert row["data_quality_status"] == "verified" + assert row["ingestion_method"] == "targeted_momo_search" + assert raw_payload["price_basis"] == "unit_price" + assert raw_payload["pchome_public_price"] == 920 + assert raw_payload["unit_price_comparison"]["unit_gap_pct"] == -49.13 + + def test_external_source_readiness_uses_legacy_momo_reference_cache(): from services.external_market_offer_service import build_external_source_readiness diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 3c1ff9b..cb00f86 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -479,6 +479,8 @@ def test_ai_intelligence_uses_v2_shell_and_real_runtime_apis(): assert "compSourceSummary" in template assert "'external_offers':" in template assert "'自動同步'" in template + assert "price_basis_label" in template + assert "商品總價" in template assert "/api/ai/pchome-growth/opportunities" in template assert "最近處理紀錄" in template assert "處理紀錄" in template @@ -529,6 +531,8 @@ def test_price_comparison_page_is_action_oriented_and_plain_chinese(): assert "renderMomoUnitComparePanel" in template assert "自動單位價比較" in template assert "查看單位價" in template + assert "sync_external_offers: true" in template + assert "已同步 ${syncedCount} 筆到作戰清單" in template assert "renderMomoReviewPanel" in template assert "/api/price_comparison/fetch_momo_for_pchome" in template assert "MOMO 候選待確認" in template diff --git a/tests/test_momo_crawler_targeted_search.py b/tests/test_momo_crawler_targeted_search.py index eb681b7..80cdb72 100644 --- a/tests/test_momo_crawler_targeted_search.py +++ b/tests/test_momo_crawler_targeted_search.py @@ -79,6 +79,7 @@ def test_search_momo_products_for_pchome_products_uses_each_pchome_product_as_ta assert products[0]["product_id"] == "12345678" assert products[0]["target_pchome_product_id"] == "PCH-1" assert products[0]["target_pchome_name"] == "【LA ROCHE-POSAY 理膚寶水】B5全面修復霜 40ml" + assert products[0]["target_pchome_price"] == 920 assert products[0]["target_match_score"] > 0 assert products[0]["source_strategy"] == "pchome_targeted_momo_search" diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index f114992..59c5068 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -87,6 +87,46 @@ def _seed_growth_external_offers(engine): """)) +def _seed_growth_unit_price_external_offer(engine): + with engine.begin() as conn: + conn.execute(text(""" + CREATE TABLE external_offers ( + id INTEGER PRIMARY KEY, + source_code TEXT, + platform_code TEXT, + source_product_id TEXT, + source_offer_key TEXT, + title TEXT, + price REAL, + observed_at TEXT, + expires_at TEXT, + ingestion_method TEXT, + pchome_product_id TEXT, + momo_sku TEXT, + match_status TEXT, + quality_score REAL, + data_quality_status TEXT, + quality_notes_json TEXT, + raw_payload_json TEXT + ) + """)) + conn.execute(text(""" + INSERT INTO external_offers ( + id, source_code, platform_code, source_product_id, source_offer_key, + title, price, observed_at, expires_at, ingestion_method, + pchome_product_id, momo_sku, match_status, quality_score, + data_quality_status, quality_notes_json, raw_payload_json + ) + VALUES ( + 1, 'momo_reference', 'momo', 'MOMO-UNIT', 'momo_reference:MOMO-UNIT:PCH-1:unit_price', + 'MOMO 單位價商品', 468, '2026-06-14 12:00:00', NULL, 'targeted_momo_search', + 'PCH-1', 'MOMO-UNIT', 'verified', 82, + 'verified', '["自動單位價比較"]', + '{"price_basis": "unit_price", "pchome_public_price": 920, "pchome_public_name": "PChome 公開商品", "tags": ["identity_v2", "price_basis_unit_price"], "unit_price_comparison": {"unit_label": "ml", "momo_unit_price": 11.7, "competitor_unit_price": 23.0, "momo_total_quantity": 40, "competitor_total_quantity": 40, "unit_gap_pct": -49.13}}' + ) + """)) + + def test_pchome_growth_opportunities_use_plain_language_and_pause_shopee_coupang(): from services.pchome_revenue_growth_service import build_pchome_growth_opportunities @@ -138,6 +178,31 @@ def test_pchome_growth_prefers_external_offers_over_legacy_competitor_cache(): assert payload["stats"]["external_data_source_counts"] == {"自動同步資料層": 1} +def test_pchome_growth_understands_unit_price_external_offers(): + from services.pchome_revenue_growth_service import build_pchome_growth_opportunities + + engine = create_engine("sqlite:///:memory:") + _seed_growth_tables(engine) + _seed_growth_unit_price_external_offer(engine) + + payload = build_pchome_growth_opportunities(engine, limit=5) + + assert payload["success"] is True + pchome_1 = next(item for item in payload["opportunities"] if item["pchome_product_id"] == "PCH-1") + external_price = pchome_1["external_price"] + assert external_price["data_source"] == "external_offers" + assert external_price["price_basis"] == "unit_price" + assert external_price["price_basis_label"] == "單位價" + assert external_price["momo_price"] == 468 + assert external_price["pchome_price"] == 920 + assert external_price["momo_unit_price"] == 11.7 + assert external_price["pchome_unit_price"] == 23.0 + assert external_price["gap_pct"] == -49.1 + assert pchome_1["recommended_action"]["label"] == "檢查售價與活動" + assert pchome_1["data_quality"]["label"] == "資料可用單位價判斷" + assert any("單位價" in line for line in pchome_1["reason_lines"]) + + 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/tests/test_price_comparison_routes.py b/tests/test_price_comparison_routes.py index 3e9f4fc..3ff8367 100644 --- a/tests/test_price_comparison_routes.py +++ b/tests/test_price_comparison_routes.py @@ -172,3 +172,66 @@ def test_fetch_momo_for_pchome_endpoint_splits_auto_and_review_candidates(monkey assert payload["data"]["products"][0]["product_id"] == "AUTO-1" assert payload["data"]["unit_compare_candidates"][0]["product_id"] == "UNIT-1" assert payload["data"]["review_candidates"][0]["product_id"] == "REVIEW-1" + assert payload["data"]["external_offer_sync"]["status"] == "not_requested" + + +def test_fetch_momo_for_pchome_endpoint_syncs_auto_candidates_when_requested(monkeypatch): + from routes import price_comparison_routes as routes + + captured = {} + + def fake_targeted_search(pchome_products, **kwargs): + return True, "找到候選", [ + { + "name": "可直接比價商品", + "price": 890, + "product_id": "AUTO-1", + "auto_compare_type": "total_price", + "can_auto_compare": True, + }, + { + "name": "自動單位價商品", + "price": 468, + "product_id": "UNIT-1", + "auto_compare_type": "unit_price", + "can_auto_compare": True, + }, + { + "name": "需確認商品", + "price": 468, + "product_id": "REVIEW-1", + "can_auto_compare": False, + }, + ] + + def fake_sync(candidates): + captured["sync_candidates"] = candidates + return { + "success": True, + "status": "synced", + "written_count": len(candidates), + "unit_price_count": 1, + "total_price_count": 1, + } + + monkeypatch.setattr(routes, "search_momo_products_for_pchome_products", fake_targeted_search) + monkeypatch.setattr(routes, "_sync_targeted_candidates_to_external_offers", fake_sync) + + app = Flask(__name__) + with app.test_request_context( + "/api/price_comparison/fetch_momo_for_pchome", + method="POST", + json={ + "sync_external_offers": True, + "pchome_products": [ + {"name": "理膚寶水 B5 修復霜", "price": 920, "product_id": "PCH-1"}, + ], + }, + ): + response = routes.fetch_momo_for_pchome_products.__wrapped__() + + payload = response.get_json() + assert payload["success"] is True + assert payload["data"]["external_offer_sync"]["status"] == "synced" + assert payload["data"]["external_offer_sync"]["written_count"] == 2 + assert [item["product_id"] for item in captured["sync_candidates"]] == ["AUTO-1", "UNIT-1"]