V10.621 sync auto candidates to growth layer
Some checks failed
CD Pipeline / deploy (push) Failing after 34s
Some checks failed
CD Pipeline / deploy (push) Failing after 34s
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`,不自動改價;目標是減少人工補資料,而不是放寬正式價差寫入。
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 ()),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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' ? '接著處理' : '排程追蹤';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user