V10.621 sync auto candidates to growth layer
Some checks failed
CD Pipeline / deploy (push) Failing after 34s

This commit is contained in:
OoO
2026-06-16 11:41:34 +08:00
parent 15010ab724
commit 01c73e02a2
14 changed files with 504 additions and 15 deletions

View File

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

View File

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

View File

@@ -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`,不自動改價;目標是減少人工補資料,而不是放寬正式價差寫入。

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' ? '接著處理' : '排程追蹤';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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