diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index b58bbd0..0542b39 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,7 +4,7 @@ ================================================================================ 【已完成】 - - V10.295 補核心 MOMO/PChome 比價第三層語意:同核心商品但買送、套組、件數不同且只有單一基礎規格時標記 `unit_comparable`,只寫入 `competitor_match_attempts`;商品看板顯示「需單位價比較」、候選商品、候選 PChome 價格與單位價換算證據;多容量/多品項套組仍保持不可比較,避免把不同販售組合直接寫進正式總價差。 + - V10.296 補核心 MOMO/PChome 比價第三層語意與覆核閉環:同核心商品但買送、套組、件數不同且只有單一基礎規格時標記 `unit_comparable`,只寫入 `competitor_match_attempts`;商品看板、daily/growth 報表、OpenClaw/PPT 摘要共用 `competitor_intel_repository` 的覆核隊列,顯示「需單位價比較」、候選商品、候選 PChome 價格與單位價換算證據;多容量/多品項套組仍保持不可比較,避免把不同販售組合直接寫進正式總價差。 - V10.289 重排 Elephant Alpha L3 HITL `ea_escalation` Telegram 告警:改成專業 incident brief 格式,分成決策狀態、背景摘要、風險摘要、TOP 待審 SKU 與建議處置;價格行動會拆出 MOMO/PChome 價格、價差、人工處置與 PChome ID,避免長 bullet 難讀。 - V10.284 關閉 Code Review Hermes LLM scan 預設路徑:Step 2 改 deterministic fast static scan,不再讓部署後先卡三段 Ollama timeout;若需要 LLM scan 可用 `CODE_REVIEW_HERMES_LLM_SCAN_ENABLED=true` 顯式開啟,仍只走本地矩陣、不走 Gemini。 - V10.283 將 Code Review Hermes scan 收斂為 fast compact prompt:預設 2 檔 × 900 字、輸出 384 tokens,仍走 GCP-A → GCP-B → 111 本地矩陣,避免部署後 code_review_hermes 先卡三段 timeout。 diff --git a/config.py b/config.py index 7a8be6e..624677a 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.295" +SYSTEM_VERSION = "V10.296" 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 55da53d..6ca5dcb 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-20 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 僅備援 / 鎖定場景 -> **適用版本**: V10.295 +> **適用版本**: V10.296 --- @@ -345,7 +345,7 @@ LEFT JOIN competitor_prices cp - `services/competitor_identity_revalidator.py` 可對既有 `competitor_prices` legacy row 離線重跑 `identity_v2`:只有新版 matcher 分數 `>= 0.76` 且無 hard veto 才補 `identity_v2` / `legacy_revalidated` tags;預設不刷新 `expires_at`,避免過期價格進入決策。 - `CompetitorPriceFeeder.run_expired_identity_refresh()` 會優先刷新已通過 `identity_v2` 但 TTL 過期的 PChome row:直接用既有 `competitor_product_id` 批次呼叫 PChome 商品 API,再用新版 matcher 重新驗證名稱/規格/價格 sanity,通過後寫回 `competitor_prices` 與 `competitor_price_history`。這條路徑提升新鮮價格覆蓋率,但不降低 match threshold,也不讓過期價格直接進入決策。 - `marketplace_product_matcher.py` 的擴充只能走「正向證據 + 反向 veto」:品牌一致、商品線/型號訊號強、價格合理且無 hard veto 時才允許 `strong_product_line_match` 加分;補充瓶/補充包/refill 與一般正裝不互相配對,分享組/加量組/明星組等組合包不得誤配單品。 -- 套組/買送/件數不同但品牌、核心商品線與單一基礎規格一致時,matcher 必須回傳 `comparison_mode='unit_comparable'` 與 `unit_comparable` reason;Feeder 只能寫入 `competitor_match_attempts.attempt_status='unit_comparable'` 或 `refresh_unit_comparable`,不得寫入 `competitor_prices`。Dashboard 與 `competitor_intel_repository` 必須用 `build_unit_price_comparison()` 產生每 ml / 每 g / 每入單位價證據,讓 PPT / AI 報表可說明「需單位價比較」而不是把總價當同款價差。商品看板在正式配對尚未成立時,仍必須顯示最佳候選 PChome 商品名稱、候選價與「候選價,需單位換算」說明,讓人工覆核可直接看見下一步;若任一側含多個不同容量/重量規格,視為多品項套組,不可進 `unit_comparable`。 +- 套組/買送/件數不同但品牌、核心商品線與單一基礎規格一致時,matcher 必須回傳 `comparison_mode='unit_comparable'` 與 `unit_comparable` reason;Feeder 只能寫入 `competitor_match_attempts.attempt_status='unit_comparable'` 或 `refresh_unit_comparable`,不得寫入 `competitor_prices`。Dashboard 與 `competitor_intel_repository` 必須用 `build_unit_price_comparison()` 產生每 ml / 每 g / 每入單位價證據,讓 PPT / AI 報表可說明「需單位價比較」而不是把總價當同款價差。商品看板在正式配對尚未成立時,仍必須顯示最佳候選 PChome 商品名稱、候選價與「候選價,需單位換算」說明,讓人工覆核可直接看見下一步;daily/growth、PPT 與 OpenClaw 摘要不得自建查詢,需消費 `fetch_competitor_review_queue()` 與 coverage 的 `unit_comparable_count`。若任一側含多個不同容量/重量規格,視為多品項套組,不可進 `unit_comparable`。 - PChome feeder 的外部 request timeout 由 `PCHOME_FEEDER_TIMEOUT` 控制,預設 12 秒;排程不得因單一 PChome 搜尋 API timeout 被拖到數分鐘。 - 商品看板的 PChome 狀態必須把 matcher 診斷原因翻成可行動語意:品牌衝突、規格衝突、補充包差異、組合差異、商品線不符等,不可只顯示籠統「待比對」或「身份否決」。 - Dashboard 必須把「待比對」拆成可診斷狀態:`價格過期待刷新`、`舊版配對待重驗`、`低分配對待審`、`身份否決`、`需單位價比較`、`找不到同款`、`抓取異常`、`尚未搜尋`。不可再用單一「待比對」掩蓋資料品質原因。 diff --git a/routes/openclaw_bot_routes.py b/routes/openclaw_bot_routes.py index 909cfed..bf0f8d2 100644 --- a/routes/openclaw_bot_routes.py +++ b/routes/openclaw_bot_routes.py @@ -3334,14 +3334,18 @@ 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')] 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')] + unit_comparable_c = [ + r for r in results + if not r.get('found') and r.get('match_status') in ('unit_comparable', 'refresh_unit_comparable') + ] + not_found_c = [r for r in results if not r.get('found') and r not in unit_comparable_c] 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)} 件 | 待補身份/價格:{len(not_found_c)} 件\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" f"平均價差:{avg_diff_c:+.1f}%(正值=PChome較貴、MOMO具價格優勢;負值=PChome較便宜)\n\n" f"PChome低價壓力 TOP3(需研擬因應):" + " / ".join( @@ -3350,6 +3354,9 @@ def _generate_ppt_cmd(sub_type: str, sub_arg: str, _chat_id: int, target: str, f"MOMO價格優勢 TOP3(可加強曝光):" + " / ".join( f"{r['momo_name'][:15]}(MOMO便宜NT${r['price_diff']:,.0f})" for r in momo_low_price_c[:3]) + "\n" + f"單位價覆核樣本:" + " / ".join( + f"{r['momo_name'][:12]}({(r.get('unit_comparison') or {}).get('summary') or '候選價需換算'})" + for r in unit_comparable_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" diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 89cf6f5..3a5acfa 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -22,6 +22,36 @@ from sqlalchemy import inspect, text PCHOME_MATCH_SCORE_FLOOR = 0.76 +UNIT_COMPARABLE_STATUSES = {"unit_comparable", "refresh_unit_comparable"} +ACTIONABLE_ATTEMPT_STATUSES = { + "unit_comparable", + "refresh_unit_comparable", + "identity_veto", + "low_score", + "expired_match", + "refresh_no_result", + "no_result", +} +ATTEMPT_STATUS_LABELS = { + "unit_comparable": "需單位價比較", + "refresh_unit_comparable": "需單位價比較", + "identity_veto": "身份否決", + "low_score": "低信心待審", + "expired_match": "價格過期待刷新", + "refresh_no_result": "刷新找不到商品", + "no_result": "找不到同款", + "never_attempted": "尚未搜尋", +} +ATTEMPT_ACTION_LABELS = { + "unit_comparable": "人工確認檔期、贈品與單位價", + "refresh_unit_comparable": "人工確認檔期、贈品與單位價", + "identity_veto": "確認是否為不同商品線或規格", + "low_score": "人工審核候選商品身份", + "expired_match": "重新刷新 PChome 價格", + "refresh_no_result": "調整搜尋詞後重抓", + "no_result": "補充搜尋詞或品牌關鍵字", + "never_attempted": "排入 PChome 補抓", +} COMPETITOR_INTEL_CACHE_TTL_SECONDS = int(os.getenv("COMPETITOR_INTEL_CACHE_TTL_SECONDS", "1800")) _BASE_DIR = Path(__file__).resolve().parents[1] _CACHE_FILE = _BASE_DIR / "data" / "competitor_intel_cache.pkl" @@ -48,6 +78,30 @@ def _month_label(value: Any) -> str: return str(value or "")[:7] +def _attempt_status_label(status: Any) -> str: + return ATTEMPT_STATUS_LABELS.get(str(status or ""), str(status or "待比對")) + + +def _attempt_action_label(status: Any) -> str: + return ATTEMPT_ACTION_LABELS.get(str(status or ""), "人工確認比對證據") + + +def _build_unit_comparison_for_attempt(row: dict[str, Any]) -> Optional[dict[str, Any]]: + status = str(row.get("attempt_status") or "") + if status not in UNIT_COMPARABLE_STATUSES: + return None + try: + from services.marketplace_product_matcher import build_unit_price_comparison + return build_unit_price_comparison( + row.get("name") or row.get("momo_product_name") or "", + row.get("best_competitor_product_name") or "", + row.get("momo_price"), + row.get("best_competitor_price"), + ) + except Exception: + return {"comparable": False, "reason": "build_error"} + + def clear_competitor_intel_cache() -> None: """Clear cached PChome/MOMO intelligence after crawler/import updates.""" with _CACHE_LOCK: @@ -124,15 +178,39 @@ def fetch_competitor_coverage(engine) -> dict: def _fetch_competitor_coverage_uncached(engine) -> dict: """讀取目前 PChome 比價覆蓋率與待審分類。""" - if not inspect(engine).has_table("competitor_prices"): + inspector = inspect(engine) + if not inspector.has_table("competitor_prices"): return { "active_with_price": 0, "valid_matches": 0, "pending": 0, "match_rate": 0, "attempt_status": {}, + "unit_comparable_count": 0, + "actionable_review_count": 0, } + has_match_attempts = inspector.has_table("competitor_match_attempts") + attempt_cte = """ + latest_attempt AS ( + SELECT + NULL AS sku, + NULL AS attempt_status + WHERE FALSE + ) + """ + if has_match_attempts: + attempt_cte = """ + latest_attempt AS ( + SELECT DISTINCT ON (sku) + sku, + attempt_status + FROM competitor_match_attempts + WHERE source = 'pchome' + ORDER BY sku, attempted_at DESC NULLS LAST + ) + """ + sql = text(f""" WITH latest_momo AS ( SELECT @@ -156,14 +234,7 @@ def _fetch_competitor_coverage_uncached(engine) -> dict: AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST ), - latest_attempt AS ( - SELECT DISTINCT ON (sku) - sku, - attempt_status - FROM competitor_match_attempts - WHERE source = 'pchome' - ORDER BY sku, attempted_at DESC NULLS LAST - ) + {attempt_cte} SELECT (SELECT COUNT(*) FROM latest_momo WHERE rn = 1) AS active_with_price, (SELECT COUNT(*) FROM valid_competitor) AS valid_matches, @@ -190,12 +261,16 @@ def _fetch_competitor_coverage_uncached(engine) -> dict: str(row.get("attempt_status")): int(row.get("status_count") or 0) for row in rows } + unit_count = sum(statuses.get(status, 0) for status in UNIT_COMPARABLE_STATUSES) + actionable_count = sum(statuses.get(status, 0) for status in ACTIONABLE_ATTEMPT_STATUSES) return { "active_with_price": active, "valid_matches": valid, "pending": pending, "match_rate": round(valid / max(active, 1) * 100, 1), "attempt_status": statuses, + "unit_comparable_count": unit_count, + "actionable_review_count": actionable_count, "match_score_floor": PCHOME_MATCH_SCORE_FLOOR, } @@ -394,6 +469,133 @@ def _fetch_top_competitor_risks_uncached(engine, limit: int = 10) -> list[dict]: return result +def fetch_competitor_review_queue(engine, limit: int = 12) -> list[dict]: + """可行動的 PChome 比對覆核隊列,供 Dashboard / AI / PPT 共用。""" + limit = max(1, min(int(limit or 12), 50)) + return _cached_payload( + f"review_queue:v1:limit={limit}:floor={PCHOME_MATCH_SCORE_FLOOR}", + lambda: _fetch_competitor_review_queue_uncached(engine, limit=limit), + ) + + +def _fetch_competitor_review_queue_uncached(engine, limit: int = 12) -> list[dict]: + inspector = inspect(engine) + if not ( + inspector.has_table("products") + and inspector.has_table("price_records") + and inspector.has_table("competitor_prices") + and inspector.has_table("competitor_match_attempts") + ): + return [] + + limit = max(1, min(int(limit or 12), 50)) + sql = text(f""" + WITH latest_momo AS ( + SELECT + p.id AS product_id, + p.i_code AS sku, + p.name, + p.category, + pr.price AS momo_price, + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC, pr.id DESC) AS rn + FROM products p + JOIN price_records pr ON pr.product_id = p.id + WHERE p.status = 'ACTIVE' + ), + valid_competitor AS ( + SELECT DISTINCT ON (cp.sku) + cp.sku + 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 + ), + latest_attempt AS ( + SELECT DISTINCT ON (cma.sku) + cma.sku, + cma.attempt_status, + cma.candidate_count, + cma.best_competitor_product_id, + cma.best_competitor_product_name, + cma.best_competitor_price, + 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 + ) + SELECT + lm.sku, + lm.name, + lm.category, + lm.momo_price, + la.attempt_status, + la.candidate_count, + la.best_competitor_product_id, + la.best_competitor_product_name, + la.best_competitor_price, + la.best_match_score, + la.error_message, + la.attempted_at + FROM latest_momo lm + JOIN latest_attempt la ON la.sku = lm.sku + LEFT JOIN valid_competitor vc ON vc.sku = lm.sku + WHERE lm.rn = 1 + AND vc.sku IS NULL + AND la.attempt_status IN ( + 'unit_comparable', + 'refresh_unit_comparable', + 'identity_veto', + 'low_score', + 'expired_match', + 'refresh_no_result', + 'no_result' + ) + ORDER BY + CASE + WHEN la.attempt_status IN ('unit_comparable', 'refresh_unit_comparable') THEN 0 + WHEN la.attempt_status = 'identity_veto' THEN 1 + WHEN la.attempt_status = 'low_score' THEN 2 + WHEN la.attempt_status = 'expired_match' THEN 3 + ELSE 4 + END, + lm.momo_price DESC NULLS LAST, + la.best_match_score DESC NULLS LAST, + la.attempted_at DESC NULLS LAST + LIMIT :limit + """) + with engine.connect() as conn: + rows = conn.execute(sql, {"limit": limit}).mappings().all() + + queue = [] + for row in rows: + item = dict(row) + unit_comparison = _build_unit_comparison_for_attempt(item) + queue.append({ + "sku": str(item.get("sku") or ""), + "name": item.get("name") or "", + "category": item.get("category") or "", + "momo_price": _num(item.get("momo_price")), + "attempt_status": item.get("attempt_status") or "", + "status_label": _attempt_status_label(item.get("attempt_status")), + "action_label": _attempt_action_label(item.get("attempt_status")), + "candidate_count": int(item.get("candidate_count") or 0), + "candidate_pc_id": item.get("best_competitor_product_id"), + "candidate_pc_name": item.get("best_competitor_product_name") or "", + "candidate_pc_price": _num(item.get("best_competitor_price")), + "best_match_score": _num(item.get("best_match_score")), + "match_diagnostic": item.get("error_message") or "", + "attempted_at": _date_label(item.get("attempted_at")), + "unit_comparison": unit_comparison, + }) + return queue + + def fetch_competitor_comparison_results( engine, start_date: Optional[Union[date, datetime, str]] = None, @@ -549,18 +751,13 @@ def fetch_competitor_comparison_results( pchome_id = row.get("competitor_product_id") found = bool(row.get("pchome_price")) match_status = "matched" if found else (row.get("attempt_status") or "no_valid_match") - unit_comparison = None - if match_status in {"unit_comparable", "refresh_unit_comparable"}: - try: - from services.marketplace_product_matcher import build_unit_price_comparison - unit_comparison = build_unit_price_comparison( - row.get("name") or "", - row.get("best_competitor_product_name") or "", - row.get("momo_price"), - row.get("best_competitor_price"), - ) - except Exception: - unit_comparison = {"comparable": False, "reason": "build_error"} + unit_comparison = _build_unit_comparison_for_attempt({ + "attempt_status": match_status, + "name": row.get("name") or "", + "best_competitor_product_name": row.get("best_competitor_product_name") or "", + "momo_price": row.get("momo_price"), + "best_competitor_price": row.get("best_competitor_price"), + }) results.append({ "found": found, "momo_icode": str(row.get("sku") or ""), @@ -577,6 +774,8 @@ def fetch_competitor_comparison_results( "match_score": _num(row.get("match_score")), "momo_revenue": _num(row.get("momo_revenue")), "match_status": match_status, + "match_status_label": _attempt_status_label(match_status), + "action_label": _attempt_action_label(match_status), "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 "", @@ -591,5 +790,6 @@ def build_competitor_intel_payload(engine, days: int = 30) -> dict: "coverage": fetch_competitor_coverage(engine), "trend": fetch_competitor_gap_trend(engine, days=days), "top_risks": fetch_top_competitor_risks(engine, limit=10), + "review_queue": fetch_competitor_review_queue(engine, limit=12), "match_score_floor": PCHOME_MATCH_SCORE_FLOOR, } diff --git a/services/openclaw_strategist_service.py b/services/openclaw_strategist_service.py index d0ced47..e16df60 100644 --- a/services/openclaw_strategist_service.py +++ b/services/openclaw_strategist_service.py @@ -30,7 +30,7 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Optional from database.manager import get_session -from sqlalchemy import bindparam, text +from sqlalchemy import bindparam, inspect, text from services.ai_call_logger import log_ai_call # Operation Ollama-First v5.0 P1 from services.action_plan_dedupe import ( @@ -563,11 +563,29 @@ def _fetch_competitor_summary() -> Dict[str, Any]: AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' """)).fetchone() if row and row[0]: + attempt_row = None + if session.bind is not None and inspect(session.bind).has_table("competitor_match_attempts"): + attempt_row = session.execute(text(""" + WITH latest_attempt AS ( + SELECT DISTINCT ON (sku) + sku, + attempt_status + FROM competitor_match_attempts + WHERE source = 'pchome' + ORDER BY sku, attempted_at DESC NULLS LAST + ) + SELECT + SUM(CASE WHEN attempt_status IN ('unit_comparable', 'refresh_unit_comparable') THEN 1 ELSE 0 END) AS unit_comparable_count, + SUM(CASE WHEN attempt_status IN ('unit_comparable', 'refresh_unit_comparable', 'identity_veto', 'low_score', 'expired_match', 'no_result', 'refresh_no_result') THEN 1 ELSE 0 END) AS review_queue_count + FROM latest_attempt + """)).fetchone() return { "total_skus": int(row[0]), "avg_gap_pct": round(float(row[1] or 0), 1), "undercut_count": int(row[2] or 0), "premium_count": int(row[3] or 0), + "unit_comparable_count": int((attempt_row[0] if attempt_row else 0) or 0), + "review_queue_count": int((attempt_row[1] if attempt_row else 0) or 0), } return {} except Exception as e: @@ -1342,6 +1360,7 @@ def generate_weekly_strategy_report( 平均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}% 被競品削價數:{competitor_summary.get('undercut_count', 0)} 個 我方具優勢數:{competitor_summary.get('premium_count', 0)} 個 + 需單位價覆核:{competitor_summary.get('unit_comparable_count', 0)} 個 TOP 威脅品項(近48h Hermes 偵測): {_format_threats(threats)} @@ -1615,6 +1634,7 @@ def _legacy_full_gemini_daily_report() -> dict: 監控SKU:{competitor_summary.get('total_skus', 0)} 個 被削價風險:{competitor_summary.get('undercut_count', 0)} 個(價差超過10%) 平均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}% + 單位價/身份覆核隊列:{competitor_summary.get('review_queue_count', 0)} 個 請按以下結構輸出(使用 HTML 標題): @@ -1785,6 +1805,7 @@ def generate_monthly_report() -> dict: 監控SKU:{competitor_summary.get('total_skus', 0)} 個 月均價差:{competitor_summary.get('avg_gap_pct', 0):+.1f}% 被削價風險SKU:{competitor_summary.get('undercut_count', 0)} 個 + 需單位價覆核SKU:{competitor_summary.get('unit_comparable_count', 0)} 個 【價格變動概況】 本月調價次數:{price_trend_data.get('price_changes', 0)} 次 diff --git a/templates/daily_sales.html b/templates/daily_sales.html index 2877549..70779c4 100644 --- a/templates/daily_sales.html +++ b/templates/daily_sales.html @@ -355,8 +355,24 @@ 待審/待補 {{ comp_coverage.pending | default(0) | number_format }} +
+ 需單位價覆核 + {{ comp_coverage.unit_comparable_count | default(0) | number_format }} +
- {% if competitor_intel.top_risks %} + {% if competitor_intel.review_queue %} +
    + {% for item in competitor_intel.review_queue[:3] %} +
  1. + {{ item.name[:22] }} · {{ item.status_label }} + {{ item.action_label }} + {% if item.unit_comparison and item.unit_comparison.summary %} + {{ item.unit_comparison.summary }} + {% endif %} +
  2. + {% endfor %} +
+ {% elif competitor_intel.top_risks %}
    {% for item in competitor_intel.top_risks[:5] %}
  1. diff --git a/templates/growth_analysis.html b/templates/growth_analysis.html index fe2a5ee..b1b3ae9 100644 --- a/templates/growth_analysis.html +++ b/templates/growth_analysis.html @@ -147,6 +147,8 @@ {{ coverage.match_rate | default(0) }}% 待審/待補 {{ coverage.pending | default(0) | number_format }} + 需單位價覆核 + {{ coverage.unit_comparable_count | default(0) | number_format }} diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index 6f788dc..9539fe6 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -51,6 +51,22 @@ def test_competitor_ppt_results_keep_pending_diagnostics_in_export(): assert "(vc.pchome_price IS NULL)" in source +def test_competitor_review_queue_is_canonical_unit_price_handoff(): + source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8") + daily_template = (ROOT / "templates" / "daily_sales.html").read_text(encoding="utf-8") + growth_template = (ROOT / "templates" / "growth_analysis.html").read_text(encoding="utf-8") + + assert "def fetch_competitor_review_queue" in source + assert "\"review_queue\": fetch_competitor_review_queue" in source + assert "\"unit_comparable_count\"" in source + assert "\"status_label\"" in source + assert "\"action_label\"" in source + assert "build_unit_price_comparison" in source + assert "需單位價覆核" in daily_template + assert "competitor_intel.review_queue" in daily_template + assert "coverage.unit_comparable_count" in growth_template + + def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint(): source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8") @@ -58,14 +74,17 @@ def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint(): assert "待補資料不可當成成功配對" in source assert "高信心比對" in source assert "待補身份/價格" in source + assert "需單位價比較" in source + assert "單位價覆核樣本" in source assert "我方 = PChome" not in source assert "請以 PChome 視角" not in source def test_top_competitor_risks_reads_latest_momo_price_after_valid_competitor_filter(): source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8") + risk_source = source.split("def _fetch_top_competitor_risks_uncached", 1)[1].split("def fetch_competitor_review_queue", 1)[0] - assert "FROM valid_competitor vc" in source - assert "JOIN LATERAL" in source - assert "WHERE pr.product_id = p.id" in source - assert "ROW_NUMBER() OVER (PARTITION BY p.id" not in source.split("def _fetch_top_competitor_risks_uncached", 1)[1].split("def fetch_competitor_comparison_results", 1)[0] + assert "FROM valid_competitor vc" in risk_source + assert "JOIN LATERAL" in risk_source + assert "WHERE pr.product_id = p.id" in risk_source + assert "ROW_NUMBER() OVER (PARTITION BY p.id" not in risk_source diff --git a/web/static/css/page-daily-sales.css b/web/static/css/page-daily-sales.css index 25dd5a6..606c5ca 100644 --- a/web/static/css/page-daily-sales.css +++ b/web/static/css/page-daily-sales.css @@ -815,7 +815,7 @@ .daily-competitor-summary { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 14px; } @@ -863,6 +863,25 @@ color: var(--momo-danger-text); } +.daily-competitor-risk-list--review { + padding-left: 0; + list-style: none; +} + +.daily-competitor-risk-list--review strong { + color: var(--momo-page-accent-dark); + font-size: 0.78rem; +} + +.daily-competitor-risk-list--review em { + display: block; + margin-top: 3px; + color: var(--momo-text-muted); + font-family: var(--momo-font-mono, ui-monospace, monospace); + font-size: 0.72rem; + font-style: normal; +} + .card-header--split { display: flex; justify-content: space-between; diff --git a/web/static/css/page-growth.css b/web/static/css/page-growth.css index 389d7af..3097468 100644 --- a/web/static/css/page-growth.css +++ b/web/static/css/page-growth.css @@ -76,3 +76,23 @@ .growth-analysis-page .trend-down { color: var(--momo-danger-text) !important; } + +.ga-competitor-quality { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px 14px; + align-items: baseline; +} + +.ga-competitor-quality span { + min-width: 0; + color: var(--momo-text-secondary); + font-size: var(--momo-text-body-sm); + font-weight: var(--momo-font-weight-semibold); +} + +.ga-competitor-quality strong { + color: var(--momo-text-primary); + font-weight: var(--momo-font-weight-black); + text-align: right; +}