${escapeHtml(row.title || '未命名商品')}
+ +${escapeHtml(reasons)}
+diff --git a/config.py b/config.py
index 85f2463..b100472 100644
--- a/config.py
+++ b/config.py
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.607"
+SYSTEM_VERSION = "V10.608"
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 771cfbb..fc68197 100644
--- a/docs/AI_INTELLIGENCE_MODULE_SOT.md
+++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md
@@ -1,8 +1,8 @@
# PChome 業績成長自動化作戰系統 — AI 競價情報模組 Single Source of Truth
> **最後更新**: 2026-06-15 (台北時間)
-> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層已建立
-> **適用版本**: V10.607
+> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層與 CSV 預檢已建立
+> **適用版本**: V10.608
---
@@ -54,6 +54,7 @@
- 2026-06-15 只讀盤點確認:`daily_sales_snapshot."商品ID"` 與 `competitor_prices.competitor_product_id` 在正式資料中直接重疊為 0。因此第一版作戰清單不得硬接兩邊 ID;若沒有可驗證對應,只能輸出「先補商品對應」任務。
- 蝦皮與酷澎暫停接入,不進作戰清單、不發告警;後續只可透過 official API / provider API / manual CSV 進 `external_offers` 類正規化層,並清楚標示資料品質。
- V10.607 新增 `external_market_sources` / `external_offers` 正規化層與 `/api/ai/pchome-growth/source-contract` 只讀 API。MOMO 先以既有比價快取橋接進來源狀態;蝦皮與酷澎只保留 official API、provider API、manual CSV contract,預設暫停且不進告警。
+- V10.608 新增 `/api/ai/pchome-growth/external-offers/csv-dry-run` 與 AI 情報頁「外部報價預檢」。CSV 預檢只讀、不寫 DB;逐列回報「可使用」「需人工確認」「不能使用」,並支援中文表頭,避免格式小錯造成整批匯入失敗。
## 零之一、12 Agent 決策信封(2026-05-24)
diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md
index 17df6c7..dbefd69 100644
--- a/docs/memory/current_execution_queue_20260524.md
+++ b/docs/memory/current_execution_queue_20260524.md
@@ -192,3 +192,10 @@
- `/api/ai/pchome-growth/source-contract` 提供只讀來源狀態與欄位 contract;UI 只顯示白話狀態,例如「正在使用」「先暫停」「可用資料」。
- MOMO 目前先橋接既有已確認同款的比價快取;蝦皮與酷澎只保留 contract,預設暫停、不進告警。
- 下一步:做手動 CSV 匯入 dry-run 與外部報價品質檢查頁,讓未來無論官方 API 或 provider API 都能先經過同一套品質門檻。
+
+## 11. 2026-06-15 V10.608 外部報價 CSV 預檢
+
+- 新增 `/api/ai/pchome-growth/external-offers/csv-dry-run`,接受 CSV 檔案或貼上的 CSV 文字,只做預檢、不寫 DB。
+- AI 情報頁新增「外部報價預檢」區塊,顯示可使用、待確認、不能使用;用字保持白話,不顯示工程欄位給一般使用者。
+- 預檢支援中文表頭,例如「資料來源、外部商品ID、商品名稱、售價、資料時間、取得方式、PChome商品ID、同款狀態、資料可信度」。
+- 下一步:在 dry-run 通過後新增人工批准寫入器,先寫 `external_offers`,再串回 PChome 成長作戰清單。
diff --git a/routes/README.md b/routes/README.md
index 4a3ef95..de6b375 100644
--- a/routes/README.md
+++ b/routes/README.md
@@ -25,7 +25,7 @@
| `market_intel_review_post_ai_routes.py` | 市場情報 AI summary persistence / Telegram dispatch 後續只讀延伸 API(掛在 `market_intel_review_bp`) | `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_gate`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_package`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_archive`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_archive_summary` |
| `market_intel_review_report_routes.py` | 市場情報 report input / report run package / report run readiness / report run receipt / report closeout / report archive / report catalog handoff 後續只讀延伸 API(掛在 `market_intel_review_bp`) | `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_input`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_package`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_readiness`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_run_receipt`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_closeout`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_archive`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_archive_summary`, `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_catalog_handoff` |
| `api_routes.py` | 通用任務與查詢 API | `/api/run_task`, `/api/history/*` |
-| `ai_routes.py` | AI 推薦、競情儀表板與 PChome 成長作戰 API | `/ai_recommend`, `/ai_intelligence`, `/api/ai/status`, `/api/ai/icaim/dashboard`, `/api/ai/pchome-growth/opportunities`, `/api/ai/pchome-growth/source-contract` |
+| `ai_routes.py` | AI 推薦、競情儀表板與 PChome 成長作戰 API | `/ai_recommend`, `/ai_intelligence`, `/api/ai/status`, `/api/ai/icaim/dashboard`, `/api/ai/pchome-growth/opportunities`, `/api/ai/pchome-growth/source-contract`, `/api/ai/pchome-growth/external-offers/csv-dry-run` |
| `export_routes.py` | 匯出功能 | `/api/export/*` |
| `import_routes.py` | 匯入功能 | `/api/import_excel`, `/api/import/monthly_summary` |
diff --git a/routes/ai_routes.py b/routes/ai_routes.py
index de11d38..b518bf3 100644
--- a/routes/ai_routes.py
+++ b/routes/ai_routes.py
@@ -1677,6 +1677,48 @@ def api_pchome_growth_source_contract():
}), 500
+def _decode_external_offer_csv_upload(raw_bytes):
+ for encoding in ("utf-8-sig", "utf-8", "big5", "cp950"):
+ try:
+ return raw_bytes.decode(encoding)
+ except UnicodeDecodeError:
+ continue
+ return raw_bytes.decode("utf-8", errors="replace")
+
+
+@ai_bp.route('/api/ai/pchome-growth/external-offers/csv-dry-run', methods=['POST'])
+@login_required
+def api_pchome_growth_external_offer_csv_dry_run():
+ """手動 CSV 外部報價預檢,只讀、不寫 DB。"""
+ try:
+ from services.external_market_offer_service import dry_run_external_offer_csv
+
+ csv_text = ""
+ upload = request.files.get("file")
+ if upload and upload.filename:
+ raw_bytes = upload.read()
+ if len(raw_bytes) > 1024 * 1024:
+ return jsonify({
+ "success": False,
+ "error": "CSV 檔案太大,請先切成 1 MB 以下再預檢。",
+ }), 400
+ csv_text = _decode_external_offer_csv_upload(raw_bytes)
+ elif request.is_json:
+ csv_text = str((request.get_json(silent=True) or {}).get("csv_text") or "")
+ else:
+ csv_text = str(request.form.get("csv_text") or "")
+
+ payload = dry_run_external_offer_csv(csv_text, limit=200)
+ status_code = 200 if payload.get("success") else 400
+ return jsonify(payload), status_code
+ except Exception as exc:
+ logger.error("[PChomeGrowth] 外部報價 CSV 預檢失敗: %s", exc, exc_info=True)
+ return jsonify({
+ "success": False,
+ "error": "CSV 預檢暫時無法執行,請稍後再試。",
+ }), 500
+
+
@ai_bp.route('/api/ai/icaim/dashboard')
@login_required
def api_icaim_dashboard():
diff --git a/services/external_market_offer_service.py b/services/external_market_offer_service.py
index 51f0269..7fbfc88 100644
--- a/services/external_market_offer_service.py
+++ b/services/external_market_offer_service.py
@@ -9,6 +9,8 @@ from __future__ import annotations
import json
import logging
+import csv
+import io
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
@@ -105,6 +107,35 @@ NORMALIZED_OFFER_FIELDS = [
},
]
+CSV_HEADER_ALIASES = {
+ "source_code": {"source_code", "資料來源", "來源", "平台來源"},
+ "platform_code": {"platform_code", "平台", "平台代碼"},
+ "source_product_id": {"source_product_id", "外部商品 ID", "外部商品ID", "商品ID", "商品編號"},
+ "title": {"title", "商品名稱", "品名", "name"},
+ "price": {"price", "售價", "價格", "成交價"},
+ "observed_at": {"observed_at", "資料時間", "抓取時間", "看到時間", "時間"},
+ "ingestion_method": {"ingestion_method", "取得方式", "匯入方式", "來源方式"},
+ "currency": {"currency", "幣別"},
+ "original_price": {"original_price", "原價", "牌價"},
+ "product_url": {"product_url", "商品網址", "網址", "url"},
+ "brand": {"brand", "品牌"},
+ "category_text": {"category_text", "分類", "類別"},
+ "pchome_product_id": {"pchome_product_id", "PChome 商品 ID", "PChome商品ID", "pchome_id"},
+ "momo_sku": {"momo_sku", "MOMO SKU", "momo_sku", "momo_i_code"},
+ "match_status": {"match_status", "同款狀態", "比對狀態"},
+ "quality_score": {"quality_score", "資料可信度", "可信度", "品質分數"},
+ "data_quality_status": {"data_quality_status", "資料狀態", "品質狀態"},
+ "quality_note": {"quality_note", "備註", "品質備註"},
+}
+
+ALLOWED_SOURCE_CODES = {source["code"] for source in SOURCE_CONTRACTS}
+PAUSED_SOURCE_CODES = {
+ source["code"] for source in SOURCE_CONTRACTS if source["status_code"] == "paused"
+}
+ACTIVE_SOURCE_CODES = {
+ source["code"] for source in SOURCE_CONTRACTS if source["status_code"] == "active"
+}
+
@dataclass(frozen=True)
class ExternalOfferPayload:
@@ -245,6 +276,156 @@ def normalize_external_offer_payload(payload: dict[str, Any]) -> tuple[ExternalO
return record, []
+def _normalize_header(header: str) -> str:
+ cleaned = str(header or "").strip().replace("\ufeff", "")
+ for canonical, aliases in CSV_HEADER_ALIASES.items():
+ if cleaned in aliases:
+ return canonical
+ return cleaned
+
+
+def _read_csv_rows(csv_text: str, limit: int) -> tuple[list[dict[str, Any]], list[str]]:
+ text_value = (csv_text or "").strip("\ufeff\n\r ")
+ if not text_value:
+ return [], ["CSV 內容是空的"]
+
+ sample = text_value[:4096]
+ try:
+ dialect = csv.Sniffer().sniff(sample, delimiters=",\t;")
+ except csv.Error:
+ dialect = csv.excel
+
+ reader = csv.DictReader(io.StringIO(text_value), dialect=dialect)
+ if not reader.fieldnames:
+ return [], ["找不到表頭列"]
+
+ raw_headers = [str(header or "").strip().replace("\ufeff", "") for header in reader.fieldnames]
+ normalized_headers = [_normalize_header(header) for header in raw_headers]
+ if len(set(normalized_headers)) != len(normalized_headers):
+ return [], ["表頭有重複欄位,請先合併或重新命名"]
+
+ rows = []
+ for index, raw_row in enumerate(reader, start=2):
+ if len(rows) >= limit:
+ break
+ normalized = {}
+ has_value = False
+ for raw_header, normalized_header in zip(raw_headers, normalized_headers):
+ value = raw_row.get(raw_header)
+ if value is not None and str(value).strip():
+ has_value = True
+ normalized[normalized_header] = str(value or "").strip()
+ if has_value:
+ normalized["_row_number"] = index
+ rows.append(normalized)
+
+ return rows, []
+
+
+def _classify_offer_record(record: ExternalOfferPayload | None, errors: list[str]) -> dict[str, Any]:
+ if errors or record is None:
+ return {
+ "status_code": "blocked",
+ "status_label": "不能使用",
+ "can_enter_alerts": False,
+ "reasons": errors or ["資料格式需要修正"],
+ }
+
+ reasons: list[str] = []
+ source_code = record.source_code
+ match_status = (record.match_status or "").strip().lower()
+ is_verified_match = match_status in {"verified", "usable", "reviewed", "exact", "confirmed"}
+ has_pchome_id = bool(str(record.pchome_product_id or "").strip())
+ has_good_quality = record.quality_score >= 76
+
+ if source_code not in ALLOWED_SOURCE_CODES:
+ reasons.append("資料來源不在允許清單")
+ if source_code in PAUSED_SOURCE_CODES:
+ reasons.append("這個來源目前先暫停,不進告警")
+ if not is_verified_match:
+ reasons.append("尚未確認同款")
+ if not has_pchome_id:
+ reasons.append("缺少 PChome 商品 ID,無法連到業績")
+ if not has_good_quality:
+ reasons.append("資料可信度低於 76")
+
+ can_use = (
+ source_code in ACTIVE_SOURCE_CODES
+ and is_verified_match
+ and has_pchome_id
+ and has_good_quality
+ and not reasons
+ )
+ if can_use:
+ return {
+ "status_code": "ready",
+ "status_label": "可使用",
+ "can_enter_alerts": True,
+ "reasons": ["可進作戰清單"],
+ }
+
+ return {
+ "status_code": "review",
+ "status_label": "需人工確認",
+ "can_enter_alerts": False,
+ "reasons": reasons or ["需要人工確認"],
+ }
+
+
+def dry_run_external_offer_csv(csv_text: str, *, limit: int = 200) -> dict[str, Any]:
+ """檢查手動 CSV 是否能轉成外部報價格式;只讀,不寫 DB。"""
+ limit = max(1, min(int(limit or 200), 1000))
+ rows, parse_errors = _read_csv_rows(csv_text, limit=limit)
+ if parse_errors:
+ return {
+ "success": False,
+ "message": "CSV 預檢失敗,請先修正檔案格式。",
+ "summary": {
+ "total_rows": 0,
+ "ready_count": 0,
+ "review_count": 0,
+ "blocked_count": 0,
+ },
+ "errors": parse_errors,
+ "rows": [],
+ }
+
+ checked_rows = []
+ summary = {
+ "total_rows": len(rows),
+ "ready_count": 0,
+ "review_count": 0,
+ "blocked_count": 0,
+ }
+ for row in rows:
+ record, errors = normalize_external_offer_payload(row)
+ classification = _classify_offer_record(record, errors)
+ summary[f"{classification['status_code']}_count"] += 1
+ preview = record.to_record() if record else {}
+ checked_rows.append({
+ "row_number": row.get("_row_number"),
+ "status_code": classification["status_code"],
+ "status_label": classification["status_label"],
+ "can_enter_alerts": classification["can_enter_alerts"],
+ "reasons": classification["reasons"][:4],
+ "source_code": preview.get("source_code") or row.get("source_code") or "",
+ "source_product_id": preview.get("source_product_id") or row.get("source_product_id") or "",
+ "title": preview.get("title") or row.get("title") or "",
+ "price": preview.get("price"),
+ "pchome_product_id": preview.get("pchome_product_id") or "",
+ "quality_score": preview.get("quality_score") if preview else row.get("quality_score"),
+ })
+
+ return {
+ "success": True,
+ "message": "CSV 預檢完成,尚未寫入資料。",
+ "summary": summary,
+ "errors": [],
+ "rows": checked_rows,
+ "manual_csv": build_connector_contracts()["manual_csv"],
+ }
+
+
def _legacy_momo_reference_stats(conn) -> dict[str, Any]:
if not _has_table(conn, "competitor_prices"):
return {"usable_offer_count": 0, "last_seen_at": None}
diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html
index 3cffc2f..a70ce82 100644
--- a/templates/ai_intelligence.html
+++ b/templates/ai_intelligence.html
@@ -374,6 +374,95 @@
white-space: nowrap;
}
+ .offer-dryrun-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.55fr);
+ gap: 14px;
+ }
+
+ .offer-dryrun-field {
+ display: grid;
+ gap: 8px;
+ }
+
+ .offer-dryrun-field label {
+ color: var(--momo-text-strong);
+ font-size: 0.78rem;
+ font-weight: 900;
+ }
+
+ .offer-dryrun-field textarea {
+ min-height: 132px;
+ resize: vertical;
+ border-color: var(--momo-border-subtle);
+ border-radius: 8px;
+ font-size: 0.8rem;
+ }
+
+ .offer-dryrun-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ margin-bottom: 10px;
+ }
+
+ .offer-dryrun-result {
+ border: 1px solid rgba(42, 37, 32, 0.1);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.76);
+ min-height: 216px;
+ padding: 10px;
+ }
+
+ .offer-dryrun-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ border-bottom: 1px solid rgba(42, 37, 32, 0.08);
+ padding: 8px 0;
+ }
+
+ .offer-dryrun-row:last-child {
+ border-bottom: 0;
+ }
+
+ .offer-dryrun-title {
+ margin: 0;
+ color: var(--momo-text-strong);
+ font-size: 0.82rem;
+ font-weight: 900;
+ line-height: 1.35;
+ }
+
+ .offer-dryrun-meta,
+ .offer-dryrun-reason {
+ margin: 3px 0 0;
+ color: var(--momo-text-muted);
+ font-size: 0.74rem;
+ line-height: 1.4;
+ }
+
+ .offer-status-pill {
+ align-self: start;
+ border-radius: 999px;
+ background: rgba(42, 37, 32, 0.08);
+ color: var(--momo-text-muted);
+ font-size: 0.68rem;
+ font-weight: 900;
+ padding: 4px 8px;
+ white-space: nowrap;
+ }
+
+ .offer-status-pill.is-ready {
+ background: rgba(42, 134, 96, 0.14);
+ color: #1f6d4c;
+ }
+
+ .offer-status-pill.is-blocked {
+ background: rgba(188, 78, 67, 0.14);
+ color: #94372d;
+ }
+
@media (max-width: 992px) {
.ai-intel-hero {
grid-template-columns: 1fr;
@@ -484,11 +573,13 @@
}
.growth-ops-grid,
+ .offer-dryrun-grid,
.growth-metric-row {
grid-template-columns: 1fr;
}
- .growth-item {
+ .growth-item,
+ .offer-dryrun-row {
grid-template-columns: 1fr;
}
}
@@ -577,6 +668,55 @@
+
+
${escapeHtml(reasons)}
+