feat(pchome): close freshness and multi-market decision gaps
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-10 21:22:42 +08:00
parent 744cd946f3
commit b8cc27e4c4
29 changed files with 1373 additions and 123 deletions

View File

@@ -139,7 +139,10 @@ def _relative(path: Path, root: Path) -> str:
def _is_excluded(relative_path: str) -> bool:
if relative_path == "services/ai_automation_debt_service.py":
if relative_path in {
"services/ai_automation_debt_service.py",
"services/ai_surface_html_readback_service.py",
}:
return True
parts = set(relative_path.split("/"))
if parts & {".git", ".pytest_cache", "__pycache__", "node_modules", "data", "tests", "migrations"}:

View File

@@ -130,6 +130,28 @@ SOURCE_CONTRACTS = [
"data_quality_label": "暫不進告警",
"plain_note": "保留本地平台促銷與價格監控欄位,接入前不影響現有作戰清單。",
},
{
"code": "etmall",
"display_name": "東森購物",
"platform_code": "etmall",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方 API", "公開結構化資料", "供應商 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "保留東森購物的商品、售價與促銷訊號;完成來源授權與商品身份驗證後才進入決策。",
},
{
"code": "friday",
"display_name": "friDay 購物",
"platform_code": "friday",
"status_code": "paused",
"status_label": "待接入",
"source_kind": "connector_contract",
"input_methods": ["官方 API", "公開結構化資料", "供應商 API", "手動 CSV"],
"data_quality_label": "暫不進告警",
"plain_note": "保留 friDay 購物的商品、售價與促銷訊號;完成來源授權與商品身份驗證後才進入決策。",
},
{
"code": "ruten",
"display_name": "露天",
@@ -533,6 +555,22 @@ def _normalize_source_code(value: Any) -> str:
"shopee": "shopee",
"酷澎": "coupang",
"coupang": "coupang",
"yahoo": "yahoo_shopping",
"yahoo_shopping": "yahoo_shopping",
"yahoo_shopping_tw": "yahoo_shopping",
"yahoo購物": "yahoo_shopping",
"yahoo 購物": "yahoo_shopping",
"東森": "etmall",
"東森購物": "etmall",
"etmall": "etmall",
"etmall_tw": "etmall",
"friday": "friday",
"friday_tw": "friday",
"friday購物": "friday",
"friday 購物": "friday",
"樂天": "rakuten",
"rakuten": "rakuten",
"rakuten_tw": "rakuten",
}
return mapping.get(lower, mapping.get(raw, raw))
@@ -677,8 +715,6 @@ def _ensure_external_market_source_seeds(conn) -> None:
display_name = EXCLUDED.display_name,
platform_code = EXCLUDED.platform_code,
source_kind = EXCLUDED.source_kind,
status = EXCLUDED.status,
enabled = EXCLUDED.enabled,
allowed_input_methods_json = EXCLUDED.allowed_input_methods_json,
quality_policy_json = EXCLUDED.quality_policy_json,
plain_note = EXCLUDED.plain_note,
@@ -698,8 +734,6 @@ def _ensure_external_market_source_seeds(conn) -> None:
display_name = excluded.display_name,
platform_code = excluded.platform_code,
source_kind = excluded.source_kind,
status = excluded.status,
enabled = excluded.enabled,
allowed_input_methods_json = excluded.allowed_input_methods_json,
quality_policy_json = excluded.quality_policy_json,
plain_note = excluded.plain_note,
@@ -1709,6 +1743,17 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
for source in sources:
stats = normalized_stats.get(source["code"], {})
runtime_status = str(stats.get("status") or source["status_code"]).strip() or source["status_code"]
runtime_enabled = bool(stats.get("enabled")) if stats else source["status_code"] == "active"
source["status_code"] = runtime_status
source["enabled"] = runtime_enabled
source["status_label"] = (
"正在使用"
if runtime_status == "active" and runtime_enabled
else "已停用"
if runtime_status in {"disabled", "retired"}
else "先暫停"
)
if source["code"] == "momo_reference":
usable = max(
int(stats.get("usable_offer_count") or 0),
@@ -1723,8 +1768,9 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
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["has_runtime_data"] = usable > 0 or source["review_offer_count"] > 0
source["can_alert"] = runtime_status == "active" and runtime_enabled and usable > 0
if runtime_status == "active" and runtime_enabled:
if usable:
source["plain_state"] = "已接入,可進作戰清單"
elif source["review_offer_count"]:
@@ -1734,8 +1780,12 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
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")
active_count = sum(
1 for source in sources
if source["status_code"] == "active" and source.get("enabled")
)
paused_count = len(sources) - active_count
sources_with_data_count = sum(1 for source in sources if source.get("has_runtime_data"))
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)
@@ -1744,12 +1794,17 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
"schema_ready": schema_ready,
"active_count": active_count,
"paused_count": paused_count,
"sources_with_data_count": sources_with_data_count,
"source_coverage_rate": round(sources_with_data_count / max(len(sources), 1) * 100, 1),
"usable_offer_count": usable_count,
"review_offer_count": review_offer_count,
"sources": sources,
"connector_contract": build_connector_contracts(),
"offer_evidence_contract": build_offer_evidence_contract(),
"plain_summary": "MOMO 先用;其他主流平台已列管,未接合法穩定來源前不進告警。",
"plain_summary": (
f"已列管 {len(sources)} 個來源,{sources_with_data_count} 個已有資料;"
"只有 runtime 已啟用且通過身份與品質驗證的報價會進入決策。"
),
}

