${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}
+${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')}
+diff --git a/config.py b/config.py
index 01b1073..8f46c46 100644
--- a/config.py
+++ b/config.py
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.704"
+SYSTEM_VERSION = "V10.705"
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 07097e5..5eb097e 100644
--- a/docs/AI_INTELLIGENCE_MODULE_SOT.md
+++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md
@@ -782,3 +782,5 @@ POSTGRES_HOST=momo-db
| 2026-06-26 | 關鍵決策框架不得因資料不足整段消失 | V10.703 起 AI 流量頁的「情境 × 知識命中矩陣」改為永遠顯示;資料不足時顯示營運空狀態,避免使用者看不到頁面應該如何判讀。 |
| 2026-06-26 | 使用者頁不得把提交、分支、JSON/API 或 raw caller 當主訊息 | V10.704 起成本治理、AI 分工、AI 健康檢查與登入頁再收斂:raw caller/server 改成使用情境與服務元件,JSONL/API/PostgreSQL/Session/CSRF 等工程語改成檢查紀錄、健康檢查服務、資料服務與工作階段。 |
| 2026-06-26 | 全站 UI/UX 工作重點必須文件化並納入入口索引 | V10.704 起新增 `docs/guides/pchome_growth_ui_ux_guardrails.md` 並由 `AGENTS.md` 索引;所有前台頁面以「提升 PChome 業績、快速判斷、直接下一步」為共同目標,避免後續工作再偏回局部文案修補。 |
+| 2026-06-26 | 待確認商品必須能並排比較兩家賣場 | V10.705 起 `ai_intelligence` 與 `price_comparison` 的 MOMO 待確認候選都要以 PChome/MOMO 兩欄比較卡呈現,並提供「同時開兩家賣場」主要操作;不得只顯示候選摘要或只放單一平台連結。 |
+| 2026-06-26 | 外部促銷活動要進商業情報與 PChome 解法 | V10.705 起商業情報頁新增外部促銷活動監控,從 24h 外部價格/折扣訊號推導外部低價壓力或促銷訊號,並用守價、組合、曝光、會員四類 PChome 業績提升解法承接。 |
diff --git a/docs/guides/pchome_growth_ui_ux_guardrails.md b/docs/guides/pchome_growth_ui_ux_guardrails.md
index 39c2583..7c4e486 100644
--- a/docs/guides/pchome_growth_ui_ux_guardrails.md
+++ b/docs/guides/pchome_growth_ui_ux_guardrails.md
@@ -11,6 +11,7 @@
3. 比價、缺貨、匯入、AI 建議、觀測與服務健康頁,都要用同一套 PChome 業績成長語言:主推、守價、補比價、供貨風險、成長缺口、建議路徑、使用情境、服務元件。
4. 資料不足時不能整段消失,要顯示可理解的空狀態與下一步。
5. 不得把工作視窗溝通、部署交接、工程判斷或維護工作摘要搬到前台。
+6. 外部促銷活動、折扣、價格壓力與平台活動訊號,必須被整理成「PChome 現況對比」與「業績提升解法」,不能只顯示外部事件本身。
## 每次 UI/UX 修改的驗收
@@ -20,6 +21,7 @@
- 相關業務測試:`tests/test_pchome_revenue_growth_service.py`
- 正式 smoke:檢查 `/health` 版本、核心頁 HTTP 200、可見文案無 raw terms、靜態資源 HTTP 200
- 如果頁面有 PChome/MOMO 商品比較,必須能一眼看到兩平台價格與可同時開啟的外部賣場連結
+- 如果頁面有外部促銷或競品活動訊號,必須至少提供守價、組合、曝光或會員回饋等 PChome 可執行解法
## 判斷標準
diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py
index aa380c4..cdbfaf9 100644
--- a/routes/admin_observability_routes.py
+++ b/routes/admin_observability_routes.py
@@ -821,7 +821,7 @@ def business_intel_dashboard():
sa_text("""
SELECT cph.sku, cph.competitor_product_name, cph.price,
cph.momo_price, cph.discount_pct, cph.match_score,
- cph.crawled_at
+ cph.crawled_at, cph.source
FROM competitor_price_history cph
WHERE cph.crawled_at >= NOW() - INTERVAL '24 hours'
AND cph.match_score >= 0.7
@@ -839,10 +839,35 @@ def business_intel_dashboard():
'match_score': round(float(r[5] or 0), 3),
'gap': (float(r[3]) - float(r[2])) if (r[2] and r[3]) else None,
'crawled_at': r[6].strftime('%m-%d %H:%M') if r[6] else '',
+ 'source': r[7] or '外部電商',
}
for r in recent_competitor
]
+ promo_watch_rows = []
+ for item in recent_competitor_prices:
+ discount_pct = float(item.get('discount_pct') or 0)
+ gap = item.get('gap')
+ gap_value = float(gap) if gap is not None else 0.0
+ is_external_pressure = gap_value < 0
+ is_discount_signal = discount_pct >= 5
+ if not (is_external_pressure or is_discount_signal):
+ continue
+ if is_external_pressure:
+ pressure_label = '外部低價壓力'
+ recommended_action = '檢查 PChome 售價、折扣券、組合包與商品頁主賣點'
+ else:
+ pressure_label = '外部促銷訊號'
+ recommended_action = '比對活動條件後,安排 PChome 主推曝光或會員回饋'
+ promo_watch_rows.append({
+ **item,
+ 'pressure_label': pressure_label,
+ 'recommended_action': recommended_action,
+ 'gap_abs': abs(gap_value),
+ })
+ if len(promo_watch_rows) >= 8:
+ break
+
# 7. 高 confidence 但未 follow-through (recommendation 沒對應 action_plan)
unfollowed = session.execute(
sa_text(f"""
@@ -870,6 +895,7 @@ def business_intel_dashboard():
verdict_stats=verdict_stats,
match_stats=match_stats,
recent_competitor_prices=recent_competitor_prices,
+ promo_watch_rows=promo_watch_rows,
unfollowed_count=unfollowed_count,
error=None,
)
@@ -879,6 +905,7 @@ def business_intel_dashboard():
active_page='obs_business_intel', days=days,
rec_by_strategy=[], latest_recommendations=[], loop_records=[],
verdict_stats=[], match_stats=[], recent_competitor_prices=[],
+ promo_watch_rows=[],
unfollowed_count=0,
error='商業 AI 資料暫時不可用,已切換安全空狀態。',
)
diff --git a/routes/price_comparison_routes.py b/routes/price_comparison_routes.py
index 548e636..3bf89c8 100644
--- a/routes/price_comparison_routes.py
+++ b/routes/price_comparison_routes.py
@@ -30,6 +30,55 @@ def _candidate_auto_compare_type(item: dict) -> str:
return "manual_review"
+def _build_pchome_product_url(product_id: str) -> str:
+ product_id = str(product_id or "").strip()
+ return f"https://24h.pchome.com.tw/prod/{product_id}" if product_id else ""
+
+
+def _humanize_targeted_review_reasons(candidate: dict) -> list[str]:
+ labels = []
+ score = candidate.get("target_match_score")
+ try:
+ score_pct = round(float(score or 0) * 100)
+ except (TypeError, ValueError):
+ score_pct = 0
+ if score_pct > 0:
+ labels.append(f"可信度 {score_pct}%")
+
+ reason_map = {
+ "variant_selection_review": "需確認色號",
+ "strong_exact_spec_match": "規格接近",
+ "strong_product_line_match": "系列接近",
+ "count_conflict": "組合數量需確認",
+ "unit_comparable": "可用單位價判斷",
+ "makeup_catalog_selection_gap": "款式需確認",
+ }
+ for reason in candidate.get("target_match_reasons") or []:
+ label = reason_map.get(str(reason or "").strip())
+ if label and label not in labels:
+ labels.append(label)
+ return labels or ["需人工確認同款"]
+
+
+def _present_momo_review_candidate(candidate: dict) -> dict:
+ """Return only user-facing review candidate fields for the UI."""
+ pchome_product_id = str(candidate.get("target_pchome_product_id") or "").strip()
+ return {
+ "product_id": candidate.get("product_id") or candidate.get("goodsCode") or candidate.get("id"),
+ "name": candidate.get("name") or candidate.get("title") or "",
+ "price": candidate.get("price"),
+ "product_url": candidate.get("product_url") or candidate.get("url") or "",
+ "image_url": candidate.get("image_url") or "",
+ "target_pchome_product_id": pchome_product_id,
+ "target_pchome_name": candidate.get("target_pchome_name") or pchome_product_id,
+ "target_pchome_price": candidate.get("target_pchome_price"),
+ "target_pchome_url": candidate.get("target_pchome_url") or _build_pchome_product_url(pchome_product_id),
+ "target_gap_pct": candidate.get("target_gap_pct"),
+ "target_match_score": candidate.get("target_match_score"),
+ "target_match_reason_labels": _humanize_targeted_review_reasons(candidate),
+ }
+
+
def _sync_targeted_candidates_to_external_offers(candidates: list[dict]) -> dict:
from database.manager import DatabaseManager
from services.external_market_offer_service import sync_targeted_momo_candidates_to_external_offers
@@ -239,10 +288,14 @@ def fetch_momo_for_pchome_products():
item for item in products
if _candidate_auto_compare_type(item) == "unit_price"
]
- review_candidates = [
+ raw_review_candidates = [
item for item in products
if _candidate_auto_compare_type(item) not in {"total_price", "unit_price"}
]
+ review_candidates = [
+ _present_momo_review_candidate(item)
+ for item in raw_review_candidates
+ ]
external_offer_sync = {
"success": True,
"status": "not_requested",
diff --git a/scripts/observability_contract.py b/scripts/observability_contract.py
index 6981937..df32c71 100644
--- a/scripts/observability_contract.py
+++ b/scripts/observability_contract.py
@@ -49,7 +49,7 @@ OBSERVABILITY_PAGES = (
"/observability/business_intel",
"商業面 × AI",
"商業",
- ("商業面 × AI", "AI", "競品"),
+ ("外部促銷活動監控", "PChome 解法", "競品"),
),
ObservabilityPage(
"templates/admin/host_health.html",
diff --git a/templates/admin/business_intel.html b/templates/admin/business_intel.html
index a5dddbd..a6cd7a4 100644
--- a/templates/admin/business_intel.html
+++ b/templates/admin/business_intel.html
@@ -177,6 +177,101 @@
font-weight: 850;
}
+.biz-promo-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: .75rem;
+}
+
+.biz-solution-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: .6rem;
+ margin-bottom: .85rem;
+}
+
+.biz-solution-card {
+ min-height: 92px;
+ padding: .72rem;
+ border: 1px solid rgba(42, 37, 32, 0.09);
+ border-radius: 16px;
+ background: rgba(250, 247, 240, 0.68);
+}
+
+.biz-solution-card strong {
+ display: block;
+ color: var(--biz-ink);
+ font-size: .82rem;
+ font-weight: 950;
+}
+
+.biz-solution-card span {
+ display: block;
+ margin-top: .35rem;
+ color: var(--biz-muted);
+ font-size: .72rem;
+ font-weight: 780;
+ line-height: 1.35;
+}
+
+.biz-promo-card {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(150px, .42fr);
+ gap: .8rem;
+ align-items: start;
+ padding: .95rem;
+ border: 1px solid rgba(201, 100, 66, 0.16);
+ border-radius: 18px;
+ background: rgba(255, 250, 243, 0.9);
+}
+
+.biz-promo-source {
+ display: flex;
+ flex-wrap: wrap;
+ gap: .45rem;
+ align-items: center;
+ margin-bottom: .45rem;
+}
+
+.biz-promo-title {
+ color: var(--biz-ink);
+ font-size: .95rem;
+ font-weight: 950;
+ line-height: 1.35;
+}
+
+.biz-promo-solution {
+ margin-top: .55rem;
+ color: var(--biz-muted);
+ font-size: .82rem;
+ font-weight: 780;
+ line-height: 1.5;
+}
+
+.biz-promo-metrics {
+ display: grid;
+ gap: .45rem;
+}
+
+.biz-promo-metric {
+ padding: .55rem;
+ border-radius: 14px;
+ background: rgba(201, 100, 66, 0.07);
+}
+
+.biz-promo-metric strong {
+ display: block;
+ color: var(--biz-ink);
+ font-family: var(--momo-font-mono, monospace);
+ font-size: .92rem;
+}
+
+.biz-promo-metric span {
+ color: var(--biz-muted);
+ font-size: .68rem;
+ font-weight: 900;
+}
+
.biz-layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(340px, .9fr);
@@ -400,7 +495,10 @@
.biz-signal-grid,
.biz-strategy-grid,
- .biz-decision-card {
+ .biz-solution-grid,
+ .biz-decision-card,
+ .biz-promo-grid,
+ .biz-promo-card {
grid-template-columns: 1fr;
}
@@ -500,6 +598,48 @@
{% endif %}
+ AI Agent 以外部價格與折扣訊號監控促銷壓力,先對照 PChome 現況,再提出業績提升解法。外部促銷活動監控
+
${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}
+${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')}
+{{ c.caller }}"],
"templates/ai_automation_smoke.html": ["JSONL", "健康檢查 API", "JSON.stringify(item.details"],
diff --git a/tests/test_price_comparison_routes.py b/tests/test_price_comparison_routes.py
index 3ff8367..8e45d88 100644
--- a/tests/test_price_comparison_routes.py
+++ b/tests/test_price_comparison_routes.py
@@ -141,8 +141,14 @@ def test_fetch_momo_for_pchome_endpoint_splits_auto_and_review_candidates(monkey
"name": "組合需確認商品",
"price": 468,
"product_id": "REVIEW-1",
+ "product_url": "https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=REVIEW-1",
"can_auto_compare": False,
"target_review_status": "需人工確認",
+ "target_pchome_product_id": "PCH-1",
+ "target_pchome_name": "PChome B5 修復霜",
+ "target_pchome_price": 920,
+ "target_match_score": 0.97,
+ "target_match_reasons": ["variant_selection_review"],
},
]
@@ -171,7 +177,14 @@ def test_fetch_momo_for_pchome_endpoint_splits_auto_and_review_candidates(monkey
assert payload["data"]["candidate_count"] == 3
assert payload["data"]["products"][0]["product_id"] == "AUTO-1"
assert payload["data"]["unit_compare_candidates"][0]["product_id"] == "UNIT-1"
- assert payload["data"]["review_candidates"][0]["product_id"] == "REVIEW-1"
+ review_candidate = payload["data"]["review_candidates"][0]
+ assert review_candidate["product_id"] == "REVIEW-1"
+ assert review_candidate["target_pchome_product_id"] == "PCH-1"
+ assert review_candidate["target_pchome_name"] == "PChome B5 修復霜"
+ assert review_candidate["target_pchome_price"] == 920
+ assert review_candidate["target_pchome_url"] == "https://24h.pchome.com.tw/prod/PCH-1"
+ assert review_candidate["target_match_reason_labels"] == ["可信度 97%", "需確認色號"]
+ assert "target_match_reasons" not in review_candidate
assert payload["data"]["external_offer_sync"]["status"] == "not_requested"