feat: persist targeted momo review candidates
Some checks failed
CD Pipeline / deploy (push) Failing after 35s
Some checks failed
CD Pipeline / deploy (push) Failing after 35s
This commit is contained in:
@@ -834,6 +834,92 @@ def _targeted_candidate_to_external_offer(
|
||||
}, ""
|
||||
|
||||
|
||||
def _targeted_review_candidate_to_external_offer(
|
||||
candidate: dict[str, Any],
|
||||
*,
|
||||
observed_at: datetime,
|
||||
) -> tuple[dict[str, Any] | None, str]:
|
||||
"""保存待人工確認候選;這類資料不得進價格判斷。"""
|
||||
alert_tier = str(candidate.get("target_alert_tier") or "").strip()
|
||||
match_type = str(candidate.get("target_match_type") or "").strip()
|
||||
if alert_tier not in {"identity_review", "unit_price_review"} and match_type in {"", "no_match"}:
|
||||
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"))
|
||||
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 售價"
|
||||
|
||||
match_score = _quality_score_from_match(candidate.get("target_match_score"))
|
||||
if match_score < 60:
|
||||
return None, "人工確認候選分數過低"
|
||||
|
||||
unit_price_comparison = (
|
||||
candidate.get("target_unit_price_comparison")
|
||||
if isinstance(candidate.get("target_unit_price_comparison"), dict)
|
||||
else {}
|
||||
)
|
||||
title = str(candidate.get("name") or candidate.get("title") or momo_sku).strip()
|
||||
price_basis = str(candidate.get("target_price_basis") or "manual_review").strip()
|
||||
raw_payload = {
|
||||
"source": "pchome_targeted_momo_search",
|
||||
"review_state": "needs_review",
|
||||
"auto_compare_type": "manual_review",
|
||||
"price_basis": price_basis,
|
||||
"pchome_public_price": _to_float(candidate.get("target_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"),
|
||||
"match_type": match_type,
|
||||
"alert_tier": alert_tier,
|
||||
"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",
|
||||
"needs_review",
|
||||
f"review_{alert_tier or 'identity_review'}",
|
||||
],
|
||||
}
|
||||
return {
|
||||
"source_code": "momo_reference",
|
||||
"platform_code": "momo",
|
||||
"source_product_id": momo_sku,
|
||||
"source_offer_key": f"momo_reference:{momo_sku}:{pchome_product_id}:needs_review",
|
||||
"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_review",
|
||||
"connector_key": "pchome_targeted_momo_search_review",
|
||||
"pchome_product_id": pchome_product_id,
|
||||
"momo_sku": momo_sku,
|
||||
"match_status": "needs_review",
|
||||
"quality_score": round(match_score, 2),
|
||||
"data_quality_status": "needs_review",
|
||||
"quality_notes_json": json.dumps(["候選已找到,需人工確認同款或色號"], 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]],
|
||||
@@ -914,6 +1000,76 @@ def sync_targeted_momo_candidates_to_external_offers(
|
||||
}
|
||||
|
||||
|
||||
def sync_targeted_momo_review_candidates_to_external_offers(
|
||||
engine,
|
||||
candidates: list[dict[str, Any]],
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""把待確認 MOMO 候選保存起來,供人工審核;不進價格告警。"""
|
||||
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()
|
||||
ranked_offers: list[tuple[dict[str, Any], tuple[float, float, float, float]]] = []
|
||||
skipped_reasons: dict[str, int] = {}
|
||||
for index, candidate in enumerate(candidates):
|
||||
offer, reason = _targeted_review_candidate_to_external_offer(
|
||||
candidate,
|
||||
observed_at=base_observed_at + timedelta(microseconds=index),
|
||||
)
|
||||
if offer:
|
||||
ranked_offers.append((offer, _targeted_candidate_sync_rank(candidate)))
|
||||
else:
|
||||
skipped_reasons[reason] = skipped_reasons.get(reason, 0) + 1
|
||||
|
||||
selected_by_pchome: dict[str, tuple[dict[str, Any], tuple[float, float, float, float]]] = {}
|
||||
for offer, rank in ranked_offers:
|
||||
key = str(offer.get("pchome_product_id") or offer.get("source_offer_key") or "").strip()
|
||||
existing = selected_by_pchome.get(key)
|
||||
if existing is None or rank > existing[1]:
|
||||
selected_by_pchome[key] = (offer, rank)
|
||||
offers = [offer for offer, _ in selected_by_pchome.values()]
|
||||
|
||||
if not dry_run:
|
||||
for offer in offers:
|
||||
_upsert_external_offer(conn, offer)
|
||||
if offers:
|
||||
mark_pchome_growth_cache_stale()
|
||||
|
||||
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",
|
||||
"skipped_reasons": skipped_reasons,
|
||||
"message": (
|
||||
"已保存待人工確認的 MOMO 候選。"
|
||||
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))
|
||||
@@ -1172,6 +1328,11 @@ def _normalized_offer_stats(conn) -> dict[str, dict[str, Any]]:
|
||||
AND COALESCE(o.quality_score, 0) >= 76
|
||||
THEN 1 ELSE 0 END
|
||||
) AS usable_offer_count,
|
||||
SUM(CASE
|
||||
WHEN o.match_status = 'needs_review'
|
||||
OR o.data_quality_status = 'needs_review'
|
||||
THEN 1 ELSE 0 END
|
||||
) AS review_offer_count,
|
||||
MAX(o.observed_at) AS last_seen_at
|
||||
FROM external_market_sources s
|
||||
LEFT JOIN external_offers o ON o.source_code = s.code
|
||||
@@ -1184,6 +1345,7 @@ def _normalized_offer_stats(conn) -> dict[str, dict[str, Any]]:
|
||||
"enabled": bool(row.get("enabled")),
|
||||
"offer_count": int(row.get("offer_count") or 0),
|
||||
"usable_offer_count": int(row.get("usable_offer_count") or 0),
|
||||
"review_offer_count": int(row.get("review_offer_count") or 0),
|
||||
"last_seen_at": str(row.get("last_seen_at") or "") or None,
|
||||
}
|
||||
for row in rows
|
||||
@@ -1244,16 +1406,23 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
|
||||
|
||||
source["schema_ready"] = schema_ready
|
||||
source["usable_offer_count"] = usable
|
||||
source["review_offer_count"] = int(stats.get("review_offer_count") or 0)
|
||||
source["last_seen_at"] = last_seen_at
|
||||
source["can_alert"] = source["status_code"] == "active" and usable > 0
|
||||
if source["status_code"] == "active":
|
||||
source["plain_state"] = "已接入,可進作戰清單" if usable else "已接入,等待可用資料"
|
||||
if usable:
|
||||
source["plain_state"] = "已接入,可進作戰清單"
|
||||
elif source["review_offer_count"]:
|
||||
source["plain_state"] = "已有待確認候選"
|
||||
else:
|
||||
source["plain_state"] = "已接入,等待可用資料"
|
||||
else:
|
||||
source["plain_state"] = "先保留接口,不進告警"
|
||||
|
||||
active_count = sum(1 for source in sources if source["status_code"] == "active")
|
||||
paused_count = sum(1 for source in sources if source["status_code"] == "paused")
|
||||
usable_count = sum(int(source["usable_offer_count"]) for source in sources)
|
||||
review_offer_count = sum(int(source.get("review_offer_count") or 0) for source in sources)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -1261,6 +1430,7 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
|
||||
"active_count": active_count,
|
||||
"paused_count": paused_count,
|
||||
"usable_offer_count": usable_count,
|
||||
"review_offer_count": review_offer_count,
|
||||
"sources": sources,
|
||||
"connector_contract": build_connector_contracts(),
|
||||
"plain_summary": "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。",
|
||||
|
||||
@@ -97,6 +97,12 @@ def _default_sync_candidates(engine, candidates: list[dict[str, Any]]) -> dict[s
|
||||
return sync_targeted_momo_candidates_to_external_offers(engine, candidates, dry_run=False)
|
||||
|
||||
|
||||
def _default_sync_review_candidates(engine, candidates: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
from services.external_market_offer_service import sync_targeted_momo_review_candidates_to_external_offers
|
||||
|
||||
return sync_targeted_momo_review_candidates_to_external_offers(engine, candidates, dry_run=False)
|
||||
|
||||
|
||||
def run_pchome_growth_momo_backfill(
|
||||
engine,
|
||||
*,
|
||||
@@ -104,16 +110,18 @@ def run_pchome_growth_momo_backfill(
|
||||
build_payload_func: Callable[[Any, int], dict[str, Any]] | None = None,
|
||||
search_func: Callable[[list[dict[str, Any]], int], tuple[bool, str, list[dict[str, Any]]]] | None = None,
|
||||
sync_func: Callable[[Any, list[dict[str, Any]]], dict[str, Any]] | None = None,
|
||||
sync_review_func: Callable[[Any, list[dict[str, Any]]], dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""補高業績 PChome 商品的 MOMO 對應。
|
||||
|
||||
不呼叫 LLM,只搜尋 MOMO 候選,並只把可自動判斷的 total_price / unit_price
|
||||
寫入 external_offers;需人工確認的候選只回報、不寫入。
|
||||
寫入 external_offers;需人工確認的候選會以 needs_review 保存,不進價格判斷。
|
||||
"""
|
||||
limit = max(1, min(int(limit or 12), 20))
|
||||
build_payload = build_payload_func or _default_build_payload
|
||||
search_candidates = search_func or _default_search_candidates
|
||||
sync_candidates = sync_func or _default_sync_candidates
|
||||
sync_review_candidates = sync_review_func or _default_sync_review_candidates
|
||||
|
||||
before_payload = build_payload(engine, max(limit, 16))
|
||||
targets = build_momo_backfill_targets(before_payload, limit)
|
||||
@@ -135,6 +143,12 @@ def run_pchome_growth_momo_backfill(
|
||||
"written_count": 0,
|
||||
"message": "沒有需要同步的自動候選。",
|
||||
},
|
||||
"review_candidate_sync": {
|
||||
"success": True,
|
||||
"status": "not_needed",
|
||||
"written_count": 0,
|
||||
"message": "沒有需要保存的待確認候選。",
|
||||
},
|
||||
"before_stats": before_payload.get("stats") or {},
|
||||
"after_stats": before_payload.get("stats") or {},
|
||||
"targets": [],
|
||||
@@ -165,6 +179,14 @@ def run_pchome_growth_momo_backfill(
|
||||
}
|
||||
if auto_candidates:
|
||||
external_offer_sync = sync_candidates(engine, auto_candidates)
|
||||
review_candidate_sync = {
|
||||
"success": True,
|
||||
"status": "not_found",
|
||||
"written_count": 0,
|
||||
"message": "沒有需要保存的待確認候選。",
|
||||
}
|
||||
if review_candidates:
|
||||
review_candidate_sync = sync_review_candidates(engine, review_candidates)
|
||||
|
||||
after_payload = build_payload(engine, max(limit, 16))
|
||||
written_count = int(external_offer_sync.get("written_count") or 0)
|
||||
@@ -189,6 +211,7 @@ def run_pchome_growth_momo_backfill(
|
||||
"auto_compare_count": len(auto_candidates),
|
||||
"review_count": len(review_candidates),
|
||||
"external_offer_sync": external_offer_sync,
|
||||
"review_candidate_sync": review_candidate_sync,
|
||||
"before_stats": before_payload.get("stats") or {},
|
||||
"after_stats": after_payload.get("stats") or {},
|
||||
"targets": targets[:8],
|
||||
|
||||
@@ -703,6 +703,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
|
||||
limit = max(5, min(int(limit or 20), 50))
|
||||
generated_at = datetime.now().isoformat(timespec="seconds")
|
||||
source_readiness = build_external_source_readiness(engine)
|
||||
review_candidate_count = int(source_readiness.get("review_offer_count") or 0)
|
||||
source_scope = {
|
||||
"primary_goal": "提升 PChome 業績",
|
||||
"primary_sales_source": PRIMARY_SALES_SOURCE,
|
||||
@@ -724,6 +725,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
|
||||
"mapped_count": 0,
|
||||
"mapping_rate": 0,
|
||||
"needs_mapping_count": 0,
|
||||
"review_candidate_count": review_candidate_count,
|
||||
},
|
||||
"opportunities": [],
|
||||
"message": "目前還沒有 PChome 業績資料,請先完成業績匯入。",
|
||||
@@ -745,6 +747,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
|
||||
"mapped_count": 0,
|
||||
"mapping_rate": 0,
|
||||
"needs_mapping_count": 0,
|
||||
"review_candidate_count": review_candidate_count,
|
||||
"total_sales_7d": 0,
|
||||
"opportunity_sales_7d": 0,
|
||||
"action_counts": {},
|
||||
@@ -794,6 +797,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
|
||||
"mapped_count": mapped_count,
|
||||
"mapping_rate": mapping_rate,
|
||||
"needs_mapping_count": needs_mapping_count,
|
||||
"review_candidate_count": review_candidate_count,
|
||||
"total_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2),
|
||||
"opportunity_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2),
|
||||
"action_counts": action_counts,
|
||||
|
||||
Reference in New Issue
Block a user