fix: sanitize import and operations UI copy
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>選擇條件", "點擊類別查看詳情", "點擊篩選此廠商商品"],
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user