fix: sanitize import and operations UI copy
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
ogt
2026-06-25 14:28:20 +08:00
parent dc154a01b1
commit b17493f245
12 changed files with 277 additions and 95 deletions

View File

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

View File

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

View File

@@ -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/<int:job_id>/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

View File

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

View File

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

View File

@@ -135,14 +135,14 @@
<table class="ai-jobtable" id="jobsTable">
<thead>
<tr>
<th>ID</th>
<th>任務</th>
<th>檔案名稱</th>
<th>狀態</th>
<th>進度</th>
<th>成功 / 總</th>
<th>開始時間</th>
<th>完成時間</th>
<th>錯誤訊息</th>
<th>處置提醒</th>
<th>操作</th>
</tr>
</thead>
@@ -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 = `

View File

@@ -24,8 +24,7 @@
<thead class="notification-table-head">
<tr>
<th style="width: 40px;">狀態</th>
<th style="width: 150px;">代碼</th>
<th style="width: 200px;">名稱</th>
<th style="width: 220px;">通知名稱</th>
<th style="width: 100px;">分類</th>
<th style="width: 80px;">渠道</th>
<th>預覽</th>
@@ -33,7 +32,7 @@
</tr>
</thead>
<tbody id="templateList">
<tr><td colspan="7" class="notification-empty">載入中...</td></tr>
<tr><td colspan="6" class="notification-empty">載入中...</td></tr>
</tbody>
</table>
</div>
@@ -55,12 +54,8 @@
<form id="editForm">
<input type="hidden" id="editCode">
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">模板代碼</label>
<input type="text" class="form-control" id="editCodeDisplay" readonly>
</div>
<div class="col-md-6">
<label class="form-label">名稱</label>
<div class="col-12">
<label class="form-label">通知名稱</label>
<input type="text" class="form-control" id="editName" required>
</div>
</div>
@@ -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 = '<tr><td colspan="7" class="notification-empty">沒有模板</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="notification-empty">沒有模板</td></tr>';
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 `
<tr>
<td>
<span class="notification-status-badge ${t.is_active ? 'is-active' : 'is-inactive'}">
${t.is_active ? 'ON' : 'OFF'}
${t.is_active ? '啟用' : '停用'}
</span>
</td>
<td><code>${t.code}</code></td>
<td>${t.name}</td>
<td>${escapeHtml(t.name || '未命名通知')}</td>
<td><span class="notification-category-badge">${catName}</span></td>
<td>${t.channel === 'telegram' ? '📱 TG' : t.channel === 'line' ? '💬 LINE' : '📱💬'}</td>
<td>${channelLabel}</td>
<td class="template-preview">${escapeHtml(preview)}...</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="editTemplate('${t.code}')">
@@ -451,7 +472,6 @@ function editTemplate(code) {
if (!template) return;
document.getElementById('editCode').value = template.code;
document.getElementById('editCodeDisplay').value = template.code;
document.getElementById('editName').value = template.name || '';
document.getElementById('editEmoji').value = template.emoji_prefix || '';
document.getElementById('editChannel').value = template.channel || 'telegram';
@@ -470,25 +490,10 @@ async function previewTemplate() {
const body = document.getElementById('editBody').value;
// 本地預覽(使用範例變數)
const sampleVars = {
usage_percent: '87.5', free_gb: '12.3', total_gb: '100',
new_usage_percent: '75.2', results: '• Docker 清理完成\n• 日誌輪轉完成',
status: 'unhealthy', database: 'disconnected', deployment: 'momo-app',
issues: '⏳ mo.wooo.work: 10 天後到期', error: '找不到備份檔案',
project: 'momo-pro-system', branch: 'main', pipeline_id: '123',
commit_message: 'feat: 新增功能', author: 'Developer', duration: '2m 30s',
url: 'http://192.168.0.110:8929/...',
date: '2026-01-25', app_status: '✅ 正常', backup_status: '✅ 正常',
crawler_status: '⚠️ 需檢查', last_backup: 'backup_20260125.zip',
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: '一月'
};
let preview = `${emoji} *${title}*\n\n${body}`;
// 替換變數
for (const [key, value] of Object.entries(sampleVars)) {
for (const [key, value] of Object.entries(SAMPLE_TEMPLATE_VALUES)) {
preview = preview.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
}

View File

@@ -113,7 +113,7 @@
<div class="import-panel">
<div class="row align-items-center g-3">
<div class="col-md-9">
<h6 class="fw-bold mb-1">月份業績匯總分析 (42 欄位版)</h6>
<h6 class="fw-bold mb-1">月份業績匯總分析(標準版)</h6>
<p class="text-muted small mb-3">匯入月結業績,更新成長、毛利與品類結構判斷。</p>
<input class="form-control" type="file" id="monthlySummaryFile" accept=".xlsx, .xls">
</div>
@@ -151,15 +151,15 @@
</div>
<div class="table-container">
<h5 class="mb-3">一般 Excel 匯入 (自動建表)</h5>
<h5 class="mb-3">一般 Excel 匯入</h5>
<div class="row align-items-end g-3">
<div class="col-md-8">
<label for="excelFile" class="form-label text-muted small">匯入指定 Excel補齊分析或營運需要的資料</label>
<label for="excelFile" class="form-label text-muted small">匯入指定 Excel補齊分析或營運需要的資料</label>
<input class="form-control" type="file" id="excelFile" accept=".xlsx, .xls">
</div>
<div class="col-md-4">
<button class="btn btn-primary w-100" onclick="uploadExcel()">
<i class="fas fa-table me-2"></i>匯入並建立通用資料
<i class="fas fa-file-import me-2"></i>匯入營運資料
</button>
</div>
</div>
@@ -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);

View File

@@ -127,7 +127,7 @@
<section class="vendor-table-card" aria-label="資料狀態摘要">
<div class="vendor-table-head">
<span class="vendor-table-title">資料狀態摘要</span>
<span class="vendor-kpi-sub momo-mono">來源vendor_stockout / vendor_list / vendor_emails / email_send_log</span>
<span class="vendor-kpi-sub momo-mono">整合缺貨、窗口與通知紀錄</span>
</div>
<div class="vendor-summary-grid">
<div class="vendor-summary-item">
@@ -157,11 +157,11 @@
<div class="vendor-summary-title momo-mono">{{ stats.latest_email_status or '未標記狀態' }}</div>
<div class="vendor-summary-meta momo-mono">
{{ stats.latest_email_time.strftime('%Y-%m-%d %H:%M') }}<br>
vendor_id {{ stats.latest_email_vendor_id }}
廠商編號 {{ stats.latest_email_vendor_id }}
</div>
{% else %}
<div class="vendor-summary-title">尚無郵件紀錄</div>
<div class="vendor-summary-meta momo-mono">email_send_log 目前沒有資料</div>
<div class="vendor-summary-meta momo-mono">目前尚無通知紀錄</div>
{% endif %}
</div>
</div>

View File

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

View File

@@ -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": ["模板代碼", "<code>${t.code}</code>", "CI/CD Pipeline SUCCESS"],
"templates/dashboard_v2.html": ["尚無 AI 挑品", "挑品 Agent"],
"templates/daily_sales.html": ["左右滑動查看完整圖表", "左右滑動查看完整列表"],
"templates/sales_analysis.html": ["提示:</strong>選擇條件", "點擊類別查看詳情", "點擊篩選此廠商商品"],

View File

@@ -285,7 +285,7 @@
if (result.status === 'success') {
showAlert('uploadAlert', 'success',
`<i class="fas fa-check-circle me-2"></i>${result.message}<br>` +
`<small>資料表: ${result.table} | ${result.rows} 筆資料</small>`
`<small>共更新 ${result.rows}業績資料</small>`
);
fileInput.value = '';
setTimeout(() => loadJobs(), 1000);