修正競品歷史報表取價來源
All checks were successful
CD Pipeline / deploy (push) Successful in 1m13s

This commit is contained in:
OoO
2026-05-20 11:32:40 +08:00
parent 44b8075506
commit c2528c1339
6 changed files with 93 additions and 23 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.315 修正競品簡報/報表指定日期取價:`fetch_competitor_comparison_results()` 在有 start/end date 時改讀 `competitor_price_history` 的期間快照MOMO 價格也取期間結束前最新價;沒有指定日期才使用目前有效 `competitor_prices`,避免把今天的 PChome 快取價塞回歷史 daily/growth/PPT 判讀。
- V10.314 擴大 PChome 候選池與搜尋韌性PChome 搜尋 API 改為依 limit 掃多頁並對 429/5xx/timeout 做有限重試feeder 預設每個商品最多 5 組搜尋詞、每詞 20 候選、2 頁,且搜尋清理不再刪掉括號/方括號內的品牌與規格,讓正確候選更有機會進 matcher而不是長期停在「待對比」。
- V10.312 強化 PChome 商品身份比對防錯配matcher 開始解析 mg/mcg 劑量、件組套組與多規格集合60ml+150ml vs 60ml+20ml、10mg vs 20mg、10片 vs 10盒、精華 vs 化妝水都會進硬否決或單位價覆核,不再靠單一規格重疊放行;覆核診斷同步新增「劑量差異」標籤,降低核心比價錯配污染 daily/growth/PPT/AI 分析。
- V10.311 統一競品價差語意:`fetch_competitor_comparison_results()`、competitor PPT 與 OpenClaw competitor prompt 全部改用 `MOMO - PChome`,正值代表 MOMO 較貴 / PChome 低價壓力,負值代表 MOMO 價格優勢;避免 daily/growth 顯示價格壓力但 PPT/AI 反向解讀。

View File

@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.314"
SYSTEM_VERSION = "V10.315"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -607,3 +607,4 @@ POSTGRES_HOST=momo-db
| 2026-04-17 | NIM Tool Calling E2E | 真實 NVIDIA_API_KEY 驗證dispatched=3, errors=[] |
| 2026-05-20 | PChome 商品身份比對仍可能因單一規格重疊誤放行 | V10.312 起 matcher 解析 mg/mcg 劑量、件組套組、多規格集合與同數字不同單位;劑量/容量/重量/件數/品類衝突會硬否決或導向單位價覆核,避免錯配污染 Dashboard、daily/growth、PPT 與 AI 競價分析 |
| 2026-05-20 | 正確 PChome 候選常因只掃第一頁或搜尋詞丟失品牌/規格而未進入 matcher | V10.314 起搜尋 API 依 limit 掃多頁、對暫時性錯誤有限重試feeder 預設 5 組搜尋詞、20 候選、2 頁,並保留括號/方括號內品牌與規格,提升覆核隊列與正式比價的候選品質 |
| 2026-05-20 | 指定日期競品簡報可能混用目前 `competitor_prices` 快取價 | V10.315 起 `fetch_competitor_comparison_results()` 有 start/end date 時改用 `competitor_price_history` 期間快照MOMO 價格取報表結束日前最新價;即時報表才使用目前有效 `competitor_prices` |

View File

