V10.606 正名 PChome 業績成長作戰系統
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
This commit is contained in:
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.605"
|
||||
SYSTEM_VERSION = "V10.606"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# MOMO PRO — AI 競價情報模組 Single Source of Truth
|
||||
# PChome 業績成長自動化作戰系統 — AI 競價情報模組 Single Source of Truth
|
||||
|
||||
> **最後更新**: 2026-06-15 (台北時間)
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強
|
||||
> **適用版本**: V10.605
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」
|
||||
> **適用版本**: V10.606
|
||||
|
||||
---
|
||||
|
||||
@@ -45,6 +45,15 @@
|
||||
- 111 的 LAN 入口必須經 `scripts/ops/ollama111_allow_proxy.py` allowlist proxy:真實 Ollama 綁 `127.0.0.1:11434`,proxy 綁 `192.168.0.111:11434`,預設只允許 111 本機與 188 生產宿主;110 / 121 / 其他 LAN client 不能直接打 111,避免跨專案 CI 或 VM 繞過 momo-pro router 載入 7B+ runner。111 上以 `scripts/ops/install_ollama111_allow_proxy.sh` 安裝 user LaunchAgent,安裝器會把 proxy script 複製到 `~/.local/share/momo-pro-system/ollama111_allow_proxy.py`,讓 LaunchAgent 不依賴 iCloud repo 掛載路徑,並讓 proxy 與 `OLLAMA_HOST=127.0.0.1:11434` 在登入/重啟後自動恢復。拒絕日誌以 `OLLAMA111_PROXY_REJECT_LOG_DEDUP_SEC=60` 去重,避免 121 這類旁路探測刷爆 111 磁碟日誌。
|
||||
- ElephantAlpha 的 `price_drop_alert` / `market_opportunity` Telegram HITL 告警必須把同款證據獨立呈現,至少包含 `match_type`、`price_basis`、`alert_tier` 與 `match_score`;沒有高信心同款與總價可比證據時,不得把 PChome/MOMO 價差寫成可直接跟價建議。
|
||||
|
||||
## 零之零、產品定位正名(2026-06-15)
|
||||
|
||||
- 本專案的營運定位正名為「PChome 業績成長自動化作戰系統」。
|
||||
- 主要目標是提升 PChome 銷售業績;MOMO 是目前已接入的外部價格參考來源,不再把 PChome 視為附屬競品語意。
|
||||
- 使用者可見 UI、Telegram 與報表文案必須白話、可行動,優先使用「商品對應」「可直接比價」「待補對應」「放大價格優勢」「檢查售價與活動」等營運語言,避免把 `identity_v2`、`match_score`、`candidate queue` 等工程詞直接丟給使用者。
|
||||
- `services/pchome_revenue_growth_service.py` 是第一版只讀作戰清單:讀 PChome 後台業績與已驗證 MOMO 外部價格參考,輸出 `/api/ai/pchome-growth/opportunities`。此服務不呼叫 LLM、不抓外站、不寫 DB。
|
||||
- 2026-06-15 只讀盤點確認:`daily_sales_snapshot."商品ID"` 與 `competitor_prices.competitor_product_id` 在正式資料中直接重疊為 0。因此第一版作戰清單不得硬接兩邊 ID;若沒有可驗證對應,只能輸出「先補商品對應」任務。
|
||||
- 蝦皮與酷澎暫停接入,不進作戰清單、不發告警;後續只可透過 official API / provider API / manual CSV 進 `external_offers` 類正規化層,並清楚標示資料品質。
|
||||
|
||||
## 零之一、12 Agent 決策信封(2026-05-24)
|
||||
|
||||
- 12 角色分工不作為 12 個常駐模型;在產品層統一收斂成 `decision_envelope`,由 Hermes / NemoTron / OpenClaw / ElephantAlpha 與人工審核、PPT QA、競品 review queue 共用。
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
- 2026-06-02 起,`V10.568` 將價格類 `decision_envelope` 的 Telegram 直送訊息改為專業 brief:標的、價格證據、比對證據、人工下一步四段式;review queue 信封 subject 同步帶 `momo_price` / `competitor_price`,讓 Telegram、PPT、Webcrumbs 與 AI 摘要共用價格證據。
|
||||
- 2026-06-02 起,`V10.569` 將 Webcrumbs host data 串到 `summarize_review_decision_envelopes()`,payload 新增 `reviewDecisionBrief` 與 review queue / HITL / auto-execute-blocked metadata;共用 UI runtime 讀同一份 PChome 覆核信封摘要,仍只讀 DB、不呼叫 LLM、不抓外站、不寫資料。
|
||||
- 2026-06-15 起,`V10.605` 修正 PChome 後台業績 Excel 匯入韌性:auto import 會掃所有 worksheet / 表頭列並選擇 `即時業績明細` 類明細 sheet,欄位或日期不合格的檔案會移至 Drive `匯入失敗` 避免 30 分鐘重複告警;同版修復 scheduler 容器缺 `pg_dump` 的備份告警。Production 匯入任務 #54 成功寫入 12,460 筆,`daily_sales_snapshot` 與 `realtime_sales_monthly` 最新日期皆為 2026-06-14;資料新鮮度 probe 降為 `gap=1 / info / notified=false`,daily/growth chart runtime guard 通過。
|
||||
- 2026-06-15 起,`V10.606` 正名為「PChome 業績成長自動化作戰系統」並新增只讀 `/api/ai/pchome-growth/opportunities`:作戰清單以 PChome 後台業績為主、MOMO 作為外部價格參考,蝦皮與酷澎先暫停且不進告警。正式只讀盤點確認 `daily_sales_snapshot."商品ID"` 與 `competitor_prices.competitor_product_id` 直接重疊為 0,因此第一版不硬接 ID;無可驗證對應時只輸出「先補商品對應」任務。AI 競情頁同步改成白話營運文案,避免把工程術語直接呈現給使用者。
|
||||
|
||||
## 3. 12 Agent 決策信封整合
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@ _ICAIM_DASHBOARD_TTL_SECONDS = 120
|
||||
_ICAIM_DASHBOARD_STALE_TTL_SECONDS = 900
|
||||
_ICAIM_MATCH_SCORE_FLOOR = 0.76
|
||||
_ICAIM_DB_STATEMENT_TIMEOUT_MS = 5000
|
||||
_PCHOME_GROWTH_CACHE = {
|
||||
"expires_at": 0.0,
|
||||
"payload": None,
|
||||
}
|
||||
_PCHOME_GROWTH_TTL_SECONDS = 120
|
||||
|
||||
# 服務實例
|
||||
ollama_service = OllamaService()
|
||||
@@ -1596,6 +1601,59 @@ def _create_icaim_dashboard_engine(database_path):
|
||||
return create_engine(database_path, **engine_kwargs)
|
||||
|
||||
|
||||
def _get_cached_pchome_growth_payload():
|
||||
now_ts = time.time()
|
||||
cached_payload = _PCHOME_GROWTH_CACHE.get("payload")
|
||||
if cached_payload is not None and now_ts < float(_PCHOME_GROWTH_CACHE.get("expires_at") or 0):
|
||||
payload = json.loads(json.dumps(cached_payload, ensure_ascii=False, default=str))
|
||||
payload["cache_state"] = "fresh"
|
||||
return payload
|
||||
return None
|
||||
|
||||
|
||||
def _set_pchome_growth_cache(payload):
|
||||
cached = json.loads(json.dumps(payload, ensure_ascii=False, default=str))
|
||||
cached["cache_state"] = "fresh"
|
||||
_PCHOME_GROWTH_CACHE.update({
|
||||
"expires_at": time.time() + _PCHOME_GROWTH_TTL_SECONDS,
|
||||
"payload": cached,
|
||||
})
|
||||
|
||||
|
||||
@ai_bp.route('/api/ai/pchome-growth/opportunities')
|
||||
@login_required
|
||||
def api_pchome_growth_opportunities():
|
||||
"""PChome 業績成長自動化作戰清單,只讀、不呼叫 LLM、不寫 DB。"""
|
||||
try:
|
||||
from config import DATABASE_PATH
|
||||
from services.pchome_revenue_growth_service import build_pchome_growth_opportunities
|
||||
|
||||
force_refresh = str(request.args.get('refresh') or '').strip().lower() in {'1', 'true', 'yes'}
|
||||
limit = request.args.get('limit', 20, type=int)
|
||||
limit = max(5, min(limit, 50))
|
||||
|
||||
if not force_refresh:
|
||||
cached_payload = _get_cached_pchome_growth_payload()
|
||||
if cached_payload:
|
||||
return jsonify(cached_payload)
|
||||
|
||||
engine = _create_icaim_dashboard_engine(DATABASE_PATH)
|
||||
try:
|
||||
payload = build_pchome_growth_opportunities(engine, limit=limit)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
payload["cache_state"] = "fresh"
|
||||
_set_pchome_growth_cache(payload)
|
||||
return jsonify(payload)
|
||||
except Exception as exc:
|
||||
logger.error("[PChomeGrowth] 作戰清單讀取失敗: %s", exc, exc_info=True)
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "PChome 成長作戰清單暫時無法讀取,請稍後再試。",
|
||||
}), 500
|
||||
|
||||
|
||||
@ai_bp.route('/api/ai/icaim/dashboard')
|
||||
@login_required
|
||||
def api_icaim_dashboard():
|
||||
|
||||
@@ -14,6 +14,7 @@ AI 建議挑品 Agent
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
@@ -107,6 +108,16 @@ def _identity_match_condition(conn, alias: str = "cp") -> str:
|
||||
return f"AND COALESCE({alias}.tags, '') LIKE '%identity_v2%'"
|
||||
|
||||
|
||||
def _sales_join_by_momo_sku_enabled() -> bool:
|
||||
"""舊 MOMO SKU 直連銷售表的路徑預設關閉,避免把 PChome 業績 ID 誤當 MOMO SKU。"""
|
||||
return os.getenv("PCHOME_SALES_JOIN_BY_MOMO_SKU_ENABLED", "false").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
def _fetch_candidates_without_sales(conn, limit: int) -> List[Dict[str, Any]]:
|
||||
"""DB-portable fallback query used when sales-aware PostgreSQL SQL is unavailable."""
|
||||
from sqlalchemy import text
|
||||
@@ -172,7 +183,7 @@ def _fetch_candidates(conn, limit: int) -> List[Dict[str, Any]]:
|
||||
sales_join = ""
|
||||
sales_select = "0 AS sales_7d, 0 AS sales_prev_7d, 0 AS qty_7d, 0 AS profit_7d, 0 AS cost_7d"
|
||||
sales_cols = {}
|
||||
if _has_daily_sales_snapshot(conn):
|
||||
if _sales_join_by_momo_sku_enabled() and _has_daily_sales_snapshot(conn):
|
||||
try:
|
||||
sales_cols = _daily_sales_columns(conn)
|
||||
except Exception as exc:
|
||||
|
||||
468
services/pchome_revenue_growth_service.py
Normal file
468
services/pchome_revenue_growth_service.py
Normal file
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PChome 業績成長自動化作戰系統的只讀作戰清單。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import bindparam, inspect, text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_DISPLAY_NAME = "PChome 業績成長自動化作戰系統"
|
||||
PRIMARY_SALES_SOURCE = "PChome 後台業績"
|
||||
ACTIVE_EXTERNAL_SOURCES = ("MOMO 外部價格參考",)
|
||||
PAUSED_EXTERNAL_SOURCES = ("蝦皮", "酷澎")
|
||||
|
||||
|
||||
def _to_float(value: Any, default: float = 0.0) -> float:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _quote_identifier(identifier: str) -> str:
|
||||
return '"' + identifier.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def _first_available(columns: set[str], candidates: list[str]) -> str | None:
|
||||
return next((col for col in candidates if col in columns), None)
|
||||
|
||||
|
||||
def _load_json_tags(value: Any) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _table_exists(engine, table_name: str) -> bool:
|
||||
try:
|
||||
return inspect(engine).has_table(table_name)
|
||||
except Exception:
|
||||
logger.warning("[PChomeGrowth] table probe failed: %s", table_name, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _daily_sales_columns(conn) -> dict[str, str | None]:
|
||||
if conn.dialect.name == "postgresql":
|
||||
rows = conn.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'daily_sales_snapshot'
|
||||
""")).fetchall()
|
||||
columns = {row[0] for row in rows}
|
||||
elif conn.dialect.name == "sqlite":
|
||||
rows = conn.execute(text("PRAGMA table_info(daily_sales_snapshot)")).fetchall()
|
||||
columns = {row[1] for row in rows}
|
||||
else:
|
||||
result = conn.execute(text("SELECT * FROM daily_sales_snapshot LIMIT 0"))
|
||||
columns = set(result.keys())
|
||||
|
||||
return {
|
||||
"sku": _first_available(columns, ["商品ID", "Product ID", "ID", "Item Code"]),
|
||||
"name": _first_available(columns, ["商品名稱", "商品名", "品名", "Product Name", "Name"]),
|
||||
"date": _first_available(columns, ["snapshot_date", "日期", "訂單日期", "交易日期", "Date"]),
|
||||
"revenue": _first_available(columns, ["總業績", "銷售金額", "業績", "金額", "Amount", "Sales", "Total"]),
|
||||
"qty": _first_available(columns, ["數量", "銷售數量", "銷量", "Qty", "Quantity"]),
|
||||
"category": _first_available(columns, ["商品館", "館別", "分類", "Category"]),
|
||||
"vendor": _first_available(columns, ["廠商名稱", "供應商", "Vendor"]),
|
||||
}
|
||||
|
||||
|
||||
def _as_text_expr(identifier_or_expr: str, dialect_name: str, *, raw: bool = False) -> str:
|
||||
expr = identifier_or_expr if raw else _quote_identifier(identifier_or_expr)
|
||||
if dialect_name == "postgresql":
|
||||
return f"{expr}::text"
|
||||
return f"CAST({expr} AS TEXT)"
|
||||
|
||||
|
||||
def _numeric_expr(identifier: str, dialect_name: str) -> str:
|
||||
quoted = _quote_identifier(identifier)
|
||||
if dialect_name == "postgresql":
|
||||
return f"COALESCE(NULLIF(regexp_replace({quoted}::text, '[^0-9.-]', '', 'g'), '')::numeric, 0)"
|
||||
return f"COALESCE(CAST(REPLACE(CAST({quoted} AS TEXT), ',', '') AS REAL), 0)"
|
||||
|
||||
|
||||
def _fetch_sales_rows(conn, limit: int) -> tuple[list[dict[str, Any]], str | None]:
|
||||
cols = _daily_sales_columns(conn)
|
||||
missing = [key for key in ["sku", "name", "date", "revenue"] if not cols.get(key)]
|
||||
if missing:
|
||||
raise RuntimeError("PChome 業績檔缺少必要欄位:" + "、".join(missing))
|
||||
|
||||
dialect = conn.dialect.name
|
||||
sku_col = _quote_identifier(cols["sku"])
|
||||
name_col = _quote_identifier(cols["name"])
|
||||
date_col = _quote_identifier(cols["date"])
|
||||
revenue_expr = _numeric_expr(cols["revenue"], dialect)
|
||||
qty_expr = _numeric_expr(cols["qty"], dialect) if cols.get("qty") else "0"
|
||||
category_text = _as_text_expr(cols["category"], dialect) if cols.get("category") else "NULL"
|
||||
vendor_text = _as_text_expr(cols["vendor"], dialect) if cols.get("vendor") else "NULL"
|
||||
sku_text = _as_text_expr(cols["sku"], dialect)
|
||||
name_text = _as_text_expr(cols["name"], dialect)
|
||||
candidate_limit = max(limit * 4, 80)
|
||||
|
||||
if dialect == "postgresql":
|
||||
sale_date_expr = f"NULLIF({date_col}::text, '')::date"
|
||||
curr_window = "lw.latest_date - INTERVAL '6 days'"
|
||||
prev_window_start = "lw.latest_date - INTERVAL '13 days'"
|
||||
prev_window_end = "lw.latest_date - INTERVAL '6 days'"
|
||||
else:
|
||||
sale_date_expr = f"date({date_col})"
|
||||
curr_window = "date(lw.latest_date, '-6 days')"
|
||||
prev_window_start = "date(lw.latest_date, '-13 days')"
|
||||
prev_window_end = "date(lw.latest_date, '-6 days')"
|
||||
|
||||
order_metric = "CASE WHEN sales_7d >= sales_prev_7d THEN sales_7d ELSE sales_prev_7d END"
|
||||
rows = conn.execute(text(f"""
|
||||
WITH sales_rows AS (
|
||||
SELECT
|
||||
NULLIF(TRIM({sku_text}), '') AS pchome_product_id,
|
||||
NULLIF(TRIM({name_text}), '') AS product_name,
|
||||
NULLIF(TRIM({_as_text_expr(category_text, dialect, raw=True)}), '') AS category,
|
||||
NULLIF(TRIM({_as_text_expr(vendor_text, dialect, raw=True)}), '') AS vendor,
|
||||
{sale_date_expr} AS sale_date,
|
||||
{revenue_expr} AS revenue,
|
||||
{qty_expr} AS qty
|
||||
FROM daily_sales_snapshot
|
||||
WHERE {sku_col} IS NOT NULL
|
||||
),
|
||||
latest_window AS (
|
||||
SELECT MAX(sale_date) AS latest_date
|
||||
FROM sales_rows
|
||||
WHERE sale_date IS NOT NULL
|
||||
),
|
||||
sales AS (
|
||||
SELECT
|
||||
sr.pchome_product_id,
|
||||
MAX(sr.product_name) AS product_name,
|
||||
MAX(sr.category) AS category,
|
||||
MAX(sr.vendor) AS vendor,
|
||||
SUM(CASE WHEN sr.sale_date >= {curr_window}
|
||||
THEN sr.revenue ELSE 0 END) AS sales_7d,
|
||||
SUM(CASE WHEN sr.sale_date >= {prev_window_start}
|
||||
AND sr.sale_date < {prev_window_end}
|
||||
THEN sr.revenue ELSE 0 END) AS sales_prev_7d,
|
||||
SUM(CASE WHEN sr.sale_date >= {curr_window}
|
||||
THEN sr.qty ELSE 0 END) AS qty_7d,
|
||||
MAX(sr.sale_date) AS last_sale_date,
|
||||
MAX(lw.latest_date) AS latest_sales_date
|
||||
FROM sales_rows sr
|
||||
CROSS JOIN latest_window lw
|
||||
WHERE sr.pchome_product_id IS NOT NULL
|
||||
GROUP BY sr.pchome_product_id
|
||||
)
|
||||
SELECT *
|
||||
FROM sales
|
||||
WHERE sales_7d > 0 OR sales_prev_7d > 0
|
||||
ORDER BY {order_metric} DESC, qty_7d DESC
|
||||
LIMIT :limit
|
||||
"""), {"limit": candidate_limit}).mappings().all()
|
||||
|
||||
mapped_rows = [dict(row) for row in rows]
|
||||
latest_date = None
|
||||
for row in mapped_rows:
|
||||
value = row.get("latest_sales_date")
|
||||
if value:
|
||||
latest_date = value.isoformat() if hasattr(value, "isoformat") else str(value)
|
||||
break
|
||||
return mapped_rows, latest_date
|
||||
|
||||
|
||||
def _fetch_external_price_map(conn, pchome_product_ids: list[str]) -> dict[str, dict[str, Any]]:
|
||||
inspector = inspect(conn)
|
||||
if not all(inspector.has_table(table) for table in {"competitor_prices", "products", "price_records"}):
|
||||
return {}
|
||||
ids = [str(item).strip() for item in pchome_product_ids if str(item or "").strip()]
|
||||
if not ids:
|
||||
return {}
|
||||
|
||||
if conn.dialect.name == "postgresql":
|
||||
sql = """
|
||||
WITH valid_cp AS (
|
||||
SELECT DISTINCT ON (cp.competitor_product_id)
|
||||
cp.competitor_product_id AS pchome_product_id,
|
||||
cp.competitor_product_name AS pchome_public_name,
|
||||
cp.sku AS momo_sku,
|
||||
cp.price AS pchome_price,
|
||||
cp.match_score,
|
||||
cp.tags,
|
||||
cp.crawled_at
|
||||
FROM competitor_prices cp
|
||||
WHERE cp.source = 'pchome'
|
||||
AND cp.competitor_product_id IS NOT NULL
|
||||
AND cp.price IS NOT NULL
|
||||
AND cp.price > 0
|
||||
AND COALESCE(cp.match_score, 0) >= 0.76
|
||||
AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)
|
||||
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
|
||||
AND cp.competitor_product_id IN :ids
|
||||
ORDER BY cp.competitor_product_id, cp.crawled_at DESC NULLS LAST
|
||||
)
|
||||
SELECT vc.*, lm.momo_name, lm.momo_price, lm.momo_price_at
|
||||
FROM valid_cp vc
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
p.name AS momo_name,
|
||||
pr.price AS momo_price,
|
||||
pr.timestamp AS momo_price_at
|
||||
FROM products p
|
||||
JOIN price_records pr ON pr.product_id = p.id
|
||||
WHERE p.i_code = vc.momo_sku
|
||||
AND p.status = 'ACTIVE'
|
||||
ORDER BY pr.timestamp DESC, pr.id DESC
|
||||
LIMIT 1
|
||||
) lm ON TRUE
|
||||
"""
|
||||
else:
|
||||
sql = """
|
||||
WITH latest_cp AS (
|
||||
SELECT
|
||||
cp.competitor_product_id AS pchome_product_id,
|
||||
cp.competitor_product_name AS pchome_public_name,
|
||||
cp.sku AS momo_sku,
|
||||
cp.price AS pchome_price,
|
||||
cp.match_score,
|
||||
cp.tags,
|
||||
cp.crawled_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY cp.competitor_product_id
|
||||
ORDER BY cp.crawled_at DESC
|
||||
) AS rn
|
||||
FROM competitor_prices cp
|
||||
WHERE cp.source = 'pchome'
|
||||
AND cp.competitor_product_id IS NOT NULL
|
||||
AND cp.price IS NOT NULL
|
||||
AND cp.price > 0
|
||||
AND COALESCE(cp.match_score, 0) >= 0.76
|
||||
AND COALESCE(cp.tags, '') LIKE '%identity_v2%'
|
||||
AND cp.competitor_product_id IN :ids
|
||||
),
|
||||
latest_momo AS (
|
||||
SELECT
|
||||
p.i_code AS momo_sku,
|
||||
p.name AS momo_name,
|
||||
pr.price AS momo_price,
|
||||
pr.timestamp AS momo_price_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY p.i_code
|
||||
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'
|
||||
AND p.i_code IN (SELECT momo_sku FROM latest_cp)
|
||||
)
|
||||
SELECT cp.pchome_product_id, cp.pchome_public_name, cp.momo_sku,
|
||||
cp.pchome_price, cp.match_score, cp.tags, cp.crawled_at,
|
||||
lm.momo_name, lm.momo_price, lm.momo_price_at
|
||||
FROM latest_cp cp
|
||||
LEFT JOIN latest_momo lm ON lm.momo_sku = cp.momo_sku AND lm.rn = 1
|
||||
WHERE cp.rn = 1
|
||||
"""
|
||||
|
||||
stmt = text(sql).bindparams(bindparam("ids", expanding=True))
|
||||
rows = conn.execute(stmt, {"ids": ids}).mappings().all()
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
key = str(row.get("pchome_product_id") or "").strip()
|
||||
if key:
|
||||
result[key] = dict(row)
|
||||
return result
|
||||
|
||||
|
||||
def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] | None) -> dict[str, Any]:
|
||||
sales_7d = _to_float(sales_row.get("sales_7d"))
|
||||
sales_prev_7d = _to_float(sales_row.get("sales_prev_7d"))
|
||||
qty_7d = _to_float(sales_row.get("qty_7d"))
|
||||
sales_delta_pct = None
|
||||
if sales_prev_7d > 0:
|
||||
sales_delta_pct = (sales_7d - sales_prev_7d) / sales_prev_7d * 100
|
||||
|
||||
volume_score = min(34, max(sales_7d, sales_prev_7d) / 80000 * 34)
|
||||
qty_score = min(10, qty_7d / 25 * 10)
|
||||
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
|
||||
action_code = "map_external_product"
|
||||
action_label = "先補商品對應"
|
||||
action_message = "這項商品已有業績訊號,但還沒有可確認的 MOMO 對照商品。先補對應,後續才能判斷價格壓力。"
|
||||
reason_lines = []
|
||||
|
||||
if external_row:
|
||||
pchome_price = _to_float(external_row.get("pchome_price"))
|
||||
momo_price = _to_float(external_row.get("momo_price"))
|
||||
gap_pct = ((momo_price - pchome_price) / pchome_price * 100) if pchome_price else None
|
||||
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",
|
||||
"momo_sku": external_row.get("momo_sku"),
|
||||
"momo_name": external_row.get("momo_name"),
|
||||
"momo_price": round(momo_price, 2) if momo_price else None,
|
||||
"pchome_price": round(pchome_price, 2) if pchome_price else None,
|
||||
"gap_pct": round(gap_pct, 1) if gap_pct is not None else None,
|
||||
"match_score": round(_to_float(external_row.get("match_score")), 3),
|
||||
"tags": tags,
|
||||
"updated_at": str(external_row.get("crawled_at") or ""),
|
||||
}
|
||||
|
||||
if gap_pct is not None and gap_pct < -5:
|
||||
action_code = "review_price_or_promo"
|
||||
action_label = "檢查售價與活動"
|
||||
action_message = "MOMO 外部參考價比較低,建議檢查 PChome 售價、活動組合或曝光策略。"
|
||||
elif gap_pct is not None and gap_pct > 5:
|
||||
action_code = "amplify_price_advantage"
|
||||
action_label = "放大價格優勢"
|
||||
action_message = "PChome 目前有價格優勢,適合檢查曝光、文案與活動位置。"
|
||||
elif sales_delta_pct is not None and sales_delta_pct < -10:
|
||||
action_code = "recover_sales_momentum"
|
||||
action_label = "找回銷售動能"
|
||||
action_message = "價格差距不大,但近 7 天業績轉弱,建議檢查曝光、庫存與商品頁內容。"
|
||||
else:
|
||||
action_code = "monitor"
|
||||
action_label = "持續觀察"
|
||||
action_message = "業績與外部價格暫無明顯異常,先保留在觀察清單。"
|
||||
|
||||
if gap_pct is not None:
|
||||
if gap_pct > 0:
|
||||
reason_lines.append(f"PChome 目前比 MOMO 低約 {abs(gap_pct):.1f}%。")
|
||||
elif gap_pct < 0:
|
||||
reason_lines.append(f"MOMO 目前比 PChome 低約 {abs(gap_pct):.1f}%。")
|
||||
else:
|
||||
reason_lines.append("PChome 與 MOMO 價格幾乎相同。")
|
||||
else:
|
||||
data_quality_score -= 12
|
||||
reason_lines.append("尚未找到可確認的 MOMO 對照商品。")
|
||||
|
||||
if sales_delta_pct is None:
|
||||
reason_lines.append("前 7 天沒有可比基準,先看近 7 天表現。")
|
||||
elif sales_delta_pct < 0:
|
||||
reason_lines.append(f"近 7 天業績比前 7 天少約 {abs(sales_delta_pct):.1f}%。")
|
||||
elif sales_delta_pct > 0:
|
||||
reason_lines.append(f"近 7 天業績比前 7 天多約 {sales_delta_pct:.1f}%。")
|
||||
else:
|
||||
reason_lines.append("近 7 天業績與前 7 天大致持平。")
|
||||
|
||||
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
|
||||
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"])
|
||||
if gap < -5:
|
||||
priority_score = min(100, priority_score + min(18, abs(gap) / 20 * 18))
|
||||
elif gap > 5:
|
||||
priority_score = min(100, priority_score + min(10, gap / 25 * 10))
|
||||
|
||||
issues = []
|
||||
if not external_row:
|
||||
issues.append("需要補商品對應")
|
||||
if sales_delta_pct is None:
|
||||
issues.append("前期業績不足")
|
||||
|
||||
return {
|
||||
"pchome_product_id": str(sales_row.get("pchome_product_id") or ""),
|
||||
"product_name": sales_row.get("product_name") or "未命名商品",
|
||||
"category": sales_row.get("category") or "",
|
||||
"vendor": sales_row.get("vendor") or "",
|
||||
"sales_7d": round(sales_7d, 2),
|
||||
"sales_prev_7d": round(sales_prev_7d, 2),
|
||||
"sales_delta_pct": round(sales_delta_pct, 1) if sales_delta_pct is not None else None,
|
||||
"qty_7d": round(qty_7d, 2),
|
||||
"last_sale_date": str(sales_row.get("last_sale_date") or ""),
|
||||
"external_price": external_payload,
|
||||
"priority_score": round(priority_score, 1),
|
||||
"recommended_action": {
|
||||
"code": action_code,
|
||||
"label": action_label,
|
||||
"message": action_message,
|
||||
},
|
||||
"reason_lines": reason_lines[:4],
|
||||
"data_quality": {
|
||||
"label": "資料可直接判斷" if external_row else "需要補資料",
|
||||
"score": round(max(0, min(100, data_quality_score)), 1),
|
||||
"issues": issues,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]:
|
||||
"""讀取 PChome 業績與已驗證外部價格,產生營運用作戰清單。"""
|
||||
limit = max(5, min(int(limit or 20), 50))
|
||||
generated_at = datetime.now().isoformat(timespec="seconds")
|
||||
source_scope = {
|
||||
"primary_goal": "提升 PChome 業績",
|
||||
"primary_sales_source": PRIMARY_SALES_SOURCE,
|
||||
"active_external_sources": list(ACTIVE_EXTERNAL_SOURCES),
|
||||
"paused_external_sources": list(PAUSED_EXTERNAL_SOURCES),
|
||||
"plain_note": "蝦皮與酷澎先暫停,不進作戰清單,也不發告警。",
|
||||
}
|
||||
|
||||
if not _table_exists(engine, "daily_sales_snapshot"):
|
||||
return {
|
||||
"success": True,
|
||||
"system_name": SYSTEM_DISPLAY_NAME,
|
||||
"generated_at": generated_at,
|
||||
"source_scope": source_scope,
|
||||
"stats": {
|
||||
"latest_sales_date": None,
|
||||
"candidate_count": 0,
|
||||
"mapped_count": 0,
|
||||
"mapping_rate": 0,
|
||||
"needs_mapping_count": 0,
|
||||
},
|
||||
"opportunities": [],
|
||||
"message": "目前還沒有 PChome 業績資料,請先完成業績匯入。",
|
||||
}
|
||||
|
||||
with engine.connect() as conn:
|
||||
sales_rows, latest_sales_date = _fetch_sales_rows(conn, limit=limit)
|
||||
sales_ids = [str(row.get("pchome_product_id") or "") for row in sales_rows]
|
||||
external_map = _fetch_external_price_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)))
|
||||
|
||||
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
|
||||
mapping_rate = round(mapped_count / max(len(opportunities), 1) * 100, 1)
|
||||
action_counts: dict[str, int] = {}
|
||||
for item in opportunities:
|
||||
label = item["recommended_action"]["label"]
|
||||
action_counts[label] = action_counts.get(label, 0) + 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"system_name": SYSTEM_DISPLAY_NAME,
|
||||
"generated_at": generated_at,
|
||||
"source_scope": source_scope,
|
||||
"stats": {
|
||||
"latest_sales_date": latest_sales_date,
|
||||
"candidate_count": len(opportunities),
|
||||
"mapped_count": mapped_count,
|
||||
"mapping_rate": mapping_rate,
|
||||
"needs_mapping_count": needs_mapping_count,
|
||||
"total_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2),
|
||||
"action_counts": action_counts,
|
||||
},
|
||||
"opportunities": opportunities,
|
||||
"message": "已整理今日 PChome 業績成長作戰清單。",
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'ewoooc_base.html' %}
|
||||
{% block title %}AI 競情中樞 · EwoooC{% endblock %}
|
||||
{% block title %}PChome 業績成長自動化作戰系統 · EwoooC{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
@@ -233,6 +233,97 @@
|
||||
background: rgba(250, 247, 240, 0.54);
|
||||
}
|
||||
|
||||
.growth-ops-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.7fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.growth-metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.growth-metric {
|
||||
border: 1px solid var(--momo-border-subtle);
|
||||
border-radius: 8px;
|
||||
background: rgba(250, 247, 240, 0.62);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.growth-metric strong {
|
||||
display: block;
|
||||
color: var(--momo-text-strong);
|
||||
font-family: var(--momo-font-mono);
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.growth-metric span {
|
||||
color: var(--momo-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.growth-source-note {
|
||||
margin-top: 10px;
|
||||
color: var(--momo-text-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.growth-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 292px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.growth-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
border: 1px solid rgba(42, 37, 32, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.growth-item-title {
|
||||
margin: 0;
|
||||
color: var(--momo-text-strong);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.growth-item-meta,
|
||||
.growth-item-reason {
|
||||
margin: 4px 0 0;
|
||||
color: var(--momo-text-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.growth-action-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 92px;
|
||||
border: 1px solid rgba(42, 37, 32, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(242, 178, 90, 0.2);
|
||||
color: var(--momo-text-strong);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 900;
|
||||
padding: 5px 8px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.ai-intel-hero {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -332,8 +423,8 @@
|
||||
#competitorTable td:nth-child(2)::before { content: "MOMO"; }
|
||||
#competitorTable td:nth-child(3)::before { content: "PChome"; }
|
||||
#competitorTable td:nth-child(4)::before { content: "價差"; }
|
||||
#competitorTable td:nth-child(5)::before { content: "標籤"; }
|
||||
#competitorTable td:nth-child(6)::before { content: "分數"; }
|
||||
#competitorTable td:nth-child(5)::before { content: "狀態"; }
|
||||
#competitorTable td:nth-child(6)::before { content: "可信度"; }
|
||||
#competitorTable td:nth-child(7)::before { content: "更新"; }
|
||||
|
||||
.ai-panel .card-footer {
|
||||
@@ -341,6 +432,15 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.growth-ops-grid,
|
||||
.growth-metric-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.growth-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -353,23 +453,23 @@
|
||||
<div>
|
||||
<h1 class="ai-intel-title">
|
||||
<i class="fas fa-brain"></i>
|
||||
AI 競情中樞
|
||||
<span class="ai-intel-badge">ICAIM</span>
|
||||
PChome 業績成長自動化作戰系統
|
||||
<span class="ai-intel-badge">AI 競情中樞</span>
|
||||
</h1>
|
||||
<p class="ai-intel-subtitle">Hermes 3 分析師 × NemoTron 派發器,使用資料庫內的競品價格、AI 決策與 PChome 比對記錄進行監控。</p>
|
||||
<p class="ai-intel-subtitle">把 PChome 後台業績、MOMO 外部價格參考與商品對應狀態整理成每天可處理的作戰清單。</p>
|
||||
</div>
|
||||
<div class="ai-intel-actions">
|
||||
<span id="lastUpdateBadge" class="ai-status-badge">
|
||||
<i class="fas fa-sync me-1"></i>載入中...
|
||||
</span>
|
||||
<button class="btn btn-outline-danger btn-sm ai-action-btn" id="btnTrigger" onclick="triggerAnalysis()">
|
||||
<i class="fas fa-bolt me-1"></i>立即分析
|
||||
<i class="fas fa-bolt me-1"></i>整理建議
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm ai-action-btn" id="btnPickList" onclick="generatePickList()">
|
||||
<i class="fas fa-wand-magic-sparkles me-1"></i>產生挑品清單
|
||||
<i class="fas fa-wand-magic-sparkles me-1"></i>產生作戰商品
|
||||
</button>
|
||||
<button class="btn btn-outline-warning btn-sm ai-action-btn" id="btnBackfill" onclick="backfillPchomeMatches()">
|
||||
<i class="fas fa-magnifying-glass-chart me-1"></i>補抓未搜尋
|
||||
<i class="fas fa-magnifying-glass-chart me-1"></i>補商品對應
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadDashboard()">
|
||||
<i class="fas fa-redo me-1"></i>重新整理
|
||||
@@ -377,6 +477,47 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── PChome 成長作戰清單 ── -->
|
||||
<section class="card shadow-sm ai-panel" id="growthOpsPanel">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
|
||||
<span class="ai-panel-title">
|
||||
<i class="fas fa-compass"></i>PChome 成長作戰
|
||||
<small class="text-muted fw-normal ms-2">先處理最可能影響業績的商品</small>
|
||||
</span>
|
||||
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadGrowthOps(true)">
|
||||
<i class="fas fa-redo me-1"></i>更新清單
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="growth-ops-grid">
|
||||
<div>
|
||||
<div class="growth-metric-row">
|
||||
<div class="growth-metric">
|
||||
<strong id="growthCandidateCount">—</strong>
|
||||
<span>作戰商品</span>
|
||||
</div>
|
||||
<div class="growth-metric">
|
||||
<strong id="growthMappedCount">—</strong>
|
||||
<span>可直接比價</span>
|
||||
</div>
|
||||
<div class="growth-metric">
|
||||
<strong id="growthNeedsMapping">—</strong>
|
||||
<span>待補對應</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="growth-source-note" id="growthSourceNote">來源整理中...</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="growth-list" id="growthOpsList">
|
||||
<div class="text-center py-4 text-muted">
|
||||
<div class="spinner-border spinner-border-sm me-2"></div>整理作戰清單中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── KPI 卡片 ── -->
|
||||
<div class="row g-3 mb-4" id="kpiRow">
|
||||
<div class="col-6 col-md-3">
|
||||
@@ -392,7 +533,7 @@
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="fs-2 fw-bold text-success" id="kpiCompetitors">—</div>
|
||||
<div class="small text-muted mt-1">
|
||||
<i class="fas fa-store me-1"></i>有效競品比價
|
||||
<i class="fas fa-store me-1"></i>可直接比價
|
||||
<span id="kpiMatchRate" class="text-muted" style="font-size:0.7rem"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -404,8 +545,7 @@
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="fs-2 fw-bold text-danger" id="kpiHighRisk">—</div>
|
||||
<div class="small text-muted mt-1">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>高風險商品
|
||||
<span class="text-muted" style="font-size:0.7rem">(貴>15%)</span>
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>需檢查價格
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,7 +554,7 @@
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<div class="fs-2 fw-bold text-info" id="kpiAiRecs">—</div>
|
||||
<div class="small text-muted mt-1"><i class="fas fa-robot me-1"></i>AI 挑品/決策記錄</div>
|
||||
<div class="small text-muted mt-1"><i class="fas fa-robot me-1"></i>作戰建議紀錄</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,12 +563,12 @@
|
||||
<!-- ── 主體分兩欄(競品比價 + AI 決策) ── -->
|
||||
<div class="row g-3">
|
||||
|
||||
<!-- ── 左:PChome 競品比價 ── -->
|
||||
<!-- ── 左:外部價格參考 ── -->
|
||||
<div class="col-xl-7">
|
||||
<div class="card shadow-sm h-100 ai-panel">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
|
||||
<span class="ai-panel-title">
|
||||
<i class="fas fa-balance-scale text-warning me-2"></i>PChome 競品比價監控
|
||||
<i class="fas fa-balance-scale text-warning me-2"></i>MOMO 外部價格參考
|
||||
</span>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<select class="form-select form-select-sm" id="riskFilter" onchange="filterTable()" style="width:100px">
|
||||
@@ -455,8 +595,8 @@
|
||||
<th class="text-end" style="min-width:75px">MOMO</th>
|
||||
<th class="text-end" style="min-width:75px">PChome</th>
|
||||
<th class="text-end" style="min-width:70px">價差</th>
|
||||
<th style="min-width:90px">競品標籤</th>
|
||||
<th class="text-center" style="min-width:55px">分數</th>
|
||||
<th style="min-width:90px">比價狀態</th>
|
||||
<th class="text-center" style="min-width:55px">可信度</th>
|
||||
<th class="text-muted" style="min-width:80px">更新</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -469,18 +609,18 @@
|
||||
</div>
|
||||
<div class="card-footer bg-white py-2 d-flex justify-content-between small text-muted">
|
||||
<span id="compCount">—</span>
|
||||
<span>僅顯示已通過身份比對的競品</span>
|
||||
<span>僅顯示已確認同款的商品</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 右:AI 決策日誌 ── -->
|
||||
<!-- ── 右:作戰建議紀錄 ── -->
|
||||
<div class="col-xl-5">
|
||||
<div class="card shadow-sm h-100 ai-panel">
|
||||
<div class="card-header py-2 bg-white border-bottom">
|
||||
<span class="ai-panel-title">
|
||||
<i class="fas fa-robot text-danger me-2"></i>AI 決策日誌
|
||||
<small class="text-muted fw-normal ms-2">挑品 Agent × Hermes × NemoTron</small>
|
||||
<i class="fas fa-robot text-danger me-2"></i>作戰建議紀錄
|
||||
<small class="text-muted fw-normal ms-2">挑品、比價與人工覆核</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-0 ai-recs-scroll">
|
||||
@@ -492,7 +632,7 @@
|
||||
</div>
|
||||
<div class="card-footer bg-white py-2 d-flex justify-content-between small text-muted">
|
||||
<span id="aiRecsCount">—</span>
|
||||
<span>可手動產生挑品清單</span>
|
||||
<span>可手動產生作戰商品</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -518,7 +658,10 @@
|
||||
let allCompetitors = [];
|
||||
|
||||
// ── 頁面載入 ────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', loadDashboard);
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadDashboard();
|
||||
loadGrowthOps();
|
||||
});
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
@@ -558,6 +701,86 @@ function renderKPIs(stats) {
|
||||
: 'card border-0 shadow-sm h-100';
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
const num = Number(value || 0);
|
||||
return 'NT$ ' + Math.round(num).toLocaleString();
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
async function loadGrowthOps(forceRefresh = false) {
|
||||
const list = document.getElementById('growthOpsList');
|
||||
if (forceRefresh) {
|
||||
list.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<div class="spinner-border spinner-border-sm me-2"></div>更新作戰清單中...
|
||||
</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = '/api/ai/pchome-growth/opportunities?limit=8' + (forceRefresh ? '&refresh=1' : '');
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || '讀取失敗');
|
||||
|
||||
const stats = data.stats || {};
|
||||
document.getElementById('growthCandidateCount').textContent = (stats.candidate_count || 0).toLocaleString();
|
||||
document.getElementById('growthMappedCount').textContent = (stats.mapped_count || 0).toLocaleString();
|
||||
document.getElementById('growthNeedsMapping').textContent = (stats.needs_mapping_count || 0).toLocaleString();
|
||||
|
||||
const scope = data.source_scope || {};
|
||||
const active = (scope.active_external_sources || []).join('、') || '尚未接入';
|
||||
const paused = (scope.paused_external_sources || []).join('、') || '無';
|
||||
document.getElementById('growthSourceNote').textContent =
|
||||
`業績來源:${scope.primary_sales_source || 'PChome 後台業績'}。外部價格先看:${active}。暫停來源:${paused}。`;
|
||||
|
||||
renderGrowthOps(data.opportunities || []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
list.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-circle-exclamation d-block mb-2"></i>
|
||||
PChome 成長作戰清單暫時無法讀取,請稍後再試。
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderGrowthOps(rows) {
|
||||
const list = document.getElementById('growthOpsList');
|
||||
if (!rows.length) {
|
||||
list.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-circle-info d-block mb-2"></i>
|
||||
目前沒有足夠資料,請先確認 PChome 業績檔已匯入。
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = rows.map((row) => {
|
||||
const action = row.recommended_action || {};
|
||||
const reason = (row.reason_lines || []).slice(0, 2).join(' ');
|
||||
const price = row.external_price;
|
||||
const priceText = price && price.gap_pct !== null && price.gap_pct !== undefined
|
||||
? `外部價差 ${price.gap_pct > 0 ? '+' : ''}${price.gap_pct}%`
|
||||
: '尚未可比價';
|
||||
return `<article class="growth-item">
|
||||
<div>
|
||||
<h3 class="growth-item-title">${escapeHtml(row.product_name)}</h3>
|
||||
<p class="growth-item-meta">
|
||||
${formatMoney(row.sales_7d)} · 近 7 天業績 · ${escapeHtml(priceText)}
|
||||
</p>
|
||||
<p class="growth-item-reason">${escapeHtml(reason)}</p>
|
||||
</div>
|
||||
<span class="growth-action-pill">${escapeHtml(action.label || '待判斷')}</span>
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── 競品比價表格(熱力圖底色)──────────────────────
|
||||
function renderCompetitorTable(rows) {
|
||||
const tbody = document.getElementById('competitorTbody');
|
||||
@@ -570,7 +793,7 @@ function renderCompetitorTable(rows) {
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
// 熱力圖底色(行層級)
|
||||
// 行底色
|
||||
let rowBg = '';
|
||||
if (r.gap_pct > 20) rowBg = 'background:#fee2e2'; // 淺紅 — 嚴重貴
|
||||
else if (r.gap_pct > 10) rowBg = 'background:#fef9c3'; // 淺黃 — 有風險
|
||||
@@ -595,7 +818,7 @@ function renderCompetitorTable(rows) {
|
||||
'match_type_exact':['bg-success text-white', '同款確認'],
|
||||
'price_alert_exact':['bg-danger text-white', '價差告警'],
|
||||
'evidence_brand': ['bg-light text-dark', '品牌一致'],
|
||||
'evidence_identity':['bg-light text-dark', '身份證據'],
|
||||
'evidence_identity':['bg-light text-dark', '同款證據'],
|
||||
'match_shared_model_token':['bg-light text-dark','型號一致'],
|
||||
'match_product_line':['bg-light text-dark', '品線一致'],
|
||||
'alert_tier_price':['bg-warning text-dark', '優先追蹤'],
|
||||
@@ -640,23 +863,22 @@ function filterTable() {
|
||||
renderCompetitorTable(filtered);
|
||||
}
|
||||
|
||||
// ── AI 決策日誌(含推理足跡)───────────────────────
|
||||
// ── 作戰建議紀錄 ────────────────────────
|
||||
function renderAiRecs(recs) {
|
||||
const container = document.getElementById('aiRecsList');
|
||||
document.getElementById('aiRecsCount').textContent =
|
||||
recs.length ? `共 ${recs.length} 筆決策記錄` : '尚無決策記錄';
|
||||
recs.length ? `共 ${recs.length} 筆建議` : '尚無建議';
|
||||
|
||||
if (!recs.length) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-brain fa-3x text-muted mb-3 d-block"></i>
|
||||
<p class="text-muted mb-1">AI 決策日誌尚為空</p>
|
||||
<p class="text-muted mb-1">目前還沒有作戰建議</p>
|
||||
<p class="small text-muted mb-3">
|
||||
排程每 6 小時執行一次 Hermes 分析 + NemoTron 派發<br>
|
||||
或點擊「立即分析」手動觸發
|
||||
系統會定期整理,也可以手動更新。
|
||||
</p>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="triggerAnalysis()">
|
||||
<i class="fas fa-bolt me-1"></i>立即觸發分析
|
||||
<i class="fas fa-bolt me-1"></i>整理建議
|
||||
</button>
|
||||
</div>`;
|
||||
return;
|
||||
@@ -676,14 +898,6 @@ function renderAiRecs(recs) {
|
||||
const confColor = confPct >= 80 ? 'bg-success' : confPct >= 60 ? 'bg-warning' : 'bg-danger';
|
||||
const gapSign = r.gap_pct > 0 ? '+' : '';
|
||||
|
||||
// 推理足跡
|
||||
const hermesInfo = r.hermes_duration
|
||||
? `${r.analyst} ${r.hermes_duration}s${r.hermes_tokens ? ' / ' + r.hermes_tokens + 'tok' : ''}`
|
||||
: r.analyst;
|
||||
const nimInfo = r.nim_tokens
|
||||
? `${r.dispatcher} ${r.nim_tokens}tok`
|
||||
: r.dispatcher;
|
||||
|
||||
return `<div class="border rounded mb-2 p-2" style="font-size:0.83rem">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="fw-bold text-truncate me-2" style="max-width:200px" title="${r.name}">${r.name}</span>
|
||||
@@ -704,9 +918,8 @@ function renderAiRecs(recs) {
|
||||
</div>
|
||||
<small class="text-muted">${confPct}%</small>
|
||||
</div>
|
||||
<small class="text-muted" title="推理足跡">
|
||||
<i class="fas fa-microchip me-1" style="font-size:0.68rem"></i>${hermesInfo} → ${nimInfo}
|
||||
· ${r.created_at}
|
||||
<small class="text-muted">
|
||||
${r.created_at}
|
||||
</small>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -720,7 +933,7 @@ async function generatePickList() {
|
||||
const msg = document.getElementById('triggerToastMsg');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>挑品中...';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai/product-picks/generate', {
|
||||
@@ -739,12 +952,12 @@ async function generatePickList() {
|
||||
|
||||
if (data.success) loadDashboard();
|
||||
} catch (e) {
|
||||
msg.innerHTML = '<i class="fas fa-times-circle me-1"></i>挑品失敗:' + e.message;
|
||||
msg.innerHTML = '<i class="fas fa-times-circle me-1"></i>整理失敗:' + e.message;
|
||||
toast.className = 'toast align-items-center text-white border-0 bg-danger';
|
||||
new bootstrap.Toast(toast, { delay: 4000 }).show();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-wand-magic-sparkles me-1"></i>產生挑品清單';
|
||||
btn.innerHTML = '<i class="fas fa-wand-magic-sparkles me-1"></i>產生作戰商品';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,7 +968,7 @@ async function backfillPchomeMatches() {
|
||||
const msg = document.getElementById('triggerToastMsg');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>補抓中...';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai/pchome-match/backfill', {
|
||||
@@ -767,19 +980,19 @@ async function backfillPchomeMatches() {
|
||||
|
||||
msg.innerHTML = data.success
|
||||
? `<i class="fas fa-check-circle me-1"></i>${data.message}`
|
||||
: `<i class="fas fa-times-circle me-1"></i>${data.error || '補抓啟動失敗'}`;
|
||||
: `<i class="fas fa-times-circle me-1"></i>${data.error || '商品對應啟動失敗'}`;
|
||||
toast.className = 'toast align-items-center text-white border-0 ' +
|
||||
(data.success ? 'bg-success' : 'bg-danger');
|
||||
new bootstrap.Toast(toast, { delay: 6000 }).show();
|
||||
|
||||
if (data.success) setTimeout(loadDashboard, 90000);
|
||||
} catch (e) {
|
||||
msg.innerHTML = '<i class="fas fa-times-circle me-1"></i>補抓失敗:' + e.message;
|
||||
msg.innerHTML = '<i class="fas fa-times-circle me-1"></i>商品對應失敗:' + e.message;
|
||||
toast.className = 'toast align-items-center text-white border-0 bg-danger';
|
||||
new bootstrap.Toast(toast, { delay: 4000 }).show();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-magnifying-glass-chart me-1"></i>補抓未搜尋';
|
||||
btn.innerHTML = '<i class="fas fa-magnifying-glass-chart me-1"></i>補商品對應';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +1003,7 @@ async function triggerAnalysis() {
|
||||
const msg = document.getElementById('triggerToastMsg');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>啟動中...';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai/icaim/trigger', { method: 'POST' });
|
||||
@@ -809,12 +1022,12 @@ async function triggerAnalysis() {
|
||||
setTimeout(loadDashboard, 60000);
|
||||
}
|
||||
} catch (e) {
|
||||
msg.innerHTML = '<i class="fas fa-times-circle me-1"></i>觸發失敗:' + e.message;
|
||||
msg.innerHTML = '<i class="fas fa-times-circle me-1"></i>整理失敗:' + e.message;
|
||||
toast.className = 'toast align-items-center text-white border-0 bg-danger';
|
||||
new bootstrap.Toast(toast, { delay: 4000 }).show();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-bolt me-1"></i>立即分析';
|
||||
btn.innerHTML = '<i class="fas fa-bolt me-1"></i>整理建議';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -379,13 +379,15 @@ def test_ai_intelligence_uses_v2_shell_and_real_runtime_apis():
|
||||
assert "{% block ewooo_content %}" in template
|
||||
assert "ai-intel-hero" in template
|
||||
assert "ai-status-badge" in template
|
||||
assert "PChome 競品比價監控" in template
|
||||
assert "AI 決策日誌" in template
|
||||
assert "PChome 業績成長自動化作戰系統" in template
|
||||
assert "MOMO 外部價格參考" in template
|
||||
assert "/api/ai/pchome-growth/opportunities" in template
|
||||
assert "作戰建議紀錄" in template
|
||||
assert "fetch('/api/ai/icaim/dashboard')" in template
|
||||
assert "fetch('/api/ai/product-picks/generate'" in template
|
||||
assert "fetch('/api/ai/pchome-match/backfill'" in template
|
||||
assert "JSON.stringify({ limit: 50 })" in template
|
||||
assert "僅顯示已通過身份比對的競品" in template
|
||||
assert "僅顯示已確認同款的商品" in template
|
||||
assert "tagMap[t] || ['bg-light text-dark', t]" not in template
|
||||
assert "\"match_type_exact\":[" not in template
|
||||
assert "'match_type_exact':[" in template
|
||||
@@ -684,8 +686,8 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action():
|
||||
assert "每日 10:30:pchome_match_backfill" in run_scheduler_source
|
||||
assert '"run_pchome_match_backfill_task"' in agent_actions_source
|
||||
|
||||
assert "產生挑品清單" in template
|
||||
assert "補抓未搜尋" in template
|
||||
assert "產生作戰商品" in template
|
||||
assert "補商品對應" in template
|
||||
assert "generatePickList" in template
|
||||
assert "backfillPchomeMatches" in template
|
||||
assert "/api/ai/product-picks/generate" in template
|
||||
|
||||
87
tests/test_pchome_revenue_growth_service.py
Normal file
87
tests/test_pchome_revenue_growth_service.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
|
||||
def _seed_growth_tables(engine):
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text(
|
||||
'CREATE TABLE daily_sales_snapshot ('
|
||||
'"商品ID" TEXT, "商品名稱" TEXT, snapshot_date TEXT, "總業績" REAL, "數量" REAL, "商品館" TEXT)'
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE TABLE competitor_prices ("
|
||||
"sku TEXT, source TEXT, competitor_product_id TEXT, competitor_product_name TEXT, "
|
||||
"price REAL, match_score REAL, tags TEXT, crawled_at TEXT, expires_at TEXT)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE TABLE products ("
|
||||
"id INTEGER PRIMARY KEY, i_code TEXT, name TEXT, status TEXT)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE TABLE price_records ("
|
||||
"id INTEGER PRIMARY KEY, product_id INTEGER, price REAL, timestamp TEXT)"
|
||||
))
|
||||
conn.execute(text("""
|
||||
INSERT INTO daily_sales_snapshot
|
||||
("商品ID", "商品名稱", snapshot_date, "總業績", "數量", "商品館")
|
||||
VALUES
|
||||
('PCH-1', '高業績商品', '2026-06-14', 120000, 36, '保養'),
|
||||
('PCH-1', '高業績商品', '2026-06-08', 180000, 50, '保養'),
|
||||
('PCH-2', '待補對應商品', '2026-06-14', 90000, 18, '彩妝'),
|
||||
('PCH-2', '待補對應商品', '2026-06-08', 30000, 8, '彩妝')
|
||||
"""))
|
||||
conn.execute(text(
|
||||
"INSERT INTO products (id, i_code, name, status) "
|
||||
"VALUES (1, 'MOMO-1', 'MOMO 參考商品', 'ACTIVE')"
|
||||
))
|
||||
conn.execute(text(
|
||||
"INSERT INTO price_records (product_id, price, timestamp) "
|
||||
"VALUES (1, 900, '2026-06-14 10:00:00')"
|
||||
))
|
||||
conn.execute(text("""
|
||||
INSERT INTO competitor_prices
|
||||
(sku, source, competitor_product_id, competitor_product_name,
|
||||
price, match_score, tags, crawled_at, expires_at)
|
||||
VALUES
|
||||
('MOMO-1', 'pchome', 'PCH-1', '高業績商品',
|
||||
1000, 0.91, '["identity_v2","match_type_exact"]', '2026-06-14 11:00:00', NULL)
|
||||
"""))
|
||||
|
||||
|
||||
def test_pchome_growth_opportunities_use_plain_language_and_pause_shopee_coupang():
|
||||
from services.pchome_revenue_growth_service import build_pchome_growth_opportunities
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
_seed_growth_tables(engine)
|
||||
|
||||
payload = build_pchome_growth_opportunities(engine, limit=5)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["system_name"] == "PChome 業績成長自動化作戰系統"
|
||||
assert payload["source_scope"]["active_external_sources"] == ["MOMO 外部價格參考"]
|
||||
assert payload["source_scope"]["paused_external_sources"] == ["蝦皮", "酷澎"]
|
||||
assert payload["stats"]["candidate_count"] == 2
|
||||
assert payload["stats"]["mapped_count"] == 1
|
||||
assert payload["stats"]["needs_mapping_count"] == 1
|
||||
|
||||
actions = {item["pchome_product_id"]: item["recommended_action"]["label"] for item in payload["opportunities"]}
|
||||
assert actions["PCH-1"] == "檢查售價與活動"
|
||||
assert actions["PCH-2"] == "先補商品對應"
|
||||
assert all("identity" not in " ".join(item["reason_lines"]).lower() for item in payload["opportunities"])
|
||||
|
||||
|
||||
def test_ai_product_pick_sales_join_by_sku_disabled_by_default(monkeypatch):
|
||||
from services.ai_product_pick_agent import _sales_join_by_momo_sku_enabled
|
||||
|
||||
monkeypatch.delenv("PCHOME_SALES_JOIN_BY_MOMO_SKU_ENABLED", raising=False)
|
||||
|
||||
assert _sales_join_by_momo_sku_enabled() is False
|
||||
|
||||
|
||||
def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
|
||||
from pathlib import Path
|
||||
|
||||
template = Path("templates/ai_intelligence.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "PChome 業績成長自動化作戰系統" in template
|
||||
assert "/api/ai/pchome-growth/opportunities" in template
|
||||
assert "待補對應" in template
|
||||
Reference in New Issue
Block a user