View File

@@ -1143,6 +1143,8 @@ class ImportService:
if not files:
logger.info("沒有找到待匯入的檔案")
data_lag_days = None
latest_sales_date = None
# Staleness gate (critic-approved 2026-05-03)
# 'move-then-success' 反模式:成功 import 後 move_file 把 Excel 搬到
@@ -1150,25 +1152,31 @@ class ImportService:
# → 4/27~5/2 daily_sales_snapshot 停更 8 天無告警。補主動偵測:
# Drive 空 + DB ≥3 天無新資料時主動發催促告警(週末跨假期不誤觸)。
try:
from database.manager import get_session
from sqlalchemy import text
from datetime import date
from services.openclaw_strategist_service import _send_data_stale_alert
_stale_session = get_session()
_stale_session = Session()
try:
last_date = _stale_session.execute(
text("SELECT MAX(snapshot_date)::date FROM daily_sales_snapshot")
text("SELECT MAX(snapshot_date) FROM daily_sales_snapshot")
).scalar()
finally:
_stale_session.close()
if last_date:
days_since = (date.today() - last_date).days
normalized_last_date = (
last_date
if isinstance(last_date, date)
else date.fromisoformat(str(last_date)[:10])
)
days_since = (date.today() - normalized_last_date).days
data_lag_days = days_since
latest_sales_date = str(normalized_last_date)
if days_since >= 3:
_send_data_stale_alert(
report_type="upstream_drive",
last_date=str(last_date),
last_date=str(normalized_last_date),
period=f"已停更 {days_since}",
)
except Exception:
@@ -1179,8 +1187,24 @@ class ImportService:
return {
'success': True,
'message': '沒有找到待匯入的檔案',
'file_count': 0
'status': (
'upstream_missing'
if data_lag_days is None
else 'upstream_stale'
if data_lag_days >= 3
else 'no_pending_file'
),
'message': (
f'上游沒有新檔,最新業績已落後 {data_lag_days}'
if data_lag_days is not None and data_lag_days >= 3
else '沒有找到待匯入的檔案'
),
'file_count': 0,
'imported_count': 0,
'latest_sales_date': latest_sales_date,
'data_lag_days': data_lag_days,
'decision_ready': data_lag_days is not None and data_lag_days <= 1,
'requires_upstream_acquisition': data_lag_days is None or data_lag_days >= 3,
}
# 處理每個檔案

View File

