From df6714c3f7ef39ece3ce1b5e107dbfdf18e57aca Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 15 Jun 2026 20:39:32 +0800 Subject: [PATCH] =?UTF-8?q?V10.608=20=E6=96=B0=E5=A2=9E=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E5=A0=B1=E5=83=B9=20CSV=20=E9=A0=90=E6=AA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 5 +- .../current_execution_queue_20260524.md | 7 + routes/README.md | 2 +- routes/ai_routes.py | 42 +++ services/external_market_offer_service.py | 181 +++++++++++++ templates/ai_intelligence.html | 244 +++++++++++++++++- tests/test_external_market_offer_service.py | 47 ++++ tests/test_pchome_revenue_growth_service.py | 2 + 9 files changed, 527 insertions(+), 5 deletions(-) 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 @@ + +
+
+ + 外部報價預檢 + 只做檢查,不會匯入資料 + + +
+
+
+
+ + + + + +
+
+
+
+ + 可使用 +
+
+ + 待確認 +
+
+ + 不能使用 +
+
+
+
+ + 上傳或貼上 CSV 後,先檢查資料品質。 +
+
+
+
+
+
+
@@ -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 = `
+ + 請先上傳 CSV 或貼上內容。 +
`; + return; + } + + button.disabled = true; + button.innerHTML = '預檢中'; + resultBox.innerHTML = `
+
檢查資料品質中... +
`; + + 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 = `
+ + CSV 預檢暫時失敗,請稍後再試。 +
`; + } finally { + button.disabled = false; + button.innerHTML = '預檢 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 = `
+ + ${escapeHtml(errors.join(';'))} +
`; + return; + } + + const rows = (data.rows || []).slice(0, 8); + if (!rows.length) { + resultBox.innerHTML = `
+ + CSV 裡沒有可檢查的資料列。 +
`; + 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 `
+
+

${escapeHtml(row.title || '未命名商品')}

+

+ 第 ${escapeHtml(row.row_number || '-')} 列 · ${escapeHtml(row.source_code || '未填來源')} · ${escapeHtml(price)} +

+

${escapeHtml(reasons)}

+
+ ${escapeHtml(row.status_label || '待確認')} +
`; + }).join(''); +} + // ── 競品比價表格(熱力圖底色)────────────────────── function renderCompetitorTable(rows) { const tbody = document.getElementById('competitorTbody'); diff --git a/tests/test_external_market_offer_service.py b/tests/test_external_market_offer_service.py index 0e41f64..10aba98 100644 --- a/tests/test_external_market_offer_service.py +++ b/tests/test_external_market_offer_service.py @@ -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 diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index 8f36470..7f123af 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -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