diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index c881623..688cddd 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -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 反向解讀。 diff --git a/config.py b/config.py index a89c6d0..594cfd0 100644 --- a/config.py +++ b/config.py @@ -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 # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index d650dcd..171ab2c 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -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` | diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index c101a6f..0a4484d 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -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_prices,MOMO 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" diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 01d6f4d..9d97f6d 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -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), diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index 9021b76..627f9d1 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -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")