@@ -6,7 +6,7 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from datetime import date, datetime
from typing import Any
from sqlalchemy import bindparam, inspect, text
@@ -54,10 +54,53 @@ def _source_names_by_status(source_readiness: dict[str, Any], status_code: str)
return [
str(source.get("display_name") or source.get("code") or "").strip()
for source in source_readiness.get("sources") or []
if source.get("status_code") == status_code and str(source.get("display_name") or source.get("code") or "").strip()
if source.get("status_code") == status_code
and (status_code != "active" or source.get("enabled", True))
and str(source.get("display_name") or source.get("code") or "").strip()
]
def _sales_freshness(latest_sales_date: Any, *, today: date | None = None) -> dict[str, Any]:
raw = str(latest_sales_date or "").strip()
if not raw:
return {
"status": "missing",
"label": "尚無業績資料",
"age_days": None,
"decision_ready": False,
"next_action": "自動取得並匯入最新 PChome 業績檔",
}
try:
parsed = date.fromisoformat(raw[:10])
except ValueError:
return {
"status": "invalid",
"label": "業績日期格式異常",
"age_days": None,
"decision_ready": False,
"next_action": "修正業績資料日期後重新匯入",
}
age_days = max(0, ((today or datetime.now().date()) - parsed).days)
if age_days <= 1:
status, label, ready = "fresh", "資料新鮮", True
elif age_days == 2:
status, label, ready = "warning", "資料即將過期", False
else:
status, label, ready = "critical", "資料已過期", False
return {
"status": status,
"label": label,
"age_days": age_days,
"decision_ready": ready,
"next_action": (
"依最新業績執行今日作戰清單"
if ready
else "自動取得並匯入最新 PChome 業績檔"
),
}
def _table_exists(engine, table_name: str) -> bool:
try:
return inspect(engine).has_table(table_name)
@@ -336,7 +379,7 @@ 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]]:
def _fetch_normalized_external_offer_map(conn, pchome_product_ids: list[str]) -> dict[str, list[dict[str, Any]]]:
inspector = inspect(conn)
if not inspector.has_table("external_offers"):
return {}
@@ -347,10 +390,13 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) ->
if conn.dialect.name == "postgresql":
sql = """
WITH latest_offer AS (
SELECT DISTINCT ON (eo.pchome_product_id)
SELECT DISTINCT ON (eo.pchome_product_id, eo.source_code)
eo.pchome_product_id,
eo.source_code,
eo.platform_code,
eo.source_product_id AS momo_sku,
eo.title AS momo_name,
eo.product_url,
eo.price AS momo_price,
eo.quality_score,
eo.quality_notes_json,
@@ -358,8 +404,7 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) ->
eo.observed_at,
eo.ingestion_method
FROM external_offers eo
WHERE eo.source_code = 'momo_reference'
AND eo.pchome_product_id IS NOT NULL
WHERE eo.pchome_product_id IS NOT NULL
AND eo.pchome_product_id IN :ids
AND eo.price IS NOT NULL
AND eo.price > 0
@@ -367,7 +412,7 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) ->
AND eo.match_status IN ('verified', 'usable', 'reviewed', 'exact', 'confirmed')
AND eo.data_quality_status IN ('verified', 'usable', 'reviewed')
AND (eo.expires_at IS NULL OR eo.expires_at > CURRENT_TIMESTAMP)
ORDER BY eo.pchome_product_id, eo.observed_at DESC NULLS LAST, eo.id DESC
ORDER BY eo.pchome_product_id, eo.source_code, eo.observed_at DESC NULLS LAST, eo.id DESC
)
SELECT * FROM latest_offer
"""
@@ -376,8 +421,11 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) ->
WITH latest_offer AS (
SELECT
eo.pchome_product_id,
eo.source_code,
eo.platform_code,
eo.source_product_id AS momo_sku,
eo.title AS momo_name,
eo.product_url,
eo.price AS momo_price,
eo.quality_score,
eo.quality_notes_json,
@@ -385,12 +433,11 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) ->
eo.observed_at,
eo.ingestion_method,
ROW_NUMBER() OVER (
PARTITION BY eo.pchome_product_id
PARTITION BY eo.pchome_product_id, eo.source_code
ORDER BY eo.observed_at DESC, eo.id DESC
) AS rn
FROM external_offers eo
WHERE eo.source_code = 'momo_reference'
AND eo.pchome_product_id IS NOT NULL
WHERE eo.pchome_product_id IS NOT NULL
AND eo.pchome_product_id IN :ids
AND eo.price IS NOT NULL
AND eo.price > 0
@@ -405,7 +452,7 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) ->
stmt = text(sql).bindparams(bindparam("ids", expanding=True))
rows = conn.execute(stmt, {"ids": ids}).mappings().all()
result: dict[str, dict[str, Any]] = {}
result: dict[str, list[dict[str, Any]]] = {}
for row in rows:
raw_payload = _json_dict(row.get("raw_payload_json"))
price_basis = _external_price_basis(raw_payload)
@@ -413,11 +460,14 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) ->
key = str(row.get("pchome_product_id") or "").strip()
if not key:
continue
result[key] = {
offer = {
"pchome_product_id": key,
"source_code": str(row.get("source_code") or "").strip(),
"platform_code": str(row.get("platform_code") or row.get("source_code") or "").strip(),
"pchome_public_name": raw_payload.get("pchome_public_name"),
"momo_sku": row.get("momo_sku"),
"momo_name": row.get("momo_name"),
"product_url": row.get("product_url"),
"momo_price": row.get("momo_price"),
"pchome_price": raw_payload.get("pchome_public_price"),
"price_basis": price_basis,
@@ -435,6 +485,7 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) ->
"data_source": "external_offers",
"ingestion_method": row.get("ingestion_method"),
}
result.setdefault(key, []).append(offer)
return result
@@ -542,15 +593,138 @@ def _fetch_legacy_external_price_map(conn, pchome_product_ids: list[str]) -> dic
return result
def _fetch_external_price_map(conn, pchome_product_ids: list[str]) -> dict[str, dict[str, Any]]:
normalized_map = _fetch_normalized_external_price_map(conn, pchome_product_ids)
def _external_offer_gap(offer: dict[str, Any]) -> float:
price_basis = str(offer.get("price_basis") or "total_price")
if price_basis == "unit_price":
explicit_gap = offer.get("unit_gap_pct")
if explicit_gap is not None:
return _to_float(explicit_gap)
pchome_price = _to_float(offer.get("pchome_unit_price"))
external_price = _to_float(offer.get("momo_unit_price"))
else:
pchome_price = _to_float(offer.get("pchome_price"))
external_price = _to_float(offer.get("momo_price"))
if pchome_price > 0 and external_price > 0:
return (external_price - pchome_price) / pchome_price * 100
return 9999.0
def _fetch_external_offer_maps(
conn,
pchome_product_ids: list[str],
source_readiness: dict[str, Any],
) -> tuple[dict[str, dict[str, Any]], dict[str, list[dict[str, Any]]]]:
normalized_map = _fetch_normalized_external_offer_map(conn, pchome_product_ids)
source_policy = {
str(source.get("code") or ""): source
for source in source_readiness.get("sources") or []
}
primary_map: dict[str, dict[str, Any]] = {}
market_map: dict[str, list[dict[str, Any]]] = {}
for product_id, offers in normalized_map.items():
normalized_offers = []
for offer in offers:
source_code = str(offer.get("source_code") or "").strip()
policy = source_policy.get(source_code, {})
enriched = {
**offer,
"source_display_name": policy.get("display_name") or source_code or "外部平台",
"source_status": policy.get("status_code") or "unregistered",
"source_enabled": bool(policy.get("enabled", False)),
"actionable": bool(policy.get("can_alert")),
}
enriched["gap_pct"] = round(_external_offer_gap(enriched), 1)
normalized_offers.append(enriched)
normalized_offers.sort(key=lambda item: (_external_offer_gap(item), item.get("source_code") or ""))
market_map[product_id] = normalized_offers
actionable = [offer for offer in normalized_offers if offer.get("actionable")]
if actionable:
primary_map[product_id] = actionable[0]
missing_ids = [
str(item).strip()
for item in pchome_product_ids
if str(item or "").strip() and str(item).strip() not in normalized_map
if str(item or "").strip() and str(item).strip() not in primary_map
]
legacy_map = _fetch_legacy_external_price_map(conn, missing_ids)
return {**legacy_map, **normalized_map}
momo_policy = source_policy.get("momo_reference", {})
for product_id, legacy in legacy_map.items():
legacy_offer = {
**legacy,
"source_code": "momo_reference",
"platform_code": "momo",
"source_display_name": momo_policy.get("display_name") or "MOMO 外部價格參考",
"source_status": momo_policy.get("status_code") or "active",
"source_enabled": bool(momo_policy.get("enabled", True)),
"actionable": bool(momo_policy.get("can_alert", True)),
}
legacy_offer["gap_pct"] = round(_external_offer_gap(legacy_offer), 1)
existing_sources = {
str(item.get("source_code") or "") for item in market_map.get(product_id, [])
}
if "momo_reference" not in existing_sources:
market_map.setdefault(product_id, []).append(legacy_offer)
if legacy_offer["actionable"]:
primary_map[product_id] = legacy_offer
for offers in market_map.values():
offers.sort(key=lambda item: (_external_offer_gap(item), item.get("source_code") or ""))
return primary_map, market_map
def _fetch_catalog_mapping_summary(conn) -> dict[str, Any]:
cols = _daily_sales_columns(conn)
sku_column = cols.get("sku")
if not sku_column:
return {
"catalog_product_count": 0,
"mapped_product_count": 0,
"catalog_mapping_rate": 0.0,
}
sales_rows = conn.execute(text(
f"SELECT DISTINCT TRIM({_as_text_expr(sku_column, conn.dialect.name)}) AS product_id "
"FROM daily_sales_snapshot "
f"WHERE {_quote_identifier(sku_column)} IS NOT NULL"
)).fetchall()
sales_ids = {str(row[0]).strip() for row in sales_rows if str(row[0] or "").strip()}
mapped_ids: set[str] = set()
inspector = inspect(conn)
if inspector.has_table("external_offers"):
rows = conn.execute(text("""
SELECT DISTINCT pchome_product_id
FROM external_offers
WHERE pchome_product_id IS NOT NULL
AND price IS NOT NULL
AND price > 0
AND COALESCE(quality_score, 0) >= 76
AND match_status IN ('verified', 'usable', 'reviewed', 'exact', 'confirmed')
AND data_quality_status IN ('verified', 'usable', 'reviewed')
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
""")).fetchall()
mapped_ids.update(str(row[0]).strip() for row in rows if str(row[0] or "").strip())
if inspector.has_table("competitor_prices"):
rows = conn.execute(text("""
SELECT DISTINCT competitor_product_id
FROM competitor_prices
WHERE source = 'pchome'
AND competitor_product_id IS NOT NULL
AND price IS NOT NULL
AND price > 0
AND COALESCE(match_score, 0) >= 0.76
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
""")).fetchall()
mapped_ids.update(str(row[0]).strip() for row in rows if str(row[0] or "").strip())
mapped_catalog_ids = sales_ids & mapped_ids
return {
"catalog_product_count": len(sales_ids),
"mapped_product_count": len(mapped_catalog_ids),
"catalog_mapping_rate": round(len(mapped_catalog_ids) / max(len(sales_ids), 1) * 100, 2),
"unmapped_product_count": max(0, len(sales_ids) - len(mapped_catalog_ids)),
}
def _fetch_review_candidate_map(conn, pchome_product_ids: list[str]) -> dict[str, dict[str, Any]]:
@@ -646,10 +820,32 @@ def _fetch_review_candidate_map(conn, pchome_product_ids: list[str]) -> dict[str
return result
def _serialize_market_offer(offer: dict[str, Any]) -> dict[str, Any]:
gap = _external_offer_gap(offer)
return {
"source_code": offer.get("source_code") or "momo_reference",
"source": offer.get("source_display_name") or "MOMO 外部價格參考",
"platform_code": offer.get("platform_code") or "momo",
"source_product_id": offer.get("momo_sku"),
"title": offer.get("momo_name"),
"product_url": offer.get("product_url"),
"price": round(_to_float(offer.get("momo_price")), 2) if _to_float(offer.get("momo_price")) else None,
"pchome_price": round(_to_float(offer.get("pchome_price")), 2) if _to_float(offer.get("pchome_price")) else None,
"price_basis": offer.get("price_basis") or "total_price",
"gap_pct": round(gap, 1) if gap < 9999 else None,
"match_score": round(_to_float(offer.get("match_score")), 3),
"source_status": offer.get("source_status") or "active",
"actionable": bool(offer.get("actionable")),
"evidence_state": "decision_ready" if offer.get("actionable") else "observation_only",
"updated_at": str(offer.get("crawled_at") or ""),
}
def _score_opportunity(
sales_row: dict[str, Any],
external_row: dict[str, Any] | None,
review_candidate: dict[str, Any] | None = None,
market_offers: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
sales_7d = _to_float(sales_row.get("sales_7d"))
sales_prev_7d = _to_float(sales_row.get("sales_prev_7d"))
@@ -663,13 +859,15 @@ def _score_opportunity(
decline_score = min(24, abs(sales_delta_pct) / 45 * 24) if sales_delta_pct is not None and sales_delta_pct < 0 else 0
data_quality_score = 54
external_payload = None
market_offer_payloads = [_serialize_market_offer(offer) for offer in (market_offers or [])]
review_candidate_payload = None
action_code = "map_external_product"
action_label = "先補商品對應"
action_message = "這項商品已有業績訊號,但還沒有可確認的 MOMO 對照商品。先補對應,後續才能判斷價格壓力。"
action_message = "這項商品已有業績訊號,但還沒有可確認的外部平台同款。先補對應,後續才能判斷價格與促銷壓力。"
reason_lines = []
if external_row:
source_name = str(external_row.get("source_display_name") or "MOMO 外部價格參考")
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"))
@@ -691,7 +889,9 @@ def _score_opportunity(
tags = _load_json_tags(external_row.get("tags"))
data_quality_score = 78 + min(12, _to_float(external_row.get("match_score")) * 12)
external_payload = {
"source": "MOMO",
"source": source_name,
"source_code": external_row.get("source_code") or "momo_reference",
"platform_code": external_row.get("platform_code") or "momo",
"data_source": external_row.get("data_source") or "competitor_prices",
"data_source_label": "自動同步資料層"
if external_row.get("data_source") == "external_offers"
@@ -716,7 +916,7 @@ def _score_opportunity(
if gap_pct is not None and gap_pct < -5:
action_code = "review_price_or_promo"
action_label = "檢查售價與活動"
action_message = "MOMO 外部參考價比較低,建議檢查 PChome 售價、活動組合或曝光策略。"
action_message = f"{source_name}的同款價格較低,建議檢查 PChome 售價、活動組合或曝光策略。"
elif gap_pct is not None and gap_pct > 5:
action_code = "amplify_price_advantage"
action_label = "放大價格優勢"
@@ -733,11 +933,11 @@ def _score_opportunity(
if gap_pct is not None:
basis_prefix = "單位價" if price_basis == "unit_price" else "價格"
if gap_pct > 0:
reason_lines.append(f"PChome {basis_prefix}目前比 MOMO 低約 {abs(gap_pct):.1f}%。")
reason_lines.append(f"PChome {basis_prefix}目前比{source_name}低約 {abs(gap_pct):.1f}%。")
elif gap_pct < 0:
reason_lines.append(f"MOMO {basis_prefix}目前比 PChome 低約 {abs(gap_pct):.1f}%。")
reason_lines.append(f"{source_name}{basis_prefix}目前比 PChome 低約 {abs(gap_pct):.1f}%。")
else:
reason_lines.append(f"PChome 與 MOMO {basis_prefix}幾乎相同。")
reason_lines.append(f"PChome 與{source_name}{basis_prefix}幾乎相同。")
else:
if review_candidate:
review_candidate_payload = {
@@ -757,8 +957,16 @@ def _score_opportunity(
action_message = "已找到 MOMO 候選,但還要確認同款、色號或組合後才能進價格判斷。"
reason_lines.append("已找到 MOMO 候選,先確認同款、色號或組合。")
else:
data_quality_score -= 12
reason_lines.append("尚未找到可確認的 MOMO 對照商品。")
if market_offer_payloads:
data_quality_score = max(data_quality_score, 60)
action_code = "validate_external_source"
action_label = "驗證外部來源"
action_message = "已有外部平台同款證據,但來源尚未通過啟用政策;系統會先完成來源與商品身份驗證。"
observed_sources = sorted({str(item.get("source") or "") for item in market_offer_payloads})
reason_lines.append(f"已有 {len(observed_sources)} 個外部平台證據,尚未進入正式價格決策。")
else:
data_quality_score -= 12
reason_lines.append("尚未找到可確認的外部平台同款商品。")
if sales_delta_pct is None:
reason_lines.append("前 7 天沒有可比基準,先看近 7 天表現。")
@@ -772,7 +980,7 @@ def _score_opportunity(
if sales_7d > 0:
reason_lines.append(f"近 7 天業績約 NT$ {sales_7d:,.0f},銷量 {qty_7d:,.0f}")
mapping_gap_score = 18 if not external_row and max(sales_7d, sales_prev_7d) > 0 else 0
mapping_gap_score = 18 if not external_row and not market_offer_payloads and max(sales_7d, sales_prev_7d) > 0 else 0
priority_score = min(100, volume_score + qty_score + decline_score + mapping_gap_score + data_quality_score * 0.18)
if external_payload and external_payload.get("gap_pct") is not None:
gap = float(external_payload["gap_pct"])
@@ -783,7 +991,13 @@ def _score_opportunity(
issues = []
if not external_row:
issues.append("需要確認 MOMO 候選" if review_candidate_payload else "需要補商品對應")
issues.append(
"外部來源尚未啟用"
if market_offer_payloads
else "需要確認 MOMO 候選"
if review_candidate_payload
else "需要補商品對應"
)
if sales_delta_pct is None:
issues.append("前期業績不足")
@@ -801,6 +1015,14 @@ def _score_opportunity(
else None,
"last_sale_date": str(sales_row.get("last_sale_date") or ""),
"external_price": external_payload,
"market_offers": market_offer_payloads,
"cross_platform": {
"observed_platform_count": len({item.get("source_code") for item in market_offer_payloads}),
"decision_ready_platform_count": len({
item.get("source_code") for item in market_offer_payloads if item.get("actionable")
}),
"lowest_external_offer": market_offer_payloads[0] if market_offer_payloads else None,
},
"review_candidate": review_candidate_payload,
"priority_score": round(priority_score, 1),
"recommended_action": {
@@ -833,12 +1055,18 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
review_candidate_count = int(source_readiness.get("review_offer_count") or 0)
active_external_sources = _source_names_by_status(source_readiness, "active") or list(ACTIVE_EXTERNAL_SOURCES)
paused_external_sources = _source_names_by_status(source_readiness, "paused") or list(PAUSED_EXTERNAL_SOURCES)
observed_external_sources = [
str(source.get("display_name") or source.get("code") or "")
for source in source_readiness.get("sources") or []
if source.get("has_runtime_data")
]
source_scope = {
"primary_goal": "提升 PChome 業績",
"primary_sales_source": PRIMARY_SALES_SOURCE,
"active_external_sources": active_external_sources,
"paused_external_sources": paused_external_sources,
"plain_note": "MOMO 先用;其他主流平台待接入,不進作戰清單,也不發告警。",
"observed_external_sources": observed_external_sources,
"plain_note": "多平台證據會完整保留;只有 runtime 已啟用且通過同款與品質驗證的報價能驅動正式決策。",
"source_readiness": source_readiness,
"offer_evidence_contract": source_readiness.get("offer_evidence_contract") or {},
}
@@ -856,6 +1084,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
"mapping_rate": 0,
"needs_mapping_count": 0,
"review_candidate_count": review_candidate_count,
"sales_freshness": _sales_freshness(None),
},
"opportunities": [],
"message": "目前還沒有 PChome 業績資料,請先完成業績匯入。",
@@ -863,6 +1092,8 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
with engine.connect() as conn:
sales_summary = _fetch_sales_summary(conn)
catalog_mapping_summary = _fetch_catalog_mapping_summary(conn)
summary_freshness = _sales_freshness(sales_summary.get("overall_latest_sales_date"))
try:
sales_rows, latest_sales_date = _fetch_sales_rows(conn, limit=limit)
except RuntimeError as exc:
@@ -883,28 +1114,43 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
"action_counts": {},
"action_code_counts": {},
"external_data_source_counts": {},
"external_platform_counts": {},
"sales_freshness": summary_freshness,
**catalog_mapping_summary,
**sales_summary,
},
"opportunities": [],
"message": str(exc),
}
sales_ids = [str(row.get("pchome_product_id") or "") for row in sales_rows]
external_map = _fetch_external_price_map(conn, sales_ids)
external_map, market_offer_map = _fetch_external_offer_maps(
conn,
sales_ids,
source_readiness,
)
review_candidate_map = _fetch_review_candidate_map(conn, sales_ids)
opportunities = []
for row in sales_rows:
key = str(row.get("pchome_product_id") or "").strip()
opportunities.append(_score_opportunity(row, external_map.get(key), review_candidate_map.get(key)))
opportunities.append(_score_opportunity(
row,
external_map.get(key),
review_candidate_map.get(key),
market_offer_map.get(key),
))
opportunities.sort(key=lambda item: item["priority_score"], reverse=True)
opportunities = opportunities[:limit]
needs_mapping_count = sum(1 for item in opportunities if not item.get("external_price"))
mapped_count = len(opportunities) - needs_mapping_count
needs_mapping_count = sum(1 for item in opportunities if not item.get("market_offers"))
observed_mapping_count = len(opportunities) - needs_mapping_count
mapped_count = sum(1 for item in opportunities if item.get("external_price"))
mapping_rate = round(mapped_count / max(len(opportunities), 1) * 100, 1)
observed_mapping_rate = round(observed_mapping_count / max(len(opportunities), 1) * 100, 1)
action_counts: dict[str, int] = {}
action_code_counts: dict[str, int] = {}
external_data_source_counts: dict[str, int] = {}
external_platform_counts: dict[str, int] = {}
for item in opportunities:
label = item["recommended_action"]["label"]
code = item["recommended_action"]["code"]
@@ -916,6 +1162,12 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
external_data_source_counts[data_source_label] = (
external_data_source_counts.get(data_source_label, 0) + 1
)
for offer in item.get("market_offers") or []:
source_name = str(offer.get("source") or offer.get("source_code") or "外部平台")
external_platform_counts[source_name] = external_platform_counts.get(source_name, 0) + 1
latest_sales = latest_sales_date or sales_summary.get("overall_latest_sales_date")
sales_freshness = _sales_freshness(latest_sales)
return {
"success": True,
@@ -923,10 +1175,13 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
"generated_at": generated_at,
"source_scope": source_scope,
"stats": {
"latest_sales_date": latest_sales_date or sales_summary.get("overall_latest_sales_date"),
"latest_sales_date": latest_sales,
"sales_freshness": sales_freshness,
"candidate_count": len(opportunities),
"mapped_count": mapped_count,
"mapping_rate": mapping_rate,
"observed_mapping_count": observed_mapping_count,
"observed_mapping_rate": observed_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),
@@ -934,8 +1189,16 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
"action_counts": action_counts,
"action_code_counts": action_code_counts,
"external_data_source_counts": external_data_source_counts,
"external_platform_counts": external_platform_counts,
"platforms_with_runtime_data": int(source_readiness.get("sources_with_data_count") or 0),
"platform_source_coverage_rate": _to_float(source_readiness.get("source_coverage_rate")),
**catalog_mapping_summary,
**sales_summary,
},
"opportunities": opportunities,
"message": "已整理今日 PChome 業績成長作戰清單。",
"message": (
"業績資料已過期,作戰清單只供觀察;系統應先自動取得最新業績。"
if not sales_freshness["decision_ready"]
else "已依最新業績與外部平台證據整理今日作戰清單。"
),
}