From b17493f24588279c58a4d44b4b2b6ac40df48783 Mon Sep 17 00:00:00 2001 From: ogt Date: Thu, 25 Jun 2026 14:28:20 +0800 Subject: [PATCH] fix: sanitize import and operations UI copy --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 + routes/auto_import_routes.py | 76 +++++++++---- routes/import_routes.py | 27 +++-- services/import_service.py | 113 +++++++++++++++++-- templates/auto_import_index.html | 8 +- templates/notification_templates.html | 81 ++++++------- templates/system_settings.html | 16 +-- templates/vendor_stockout_index_v2.html | 6 +- tests/test_auto_import_failure_boundaries.py | 33 +++++- tests/test_pchome_revenue_growth_service.py | 7 ++ web/static/js/page-auto-import.js | 2 +- 12 files changed, 277 insertions(+), 95 deletions(-) diff --git a/config.py b/config.py index fcc270e..ffc4fe0 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.674" +SYSTEM_VERSION = "V10.675" 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 cabe3f2..3691885 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -751,3 +751,4 @@ POSTGRES_HOST=momo-db | 2026-06-25 | 待確認候選必須能一眼比對雙平台賣場 | V10.672 起 MOMO 待確認候選回傳 PChome/MOMO 兩個賣場連結與白話檢核點,前台改成雙欄比對並提供「同時開兩個賣場」,不再顯示 `variant_selection_review` 等工程 matcher tag。 | | 2026-06-25 | 待確認候選 API 不可回傳 raw matcher code | V10.673 起 `match_reasons` 相容欄位也改回白話理由,避免前台或檢視 payload 時再次看到 `variant_selection_review`、`focused_exact_identity_*` 等工程代碼。 | | 2026-06-25 | 共用成長流程列手機版不可溢出畫面 | V10.674 起全站 `momo-growth-rail` 在手機寬度改為換行呈現,避免「評估 / 分析 / 建議 / 解法 / 治理」流程 chip 超出視覺邊界。 | +| 2026-06-25 | 匯入、缺貨、設定與通知模板頁不可外露 SQL / 資料表 / 模板代碼 | V10.675 起匯入 job API 對前台回傳白話處置訊息,保留 raw error 於 DB/log;自動匯入失敗通知不再顯示 `psycopg2`、`daily_sales_snapshot`、`snapshot_date` 等內部字串;缺貨首頁、系統設定與通知模板列表改用營運語言,並補上逾時匯入任務重置與取消 API。 | diff --git a/routes/auto_import_routes.py b/routes/auto_import_routes.py index c4b1ffd..86e110a 100644 --- a/routes/auto_import_routes.py +++ b/routes/auto_import_routes.py @@ -6,7 +6,7 @@ Google Drive 自動匯入路由 from flask import Blueprint, render_template, jsonify, request import logging -from services.import_service import import_service +from services.import_service import humanize_import_error, import_service from services.google_drive_service import drive_service # 建立 Blueprint @@ -33,11 +33,11 @@ def get_import_jobs(): 'data': jobs }) - except Exception as e: - logger.error(f"取得匯入任務清單失敗: {str(e)}") + except Exception: + logger.exception("取得匯入任務清單失敗") return jsonify({ 'success': False, - 'message': f'取得匯入任務清單失敗: {str(e)}' + 'message': '取得匯入任務清單失敗,請稍後再試。' }), 500 @@ -58,11 +58,11 @@ def get_import_job(job_id): 'message': '找不到該任務' }), 404 - except Exception as e: - logger.error(f"取得匯入任務狀態失敗: {str(e)}") + except Exception: + logger.exception("取得匯入任務狀態失敗") return jsonify({ 'success': False, - 'message': f'取得匯入任務狀態失敗: {str(e)}' + 'message': '取得匯入任務狀態失敗,請稍後再試。' }), 500 @@ -81,11 +81,11 @@ def get_import_config(): } }) - except Exception as e: - logger.error(f"取得匯入配置失敗: {str(e)}") + except Exception: + logger.exception("取得匯入配置失敗") return jsonify({ 'success': False, - 'message': f'取得匯入配置失敗: {str(e)}' + 'message': '取得匯入配置失敗,請稍後再試。' }), 500 @@ -118,11 +118,11 @@ def set_import_config(): 'message': '配置已更新' }) - except Exception as e: - logger.error(f"設定匯入配置失敗: {str(e)}") + except Exception: + logger.exception("設定匯入配置失敗") return jsonify({ 'success': False, - 'message': f'設定匯入配置失敗: {str(e)}' + 'message': '設定匯入配置失敗,請稍後再試。' }), 500 @@ -143,10 +143,10 @@ def test_drive_connection(): }), 400 except Exception as e: - logger.error(f"測試 Google Drive 連接失敗: {str(e)}") + logger.exception("測試 Google Drive 連接失敗") return jsonify({ 'success': False, - 'message': f'測試連接失敗: {str(e)}' + 'message': f'測試連接失敗。{humanize_import_error(e)}' }), 500 @@ -167,10 +167,10 @@ def list_drive_files(): }) except Exception as e: - logger.error(f"列出 Google Drive 檔案失敗: {str(e)}") + logger.exception("列出 Google Drive 檔案失敗") return jsonify({ 'success': False, - 'message': f'列出檔案失敗: {str(e)}' + 'message': f'列出檔案失敗。{humanize_import_error(e)}' }), 500 @@ -183,8 +183,46 @@ def manual_import(): return jsonify(result) except Exception as e: - logger.error(f"手動匯入失敗: {str(e)}") + logger.exception("手動匯入失敗") return jsonify({ 'success': False, - 'message': f'手動匯入失敗: {str(e)}' + 'message': f'手動匯入失敗。{humanize_import_error(e)}' + }), 500 + + +@auto_import_bp.route('/api/reset_stuck_jobs', methods=['POST']) +def reset_stuck_jobs(): + """重置逾時未完成的匯入任務""" + try: + count = import_service.reset_stuck_jobs(older_than_minutes=60) + return jsonify({ + 'success': True, + 'message': f'已重置 {count} 筆逾時任務,可重新執行匯入。' + }) + except Exception: + logger.exception("重置逾時匯入任務失敗") + return jsonify({ + 'success': False, + 'message': '重置失敗,請稍後再試或通知維護人員。' + }), 500 + + +@auto_import_bp.route('/api/import_jobs//fail', methods=['POST']) +def fail_import_job(job_id): + """取消單一匯入任務""" + try: + if import_service.fail_job(job_id): + return jsonify({ + 'success': True, + 'message': '任務已取消,可重新執行匯入。' + }) + return jsonify({ + 'success': False, + 'message': '找不到該任務' + }), 404 + except Exception: + logger.exception("取消匯入任務失敗") + return jsonify({ + 'success': False, + 'message': '取消失敗,請稍後再試或通知維護人員。' }), 500 diff --git a/routes/import_routes.py b/routes/import_routes.py index 268ff13..34269d0 100644 --- a/routes/import_routes.py +++ b/routes/import_routes.py @@ -62,7 +62,7 @@ def _safe_read_sql(table_name, engine): @login_required def import_excel(): """ - API: 匯入 Excel/CSV 並自動建表 + API: 匯入 Excel/CSV 並整理為可分析資料 已加入檔案上傳安全驗證 (副檔名白名單、檔案名稱清理) """ try: @@ -88,7 +88,8 @@ def import_excel(): try: df = pd.read_excel(file, engine='openpyxl', dtype=str) except Exception as e: - return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500 + sys_log.error(f"[Web] [Import] Excel 讀取失敗: {e}") + return jsonify({'status': 'error', 'message': 'Excel 讀取失敗,請確認檔案格式後重試。'}), 500 elif filename_lower.endswith('.csv'): try: try: @@ -97,7 +98,8 @@ def import_excel(): file.seek(0) df = pd.read_csv(file, encoding='big5', dtype=str) except Exception as e: - return jsonify({'status': 'error', 'message': f'CSV 讀取失敗: {str(e)}'}), 500 + sys_log.error(f"[Web] [Import] CSV 讀取失敗: {e}") + return jsonify({'status': 'error', 'message': 'CSV 讀取失敗,請確認檔案格式後重試。'}), 500 else: return jsonify({'status': 'error', 'message': '不支援的檔案格式'}), 400 @@ -158,12 +160,12 @@ def import_excel(): try: inspector = inspect(engine) if not inspector.has_table(table_name): - sys_log.info(f"[Web] [Import] 資料表不存在,建立新表: {table_name}") + sys_log.info(f"[Web] [Import] 首次建立營運資料集: {table_name}") df.to_sql(table_name, con=engine, if_exists='replace', index=False) rows_imported = len(df) - message = f'匯入成功!已建立新資料表並寫入 {rows_imported} 筆資料。' + message = f'匯入成功!已更新 {rows_imported} 筆業績資料。' else: - sys_log.info(f"[Web] [Import] 資料表已存在,執行自動去重 (Deduplication)...") + sys_log.info(f"[Web] [Import] 營運資料集已存在,執行自動去重。") # 讀取現有資料 try: @@ -225,7 +227,7 @@ def import_excel(): message = f'匯入成功!已去重並新增 {rows_imported} 筆資料。' else: rows_imported = 0 - message = '匯入完成,但所有資料皆已存在 (重複),無新增數據。' + message = '匯入完成,本批資料已存在,沒有新增資料。' clear_sales_cache_for_table(table_name) if table_name == 'realtime_sales_monthly': @@ -240,10 +242,10 @@ def import_excel(): except Exception as de: sys_log.error(f"[Web] [Import] 業績報表匯入去重或寫入時發生錯誤: {de}") - return jsonify({'status': 'error', 'message': f'業績報表匯入失敗: {de}'}), 500 + return jsonify({'status': 'error', 'message': '業績報表匯入失敗,請確認檔案格式後重試。'}), 500 else: # 對於非業績報表,維持覆蓋邏輯 - sys_log.info(f"[Web] [Import] 使用覆蓋模式 (replace)寫入資料表: {table_name}") + sys_log.info(f"[Web] [Import] 使用覆蓋模式更新營運資料集: {table_name}") df.to_sql(table_name, con=engine, if_exists='replace', index=False) clear_sales_cache_for_table(table_name) @@ -257,14 +259,14 @@ def import_excel(): return jsonify({ 'status': 'success', - 'message': f'通用匯入成功!資料已覆蓋至 {table_name}。', + 'message': f'匯入成功!已整理 {len(df)} 筆營運資料。', 'rows': len(df), 'table': table_name }) except Exception as e: sys_log.error(f"[Web] [Import] 檔案匯入發生嚴重錯誤 | Error: {str(e)}") - return jsonify({'status': 'error', 'message': f'檔案匯入失敗: {str(e)}'}), 500 + return jsonify({'status': 'error', 'message': '檔案匯入失敗,請確認檔案格式後重試。'}), 500 @import_bp.route('/api/import/monthly_summary', methods=['POST']) @@ -285,7 +287,8 @@ def import_monthly_summary(): try: df = pd.read_excel(file, engine='openpyxl') except Exception as e: - return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500 + sys_log.error(f"[Web] [MonthlyImport] Excel 讀取失敗: {e}") + return jsonify({'status': 'error', 'message': 'Excel 讀取失敗,請確認檔案格式後重試。'}), 500 if df.empty: return jsonify({'status': 'error', 'message': '檔案內容為空'}), 400 diff --git a/services/import_service.py b/services/import_service.py index 5fb49cb..9920c9c 100644 --- a/services/import_service.py +++ b/services/import_service.py @@ -8,7 +8,8 @@ import logging import json import os -from datetime import date, datetime +import re +from datetime import date, datetime, timedelta from typing import Any, Dict, Optional import pandas as pd @@ -34,6 +35,51 @@ from database.manager import ensure_metadata_initialized # 設定日誌 logger = logging.getLogger(__name__) +_INTERNAL_ERROR_MARKERS = ( + "psycopg2", + "sqlalchemy", + "undefinedfunction", + "traceback", + "daily_sales_snapshot", + "realtime_sales_monthly", + "snapshot_date", + "import_jobs", + "::date", +) +_SNAKE_CASE_RE = re.compile(r"\b[a-z][a-z0-9]+(?:_[a-z0-9]+){1,}\b") + + +def humanize_import_error(message: Any) -> str: + """Return operator-facing import error text without SQL/table internals.""" + raw = str(message or "").strip() + if not raw: + return "" + + lowered = raw.lower() + if "could not locate runnable browser" in lowered or "reauthorization_required" in lowered: + return "Google Drive 授權需要重新確認;請重新完成雲端授權後再執行匯入。" + if "token_store_failed" in lowered or ("permission" in lowered and "google" in lowered): + return "Google Drive 授權無法穩定保存;請通知維護人員確認主機授權檔權限,避免重啟後再次失效。" + if "google drive" in lowered or "credentials" in lowered or "authenticate" in lowered or "token" in lowered: + return "Google Drive 連線或授權異常;請確認雲端資料夾權限後再執行匯入。" + if "欄位" in raw or "excel" in lowered: + return "匯入檔案格式與業績報表不一致;請確認檔案包含日期、商品與業績金額後重新匯入。" + if "monthly sync" in lowered or any(marker in lowered for marker in _INTERNAL_ERROR_MARKERS): + return "業績資料處理未完整完成;請重新匯入最新檔案,若重複失敗請通知維護人員檢查同步流程。" + if _SNAKE_CASE_RE.search(raw): + return "匯入處理發生系統異常;請重新匯入最新檔案,若重複失敗請通知維護人員。" + + compact = " ".join(raw.split()) + return compact[:180] + ("…" if len(compact) > 180 else "") + + +def _public_import_job_payload(job: ImportJob) -> Dict[str, Any]: + payload = job.to_dict() + display_error = humanize_import_error(payload.get("error_message")) + payload["error_message"] = display_error + payload["display_error_message"] = display_error + return payload + def _build_in_clause(prefix: str, values) -> tuple: """Build a SQLAlchemy-safe IN clause placeholder list and params.""" @@ -556,7 +602,7 @@ class ImportService: try: job = session.query(ImportJob).filter_by(id=job_id).first() if job: - return job.to_dict() + return _public_import_job_payload(job) return None finally: session.close() @@ -577,7 +623,55 @@ class ImportService: ImportJob.created_at.desc() ).limit(limit).all() - return [job.to_dict() for job in jobs] + return [_public_import_job_payload(job) for job in jobs] + finally: + session.close() + + def reset_stuck_jobs(self, older_than_minutes: int = 60) -> int: + """Mark long-running import jobs as failed so operators can retry cleanly.""" + cutoff = datetime.now(TAIPEI_TZ).replace(tzinfo=None) - timedelta(minutes=older_than_minutes) + session = Session() + try: + jobs = session.query(ImportJob).filter( + ImportJob.status.in_(["pending", "downloading", "importing"]), + ( + (ImportJob.started_at.isnot(None) & (ImportJob.started_at < cutoff)) + | (ImportJob.started_at.is_(None) & (ImportJob.created_at < cutoff)) + ) + ).all() + for job in jobs: + job.status = "failed" + job.progress_percent = 100 + job.current_step = "已重置,請重新匯入" + job.error_message = "任務逾時未完成,已重置;請重新執行匯入。" + job.completed_at = datetime.now(TAIPEI_TZ).replace(tzinfo=None) + session.commit() + return len(jobs) + except Exception: + session.rollback() + logger.error("重置卡住匯入任務失敗", exc_info=True) + raise + finally: + session.close() + + def fail_job(self, job_id: int) -> bool: + """Cancel a running import job from the operator UI.""" + session = Session() + try: + job = session.query(ImportJob).filter_by(id=job_id).first() + if not job: + return False + job.status = "failed" + job.progress_percent = max(float(job.progress_percent or 0), 100) + job.current_step = "已取消" + job.error_message = "任務已由操作員取消;如需更新資料請重新匯入。" + job.completed_at = datetime.now(TAIPEI_TZ).replace(tzinfo=None) + session.commit() + return True + except Exception: + session.rollback() + logger.error("取消匯入任務失敗: %s", job_id, exc_info=True) + raise finally: session.close() @@ -986,9 +1080,10 @@ class ImportService: drive_error = getattr(drive_service, "last_error", None) if drive_error_kind: + public_error = humanize_import_error(drive_error or drive_error_kind) message = ( - "Google Drive 連線或認證失敗,未能確認來源資料夾是否有新檔案:" - f"{drive_error or drive_error_kind}" + "Google Drive 連線或認證失敗,未能確認來源資料夾是否有新檔案。" + f"{public_error}" ) logger.error(message) return { @@ -1178,9 +1273,10 @@ class ImportService: if failed_files: first_error = failed_files[0].get('error') or '未知錯誤' + public_error = humanize_import_error(first_error) or "請重新匯入最新檔案。" message = ( f'找到 {len(files)} 個檔案,成功匯入 {imported_count} 個,' - f'失敗 {len(failed_files)} 個。首筆錯誤:{first_error[:220]}' + f'失敗 {len(failed_files)} 個。{public_error}' ) else: message = f'成功匯入 {imported_count} 個檔案' @@ -1221,9 +1317,10 @@ class ImportService: if is_connection_error: # Drive 連線 / 認證錯誤不是「無檔案」,必須 fail-closed 才能觸發告警與人工補件。 logger.error(f"Google Drive 連線問題,無法確認待匯入檔案: {error_msg}") + public_error = humanize_import_error(error_msg) return { 'success': False, - 'message': f'Google Drive 連線問題,無法確認待匯入檔案: {error_msg}', + 'message': f'Google Drive 連線問題,無法確認待匯入檔案。{public_error}', 'file_count': 0, 'imported_count': 0, 'failed_count': 1, @@ -1233,7 +1330,7 @@ class ImportService: # 真正的匯入錯誤:返回失敗 return { 'success': False, - 'message': f'自動匯入失敗: {error_msg}', + 'message': f'自動匯入失敗:{humanize_import_error(error_msg)}', 'file_count': 0, 'imported_count': 0 } diff --git a/templates/auto_import_index.html b/templates/auto_import_index.html index 6c797a1..c807624 100644 --- a/templates/auto_import_index.html +++ b/templates/auto_import_index.html @@ -135,14 +135,14 @@ - + - + @@ -274,8 +274,8 @@ tbody.innerHTML = ''; jobs.forEach(job => { const tr = document.createElement('tr'); - const fileName = job.drive_file_name || 'N/A'; - const errorMsg = job.error_message || '—'; + const fileName = job.drive_file_name || '未記錄檔名'; + const errorMsg = job.display_error_message || job.error_message || '—'; const isRunning = job.status === 'downloading' || job.status === 'importing'; tr.innerHTML = ` diff --git a/templates/notification_templates.html b/templates/notification_templates.html index d4ca477..3751407 100644 --- a/templates/notification_templates.html +++ b/templates/notification_templates.html @@ -24,8 +24,7 @@ - - + @@ -33,7 +32,7 @@ - +
ID任務 檔案名稱 狀態 進度 成功 / 總 開始時間 完成時間錯誤訊息處置提醒 操作
狀態代碼名稱通知名稱 分類 渠道 預覽
載入中...
載入中...
@@ -55,12 +54,8 @@
-
- - -
-
- +
+
@@ -335,12 +330,11 @@ } .notification-template-table tbody td:nth-child(1)::before { content: "狀態"; } - .notification-template-table tbody td:nth-child(2)::before { content: "代碼"; } - .notification-template-table tbody td:nth-child(3)::before { content: "名稱"; } - .notification-template-table tbody td:nth-child(4)::before { content: "分類"; } - .notification-template-table tbody td:nth-child(5)::before { content: "渠道"; } - .notification-template-table tbody td:nth-child(6)::before { content: "預覽"; } - .notification-template-table tbody td:nth-child(7)::before { content: "操作"; } + .notification-template-table tbody td:nth-child(2)::before { content: "通知"; } + .notification-template-table tbody td:nth-child(3)::before { content: "分類"; } + .notification-template-table tbody td:nth-child(4)::before { content: "渠道"; } + .notification-template-table tbody td:nth-child(5)::before { content: "預覽"; } + .notification-template-table tbody td:nth-child(6)::before { content: "操作"; } .notification-template-table tbody td[colspan] { display: block; @@ -361,6 +355,33 @@ let templates = []; let categories = []; +const SAMPLE_TEMPLATE_VALUES = { + usage_percent: '87.5', free_gb: '12.3', total_gb: '100', + new_usage_percent: '75.2', results: '已完成清理並釋放空間', + status: '需確認', database: '資料連線需確認', deployment: '主系統', + issues: '網站憑證將於 10 天後到期', error: '找不到最新備份檔案', + project: 'PChome 業績成長系統', branch: '正式版', pipeline_id: '123', + commit_message: '更新業績流程', author: '系統管理員', duration: '2 分 30 秒', + url: 'https://mo.wooo.work', + date: '2026-01-25', app_status: '正常', backup_status: '正常', + crawler_status: '需檢查', last_backup: '最新備份已建立', + week_start: '2026-01-20', week_end: '2026-01-26', total_sales: '1,234,567', + order_count: '456', growth_rate: '15.2', + month: '二月', prev_month: '一月' +}; + +function publicTemplateText(text) { + let output = String(text || ''); + for (const [key, value] of Object.entries(SAMPLE_TEMPLATE_VALUES)) { + output = output.replace(new RegExp(`\\{${key}\\}`, 'g'), value); + } + return output + .replace(/CI\/CD Pipeline SUCCESS/g, '部署流程成功') + .replace(/CI\/CD Pipeline FAILED/g, '部署流程失敗') + .replace(/Pipeline/g, '部署流程') + .replace(/Commit/g, '更新內容'); +} + document.addEventListener('DOMContentLoaded', function() { loadTemplates(); @@ -411,24 +432,24 @@ function renderTemplates() { const tbody = document.getElementById('templateList'); if (filtered.length === 0) { - tbody.innerHTML = '沒有模板'; + tbody.innerHTML = '沒有模板'; return; } tbody.innerHTML = filtered.map(t => { const catName = categories.find(c => c.code === t.category)?.name || t.category; - const preview = `${t.emoji_prefix || ''} ${t.title || ''}\n${t.body || ''}`.substring(0, 80); + const preview = publicTemplateText(`${t.emoji_prefix || ''} ${t.title || ''}\n${t.body || ''}`).substring(0, 96); + const channelLabel = t.channel === 'telegram' ? 'Telegram' : t.channel === 'line' ? 'LINE' : '雙渠道'; return ` - ${t.is_active ? 'ON' : 'OFF'} + ${t.is_active ? '啟用' : '停用'} - ${t.code} - ${t.name} + ${escapeHtml(t.name || '未命名通知')} ${catName} - ${t.channel === 'telegram' ? '📱 TG' : t.channel === 'line' ? '💬 LINE' : '📱💬'} + ${channelLabel} ${escapeHtml(preview)}...
@@ -209,7 +209,7 @@ function uploadSalesReport() { } if (!file.name.includes('即時業績') || !file.name.includes('全月')) { - if (!confirm('檔案名稱似乎不符合「即時業績(全月)」的格式,確定要繼續匯入嗎?\n系統將嘗試根據檔名建立資料表。')) { + if (!confirm('檔案名稱似乎不符合「即時業績(全月)」的格式,確定要繼續匯入嗎?\n系統會嘗試辨識檔案內容並更新業績資料。')) { return; } } else if (!confirm('確定要匯入此份業績報表嗎?\n匯入後會更新月度業績判斷,供成長分析與報表使用。')) { @@ -235,7 +235,7 @@ function uploadSalesReport() { .then(data => { if (data.status === 'success') { if (data.table === 'realtime_sales_monthly') { - alert('業績報表匯入成功!\n資料表: ' + data.table + '\n共 ' + data.rows + ' 筆資料已累加寫入。'); + alert('業績報表匯入成功!\n共 ' + data.rows + ' 筆資料已更新,可回到分析頁確認結果。'); } else { alert('匯入操作完成。\n系統偵測到資料落點與預期不同,請確認月度分析是否已更新。\n共寫入 ' + data.rows + ' 筆資料。'); } @@ -263,7 +263,7 @@ function uploadExcel() { return; } - if (!confirm('確定要匯入嗎?\n這將會建立一張新的資料表 (若名稱相同則會覆蓋)。')) { + if (!confirm('確定要匯入嗎?\n系統會整理成可分析的營運資料集。')) { return; } @@ -285,7 +285,7 @@ function uploadExcel() { .then(response => response.json()) .then(data => { if (data.status === 'success') { - alert('匯入成功!\n已建立資料表: ' + data.table + '\n共寫入 ' + data.rows + ' 筆資料。'); + alert('匯入成功!\n共寫入 ' + data.rows + ' 筆資料,可回到分析頁確認結果。'); fileInput.value = ''; } else { alert('匯入失敗: ' + data.message); diff --git a/templates/vendor_stockout_index_v2.html b/templates/vendor_stockout_index_v2.html index 4fea56e..fab1bd5 100644 --- a/templates/vendor_stockout_index_v2.html +++ b/templates/vendor_stockout_index_v2.html @@ -127,7 +127,7 @@
資料狀態摘要 - 來源:vendor_stockout / vendor_list / vendor_emails / email_send_log + 整合缺貨、窗口與通知紀錄
@@ -157,11 +157,11 @@
{{ stats.latest_email_status or '未標記狀態' }}
{{ stats.latest_email_time.strftime('%Y-%m-%d %H:%M') }}
- vendor_id {{ stats.latest_email_vendor_id }} + 廠商編號 {{ stats.latest_email_vendor_id }}
{% else %}
尚無郵件紀錄
-
email_send_log 目前沒有資料
+
目前尚無通知紀錄
{% endif %}
diff --git a/tests/test_auto_import_failure_boundaries.py b/tests/test_auto_import_failure_boundaries.py index c365d54..22dbb8c 100644 --- a/tests/test_auto_import_failure_boundaries.py +++ b/tests/test_auto_import_failure_boundaries.py @@ -168,7 +168,38 @@ def test_auto_import_fails_closed_when_drive_auth_fails(monkeypatch, tmp_path): assert result["connection_error"] is True assert result["error_kind"] == "authentication_failed" assert "Google Drive" in result["message"] - assert "could not locate runnable browser" in result["message"] + assert "重新完成雲端授權" in result["message"] + assert "could not locate runnable browser" not in result["message"] + + +def test_import_job_public_payload_hides_database_error(monkeypatch, tmp_path): + import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}") + import_service.Base.metadata.create_all(import_service.engine) + + service = import_service.ImportService() + job_id = service.create_import_job("daily_sales", "drive-file-1", "daily.xlsx", 1024) + raw_error = ( + "(psycopg2.errors.UndefinedFunction) operator does not exist: " + "daily_sales_snapshot.snapshot_date >= realtime_sales_monthly.snapshot_date" + ) + service.update_job_status(job_id, "failed", 100, "業績分析儀表板同步失敗", raw_error) + + public_job = service.get_job_status(job_id) + public_error = public_job["error_message"] + + assert public_job["display_error_message"] == public_error + assert "重新匯入最新檔案" in public_error + assert "psycopg2" not in public_error + assert "daily_sales_snapshot" not in public_error + assert "realtime_sales_monthly" not in public_error + assert "snapshot_date" not in public_error + + session = import_service.Session() + try: + stored = session.query(import_service.ImportJob).filter_by(id=job_id).one() + assert raw_error in stored.error_message + finally: + session.close() def test_google_drive_auth_refuses_browser_without_json_token(monkeypatch, tmp_path): diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index a12e031..b2e42e0 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -862,6 +862,11 @@ def test_governance_and_low_frequency_pages_avoid_engineering_status_copy(): "匯入資料庫", "刪除雲端原檔", "資料表:", + "資料表:", + "vendor_stockout / vendor_list", + "vendor_id {{", + "email_send_log", + "模板代碼", ] legacy_paths = [ @@ -913,6 +918,8 @@ def test_visible_operations_pages_hide_internal_runtime_terms(): forbidden_by_path = { "templates/ai_recommend.html": ["權杖:", "AI 模型"], "templates/vendor_stockout_index_v2.html": ["資料庫目前沒有缺貨資料"], + "templates/system_settings.html": ["資料表:", "資料表:", "自動建表", "匯入並建立通用資料表"], + "templates/notification_templates.html": ["模板代碼", "${t.code}", "CI/CD Pipeline SUCCESS"], "templates/dashboard_v2.html": ["尚無 AI 挑品", "挑品 Agent"], "templates/daily_sales.html": ["左右滑動查看完整圖表", "左右滑動查看完整列表"], "templates/sales_analysis.html": ["提示:選擇條件", "點擊類別查看詳情", "點擊篩選此廠商商品"], diff --git a/web/static/js/page-auto-import.js b/web/static/js/page-auto-import.js index bf9a431..d2c2e96 100644 --- a/web/static/js/page-auto-import.js +++ b/web/static/js/page-auto-import.js @@ -285,7 +285,7 @@ if (result.status === 'success') { showAlert('uploadAlert', 'success', `${result.message}
` + - `資料表: ${result.table} | 共 ${result.rows} 筆資料` + `共更新 ${result.rows} 筆業績資料` ); fileInput.value = ''; setTimeout(() => loadJobs(), 1000);