This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 成長作戰清單。
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 外部報價 CSV 預檢 ── -->
|
||||
<section class="card shadow-sm ai-panel" id="externalOfferDryRunPanel">
|
||||
<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-file-circle-check"></i>外部報價預檢
|
||||
<small class="text-muted fw-normal ms-2">只做檢查,不會匯入資料</small>
|
||||
</span>
|
||||
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="fillExternalOfferSample()">
|
||||
<i class="fas fa-table me-1"></i>填入範例
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="offer-dryrun-grid">
|
||||
<div class="offer-dryrun-field">
|
||||
<label for="externalOfferCsvFile">CSV 檔案</label>
|
||||
<input class="form-control form-control-sm" type="file" id="externalOfferCsvFile" accept=".csv,text/csv">
|
||||
<label for="externalOfferCsvText">或貼上 CSV 內容</label>
|
||||
<textarea class="form-control" id="externalOfferCsvText" spellcheck="false"
|
||||
placeholder="資料來源,外部商品ID,商品名稱,售價,資料時間,取得方式,PChome商品ID,同款狀態,資料可信度"></textarea>
|
||||
<button class="btn btn-primary btn-sm ai-action-btn" id="btnExternalOfferDryRun" onclick="previewExternalOfferCsv()">
|
||||
<i class="fas fa-magnifying-glass me-1"></i>預檢 CSV
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="offer-dryrun-summary">
|
||||
<div class="growth-metric">
|
||||
<strong id="offerDryRunReady">—</strong>
|
||||
<span>可使用</span>
|
||||
</div>
|
||||
<div class="growth-metric">
|
||||
<strong id="offerDryRunReview">—</strong>
|
||||
<span>待確認</span>
|
||||
</div>
|
||||
<div class="growth-metric">
|
||||
<strong id="offerDryRunBlocked">—</strong>
|
||||
<span>不能使用</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="offer-dryrun-result" id="offerDryRunResult">
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-circle-info d-block mb-2"></i>
|
||||
上傳或貼上 CSV 後,先檢查資料品質。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── KPI 卡片 ── -->
|
||||
<div class="row g-3 mb-4" id="kpiRow">
|
||||
<div class="col-6 col-md-3">
|
||||
@@ -871,6 +1011,108 @@ function renderGrowthOps(rows) {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function fillExternalOfferSample() {
|
||||
const sample = [
|
||||
'資料來源,外部商品ID,商品名稱,售價,資料時間,取得方式,PChome商品ID,同款狀態,資料可信度',
|
||||
'momo_reference,MOMO-1001,範例保養商品,899,2026-06-15T10:00:00,manual_csv,PCH-1001,verified,88',
|
||||
'shopee,SHP-2001,待確認商品,790,2026-06-15T10:05:00,manual_csv,,unmatched,60'
|
||||
].join('\\n');
|
||||
document.getElementById('externalOfferCsvText').value = sample;
|
||||
}
|
||||
|
||||
async function previewExternalOfferCsv() {
|
||||
const fileInput = document.getElementById('externalOfferCsvFile');
|
||||
const textInput = document.getElementById('externalOfferCsvText');
|
||||
const resultBox = document.getElementById('offerDryRunResult');
|
||||
const button = document.getElementById('btnExternalOfferDryRun');
|
||||
const formData = new FormData();
|
||||
|
||||
if (fileInput.files && fileInput.files[0]) {
|
||||
formData.append('file', fileInput.files[0]);
|
||||
}
|
||||
if (textInput.value.trim()) {
|
||||
formData.append('csv_text', textInput.value.trim());
|
||||
}
|
||||
|
||||
if (!formData.has('file') && !formData.has('csv_text')) {
|
||||
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-circle-exclamation d-block mb-2"></i>
|
||||
請先上傳 CSV 或貼上內容。
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>預檢中';
|
||||
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<div class="spinner-border spinner-border-sm me-2"></div>檢查資料品質中...
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/pchome-growth/external-offers/csv-dry-run', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
renderExternalOfferDryRun(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-circle-exclamation d-block mb-2"></i>
|
||||
CSV 預檢暫時失敗,請稍後再試。
|
||||
</div>`;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.innerHTML = '<i class="fas fa-magnifying-glass me-1"></i>預檢 CSV';
|
||||
}
|
||||
}
|
||||
|
||||
function renderExternalOfferDryRun(data) {
|
||||
const resultBox = document.getElementById('offerDryRunResult');
|
||||
const summary = data.summary || {};
|
||||
document.getElementById('offerDryRunReady').textContent = (summary.ready_count || 0).toLocaleString();
|
||||
document.getElementById('offerDryRunReview').textContent = (summary.review_count || 0).toLocaleString();
|
||||
document.getElementById('offerDryRunBlocked').textContent = (summary.blocked_count || 0).toLocaleString();
|
||||
|
||||
if (!data.success) {
|
||||
const errors = (data.errors || [data.error || 'CSV 格式需要修正']).slice(0, 4);
|
||||
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-circle-exclamation d-block mb-2"></i>
|
||||
${escapeHtml(errors.join(';'))}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = (data.rows || []).slice(0, 8);
|
||||
if (!rows.length) {
|
||||
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-circle-info d-block mb-2"></i>
|
||||
CSV 裡沒有可檢查的資料列。
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
resultBox.innerHTML = rows.map((row) => {
|
||||
const statusClass = row.status_code === 'ready'
|
||||
? ' is-ready'
|
||||
: row.status_code === 'blocked'
|
||||
? ' is-blocked'
|
||||
: '';
|
||||
const reasons = (row.reasons || []).join('、');
|
||||
const price = row.price ? formatMoney(row.price) : '未填價格';
|
||||
return `<article class="offer-dryrun-row">
|
||||
<div>
|
||||
<h3 class="offer-dryrun-title">${escapeHtml(row.title || '未命名商品')}</h3>
|
||||
<p class="offer-dryrun-meta">
|
||||
第 ${escapeHtml(row.row_number || '-')} 列 · ${escapeHtml(row.source_code || '未填來源')} · ${escapeHtml(price)}
|
||||
</p>
|
||||
<p class="offer-dryrun-reason">${escapeHtml(reasons)}</p>
|
||||
</div>
|
||||
<span class="offer-status-pill${statusClass}">${escapeHtml(row.status_label || '待確認')}</span>
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── 競品比價表格(熱力圖底色)──────────────────────
|
||||
function renderCompetitorTable(rows) {
|
||||
const tbody = document.getElementById('competitorTbody');
|
||||
|
||||
@@ -46,6 +46,44 @@ def test_normalized_offer_payload_validates_plain_required_fields():
|
||||
assert "售價必須大於 0" in missing_errors
|
||||
|
||||
|
||||
def test_external_offer_csv_dry_run_accepts_chinese_headers_and_classifies_rows():
|
||||
from services.external_market_offer_service import dry_run_external_offer_csv
|
||||
|
||||
csv_text = "\n".join([
|
||||
"資料來源,外部商品ID,商品名稱,售價,資料時間,取得方式,PChome商品ID,同款狀態,資料可信度",
|
||||
"momo_reference,MOMO-1,可用商品,899,2026-06-15T10:00:00,manual_csv,PCH-1,verified,88",
|
||||
"shopee,SHP-1,暫停來源商品,799,2026-06-15T10:01:00,manual_csv,PCH-2,verified,90",
|
||||
"momo_reference,MOMO-2,缺價格商品,,2026-06-15T10:02:00,manual_csv,PCH-3,verified,90",
|
||||
])
|
||||
|
||||
payload = dry_run_external_offer_csv(csv_text)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["message"] == "CSV 預檢完成,尚未寫入資料。"
|
||||
assert payload["summary"] == {
|
||||
"total_rows": 3,
|
||||
"ready_count": 1,
|
||||
"review_count": 1,
|
||||
"blocked_count": 1,
|
||||
}
|
||||
rows = payload["rows"]
|
||||
assert rows[0]["status_label"] == "可使用"
|
||||
assert rows[1]["status_label"] == "需人工確認"
|
||||
assert "這個來源目前先暫停,不進告警" in rows[1]["reasons"]
|
||||
assert rows[2]["status_label"] == "不能使用"
|
||||
assert "缺少售價" in rows[2]["reasons"]
|
||||
|
||||
|
||||
def test_external_offer_csv_dry_run_reports_empty_file_plainly():
|
||||
from services.external_market_offer_service import dry_run_external_offer_csv
|
||||
|
||||
payload = dry_run_external_offer_csv("")
|
||||
|
||||
assert payload["success"] is False
|
||||
assert payload["errors"] == ["CSV 內容是空的"]
|
||||
assert payload["summary"]["blocked_count"] == 0
|
||||
|
||||
|
||||
def test_external_source_readiness_uses_legacy_momo_reference_cache():
|
||||
from services.external_market_offer_service import build_external_source_readiness
|
||||
|
||||
@@ -87,3 +125,12 @@ def test_external_market_migration_creates_source_and_offer_tables():
|
||||
assert "coupang" in migration
|
||||
assert "DROP " not in migration.upper()
|
||||
assert "TRUNCATE " not in migration.upper()
|
||||
|
||||
|
||||
def test_external_offer_csv_dry_run_route_is_registered_as_post_only():
|
||||
from pathlib import Path
|
||||
|
||||
route_source = Path("routes/ai_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "@ai_bp.route('/api/ai/pchome-growth/external-offers/csv-dry-run', methods=['POST'])" in route_source
|
||||
assert "dry_run_external_offer_csv" in route_source
|
||||
|
||||
@@ -89,5 +89,7 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
|
||||
|
||||
assert "PChome 業績成長自動化作戰系統" in template
|
||||
assert "/api/ai/pchome-growth/opportunities" in template
|
||||
assert "/api/ai/pchome-growth/external-offers/csv-dry-run" in template
|
||||
assert "growthSourceReadiness" in template
|
||||
assert "外部報價預檢" in template
|
||||
assert "待補對應" in template
|
||||
|
||||
Reference in New Issue
Block a user