V10.608 新增外部報價 CSV 預檢
All checks were successful
CD Pipeline / deploy (push) Successful in 1m7s

This commit is contained in:
OoO
2026-06-15 20:39:32 +08:00
parent 9260cc1740
commit df6714c3f7
9 changed files with 527 additions and 5 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -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

View File

@@ -192,3 +192,10 @@
- `/api/ai/pchome-growth/source-contract` 提供只讀來源狀態與欄位 contractUI 只顯示白話狀態,例如「正在使用」「先暫停」「可用資料」。
- 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 成長作戰清單。

View File

@@ -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` |

View File

@@ -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():

View File

@@ -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}

View File

@@ -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');

View File

@@ -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

View File

@@ -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