diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 4cb607a..909cfed 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -2424,24 +2424,20 @@ def _ppt_ai_analysis(prompt_data: str, report_type: str = '') -> str: "台灣消費者行為與美妝/保健品類購買決策模式。\n\n" "═══════════════════════════════\n" "角色設定(絕對不可違反)\n" - " 我方 = PChome  競品 = momo\n" - " price_diff = momo售價 − PChome售價\n" - " 正值 → momo偏貴 → PChome定價優勢 → 加強曝光與轉換\n" - " 負值 → momo較便宜 → 競品威脅 → 研擬差異化因應策略\n" + " 視角 = EwoooC 商品營運決策,不預設單一平台永遠正確\n" + " price_diff = PChome售價 − MOMO售價\n" + " 正值 → PChome較貴 / MOMO具價格優勢 → 可放大 MOMO 價格賣點\n" + " 負值 → PChome較便宜 / PChome低價壓力 → 需評估促銷、組合或服務差異化\n" + " 待補資料不可當成成功配對;必須明確列為資料品質風險\n" "═══════════════════════════════\n\n" - f"請以 PChome 視角,針對以下{report_type}輸出一份專業競品分析報告:\n\n" + f"請以 EwoooC 商品營運視角,針對以下{report_type}輸出一份專業競品分析報告:\n\n" "【整體競爭態勢】(3-4句)\n" - "從 PChome 角度評估本期整體定價競爭力,引用平均價差百分比," - "指出最關鍵亮點與警訊,並結合美妝/保健/母嬰市場脈動說明意涵。\n\n" - "【PChome 定價優勢商品深度解析】(4-5句)\n" - "點名具體優勢商品與品類(如施巴、ESTEE LAUDER 等)," - "推測 PChome 具優勢的可能原因(進貨量優勢、活動定價、品牌授權)," - "給出具體的曝光放大策略:首頁置頂、搜尋關鍵字投放、會員專案推廣。\n\n" - "【momo 威脅商品應對方案】(4-5句)\n" - "點名具體威脅商品,判斷 momo 低價策略來源(平台補貼/獨家代理/批量進貨)," - "提出 PChome 差異化應對:加值服務(快速到貨/禮盒包裝)、" - "組合促銷、PChome 幣回饋、VIP 會員專屬折扣," - "避免純粹降價而犧牲毛利。\n\n" + "引用平均價差、比對成功件數、待補資料件數,指出價格壓力、MOMO 優勢與資料覆蓋風險。\n\n" + "【PChome 低價壓力商品深度解析】(4-5句)\n" + "點名 PChome 較便宜的具體商品與品類,判斷可能原因(活動定價、組合包、會員回饋或清庫存)," + "提出 MOMO 端可採取的促銷、組合、內容曝光或服務差異化做法,避免只用降價犧牲毛利。\n\n" + "【MOMO 價格優勢商品放大策略】(4-5句)\n" + "點名 MOMO 較便宜的商品,提出可放大的搜尋關鍵字、站內陳列、檔期素材與推薦理由。\n\n" "【美妝/保健/母嬰品類專項洞察】(3-4句)\n" "針對本期出現的具體商品,結合台灣市場趨勢深度分析:\n" "美妝:成分透明化趨勢、敏感肌/無添加需求、社群口碑行銷;\n" @@ -3336,23 +3332,27 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, ) found_c = [r for r in results if r.get('found')] - pc_wins_c = [r for r in found_c if r.get('price_diff', 0) < -10] - mo_wins_c = [r for r in found_c if r.get('price_diff', 0) > 10] + pchome_low_price_c = [r for r in found_c if r.get('price_diff', 0) < -10] + momo_low_price_c = [r for r in found_c if r.get('price_diff', 0) > 10] + not_found_c = [r for r in results if not r.get('found')] avg_diff_c = (sum(r.get('price_diff_pct', 0) for r in found_c) / len(found_c) if found_c else 0) data_summary = ( f"【可信資料源=competitor_prices 高信心配對,MOMO vs PChome】\n" f"分析週期:{period_label}\n" - f"掃描商品:{len(results)} 件 | 比對成功:{len(found_c)} 件\n" - f"PChome定價優勢(比momo便宜):{len(pc_wins_c)} 件 | momo威脅(比PChome便宜):{len(mo_wins_c)} 件\n" + f"掃描商品:{len(results)} 件 | 高信心比對:{len(found_c)} 件 | 待補身份/價格:{len(not_found_c)} 件\n" + f"PChome 低價壓力(PChome 比 MOMO 便宜):{len(pchome_low_price_c)} 件 | MOMO 價格優勢(MOMO 比 PChome 便宜):{len(momo_low_price_c)} 件\n" f"平均價差:{avg_diff_c:+.1f}%(正值=PChome較貴、MOMO具價格優勢;負值=PChome較便宜)\n\n" - f"PChome優勢 TOP3(應加強曝光宣傳):" + " / ".join( + f"PChome低價壓力 TOP3(需研擬因應):" + " / ".join( f"{r['momo_name'][:15]}(PChome便宜NT${abs(r['price_diff']):,.0f})" - for r in pc_wins_c[:3]) + "\n" - f"momo威脅 TOP3(需研擬因應策略):" + " / ".join( + for r in pchome_low_price_c[:3]) + "\n" + f"MOMO價格優勢 TOP3(可加強曝光):" + " / ".join( f"{r['momo_name'][:15]}(MOMO便宜NT${r['price_diff']:,.0f})" - for r in mo_wins_c[:3]) + "\n\n" + for r in momo_low_price_c[:3]) + "\n" + f"待補資料樣本:" + " / ".join( + f"{r['momo_name'][:12]}({r.get('match_status', 'no_valid_match')})" + for r in not_found_c[:3]) + "\n\n" f"外部情報:{mcp_text_c[:400]}" ) ai_text = cached_ai or _ppt_ai_analysis(data_summary, f'競品比較簡報({period_label})') diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index da4a966..94c8742 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -406,11 +406,36 @@ def fetch_competitor_comparison_results( ) -> list[dict]: """輸出與 legacy competitor PPT 相容的比價結果,不再 live crawl。""" limit = max(1, min(int(limit or 30), 100)) - has_daily_sales = inspect(engine).has_table("daily_sales") + inspector = inspect(engine) + if not ( + inspector.has_table("products") + and inspector.has_table("price_records") + and 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," - order_expr = "ABS((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) DESC NULLS LAST" + attempt_cte = """ + latest_attempt AS ( + SELECT + NULL AS sku, + NULL AS attempt_status, + NULL AS candidate_count, + NULL AS best_match_score, + NULL AS error_message, + NULL AS attempted_at + WHERE FALSE + ) + """ + order_expr = ( + "lm.momo_price DESC NULLS LAST, " + "(vc.pchome_price IS NULL), " + "ABS((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) DESC NULLS LAST" + ) params: dict[str, Any] = {"limit": limit} if has_daily_sales: @@ -434,7 +459,27 @@ def fetch_competitor_comparison_results( """ sales_join = "LEFT JOIN sales_rank sr ON sr.product_id = lm.product_id" sales_select = "COALESCE(sr.momo_revenue, 0) AS momo_revenue," - order_expr = "COALESCE(sr.momo_revenue, 0) DESC, " + order_expr + order_expr = ( + "COALESCE(sr.momo_revenue, 0) DESC, " + "(vc.pchome_price IS NULL), " + "ABS((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) DESC NULLS LAST" + ) + + if has_match_attempts: + attempt_cte = """ + latest_attempt AS ( + SELECT DISTINCT ON (cma.sku) + cma.sku, + cma.attempt_status, + cma.candidate_count, + cma.best_match_score, + cma.error_message, + cma.attempted_at + FROM competitor_match_attempts cma + WHERE cma.source = 'pchome' + ORDER BY cma.sku, cma.attempted_at DESC NULLS LAST + ) + """ sql = text(f""" WITH latest_momo AS ( @@ -463,7 +508,8 @@ def fetch_competitor_comparison_results( 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 - ) + ), + {attempt_cte} {sales_cte} SELECT lm.sku, @@ -473,11 +519,17 @@ def fetch_competitor_comparison_results( vc.competitor_product_id, vc.competitor_product_name, vc.match_score, + la.attempt_status, + la.candidate_count, + la.best_match_score, + la.error_message, + la.attempted_at, {sales_select} (vc.pchome_price - lm.momo_price) AS price_diff, ((vc.pchome_price - lm.momo_price) / lm.momo_price * 100) AS price_diff_pct FROM latest_momo lm - JOIN valid_competitor vc ON vc.sku = lm.sku + LEFT JOIN valid_competitor vc ON vc.sku = lm.sku + LEFT JOIN latest_attempt la ON la.sku = lm.sku {sales_join} WHERE lm.rn = 1 AND lm.momo_price > 0 @@ -490,8 +542,9 @@ def fetch_competitor_comparison_results( results = [] for row in rows: pchome_id = row.get("competitor_product_id") + found = bool(row.get("pchome_price")) results.append({ - "found": True, + "found": found, "momo_icode": str(row.get("sku") or ""), "momo_name": row.get("name") or "", "momo_price": _num(row.get("momo_price")), @@ -502,6 +555,10 @@ 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")), + "match_status": "matched" if found else (row.get("attempt_status") or "no_valid_match"), + "candidate_count": int(row.get("candidate_count") or 0), + "best_match_score": _num(row.get("best_match_score")), + "match_diagnostic": row.get("error_message") or "", }) return results diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index 68b0f72..654f5a8 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -1,6 +1,9 @@ from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] + + def test_competitor_intel_cache_reuses_memory_and_shared_file(tmp_path, monkeypatch): from services import competitor_intel_repository as repo @@ -34,3 +37,24 @@ def test_clear_competitor_intel_cache_removes_shared_file(tmp_path, monkeypatch) assert repo._MEM_CACHE == {} assert not cache_file.exists() + + +def test_competitor_ppt_results_keep_pending_diagnostics_in_export(): + source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8") + + assert "LEFT JOIN valid_competitor" in source + assert "\"found\": found" in source + assert "\"match_status\"" in source + assert "\"candidate_count\"" in source + assert "(vc.pchome_price IS NULL)" in source + + +def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint(): + source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8") + + assert "EwoooC 商品營運視角" in source + assert "待補資料不可當成成功配對" in source + assert "高信心比對" in source + assert "待補身份/價格" in source + assert "我方 = PChome" not in source + assert "請以 PChome 視角" not in source