feat(pchome): close freshness and multi-market decision gaps
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
@@ -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"}:
|
||||
|
||||
@@ -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 已啟用且通過身份與品質驗證的報價會進入決策。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
# 處理每個檔案
|
||||
|
||||
@@ -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 "已依最新業績與外部平台證據整理今日作戰清單。"
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user