@@ -3343,7 +3343,7 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str,
if found_c else 0)
data_summary = (
f"【可信資料源=competitor_prices 高信心配對MOMO vs PChome】\n"
f"【可信資料源=指定期間 competitor_price_history即時報表才用 competitor_pricesMOMO vs PChome】\n"
f"分析週期:{period_label}\n"
f"掃描商品:{len(results)} 件 | 高信心比對:{len(found_c)} 件 | 需單位價比較:{len(unit_comparable_c)} 件 | 待補身份/價格:{len(not_found_c)}\n"
f"PChome 低價壓力PChome 比 MOMO 便宜):{len(pchome_low_price_c)} 件 | MOMO 價格優勢MOMO 比 PChome 便宜):{len(momo_low_price_c)}\n"

View File

@@ -934,15 +934,24 @@ def fetch_competitor_comparison_results(
if not (
inspector.has_table("products")
and inspector.has_table("price_records")
and inspector.has_table("competitor_prices")
):
return []
start_date_param = str(start_date).replace("/", "-")[:10] if start_date else ""
end_date_param = str(end_date).replace("/", "-")[:10] if end_date else ""
requested_historical_prices = bool(start_date_param or end_date_param)
use_history_prices = bool(requested_historical_prices and inspector.has_table("competitor_price_history"))
if requested_historical_prices and not use_history_prices:
return []
if not (use_history_prices or inspector.has_table("competitor_prices")):
return []
has_daily_sales = inspector.has_table("daily_sales")
has_match_attempts = inspector.has_table("competitor_match_attempts")
sales_cte = ""
sales_join = ""
sales_select = "0 AS momo_revenue,"
momo_price_cutoff = ""
attempt_cte = """
latest_attempt AS (
SELECT
@@ -964,15 +973,17 @@ def fetch_competitor_comparison_results(
"ABS((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) DESC NULLS LAST"
)
params: dict[str, Any] = {"limit": limit}
if end_date_param:
params["end_date"] = end_date_param
momo_price_cutoff = "AND pr.timestamp < DATE(:end_date) + INTERVAL '1 day'"
if has_daily_sales:
where = []
if start_date:
if start_date_param:
where.append("DATE(s.date) >= DATE(:start_date)")
params["start_date"] = str(start_date).replace("/", "-")[:10]
if end_date:
params["start_date"] = start_date_param
if end_date_param:
where.append("DATE(s.date) <= DATE(:end_date)")
params["end_date"] = str(end_date).replace("/", "-")[:10]
sales_where = "WHERE " + " AND ".join(where) if where else ""
sales_cte = f""",
sales_rank AS (
@@ -1011,6 +1022,57 @@ def fetch_competitor_comparison_results(
)
"""
if use_history_prices:
history_filters = [
"cph.source = 'pchome'",
"cph.price IS NOT NULL",
"cph.price > 0",
f"COALESCE(cph.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR}",
"COALESCE(cph.tags, '[]'::jsonb) ? 'identity_v2'",
]
if start_date_param:
params["start_date"] = start_date_param
history_filters.append("cph.crawled_at >= DATE(:start_date)")
if end_date_param:
history_filters.append("cph.crawled_at < DATE(:end_date) + INTERVAL '1 day'")
history_where = "\n AND ".join(history_filters)
valid_competitor_cte = f"""
valid_competitor AS (
SELECT DISTINCT ON (cph.sku)
cph.sku,
cph.price AS pchome_price,
cph.competitor_product_id,
cph.competitor_product_name,
cph.match_score,
cph.crawled_at,
'competitor_price_history' AS competitor_source
FROM competitor_price_history cph
WHERE {history_where}
ORDER BY cph.sku, cph.crawled_at DESC NULLS LAST
)
"""
else:
valid_competitor_cte = f"""
valid_competitor AS (
SELECT DISTINCT ON (cp.sku)
cp.sku,
cp.price AS pchome_price,
cp.competitor_product_id,
cp.competitor_product_name,
cp.match_score,
cp.crawled_at,
'competitor_prices' AS competitor_source
FROM competitor_prices cp
WHERE cp.source = 'pchome'
AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)
AND cp.price IS NOT NULL
AND cp.price > 0
AND COALESCE(cp.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR}
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST
)
"""
sql = text(f"""
WITH latest_momo AS (
SELECT
@@ -1022,23 +1084,9 @@ def fetch_competitor_comparison_results(
FROM products p
JOIN price_records pr ON pr.product_id = p.id
WHERE p.status = 'ACTIVE'
{momo_price_cutoff}
),
valid_competitor AS (
SELECT DISTINCT ON (cp.sku)
cp.sku,
cp.price AS pchome_price,
cp.competitor_product_id,
cp.competitor_product_name,
cp.match_score
FROM competitor_prices cp
WHERE cp.source = 'pchome'
AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)
AND cp.price IS NOT NULL
AND cp.price > 0
AND COALESCE(cp.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR}
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST
),
{valid_competitor_cte},
{attempt_cte}
{sales_cte}
SELECT
@@ -1049,6 +1097,8 @@ def fetch_competitor_comparison_results(
vc.competitor_product_id,
vc.competitor_product_name,
vc.match_score,
vc.crawled_at AS competitor_crawled_at,
vc.competitor_source,
la.attempt_status,
la.candidate_count,
la.best_competitor_product_id,
@@ -1099,6 +1149,8 @@ def fetch_competitor_comparison_results(
"price_diff_pct": _num(row.get("price_diff_pct")),
"match_score": _num(row.get("match_score")),
"momo_revenue": _num(row.get("momo_revenue")),
"competitor_source": row.get("competitor_source") or "",
"pc_crawled_at": _date_label(row.get("competitor_crawled_at")),
"match_status": match_status,
"match_status_label": _attempt_status_label(match_status),
"action_label": _attempt_action_label(match_status),

View File

@@ -53,6 +53,22 @@ def test_competitor_ppt_results_keep_pending_diagnostics_in_export():
assert "((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) AS price_diff_pct" in source
def test_competitor_ppt_results_use_history_for_dated_reports():
source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
route_source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8")
assert "requested_historical_prices" in source
assert "use_history_prices" in source
assert "FROM competitor_price_history cph" in source
assert "cph.crawled_at >= DATE(:start_date)" in source
assert "cph.crawled_at < DATE(:end_date) + INTERVAL '1 day'" in source
assert "pr.timestamp < DATE(:end_date) + INTERVAL '1 day'" in source
assert "'competitor_price_history' AS competitor_source" in source
assert "\"competitor_source\"" in source
assert "\"pc_crawled_at\"" in source
assert "指定期間 competitor_price_history" in route_source
def test_competitor_ppt_and_ai_use_momo_minus_pchome_gap_direction():
ppt_source = (ROOT / "services" / "ppt_generator.py").read_text(encoding="utf-8")
route_source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8")