Files
ewoooc/app.py.backup_login_required
ogt 1b4f3a7bbe
Some checks failed
CD Pipeline / deploy (push) Failing after 59s
feat: EwoooC 初始化 — 完整專案推版至 Gitea
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml)
- 部署模式: rsync Python 檔案至 188 → docker restart (volume mount)
- Dockerfile/requirements 變動時自動重建 Docker image
- 部署通知: Telegram (開始/成功/失敗)
- 健康檢查: https://mo.wooo.work/health (最多 5 次重試)
- 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 01:21:13 +08:00

7509 lines
353 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ================= TODO LIST (待辦事項 - 重開機後請依序執行) =================
# 1. [驗證] 重啟 app.py 後,重新匯入 Excel確認「自動去重」功能是否生效 (重複匯入應顯示 0 筆新增)。
# 2. [檢查] 前往 /sales_analysis 頁面,確認 '狀態' 欄位是否正確顯示 'F' (目前為原始匯入模式)。
# 3. [決策] 若資料顯示正常,評估是否需要恢復「智慧資料清理」邏輯 (目前程式碼第 1160 行左右已註解)。
# 4. [備份] 確認系統運作正常後,執行系統備份。
# 5. [部署] UAT 驗證通過後,記得執行 `gcloud app deploy` 更新 GCP 正式環境 (Project: momo-pro-system)。
# =======================================================================
import os
import sys
import time
import threading
import math
import json
import hashlib
import shutil
import zipfile
import re
import io # V-New: 用於 Excel 匯出
from datetime import datetime, timedelta, timezone
# ================= 🔧 1. 環境與路徑鎖定 =================
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 確保專案根目錄在 sys.path 的最前面,優先讀取本地模組
sys.path.insert(0, BASE_DIR)
# 自動檢核並建立必要目錄
try:
for folder in ['database', 'services', 'crawler', 'logs', 'data', 'web/templates', 'web/static']:
folder_path = os.path.join(BASE_DIR, folder)
if not os.path.exists(folder_path):
os.makedirs(folder_path)
# 僅針對 Python 套件目錄建立 __init__.py
if 'web' not in folder:
init_file = os.path.join(folder_path, '__init__.py')
if not os.path.exists(init_file):
with open(init_file, 'w') as f: pass
except OSError as e:
print(f"❌ 系統初始化失敗: 無法建立目錄或檔案 (磁碟可能已滿) - {e}")
# ================= 🔧 2. 核心模組導入 =================
try:
from flask import Flask, render_template, jsonify, request, send_file, redirect, url_for, send_from_directory, flash, session
from auth import login_required, init_auth_routes # V-Fix: 使用專案自定義的 login_required (不使用 flask_login)
from werkzeug.utils import secure_filename
from pyngrok import ngrok, conf
import schedule
from sqlalchemy import desc, and_, func, text, literal, case
from sqlalchemy import inspect # V-New: 用於檢查資料表是否存在
from sqlalchemy.orm import joinedload
import pandas as pd # type: ignore
from pandas.api.types import is_numeric_dtype # type: ignore
import numpy as np # type: ignore # V-Opt: 引入 numpy 進行向量化運算加速
# 導入自定義模組
try:
from scheduler import run_momo_task, run_edm_task, run_festival_task, run_auto_import_task, run_whitepage_check
from database.manager import DatabaseManager
from database.models import Product, PriceRecord, MonthlySummaryAnalysis
from database.edm_models import PromoProduct
except ImportError as e:
print(f"❌ 專案內部檔案缺失: {e}\n請檢查 database/ 或 services/ 目錄下的 .py 檔案是否存在。")
sys.exit(1)
from services.logger_manager import SystemLogger
from services.exporter import Exporter # 🚩 導入匯出模組
except ImportError as e:
print(f"❌ 關鍵套件導入失敗: {e}")
sys.exit(1)
# ================= 🔧 3. 系統核心配置 =================
# 從 config.py 匯入必要的設定
from config import EXCEL_EXPORT_DIR, USE_MODULAR_ROUTES
sys_log = SystemLogger("Web_Server").get_logger()
# 🚩 V-Opt: 全域資料快取 (用於加速業績分析)
_SALES_DF_CACHE = {}
_SALES_PROCESSED_CACHE = {} # V-Opt: 新增處理後資料快取 (二級快取)
# 🚩 V-New: 商品看板資料快取 (用於加速首頁載入)
_DASHBOARD_DATA_CACHE = {
'consolidated_data': None, # get_consolidated_data() 結果
'consolidated_timestamp': None,
'full_data': None, # 包含統計數據的完整結果
'full_timestamp': None
}
_DASHBOARD_CACHE_TTL = 1800 # V-Opt: 快取有效期 30 分鐘(高規格 UAT: 126GB RAM
_SALES_CACHE_TTL = 3600 # V-Opt: 業績分析快取有效期 60 分鐘(高規格 UAT
_SALES_OPTIONS_CACHE = {} # V-Opt: 儲存下拉選單選項 (類別、品牌、廠商等)
_SALES_OPTIONS_TTL = 21600 # V-Opt: 6 小時有效(高規格 UAT
_SALES_ANALYSIS_RESULT_CACHE = {} # 🚩 V-Opt: 儲存過濾後的分析結果集 (Result Cache)
_SALES_RESULT_TTL = 3600 # V-Opt: 60 分鐘有效(高規格 UAT
# 🚩 V-New: 慢查詢監控 (供 Prometheus 監控使用)
_SLOW_QUERY_STATS = {
'total_queries': 0, # 總查詢數
'slow_queries': 0, # 慢查詢數 (>1秒)
'very_slow_queries': 0, # 極慢查詢數 (>5秒)
'total_query_time_ms': 0, # 總查詢時間(毫秒)
'last_slow_query': None, # 最後一個慢查詢
'last_slow_query_time': None, # 最後慢查詢時間
}
_SLOW_QUERY_THRESHOLD_MS = 1000 # 慢查詢閾值: 1秒
_VERY_SLOW_QUERY_THRESHOLD_MS = 5000 # 極慢查詢閾值: 5秒
def track_query_time(query_name, duration_ms):
"""追蹤查詢時間,更新慢查詢統計"""
global _SLOW_QUERY_STATS
_SLOW_QUERY_STATS['total_queries'] += 1
_SLOW_QUERY_STATS['total_query_time_ms'] += duration_ms
if duration_ms >= _VERY_SLOW_QUERY_THRESHOLD_MS:
_SLOW_QUERY_STATS['very_slow_queries'] += 1
_SLOW_QUERY_STATS['slow_queries'] += 1
_SLOW_QUERY_STATS['last_slow_query'] = query_name
_SLOW_QUERY_STATS['last_slow_query_time'] = datetime.now(TAIPEI_TZ).isoformat()
elif duration_ms >= _SLOW_QUERY_THRESHOLD_MS:
_SLOW_QUERY_STATS['slow_queries'] += 1
_SLOW_QUERY_STATS['last_slow_query'] = query_name
_SLOW_QUERY_STATS['last_slow_query_time'] = datetime.now(TAIPEI_TZ).isoformat()
# 🚩 檢查磁碟空間 (V9.52 新增)
try:
total, used, free = shutil.disk_usage(BASE_DIR)
if free < 200 * 1024 * 1024: # 小於 200MB
sys_log.critical(f"[System] [DISK_CHECK] 🚨 嚴重警告: 磁碟空間極低 | Free: {free // (1024*1024)} MB")
elif free < 1024 * 1024 * 1024: # 小於 1GB
sys_log.warning(f"[System] [DISK_CHECK] ⚠️ 警告: 磁碟空間不足 1GB | Free: {free // (1024*1024)} MB")
except Exception as e:
sys_log.error(f"無法檢測磁碟空間: {e}")
# 🚩 系統版本定義 (備份與顯示用)
# TODO: 下次進行重大更新時,記得修改此版本號 (目前 V9.4)
SYSTEM_VERSION = "V9.4"
# ==========================================
# 🔒 SQL Injection 防護函數
# ==========================================
# 允許的資料表白名單
ALLOWED_TABLES = {
'realtime_sales_monthly',
'daily_sales_snapshot',
'products',
'price_records',
'promo_products',
'edm_products',
'festival_products'
}
def validate_table_name(table_name):
"""
驗證資料表名稱,防止 SQL Injection
Args:
table_name: 要驗證的資料表名稱
Returns:
str: 驗證通過的表名
Raises:
ValueError: 表名不在白名單中
"""
# 移除空白字符
table_name = str(table_name).strip()
# 檢查是否為空
if not table_name:
raise ValueError("表名不能為空")
# 檢查是否包含危險字符
if not re.match(r'^[a-zA-Z0-9_]+$', table_name):
raise ValueError(f"表名包含非法字符: {table_name}")
# 檢查是否在白名單中
if table_name not in ALLOWED_TABLES:
# 對於動態表名(從檔名生成),允許但記錄警告
sys_log.warning(f"[Security] 表名不在白名單中: {table_name}")
# 至少確保沒有 SQL 關鍵字
sql_keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'UNION', 'WHERE', 'FROM']
if any(keyword in table_name.upper() for keyword in sql_keywords):
raise ValueError(f"表名包含 SQL 關鍵字: {table_name}")
return table_name
def validate_column_names(column_names):
"""
驗證欄位名稱列表,防止 SQL Injection
Args:
column_names: 欄位名稱列表
Returns:
list: 驗證通過的欄位名稱列表
Raises:
ValueError: 欄位名稱包含非法字符
"""
if isinstance(column_names, str):
column_names = [column_names]
validated = []
for col in column_names:
col = str(col).strip()
# 允許中文、英文、數字、底線
if not re.match(r'^[\w\u4e00-\u9fff]+$', col):
raise ValueError(f"欄位名稱包含非法字符: {col}")
validated.append(col)
return validated
def safe_read_sql(table_name, columns=None, engine=None, where_clause=None, limit=None, params=None):
"""
安全的 SQL 查詢函數,防止 SQL Injection
Args:
table_name: 資料表名稱
columns: 欄位列表None 表示 *
engine: SQLAlchemy engine
where_clause: WHERE 子句
limit: 限制筆數
params: 參數化查詢的參數字典
Returns:
DataFrame: 查詢結果
"""
from sqlalchemy import text
# 驗證表名
table_name = validate_table_name(table_name)
# 驗證欄位名
if columns:
columns = validate_column_names(columns)
col_str = ', '.join([f'"{col}"' for col in columns])
else:
col_str = '*'
# 使用 SQLAlchemy 的參數化查詢
# 注意:表名和欄位名不能參數化,所以必須先驗證
try:
query = f'SELECT {col_str} FROM "{table_name}"'
if where_clause:
query += f' WHERE {where_clause}'
if limit:
query += f' LIMIT {int(limit)}'
return pd.read_sql(text(query), engine, params=params)
except Exception as e:
sys_log.error(f"[Security] SQL 查詢失敗: {e}")
raise
# ==========================================
# 🔒 路徑遍歷防護函數
# ==========================================
from pathlib import Path
def safe_join(base, *paths):
"""
安全的路徑拼接,防止路徑遍歷攻擊
Args:
base: 基礎目錄(絕對路徑)
*paths: 子路徑組件
Returns:
Path: 安全的完整路徑
Raises:
ValueError: 偵測到路徑遍歷嘗試
"""
# 確保 base 是絕對路徑
base = Path(base).resolve()
# 檢查路徑組件中是否包含危險字符
for path_component in paths:
path_str = str(path_component)
# 阻擋包含 Windows 反斜線的路徑
if '\\' in path_str:
sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 (Windows 反斜線) | Base: {base} | Requested: {paths}")
raise ValueError(f"路徑遍歷偵測: 不允許使用反斜線")
# 阻擋包含連續點的路徑 (如 ...., ....//)
if '..' in path_str.replace('\\', '/'):
sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 (雙點) | Base: {base} | Requested: {paths}")
raise ValueError(f"路徑遍歷偵測: 不允許使用 '..'")
# 拼接並解析完整路徑
full_path = (base / Path(*paths)).resolve()
# 驗證最終路徑必須在基礎目錄內
try:
full_path.relative_to(base)
except ValueError:
sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 | Base: {base} | Requested: {paths}")
raise ValueError(f"路徑遍歷偵測: 不允許存取基礎目錄外的檔案")
return full_path
# ==========================================
# 🔒 檔案上傳安全驗證
# ==========================================
# 允許的檔案副檔名與 MIME types
ALLOWED_UPLOAD_EXTENSIONS = {'xlsx', 'xls', 'csv'}
ALLOWED_MIME_TYPES = {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xlsx
'application/vnd.ms-excel', # .xls
'text/csv', # .csv
'application/octet-stream' # CSV sometimes detected as this
}
def secure_filename_unicode(filename):
"""
支援中文的安全檔案名稱清理
Args:
filename: 原始檔案名稱
Returns:
str: 清理後的安全檔案名稱
"""
import re
import unicodedata
# 正規化 Unicode 字元
filename = unicodedata.normalize('NFKC', filename)
# 移除危險字元但保留中文、英文、數字、空格、括號、底線、連字號
# 允許的字元: 中文、英文字母、數字、空格、括號、底線、連字號、點
safe_chars = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s\(\)_\-\.]', '', filename)
# 將多個空格替換為單一空格
safe_chars = re.sub(r'\s+', ' ', safe_chars)
# 移除前後空格
safe_chars = safe_chars.strip()
return safe_chars
def allowed_file(filename):
"""
檢查檔案副檔名是否在白名單中
Args:
filename: 檔案名稱
Returns:
bool: 是否允許上傳
"""
if not filename or '.' not in filename:
return False
# 分割檔名和副檔名
parts = filename.rsplit('.', 1)
if len(parts) != 2:
return False
basename, ext = parts
# 拒絕純副檔名檔案(如 .xlsx
if not basename or basename.strip() == '':
return False
# 檢查副檔名是否在白名單中
return ext.lower() in ALLOWED_UPLOAD_EXTENSIONS
def validate_upload_file(file):
"""
完整的檔案上傳驗證(副檔名、檔案名稱清理)
Args:
file: Flask request.files 物件
Returns:
tuple: (is_valid, error_message, safe_filename)
"""
# 檢查檔案是否存在
if not file or file.filename == '':
return False, '未選擇檔案', None
original_filename = file.filename
# 1. 在清理前先檢查路徑遍歷攻擊
# 檢查連續的雙點(路徑遍歷)
if '..' in original_filename:
sys_log.warning(f"[Security] 檔案上傳 - 偵測到路徑遍歷嘗試(雙點): {original_filename}")
return False, '檔案名稱包含非法字元', None
# 檢查絕對路徑或目錄分隔符(在檔名中間)
# 允許檔名開頭沒有分隔符,且允許 HTML 標籤中的斜線(不在路徑位置)
import os
if os.path.sep in original_filename or (os.path.altsep and os.path.altsep in original_filename):
# 進一步檢查是否真的是路徑分隔(而不是 HTML 標籤等)
# 如果檔名以 / 或 \ 開頭,或包含 ./ 或 .\\ 模式,則為路徑遍歷
if original_filename.startswith(('/','\\')) or './' in original_filename or '.\\' in original_filename:
sys_log.warning(f"[Security] 檔案上傳 - 偵測到路徑遍歷嘗試(路徑分隔符): {original_filename}")
return False, '檔案名稱包含非法字元', None
# 2. 檔案名稱清理(使用支援中文的版本)
safe_name = secure_filename_unicode(original_filename)
if not safe_name:
return False, '檔案名稱不合法', None
# 3. 副檔名驗證
if not allowed_file(safe_name):
return False, f'不支援的檔案格式,僅允許: {", ".join(ALLOWED_UPLOAD_EXTENSIONS)}', None
# 4. 檔案大小驗證(由 Flask MAX_CONTENT_LENGTH 自動處理,此處記錄)
# Flask 會在超過大小時自動拋出 413 錯誤
return True, None, safe_name
# 🚩 資料庫結構自動修復 (V9.53 新增)
def repair_database_schema():
db = DatabaseManager()
engine = db.engine
from sqlalchemy import inspect, text
try:
# 🚩 V9.96: 啟用 SQLite WAL 模式以解決 database is locked 問題
with engine.connect() as conn:
# 啟用 WAL 模式 (Write-Ahead Logging)
conn.execute(text("PRAGMA journal_mode=WAL"))
conn.commit()
sys_log.info("[Database] [WAL] ✅ SQLite WAL 模式已啟用 | 提升並發寫入效能")
inspector = inspect(engine)
# V9.70: 檢查 products 表
if 'products' in inspector.get_table_names():
product_columns = [c['name'] for c in inspector.get_columns('products')]
if 'image_url' not in product_columns:
sys_log.warning("[Database] [Schema] ⚠️ 偵測到 products 表缺少 image_url 欄位 | 正在自動修復...")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE products ADD COLUMN image_url TEXT"))
conn.commit()
sys_log.info("[Database] [Schema] ✅ products.image_url 欄位修復完成")
if 'created_at' not in product_columns:
sys_log.warning("[Database] [Schema] ⚠️ 偵測到 products 表缺少 created_at 欄位 | 正在自動修復...")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE products ADD COLUMN created_at DATETIME"))
conn.execute(text("UPDATE products SET created_at = updated_at WHERE created_at IS NULL"))
conn.commit()
sys_log.info("[Database] [Schema] ✅ products.created_at 欄位修復完成")
if 'promo_products' in inspector.get_table_names():
columns = [c['name'] for c in inspector.get_columns('promo_products')]
if 'url' not in columns:
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 url 欄位,正在自動修復...")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE promo_products ADD COLUMN url TEXT"))
conn.commit()
sys_log.info("✅ url 欄位修復完成")
if 'image_url' not in columns:
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 image_url 欄位,正在自動修復...")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE promo_products ADD COLUMN image_url TEXT"))
conn.commit()
sys_log.info("✅ image_url 欄位修復完成")
if 'previous_price' not in columns:
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 previous_price 欄位,正在自動修復...")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE promo_products ADD COLUMN previous_price INTEGER"))
conn.commit()
sys_log.info("✅ previous_price 欄位修復完成")
if 'session_time_text' not in columns:
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 session_time_text 欄位,正在自動修復...")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE promo_products ADD COLUMN session_time_text TEXT"))
conn.commit()
sys_log.info("✅ session_time_text 欄位修復完成")
if 'remain_qty' not in columns:
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 remain_qty 欄位,正在自動修復...")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE promo_products ADD COLUMN remain_qty INTEGER"))
conn.commit()
sys_log.info("✅ remain_qty 欄位修復完成")
if 'discount_text' not in columns:
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 discount_text 欄位,正在自動修復...")
with engine.connect() as conn:
conn.execute(text("ALTER TABLE promo_products ADD COLUMN discount_text TEXT"))
conn.commit()
sys_log.info("✅ discount_text 欄位修復完成")
if 'page_type' not in columns:
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 page_type 欄位,正在自動修復...")
with engine.connect() as conn:
# 將既有資料預設為 'edm'
conn.execute(text("ALTER TABLE promo_products ADD COLUMN page_type TEXT DEFAULT 'edm'"))
conn.commit()
sys_log.info("✅ page_type 欄位修復完成")
except Exception as e:
sys_log.error(f"[Database] [Schema] ❌ 資料庫修復失敗 | Error: {e}")
# 從環境變數讀取 NGROK_AUTH_TOKEN如果未設定則使用原值但會發出警告
NGROK_AUTH_TOKEN = os.getenv('NGROK_AUTH_TOKEN', '36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF')
if NGROK_AUTH_TOKEN == '36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF':
sys_log.warning("[Security] ⚠️ 使用預設 NGROK_AUTH_TOKEN請設定環境變數")
conf.get_default().auth_token = NGROK_AUTH_TOKEN
TEMPLATE_DIR = BASE_DIR # 舊模板路徑(根目錄)
TEMPLATE_DIR_NEW = os.path.join(BASE_DIR, 'templates') # 新模板路徑(模組化)
TEMPLATE_DIR_WEB = os.path.join(BASE_DIR, 'web/templates') # web 子目錄模板
STATIC_DIR = os.path.join(BASE_DIR, 'web/static')
# 檢查關鍵模板是否存在
if not os.path.exists(os.path.join(BASE_DIR, 'dashboard.html')):
sys_log.warning(f"[Web] [Template] ⚠️ 警告: 找不到 dashboard.html | Path: {TEMPLATE_DIR}")
# Flask 應用程式
# 使用 Jinja2 的多路徑載入器支援新舊兩種模板路徑
from jinja2 import FileSystemLoader, ChoiceLoader
app = Flask(__name__,
template_folder=TEMPLATE_DIR,
static_folder=STATIC_DIR)
# 設定多路徑模板載入器:優先載入 templates/ 目錄,再載入根目錄與 web/templates
app.jinja_loader = ChoiceLoader([
FileSystemLoader(TEMPLATE_DIR_NEW), # 新模板路徑優先 (templates/)
FileSystemLoader(TEMPLATE_DIR), # 舊模板路徑備用 (根目錄)
FileSystemLoader(TEMPLATE_DIR_WEB), # web 子目錄模板 (web/templates/)
])
# ==========================================
# 🔒 Flask 安全配置
# ==========================================
# 從 config.py 導入 SECRET_KEY
from config import SECRET_KEY
# 基本配置
app.config['SECRET_KEY'] = SECRET_KEY
# Session 安全配置
app.config['SESSION_COOKIE_HTTPONLY'] = True # 防止 JavaScript 存取 cookie防 XSS
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # 防止 CSRF 攻擊
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=24) # Session 有效期 24 小時(延長避免長時間閒置斷線)
# 如果使用 HTTPS啟用 SECURE cookie本地開發時應設為 False
# 注意:如果您的系統部署在 HTTPS 環境,請將 .env 中的 USE_HTTPS 設為 true
USE_HTTPS = os.getenv('USE_HTTPS', 'false').lower() == 'true'
if USE_HTTPS:
app.config['SESSION_COOKIE_SECURE'] = True
sys_log.info("[Security] ✅ HTTPS 模式已啟用Session cookie 僅透過 HTTPS 傳輸")
else:
app.config['SESSION_COOKIE_SECURE'] = False
sys_log.warning("[Security] ⚠️ HTTP 模式開發環境Session cookie 未強制 HTTPS")
# 檔案上傳大小限制10MB
# V-New: 提高檔案上傳大小限制 (從 10MB 提高到 100MB)
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024
sys_log.info("[Security] ✅ Flask 安全配置已載入")
sys_log.info(f"[Security] • Session 有效期: 2 小時")
sys_log.info(f"[Security] • 檔案上傳限制: 10 MB")
sys_log.info(f"[Security] • CSRF 防護: SameSite=Lax")
sys_log.info(f"[Security] • XSS 防護: HttpOnly=True")
# ==========================================
# 🔒 CSRF 防護配置
# ==========================================
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app)
sys_log.info("[Security] ✅ CSRF 防護已啟用 (Flask-WTF)")
# ==========================================
# 🔧 Blueprint 註冊 - 廠商缺貨系統
# ==========================================
# ==========================================
# 🔒 認證路由初始化
# ==========================================
init_auth_routes(app)
sys_log.info("[Auth] ✅ 認證路由已註冊 (/login, /logout)")
from vendor_routes import vendor_bp
app.register_blueprint(vendor_bp)
sys_log.info("[Blueprint] ✅ 廠商缺貨系統 Blueprint 已註冊")
# ==========================================
# 🔧 Blueprint 註冊 - Google Drive 自動匯入
# ==========================================
from auto_import_routes import auto_import_bp
app.register_blueprint(auto_import_bp)
csrf.exempt(auto_import_bp)
sys_log.info("[Blueprint] ✅ Google Drive 自動匯入 Blueprint 已註冊 (CSRF 已豁免)")
# ==========================================
# 🔧 Blueprint 註冊 - 爬蟲管理系統
# ==========================================
from crawler_management_routes import crawler_bp
app.register_blueprint(crawler_bp)
sys_log.info("[Blueprint] ✅ 爬蟲管理系統 Blueprint 已註冊")
# ==========================================
# 🔧 Blueprint 註冊 - 重構後的路由模組
# ==========================================
from routes import register_blueprints
register_blueprints(app)
sys_log.info("[Blueprint] ✅ 重構路由模組已註冊")
# ==========================================
# 🔧 Blueprint 註冊 - 用戶管理系統
# ==========================================
from routes.user_routes import user_bp
app.register_blueprint(user_bp)
sys_log.info("[Blueprint] ✅ 用戶管理系統 Blueprint 已註冊")
# V-Fix: 註冊 slugify 函數供模板使用,解決 'slugify is undefined' 錯誤
def slugify(text):
if not text: return ""
return str(text).replace(' ', '_').replace(':', '').replace('!', '').replace('?', '').replace('/', '').replace('&', '').replace('(', '').replace(')', '').replace('+', '_').replace('.', '_').replace('%', '').replace("'", "")
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = "服務啟動中..."
# 🚩 時區設定:台北時間 (UTC+8)
TAIPEI_TZ = timezone(timedelta(hours=8))
# ================= 🛠️ V9.72: 分類設定管理核心 =================
CATEGORIES_JSON_PATH = os.path.join(BASE_DIR, 'data', 'categories.json')
def load_categories():
"""從 JSON 檔案載入分類列表"""
try:
with open(CATEGORIES_JSON_PATH, 'r', encoding='utf-8') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def save_categories(categories):
"""將分類列表儲存到 JSON 檔案"""
with open(CATEGORIES_JSON_PATH, 'w', encoding='utf-8') as f:
json.dump(categories, f, ensure_ascii=False, indent=4)
def load_scheduler_stats():
"""讀取排程統計資料"""
stats_path = os.path.join(BASE_DIR, 'data', 'scheduler_stats.json')
if os.path.exists(stats_path):
try:
with open(stats_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (IOError, json.JSONDecodeError):
return {}
return {}
# ================= 🛠️ 數據處理核心 (封裝) =================
def get_color_for_string(s):
"""為字串生成一個穩定且美觀的 HSL 顏色"""
if not s: return "hsl(0, 0%, 85%)" # 預設灰色
# 使用 md5 hash 確保顏色穩定,並映射到 HSL 色彩空間以獲得柔和色彩
hash_val = int(hashlib.md5(s.encode('utf-8')).hexdigest(), 16)
hue = hash_val % 360
return f"hsl({hue}, 60%, 88%)"
def extract_snapshot_date_from_filename(filename):
"""從檔名提取日期即時業績_當日_20260111.xlsx → 2026-01-11"""
match = re.search(r'(\d{8})', filename)
if match:
date_str = match.group(1) # '20260111'
try:
# 轉換為 YYYY-MM-DD 格式
year = date_str[:4]
month = date_str[4:6]
day = date_str[6:8]
return f"{year}-{month}-{day}"
except:
return None
return None
@app.template_filter('number_format')
def number_format_filter(value):
"""V9.61: 將數字格式化,加上千分位符號。"""
if isinstance(value, (int, float)):
return "{:,.0f}".format(value)
return value
# V-Refactor: 將 find_col 移至全域,方便多個函式共用
def find_col(df_cols, keywords):
"""從欄位列表中,根據關鍵字列表找出最匹配的欄位名稱"""
# V-Opt: 改為優先遍歷關鍵字,確保優先匹配更精確的名稱 (例如 '廠商名稱' 優於 '廠商')
for k in keywords:
for col in df_cols:
if k in str(col):
return col
return None
def get_consolidated_data():
"""🚩 統一封裝:獲取全分類去重後的當前數據、昨日對比及差值 (V-Opt: 優化查詢效能 + 快取)"""
global _DASHBOARD_DATA_CACHE
# V-New: 檢查快取是否有效
now = datetime.now(TAIPEI_TZ)
if (_DASHBOARD_DATA_CACHE['consolidated_data'] is not None and
_DASHBOARD_DATA_CACHE['consolidated_timestamp'] is not None):
cache_age = (now.timestamp() - _DASHBOARD_DATA_CACHE['consolidated_timestamp'])
if cache_age < _DASHBOARD_CACHE_TTL:
sys_log.debug(f"[Dashboard] [Cache] ✅ 使用快取資料 | 快取年齡: {cache_age:.1f}秒")
return _DASHBOARD_DATA_CACHE['consolidated_data'], _DASHBOARD_DATA_CACHE['today_start']
sys_log.debug("[Dashboard] [Cache] 🔄 快取過期或不存在,重新查詢資料庫")
# 🚩 V-New: 追蹤查詢時間
query_start_time = time.time()
db = DatabaseManager()
session = db.get_session()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
seven_days_ago = today_start - timedelta(days=7)
thirty_days_ago = today_start - timedelta(days=30)
try:
# Query 1: Get the latest price record for every product. This is our main list of items.
latest_price_subq = session.query(
func.max(PriceRecord.id).label('max_id')
).group_by(PriceRecord.product_id).subquery()
latest_records = session.query(PriceRecord).options(
joinedload(PriceRecord.product)
).join(latest_price_subq, PriceRecord.id == latest_price_subq.c.max_id).all()
product_ids = [r.product_id for r in latest_records]
if not product_ids:
session.close() # 提前關閉連線
return [], today_start
# Query 2: Get yesterday's closing prices for all products in one go
yesterday_prices_subq = session.query(
PriceRecord.product_id,
func.max(PriceRecord.id).label('max_id')
).filter(
PriceRecord.product_id.in_(product_ids),
PriceRecord.timestamp < today_start
).group_by(PriceRecord.product_id).subquery()
yesterday_prices_q = session.query(
PriceRecord.product_id, PriceRecord.price
).join(
yesterday_prices_subq,
PriceRecord.id == yesterday_prices_subq.c.max_id
)
yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q}
# Query 3: Get specific historical price points (7 days ago and 30 days ago)
# Instead of fetching ALL history, we fetch only the records closest to the target dates.
# This is a significant optimization.
# Helper to get price map for a specific date (start of day)
def get_price_map_before(target_date):
subq = session.query(
PriceRecord.product_id,
func.max(PriceRecord.timestamp).label('max_ts')
).filter(
PriceRecord.product_id.in_(product_ids),
PriceRecord.timestamp < target_date
).group_by(PriceRecord.product_id).subquery()
q = session.query(PriceRecord.product_id, PriceRecord.price).join(
subq,
and_(PriceRecord.product_id == subq.c.product_id, PriceRecord.timestamp == subq.c.max_ts)
)
return {pid: price for pid, price in q}
prices_7d_ago_map = get_price_map_before(seven_days_ago + timedelta(days=1)) # Approximate 7 days ago
prices_30d_ago_map = get_price_map_before(thirty_days_ago + timedelta(days=1)) # Approximate 30 days ago
# Query 4: Get TODAY's records only (for sparkline/intraday change)
today_records_q = session.query(PriceRecord).filter(
PriceRecord.product_id.in_(product_ids),
PriceRecord.timestamp >= today_start
).order_by(PriceRecord.product_id, PriceRecord.timestamp).all()
today_map = {}
for r in today_records_q:
if r.product_id not in today_map: today_map[r.product_id] = []
today_map[r.product_id].append(r)
# Final Assembly (in-memory, no more DB queries)
unique_items = []
for r in latest_records:
pid = r.product_id
# 7d/30d stats
price_7d = prices_7d_ago_map.get(pid)
price_30d = prices_30d_ago_map.get(pid)
stats_7d_diff = r.price - price_7d if price_7d is not None else 0
stats_30d_diff = r.price - price_30d if price_30d is not None else 0
# Today's stats
today_records = today_map.get(pid, [])
today_diff = 0
today_changes = []
if len(today_records) > 1:
today_diff = today_records[-1].price - today_records[0].price
# Yesterday diff
y_price = yesterday_prices_map.get(pid)
yesterday_diff = r.price - y_price if y_price is not None else 0
status = "NONE"
if yesterday_diff > 0:
status = "PRICE_UP"
elif yesterday_diff < 0:
status = "PRICE_DOWN"
# Today's changes details
last_p = y_price if y_price is not None else (today_records[0].price if today_records else r.price)
for tr in today_records:
if tr.price != last_p:
diff = tr.price - last_p
today_changes.append({
'time': tr.timestamp.strftime('%H:%M'),
'price': tr.price,
'diff': diff
})
last_p = tr.price
unique_items.append({
'record': r,
'stats': {'7d_diff': stats_7d_diff, '30d_diff': stats_30d_diff, '1d_diff': today_diff},
'yesterday_diff': yesterday_diff,
'today_changes': today_changes,
'status': status
})
# V-New: 更新快取
_DASHBOARD_DATA_CACHE['consolidated_data'] = unique_items
_DASHBOARD_DATA_CACHE['consolidated_timestamp'] = now.timestamp()
_DASHBOARD_DATA_CACHE['today_start'] = today_start
# 🚩 V-New: 追蹤查詢時間並記錄
query_duration_ms = (time.time() - query_start_time) * 1000
track_query_time('get_consolidated_data', query_duration_ms)
sys_log.debug(f"[Dashboard] [Cache] 💾 快取已更新 | 商品數: {len(unique_items)} | 耗時: {query_duration_ms:.0f}ms")
return unique_items, today_start
finally:
session.close()
def get_full_dashboard_data():
"""🚩 獲取完整的看板資料,包含快取清單與全部 KPIs (V-Opt: 深度快取)"""
global _DASHBOARD_DATA_CACHE
now = datetime.now(TAIPEI_TZ)
# 1. 檢查完整快取是否有效 (300秒)
if _DASHBOARD_DATA_CACHE.get('full_data') and _DASHBOARD_DATA_CACHE.get('full_timestamp'):
age = now.timestamp() - _DASHBOARD_DATA_CACHE['full_timestamp']
if age < _DASHBOARD_CACHE_TTL:
sys_log.debug(f"[Dashboard] [Cache] ✅ 使用完整看板快取 | 快取年齡: {age:.0f}秒")
return _DASHBOARD_DATA_CACHE['full_data']
sys_log.info("[Dashboard] [Cache] 🔄 完整快取過期,重新計算所有 KPIs 與統計數據...")
# 🚩 V-New: 追蹤查詢時間
query_start_time = time.time()
# 2. 獲取基本彙總資料
unique_items, today_start = get_consolidated_data()
today_start_db = today_start.replace(tzinfo=None)
db = DatabaseManager()
session = db.get_session()
try:
# A. 基礎清單統計
increase_items = [item for item in unique_items if item['yesterday_diff'] > 0]
decrease_items = [item for item in unique_items if item['yesterday_diff'] < 0]
# B. 分類筆數統計
cat_counts = {}
for item in unique_items:
c = item['record'].product.category
if c: cat_counts[c] = cat_counts.get(c, 0) + 1
all_categories = [f"{cat} ({count}筆)" for cat, count in sorted(cat_counts.items())]
# C. 核心 KPI 統計 (資料庫查詢)
total_products_history = session.query(Product).count()
total_price_records = session.query(PriceRecord).count()
today_updates = session.query(PriceRecord).filter(PriceRecord.timestamp >= today_start_db).count()
# 今日新增商品 ID 集合與數量 (優化查詢)
new_pids_query = session.query(PriceRecord.product_id).group_by(PriceRecord.product_id).having(func.min(PriceRecord.timestamp) >= today_start_db)
new_product_ids = {r[0] for r in new_pids_query.all()}
today_new_products = len(new_product_ids)
# D. 今日下架商品處理
raw_delisted_items = session.query(Product).filter(
Product.status == 'INACTIVE',
Product.updated_at >= today_start_db
).all()
today_delisted_items = []
if raw_delisted_items:
delisted_ids = [p.id for p in raw_delisted_items]
# 一次性查詢最後價格
last_prices_subq = session.query(
PriceRecord.product_id,
func.max(PriceRecord.id).label('max_id')
).filter(PriceRecord.product_id.in_(delisted_ids)).group_by(PriceRecord.product_id).subquery()
last_prices_q = session.query(PriceRecord.product_id, PriceRecord.price).join(
last_prices_subq, PriceRecord.id == last_prices_subq.c.max_id).all()
price_map = {pid: price for pid, price in last_prices_q}
for p in raw_delisted_items:
today_delisted_items.append({'product': p, 'last_price': price_map.get(p.id, 0)})
# E. 週增長 (過去 7 天新增的商品數)
week_ago_db = (now.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=7)).replace(tzinfo=None)
week_new_products = session.query(func.count(Product.id)).filter(
Product.id.in_(
session.query(PriceRecord.product_id)
.group_by(PriceRecord.product_id)
.having(func.min(PriceRecord.timestamp) >= week_ago_db)
)
).scalar() or 0
# F. 價格穩定商品數7 天內無變價)
try:
stable_count = session.query(PriceRecord.product_id).filter(
PriceRecord.timestamp >= week_ago_db
).group_by(PriceRecord.product_id).having(
func.count(func.distinct(PriceRecord.price)) == 1
).count()
except Exception:
stable_count = 0
# G. 最大變動計算
max_change_item = None
max_change_value = 0
for item in unique_items:
if abs(item['yesterday_diff']) > abs(max_change_value):
max_change_value = item['yesterday_diff']
max_change_item = item
# H. 最活躍分類
category_activity = {}
for item in increase_items + decrease_items:
cat = item['record'].product.category
if cat: category_activity[cat] = category_activity.get(cat, 0) + 1
most_active_category_item = max(category_activity.items(), key=lambda x: x[1]) if category_activity else (None, 0)
# I. 組合結果
full_data = {
'unique_items': unique_items,
'today_start': today_start,
'today_start_db': today_start_db,
'increase_items_all': increase_items,
'decrease_items_all': decrease_items,
'all_categories': all_categories,
'new_product_ids': new_product_ids,
'total_products_history': total_products_history,
'total_price_records': total_price_records,
'today_updates': today_updates,
'today_new_products': today_new_products,
'today_delisted_count': len(raw_delisted_items),
'today_delisted_items': today_delisted_items,
'max_change_item': max_change_item,
'max_change_value': max_change_value,
'avg_increase': sum(item['yesterday_diff'] for item in increase_items) / len(increase_items) if increase_items else 0,
'avg_decrease': sum(item['yesterday_diff'] for item in decrease_items) / len(decrease_items) if decrease_items else 0,
'activity_rate': (len(increase_items) + len(decrease_items)) / total_products_history * 100 if total_products_history > 0 else 0,
'active_count': len(increase_items) + len(decrease_items),
'week_new_products': week_new_products,
'stable_count': stable_count,
'most_active_category': most_active_category_item[0],
'most_active_count': most_active_category_item[1]
}
# 更新快取
_DASHBOARD_DATA_CACHE['full_data'] = full_data
_DASHBOARD_DATA_CACHE['full_timestamp'] = now.timestamp()
# 🚩 V-New: 追蹤查詢時間
query_duration_ms = (time.time() - query_start_time) * 1000
track_query_time('get_full_dashboard_data', query_duration_ms)
sys_log.info(f"[Dashboard] [Cache] 💾 完整看板快取已更新 | 耗時: {query_duration_ms:.0f}ms")
return full_data
except Exception as e:
sys_log.error(f"[Dashboard] ❌ KPI 計算失敗: {e}")
import traceback
traceback.print_exc()
return None
finally:
session.close()
def get_dashboard_stats():
"""計算看板統計數據 (供通知使用) - 改成使用快取版本"""
data = get_full_dashboard_data()
if data:
return {
'new': data['today_new_products'],
'up': len(data['increase_items_all']),
'down': len(data['decrease_items_all']),
'delisted': data['today_delisted_count']
}
return {'new': 0, 'up': 0, 'down': 0, 'delisted': 0}
# ================= 🛣️ 4. Flask 路由 =================
# Session 自動續期機制
@app.before_request
def refresh_session():
"""
在每次請求時自動刷新 Session避免長時間閒置後突然斷線
只要用戶有任何操作Session 就會自動延長
"""
if session.get('logged_in'):
session.modified = True # 標記 Session 已修改,觸發 Cookie 更新
# =============================================================================
# 全域模板變數 (Context Processor)
# =============================================================================
@app.context_processor
def inject_global_vars():
"""
注入全域模板變數,供所有模板使用
"""
import config as app_config # 區域導入避免循環引用
from auth import get_current_user
# 建立模擬的 current_user 對象供導航列使用
class CurrentUser:
def __init__(self, logged_in, user_info=None):
self._logged_in = logged_in
self._user_info = user_info or {}
@property
def is_authenticated(self):
return self._logged_in
@property
def role(self):
return self._user_info.get('role', 'user') if self._logged_in else None
@property
def username(self):
return self._user_info.get('username', 'User') if self._logged_in else None
@property
def display_name(self):
return self._user_info.get('display_name', self.username) if self._logged_in else None
@property
def user_id(self):
return self._user_info.get('user_id') if self._logged_in else None
def is_admin(self):
return self.role == 'admin'
def is_manager_or_above(self):
return self.role in ['admin', 'manager']
is_logged_in = session.get('logged_in', False)
user_info = get_current_user() if is_logged_in else None
return {
'metabase_url': app_config.METABASE_URL, # Metabase BI 連結 (空值時不顯示)
'grist_url': getattr(app_config, 'GRIST_URL', ''), # Grist 連結 (空值時不顯示)
'system_version': app_config.SYSTEM_VERSION,
'current_user': CurrentUser(is_logged_in, user_info), # 支援多用戶角色
}
# =============================================================================
# 模板繼承測試端點 (開發用)
# =============================================================================
@app.route('/test_template')
def test_template():
"""測試 base.html 模板繼承是否正常運作"""
return render_template('test_base.html',
datetime_now=datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'),
active_page='test')
# =============================================================================
# 健康檢查端點 (供 Docker / Load Balancer 使用)
# =============================================================================
@app.route('/health')
def health_check():
"""
健康檢查端點,返回系統狀態
- 用於 Docker HEALTHCHECK
- 用於 Nginx upstream 健康檢查
- 用於負載均衡器健康檢查
"""
try:
# 檢查資料庫連線
db = DatabaseManager()
with db.get_session() as session:
session.execute(text("SELECT 1"))
return jsonify({
'status': 'healthy',
'timestamp': datetime.now(TAIPEI_TZ).isoformat(),
'version': SYSTEM_VERSION
}), 200
except Exception as e:
return jsonify({
'status': 'unhealthy',
'error': str(e),
'timestamp': datetime.now(TAIPEI_TZ).isoformat()
}), 503
# =============================================================================
# Prometheus Metrics 端點 (供監控系統使用)
# =============================================================================
@app.route('/metrics')
def prometheus_metrics():
"""
Prometheus metrics endpoint - 暴露系統指標供 Prometheus 抓取
包含:資料庫大小、表記錄數、連線狀態等
"""
try:
db = DatabaseManager()
metrics = []
# 1. 資料庫檔案大小
db_path = os.path.join(BASE_DIR, 'data', 'momo_database.db')
if os.path.exists(db_path):
db_size = os.path.getsize(db_path)
metrics.append(f'momo_database_size_bytes{{db="main"}} {db_size}')
# 2. WAL 檔案大小
wal_path = db_path + '-wal'
if os.path.exists(wal_path):
wal_size = os.path.getsize(wal_path)
metrics.append(f'momo_database_wal_size_bytes{{db="main"}} {wal_size}')
else:
metrics.append(f'momo_database_wal_size_bytes{{db="main"}} 0')
# 3. 資料表記錄數
with db.get_session() as session:
# Products 表
product_count = session.execute(text("SELECT COUNT(*) FROM products")).scalar() or 0
metrics.append(f'momo_table_rows{{table="products"}} {product_count}')
# PriceRecords 表
price_count = session.execute(text("SELECT COUNT(*) FROM price_records")).scalar() or 0
metrics.append(f'momo_table_rows{{table="price_records"}} {price_count}')
# MonthlySummaryAnalysis 表
try:
monthly_count = session.execute(text("SELECT COUNT(*) FROM monthly_summary_analysis")).scalar() or 0
metrics.append(f'momo_table_rows{{table="monthly_summary_analysis"}} {monthly_count}')
except:
metrics.append(f'momo_table_rows{{table="monthly_summary_analysis"}} 0')
# PromoProducts 表 (EDM)
try:
promo_count = session.execute(text("SELECT COUNT(*) FROM promo_products")).scalar() or 0
metrics.append(f'momo_table_rows{{table="promo_products"}} {promo_count}')
except:
metrics.append(f'momo_table_rows{{table="promo_products"}} 0')
# 4. 資料庫連線狀態
metrics.append('momo_database_up 1')
# 5. 今日新增商品數
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
today_products = session.execute(
text("SELECT COUNT(*) FROM products WHERE created_at >= :today"),
{'today': today_start}
).scalar() or 0
metrics.append(f'momo_products_today_total {today_products}')
# 6. 今日價格變動記錄數
today_price_records = session.execute(
text("SELECT COUNT(*) FROM price_records WHERE timestamp >= :today"),
{'today': today_start}
).scalar() or 0
metrics.append(f'momo_price_records_today_total {today_price_records}')
# 7. 磁碟使用率
total, used, free = shutil.disk_usage(BASE_DIR)
metrics.append(f'momo_disk_total_bytes {total}')
metrics.append(f'momo_disk_used_bytes {used}')
metrics.append(f'momo_disk_free_bytes {free}')
# 8. 應用程式資訊
metrics.append(f'momo_app_info{{version="{SYSTEM_VERSION}"}} 1')
# 9. 慢查詢統計
metrics.append(f'momo_query_total {_SLOW_QUERY_STATS["total_queries"]}')
metrics.append(f'momo_query_slow_total {_SLOW_QUERY_STATS["slow_queries"]}')
metrics.append(f'momo_query_very_slow_total {_SLOW_QUERY_STATS["very_slow_queries"]}')
metrics.append(f'momo_query_time_total_ms {_SLOW_QUERY_STATS["total_query_time_ms"]}')
# 10. 計算平均查詢時間
if _SLOW_QUERY_STATS["total_queries"] > 0:
avg_query_time = _SLOW_QUERY_STATS["total_query_time_ms"] / _SLOW_QUERY_STATS["total_queries"]
metrics.append(f'momo_query_avg_time_ms {avg_query_time:.2f}')
else:
metrics.append('momo_query_avg_time_ms 0')
# 11. 慢查詢率 (百分比)
if _SLOW_QUERY_STATS["total_queries"] > 0:
slow_rate = (_SLOW_QUERY_STATS["slow_queries"] / _SLOW_QUERY_STATS["total_queries"]) * 100
metrics.append(f'momo_query_slow_rate_percent {slow_rate:.2f}')
else:
metrics.append('momo_query_slow_rate_percent 0')
# 12. SQLite 連線池狀態 (使用 PRAGMA 查詢)
with db.get_session() as session:
try:
# 查詢 SQLite 頁面統計
page_count = session.execute(text("PRAGMA page_count")).scalar() or 0
page_size = session.execute(text("PRAGMA page_size")).scalar() or 4096
freelist_count = session.execute(text("PRAGMA freelist_count")).scalar() or 0
metrics.append(f'momo_sqlite_page_count {page_count}')
metrics.append(f'momo_sqlite_page_size {page_size}')
metrics.append(f'momo_sqlite_freelist_count {freelist_count}')
# 碎片率 (freelist / page_count * 100)
if page_count > 0:
fragmentation = (freelist_count / page_count) * 100
metrics.append(f'momo_sqlite_fragmentation_percent {fragmentation:.2f}')
else:
metrics.append('momo_sqlite_fragmentation_percent 0')
# 緩存命中率 (如果啟用)
try:
cache_stats = session.execute(text("PRAGMA cache_stats")).fetchone()
if cache_stats:
metrics.append(f'momo_sqlite_cache_hit {cache_stats[0] if cache_stats[0] else 0}')
metrics.append(f'momo_sqlite_cache_miss {cache_stats[1] if len(cache_stats) > 1 else 0}')
except:
pass
except Exception as pragma_error:
pass # PRAGMA 查詢失敗不影響其他指標
# 返回 Prometheus 格式
return '\n'.join(metrics) + '\n', 200, {'Content-Type': 'text/plain; charset=utf-8'}
except Exception as e:
# 資料庫連線失敗
return f'momo_database_up 0\nmomo_database_error{{error="{str(e)}"}} 1\n', 200, {'Content-Type': 'text/plain; charset=utf-8'}
@app.route('/')
def index():
db = DatabaseManager()
session = db.get_session()
page = request.args.get('page', 1, type=int)
category_filter = request.args.get('category', 'all')
sort_by = request.args.get('sort_by', 'timestamp') # 預設按時間排序
filter_type = request.args.get('filter', 'all') # 🚩 新增:狀態篩選 (increase, decrease, delisted)
order = request.args.get('order', 'desc')
search_query = request.args.get('q', '').strip() # 🚩 新增:搜尋關鍵字
per_page = 50
# 🚩 取得台北時間的今日起始點 (用於資料庫查詢比較)
# 注意:若資料庫內存的是 naive time (無時區),則需轉為 naive 進行比較
now_taipei = datetime.now(TAIPEI_TZ)
today_start_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
try:
# 🚩 1. 使用「深度快取」獲取所有數據 (優化:不再於路由中重複計算 KPIs)
data = get_full_dashboard_data()
if not data:
return render_template('index.html', error="無法載入數據,請檢查資料庫。")
unique_items = data['unique_items']
today_start = data['today_start']
today_start_db = data['today_start_db']
increase_items = data['increase_items_all']
decrease_items = data['decrease_items_all']
all_categories = data['all_categories']
new_product_ids = data['new_product_ids']
total_products_history = data['total_products_history']
today_new_products = data['today_new_products']
total_price_records = data['total_price_records']
today_updates = data['today_updates']
today_delisted_count = data['today_delisted_count']
today_delisted_items = data['today_delisted_items']
max_change_item = data['max_change_item']
max_change_value = data['max_change_value']
avg_increase = data['avg_increase']
avg_decrease = data['avg_decrease']
activity_rate = data['activity_rate']
week_new_products = data['week_new_products']
stable_count = data['stable_count']
most_active_category = data['most_active_category']
most_active_count = data['most_active_count']
active_count = data.get('active_count', 0)
# 🚩 讀取系統狀態 (用於紅綠燈顯示)
system_status = {"status": "UNKNOWN", "message": "尚無執行紀錄", "timestamp": "-"}
status_path = os.path.join(BASE_DIR, 'data/system_status.json')
if os.path.exists(status_path):
try:
with open(status_path, 'r', encoding='utf-8') as f:
system_status = json.load(f)
except: pass
# --- 取得所有分類用於篩選器 ---
# (已在上方取得)
# 🚩 2. 後端篩選 (Server-side Filtering)
scheduler_stats = load_scheduler_stats()
# V-Fix: Handle old scheduler stats format (dict) by converting to list to prevent template errors
if scheduler_stats.get('momo_task') and isinstance(scheduler_stats.get('momo_task'), dict):
scheduler_stats['momo_task'] = [scheduler_stats['momo_task']]
if scheduler_stats.get('edm_task') and isinstance(scheduler_stats.get('edm_task'), dict):
scheduler_stats['edm_task'] = [scheduler_stats['edm_task']]
filtered_items = []
# 0. 先處理搜尋 (若有)
if search_query:
search_lower = search_query.lower()
# V9.81: 搜尋功能修復,支援搜尋商品名稱與 i_code
base_items = [
item for item in unique_items
if (item['record'].product.name and search_lower in item['record'].product.name.lower()) or
(item['record'].product.i_code and search_lower in str(item['record'].product.i_code))
]
else:
base_items = unique_items
# A. 先處理狀態篩選 (漲/跌/下架)
if filter_type == 'increase':
filtered_items = [i for i in base_items if i in increase_items]
elif filter_type == 'decrease':
filtered_items = [i for i in base_items if i in decrease_items]
elif filter_type == 'new':
# V-New: 新上架篩選 (今日新增的商品)
filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids]
elif filter_type == 'delisted':
# 特殊處理:將下架商品轉換為列表格式以便顯示
for item in today_delisted_items:
# 模擬 record 物件結構
class MockRecord:
def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at
if not search_query or search_query.lower() in item['product'].name.lower():
filtered_items.append({
'record': MockRecord(item['product'], item['last_price']),
'stats': {'1d_diff': 0, '7d_diff': 0, '30d_diff': 0}, # 模擬 stats 結構
'yesterday_diff': 0,
'today_changes': [], # 確保結構一致
'status': 'DELISTED' # 新增狀態
})
else:
# B. 若無狀態篩選,則處理分類篩選
if category_filter != 'all':
# V-New: 處理帶有筆數的分類名稱,例如 "化妝水 (50筆)" -> "化妝水"
real_category = category_filter
if "(" in category_filter and "筆)" in category_filter:
real_category = category_filter.rsplit(" (", 1)[0]
filtered_items = [item for item in base_items if item['record'].product.category == real_category]
else:
filtered_items = base_items
# 🚩 3. 後端排序 (Server-side Sorting)
reverse = (order == 'desc')
def get_sort_key(item):
# 處理 None 值,確保排序時不會出錯
def safe_get(value, default=0):
return default if value is None else value
if sort_by == 'i_code': return int(safe_get(item['record'].product.i_code, 0))
if sort_by == 'category': return safe_get(item['record'].product.category, '')
if sort_by == 'name': return safe_get(item['record'].product.name, '')
if sort_by == 'price': return safe_get(item['record'].price, 0)
if sort_by == 'today_change': return safe_get(item['stats']['1d_diff'], 0) # 今日內波動
if sort_by == 'yesterday_change': return safe_get(item['yesterday_diff'], 0)
if sort_by == 'week_change': return safe_get(item['stats']['7d_diff'], 0)
return item['record'].timestamp # 預設
sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse)
# 🚩 4. 分頁 (Pagination) - 在篩選和排序之後執行
total_items = len(sorted_items)
total_pages = math.ceil(total_items / per_page)
start_idx = (page - 1) * per_page
paged_items = sorted_items[start_idx : start_idx + per_page]
# V-Fix: 為前端準備安全的 created_at 屬性
for item in paged_items:
item['safe_created_at'] = getattr(item['record'].product, 'created_at', None)
# 🚩 5. 為當前頁面項目添加顏色
for item in paged_items:
category_name = item['record'].product.category
item['category_color'] = get_color_for_string(category_name)
return render_template('dashboard.html',
total_products=total_products_history,
today_new_products=today_new_products,
total_price_records=total_price_records,
cnt_increase=len(increase_items),
cnt_decrease=len(decrease_items), # 傳遞跌價數
today_delisted_count=today_delisted_count,
today_delisted_items=today_delisted_items,
system_status=system_status,
items=paged_items,
categories=all_categories,
current_page=page,
total_pages=total_pages, # V-New: 傳遞總項目數
total_items=total_items,
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), # 顯示台北時間
today_date=now_taipei.strftime('%Y-%m-%d'), # 傳遞今日日期
public_url=public_url,
current_category=category_filter,
current_filter=filter_type, # 傳遞當前篩選狀態
search_query=search_query, # 傳遞搜尋關鍵字
current_sort=sort_by,
current_order=order,
scheduler_stats=scheduler_stats,
# V9.2: 新增 KPI 數據
avg_increase=avg_increase,
avg_decrease=avg_decrease,
activity_rate=activity_rate,
active_count=active_count,
max_change_item=max_change_item,
max_change_value=max_change_value,
week_new_products=week_new_products,
stable_count=stable_count,
most_active_category=most_active_category,
most_active_count=most_active_count)
except Exception as e:
sys_log.error(f"[Web] [Dashboard] 🚨 渲染錯誤 | Error: {e}")
return f"系統維護中,錯誤詳情:{e}"
finally:
session.close()
@app.route('/settings')
def settings():
"""分類設定頁面"""
categories = load_categories()
return render_template('settings.html',
categories=categories,
public_url=public_url,
system_version=SYSTEM_VERSION)
@app.route('/system_settings')
def system_settings_page():
"""系統設定與匯入頁面"""
now_taipei = datetime.now(TAIPEI_TZ)
return render_template('system_settings.html', system_version=SYSTEM_VERSION, datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'))
@app.route('/api/categories', methods=['POST'])
def add_category():
"""API: 新增分類"""
name = request.form.get('name')
url = request.form.get('url')
if not name or not url:
return jsonify({"status": "error", "message": "名稱和 URL 皆不可為空"}), 400
categories = load_categories()
new_id = int(time.time() * 1000) # 使用時間戳作為簡易唯一 ID
categories.append({'id': new_id, 'name': name, 'url': url})
save_categories(categories)
return jsonify({"status": "success", "message": "分類新增成功"})
@app.route('/api/categories/<int:category_id>', methods=['PUT'])
def update_category(category_id):
"""API: 更新分類"""
name = request.form.get('name')
url = request.form.get('url')
if not name or not url:
return jsonify({"status": "error", "message": "名稱和 URL 皆不可為空"}), 400
categories = load_categories()
category_found = False
for cat in categories:
if cat.get('id') == category_id:
cat['name'] = name
cat['url'] = url
category_found = True
break
if not category_found:
return jsonify({"status": "error", "message": "找不到指定的分類 ID"}), 404
save_categories(categories)
return jsonify({"status": "success", "message": "分類更新成功"})
@app.route('/api/categories/<int:category_id>', methods=['DELETE'])
def delete_category(category_id):
"""API: 刪除分類"""
categories = [cat for cat in load_categories() if cat.get('id') != category_id]
save_categories(categories)
return jsonify({"status": "success", "message": "分類刪除成功"})
@app.route('/api/test_url', methods=['POST'])
def test_url():
"""API: 測試網址是否有效"""
try:
data = request.get_json()
url = data.get('url')
if not url:
return jsonify({"status": "error", "message": "網址不能為空"}), 400
import requests
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
# 設定 10 秒超時,避免卡住
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
return jsonify({"status": "success", "message": f"✅ 連結有效 (Status: 200)"})
else:
return jsonify({"status": "warning", "message": f"⚠️ 連結回應異常 (Status: {response.status_code})"})
except Exception as e:
return jsonify({"status": "error", "message": f"❌ 連線失敗: {str(e)}"}), 500
@app.route('/brand_assets')
def brand_assets():
"""顯示品牌資產庫"""
return render_template('brand_assets.html')
@app.route('/edm')
def edm_dashboard():
"""🚩 新增MOMO 限時搶購 (EDM) 專屬儀表板"""
db = DatabaseManager()
session = db.get_session()
# V-New: 排序參數
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
try:
# 1. 基礎統計
# 取得最後更新時間
last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料"
# 🚩 V9.29 新增:取得最新的活動時間文字
latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
activity_time = getattr(latest_entry, 'activity_time_text', '限時搶購') if latest_entry else "限時搶購"
# 2. 查詢資料 (V9.44: 只顯示最新批次的資料)
# 找出最新的 batch_id
latest_batch = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
current_batch_id = latest_batch[0] if latest_batch else None
# 🚩 V9.55 修正:改為查詢「全商品的最新狀態快照」,而非僅查詢最新批次
# 因為 Scheduler 現在只記錄異動,若只查最新批次,未變動的商品會消失
subq = session.query(
func.max(PromoProduct.id).label('max_id')
).filter(PromoProduct.page_type == 'edm').group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery()
latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all()
# 過濾顯示列表:顯示「上架中」、「本批次剛下架」或「今日結束時段」的商品
items_in_batch = []
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
for item in latest_records:
# V9.60: 隱藏自然結束的時段商品
# V-New: 如果商品是今天才結束的,則依然顯示在儀表板上,方便回查
if item.status_change == 'SLOT_END' and item.crawled_at < today_start:
continue
# V-New: 如果是下架狀態,只有當它是「今天」下架的才顯示
if item.status_change == 'DELISTED' and item.crawled_at < today_start:
continue
items_in_batch.append(item)
# V9.45: 按時段分組
grouped_items = {}
for item in items_in_batch:
if item.time_slot not in grouped_items:
grouped_items[item.time_slot] = []
grouped_items[item.time_slot].append(item)
# 按時段鍵值排序 (e.g., 00:00, 07:00, ...)
sorted_grouped_items = dict(sorted(grouped_items.items()))
# V9.45: 決定預設顯示的頁籤
def get_current_time_slot():
hour = datetime.now(TAIPEI_TZ).hour
available_slots = sorted([int(s.split(':')[0]) for s in sorted_grouped_items.keys() if s and ':' in s]) if sorted_grouped_items else [0, 7, 11, 14, 18, 22]
current_slot_hour = 0
for s in available_slots:
if hour >= s:
current_slot_hour = s
return f"{current_slot_hour:02d}:00"
active_tab = get_current_time_slot()
if active_tab not in sorted_grouped_items and sorted_grouped_items:
active_tab = next(iter(sorted_grouped_items))
# V-New: 計算在架天數與總銷量
all_icodes_in_batch = [item.i_code for item in items_in_batch]
product_categories = {}
days_on_shelf_map = {}
total_sold_map = {}
if all_icodes_in_batch:
# 從主商品表 (products) 查詢這些 i_code 對應的分類
main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all()
product_categories = {p.i_code: p.category for p in main_products}
# 計算上架天數 (days_on_shelf)
days_on_shelf_q = session.query(
PromoProduct.i_code,
func.count(func.distinct(func.strftime('%Y-%m-%d', PromoProduct.crawled_at)))
).filter( # V-New: 增加 page_type 過濾
PromoProduct.i_code.in_(all_icodes_in_batch),
PromoProduct.page_type == 'edm'
).group_by(PromoProduct.i_code).all()
days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q}
# 計算總銷量
# 1. 找出每個商品第一次有庫存紀錄的 ID
first_qty_subq = session.query(
PromoProduct.i_code,
func.min(PromoProduct.id).label('min_id')
).filter(
PromoProduct.i_code.in_(all_icodes_in_batch),
PromoProduct.remain_qty.isnot(None),
PromoProduct.page_type == 'edm'
).group_by(PromoProduct.i_code).subquery()
# 2. 根據 ID 取得當時的庫存
first_qty_records = session.query(
PromoProduct.i_code, PromoProduct.remain_qty
).join(first_qty_subq, PromoProduct.id == first_qty_subq.c.min_id).all()
first_qty_map = {r[0]: r[1] for r in first_qty_records}
# 3. 計算總銷量 (初始庫存 - 當前庫存)
for item in items_in_batch:
# 確保該商品有初始庫存紀錄,且當前庫存也存在
if item.i_code in first_qty_map and item.remain_qty is not None:
initial_qty = first_qty_map[item.i_code]
current_qty = item.remain_qty
# 只有在初始庫存大於當前庫存時才計算,避免負數
if initial_qty > current_qty:
total_sold_map[item.i_code] = initial_qty - current_qty
# V-Fix: 修正 NameError: name 'history_map' is not defined
# 準備銷售歷程資料
history_map = {}
if all_icodes_in_batch:
all_history_records = session.query(
PromoProduct.i_code,
PromoProduct.time_slot,
PromoProduct.remain_qty,
PromoProduct.crawled_at
).filter(
PromoProduct.i_code.in_(all_icodes_in_batch),
PromoProduct.crawled_at >= today_start
).order_by(PromoProduct.crawled_at).all()
for rec in all_history_records:
key = (rec.i_code, rec.time_slot)
if key not in history_map:
history_map[key] = []
if rec.remain_qty is not None:
if not history_map[key] or (history_map[key] and history_map[key][-1]['qty'] != rec.remain_qty):
history_map[key].append({'time': rec.crawled_at.strftime('%H:%M'), 'qty': rec.remain_qty})
# 將查到的分類資訊附加到每個 item 物件上
for item in items_in_batch:
item.main_category = product_categories.get(item.i_code)
if item.main_category:
item.category_color = get_color_for_string(item.main_category)
# V-New: 附加在架天數與總銷量
item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1)
item.total_sold = total_sold_map.get(item.i_code, 0)
# V-New: Attach quantity history
item.qty_history = history_map.get((item.i_code, item.time_slot), [])
# V9.46: 排序邏輯優化 (中文註解)
# 排序規則:
# 1. 有貼標 (main_category 存在) 的商品優先
# 2. 有狀態變更 (NEW, 漲價, 降價) 的商品次之
# 3. 已下架的商品再次之
# 4. 最後按價格由高到低排序
reverse = (order == 'desc')
for time_slot in sorted_grouped_items:
if sort_by == 'name':
sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse)
elif sort_by == 'remain_qty':
# 將 None 視為 -1確保排序時在最下方
sorted_grouped_items[time_slot].sort(key=lambda x: x.remain_qty if x.remain_qty is not None else -1, reverse=reverse)
elif sort_by == 'price':
sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse)
else: # 預設排序
sorted_grouped_items[time_slot].sort(key=lambda x: (
1 if x.main_category else 0,
2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0),
x.price if x.price is not None else -1
), reverse=True)
# 🚩 V-Fix: 修正時段統計,使其能區分「當前狀態」與「上次異動」
# V-New: 重構時段統計邏輯,確保統計所有今日異動
slot_stats = {}
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
# 1. 取得今日所有異動紀錄
today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == 'edm').all()
# 2. 取得所有相關時段的鍵 (今日異動的 + 當前在架的)
slots_from_changes = {rec.time_slot for rec in today_change_records}
slots_from_display = set(sorted_grouped_items.keys())
all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display)))
# 3. 初始化所有相關時段的統計數據
for slot in all_relevant_slots:
slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0}
# 4. 累加新品、漲價、降價、下架的數量 (從今日歷史紀錄)
for rec in today_change_records:
if rec.time_slot in slot_stats:
if rec.status_change == 'NEW':
slot_stats[rec.time_slot]['new'] += 1
elif rec.status_change == 'PRICE_UP':
slot_stats[rec.time_slot]['up'] += 1
elif rec.status_change == 'PRICE_DOWN':
slot_stats[rec.time_slot]['down'] += 1
elif rec.status_change in ['DELISTED', 'SLOT_END']:
slot_stats[rec.time_slot]['delisted_last_run'] += 1
# 5. 計算在架與下架總數 (從當前顯示的商品快照)
for slot, items in sorted_grouped_items.items():
if slot in slot_stats:
on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END'])
delisted_total_count = len(items) - on_shelf_count
slot_stats[slot]['on_shelf'] = on_shelf_count
slot_stats[slot]['delisted_total'] = delisted_total_count
# V-New: 建立儀表板頁籤
promo_pages = [
{'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
{'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}
]
scheduler_stats = load_scheduler_stats()
now_taipei = datetime.now(TAIPEI_TZ)
return render_template('edm_dashboard.html',
promo_pages=promo_pages,
current_promo_page='edm',
page_title='MOMO 限時搶購',
grouped_items=sorted_grouped_items,
slot_stats=slot_stats,
total_edm_products=len(items_in_batch),
last_update=last_update_str,
activity_time=activity_time,
active_tab=active_tab,
current_batch_id=current_batch_id,
public_url=public_url,
scheduler_stats=scheduler_stats,
current_sort=sort_by,
current_order=order,
slugify=slugify,
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'))
except Exception as e:
sys_log.error(f"🚨 EDM Dashboard 渲染錯誤: {e}")
return f"系統錯誤: {e}"
finally:
session.close()
@app.route('/festival')
def festival_dashboard():
"""🚩 新增1.1 狂歡購物節專屬儀表板"""
db = DatabaseManager()
session = db.get_session()
PAGE_TYPE = "festival"
PAGE_NAME = "1.1狂歡購物節"
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
try:
# 1. 基礎統計
last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first()
last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料"
latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first()
activity_time = getattr(latest_entry, 'activity_time_text', PAGE_NAME) if latest_entry else PAGE_NAME
# 2. 查詢資料
subq = session.query(
func.max(PromoProduct.id).label('max_id')
).filter(PromoProduct.page_type == PAGE_TYPE).group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery()
latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all()
items_in_batch = []
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
for item in latest_records:
if item.status_change == 'SLOT_END' and item.crawled_at < today_start:
continue
if item.status_change == 'DELISTED' and item.crawled_at < today_start:
continue
items_in_batch.append(item)
# 此頁面使用區塊標題作為分組依據
grouped_items = {}
for item in items_in_batch:
if item.time_slot not in grouped_items:
grouped_items[item.time_slot] = []
grouped_items[item.time_slot].append(item)
sorted_grouped_items = dict(sorted(grouped_items.items()))
# 預設顯示第一個頁籤
active_tab = next(iter(sorted_grouped_items)) if sorted_grouped_items else ""
all_icodes_in_batch = [item.i_code for item in items_in_batch]
product_categories = {}
days_on_shelf_map = {}
total_sold_map = {}
if all_icodes_in_batch:
main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all()
product_categories = {p.i_code: p.category for p in main_products}
days_on_shelf_q = session.query(
PromoProduct.i_code,
func.count(func.distinct(func.strftime('%Y-%m-%d', PromoProduct.crawled_at)))
).filter(
PromoProduct.i_code.in_(all_icodes_in_batch),
PromoProduct.page_type == PAGE_TYPE
).group_by(PromoProduct.i_code).all()
days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q}
# 將查到的分類資訊附加到每個 item 物件上
for item in items_in_batch:
item.main_category = product_categories.get(item.i_code)
if item.main_category:
item.category_color = get_color_for_string(item.main_category)
item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1)
# V-Fix: 為 festival 頁面提供預設值,避免共用模板在渲染 total_sold 和 qty_history 時出錯
item.total_sold = 0
item.qty_history = []
# 排序邏輯
reverse = (order == 'desc')
for time_slot in sorted_grouped_items:
if sort_by == 'name':
sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse)
elif sort_by == 'price':
sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse)
else: # 預設排序
sorted_grouped_items[time_slot].sort(key=lambda x: (
1 if x.main_category else 0,
2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0),
x.price if x.price is not None else -1
), reverse=True)
# 時段統計
slot_stats = {}
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == PAGE_TYPE).all()
slots_from_changes = {rec.time_slot for rec in today_change_records}
slots_from_display = set(sorted_grouped_items.keys())
all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display)))
for slot in all_relevant_slots:
slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0}
for rec in today_change_records:
if rec.time_slot in slot_stats:
if rec.status_change == 'NEW': slot_stats[rec.time_slot]['new'] += 1
elif rec.status_change == 'PRICE_UP': slot_stats[rec.time_slot]['up'] += 1
elif rec.status_change == 'PRICE_DOWN': slot_stats[rec.time_slot]['down'] += 1
elif rec.status_change in ['DELISTED', 'SLOT_END']: slot_stats[rec.time_slot]['delisted_last_run'] += 1
for slot, items in sorted_grouped_items.items():
if slot in slot_stats:
on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END'])
delisted_total_count = len(items) - on_shelf_count
slot_stats[slot]['on_shelf'] = on_shelf_count
slot_stats[slot]['delisted_total'] = delisted_total_count
scheduler_stats = load_scheduler_stats()
# 建立儀表板頁籤
promo_pages = [
{'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
{'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}
]
# 注意:這裡我們重複使用 edm_dashboard.html 範本
# 您需要建立一個它的複本,命名為 festival.html
return render_template('edm_dashboard.html',
promo_pages=promo_pages,
current_promo_page='festival',
page_title=PAGE_NAME,
grouped_items=sorted_grouped_items,
slot_stats=slot_stats,
total_edm_products=len(items_in_batch),
last_update=last_update_str,
activity_time=activity_time,
active_tab=active_tab,
public_url=public_url,
scheduler_stats=scheduler_stats,
current_sort=sort_by,
current_order=order,
slugify=slugify)
except Exception as e:
sys_log.error(f"🚨 {PAGE_NAME} Dashboard 渲染錯誤: {e}")
return f"系統錯誤: {e}"
finally:
session.close()
@app.route('/api/export/all_categories')
def export_all_categories():
"""🚩 需求 A處理全分類報表匯出請求"""
try:
sys_log.info("📊 執行全分類 CSV 數據導出...")
# 1. 獲取與看板一致的整合數據
items, _ = get_consolidated_data()
# 2. 呼叫匯出服務
exporter = Exporter()
file_path = exporter.generate_all_categories_report() # 此函式內部已處理按分類分 Sheet
if file_path:
# 🚩 強制轉為絕對路徑,解決 CWD 與 Flask Root Path 不一致導致的 404 問題
abs_file_path = os.path.abspath(file_path)
if os.path.exists(abs_file_path):
sys_log.info(f"✅ 報表匯出成功,準備下載: {abs_file_path}")
return send_file(abs_file_path, as_attachment=True)
return "匯出失敗:資料庫內尚無足夠數據", 404
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ 全分類報表匯出異常 | Error: {e}")
return f"匯出失敗,錯誤詳情:{e}", 500
# 🚩 V9.90: 新增 Excel 匯出路由
@app.route('/api/export/excel/all')
def export_excel_all():
try:
items, _ = get_consolidated_data()
exporter = Exporter()
file_path = exporter.generate_all_products_excel(items)
if file_path and os.path.exists(file_path):
return send_file(file_path, as_attachment=True)
return "匯出失敗", 500
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (All) | Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/excel/changes')
def export_excel_changes():
try:
items, _ = get_consolidated_data()
increase = [i for i in items if i['yesterday_diff'] > 0]
decrease = [i for i in items if i['yesterday_diff'] < 0]
exporter = Exporter()
file_path = exporter.generate_changes_excel(increase, decrease)
if file_path and os.path.exists(file_path):
return send_file(file_path, as_attachment=True)
return "匯出失敗", 500
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Changes) | Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/excel/delisted')
def export_excel_delisted():
db = DatabaseManager()
session = db.get_session()
try:
_, today_start = get_consolidated_data()
today_delisted_query = session.query(Product).filter(
Product.status == 'INACTIVE',
Product.updated_at >= today_start.replace(tzinfo=None)
)
raw_items = today_delisted_query.all()
delisted_items = [{'product': p, 'last_price': (session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first().price if session.query(PriceRecord).filter_by(product_id=p.id).first() else 0)} for p in raw_items]
exporter = Exporter()
file_path = exporter.generate_delisted_excel(delisted_items)
return send_file(file_path, as_attachment=True)
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Delisted) | Error: {e}")
return f"匯出失敗: {e}", 500
finally:
session.close()
@app.route('/api/export/price_changes')
def export_price_changes():
"""V9.4 更新:匯出今日價格異動明細 (支援篩選) - 修正:改用與儀表板相同的邏輯"""
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill
filter_type = request.args.get('type', '')
filter_category = request.args.get('category', '')
try:
db = DatabaseManager()
session = db.get_session()
# 使用與 /api/price_change_details 相同的邏輯
now_taipei = datetime.now(TAIPEI_TZ)
today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
# 基礎查詢:取得所有商品的最新記錄
latest_records_subq = session.query(
func.max(PriceRecord.id).label('max_id')
).group_by(PriceRecord.product_id).subquery()
query = session.query(PriceRecord, Product).join(
latest_records_subq,
PriceRecord.id == latest_records_subq.c.max_id
).join(Product, PriceRecord.product_id == Product.id)
# 一次性查詢所有商品的「今日之前最後價格」
product_ids = [r[0] for r in session.query(PriceRecord.product_id).join(
latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id
).all()]
yesterday_prices_subq = session.query(
PriceRecord.product_id,
func.max(PriceRecord.id).label('max_id')
).filter(
PriceRecord.product_id.in_(product_ids),
PriceRecord.timestamp < today_start
).group_by(PriceRecord.product_id).subquery()
yesterday_prices_q = session.query(
PriceRecord.product_id, PriceRecord.price
).join(
yesterday_prices_subq,
PriceRecord.id == yesterday_prices_subq.c.max_id
)
yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q}
products = []
# 根據 filter_type 篩選
if filter_type == 'increase':
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price > old_price:
products.append((product, record, old_price))
elif filter_type == 'decrease':
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price < old_price:
products.append((product, record, old_price))
elif filter_type == 'delisted':
today_delisted = session.query(Product).filter(
Product.status == 'INACTIVE',
Product.updated_at >= today_start
).all()
for product in today_delisted:
last_record = session.query(PriceRecord).filter(
PriceRecord.product_id == product.id
).order_by(PriceRecord.timestamp.desc()).first()
if last_record:
products.append((product, last_record, last_record.price))
elif filter_type == 'active':
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append((product, record, old_price))
elif filter_type == 'category' and filter_category:
for record, product in query.filter(Product.category == filter_category).all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append((product, record, old_price))
else:
# 預設:所有變動商品
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append((product, record, old_price))
session.close()
if not products:
return "無符合條件的商品資料", 404
# 建立 Excel
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "價格變動明細"
# 標題列
headers = ['商品ID', '商品名稱', '分類', '原價格', '現價格', '變動金額', '變動百分比', '更新時間', '商品網址']
ws.append(headers)
# 設定標題列樣式
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
header_font = Font(bold=True, color='FFFFFF')
for cell in ws[1]:
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center')
# 填充資料
for product, record, old_price in products:
change = record.price - old_price
change_pct = (change / old_price * 100) if old_price > 0 else 0
ws.append([
product.i_code,
product.name,
product.category or '未分類',
old_price,
record.price,
change,
f"{change_pct:.2f}%",
record.timestamp.strftime('%Y-%m-%d %H:%M'),
product.url
])
# 調整欄寬
ws.column_dimensions['A'].width = 12
ws.column_dimensions['B'].width = 40
ws.column_dimensions['C'].width = 15
ws.column_dimensions['D'].width = 12
ws.column_dimensions['E'].width = 12
ws.column_dimensions['F'].width = 12
ws.column_dimensions['G'].width = 12
ws.column_dimensions['H'].width = 18
ws.column_dimensions['I'].width = 50
# 儲存檔案
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"價格變動明細_{filter_type or 'all'}_{timestamp}.xlsx"
filepath = os.path.join(EXCEL_EXPORT_DIR, filename)
os.makedirs(EXCEL_EXPORT_DIR, exist_ok=True)
wb.save(filepath)
return send_file(filepath, as_attachment=True, download_name=filename)
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ 異動報表匯出失敗 | Type: {filter_type} | Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/low_prices')
def export_low_prices():
"""🚩 新增:匯出歷史低價商品"""
try:
exporter = Exporter()
file_path = exporter.generate_low_price_report()
if file_path and os.path.exists(file_path):
return send_file(file_path, as_attachment=True)
return "目前無歷史低價商品", 404
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ 低價報表匯出失敗 | Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/changes')
def export_changes():
"""🚩 需求:匯出篩選後的資料 (漲/跌/下架)"""
filter_type = request.args.get('type')
exporter = Exporter()
file_path = None
try:
unique_items, today_start = get_consolidated_data()
if filter_type == 'increase':
target_items = [i for i in unique_items if i['yesterday_diff'] > 0]
file_path = exporter.generate_custom_report(target_items, "今日漲價商品")
elif filter_type == 'decrease':
target_items = [i for i in unique_items if i['yesterday_diff'] < 0]
file_path = exporter.generate_custom_report(target_items, "今日跌價商品")
elif filter_type == 'delisted':
db = DatabaseManager()
session = db.get_session()
try:
today_start_naive = today_start.replace(tzinfo=None)
today_delisted_query = session.query(Product).filter(
Product.status == 'INACTIVE',
Product.updated_at >= today_start_naive
)
raw_delisted_items = today_delisted_query.all()
delisted_items_with_price = []
# 定義模擬物件 (移至迴圈外以提升效率)
class MockRecord:
def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at
for p in raw_delisted_items:
last_rec = session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first()
price = last_rec.price if last_rec else 0
delisted_items_with_price.append({'product': p, 'last_price': price})
file_path = exporter.generate_delisted_report(delisted_items_with_price, "今日下架商品")
finally:
session.close()
if file_path and os.path.exists(file_path):
return send_file(file_path, as_attachment=True)
return "無資料可匯出", 404
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ 篩選匯出失敗 | Type: {filter_type} | Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/excel/abc')
def export_abc_analysis():
"""API: 匯出 ABC 分析報表 (Excel)"""
try:
db = DatabaseManager()
table_name = 'realtime_sales_monthly'
# 1. 嘗試從快取讀取資料 (與 sales_analysis 共用快取)
df = None
cols_map = {}
if table_name in _SALES_PROCESSED_CACHE:
cache_data = _SALES_PROCESSED_CACHE[table_name]
df = cache_data['df']
cols_map = cache_data['cols']
else:
return "請先瀏覽「業績分析」頁面以載入資料與快取。", 400
# 恢復欄位變數
col_name = cols_map.get('name')
col_amount = cols_map.get('amount')
col_qty = cols_map.get('qty')
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_activity = cols_map.get('activity')
col_payment = cols_map.get('payment')
col_price = cols_map.get('price')
col_cost = cols_map.get('cost')
col_profit = cols_map.get('profit')
col_date = cols_map.get('date')
col_pid = cols_map.get('pid') # V-New: 取得商品ID欄位
# 2. 篩選資料 (複製 sales_analysis 的篩選邏輯以確保結果一致)
selected_category = request.args.get('category', 'all')
selected_brand = request.args.get('brand', 'all')
selected_vendor = request.args.get('vendor', 'all')
selected_activity = request.args.get('activity', 'all')
selected_payment = request.args.get('payment', 'all')
selected_dow = request.args.get('dow', 'all')
selected_hour = request.args.get('hour', 'all')
selected_month = request.args.get('month', 'all')
keyword = request.args.get('keyword', '').strip()
min_price = request.args.get('min_price', '')
max_price = request.args.get('max_price', '')
min_margin = request.args.get('min_margin', '')
max_margin = request.args.get('max_margin', '')
target_df = df.copy() # 複製一份以免修改到快取
# 重新計算 Top N 分類 (用於 '其他' 篩選)
TOP_N_CATS = 12
top_cats_names = []
if col_category:
cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False)
if len(cat_group_all) > TOP_N_CATS:
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
if selected_category != 'all' and col_category:
if selected_category == '其他' and top_cats_names:
target_df = target_df[~target_df[col_category].isin(top_cats_names)]
else:
target_df = target_df[target_df[col_category] == selected_category]
if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand]
if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor]
if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity]
if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment]
if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)]
if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)]
if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month]
if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)]
if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)]
if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)]
if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)]
if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)]
# 3. 執行 ABC 分析與匯出
if col_amount and not target_df.empty:
# V-Fix: 同步 abc_analysis_detail 的聚合邏輯,確保匯出數據與網頁一致
agg_rules = {col_amount: 'sum'}
if col_qty: agg_rules[col_qty] = 'sum'
if col_cost: agg_rules[col_cost] = 'sum'
if col_profit: agg_rules[col_profit] = 'sum'
if col_category: agg_rules[col_category] = 'first'
if col_vendor: agg_rules[col_vendor] = 'first'
if col_brand: agg_rules[col_brand] = 'first'
if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID
# 執行聚合
df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index()
# 重新計算聚合後的毛利率
if col_profit:
df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
elif col_cost:
df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
else:
df_agg['calculated_margin_rate'] = 0.0
df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
# 排序與 ABC 分類
target_df = df_agg.sort_values(by=col_amount, ascending=False)
target_df['cumulative_revenue'] = target_df[col_amount].cumsum()
total_revenue = target_df[col_amount].sum()
target_df['cumulative_pct'] = (target_df['cumulative_revenue'] / total_revenue) * 100
conditions = [(target_df['cumulative_pct'] <= 80), (target_df['cumulative_pct'] <= 95)]
choices = ['A', 'B']
target_df['ABC_Class'] = np.select(conditions, choices, default='C')
# V-New: 支援依類別篩選匯出 (例如只匯出 A 類)
filter_class = request.args.get('class')
if filter_class:
target_df = target_df[target_df['ABC_Class'] == filter_class]
# V-New: 計算平均單價 (Avg Unit Price)
if col_qty:
target_df['avg_unit_price'] = (target_df[col_amount] / target_df[col_qty]).fillna(0)
# V-New: 計算建議補貨量 (支援自訂係數)
if col_qty:
custom_factor = request.args.get('factor')
if custom_factor:
try:
factor = float(custom_factor)
# 若有指定係數,則全體套用 (通常用於單一類別匯出)
target_df['suggested_restock'] = (target_df[col_qty] * factor).astype(int)
except:
# 格式錯誤則回退至預設邏輯
conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')]
choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2]
target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int)
else:
# 預設邏輯 (A=1.5, B=1.2, C=0)
conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')]
choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2]
target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int)
# 整理匯出欄位
export_cols = []
header_map = {}
if col_pid: export_cols.append(col_pid); header_map[col_pid] = '商品ID' # V-New: 匯出商品ID
if col_name: export_cols.append(col_name); header_map[col_name] = '商品名稱'
if col_category: export_cols.append(col_category); header_map[col_category] = '分類'
if col_brand: export_cols.append(col_brand); header_map[col_brand] = '品牌'
if col_vendor: export_cols.append(col_vendor); header_map[col_vendor] = '廠商'
export_cols.append('ABC_Class'); header_map['ABC_Class'] = 'ABC分類'
if col_amount: export_cols.append(col_amount); header_map[col_amount] = '銷售金額'
if col_qty: export_cols.append(col_qty); header_map[col_qty] = '銷售數量'
# V-Fix: 移除 col_price 匯出,因為聚合後的資料表不包含原始單價欄位 (已由 avg_unit_price 取代)
if 'avg_unit_price' in target_df.columns:
export_cols.append('avg_unit_price'); header_map['avg_unit_price'] = '平均單價'
if col_cost: export_cols.append(col_cost); header_map[col_cost] = '成本'
if col_profit: export_cols.append(col_profit); header_map[col_profit] = '毛利'
if 'calculated_margin_rate' in target_df.columns:
export_cols.append('calculated_margin_rate'); header_map['calculated_margin_rate'] = '毛利率(%)'
if 'suggested_restock' in target_df.columns:
export_cols.append('suggested_restock')
header_map['suggested_restock'] = '建議補貨量'
export_df = target_df[export_cols].rename(columns=header_map)
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
export_df.to_excel(writer, index=False, sheet_name='ABC分析')
output.seek(0)
filename_prefix = f"ABC_Analysis_{filter_class}_" if filter_class else "ABC_Analysis_"
return send_file(output, as_attachment=True, download_name=f"{filename_prefix}{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
return "無資料可匯出", 404
except Exception as e:
sys_log.error(f"ABC Export Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/excel/vendor')
def export_vendor_analysis():
"""API: 匯出廠商獲利能力排行 (Excel)"""
try:
db = DatabaseManager()
table_name = 'realtime_sales_monthly'
# 1. 嘗試從快取讀取資料
df = None
cols_map = {}
if table_name in _SALES_PROCESSED_CACHE:
cache_data = _SALES_PROCESSED_CACHE[table_name]
df = cache_data['df']
cols_map = cache_data['cols']
else:
# V-Fix: 快取失效時,重定向到 sales_analysis 以重新載入資料
params = {k: v for k, v in request.args.items()}
flash('資料快取已失效,請稍候重新載入資料後再匯出。', 'warning')
return redirect(url_for('sales_analysis', **params))
# 恢復欄位變數
col_name = cols_map.get('name')
col_amount = cols_map.get('amount')
col_qty = cols_map.get('qty')
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_activity = cols_map.get('activity')
col_payment = cols_map.get('payment')
col_price = cols_map.get('price')
col_cost = cols_map.get('cost')
col_profit = cols_map.get('profit')
col_date = cols_map.get('date')
if not col_vendor:
return "無法識別廠商欄位,無法匯出。", 400
# 2. 篩選資料 (複製 sales_analysis 的篩選邏輯)
selected_category = request.args.get('category', 'all')
selected_brand = request.args.get('brand', 'all')
selected_vendor = request.args.get('vendor', 'all')
selected_activity = request.args.get('activity', 'all')
selected_payment = request.args.get('payment', 'all')
selected_dow = request.args.get('dow', 'all')
selected_hour = request.args.get('hour', 'all')
selected_month = request.args.get('month', 'all')
keyword = request.args.get('keyword', '').strip()
min_price = request.args.get('min_price', '')
max_price = request.args.get('max_price', '')
min_margin = request.args.get('min_margin', '')
max_margin = request.args.get('max_margin', '')
target_df = df.copy()
# Top N 分類處理
TOP_N_CATS = 12
top_cats_names = []
if col_category:
cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False)
if len(cat_group_all) > TOP_N_CATS:
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
if selected_category != 'all' and col_category:
if selected_category == '其他' and top_cats_names:
target_df = target_df[~target_df[col_category].isin(top_cats_names)]
else:
target_df = target_df[target_df[col_category] == selected_category]
if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand]
if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor]
if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity]
if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment]
if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)]
if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)]
if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month]
if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)]
if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)]
if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)]
if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)]
if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)]
# 3. 執行廠商聚合
if col_amount and not target_df.empty:
agg_dict = {col_amount: 'sum', col_name: 'nunique'}
if col_qty: agg_dict[col_qty] = 'sum' # V-Fix: 加入銷量聚合,否則無法計算 ASP
if col_profit:
agg_dict[col_profit] = 'sum'
elif col_cost:
agg_dict[col_cost] = 'sum'
vendor_group = target_df.groupby(col_vendor).agg(agg_dict).reset_index()
if col_profit:
vendor_group['total_profit'] = vendor_group[col_profit]
elif col_cost:
vendor_group['total_profit'] = vendor_group[col_amount] - vendor_group[col_cost]
else:
vendor_group['total_profit'] = 0
# V-Fix: 計算營收佔比 (Share %)
total_vendor_revenue = vendor_group[col_amount].sum()
vendor_group['revenue_share'] = (vendor_group[col_amount] / total_vendor_revenue * 100)
vendor_group['margin_rate'] = np.where(vendor_group[col_amount] > 0, (vendor_group['total_profit'] / vendor_group[col_amount] * 100), 0)
# V-Fix: 計算平均客單價 (ASP)
if col_qty:
vendor_group['asp'] = np.where(vendor_group[col_qty] > 0, vendor_group[col_amount] / vendor_group[col_qty], 0)
vendor_group['avg_sku_revenue'] = np.where(vendor_group[col_name] > 0, vendor_group[col_amount] / vendor_group[col_name], 0)
vendor_group = vendor_group.sort_values(by=col_amount, ascending=False)
# V-Fix: 更新匯出欄位以匹配儀表板
export_cols = [col_vendor, col_amount, 'revenue_share']
header_map = {col_vendor: '廠商名稱', col_amount: '總業績', 'revenue_share': '佔比(%)'}
if col_qty:
export_cols.extend([col_qty, 'asp'])
header_map.update({col_qty: '總銷量', 'asp': '平均客單(ASP)'})
export_cols.extend(['total_profit', 'margin_rate', col_name, 'avg_sku_revenue'])
header_map.update({'total_profit': '毛利額', 'margin_rate': '毛利率(%)', col_name: '商品數(SKU)', 'avg_sku_revenue': '平均單品產值'})
export_df = vendor_group[export_cols].rename(columns=header_map)
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
export_df.to_excel(writer, index=False, sheet_name='廠商排行')
output.seek(0)
return send_file(output, as_attachment=True, download_name=f"Vendor_Ranking_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
return "無資料可匯出", 404
except Exception as e:
sys_log.error(f"Vendor Export Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/abc_analysis/detail')
def abc_analysis_detail():
"""ABC 分析詳細報表頁面"""
try:
target_class = request.args.get('class', 'A') # 預設 A 類
table_name = 'realtime_sales_monthly'
# 1. 生成與主頁面一致的 cache_key
data_range_months = int(request.args.get('data_range', '0') or '0')
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
if start_date or end_date:
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
else:
cache_key = f"{table_name}_{data_range_months}m"
# 2. 使用共用篩選函式取得資料
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
# V-Fix: 如果 cache_key 不存在,嘗試後補使用 table_name 固定鍵值
if err and table_name in _SALES_PROCESSED_CACHE:
target_df, cols_map, err = _get_filtered_sales_data(table_name)
if err:
# V-Fix: 如果自動重載也失敗,則顯示稍後再試,並引導回主頁面
return f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>數據加載中 - WOOO TECH</title>
<style>
body {{ font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f8f9fa; }}
.card {{ background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); text-align: center; }}
.spinner {{ border: 3px solid #f3f3f3; border-top: 3px solid #1e3c72; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 0 auto 1rem; }}
@keyframes spin {{ 0% {{ transform: rotate(0deg); }} 100% {{ transform: rotate(360deg); }} }}
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<h3>數據準備中</h3>
<p>正在自動重新加載數據,請稍後...</p>
<script>
// 1.5 秒後嘗試重載當前頁面
setTimeout(function() {{
window.location.reload();
}}, 1500);
// 若重試 3 次仍失敗,引導回主頁
let retryCount = parseInt(sessionStorage.getItem('abc_retry') || '0');
if (retryCount > 3) {{
sessionStorage.removeItem('abc_retry');
alert('數據載入過久,請先在業績分析主頁重新整理。');
window.location.href = '/sales_analysis';
}} else {{
sessionStorage.setItem('abc_retry', retryCount + 1);
}}
</script>
</div>
</body>
</html>
''', 200
# 恢復欄位變數
col_name = cols_map.get('name')
col_amount = cols_map.get('amount')
col_qty = cols_map.get('qty')
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_price = cols_map.get('price')
col_cost = cols_map.get('cost')
col_profit = cols_map.get('profit')
col_date = cols_map.get('date')
col_pid = cols_map.get('pid')
# 3. 執行 ABC 分類
items = []
total_revenue = 0
if col_amount and not target_df.empty:
# V-Fix: 先針對商品進行聚合,確保 ABC 分析是基於「商品總銷量」而非「單筆訂單」
agg_rules = {col_amount: 'sum'}
if col_qty: agg_rules[col_qty] = 'sum'
if col_cost: agg_rules[col_cost] = 'sum'
if col_profit: agg_rules[col_profit] = 'sum'
if col_category: agg_rules[col_category] = 'first'
if col_vendor: agg_rules[col_vendor] = 'first'
if col_brand: agg_rules[col_brand] = 'first' # V-New: 加入品牌
if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID
if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique()))
df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index()
# 重新計算聚合後的毛利率
if col_profit:
df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
elif col_cost:
df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
else:
df_agg['calculated_margin_rate'] = 0.0
df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
# 執行 ABC 排序與計算
df_agg = df_agg.sort_values(by=col_amount, ascending=False)
df_agg['cumulative_revenue'] = df_agg[col_amount].cumsum()
total_revenue = df_agg[col_amount].sum()
df_agg['cumulative_pct'] = (df_agg['cumulative_revenue'] / total_revenue) * 100
conditions = [(df_agg['cumulative_pct'] <= 80), (df_agg['cumulative_pct'] <= 95)]
choices = ['A', 'B']
df_agg['ABC_Class'] = np.select(conditions, choices, default='C')
# 4. 篩選特定類別
class_df = df_agg[df_agg['ABC_Class'] == target_class].copy()
# V-New: 計算平均單價與庫存建議
if col_qty:
class_df['avg_unit_price'] = (class_df[col_amount] / class_df[col_qty]).fillna(0)
# V-New: 處理動態補貨係數
custom_factor = request.args.get('factor')
current_factor = 0.0
if custom_factor:
try:
current_factor = float(custom_factor)
except:
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
else:
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
class_df['suggested_restock'] = (class_df[col_qty] * current_factor).astype(int)
items = class_df.to_dict('records')
# 準備標題與描述
class_info = {
'A': {'title': 'A 類 - 核心商品', 'desc': '營收佔比前 80% 的主力商品,建議重點備貨與監控。', 'color': 'danger'},
'B': {'title': 'B 類 - 次要商品', 'desc': '營收佔比 80%~95% 的輔助商品,維持正常庫存。', 'color': 'warning'},
'C': {'title': 'C 類 - 長尾商品', 'desc': '營收佔比最後 5% 的長尾商品,建議評估清倉或縮減 SKU。', 'color': 'success'}
}
info = class_info.get(target_class, {'title': f'{target_class} 類', 'desc': '', 'color': 'secondary'})
# 計算 DataTables 預設排序欄位 (銷售金額) 的索引
# 欄位順序: Rank(0), [PID], Name, [Brand], [Vendor], [Cat], [Margin], [AvgPrice, Qty, Restock], Amount
sort_col_index = 1 # Rank
if col_pid: sort_col_index += 1
sort_col_index += 1 # Name
if col_brand: sort_col_index += 1
if col_vendor: sort_col_index += 1
if col_category: sort_col_index += 1
if col_cost or col_profit: sort_col_index += 1
if col_qty: sort_col_index += 3
# 此時 sort_col_index 即為 Amount 欄位的索引
return render_template('abc_analysis_detail.html',
items=items,
info=info,
target_class=target_class,
current_factor=current_factor, # V-New: 傳遞當前係數
total_revenue=total_revenue,
sort_col_index=sort_col_index, # V-New: 傳遞排序欄位索引
cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category,
'vendor': col_vendor, 'brand': col_brand, 'cost': col_cost, 'profit': col_profit, 'date': col_date, 'pid': col_pid},
# 傳遞當前查詢參數以供匯出連結使用
query_string=request.query_string.decode())
except Exception as e:
sys_log.error(f"ABC Detail Error: {e}")
return f"系統錯誤: {e}"
@app.route('/logs')
def show_logs():
now_taipei = datetime.now(TAIPEI_TZ)
return render_template('logs.html', datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'))
@app.route('/api/run_task', methods=['POST'])
def trigger_task():
try:
client_ip = request.remote_addr
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動執行請求 | IP: {client_ip}")
scheduled_job_wrapper()
return jsonify({"status": "success", "message": "爬蟲任務已在背景啟動"})
except Exception as e:
sys_log.error(f"[Web] [Task] ❌ 手動觸發任務失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/run_edm_task', methods=['POST'])
def trigger_edm_task():
"""🚩 新增:手動觸發 EDM 爬蟲任務"""
try:
target_lpn = "O1K5FBOqsvN" # 預設活動代碼
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 EDM 執行請求 | LPN: {target_lpn}")
# V-Fix: 強制重載 scheduler 模組,確保讀取到最新的截圖與通知邏輯
import importlib
import scheduler
importlib.reload(scheduler)
# 使用執行緒啟動,避免卡住 Web Server
task_thread = threading.Thread(target=scheduler.run_edm_task, args=(target_lpn,))
task_thread.daemon = True
task_thread.start()
return jsonify({"status": "success", "message": f"EDM 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"})
except Exception as e:
sys_log.error(f"[Web] [Task] ❌ 手動觸發 EDM 任務失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/run_festival_task', methods=['POST'])
def trigger_festival_task():
"""🚩 新增:手動觸發 1.1 狂歡購物節爬蟲任務"""
try:
target_lpn = "O7ylWfihYUM"
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 Festival 執行請求 | LPN: {target_lpn}")
# 使用執行緒啟動,避免卡住 Web Server
task_thread = threading.Thread(target=run_festival_task, args=(target_lpn,))
task_thread.daemon = True
task_thread.start()
return jsonify({"status": "success", "message": f"Festival 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"})
except Exception as e:
sys_log.error(f"[Web] [Task] ❌ 手動觸發 Festival 任務失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/trigger_momo_notification', methods=['POST'])
def trigger_momo_notification():
"""🚩 新增:手動觸發商品看板通知"""
try:
# 強制重載通知模組
import importlib
import scheduler
import services.notification_manager
importlib.reload(scheduler)
importlib.reload(services.notification_manager)
from services.notification_manager import NotificationManager
# 1. 取得統計數據
stats = get_dashboard_stats()
# 2. 截取儀表板畫面
dashboard_url = "http://127.0.0.1/"
screenshot_path = scheduler.capture_page_screenshot(dashboard_url, "momo_dashboard")
# 3. 發送通知
notifier = NotificationManager()
sys_log.info(f"[Web] [Notification] 📢 手動觸發 MOMO 通知")
notifier.send_momo_report(stats, screenshot_path)
return jsonify({"status": "success", "message": "已發送商品看板通知"})
except Exception as e:
sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/trigger_edm_notification', methods=['POST'])
def trigger_edm_notification():
"""🚩 新增:手動觸發 EDM 比價通知 (不重爬,僅重發)"""
try:
# V-Fix: 強制重新載入設定與通知模組,確保讀取到最新的 LINE ID (避免快取舊資料)
import importlib
import config
import services.notification_manager
import services.edm_notifier # V-New: 導入新的通知模組
importlib.reload(config)
importlib.reload(services.notification_manager)
importlib.reload(services.edm_notifier)
db = DatabaseManager()
session = db.get_session()
try:
# V-Fix: 改為只抓取最新一批次的異動資料,避免訊息過長
# 1. 找出最新的 batch_id
latest_batch_tuple = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
if not latest_batch_tuple:
return jsonify({"status": "warning", "message": "目前無 EDM 商品資料,請先執行爬蟲"}), 400
latest_batch_id = latest_batch_tuple[0]
# 2. 取得最新批次的所有異動商品
products = session.query(PromoProduct).filter(PromoProduct.batch_id == latest_batch_id).all()
if not products:
return jsonify({"status": "info", "message": "最新一輪掃描中無任何商品異動"}), 200
# V-Fix: 手動觸發時,嘗試尋找對應的截圖檔案
screenshot_path = None
try:
filename = f"edm_{latest_batch_id}.png"
potential_path = os.path.join(BASE_DIR, 'web/static/screenshots', filename)
if os.path.exists(potential_path):
screenshot_path = potential_path
except Exception: pass
from services.edm_notifier import EdmNotifier
notifier = EdmNotifier()
sys_log.info(f"[Web] [Notification] 📢 手動觸發 EDM 通知 | Count: {len(products)} | BatchID: {latest_batch_id}")
notifier.send_edm_report(products, screenshot_path)
return jsonify({"status": "success", "message": f"已針對最新批次的 {len(products)} 筆商品異動發送通知"})
finally:
session.close()
except Exception as e:
sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/test_notification', methods=['POST'])
def test_notification():
"""🚩 新增:測試訊息通知功能"""
try:
from services.notification_manager import NotificationManager
import config
import requests
notifier = NotificationManager()
# --- 🕵️‍♂️ V9.13 更新Messaging API 診斷邏輯 ---
sys_log.info("[Web] [Notification] 🕵️‍♂️ 執行手動通知發送測試 (Line/Telegram/Email)...")
token = getattr(config, 'LINE_CHANNEL_ACCESS_TOKEN', None)
target_id = getattr(config, 'LINE_GROUP_ID', None)
if token and target_id:
sys_log.info(f"[Web] [Notification] 🔑 偵測到 Channel Token: {token[:4]}...{token[-4:]}")
sys_log.info(f"[Web] [Notification] 🎯 目標 ID: {target_id}")
# 2. 嘗試直接發送請求
try:
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
payload = {
"to": target_id,
"messages": [
{
"type": "text",
"text": "🧪 這是系統診斷測試訊息 (Messaging API)\n\n✅ 連線測試成功!"
}
]
}
sys_log.info("[Web] [Notification] 📡 正在嘗試連線至 Line Messaging API (push)...")
resp = requests.post("https://api.line.me/v2/bot/message/push", headers=headers, json=payload, timeout=10)
sys_log.info(f"[Web] [Notification] 📩 Line API 回應 | Code: {resp.status_code}")
sys_log.info(f"[Web] [Notification] 📄 Line API 內容 | Body: {resp.text}")
if resp.status_code != 200:
return jsonify({"status": "error", "message": f"❌ Line API 拒絕連線: {resp.status_code} - {resp.text}"}), 400
except Exception as req_err:
sys_log.error(f"[Web] [Notification] ❌ 直接連線測試發生異常 | Error: {req_err}")
return jsonify({"status": "error", "message": f"連線異常: {req_err}"}), 500
else:
sys_log.warning("[Web] [Notification] ⚠️ 無法偵測到 Messaging API 設定 (Token 或 Group ID 缺失)")
return jsonify({"status": "error", "message": "設定檔缺少 LINE_CHANNEL_ACCESS_TOKEN 或 LINE_GROUP_ID"}), 400
# 🚩 V9.14 修改:呼叫真實的日報發送邏輯
notifier.send_daily_report()
return jsonify({"status": "success", "message": "✅ 當日異動通知已發送 (Line/Telegram/Email)"})
except ImportError:
return jsonify({"status": "error", "message": "❌ 找不到 NotificationManager 模組"}), 500
except Exception as e:
sys_log.error(f"[Web] [Notification] ❌ 測試通知失敗 | Error: {e}")
return jsonify({"status": "error", "message": f"發送失敗: {str(e)}"}), 500
@app.route('/api/logs')
def get_logs_api():
if os.path.exists(LOG_FILE_PATH):
try:
with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f:
return jsonify({"logs": "".join(f.readlines()[-60:])})
except Exception as e:
sys_log.error(f"[Web] [Logs] ❌ 日誌 API 讀取異常 | Error: {e}")
return jsonify({"logs": "讀取日誌異常"})
return jsonify({"logs": "等待系統啟動中..."})
# 🚩 V9.82: 新增歷史價格 API
@app.route('/api/history/<int:product_id>')
def get_price_history(product_id):
"""API: 取得商品過去 180 天的價格歷史"""
db = DatabaseManager()
session = db.get_session()
try:
# 計算 180 天前的日期
start_date = datetime.now(TAIPEI_TZ).replace(tzinfo=None) - timedelta(days=180)
records = session.query(PriceRecord).filter(
PriceRecord.product_id == product_id,
PriceRecord.timestamp >= start_date
).order_by(PriceRecord.timestamp).all()
data = [{
't': r.timestamp.strftime('%Y-%m-%d %H:%M'),
'p': r.price
} for r in records]
return jsonify(data)
except Exception as e:
sys_log.error(f"[Web] [History] ❌ 獲取歷史價格失敗 | ProductID: {product_id} | Error: {e}")
return jsonify([]), 500
finally:
session.close()
@app.route('/api/price_change_details')
def get_price_change_details():
"""API: V9.4 取得價格變動商品明細 (供彈窗使用) - 修正:改用與儀表板相同的邏輯"""
filter_type = request.args.get('type', '')
filter_category = request.args.get('category', '')
filter_product_id = request.args.get('product_id', '')
db = DatabaseManager()
session = db.get_session()
try:
# 取得今日起始時間
now_taipei = datetime.now(TAIPEI_TZ)
today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
# 基礎查詢:取得所有商品的最新記錄 (與儀表板相同邏輯)
latest_records_subq = session.query(
func.max(PriceRecord.id).label('max_id')
).group_by(PriceRecord.product_id).subquery()
query = session.query(PriceRecord, Product).join(
latest_records_subq,
PriceRecord.id == latest_records_subq.c.max_id
).join(Product, PriceRecord.product_id == Product.id)
# 一次性查詢所有商品的「今日之前最後價格」(yesterday_prices_map)
product_ids = [r[0] for r in session.query(PriceRecord.product_id).join(
latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id
).all()]
yesterday_prices_subq = session.query(
PriceRecord.product_id,
func.max(PriceRecord.id).label('max_id')
).filter(
PriceRecord.product_id.in_(product_ids),
PriceRecord.timestamp < today_start
).group_by(PriceRecord.product_id).subquery()
yesterday_prices_q = session.query(
PriceRecord.product_id, PriceRecord.price
).join(
yesterday_prices_subq,
PriceRecord.id == yesterday_prices_subq.c.max_id
)
yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q}
# 根據 filter_type 進行篩選
products = []
if filter_type == 'increase':
# 漲價商品 - 比對今日最新價格與今日之前的最後價格
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price > old_price:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': old_price,
'current_price': record.price,
'change': record.price - old_price,
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
})
elif filter_type == 'decrease':
# 降價商品
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price < old_price:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': old_price,
'current_price': record.price,
'change': record.price - old_price,
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
})
elif filter_type == 'delisted':
# 下架商品 (今日狀態為 INACTIVE 且今天更新的)
today_delisted = session.query(Product).filter(
Product.status == 'INACTIVE',
Product.updated_at >= today_start
).all()
for product in today_delisted:
last_record = session.query(PriceRecord).filter(
PriceRecord.product_id == product.id
).order_by(PriceRecord.timestamp.desc()).first()
if last_record:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': last_record.price,
'current_price': 0,
'change': 0,
'update_time': last_record.timestamp.strftime('%Y-%m-%d %H:%M')
})
elif filter_type == 'active':
# 活躍商品 (今日有價格變動的)
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': old_price,
'current_price': record.price,
'change': record.price - old_price,
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
})
elif filter_type == 'category' and filter_category:
# 特定分類的變動商品
for record, product in query.filter(Product.category == filter_category).all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': old_price,
'current_price': record.price,
'change': record.price - old_price,
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
})
elif filter_type == 'max_change' and filter_product_id:
# 最大變動商品 - 只顯示指定的單一商品
for record, product in query.filter(Product.i_code == filter_product_id).all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': old_price,
'current_price': record.price,
'change': record.price - old_price,
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
})
break # 只需要一件商品
return jsonify({'products': products})
except Exception as e:
sys_log.error(f"[Web] [PriceChangeDetails] ❌ 獲取價格變動明細失敗 | Type: {filter_type} | Error: {e}")
return jsonify({'products': []}), 500
finally:
session.close()
@app.route('/api/backup', methods=['POST'])
def trigger_backup():
"""API: 觸發系統完整備份"""
# Note: [功能] 尚未實作「系統還原」功能 (Restore),需評估安全性後加入
try:
sys_log.info("[System] [Backup] 💾 開始執行系統完整備份...")
backup_dir = os.path.join(BASE_DIR, 'backups')
if not os.path.exists(backup_dir):
os.makedirs(backup_dir)
timestamp = datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')
zip_filename = f"momo_system_backup_{SYSTEM_VERSION}_{timestamp}.zip"
zip_filepath = os.path.join(backup_dir, zip_filename)
with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(BASE_DIR):
# 排除不必要的目錄
dirs[:] = [d for d in dirs if d not in ['backups', '__pycache__', 'venv', '.git', '.idea', '.vscode', 'node_modules']]
for file in files:
if file == zip_filename: continue # 跳過正在寫入的檔案
if file.endswith('.pyc') or file.endswith('.DS_Store'): continue
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, BASE_DIR)
zipf.write(file_path, arcname)
sys_log.info(f"[System] [Backup] ✅ 系統備份完成 | File: {zip_filename}")
# V-New: 回傳下載連結
download_url = url_for('download_backup', filename=zip_filename)
return jsonify({
"status": "success",
"message": f"備份成功!\n檔案已儲存為: {zip_filename}\n即將開始下載...",
"download_url": download_url
})
except Exception as e:
sys_log.error(f"[System] [Backup] ❌ 備份失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/backup/download/<path:filename>')
def download_backup(filename):
"""
API: 下載備份檔案(已加入路徑遍歷防護)
"""
try:
backup_dir = os.path.join(BASE_DIR, 'backups')
# 使用 safe_join 驗證路徑,防止路徑遍歷攻擊
safe_path = safe_join(backup_dir, filename)
# 確保檔案存在
if not safe_path.exists():
sys_log.warning(f"[Security] 備份檔案不存在 | File: {filename}")
return jsonify({'error': '檔案不存在'}), 404
# 確保是檔案而非目錄
if not safe_path.is_file():
sys_log.warning(f"[Security] 嘗試下載非檔案路徑 | Path: {filename}")
return jsonify({'error': '非法路徑'}), 400
return send_from_directory(backup_dir, safe_path.name, as_attachment=True)
except ValueError as e:
# safe_join 偵測到路徑遍歷嘗試
sys_log.error(f"[Security] 路徑遍歷攻擊嘗試被阻擋 | Filename: {filename} | Error: {e}")
return jsonify({'error': '非法路徑'}), 400
except Exception as e:
sys_log.error(f"[System] 下載備份失敗 | Error: {e}")
return jsonify({'error': '下載失敗'}), 500
@app.route('/api/import_excel', methods=['POST'])
def import_excel():
"""
API: 匯入 Excel/CSV 並自動建表
已加入檔案上傳安全驗證 (副檔名白名單、檔案名稱清理)
"""
try:
# 1. 檢查是否有上傳檔案
if 'file' not in request.files:
return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400
file = request.files['file']
# 2. 使用安全驗證函數
is_valid, error_msg, safe_name = validate_upload_file(file)
if not is_valid:
sys_log.warning(f"[Security] 檔案上傳驗證失敗 | Filename: {file.filename} | Error: {error_msg}")
return jsonify({'status': 'error', 'message': error_msg}), 400
sys_log.info(f"[Web] [Import] 檔案上傳驗證通過 | Original: {file.filename} | Safe: {safe_name}")
# 3. 根據副檔名讀取檔案
df = None
filename_lower = safe_name.lower()
if filename_lower.endswith(('.xlsx', '.xls')):
try:
df = pd.read_excel(file, engine='openpyxl', dtype=str)
except Exception as e:
return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500
elif filename_lower.endswith('.csv'):
try:
# V-New: 嘗試用多種編碼讀取 CSV
try:
df = pd.read_csv(file, dtype=str)
except UnicodeDecodeError:
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
else:
# 理論上不會到這裡,因為 validate_upload_file 已經檢查過
return jsonify({'status': 'error', 'message': '不支援的檔案格式'}), 400
if df is None:
return jsonify({'status': 'error', 'message': '無法讀取檔案內容'}), 500
# V-New: 增加日誌以確認目前為原始匯入模式 (提醒使用者已略過清理)
sys_log.info("[Web] [Import] ⚠️ 偵測到原始匯入模式 (Raw Import Mode) - 已略過智慧清理")
# V-Fix: 1. 先標準化欄位名稱,確保後續關鍵字比對準確
# df.columns = [str(c).strip().replace(' ', '_').replace('-', '_').replace('(', '').replace(')', '').replace('/', '_') for c in df.columns]
# V-Fix: 2. 執行智慧資料清理 (v3 保守模式 - 解決 'F' 被強制轉 0 的問題)
# sys_log.info("[Web] [Import] 執行智慧資料清理程序 (v3 保守模式)...")
# 定義必須是數值的欄位關鍵字 (這些欄位必須是數字,髒資料轉 0 以免影響計算)
# numeric_keywords = ['序號', '數量', '單價', '金額', '成本', '毛利', '售價', '應收', '營收',
# 'Quantity', 'Qty', 'Price', 'Amount', 'Cost', 'Profit', 'Sales', 'Revenue']
# for col in df.columns:
# # 判斷是否為強制數值欄位
# is_force_numeric = any(k in col for k in numeric_keywords)
# if df[col].dtype == 'object':
# if is_force_numeric:
# # 策略 A: 強制數值欄位 -> 激進清理 (保留數字,其餘轉 0)
# # 先移除千分位逗號等非數值字符
# cleaned_series = df[col].astype(str).str.replace(r'[^\d.-]', '', regex=True)
# converted_series = pd.to_numeric(cleaned_series, errors='coerce')
# df[col] = converted_series.fillna(0)
# sys_log.info(f"[Web] [Import] 強制清理數值欄位 '{col}' (髒資料已轉為 0)")
# else:
# # 策略 B: 一般欄位 -> 保守檢查 (保留 'F' 等文字)
# # 直接嘗試轉換,不移除文字
# converted_series = pd.to_numeric(df[col], errors='coerce')
# # 檢查有多少值變成了 NaN (原本不是 NaN/空字串,但轉換後變成 NaN 的)
# original_valid_mask = df[col].notna() & (df[col].astype(str).str.strip() != '')
# converted_valid_mask = converted_series.notna()
# loss_count = (original_valid_mask & ~converted_valid_mask).sum()
# if loss_count == 0:
# # 如果沒有資料損失 (代表全是數字或空值),才轉換
# df[col] = converted_series
# else:
# # 有資料損失 (例如包含 'F'),保留為文字
# sys_log.info(f"[Web] [Import] 欄位 '{col}' 保留為文字 (含 {loss_count} 筆非數值資料,如 'F')")
# 識別檔案類型
is_daily_sales = '即時業績' in file.filename and '當日' in file.filename
is_sales_report = '即時業績' in file.filename and '全月' in file.filename
if is_daily_sales:
table_name = 'daily_sales_snapshot'
# V-New: 智慧匯入 - 根據 Excel 內的日期欄位自動拆分 snapshot_date
date_col = None
for possible_col in ['日期', '訂單日期', '交易日期', 'Date']:
if possible_col in df.columns:
date_col = possible_col
break
if date_col:
# 使用 Excel 內的日期欄位作為 snapshot_date
sys_log.info(f"[Web] [Import] 使用 Excel 內的「{date_col}」欄位作為快照日期")
# 將日期欄位轉換為標準格式 YYYY-MM-DD
df['snapshot_date'] = pd.to_datetime(df[date_col], errors='coerce').dt.strftime('%Y-%m-%d')
# 移除無效日期的資料
invalid_count = df['snapshot_date'].isna().sum()
if invalid_count > 0:
sys_log.warning(f"[Web] [Import] 發現 {invalid_count} 筆無效日期資料,已移除")
df = df.dropna(subset=['snapshot_date'])
unique_dates = df['snapshot_date'].nunique()
sys_log.info(f"[Web] [Import] 識別為當日業績報表,包含 {unique_dates} 個不同日期")
else:
# 備用方案:從檔名提取日期
snapshot_date = extract_snapshot_date_from_filename(file.filename)
if not snapshot_date:
return jsonify({'status': 'error', 'message': '無法從檔名提取日期,且 Excel 中無日期欄位'}), 400
df['snapshot_date'] = snapshot_date
sys_log.info(f"[Web] [Import] Excel 無日期欄位,使用檔名日期: {snapshot_date}")
elif is_sales_report:
table_name = 'realtime_sales_monthly'
else:
filename_no_ext = os.path.splitext(file.filename)[0]
table_name = re.sub(r'[^\w\u4e00-\u9fff]+', '_', filename_no_ext).strip('_')
if not table_name: table_name = f"import_{int(time.time())}"
db = DatabaseManager()
engine = db.engine
# V-Debug: 顯示實際寫入的資料庫路徑
sys_log.info(f"[Web] [Import] 正在寫入資料庫: {engine.url}")
if table_name in ['realtime_sales_monthly', 'daily_sales_snapshot']:
try:
# V-Fix: 實作自動去重邏輯 (Deduplication)
# 1. 檢查資料表是否存在
inspector = inspect(engine)
if not inspector.has_table(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} 筆資料。'
else:
sys_log.info(f"[Web] [Import] 資料表已存在,執行自動去重 (Deduplication)...")
# 2. 讀取現有資料(優化:僅讀取相關日期的資料以進行去重)
try:
# 嘗試根據 incoming df 的日期範圍來過濾現有資料
filter_clause = ""
if '日期' in df.columns:
# V-Fix: 確保日期格式與資料庫一致 (YYYY/MM/DD) 以便 SQL IN 查詢能正確比對
# 有時 Pandas 會將其轉換為 datetime 或 2024-01-01 格式
temp_dates = pd.to_datetime(df['日期'], errors='coerce')
unique_dates = temp_dates.dropna().dt.strftime('%Y/%m/%d').unique()
if len(unique_dates) > 0:
date_list = "', '".join([str(d) for d in unique_dates])
filter_clause = f" WHERE 日期 IN ('{date_list}')"
sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(unique_dates)} 個日期相關的現有資料 (範例: {unique_dates[0]})")
elif 'snapshot_date' in df.columns:
unique_dates = df['snapshot_date'].dropna().unique()
if len(unique_dates) > 0:
date_list = "', '".join([str(d) for d in unique_dates])
filter_clause = f" WHERE snapshot_date IN ('{date_list}')"
sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(unique_dates)} 個快照日期相關的現有資料")
if filter_clause:
# V-Security: 使用參數化查詢,防止 SQL Injection
# 注意SQLAlchemy 的 text() 配合綁定參數
# 針對 'IN' 查詢SQLite 支援綁定多個參數
try:
from sqlalchemy import text
# 準備參數字典,例如 {'d0': '2024/01/01', 'd1': '2024/01/02', ...}
params = {f"d{i}": str(d) for i, d in enumerate(unique_dates)}
param_names = ", ".join([f":d{i}" for i in range(len(unique_dates))])
date_col = "日期" if '日期' in df.columns else "snapshot_date"
sql = text(f"SELECT * FROM {table_name} WHERE {date_col} IN ({param_names})")
# V-Debug
# sys_log.debug(f"[Web] [Import] SQL Filter: {sql} with {len(params)} params")
# Pandas read_sql 支援與參數一起執行
df_existing = pd.read_sql(sql, con=engine, params=params)
except Exception as sql_err:
sys_log.error(f"[Web] [Import] 建立參數化 SQL 失敗: {sql_err}")
# 備用方案 (Sanitized)
df_existing = safe_read_sql(table_name, engine=engine)
else:
# 備用方案:若無日期欄位,仍讀取全表
sys_log.warning(f"[Web] [Import] ⚠️ 無法根據日期過濾,讀取全表進行去重 (可能效能較差)")
df_existing = safe_read_sql(table_name, engine=engine)
except Exception as e:
sys_log.warning(f"[Web] [Import] ⚠️ 讀取舊資料失敗 ({e}),略過去重直接累加。")
df_existing = pd.DataFrame()
rows_to_write = df
if not df_existing.empty:
# 3. 執行比對 (找出共有欄位)
common_cols = list(set(df.columns) & set(df_existing.columns))
# 針對 daily_sales_snapshot 使用特定去重鍵
if table_name == 'daily_sales_snapshot':
# 優先使用 snapshot_date + 訂單編號
if 'snapshot_date' in common_cols and '訂單編號' in common_cols:
common_cols = ['snapshot_date', '訂單編號']
sys_log.info(f"[Web] [Import] 使用去重鍵: snapshot_date + 訂單編號")
elif 'snapshot_date' in common_cols:
# 備用方案:使用所有共有欄位
sys_log.info(f"[Web] [Import] 使用全欄位去重 (共 {len(common_cols)} 個欄位)")
if common_cols:
# 轉換為字串以確保比對準確 (處理 NaN 與型別差異)
# V-Fix: 加強去重邏輯,處理 '100.0' vs '100' 的問題
def normalize_series(s):
return s.astype(str).str.strip().str.replace(r'\.0$', '', regex=True)
df_str = df[common_cols].apply(normalize_series).fillna('')
existing_str = df_existing[common_cols].apply(normalize_series).fillna('')
# 移除 df_existing 中的重複項 (優化 merge 效能)
existing_str = existing_str.drop_duplicates()
# 使用 merge 找出 df 中已存在的資料
merged = df_str.merge(existing_str, on=common_cols, how='left', indicator=True)
# 只保留 'left_only' 的資料 (即新資料)
rows_to_write = df[merged['_merge'] == 'left_only']
duplicates_count = len(df) - len(rows_to_write)
sys_log.info(f"[Web] [Import] 🔍 自動去重: 發現 {duplicates_count} 筆重複資料,已忽略。")
# 4. 寫入新資料
if not rows_to_write.empty:
rows_to_write.to_sql(table_name, con=engine, if_exists='append', index=False)
rows_imported = len(rows_to_write)
message = f'匯入成功!已去重並新增 {rows_imported} 筆資料。'
else:
rows_imported = 0
message = '匯入完成,但所有資料皆已存在 (重複),無新增數據。'
# V-Fix: 無條件清除快取,確保行事曆能夠顯示最新資料
# 原問題:只有 rows_imported > 0 時才清除快取,導致匯入後行事曆不更新
if table_name in _SALES_DF_CACHE:
del _SALES_DF_CACHE[table_name]
sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}")
# V-Opt: 清除所有相關的處理後快取(包含不同 data_range 的快取)
cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)]
for cache_key in cache_keys_to_delete:
del _SALES_PROCESSED_CACHE[cache_key]
sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}")
return jsonify({'status': 'success', 'message': message, 'rows': rows_imported, 'table': table_name})
except Exception as de:
sys_log.error(f"[Web] [Import] 業績報表匯入去重或寫入時發生錯誤: {de}")
return jsonify({'status': 'error', 'message': f'業績報表匯入失敗: {de}'}), 500
else:
# 對於非業績報表,維持覆蓋邏輯
sys_log.info(f"[Web] [Import] 使用覆蓋模式 (replace)寫入資料表: {table_name}")
df.to_sql(table_name, con=engine, if_exists='replace', index=False)
if table_name in _SALES_DF_CACHE:
del _SALES_DF_CACHE[table_name]
sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}")
# V-Opt: 清除所有相關的處理後快取
cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)]
for cache_key in cache_keys_to_delete:
del _SALES_PROCESSED_CACHE[cache_key]
sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}")
return jsonify({'status': 'success', 'message': f'通用匯入成功!資料已覆蓋至 {table_name}。', '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
@app.route('/api/import/monthly_summary', methods=['POST'])
def import_monthly_summary():
"""API: 匯入月份總表數據分析"""
try:
if 'file' not in request.files:
return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400
file = request.files['file']
is_valid, error_msg, safe_name = validate_upload_file(file)
if not is_valid:
sys_log.warning(f"[Security] 月份總表上傳驗證失敗: {error_msg}")
return jsonify({'status': 'error', 'message': error_msg}), 400
# 讀取 Excel
try:
df = pd.read_excel(file, engine='openpyxl')
except Exception as e:
return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500
if df.empty:
return jsonify({'status': 'error', 'message': '檔案內容為空'}), 400
# 欄位對照表 (對應 Excel 繁體中文標題與資料庫英文欄位)
mapping = {
'年': 'year', '月': 'month', '商品部': 'department', '3C百貨': 'category_3c',
'處別': 'division', '科別': 'section', '區ID': 'area_id', '區名稱': 'area_name',
'商品_PM': 'pm_name', '品牌名稱_合併': 'brand_name', '廠商編號': 'vendor_id',
'廠商名稱': 'vendor_name', '借採轉': 'trade_type', '件單價': 'unit_price',
'銷售額_本月': 'sales_amt_curr', '銷售額_上月': 'sales_amt_prev', '銷售額_去年同期': 'sales_amt_yoa',
'毛1額_本月': 'profit_amt_curr', '毛1額_上月': 'profit_amt_prev', '毛1額_去年同期': 'profit_amt_yoa',
'折扣金額_本月': 'discount_amt_curr', '折扣金額_上月': 'discount_amt_prev', '折扣金額_去年同期': 'discount_amt_yoa',
'折價券_本月': 'coupon_amt_curr', '折價券_上月': 'coupon_amt_prev', '折價券_去年同期': 'coupon_amt_yoa',
'其他行銷活動_本月': 'other_mkt_curr', '其他行銷活動_上月': 'other_mkt_prev', '其他行銷活動_去年同期': 'other_mkt_yoa',
'點我折_本月': 'spot_disc_curr', '點我折_上月': 'spot_disc_prev', '點我折_去年同期': 'spot_disc_yoa',
'點數折抵_本月': 'point_disc_curr', '點數折抵_上月': 'point_disc_prev', '點數折抵_去年同期': 'point_disc_yoa',
'銷售量_本月': 'sales_vol_curr', '銷售量_上月': 'sales_vol_prev', '銷售量_去年同期': 'sales_vol_yoa',
'轉換率': 'conv_rate', '瀏覽數_本月': 'views_curr', '瀏覽數_上月': 'views_prev', '瀏覽數_去年同期': 'views_yoa'
}
# 檢查必備欄位 (寬鬆檢查:只要有 mapping 中的欄位就匯入)
current_cols = df.columns.tolist()
import_mapping = {k: v for k, v in mapping.items() if k in current_cols}
if len(import_mapping) < 5: # 至少要有幾個維度
return jsonify({'status': 'error', 'message': '檔案欄位不符,請確認是否為正確的月份業績總表'}), 400
# 重新命名與清理資料
target_df = df[list(import_mapping.keys())].rename(columns=import_mapping)
# 轉換數值欄位,填補 NaN
numeric_cols = [v for k, v in import_mapping.items() if v not in [
'department', 'category_3c', 'division', 'section', 'area_id', 'area_name',
'pm_name', 'brand_name', 'vendor_name', 'trade_type'
]]
for col in numeric_cols:
target_df[col] = pd.to_numeric(target_df[col], errors='coerce').fillna(0)
# 寫入資料庫 - 優化效能版本 (Phase 9 Optimization)
db = DatabaseManager()
engine = db.engine
try:
# 取得要匯入的年月份,用於先行刪除重複資料
years_months = target_df[['year', 'month']].drop_duplicates()
with engine.begin() as conn:
# 1. 刪除該月份舊資料 (Transaction 開始)
for _, row in years_months.iterrows():
conn.execute(text("DELETE FROM monthly_summary_analysis WHERE year = :y AND month = :m"),
{'y': int(row['year']), 'm': int(row['month'])})
# 2. 批量寫入 (使用 multi 方法加速SQLite chunksize 建議 2000 避免參數過多)
# 比照 realtime_sales_monthly 的優化方式
target_df.to_sql('monthly_summary_analysis',
con=conn,
if_exists='append',
index=False,
chunksize=2000,
method='multi')
except Exception as e:
sys_log.error(f"[Web] [Import] 匯入資料庫失敗: {e}")
raise e
sys_log.info(f"[Web] [Import] 🚀 月份總表資料匯入成功 | 筆數: {len(target_df)}")
return jsonify({
'status': 'success',
'message': f'成功匯入 {len(target_df)} 筆分析數據。',
'rows': len(target_df)
})
except Exception as e:
sys_log.error(f"[Web] [Import] ❌ 月份總表匯入嚴重失敗: {str(e)}")
return jsonify({'status': 'error', 'message': f'匯入失敗: {str(e)}'}), 500
@app.route('/monthly_summary_analysis')
def monthly_summary_analysis_page():
"""月份總表數據分析展示頁 (Phase 9)"""
return render_template('monthly_summary_analysis.html',
datetime_now=datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'),
system_version=SYSTEM_VERSION)
@app.route('/api/monthly_summary_data')
def get_monthly_summary_data():
"""API: 取得月份總表數據與分析指標 (Phase 9)"""
year = request.args.get('year', type=int)
month = request.args.get('month', type=int)
division = request.args.get('division')
pm_name = request.args.get('pm_name')
brand_name = request.args.get('brand_name')
vendor_name = request.args.get('vendor')
area_name = request.args.get('area_name')
trade_type = request.args.get('trade_type')
limit = request.args.get('limit', default=1000, type=int)
# DEBUG LOGGING
import logging
debug_logger = logging.getLogger('app')
debug_logger.info(f"🔍 [API Debug] Request Args: {request.args}")
db = DatabaseManager()
session = db.get_session()
try:
# 基礎查詢
query = session.query(MonthlySummaryAnalysis)
# 套用過濾
if year: query = query.filter(MonthlySummaryAnalysis.year == year)
if month: query = query.filter(MonthlySummaryAnalysis.month == month)
if division: query = query.filter(MonthlySummaryAnalysis.division == division)
if pm_name: query = query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
if brand_name: query = query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
if vendor_name: query = query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
if area_name:
if ',' in area_name:
query = query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
else:
query = query.filter(MonthlySummaryAnalysis.area_name == area_name)
if trade_type: query = query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
# 取得統計數據 (KPIs)
kpi_query = session.query(
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('total_sales'),
func.sum(MonthlySummaryAnalysis.sales_amt_prev).label('total_sales_prev'),
func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('total_sales_yoa'),
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('total_profit'),
func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('total_vol'),
func.sum(MonthlySummaryAnalysis.views_curr).label('total_views')
)
# 同樣套用過濾到 KPI
if year: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.year == year)
if month: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.month == month)
if division: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.division == division)
if pm_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
if brand_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
if vendor_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
if area_name:
if ',' in area_name:
kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
else:
kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name == area_name)
if trade_type: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
kpi_res = kpi_query.one()
# 取得總筆數與月數
total_rows = session.query(func.count(MonthlySummaryAnalysis.id))
total_months_query = session.query(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).distinct()
if year:
total_rows = total_rows.filter(MonthlySummaryAnalysis.year == year)
total_months_query = total_months_query.filter(MonthlySummaryAnalysis.year == year)
if month:
total_rows = total_rows.filter(MonthlySummaryAnalysis.month == month)
total_rows = total_rows.scalar()
total_months = total_months_query.count()
# 取得趨勢數據 (按月加總)
trend_query = session.query(
MonthlySummaryAnalysis.year,
MonthlySummaryAnalysis.month,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
).group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
if division: trend_query = trend_query.filter(MonthlySummaryAnalysis.division == division)
if pm_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
if brand_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
if vendor_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
if area_name:
if ',' in area_name:
trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
else:
trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name == area_name)
if trade_type: trend_query = trend_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
# 取得排行榜 (Top 10 Brands)
rank_query = session.query(
MonthlySummaryAnalysis.brand_name,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
).group_by(MonthlySummaryAnalysis.brand_name)
if year: rank_query = rank_query.filter(MonthlySummaryAnalysis.year == year)
if month: rank_query = rank_query.filter(MonthlySummaryAnalysis.month == month)
if division: rank_query = rank_query.filter(MonthlySummaryAnalysis.division == division)
if pm_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
if brand_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
if vendor_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
if area_name:
if ',' in area_name:
rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
else:
rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name == area_name)
if trade_type: rank_query = rank_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
rank_query = rank_query.order_by(desc('sales')).limit(10)
# 取得明細資料
rows_query = query.order_by(
MonthlySummaryAnalysis.year.desc(),
MonthlySummaryAnalysis.month.desc(),
MonthlySummaryAnalysis.sales_amt_curr.desc()
).limit(limit)
# --- 📊 V-New: 進階分析子查詢 (Phase 17) ---
def apply_filters(q, ignore_year=False):
if year and not ignore_year: q = q.filter(MonthlySummaryAnalysis.year == year)
if month: q = q.filter(MonthlySummaryAnalysis.month == month)
if division: q = q.filter(MonthlySummaryAnalysis.division == division)
if pm_name: q = q.filter(MonthlySummaryAnalysis.pm_name == pm_name)
if brand_name: q = q.filter(MonthlySummaryAnalysis.brand_name == brand_name)
if vendor_name: q = q.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
if area_name:
if ',' in area_name:
q = q.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
else:
q = q.filter(MonthlySummaryAnalysis.area_name == area_name)
if trade_type: q = q.filter(MonthlySummaryAnalysis.trade_type == trade_type)
return q
# 廠商排行
# 廠商排行 (Top 20, 分年度)
# 廠商排行 (Top 20, 分年度)
vendor_rank_q = session.query(
MonthlySummaryAnalysis.vendor_name,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025'),
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit'),
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2024'),
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2025'),
).group_by(MonthlySummaryAnalysis.vendor_name)
vendor_rank_q = apply_filters(vendor_rank_q, ignore_year=True)
vendor_rank_q = vendor_rank_q.order_by(desc('sales')).limit(20)
# 區域分佈 (按 area_name, Top 12, 分年度)
div_dist_q = session.query(
MonthlySummaryAnalysis.area_name,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
).group_by(MonthlySummaryAnalysis.area_name)
div_dist_q = apply_filters(div_dist_q, ignore_year=True)
div_dist_q = div_dist_q.order_by(desc('sales')).limit(12)
# 價格帶貢獻 (分年度)
price_cont_q = session.query(
case(
(MonthlySummaryAnalysis.unit_price < 500, '0-499'),
(MonthlySummaryAnalysis.unit_price < 1000, '500-999'),
(MonthlySummaryAnalysis.unit_price < 2000, '1,000-1,999'),
(MonthlySummaryAnalysis.unit_price < 5000, '2,000-4,999'),
(MonthlySummaryAnalysis.unit_price < 10000, '5,000-9,999'),
else_='10,000+'
).label('price_range'),
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
).group_by('price_range')
price_cont_q = apply_filters(price_cont_q, ignore_year=True)
# BCG 矩陣 (品牌 x 區域)
bcg_q = session.query(
MonthlySummaryAnalysis.brand_name,
MonthlySummaryAnalysis.area_name,
func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('vol'),
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit')
).group_by(MonthlySummaryAnalysis.brand_name, MonthlySummaryAnalysis.area_name)\
.having(func.sum(MonthlySummaryAnalysis.sales_amt_curr) > 0)
bcg_q = apply_filters(bcg_q)
bcg_q = bcg_q.order_by(desc('sales')).limit(100)
# 熱力圖 (月份 x 分類)
# V-Opt: 預先執行一次 div_dist_q避免重複查詢
div_dist_results = div_dist_q.all()
top_12_areas = [r.area_name for r in div_dist_results]
heatmap_q = session.query(
MonthlySummaryAnalysis.year,
MonthlySummaryAnalysis.month,
MonthlySummaryAnalysis.area_name,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
).filter(MonthlySummaryAnalysis.area_name.in_(top_12_areas))\
.group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month, MonthlySummaryAnalysis.area_name)\
.order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
heatmap_q = apply_filters(heatmap_q, ignore_year=True)
# Highlights (Top 3)
def get_highlights_q(metric_col):
q = session.query(MonthlySummaryAnalysis.brand_name, func.sum(metric_col).label('val'))
q = apply_filters(q)
q = q.group_by(MonthlySummaryAnalysis.brand_name).order_by(desc('val')).limit(3)
return q
rev_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_amt_curr)
profit_top_q = get_highlights_q(MonthlySummaryAnalysis.profit_amt_curr)
vol_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_vol_curr)
# 區域排行
area_rank_q = session.query(
MonthlySummaryAnalysis.area_name,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
).group_by(MonthlySummaryAnalysis.area_name)
area_rank_q = apply_filters(area_rank_q, ignore_year=True)
area_rank_q = area_rank_q.order_by(desc('sales'))
# 年度對比趨勢 (需要包含本期與去年同期)
# 年度對比趨勢 (需要包含本期與去年同期)
yoy_trend_q = session.query(
MonthlySummaryAnalysis.year,
MonthlySummaryAnalysis.month,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales_curr'),
func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('sales_yoa')
)
yoy_trend_q = apply_filters(yoy_trend_q)
yoy_trend_q = yoy_trend_q.group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
rows = []
for r in rows_query.all():
rows.append({
'year': r.year,
'month': r.month,
'division': r.division,
'pm_name': r.pm_name,
'area_name': r.area_name,
'brand_name': r.brand_name,
'vendor_name': r.vendor_name,
'trade_type': r.trade_type,
'sales_amt_curr': r.sales_amt_curr,
'sales_amt_yoa': r.sales_amt_yoa,
'sales_vol_curr': r.sales_vol_curr,
'profit_amt_curr': r.profit_amt_curr,
'views_curr': r.views_curr
})
# V-Opt: 使用單一 SQL 查詢取得所有不重複的維度列表
with db.engine.connect() as conn:
filters_result = conn.execute(text("""
SELECT
GROUP_CONCAT(DISTINCT year) as years,
GROUP_CONCAT(DISTINCT month) as months,
GROUP_CONCAT(DISTINCT division) as divisions,
GROUP_CONCAT(DISTINCT pm_name) as pms,
GROUP_CONCAT(DISTINCT area_name) as areas,
GROUP_CONCAT(DISTINCT vendor_name) as vendors,
GROUP_CONCAT(DISTINCT trade_type) as trades
FROM monthly_summary_analysis
""")).fetchone()
years_list = [int(x) for x in (filters_result[0] or '').split(',') if x]
months_list = [int(x) for x in (filters_result[1] or '').split(',') if x]
divisions_list = [x for x in (filters_result[2] or '').split(',') if x]
pms_list = [x for x in (filters_result[3] or '').split(',') if x]
areas_list = [x for x in (filters_result[4] or '').split(',') if x]
vendors_list = [x for x in (filters_result[5] or '').split(',') if x]
trades_list = [x for x in (filters_result[6] or '').split(',') if x]
# V-Opt: 預先執行所有查詢,避免在 jsonify 中重複執行
area_rank_results = area_rank_q.all()
vendor_rank_results = vendor_rank_q.all()
price_cont_results = price_cont_q.all()
bcg_results = bcg_q.all()
heatmap_results = heatmap_q.all()
trend_results = trend_query.all()
yoy_trend_results = yoy_trend_q.all()
rank_results = rank_query.all()
rev_top_results = rev_top_q.all()
profit_top_results = profit_top_q.all()
vol_top_results = vol_top_q.all()
return jsonify({
'status': 'success',
'total_rows': total_rows,
'total_months': total_months,
'kpis': {
'sales': int(kpi_res.total_sales or 0),
'sales_prev': int(kpi_res.total_sales_prev or 0),
'sales_yoa': int(kpi_res.total_sales_yoa or 0),
'profit': int(kpi_res.total_profit or 0),
'vol': int(kpi_res.total_vol or 0),
'views': int(kpi_res.total_views or 0),
'margin': round((kpi_res.total_profit / kpi_res.total_sales * 100), 2) if kpi_res.total_sales and kpi_res.total_profit else 0
},
'trend': [{'date': f"{r.year}/{r.month}", 'sales': int(r.sales or 0)} for r in trend_results],
'yoy_trend': [{'date': f"{r.year}/{r.month}", 'curr': int(r.sales_curr or 0), 'yoa': int(r.sales_yoa or 0)} for r in yoy_trend_results],
'rankings': [{'brand': r.brand_name, 'sales': int(r.sales or 0)} for r in rank_results],
'area_ranking': [
{
'name': r.area_name,
'sales': int(r.sales or 0),
'sales_2024': int(r.sales_2024 or 0),
'sales_2025': int(r.sales_2025 or 0)
}
for r in area_rank_results
],
'vendor_ranking': [
{
'name': r.vendor_name,
'sales': int(r.sales or 0),
'sales_2024': int(r.sales_2024 or 0),
'sales_2025': int(r.sales_2025 or 0),
'profit': int(r.profit or 0),
'profit_2024': int(r.profit_2024 or 0),
'profit_2025': int(r.profit_2025 or 0),
'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0
}
for r in vendor_rank_results
],
'division_dist': [
{
'name': r.area_name,
'value': int(r.sales or 0),
'sales_2024': int(r.sales_2024 or 0),
'sales_2025': int(r.sales_2025 or 0)
}
for r in div_dist_results
],
'price_contribution': [
{
'range': r.price_range,
'sales': int(r.sales or 0),
'sales_2024': int(r.sales_2024 or 0),
'sales_2025': int(r.sales_2025 or 0)
}
for r in price_cont_results
],
'bcg_data': [
{'name': f"{r.brand_name}-{r.area_name}", 'qty': int(r.vol or 0), 'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0, 'sales': int(r.sales or 0)}
for r in bcg_results
],
'heatmap_data': [
{'year': r.year, 'month': r.month, 'category': r.area_name, 'sales': int(r.sales or 0)}
for r in heatmap_results
],
'highlights': {
'rev_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in rev_top_results],
'profit_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in profit_top_results],
'vol_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in vol_top_results]
},
'filters': {
'years': sorted(years_list, reverse=True),
'months': sorted(months_list),
'divisions': sorted(divisions_list),
'pms': sorted(pms_list),
'areas': sorted(areas_list),
'vendors': sorted(vendors_list),
'trades': sorted(trades_list)
},
'rows': rows
})
except Exception as e:
sys_log.error(f"取得月份總表數據失敗: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
finally:
session.close()
# ================= 📊 V-New: 業績分析報表 =================
def _get_filtered_sales_data(cache_key):
"""
🚩 共用函式:從快取讀取資料並根據 request.args 進行篩選
回傳: (target_df, cols_map, error_message)
參數: cache_key - 快取鍵值 (例如: "realtime_sales_monthly_3m")
"""
db = DatabaseManager()
# 1. 檢查資料表與快取
df = None
cols_map = {}
if cache_key in _SALES_PROCESSED_CACHE:
cache_data = _SALES_PROCESSED_CACHE[cache_key]
df = cache_data['df']
cols_map = cache_data['cols']
else:
# V-Fix: 增加自動重載邏輯,如果快取不存在,試圖從資料庫載入
sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),試圖重新從資料庫載入...")
try:
# 判斷是自訂區間還是標配區間
if "_custom_" in cache_key:
# 格式: realtime_sales_monthly_custom_2025-01-01_2025-01-31
parts = cache_key.split('_custom_')
dates = parts[1].split('_')
start_d, end_d = dates[0], dates[1]
# 呼叫資料庫讀取 (不傳入 view, 會自動處理欄位映射)
result_df, result_cols = db.get_sales_data(table_name=table_name, start_date=start_d, end_date=end_d)
else:
# 格式: realtime_sales_monthly_1m
months = int(cache_key.split('_')[-1].replace('m', '') or '1')
result_df, result_cols = db.get_sales_data(table_name=table_name, months=months)
if result_df is not None and not result_df.empty:
# 補回月份標籤供後續篩選
if '日期' in result_df.columns:
result_df['_month_str'] = pd.to_datetime(result_df['日期']).dt.strftime('%Y-%m')
# 自動存入快取
_SALES_PROCESSED_CACHE[cache_key] = {'df': result_df, 'cols': result_cols, 'time': time.time()}
df = result_df
cols_map = result_cols
sys_log.info(f"[Sales Analysis] ✅ 快取成功自動重載 | 筆數: {len(df)}")
else:
return None, None, "資料庫無可用資料,請確認匯入狀態"
except Exception as ex:
sys_log.error(f"[Sales Analysis] 🚨 自動重載失敗: {ex}")
return None, None, f"快取失效且無法重載: {ex}"
# 恢復欄位變數
col_name = cols_map.get('name')
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_activity = cols_map.get('activity')
col_payment = cols_map.get('payment')
col_price = cols_map.get('price')
col_date = cols_map.get('date')
col_return_qty = cols_map.get('return_qty') # V-New: 取得退貨欄位
# 2. 取得篩選參數
selected_category = request.args.get('category', 'all')
selected_brand = request.args.get('brand', 'all')
selected_vendor = request.args.get('vendor', 'all')
selected_activity = request.args.get('activity', 'all')
selected_payment = request.args.get('payment', 'all')
selected_dow = request.args.get('dow', 'all')
selected_hour = request.args.get('hour', 'all')
selected_month = request.args.get('month', 'all')
keyword = request.args.get('keyword', '').strip()
min_price = request.args.get('min_price', '')
max_price = request.args.get('max_price', '')
min_margin = request.args.get('min_margin', '')
max_margin = request.args.get('max_margin', '')
# 3. 執行篩選
target_df = df
# Top N 分類處理 (用於 '其他' 篩選)
TOP_N_CATS = 12
top_cats_names = []
if col_category:
# 注意:這裡為了效能,簡單重算一次 Top N或可考慮也快取起來
cat_group_all = df.groupby(col_category)[cols_map.get('amount')].sum().sort_values(ascending=False)
if len(cat_group_all) > TOP_N_CATS:
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
if selected_category != 'all' and col_category:
# Top N 分類處理 (用於 '其他' 篩選)
TOP_N_CATS = 12
# V-Opt: 從快取中讀取 Top N 分類,避免重算
cache_data = _SALES_PROCESSED_CACHE.get(cache_key, {})
top_cats_names = cache_data.get('top_cats')
if top_cats_names is None:
cat_group_all = df.groupby(col_category)[cols_map.get('amount')].sum().sort_values(ascending=False)
if len(cat_group_all) > TOP_N_CATS:
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
else:
top_cats_names = []
# 回填快取以供下次使用
if cache_key in _SALES_PROCESSED_CACHE:
_SALES_PROCESSED_CACHE[cache_key]['top_cats'] = top_cats_names
if selected_category == '其他' and top_cats_names:
target_df = target_df[~target_df[col_category].isin(top_cats_names)]
else:
target_df = target_df[target_df[col_category] == selected_category]
if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand]
if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor]
if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity]
if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment]
if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)]
if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)]
if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month]
if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)]
if col_price:
if min_price: target_df = target_df[target_df[col_price] >= float(min_price)]
if max_price: target_df = target_df[target_df[col_price] <= float(max_price)]
if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)]
if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)]
return target_df, cols_map, None
@app.route('/sales_analysis')
def sales_analysis():
"""V9.98: 業績分析儀表板 (效能優化版 + 穩定性增強)"""
now_taipei = datetime.now(TAIPEI_TZ)
datetime_now_str = now_taipei.strftime('%Y-%m-%d %H:%M:%S')
try:
# V-Fix: 定義全域默認變數以防止 Undefined 錯誤
DEFAULT_COLS = {
'name': None, 'date': None, 'amount': None, 'qty': None,
'cat': None, 'category': None, 'brand': None, 'vendor': None,
'activity': None, 'payment': None, 'price': None, 'cost': None,
'profit': None, 'return_qty': None, 'pid': None
}
DEFAULT_KPI = {'revenue': 0, 'qty': 0, 'count': 0, 'sku_count': 0, 'cost': 0, 'gross_margin': 0, 'gross_margin_rate': 0, 'avg_price': 0}
DEFAULT_BAR_DATA = {'labels': [], 'chart_values': [], 'metric_label': ''}
DEFAULT_CHART_DATA = {'labels': [], 'chart_values': []}
db = DatabaseManager()
# 1. 處理參數
table_name = request.args.get('table', 'realtime_sales_monthly')
# 1. 檢查資料表是否存在
inspector = inspect(db.engine)
if table_name not in inspector.get_table_names():
return render_template('sales_analysis.html',
error="尚未匯入「即時業績(全月)」資料,請先至設定頁面匯入 Excel。",
table_name=table_name,
selected_metric='amount',
no_filter=False,
data_range_months=0,
start_date='',
end_date='',
total_records=0,
db_data_range='',
cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(),
items=[], bar_data=DEFAULT_BAR_DATA.copy(),
cat_data=DEFAULT_CHART_DATA.copy(),
price_dist_data=DEFAULT_CHART_DATA.copy(),
datetime_now=datetime_now_str)
# V-New: 查詢資料庫的資料期間範圍
db_data_range = ''
try:
# 取得日期欄位的最小值和最大值
from sqlalchemy import text
date_query = text(f"SELECT MIN(日期) as min_date, MAX(日期) as max_date FROM {table_name}")
# V-Fix: SQLAlchemy 2.0 需要使用 connection 對象
with db.engine.connect() as conn:
result = conn.execute(date_query).fetchone()
if result and result[0] and result[1]:
min_date = result[0]
max_date = result[1]
# 格式化為 YYYY年MM月 格式
if isinstance(min_date, str):
try:
min_date_obj = datetime.strptime(min_date.split()[0], '%Y-%m-%d')
max_date_obj = datetime.strptime(max_date.split()[0], '%Y-%m-%d')
db_data_range = f"{min_date_obj.year}年{min_date_obj.month}月 ~ {max_date_obj.year}年{max_date_obj.month}月"
except:
db_data_range = f"{min_date} ~ {max_date}"
else:
db_data_range = f"{min_date.year}年{min_date.month}月 ~ {max_date.year}年{max_date.month}月"
except Exception as e:
sys_log.warning(f"[Sales Analysis] 無法取得資料期間範圍: {e}")
# V-New: 取得篩選參數
data_range_param = request.args.get('data_range', '') # 不再設預設值
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
# V-New: 按需載入 - 如果沒有任何篩選條件,顯示引導頁面
if not data_range_param and not start_date and not end_date:
sys_log.info("[Sales Analysis] 👋 首次進入頁面,等待用戶選擇篩選條件")
# V-Fix: 即使在引導頁面,也要提供下拉選單選項
# V-Opt: 讀取較多筆數1000筆以獲得更完整的月份資訊
# V-Security: 使用安全的 SQL 讀取函式
preview_df = safe_read_sql(table_name, engine=db.engine, limit=1000)
preview_categories = []
preview_brands = []
preview_vendors = []
preview_activities = []
preview_payments = []
preview_months = [] # V-New: 新增月份列表
if not preview_df.empty:
cols = preview_df.columns.tolist()
def find_col(keywords):
for k in keywords:
for col in cols:
if k in str(col): return col
return None
col_category = find_col(['館別', '商品館', '分類', 'Category'])
col_brand = find_col(['品牌', 'Brand'])
col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier'])
# V-Fix: 優先匹配具體的活動欄位名稱
col_activity = find_col(['折扣活動名稱', '折價券活動名稱', '滿額再折扣活動名稱', '活動', 'Activity', 'Campaign'])
col_payment = find_col(['付款', 'Payment', 'Pay'])
# V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期)
col_date_part = find_col(['日期', '交易日期', 'Date', 'Day'])
col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created'])
# V-Fix: 篩選掉空字串,只保留有效數據
if col_category:
preview_categories = sorted([x for x in preview_df[col_category].dropna().astype(str).unique().tolist() if x and x.strip()])
if col_brand:
preview_brands = sorted([x for x in preview_df[col_brand].dropna().astype(str).unique().tolist() if x and x.strip()])
if col_vendor:
preview_vendors = sorted([x for x in preview_df[col_vendor].dropna().astype(str).unique().tolist() if x and x.strip()])
if col_activity:
preview_activities = sorted([x for x in preview_df[col_activity].dropna().astype(str).unique().tolist() if x and x.strip()])
if col_payment:
preview_payments = sorted([x for x in preview_df[col_payment].dropna().astype(str).unique().tolist() if x and x.strip()])
# V-Fix: 從數據庫直接查詢所有月份(而不是從預覽數據提取,避免只獲得部分月份)
if col_date_part:
try:
from sqlalchemy import text
with db.engine.connect() as conn:
result = conn.execute(text(f"""
SELECT DISTINCT replace(substr("{col_date_part}", 1, 7), '/', '-') as month
FROM {table_name}
WHERE "{col_date_part}" IS NOT NULL AND "{col_date_part}" != ''
ORDER BY month
""")).fetchall()
preview_months = [row[0] for row in result if row[0] and '-' in str(row[0])]
sys_log.info(f"[Sales Analysis] 預覽模式從「{col_date_part}」欄位提取到 {len(preview_months)} 個月份")
except Exception as e:
sys_log.warning(f"[Sales Analysis] 無法從「{col_date_part}」欄位提取月份: {e}")
pass
# 傳遞必要的變數以避免模板錯誤
selected_metric = request.args.get('metric', 'amount')
# 建立空的數據結構
empty_data = {'labels': [], 'chart_values': [], 'values': [], 'metric_label': ''}
return render_template('sales_analysis.html',
no_filter=True,
table_name=table_name,
selected_metric=selected_metric,
total_records=0,
cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(),
items=[], bar_data=DEFAULT_BAR_DATA.copy(),
cat_data=DEFAULT_CHART_DATA.copy(),
price_dist_data=DEFAULT_CHART_DATA.copy(),
scatter_data=None,
bcg_data=None,
seasonality_data=None,
dow_data=DEFAULT_CHART_DATA.copy(),
monthly_data=DEFAULT_CHART_DATA.copy(),
weekly_data=DEFAULT_CHART_DATA.copy(),
hourly_data=DEFAULT_CHART_DATA.copy(),
heatmap_data=None,
treemap_data=None,
all_categories=preview_categories, all_brands=preview_brands, all_vendors=preview_vendors,
all_activities=preview_activities, all_payments=preview_payments, all_months=preview_months,
selected_category='all', selected_brand='all', selected_vendor='all',
selected_activity='all', selected_payment='all', selected_dow='all',
selected_hour='all', selected_month='all',
keyword='', min_price='', max_price='', min_margin='', max_margin='',
data_range_months=0, start_date='', end_date='',
db_data_range=db_data_range,
marketing_data=None,
datetime_now=datetime_now_str)
# 解析 data_range_months有篩選時才處理
data_range_months = int(data_range_param or '0')
# V-New: 如果有自訂日期區間,則優先使用
if start_date or end_date:
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
else:
cache_key = f"{table_name}_{data_range_months}m"
# 2. 讀取與處理資料 (V-Opt: 使用二級快取機制 Raw -> Processed)
df = None
cols_map = {}
# A. 優先檢查是否已有處理好的快取 (最快) + TTL 驗證
cache_hit = False
if cache_key in _SALES_PROCESSED_CACHE:
cache_data = _SALES_PROCESSED_CACHE[cache_key]
cache_time = cache_data.get('time', 0)
# V-Opt: 加入 TTL 檢查10 分鐘有效期)
if time.time() - cache_time < _SALES_CACHE_TTL:
df = cache_data['df']
cols_map = cache_data['cols']
cache_hit = True
sys_log.debug(f"[Sales Analysis] ✅ 快取命中: {cache_key} (age: {time.time() - cache_time:.0f}s)")
else:
sys_log.debug(f"[Sales Analysis] ⏰ 快取過期: {cache_key} (age: {time.time() - cache_time:.0f}s > {_SALES_CACHE_TTL}s)")
del _SALES_PROCESSED_CACHE[cache_key]
if cache_hit:
# 恢復欄位變數
col_name = cols_map.get('name')
col_date = cols_map.get('date')
col_amount = cols_map.get('amount')
col_qty = cols_map.get('qty')
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_activity = cols_map.get('activity')
col_payment = cols_map.get('payment')
col_pid = cols_map.get('pid') # V-New: 取得 PID 欄位
col_price = cols_map.get('price')
col_cost = cols_map.get('cost')
col_profit = cols_map.get('profit')
col_return_qty = cols_map.get('return_qty')
cached_pie_data = cache_data.get('pie_data', {'labels': [], 'chart_values': []}) # V-Opt: 讀取圓餅圖快取
else:
# B. 若無處理後快取,則從 Raw Cache 或 DB 讀取並處理
# V-Opt: 加入日期範圍篩選以減少記憶體使用
# (data_range_months 已在上方定義)
# 先讀取小樣本以識別日期欄位 (Security: Sanitized)
sample_df = safe_read_sql(table_name, engine=db.engine, limit=100)
if sample_df.empty:
return render_template('sales_analysis.html',
error="資料表為空,請重新匯入。",
table_name=table_name,
selected_metric=request.args.get('metric', 'amount'),
no_filter=False,
data_range_months=data_range_months,
start_date=start_date,
end_date=end_date,
total_records=0,
db_data_range=db_data_range,
marketing_data=None,
cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(),
items=[], bar_data=DEFAULT_BAR_DATA.copy(),
cat_data=DEFAULT_CHART_DATA.copy(),
price_dist_data=DEFAULT_CHART_DATA.copy(),
datetime_now=datetime_now_str)
# 自動識別日期欄位V-Fix: 優先匹配「日期」,因為「訂單日期」可能是固定文字)
sample_cols = sample_df.columns.tolist()
date_col_name = None
for col in sample_cols:
if any(keyword in str(col) for keyword in ['日期', '交易日期', 'Date', '訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']):
date_col_name = col
break
# 根據是否有日期欄位決定查詢方式
where_clause = None
params = {}
if date_col_name:
# V-New: 優先處理自訂日期區間
if start_date or end_date:
# V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01)
start_date_slash = start_date.replace('-', '/') if start_date else ''
end_date_slash = end_date.replace('-', '/') if end_date else ''
# 有自訂日期區間 - 使用 BETWEEN 或單邊範圍
if start_date and end_date:
where_clause = f'"{date_col_name}" BETWEEN :start AND :end'
params = {'start': start_date_slash, 'end': end_date_slash}
sys_log.info(f"[Sales Analysis] 📅 使用自訂日期範圍: {start_date} ~ {end_date}")
elif start_date:
where_clause = f'"{date_col_name}" >= :start'
params = {'start': start_date_slash}
sys_log.info(f"[Sales Analysis] 📅 使用自訂開始日期: >= {start_date}")
else: # only end_date
where_clause = f'"{date_col_name}" <= :end'
params = {'end': end_date_slash}
sys_log.info(f"[Sales Analysis] 📅 使用自訂結束日期: <= {end_date}")
elif data_range_months > 0:
# 使用相對日期範圍最近N個月
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
where_clause = f'"{date_col_name}" >= :cutoff'
params = {'cutoff': cutoff_date}
sys_log.info(f"[Sales Analysis] 📊 使用日期範圍篩選: 最近 {data_range_months} 個月")
else:
sys_log.info(f"[Sales Analysis] 📊 載入全部資料(用戶選擇)")
else:
sys_log.info(f"[Sales Analysis] ⚠️ 未找到日期欄位,載入全部資料")
if table_name in _SALES_DF_CACHE:
df = _SALES_DF_CACHE[table_name].copy()
else:
# V-Security: 使用安全的 SQL 讀取函式 (參數化查詢)
df = safe_read_sql(table_name, engine=db.engine, where_clause=where_clause, params=params)
sys_log.info(f"[Sales Analysis] 📊 載入資料: {len(df):,} 筆記錄 (Security: Parameterized)")
if not df.empty:
_SALES_DF_CACHE[table_name] = df.copy()
if df.empty:
return render_template('sales_analysis.html',
error="資料表為空,請重新匯入。",
table_name=table_name,
selected_metric=request.args.get('metric', 'amount'),
no_filter=False,
data_range_months=data_range_months,
start_date=start_date,
end_date=end_date,
total_records=0,
db_data_range=db_data_range,
marketing_data=None,
cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(),
items=[], bar_data=DEFAULT_BAR_DATA.copy(),
cat_data=DEFAULT_CHART_DATA.copy(),
price_dist_data=DEFAULT_CHART_DATA.copy(),
datetime_now=datetime_now_str)
# 3. 自動識別關鍵欄位 (模糊比對)
cols = df.columns.tolist()
def find_col(keywords):
# V-Opt: 改為優先遍歷關鍵字,確保優先匹配更精確的名稱 (例如 '廠商名稱' 優於 '廠商')
for k in keywords:
for col in cols:
if k in str(col): return col
return None
col_name = find_col(['商品名稱', '品名', 'Name', 'Product'])
col_pid = find_col(['商品ID', 'Product ID', 'ID', 'i_code', 'Item Code']) # V-New: 偵測商品ID欄位
# V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期)
col_date_part = find_col(['日期', '交易日期', 'Date', 'Day'])
col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created'])
col_brand = find_col(['品牌', 'Brand']) # V-New: 品牌欄位
col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) # V-Opt: 優先抓取名稱
col_activity = find_col(['活動', '折扣', 'Activity', 'Campaign', 'Promotion', '專案']) # V-New: 活動欄位
col_payment = find_col(['付款方式', 'Payment', 'Pay']) # V-New: 付款方式欄位
col_price = find_col(['單價', 'Price', '價格', 'Avg Price']) # V-New: 嘗試尋找單價欄位
col_cost = find_col(['成本', 'Cost', '進價', 'Cost Price', 'Wholesale']) # V-New: 成本欄位
col_profit = find_col(['毛利', 'Profit', '利潤']) # V-New: 直接尋找毛利欄位 (若有)
col_return_qty = find_col(['退貨數量', 'Return Qty', '退貨']) # V-New: 退貨欄位
col_amount = find_col(['銷售金額', '業績', '金額', 'Amount', 'Sales', 'Total'])
col_qty = find_col(['銷售數量', '銷量', '數量', 'Qty', 'Quantity'])
col_category = find_col(['館別', '分類', 'Category'])
if not col_name or not col_amount:
return render_template('sales_analysis.html',
error=f"無法自動識別關鍵欄位 (需包含 '名稱' 與 '金額')。偵測到的欄位: {cols}",
table_name=table_name,
selected_metric=request.args.get('metric', 'amount'),
no_filter=False,
data_range_months=data_range_months,
start_date=start_date,
end_date=end_date,
total_records=0,
db_data_range=db_data_range,
marketing_data=None,
cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(),
items=[], bar_data=DEFAULT_BAR_DATA.copy(),
cat_data=DEFAULT_CHART_DATA.copy(),
price_dist_data=DEFAULT_CHART_DATA.copy(),
datetime_now=datetime_now_str)
# 4. 資料處理 (Heavy Lifting - 只在快取建立時執行一次)
# 確保金額與數量是數字
df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0)
if col_qty:
df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0)
if col_cost:
df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0)
if col_profit:
df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0)
if col_return_qty:
df[col_return_qty] = pd.to_numeric(df[col_return_qty], errors='coerce').fillna(0)
# V-Fix: 智慧日期時間合併邏輯
col_date = None
if col_date_part and col_time_part:
# 兩者都有,嘗試合併
try:
df['combined_dt'] = pd.to_datetime(df[col_date_part].astype(str) + ' ' + df[col_time_part].astype(str), errors='coerce')
col_date = 'combined_dt'
except:
# 合併失敗,退回使用時間欄位 (假設包含日期) 或日期欄位
col_date = col_time_part or col_date_part
elif col_time_part:
# 只有時間欄位 (可能包含日期)
df[col_time_part] = pd.to_datetime(df[col_time_part], errors='coerce')
col_date = col_time_part
elif col_date_part:
# 只有日期欄位
df[col_date_part] = pd.to_datetime(df[col_date_part], errors='coerce')
col_date = col_date_part
# V-New: 若無明確單價欄位,則自動計算 (金額 / 數量)
if not col_price and col_amount and col_qty:
col_price = 'calculated_price'
# V-Opt: 使用 numpy 向量化運算加速 (取代 apply)
df[col_price] = np.where(df[col_qty] > 0, df[col_amount] / df[col_qty], 0)
if col_price:
df[col_price] = pd.to_numeric(df[col_price], errors='coerce').fillna(0)
# V-New: 預先計算毛利率 (Margin Rate) 用於篩選
# 邏輯: (毛利 / 金額) * 100
col_margin_rate = 'calculated_margin_rate'
with np.errstate(divide='ignore', invalid='ignore'):
if col_profit:
df[col_margin_rate] = (df[col_profit] / df[col_amount]) * 100
elif col_cost:
df[col_margin_rate] = ((df[col_amount] - df[col_cost]) / df[col_amount]) * 100
else:
df[col_margin_rate] = 0.0
# 處理無限大與 NaN (轉為 0)
df[col_margin_rate] = df[col_margin_rate].replace([np.inf, -np.inf, np.nan], 0)
# === V-Opt: 效能優化預計算 (V9.98) ===
# 1. 日期維度 (加速篩選與聚合,避免重複呼叫 .dt 存取器)
if col_date:
df['_dow'] = df[col_date].dt.dayofweek
df['_hour'] = df[col_date].dt.hour
df['_week'] = df[col_date].dt.strftime('%G-W%V')
df['_month_str'] = df[col_date].dt.strftime('%Y-%m') # V-New: 月份維度 (YYYY-MM)
# 2. 毛利額 (加速 Top 3 分析,避免 runtime 計算)
if col_profit:
df['calculated_profit'] = df[col_profit]
elif col_cost:
df['calculated_profit'] = df[col_amount] - df[col_cost]
else:
df['calculated_profit'] = 0.0
# 3. 全站分類圓餅圖 (已移至下方使用 target_df 計算)
# 建立/更新處理後快取
cache_entry = {
'df': df,
'cols': {
'name': col_name, 'date': col_date, 'amount': col_amount,
'qty': col_qty, 'category': col_category, 'cat': col_category, # V-Fix: 加上 cat 對映 category
'brand': col_brand,
'vendor': col_vendor, 'activity': col_activity, 'payment': col_payment,
'price': col_price, 'cost': col_cost, 'profit': col_profit,
'return_qty': col_return_qty,
'pid': col_pid # V-New: 儲存商品ID欄位
},
'pid': col_pid, # V-New: 儲存商品ID欄位
'time': time.time() # V-Opt: 加入時間戳記用於 TTL 檢查
}
_SALES_PROCESSED_CACHE[cache_key] = cache_entry
# V-Fix: 同時使用固定的 table_name 作為 key 儲存副本,供其他路由使用
_SALES_PROCESSED_CACHE[table_name] = cache_entry
# 🚩 V-Opt: 使用共用篩選函式
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
if err:
# V-Fix: 若快取失效,重新導向自己以觸發重新讀取(保留所有查詢參數)
params = {k: v for k, v in request.args.items()}
return redirect(url_for('sales_analysis', **params))
# 重新取得變數 (因為 _get_filtered_sales_data 內部使用了 cols_map)
col_name = cols_map.get('name')
col_amount = cols_map.get('amount')
col_qty = cols_map.get('qty')
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_activity = cols_map.get('activity')
col_payment = cols_map.get('payment')
col_price = cols_map.get('price')
col_cost = cols_map.get('cost')
col_profit = cols_map.get('profit')
col_return_qty = cols_map.get('return_qty')
col_date = cols_map.get('date')
col_pid = cols_map.get('pid')
# V-Fix: 準備前端需要的下拉選單資料
# V-Opt: 從數據庫直接查詢所有可用選項,而不是只從篩選後的快取中讀取
# 這樣可以確保下拉選單顯示完整的可選項,即使當前篩選範圍很小
# V-Fix: 預先初始化所有下拉選單變數,避免 referenced before assignment 錯誤
all_categories = []
all_brands = []
all_vendors = []
all_activities = []
all_payments = []
all_months = []
# V-Opt: 優先從快取中讀取下拉選單選項
global _SALES_OPTIONS_CACHE
if table_name in _SALES_OPTIONS_CACHE and (time.time() - _SALES_OPTIONS_CACHE[table_name]['time'] < _SALES_OPTIONS_TTL):
opts = _SALES_OPTIONS_CACHE[table_name]['options']
all_categories = opts.get('categories', [])
all_brands = opts.get('brands', [])
all_vendors = opts.get('vendors', [])
all_activities = opts.get('activities', [])
all_payments = opts.get('payments', [])
all_months = opts.get('months', [])
sys_log.debug(f"[Sales Analysis] [Cache] ✅ 使用下拉選單快取: {table_name}")
else:
try:
from sqlalchemy import text
# V-Fix: SQLAlchemy 2.0 需要使用 connection 對象
with db.engine.connect() as conn:
# 讀取完整資料表的所有可用選項(使用 DISTINCT 以提升效能)
if col_category:
result = conn.execute(text(f'SELECT DISTINCT "{col_category}" FROM {table_name} WHERE "{col_category}" IS NOT NULL AND "{col_category}" != ""')).fetchall()
all_categories = sorted([str(row[0]) for row in result if row[0]])
if col_brand:
result = conn.execute(text(f'SELECT DISTINCT "{col_brand}" FROM {table_name} WHERE "{col_brand}" IS NOT NULL AND "{col_brand}" != ""')).fetchall()
all_brands = sorted([str(row[0]) for row in result if row[0]])
if col_vendor:
result = conn.execute(text(f'SELECT DISTINCT "{col_vendor}" FROM {table_name} WHERE "{col_vendor}" IS NOT NULL AND "{col_vendor}" != ""')).fetchall()
all_vendors = sorted([str(row[0]) for row in result if row[0]])
if col_activity:
result = conn.execute(text(f'SELECT DISTINCT "{col_activity}" FROM {table_name} WHERE "{col_activity}" IS NOT NULL AND "{col_activity}" != ""')).fetchall()
all_activities = sorted([str(row[0]) for row in result if row[0]])
if col_payment:
result = conn.execute(text(f'SELECT DISTINCT "{col_payment}" FROM {table_name} WHERE "{col_payment}" IS NOT NULL AND "{col_payment}" != ""')).fetchall()
all_payments = sorted([str(row[0]) for row in result if row[0]])
# V-Fix: 從數據庫提取所有月份格式YYYY-MM
if col_date:
date_fields = ['日期', '交易日期', '訂單日期', '時間']
for field in date_fields:
try:
result = conn.execute(text(f"""
SELECT DISTINCT replace(substr(\"{field}\", 1, 7), '/', '-') as month
FROM {table_name}
WHERE \"{field}\" IS NOT NULL AND \"{field}\" != ''
ORDER BY month
""")).fetchall()
if result:
all_months = [row[0] for row in result if row[0] and '-' in str(row[0])]
if all_months: break
except: continue
# 存入快取
_SALES_OPTIONS_CACHE[table_name] = {
'options': {
'categories': all_categories, 'brands': all_brands, 'vendors': all_vendors,
'activities': all_activities, 'payments': all_payments, 'months': all_months
},
'time': time.time()
}
sys_log.info(f"[Sales Analysis] [Cache] 💾 已更新下拉選單快取: {table_name}")
except Exception as e:
sys_log.warning(f"[Sales Analysis] 從數據庫查詢下拉選項失敗: {e}")
# 如果查詢失敗,回退到從快取讀取
if cache_key in _SALES_PROCESSED_CACHE:
original_df = _SALES_PROCESSED_CACHE[cache_key]['df']
elif table_name in _SALES_PROCESSED_CACHE:
original_df = _SALES_PROCESSED_CACHE[table_name]['df']
else:
original_df = pd.DataFrame()
if not original_df.empty:
all_categories = sorted(original_df[col_category].dropna().astype(str).unique().tolist()) if col_category else []
all_brands = sorted(original_df[col_brand].dropna().astype(str).unique().tolist()) if col_brand else []
all_vendors = sorted(original_df[col_vendor].dropna().astype(str).unique().tolist()) if col_vendor else []
all_activities = sorted(original_df[col_activity].dropna().astype(str).unique().tolist()) if col_activity else []
all_payments = sorted(original_df[col_payment].dropna().astype(str).unique().tolist()) if col_payment else []
all_months = sorted(original_df['_month_str'].dropna().unique().tolist()) if col_date and '_month_str' in original_df.columns else []
# 取得前端參數供模板回填
selected_category = request.args.get('category', 'all')
selected_metric = request.args.get('metric', 'amount')
selected_brand = request.args.get('brand', 'all')
selected_vendor = request.args.get('vendor', 'all')
selected_activity = request.args.get('activity', 'all')
selected_payment = request.args.get('payment', 'all')
selected_dow = request.args.get('dow', 'all')
selected_hour = request.args.get('hour', 'all')
selected_month = request.args.get('month', 'all')
keyword = request.args.get('keyword', '').strip()
min_price = request.args.get('min_price', '')
max_price = request.args.get('max_price', '')
min_margin = request.args.get('min_margin', '')
max_margin = request.args.get('max_margin', '')
# 🚩 核心優化:檢查分析結果快取 (Result Cache)
# 快取鍵值應包含原始資料快取鍵 + 所有目前套用的篩選參數
import hashlib
filter_str = f"{request.args.get('category')}_{request.args.get('brand')}_{request.args.get('vendor')}_{request.args.get('activity')}_{request.args.get('payment')}_{request.args.get('dow')}_{request.args.get('hour')}_{request.args.get('month')}_{request.args.get('keyword')}_{request.args.get('metric')}_{request.args.get('min_price')}_{request.args.get('max_price')}_{request.args.get('min_margin')}_{request.args.get('max_margin')}"
result_cache_key = hashlib.md5(f"{cache_key}_{filter_str}".encode()).hexdigest()
if result_cache_key in _SALES_ANALYSIS_RESULT_CACHE and (time.time() - _SALES_ANALYSIS_RESULT_CACHE[result_cache_key]['time'] < _SALES_RESULT_TTL):
sys_log.info(f"[Sales Analysis] [Result Cache] 🚀 命中分析結果快取 | Key: {result_cache_key}")
cached_res = _SALES_ANALYSIS_RESULT_CACHE[result_cache_key]['data']
return render_template('sales_analysis.html', **{**cached_res, 'public_url': public_url, 'datetime_now': datetime_now_str})
# 若未命中快取,執行下方昂貴的運算
sort_col = col_amount
if selected_metric == 'qty' and col_qty:
sort_col = col_qty
target_df = target_df.sort_values(by=sort_col, ascending=False)
# 🚩 全面初始化分析變數,避免無數據時報錯
treemap_data = []
bcg_data = {'datasets': [], 'thresholds': {'x': 0, 'y': 0}}
heatmap_data = []
scatter_data = []
dow_data = {'labels': ['週一', '週二', '週三', '週四', '週五', '週六', '週日'], 'chart_values': [0]*7}
hourly_data = {'labels': [f"{i:02d}:00" for i in range(24)], 'chart_values': [0]*24}
weekly_data = {'labels': [], 'chart_values': []}
monthly_data = {'labels': [], 'chart_values': []}
vendor_stats = []
seasonality_data = None
abc_stats = {'A': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0},
'B': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0},
'C': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}}
insights = {'rev_cats': [], 'rev_prods': [], 'margin_cats': [], 'margin_prods': [], 'qty_cats': [], 'qty_prods': []}
cat_data = {'labels': [], 'chart_values': []}
bar_data = {'labels': [], 'chart_values': [], 'metric_label': ''}
price_dist_data = {'labels': [], 'chart_values': []}
table_items = []
total_revenue = total_qty = total_count = sku_count = total_cost = gross_margin = gross_margin_rate = avg_price = 0
# 🚩 運算優化:合併 GroupBy 減少對大規模目標 DataFrame 的重複掃描
# 建立一次性聚合字典
agg_map = {col_amount: 'sum'}
if col_qty: agg_map[col_qty] = 'sum'
if 'calculated_profit' in target_df.columns: agg_map['calculated_profit'] = 'sum'
# 1. 類別聚合 (包含 Top 3, 圓餅圖, 其他)
cat_agg = target_df.groupby(col_category).agg(agg_map) if col_category else pd.DataFrame()
# 2. 商品聚合 (PID + Name)
product_groupby = [col_pid, col_name] if col_pid else col_name
prod_agg = target_df.groupby(product_groupby).agg(agg_map) if col_name else pd.DataFrame()
# 📊 KPI 計算 (直接從 sum 取得,速度極快)
total_revenue = float(target_df[col_amount].sum())
total_qty = float(target_df[col_qty].sum()) if col_qty else 0
total_count = len(target_df)
sku_count = int(target_df[col_name].nunique()) if col_name else total_count
total_cost = float(target_df[col_cost].sum()) if col_cost else 0
gross_margin = float(target_df['calculated_profit'].sum()) if 'calculated_profit' in target_df.columns else (total_revenue - total_cost)
gross_margin_rate = (gross_margin / total_revenue * 100) if total_revenue > 0 else 0
avg_price = total_revenue / total_qty if total_qty > 0 else 0
# 📊 洞察分析 (從已聚合的 cat_agg/prod_agg 提取,不再掃描原表)
insights = {'rev_cats': [], 'rev_prods': [], 'margin_cats': [], 'margin_prods': [], 'qty_cats': [], 'qty_prods': []}
def format_top3(agg_df, metric, is_prod=False):
if agg_df.empty: return []
top_df = agg_df.sort_values(metric, ascending=False).head(3)
return [{'name': (str(k[-1]) if isinstance(k, tuple) else str(k)), 'value': float(v)} for k, v in top_df[metric].items() if v > 0]
insights['rev_cats'] = format_top3(cat_agg, col_amount)
insights['rev_prods'] = format_top3(prod_agg, col_amount, True)
insights['qty_cats'] = format_top3(cat_agg, col_qty) if col_qty else []
insights['qty_prods'] = format_top3(prod_agg, col_qty, True) if col_qty else []
if 'calculated_profit' in target_df.columns:
insights['margin_cats'] = format_top3(cat_agg, 'calculated_profit')
insights['margin_prods'] = format_top3(prod_agg, 'calculated_profit', True)
# 📊 1. 核心長條圖數據 (Top 20 商品) - 從 prod_agg 提取
bar_data = {'labels': [], 'chart_values': [], 'metric_label': ('銷售金額' if selected_metric == 'amount' else '銷售數量')}
if not prod_agg.empty:
top20_df = prod_agg.sort_values(sort_col, ascending=False).head(20)
bar_data['labels'] = [(str(k[-1]) if isinstance(k, tuple) else str(k)) for k in top20_df.index]
bar_data['chart_values'] = [float(x) for x in top20_df[sort_col].tolist()]
# 📊 2. 圓餅圖數據 (從 cat_agg 提取)
cat_data = {'labels': [], 'chart_values': []}
if not cat_agg.empty:
sorted_cat = cat_agg[col_amount].sort_values(ascending=False)
if len(sorted_cat) > 12:
top_cats = sorted_cat.head(12)
other_val = sorted_cat.iloc[12:].sum()
cat_data['labels'] = [str(x) for x in top_cats.index.tolist()] + ['其他']
cat_data['chart_values'] = [float(x) for x in top_cats.tolist()] + [float(other_val)]
else:
cat_data['labels'] = [str(x) for x in sorted_cat.index.tolist()]
cat_data['chart_values'] = [float(x) for x in sorted_cat.tolist()]
# 📊 價格帶分析 (維持 PD.CUT)
price_dist_data = {'labels': [], 'chart_values': []}
if col_price and not target_df.empty:
bins = [0, 500, 1000, 2000, 5000, 10000, float('inf')]
lbls = ['0-499', '500-999', '1,000-1,999', '2,000-4,999', '5,000-9,999', '10,000+']
price_bins = pd.cut(target_df[col_price], bins=bins, labels=lbls, right=False)
range_group = target_df.groupby(price_bins, observed=False)[col_amount].sum()
price_dist_data['labels'] = lbls
price_dist_data['chart_values'] = [float(range_group.get(l, 0)) for l in lbls]
# 📊 散佈圖數據 (取樣 300 點以保證前端流暢)
scatter_data = []
if col_price and col_qty and not target_df.empty:
scatter_source = target_df.head(300)
for _, row in scatter_source.iterrows():
scatter_data.append({
'x': float(row[col_price]), 'y': float(row[col_qty]),
'name': str(row[col_name]), 'amt': float(row[col_amount])
})
# 📊 BCG 矩陣分析 (BCG Matrix)
if col_qty and 'calculated_margin_rate' in target_df.columns and not target_df.empty:
med_qty = target_df[col_qty].median() if not target_df.empty else 1
med_margin = target_df['calculated_margin_rate'].median() if not target_df.empty else 0
bcg_data['thresholds'] = {'x': float(med_qty or 1), 'y': float(med_margin)}
# 分類邏輯 (Stars, Cows, Questions, Dogs)
stars = target_df[(target_df[col_qty] >= med_qty) & (target_df['calculated_margin_rate'] >= med_margin)].head(50)
cows = target_df[(target_df[col_qty] >= med_qty) & (target_df['calculated_margin_rate'] < med_margin)].head(50)
questions = target_df[(target_df[col_qty] < med_qty) & (target_df['calculated_margin_rate'] >= med_margin)].head(50)
dogs = target_df[(target_df[col_qty] < med_qty) & (target_df['calculated_margin_rate'] < med_margin)].head(50)
def format_p(df_seg):
return [{'x': float(row[col_qty]), 'y': float(row['calculated_margin_rate']), 'name': str(row[col_name]), 'amt': float(row[col_amount])} for _, row in df_seg.iterrows()]
bcg_data['datasets'] = [
{'label': '明星商品 (Stars)', 'data': format_p(stars), 'backgroundColor': 'rgba(255, 206, 86, 0.8)', 'borderColor': 'rgba(255, 206, 86, 1)'},
{'label': '金牛商品 (Cows)', 'data': format_p(cows), 'backgroundColor': 'rgba(75, 192, 192, 0.8)', 'borderColor': 'rgba(75, 192, 192, 1)'},
{'label': '問題商品 (Questions)', 'data': format_p(questions), 'backgroundColor': 'rgba(54, 162, 235, 0.8)', 'borderColor': 'rgba(54, 162, 235, 1)'},
{'label': '瘦狗商品 (Dogs)', 'data': format_p(dogs), 'backgroundColor': 'rgba(201, 203, 207, 0.8)', 'borderColor': 'rgba(201, 203, 207, 1)'}
]
# 📊 多維度熱力圖 (Day x Hour)
if col_date and not target_df.empty:
dh_group = target_df.groupby(['_dow', '_hour'])[col_amount].sum()
max_val = dh_group.max() if not dh_group.empty else 1
for (day, hour), val in dh_group.items():
radius = 3 + (math.sqrt(val) / math.sqrt(max_val)) * 22 if val > 0 else 0
heatmap_data.append({
'x': int(hour), 'y': int(day), 'r': radius, 'v': float(val)
})
# 📊 V-Fix: 計算星期業績趨勢 (dow_data)
dow_group = target_df.groupby('_dow')[col_amount].sum()
for i in range(7):
if i in dow_group.index:
dow_data['chart_values'][i] = float(dow_group[i])
# 📊 V-Fix: 計算小時業績趨勢 (hourly_data)
hour_group = target_df.groupby('_hour')[col_amount].sum()
for i in range(24):
if i in hour_group.index:
hourly_data['chart_values'][i] = float(hour_group[i])
# 📊 V-Fix: 計算週業績趨勢 (weekly_data)
if '_week' in target_df.columns:
week_group = target_df.groupby('_week')[col_amount].sum().sort_index()
weekly_data['labels'] = week_group.index.tolist()
weekly_data['chart_values'] = [float(v) for v in week_group.values]
# 📊 V-Fix: 計算月業績趨勢 (monthly_data)
if '_month_str' in target_df.columns:
month_group = target_df.groupby('_month_str')[col_amount].sum().sort_index()
monthly_data['labels'] = month_group.index.tolist()
monthly_data['chart_values'] = [float(v) for v in month_group.values]
# 📊 V-New: 板塊圖 (Treemap) 數據準備
# 結構: Root -> Category -> Product (Top 5 per cat)
if col_category and col_name and col_amount and not target_df.empty:
# V-Opt: 優化聚合邏輯,先聚合再篩選,避免在迴圈中重複過濾大表
# 1. 先聚合 Category + Product (大幅減少資料量)
cat_prod_group = target_df.groupby([col_category, col_name])[col_amount].sum().reset_index()
# 2. 找出前 10 大分類
top_cats = cat_prod_group.groupby(col_category)[col_amount].sum().nlargest(10).index.tolist()
# 3. 針對前 10 大分類,各取前 5 大商品
for cat in top_cats:
if not cat: continue
# 在縮減後的資料中篩選,速度極快
cat_subset = cat_prod_group[cat_prod_group[col_category] == cat]
top_prods = cat_subset.nlargest(5, col_amount)
for _, row in top_prods.iterrows():
treemap_data.append({
'category': str(cat),
'product': str(row[col_name]),
'value': float(row[col_amount]),
'color': get_color_for_string(str(cat)) # V-Fix: 增加顏色參數,確保與分類顏色一致且清晰
})
# 📊 V-New: ABC 分析 (Pareto Analysis) - TODO #8
# A類: 累積營收 0-80% (核心商品)
# B類: 累積營收 80-95% (次要商品)
# C類: 累積營收 95-100% (長尾商品)
abc_stats = {'A': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0},
'B': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0},
'C': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}}
if not target_df.empty and col_amount:
# 使用 numpy 加速累積計算
sorted_rev = target_df[col_amount].values # 已在上方排序過
cumsum_rev = np.cumsum(sorted_rev)
total_rev_abc = cumsum_rev[-1] if len(cumsum_rev) > 0 else 0
if total_rev_abc > 0:
pct_cumsum = cumsum_rev / total_rev_abc * 100
# 找出分界點索引
idx_a = np.searchsorted(pct_cumsum, 80)
idx_b = np.searchsorted(pct_cumsum, 95)
# A類: 0 ~ idx_a
count_a = idx_a + 1
rev_a = cumsum_rev[idx_a] if idx_a < len(cumsum_rev) else total_rev_abc
# B類: idx_a+1 ~ idx_b
count_b = max(0, idx_b - idx_a)
rev_b = (cumsum_rev[idx_b] - cumsum_rev[idx_a]) if idx_b < len(cumsum_rev) else (total_rev_abc - cumsum_rev[idx_a])
# C類: idx_b+1 ~ end
count_c = max(0, len(cumsum_rev) - 1 - idx_b)
rev_c = total_rev_abc - cumsum_rev[idx_b] if idx_b < len(cumsum_rev) else 0
abc_stats['A'] = {'count': int(count_a), 'revenue': float(rev_a), 'pct_rev': float(rev_a/total_rev_abc*100), 'pct_sku': float(count_a/total_count*100)}
abc_stats['B'] = {'count': int(count_b), 'revenue': float(rev_b), 'pct_rev': float(rev_b/total_rev_abc*100), 'pct_sku': float(count_b/total_count*100)}
abc_stats['C'] = {'count': int(count_c), 'revenue': float(rev_c), 'pct_rev': float(rev_c/total_rev_abc*100), 'pct_sku': float(count_c/total_count*100)}
# 📊 V-New: 廠商獲利能力排行 (Vendor Profitability) - TODO #9
vendor_stats = []
if col_vendor and col_amount and not target_df.empty:
# Group by vendor
agg_dict = {col_amount: 'sum', col_name: 'nunique'} # nunique 計算不重複商品數 (SKU)
if col_qty: agg_dict[col_qty] = 'sum' # V-New: 累加銷量
if col_profit:
agg_dict[col_profit] = 'sum'
elif col_cost:
agg_dict[col_cost] = 'sum'
# 使用 groupby 聚合
vendor_group = target_df.groupby(col_vendor).agg(agg_dict).reset_index()
# 計算毛利與毛利率
if col_profit:
vendor_group['total_profit'] = vendor_group[col_profit]
elif col_cost:
vendor_group['total_profit'] = vendor_group[col_amount] - vendor_group[col_cost]
else:
vendor_group['total_profit'] = 0
# 計算營收佔比 (Share %)
total_vendor_revenue = vendor_group[col_amount].sum()
if total_vendor_revenue > 0:
vendor_group['revenue_share'] = (vendor_group[col_amount] / total_vendor_revenue * 100)
else:
vendor_group['revenue_share'] = 0.0
# 避免除以零
vendor_group['margin_rate'] = np.where(vendor_group[col_amount] > 0, (vendor_group['total_profit'] / vendor_group[col_amount] * 100), 0)
# 計算平均客單價 (ASP)
if col_qty:
vendor_group['asp'] = np.where(vendor_group[col_qty] > 0, vendor_group[col_amount] / vendor_group[col_qty], 0)
# 排序:預設按總業績降序
vendor_group = vendor_group.sort_values(by=col_amount, ascending=False)
# 格式化輸出 (Top 100)
for _, row in vendor_group.head(100).iterrows():
vendor_stats.append({
'name': str(row[col_vendor]),
'revenue': float(row[col_amount]),
'share': float(row['revenue_share']), # V-New
'qty': float(row[col_qty]) if col_qty else 0, # V-New
'asp': float(row.get('asp', 0)), # V-New
'profit': float(row['total_profit']),
'margin_rate': float(row['margin_rate']),
'sku_count': int(row[col_name])
})
# 📊 格式化 Top 1000 列表商品 (DataTables 使用)
table_items = []
if not target_df.empty:
# 取得 Top 1000
display_df = target_df.head(1000)
for i, (_, row) in enumerate(display_df.iterrows()):
item = {
'rank': i + 1,
'pid': str(row[col_pid]) if col_pid else 'N/A',
'name': str(row[col_name]) if col_name else 'N/A',
'amount': float(row[col_amount]),
}
if col_brand: item['brand'] = str(row[col_brand])
if col_vendor: item['vendor'] = str(row[col_vendor])
if col_category: item['cat'] = str(row[col_category])
if col_qty:
item['qty'] = float(row[col_qty])
item['avg_price'] = float(row[col_amount] / row[col_qty]) if row[col_qty] > 0 else 0
if 'calculated_margin_rate' in df.columns: # 使用原 df 的 columns 判斷是否有此欄位
item['margin_rate'] = float(row['calculated_margin_rate'])
if col_return_qty:
item['return_rate'] = float(row[col_return_qty] / row[col_qty] * 100) if row.get(col_qty, 0) > 0 else 0
if col_date and '_month_str' in row:
item['date'] = str(row['_month_str'])
table_items.append(item)
# 📊 V-New: 淡旺季熱力圖 (Seasonality Analysis) - TODO #10
seasonality_data = None
if col_date and col_category and col_amount and not target_df.empty:
# 1. 取得前 10 大分類 (避免圖表過大)
# 使用 target_df (受篩選影響),這樣可以看特定品牌下的分類季節性
top_cats_season = target_df.groupby(col_category)[col_amount].sum().nlargest(10).index.tolist()
# 2. 聚合數據 (Month x Category)
season_group = target_df[target_df[col_category].isin(top_cats_season)].groupby(['_month_str', col_category])[col_amount].sum().reset_index()
# 3. 轉換為 Bubble Chart 格式
# X軸: 月份 (需解析 _month_str 取得順序)
# Y軸: 分類 (使用 top_cats_season 的索引)
# 取得所有月份並排序
all_months_sorted = sorted(target_df['_month_str'].unique())
month_map = {m: i for i, m in enumerate(all_months_sorted)}
cat_map = {c: i for i, c in enumerate(top_cats_season)}
points = []
max_val_season = season_group[col_amount].max() if not season_group.empty else 1
for _, row in season_group.iterrows():
m_str = row['_month_str']
cat = row[col_category]
val = row[col_amount]
if m_str in month_map and cat in cat_map:
# 正規化大小 (3~25px)
radius = 3 + (math.sqrt(val) / math.sqrt(max_val_season)) * 25 if val > 0 else 0
points.append({
'x': month_map[m_str],
'y': cat_map[cat],
'r': radius,
'v': float(val),
'm': m_str,
'c': cat
})
seasonality_data = {
'datasets': [{
'label': '淡旺季熱點',
'data': points,
# 顏色將在前端動態生成
}],
'yLabels': top_cats_season,
'xLabels': all_months_sorted
}
# 📊 V-New 2026-01-15: 行銷活動業績貢獻 (Marketing Campaign Contribution)
marketing_data = None
if not target_df.empty:
marketing_data = prepare_marketing_summary(target_df, sort_by=selected_metric)
# 📊 封裝回傳內容並存入快取
res_data = {
'marketing_data': marketing_data,
'items': table_items,
'kpi': {
'revenue': total_revenue, 'qty': total_qty, 'count': total_count,
'sku_count': sku_count, 'cost': total_cost, 'gross_margin': gross_margin,
'gross_margin_rate': gross_margin_rate, 'avg_price': avg_price
},
'insights': insights, 'abc_stats': abc_stats, 'vendor_stats': vendor_stats,
'seasonality_data': seasonality_data, 'bar_data': bar_data, 'cat_data': cat_data,
'price_dist_data': price_dist_data, 'scatter_data': scatter_data, 'bcg_data': bcg_data,
'dow_data': dow_data, 'hourly_data': hourly_data, 'monthly_data': monthly_data,
'weekly_data': weekly_data, 'heatmap_data': heatmap_data, 'treemap_data': treemap_data,
'selected_category': selected_category, 'selected_brand': selected_brand, 'selected_vendor': selected_vendor,
'selected_activity': selected_activity, 'selected_payment': selected_payment, 'selected_dow': selected_dow,
'selected_hour': selected_hour, 'selected_month': selected_month, 'selected_metric': selected_metric,
'keyword': keyword, 'min_price': min_price, 'max_price': max_price, 'min_margin': min_margin, 'max_margin': max_margin,
'all_categories': all_categories, 'all_brands': all_brands, 'all_vendors': all_vendors,
'all_activities': all_activities, 'all_payments': all_payments, 'all_months': all_months,
'table_name': table_name, 'total_records': total_count, 'data_range_months': data_range_months,
'start_date': start_date, 'end_date': end_date, 'db_data_range': db_data_range,
'cols': cols_map, 'no_filter': False
}
_SALES_ANALYSIS_RESULT_CACHE[result_cache_key] = {'data': res_data, 'time': time.time()}
return render_template('sales_analysis.html', **{**res_data, 'public_url': public_url, 'datetime_now': datetime_now_str})
except Exception as e:
sys_log.error(f"Sales Analysis Error: {e}")
import traceback
traceback.print_exc()
return render_template('sales_analysis.html',
error=f"系統發生錯誤: {str(e)}",
total_records=0,
public_url=public_url if 'public_url' in locals() else '',
db_data_range=db_data_range if 'db_data_range' in locals() else '',
start_date=request.args.get('start_date', ''),
end_date=request.args.get('end_date', ''),
data_range_months=int(request.args.get('data_range', '1') or '1'),
marketing_data=None,
insights=None,
abc_stats=None,
vendor_stats=None,
seasonality_data=None,
scatter_data=None,
bcg_data=None,
dow_data=None,
hourly_data=None,
monthly_data=None,
weekly_data=None,
heatmap_data=None,
treemap_data=None,
all_categories=[],
all_brands=[], all_vendors=[], all_activities=[], all_payments=[],
all_months=[],
selected_category='all',
selected_brand='all', selected_vendor='all',
selected_activity='all', selected_payment='all',
selected_dow='all', selected_hour='all',
selected_month='all',
selected_metric=request.args.get('metric', 'amount'),
keyword=request.args.get('keyword', ''),
min_price='', max_price='', min_margin='', max_margin='',
items=[],
kpi=DEFAULT_KPI.copy(),
cols=DEFAULT_COLS.copy(),
no_filter=False,
bar_data=DEFAULT_BAR_DATA.copy(),
cat_data=DEFAULT_CHART_DATA.copy(),
price_dist_data=DEFAULT_CHART_DATA.copy(),
datetime_now=datetime_now_str)
@app.route('/api/sales_analysis/table_data')
def get_sales_table_data():
"""API: 取得業績分析的詳細列表資料 (Server-side AJAX) - 使用 SQL 聚合優化"""
try:
from datetime import datetime, timedelta, timezone
TAIPEI_TZ = timezone(timedelta(hours=8))
table_name = 'realtime_sales_monthly'
data_range_months = int(request.args.get('data_range', '1') or '1')
start_date = request.args.get('start_date', '') # V-New: 自訂開始日期
end_date = request.args.get('end_date', '') # V-New: 自訂結束日期
# V-Fix: 取得所有篩選參數
category_filter = request.args.get('category', 'all')
brand_filter = request.args.get('brand', 'all') # V-Fix: 品牌篩選
vendor_filter = request.args.get('vendor', 'all') # V-Fix: 廠商篩選
activity_filter = request.args.get('activity', 'all') # V-Fix: 活動篩選
payment_filter = request.args.get('payment', 'all') # V-Fix: 付款方式篩選
month_filter = request.args.get('month', 'all')
dow_filter = request.args.get('dow', 'all') # 星期篩選
hour_filter = request.args.get('hour', 'all') # 小時篩選
min_price_str = request.args.get('min_price', '')
max_price_str = request.args.get('max_price', '')
min_margin_str = request.args.get('min_margin', '')
max_margin_str = request.args.get('max_margin', '')
keyword = request.args.get('keyword', '').strip()
db = DatabaseManager()
# V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱
if start_date or end_date:
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
else:
cache_key = f"{table_name}_{data_range_months}m"
# 嘗試從快取讀取欄位名稱
cols_map = {}
if cache_key in _SALES_PROCESSED_CACHE:
cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {})
elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key
cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {})
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
col_name = cols_map.get('name', '商品名稱')
col_pid = cols_map.get('pid', '商品ID')
col_brand = cols_map.get('brand', '品牌')
col_vendor = cols_map.get('vendor', '廠商名稱')
col_category = cols_map.get('category', '商品館')
col_amount = cols_map.get('amount', '總業績')
col_qty = cols_map.get('qty', '數量')
col_cost = cols_map.get('cost', '總成本')
col_return_qty = cols_map.get('return_qty', '退貨數量')
# V-Opt: 使用純 SQL 聚合查詢,避免載入完整資料集
# 建立日期篩選條件
date_filter = ""
# V-New: 優先處理自訂日期區間
if start_date or end_date:
# V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01)
start_date_slash = start_date.replace('-', '/') if start_date else ''
end_date_slash = end_date.replace('-', '/') if end_date else ''
# V-Fix: 只使用「日期」欄位(「訂單日期」欄位是固定文字「訂單日期」,不是實際日期)
if start_date and end_date:
date_filter = f"""AND ("日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}')"""
elif start_date:
date_filter = f"""AND ("日期" >= '{start_date_slash}')"""
else: # only end_date
date_filter = f"""AND ("日期" <= '{end_date_slash}')"""
elif data_range_months > 0:
# V-Fix: 使用斜線格式以匹配資料庫格式
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
# V-Fix: 只使用「日期」欄位進行篩選(「訂單日期」是固定文字,不是實際日期)
date_filter = f"""AND ("日期" >= '{cutoff_date}')"""
# V-Fix: 建立其他篩選條件
additional_filters = []
query_params = {} # V-Fix: 初始化查詢參數字典
# 分類篩選
if category_filter and category_filter != 'all' and col_category:
additional_filters.append(f"\"{col_category}\" = :cat")
query_params['cat'] = category_filter
if brand_filter and brand_filter != 'all' and col_brand:
additional_filters.append(f"\"{col_brand}\" = :brand")
query_params['brand'] = brand_filter
if vendor_filter and vendor_filter != 'all' and col_vendor:
additional_filters.append(f"\"{col_vendor}\" = :vendor")
query_params['vendor'] = vendor_filter
col_activity = cols_map.get('activity')
if activity_filter and activity_filter != 'all' and col_activity:
additional_filters.append(f"\"{col_activity}\" = :act")
query_params['act'] = activity_filter
col_payment = cols_map.get('payment')
if payment_filter and payment_filter != 'all' and col_payment:
additional_filters.append(f"\"{col_payment}\" = :pay")
query_params['pay'] = payment_filter
# 月份篩選
if month_filter and month_filter != 'all':
month_filter_slash = month_filter.replace('-', '/')
additional_filters.append('("日期" LIKE :m1 OR "日期" LIKE :m2)')
query_params['m1'] = f"{month_filter}%"
query_params['m2'] = f"{month_filter_slash}%"
# 星期篩選 (需要從日期計算)
if dow_filter and dow_filter != 'all':
sqlite_dow = str((int(dow_filter) + 1) % 7)
additional_filters.append("strftime('%w', \"日期\") = :dow")
query_params['dow'] = sqlite_dow
# 小時篩選 (需要從時間欄位提取)
if hour_filter and hour_filter != 'all':
hour_conditions = []
for field in ["時間"]: # 通常只有「時間」欄位包含小時資訊
hour_conditions.append(f"""CAST(strftime('%H', "{field}") AS INTEGER) = {hour_filter}""")
if hour_conditions:
additional_filters.append(f"({' OR '.join(hour_conditions)})")
# 關鍵字篩選
if keyword:
keyword_conditions = []
if col_name: keyword_conditions.append(f"\"{col_name}\" LIKE :kw")
if col_pid: keyword_conditions.append(f"\"{col_pid}\" LIKE :kw")
if col_brand: keyword_conditions.append(f"\"{col_brand}\" LIKE :kw")
if col_vendor: keyword_conditions.append(f"\"{col_vendor}\" LIKE :kw")
if keyword_conditions:
additional_filters.append(f"({' OR '.join(keyword_conditions)})")
query_params['kw'] = f"%{keyword}%"
# V-New: 價格區間篩選 (Price Range)
if (min_price_str or max_price_str) and col_qty and col_amount:
# 假設單價 = 總業績 / 數量 (防止除以零)
price_cal_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)'
if min_price_str:
additional_filters.append(f"{price_cal_sql} >= {float(min_price_str)}")
if max_price_str:
additional_filters.append(f"{price_cal_sql} <= {float(max_price_str)}")
# V-New: 毛利率區間篩選 (Margin Range)
if (min_margin_str or max_margin_str) and col_amount:
# 計算毛利額 SQL
if col_profit:
profit_cal_sql = f'"{col_profit}"'
elif col_cost:
profit_cal_sql = f'("{col_amount}" - "{col_cost}")'
else:
profit_cal_sql = "0"
# 計算毛利率 SQL: (毛利 / 業績) * 100
margin_cal_sql = f'({profit_cal_sql} * 100.0 / NULLIF("{col_amount}", 0))'
if min_margin_str:
additional_filters.append(f"{margin_cal_sql} >= {float(min_margin_str)}")
if max_margin_str:
additional_filters.append(f"{margin_cal_sql} <= {float(max_margin_str)}")
# 組合所有篩選條件
all_filters = date_filter
if additional_filters:
all_filters += " AND " + " AND ".join(additional_filters)
# SQL 聚合查詢 - 直接在資料庫層級完成聚合
# V-Fix: 使用動態欄位名稱
group_by_cols = []
if col_pid: group_by_cols.append(f'"{col_pid}"')
if col_name: group_by_cols.append(f'"{col_name}"')
if col_brand: group_by_cols.append(f'"{col_brand}"')
if col_vendor: group_by_cols.append(f'"{col_vendor}"')
if col_category: group_by_cols.append(f'"{col_category}"')
group_by_clause = ', '.join(group_by_cols) if group_by_cols else '"商品ID"'
sql_query = f"""
SELECT
{f'"{col_pid}" as product_id' if col_pid else "'未知' as product_id"},
{f'"{col_name}" as name' if col_name else "'未知' as name"},
{f'"{col_brand}" as brand' if col_brand else "'' as brand"},
{f'"{col_vendor}" as vendor' if col_vendor else "'' as vendor"},
{f'"{col_category}" as category' if col_category else "'' as category"},
{f'SUM(CAST("{col_amount}" AS REAL)) as amount' if col_amount else '0 as amount'},
{f'SUM(CAST("{col_qty}" AS REAL)) as qty' if col_qty else '0 as qty'},
{f'SUM(CAST("{col_cost}" AS REAL)) as cost' if col_cost else '0 as cost'},
{f'SUM(CAST("{col_return_qty}" AS REAL)) as return_qty' if col_return_qty else '0 as return_qty'},
COUNT(*) as order_count
FROM {table_name}
WHERE 1=1 {all_filters}
GROUP BY {group_by_clause}
ORDER BY amount DESC
LIMIT 1000
"""
df_agg = pd.read_sql(text(sql_query), db.engine, params=query_params)
sys_log.info(f"[API] Table Data: SQL聚合查詢返回 {len(df_agg)} 筆商品 (篩選: category={category_filter}, month={month_filter}, dow={dow_filter}, hour={hour_filter}, keyword={keyword})")
if df_agg.empty:
return jsonify({'data': []})
# 計算衍生欄位
df_agg['margin_rate'] = ((df_agg['amount'] - df_agg['cost']) / df_agg['amount'] * 100).fillna(0)
df_agg['margin_rate'] = df_agg['margin_rate'].replace([np.inf, -np.inf], 0)
df_agg['avg_price'] = (df_agg['amount'] / df_agg['qty']).fillna(0)
df_agg['return_rate'] = (df_agg['return_qty'] / df_agg['qty'] * 100).fillna(0)
# V-Fix: 應用價格區間篩選 (在計算欄位後才能篩選)
if min_price_str:
try:
min_price = float(min_price_str)
df_agg = df_agg[df_agg['avg_price'] >= min_price]
except ValueError:
pass
if max_price_str:
try:
max_price = float(max_price_str)
df_agg = df_agg[df_agg['avg_price'] <= max_price]
except ValueError:
pass
# V-Fix: 應用毛利區間篩選 (在計算欄位後才能篩選)
if min_margin_str:
try:
min_margin = float(min_margin_str)
df_agg = df_agg[df_agg['margin_rate'] >= min_margin]
except ValueError:
pass
if max_margin_str:
try:
max_margin = float(max_margin_str)
df_agg = df_agg[df_agg['margin_rate'] <= max_margin]
except ValueError:
pass
# 重新排序並限制到 1000 筆
df_agg = df_agg.sort_values('amount', ascending=False).head(1000)
# 轉換為 DataTables 格式
data = []
for i, row in enumerate(df_agg.to_dict('records')):
data.append({
'rank': i + 1,
'product_id': row.get('product_id', ''),
'name': row.get('name', ''),
'brand': row.get('brand', ''),
'vendor': row.get('vendor', ''),
'category': row.get('category', ''),
'margin_rate': row.get('margin_rate', 0),
'month_str': '', # SQL聚合模式不需要月份字串
'avg_price': row.get('avg_price', 0),
'return_rate': row.get('return_rate', 0),
'qty': row.get('qty', 0),
'amount': row.get('amount', 0)
})
return jsonify({'data': data})
except Exception as e:
sys_log.error(f"[API] Table Data Error: {e}")
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
# V-Old: 保留舊版本以防需要回滾
@app.route('/api/sales_analysis/table_data_pandas')
def get_sales_table_data_pandas():
"""API: 取得業績分析的詳細列表資料 (使用 pandas 聚合 - 舊版本)"""
try:
table_name = 'realtime_sales_monthly'
data_range_months = int(request.args.get('data_range', '1'))
cache_key = f"{table_name}_{data_range_months}m"
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
if err or target_df is None:
sys_log.warning(f"[API] Table Data: 快取不存在 ({cache_key}),返回空資料")
return jsonify({'data': []})
if target_df.empty:
return jsonify({'data': []})
col_name = cols_map.get('name')
col_amount = cols_map.get('amount')
col_qty = cols_map.get('qty')
col_cost = cols_map.get('cost')
col_profit = cols_map.get('profit')
col_category = cols_map.get('category')
col_vendor = cols_map.get('vendor')
col_date = cols_map.get('date')
col_brand = cols_map.get('brand')
col_return_qty = cols_map.get('return_qty')
selected_metric = request.args.get('metric', 'amount')
# 執行聚合 (V-Opt: 多維度聚合,增加精確度)
agg_rules = {col_amount: 'sum'}
if col_qty: agg_rules[col_qty] = 'sum'
if col_cost: agg_rules[col_cost] = 'sum'
if col_profit: agg_rules[col_profit] = 'sum'
if col_return_qty: agg_rules[col_return_qty] = 'sum'
if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique()))
# Group By 鍵值:商品名稱 + 品牌 + 廠商 + 分類 (確保唯一性)
group_cols = [col_name]
if col_brand: group_cols.append(col_brand)
if col_vendor: group_cols.append(col_vendor)
if col_category: group_cols.append(col_category)
df_agg = target_df.groupby(group_cols).agg(agg_rules).reset_index()
# 計算毛利率
if col_profit:
df_agg['agg_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
elif col_cost:
df_agg['agg_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
else:
df_agg['agg_margin_rate'] = 0.0
df_agg['agg_margin_rate'] = df_agg['agg_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
# V-New: 計算平均單價與退貨率
if col_qty:
df_agg['avg_price'] = (df_agg[col_amount] / df_agg[col_qty]).fillna(0)
if col_return_qty:
df_agg['return_rate'] = (df_agg[col_return_qty] / df_agg[col_qty] * 100).fillna(0)
# 排序
sort_col_agg = col_amount
if selected_metric == 'qty' and col_qty:
sort_col_agg = col_qty
df_agg = df_agg.sort_values(by=sort_col_agg, ascending=False).head(1000) # 限制前 1000 筆
# 轉換為 DataTables 需要的格式
data = []
for i, row in enumerate(df_agg.to_dict('records')):
data.append({
'rank': i + 1,
'name': row.get(col_name, ''),
'brand': row.get(col_brand, ''),
'vendor': row.get(col_vendor, ''),
'category': row.get(col_category, ''),
'margin_rate': row.get('agg_margin_rate', 0),
'month_str': row.get('_month_str', ''),
'avg_price': row.get('avg_price', 0),
'return_rate': row.get('return_rate', 0),
'qty': row.get(col_qty, 0),
'amount': row.get(col_amount, 0)
})
return jsonify({'data': data})
except Exception as e:
sys_log.error(f"Table Data API Error: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/export/excel/seasonality_detail')
def export_seasonality_detail():
"""API: 匯出淡旺季熱力圖的詳細資料 (點擊氣泡觸發)"""
try:
table_name = 'realtime_sales_monthly'
data_range_months = int(request.args.get('data_range', '1') or '1')
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
if start_date or end_date:
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
else:
cache_key = f"{table_name}_{data_range_months}m"
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
# V-Fix: 如果 cache_key 不存在,嘗試使用固定的 table_name
if err and table_name in _SALES_PROCESSED_CACHE:
target_df, cols_map, err = _get_filtered_sales_data(table_name)
if err: return f"匯出失敗: {err}", 400
# 取得額外參數
target_month = request.args.get('target_month')
target_category = request.args.get('target_category')
if not target_month or not target_category:
return "缺少必要參數 (month, category)", 400
col_category = cols_map.get('category')
# 進一步篩選
export_df = target_df[
(target_df['_month_str'] == target_month) &
(target_df[col_category] == target_category)
]
if export_df.empty:
return "該月份與分類無資料", 404
# 使用 BytesIO 直接在記憶體中產生 Excel (避免 Exporter 的類型不相容)
import io
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
export_df.to_excel(writer, index=False, sheet_name='明細')
output.seek(0)
filename = f"Seasonality_{target_category}_{target_month}.xlsx"
return send_file(
output,
as_attachment=True,
download_name=filename,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except Exception as e:
sys_log.error(f"Seasonality Export Error: {e}")
return f"匯出失敗: {e}", 500
# ================= 💎 V-New: Top 3 Highlights 詳細列表 API =================
@app.route('/api/sales_analysis/top_detail')
def get_top_detail():
"""API: 取得 Top N 詳細列表(業績貢獻王/獲利金雞母/人氣引流款)"""
try:
from datetime import datetime, timedelta, timezone
TAIPEI_TZ = timezone(timedelta(hours=8))
table_name = 'realtime_sales_monthly'
data_range_months = int(request.args.get('data_range', '1') or '1')
start_date = request.args.get('start_date', '') # V-New: 自訂開始日期
end_date = request.args.get('end_date', '') # V-New: 自訂結束日期
top_type = request.args.get('type', 'revenue') # revenue/margin/quantity
metric = request.args.get('metric', 'amount') # amount/profit/qty
view_type = request.args.get('view', 'product') # product/category
db = DatabaseManager()
# V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱
if start_date or end_date:
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
else:
cache_key = f"{table_name}_{data_range_months}m"
# 嘗試從快取讀取欄位名稱
cols_map = {}
if cache_key in _SALES_PROCESSED_CACHE:
cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {})
elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key
cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {})
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
col_name = cols_map.get('name', '商品名稱')
col_brand = cols_map.get('brand', '品牌')
col_vendor = cols_map.get('vendor', '廠商名稱')
col_category = cols_map.get('category', '商品館')
col_amount = cols_map.get('amount', '總業績')
col_qty = cols_map.get('qty', '數量')
col_cost = cols_map.get('cost', '總成本')
col_profit = cols_map.get('profit') # V-New: 取得利潤欄位
col_activity = cols_map.get('activity', '活動名稱')
col_payment = cols_map.get('payment', '付款方式')
# 建立日期篩選條件
date_filter = ""
# V-New: 優先處理自訂日期區間
if start_date or end_date:
# V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01)
start_date_slash = start_date.replace('-', '/') if start_date else ''
end_date_slash = end_date.replace('-', '/') if end_date else ''
if start_date and end_date:
date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'"""
elif start_date:
date_filter = f"""AND "日期" >= '{start_date_slash}'"""
else: # only end_date
date_filter = f"""AND "日期" <= '{end_date_slash}'"""
elif data_range_months > 0:
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
date_filter = f"""AND "日期" >= '{cutoff_date}'"""
# V-Fix: 補上其他所有篩選條件 (與 get_sales_table_data 一致)
category_filter = request.args.get('category', 'all')
brand_filter = request.args.get('brand', 'all')
vendor_filter = request.args.get('vendor', 'all')
activity_filter = request.args.get('activity', 'all')
payment_filter = request.args.get('payment', 'all')
month_filter = request.args.get('month', 'all')
dow_filter = request.args.get('dow', 'all')
hour_filter = request.args.get('hour', 'all')
min_price_str = request.args.get('min_price', '')
max_price_str = request.args.get('max_price', '')
min_margin_str = request.args.get('min_margin', '')
max_margin_str = request.args.get('max_margin', '')
keyword = request.args.get('keyword', '').strip()
additional_filters = []
if category_filter and category_filter != 'all':
additional_filters.append(f"\"{col_category}\" = :cat")
query_params['cat'] = category_filter
if brand_filter and brand_filter != 'all':
additional_filters.append(f"\"{col_brand}\" = :brand")
query_params['brand'] = brand_filter
if vendor_filter and vendor_filter != 'all':
additional_filters.append(f"\"{col_vendor}\" = :vendor")
query_params['vendor'] = vendor_filter
if activity_filter and activity_filter != 'all':
additional_filters.append(f"\"{col_activity}\" = :act")
query_params['act'] = activity_filter
if payment_filter and payment_filter != 'all':
additional_filters.append(f"\"{col_payment}\" = :pay")
query_params['pay'] = payment_filter
# 時間維度
if month_filter and month_filter != 'all':
month_filter_slash = month_filter.replace('-', '/')
additional_filters.append('("日期" LIKE :m1 OR "日期" LIKE :m2)')
query_params['m1'] = f"{month_filter}%"
query_params['m2'] = f"{month_filter_slash}%"
if dow_filter and dow_filter != 'all':
sqlite_dow = str((int(dow_filter) + 1) % 7)
additional_filters.append("strftime('%w', \"日期\") = :dow")
query_params['dow'] = sqlite_dow
if hour_filter and hour_filter != 'all':
additional_filters.append(f"""CAST(strftime('%H', "時間") AS INTEGER) = {hour_filter}""")
# 關鍵字
if keyword:
keyword_escaped = keyword.replace("'", "''")
k_conds = []
for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]:
k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""")
additional_filters.append(f"({' OR '.join(k_conds)})")
if (min_price_str or max_price_str):
price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)'
if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}")
if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}")
if (min_margin_str or max_margin_str):
if col_profit:
profit_sql = f'"{col_profit}"'
else:
profit_sql = f'("{col_amount}" - "{col_cost}")'
margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))'
if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}")
if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}")
if additional_filters:
date_filter += " AND " + " AND ".join(additional_filters)
# V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合)
if col_profit:
profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))'
else:
profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))'
# 根據檢視類型和指標建立 SQL 查詢
if view_type == 'category':
# 分類排行
if metric == 'qty':
sql_query = f"""
SELECT
"{col_category}" as name,
SUM(CAST("{col_qty}" AS REAL)) as value
FROM {table_name}
WHERE "{col_category}" IS NOT NULL {date_filter}
GROUP BY "{col_category}"
ORDER BY value DESC
LIMIT 50
"""
elif metric == 'profit':
sql_query = f"""
SELECT
"{col_category}" as name,
{profit_select_sql} as value,
CASE
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
ELSE 0
END as margin_rate
FROM {table_name}
WHERE "{col_category}" IS NOT NULL {date_filter}
GROUP BY "{col_category}"
ORDER BY value DESC
LIMIT 50
"""
else: # amount
sql_query = f"""
SELECT
"{col_category}" as name,
SUM(CAST("{col_amount}" AS REAL)) as value
FROM {table_name}
WHERE "{col_category}" IS NOT NULL {date_filter}
GROUP BY "{col_category}"
ORDER BY value DESC
LIMIT 50
"""
else:
# 商品排行包含商品ID
pid_col_sql = f'"{cols_map.get("pid", "商品ID")}"' # 商品ID 欄位
if metric == 'qty':
sql_query = f"""
SELECT
{pid_col_sql} as product_id,
"{col_name}" as name,
"{col_brand}" as brand,
"{col_vendor}" as vendor,
"{col_category}" as category,
SUM(CAST("{col_qty}" AS REAL)) as value
FROM {table_name}
WHERE "{col_name}" IS NOT NULL {date_filter}
GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
ORDER BY value DESC
LIMIT 100
"""
elif metric == 'profit':
sql_query = f"""
SELECT
{pid_col_sql} as product_id,
"{col_name}" as name,
"{col_brand}" as brand,
"{col_vendor}" as vendor,
"{col_category}" as category,
{profit_select_sql} as value,
CASE
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
ELSE 0
END as margin_rate
FROM {table_name}
WHERE "{col_name}" IS NOT NULL {date_filter}
GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
ORDER BY value DESC
LIMIT 100
"""
else: # amount
sql_query = f"""
SELECT
{pid_col_sql} as product_id,
"{col_name}" as name,
"{col_brand}" as brand,
"{col_vendor}" as vendor,
"{col_category}" as category,
SUM(CAST("{col_amount}" AS REAL)) as value
FROM {table_name}
WHERE "{col_name}" IS NOT NULL {date_filter}
GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
ORDER BY value DESC
LIMIT 100
"""
# 執行查詢
df = pd.read_sql(text(sql_query), db.engine, params=query_params)
sys_log.info(f"[API] Top Detail: {top_type}/{view_type} 返回 {len(df)} 筆資料")
if df.empty:
return jsonify({'items': []})
# 轉換為 JSON
items = df.to_dict('records')
return jsonify({'items': items})
except Exception as e:
sys_log.error(f"[API] Top Detail Error: {e}")
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
@app.route('/api/sales_analysis/export_top_detail')
def export_top_detail():
"""API: 匯出 Top N 詳細列表為 Excel"""
try:
from datetime import datetime, timedelta, timezone
import io
TAIPEI_TZ = timezone(timedelta(hours=8))
table_name = 'realtime_sales_monthly'
data_range_months = int(request.args.get('data_range', '1') or '1')
start_date = request.args.get('start_date', '') # V-New: 自訂開始日期
end_date = request.args.get('end_date', '') # V-New: 自訂結束日期
top_type = request.args.get('type', 'revenue')
metric = request.args.get('metric', 'amount')
view_type = request.args.get('view', 'product')
db = DatabaseManager()
# V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱
if start_date or end_date:
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
else:
cache_key = f"{table_name}_{data_range_months}m"
# 嘗試從快取讀取欄位名稱
cols_map = {}
if cache_key in _SALES_PROCESSED_CACHE:
cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {})
elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key
cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {})
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
col_name = cols_map.get('name', '商品名稱')
col_brand = cols_map.get('brand', '品牌')
col_vendor = cols_map.get('vendor', '廠商名稱')
col_category = cols_map.get('category', '商品館')
col_amount = cols_map.get('amount', '總業績')
col_qty = cols_map.get('qty', '數量')
col_cost = cols_map.get('cost', '總成本')
col_profit = cols_map.get('profit') # V-New: 取得利潤欄位
col_activity = cols_map.get('activity', '活動名稱')
col_payment = cols_map.get('payment', '付款方式')
# 建立日期篩選條件
date_filter = ""
# V-New: 優先處理自訂日期區間
if start_date or end_date:
# V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01)
start_date_slash = start_date.replace('-', '/') if start_date else ''
end_date_slash = end_date.replace('-', '/') if end_date else ''
if start_date and end_date:
date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'"""
elif start_date:
date_filter = f"""AND "日期" >= '{start_date_slash}'"""
else: # only end_date
date_filter = f"""AND "日期" <= '{end_date_slash}'"""
elif data_range_months > 0:
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
date_filter = f"""AND "日期" >= '{cutoff_date}'"""
# V-Fix: 補上其他所有篩選條件 (與 get_top_detail 一致)
category_filter = request.args.get('category', 'all')
brand_filter = request.args.get('brand', 'all')
vendor_filter = request.args.get('vendor', 'all')
activity_filter = request.args.get('activity', 'all')
payment_filter = request.args.get('payment', 'all')
month_filter = request.args.get('month', 'all')
dow_filter = request.args.get('dow', 'all')
hour_filter = request.args.get('hour', 'all')
min_price_str = request.args.get('min_price', '')
max_price_str = request.args.get('max_price', '')
min_margin_str = request.args.get('min_margin', '')
max_margin_str = request.args.get('max_margin', '')
keyword = request.args.get('keyword', '').strip()
additional_filters = []
if category_filter and category_filter != 'all':
additional_filters.append(f""""{col_category}" = '{category_filter}'""")
if brand_filter and brand_filter != 'all':
additional_filters.append(f""""{col_brand}" = '{brand_filter}'""")
if vendor_filter and vendor_filter != 'all':
additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""")
if activity_filter and activity_filter != 'all':
additional_filters.append(f""""{col_activity}" = '{activity_filter}'""")
if payment_filter and payment_filter != 'all':
additional_filters.append(f""""{col_payment}" = '{payment_filter}'""")
if month_filter and month_filter != 'all':
month_filter_slash = month_filter.replace('-', '/')
additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""")
if dow_filter and dow_filter != 'all':
pandas_dow = int(dow_filter)
sqlite_dow = str((pandas_dow + 1) % 7)
additional_filters.append(f"""strftime('%w', "日期") = '{sqlite_dow}'""")
if hour_filter and hour_filter != 'all':
additional_filters.append(f"""CAST(strftime('%H', "時間") AS INTEGER) = {hour_filter}""")
if keyword:
keyword_escaped = keyword.replace("'", "''")
k_conds = []
for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]:
k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""")
additional_filters.append(f"({' OR '.join(k_conds)})")
if (min_price_str or max_price_str):
price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)'
if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}")
if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}")
if (min_margin_str or max_margin_str):
if col_profit:
profit_sql = f'"{col_profit}"'
else:
profit_sql = f'("{col_amount}" - "{col_cost}")'
margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))'
if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}")
if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}")
if additional_filters:
date_filter += " AND " + " AND ".join(additional_filters)
# V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合)
if col_profit:
profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))'
else:
profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))'
# 根據檢視類型和指標建立 SQL 查詢(與上面相同)
if view_type == 'category':
if metric == 'qty':
sql_query = f"""
SELECT
"{col_category}" as 分類名稱,
SUM(CAST("{col_qty}" AS REAL)) as 銷售數量
FROM {table_name}
WHERE "{col_category}" IS NOT NULL {date_filter}
GROUP BY "{col_category}"
ORDER BY 銷售數量 DESC
LIMIT 50
"""
elif metric == 'profit':
sql_query = f"""
SELECT
"{col_category}" as 分類名稱,
{profit_select_sql} as 毛利金額,
CASE
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
ELSE 0
END as 毛利率
FROM {table_name}
WHERE "{col_category}" IS NOT NULL {date_filter}
GROUP BY "{col_category}"
ORDER BY 毛利金額 DESC
LIMIT 50
"""
else: # amount
sql_query = f"""
SELECT
"{col_category}" as 分類名稱,
SUM(CAST("{col_amount}" AS REAL)) as 銷售金額
FROM {table_name}
WHERE "{col_category}" IS NOT NULL {date_filter}
GROUP BY "{col_category}"
ORDER BY 銷售金額 DESC
LIMIT 50
"""
else:
# 商品排行包含商品ID
if metric == 'qty':
sql_query = f"""
SELECT
"{cols_map.get("pid", "商品ID")}" as 商品ID,
"{col_name}" as 商品名稱,
"{col_brand}" as 品牌,
"{col_vendor}" as 廠商名稱,
"{col_category}" as 分類名稱,
SUM(CAST("{col_qty}" AS REAL)) as 銷售數量
FROM {table_name}
WHERE "{col_name}" IS NOT NULL {date_filter}
GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
ORDER BY 銷售數量 DESC
LIMIT 100
"""
elif metric == 'profit':
sql_query = f"""
SELECT
"{cols_map.get("pid", "商品ID")}" as 商品ID,
"{col_name}" as 商品名稱,
"{col_brand}" as 品牌,
"{col_vendor}" as 廠商名稱,
"{col_category}" as 分類名稱,
{profit_select_sql} as 毛利金額,
CASE
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
ELSE 0
END as 毛利率
FROM {table_name}
WHERE "{col_name}" IS NOT NULL {date_filter}
GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
ORDER BY 毛利金額 DESC
LIMIT 100
"""
else: # amount
sql_query = f"""
SELECT
"{cols_map.get("pid", "商品ID")}" as 商品ID,
"{col_name}" as 商品名稱,
"{col_brand}" as 品牌,
"{col_vendor}" as 廠商名稱,
"{col_category}" as 分類名稱,
SUM(CAST("{col_amount}" AS REAL)) as 銷售金額
FROM {table_name}
WHERE "{col_name}" IS NOT NULL {date_filter}
GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
ORDER BY 銷售金額 DESC
LIMIT 100
"""
# 執行查詢並匯出
df = pd.read_sql(text(sql_query), db.engine, params=query_params)
if df.empty:
return "無資料可匯出", 400
# 生成 Excel
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, index=False, sheet_name='Top排行')
output.seek(0)
# 生成檔案名稱
type_names = {'revenue': '業績貢獻王', 'margin': '獲利金雞母', 'quantity': '人氣引流款'}
view_names = {'product': '商品排行', 'category': '分類排行'}
filename = f"{type_names.get(top_type, '排行')}_{view_names.get(view_type, '')}_{datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')}.xlsx"
sys_log.info(f"[Export] Top Detail: {filename} ({len(df)} 筆)")
return send_file(
output,
as_attachment=True,
download_name=filename,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except Exception as e:
sys_log.error(f"[Export] Top Detail Error: {e}")
return f"匯出失敗: {e}", 500
# ================= 📈 V-New: 年度對比 (Year-over-Year Comparison) =================
# YoY 快取
_YOY_CACHE = {}
_YOY_CACHE_TTL = 1800 # 30 分鐘
@app.route('/api/sales_analysis/yoy_comparison')
def yoy_comparison():
"""
API: 年度對比分析 (YoY Comparison) - 已優化效能
參數:
year1: 基準年 (例如 2025)
year2: 對比年 (例如 2026)
month: 月份 (可選1-12不帶則為全年)
metric: 指標 (revenue/qty/profit)
回傳:
JSON with year1 total, year2 total, growth rate, and monthly breakdown
優化:
- 使用單一 SQL 查詢取代 24 次查詢
- 加入 30 分鐘快取機制
"""
import time
start_time = time.time()
try:
from datetime import datetime, timedelta, timezone
TAIPEI_TZ = timezone(timedelta(hours=8))
table_name = 'realtime_sales_monthly'
year1 = request.args.get('year1', '2025')
year2 = request.args.get('year2', '2026')
month = request.args.get('month', '') # 可選1-12
metric = request.args.get('metric', 'revenue') # revenue/qty/profit
# 快取檢查
cache_key = f"{year1}_{year2}_{month}_{metric}"
now = datetime.now(TAIPEI_TZ)
if cache_key in _YOY_CACHE:
cached = _YOY_CACHE[cache_key]
cache_age = (now - cached['timestamp']).total_seconds()
if cache_age < _YOY_CACHE_TTL:
sys_log.debug(f"[YoY] [Cache] 使用快取 | 快取年齡: {int(cache_age)}秒")
return jsonify(cached['data'])
db = DatabaseManager()
# 欄位名稱
col_amount = '總業績'
col_qty = '數量'
col_cost = '總成本'
col_date = '日期'
# 根據指標決定標籤
if metric == 'qty':
metric_label = '銷售數量'
elif metric == 'profit':
metric_label = '毛利金額'
else: # revenue
metric_label = '銷售金額'
# 優化:使用 Pandas 一次讀取兩年資料,再分組聚合
# 比起 24 次 SQL 查詢,效能大幅提升
years_filter = f'("{col_date}" LIKE :y1a OR "{col_date}" LIKE :y1b OR "{col_date}" LIKE :y2a OR "{col_date}" LIKE :y2b)'
params = {
'y1a': f'{year1}/%', 'y1b': f'{year1}-%',
'y2a': f'{year2}/%', 'y2b': f'{year2}-%'
}
cols_to_fetch = [col_date, col_amount]
if metric == 'qty':
cols_to_fetch.append(col_qty)
elif metric == 'profit':
cols_to_fetch.extend([col_amount, col_cost])
cols_sql = ', '.join([f'"{c}"' for c in set(cols_to_fetch)])
sql = f'SELECT {cols_sql} FROM "{table_name}" WHERE {years_filter}'
df = pd.read_sql(text(sql), db.engine, params=params)
if df.empty:
response = {
'year1': {'label': f'{year1}年', 'total': 0},
'year2': {'label': f'{year2}年', 'total': 0},
'growth_rate': 0,
'metric': metric,
'metric_label': metric_label,
'monthly_breakdown': []
}
return jsonify(response)
# 解析日期,提取年份和月份
df['date_parsed'] = pd.to_datetime(df[col_date], errors='coerce')
df = df.dropna(subset=['date_parsed'])
df['year'] = df['date_parsed'].dt.year.astype(str)
df['month'] = df['date_parsed'].dt.month
# 計算指標值
if metric == 'qty':
df['value'] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0)
elif metric == 'profit':
df['value'] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0) - pd.to_numeric(df[col_cost], errors='coerce').fillna(0)
else:
df['value'] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0)
# 按年份+月份聚合
grouped = df.groupby(['year', 'month'])['value'].sum().reset_index()
# 計算年度總計
total_year1 = float(grouped[grouped['year'] == year1]['value'].sum())
total_year2 = float(grouped[grouped['year'] == year2]['value'].sum())
# 計算成長率
if total_year1 > 0:
growth_rate = ((total_year2 - total_year1) / total_year1) * 100
else:
growth_rate = 0 if total_year2 == 0 else 100
# 月度明細
monthly_breakdown = []
if not month:
# 建立年度-月份的 pivot 表
pivot = grouped.pivot(index='month', columns='year', values='value').fillna(0)
for m in range(1, 13):
v1 = float(pivot.loc[m, year1]) if m in pivot.index and year1 in pivot.columns else 0
v2 = float(pivot.loc[m, year2]) if m in pivot.index and year2 in pivot.columns else 0
m_growth = ((v2 - v1) / v1 * 100) if v1 > 0 else (0 if v2 == 0 else 100)
monthly_breakdown.append({
'month': m,
'month_label': f'{m}月',
'year1_value': v1,
'year2_value': v2,
'growth_rate': round(m_growth, 2)
})
response = {
'year1': {
'label': f'{year1}年' + (f'{month}月' if month else ''),
'total': total_year1
},
'year2': {
'label': f'{year2}年' + (f'{month}月' if month else ''),
'total': total_year2
},
'growth_rate': round(growth_rate, 2),
'metric': metric,
'metric_label': metric_label,
'monthly_breakdown': monthly_breakdown
}
# 儲存快取
_YOY_CACHE[cache_key] = {
'data': response,
'timestamp': now
}
elapsed = time.time() - start_time
sys_log.info(f"[YoY] {year1} vs {year2}: {total_year1:,.0f} -> {total_year2:,.0f} ({growth_rate:+.1f}%) | 耗時: {elapsed:.3f}s")
return jsonify(response)
except Exception as e:
sys_log.error(f"[YoY] Error: {e}")
traceback.print_exc()
return jsonify({'error': str(e)}), 500
# ================= 📈 V-New: 營運成長報表 (Growth Strategy) =================
@app.route('/growth_analysis')
def growth_analysis():
"""營運成長策略報表 (MoM, YoY, AOV, YTD)"""
try:
db = DatabaseManager()
table_name = 'realtime_sales_monthly'
# 1. 檢查資料表
inspector = inspect(db.engine)
if table_name not in inspector.get_table_names():
# V-Fix: 使用正確的模板或回傳錯誤訊息
return f"尚未匯入業績資料 ({table_name})", 404
# 2. 讀取資料 (只讀取必要欄位以提升效能,使用安全函數防止 SQL Injection)
# 根據 inspect_columns.py 結果,使用正確的中文欄位名稱
req_cols = ['日期', '總業績', '訂單編號', '總成本']
df = safe_read_sql(table_name, columns=req_cols, engine=db.engine)
if df.empty:
# V-Fix: 使用正確的模板或回傳錯誤訊息
return f"資料表 {table_name} 為空", 404
# 3. 資料前處理
df['dt'] = pd.to_datetime(df['日期'], errors='coerce')
df = df.dropna(subset=['dt']) # 移除日期無效的資料
df['amount'] = pd.to_numeric(df['總業績'], errors='coerce').fillna(0)
df['cost'] = pd.to_numeric(df['總成本'], errors='coerce').fillna(0)
df['profit'] = df['amount'] - df['cost']
# 4. 按月聚合統計
# resample('MS') 會將日期對齊到月初 (Month Start)
monthly_stats = df.set_index('dt').resample('MS').agg({
'amount': 'sum',
'profit': 'sum',
'訂單編號': 'nunique' # 計算不重複訂單數
}).rename(columns={'訂單編號': 'orders'})
# 5. 計算衍生指標 (AOV, MoM, YoY)
monthly_stats['aov'] = monthly_stats['amount'] / monthly_stats['orders']
monthly_stats['margin_rate'] = (monthly_stats['profit'] / monthly_stats['amount']) * 100
# MoM (月增率)
monthly_stats['mom'] = monthly_stats['amount'].pct_change() * 100
# YoY (年增率) - shift(12)
monthly_stats['yoy'] = monthly_stats['amount'].pct_change(periods=12) * 100
# 填補 NaN (第一個月或無上期資料)
monthly_stats = monthly_stats.fillna(0)
# 6. 準備圖表數據
# 轉換索引為字串 'YYYY-MM'
labels = monthly_stats.index.strftime('%Y-%m').tolist()
chart_data = {
'labels': labels,
'revenue': monthly_stats['amount'].tolist(),
'profit': monthly_stats['profit'].tolist(),
'orders': monthly_stats['orders'].tolist(),
'aov': monthly_stats['aov'].round(0).tolist(),
'mom': monthly_stats['mom'].round(2).tolist(),
'yoy': monthly_stats['yoy'].round(2).tolist(),
'margin_rate': monthly_stats['margin_rate'].round(1).tolist()
}
# 7. 計算 KPI (YTD - Year to Date)
current_year = df['dt'].max().year
last_year = current_year - 1
ytd_mask = df['dt'].dt.year == current_year
last_ytd_mask = (df['dt'].dt.year == last_year) & (df['dt'].dt.dayofyear <= df['dt'].max().dayofyear)
ytd_revenue = df.loc[ytd_mask, 'amount'].sum()
last_ytd_revenue = df.loc[last_ytd_mask, 'amount'].sum()
ytd_growth = 0
if last_ytd_revenue > 0:
ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100
# 近30天客單價
last_month_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30))
recent_revenue = df.loc[last_month_mask, 'amount'].sum()
recent_orders = df.loc[last_month_mask, '訂單編號'].nunique()
recent_aov = recent_revenue / recent_orders if recent_orders > 0 else 0
kpi = {
'ytd_revenue': ytd_revenue,
'ytd_growth': ytd_growth,
'current_year': current_year,
'recent_aov': recent_aov,
'total_orders': monthly_stats['orders'].sum()
}
# V-Fix: 將模板移至根目錄,與 sales_analysis.html 一致,解決 TemplateNotFound 問題
now_taipei = datetime.now(TAIPEI_TZ)
return render_template('growth_analysis.html', chart_data=chart_data, kpi=kpi, datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'))
except Exception as e:
sys_log.error(f"Growth Analysis Error: {e}")
return f"系統錯誤: {e}"
# ================= 📅 V-New: 當日業績看板 =================
@app.route('/daily_sales')
def daily_sales():
"""當日業績看板 (Day-over-Day 與 Week-over-Week 分析)"""
now_taipei = datetime.now(TAIPEI_TZ)
datetime_now_str = now_taipei.strftime('%Y-%m-%d %H:%M:%S')
try:
db = DatabaseManager()
engine = db.engine
table_name = 'daily_sales_snapshot'
# 1. 檢查資料表是否存在
inspector = inspect(engine)
if table_name not in inspector.get_table_names():
return render_template('daily_sales.html',
error="尚未匯入當日業績資料,請先至系統設定頁面匯入 Excel。",
selected_date=None, available_dates=[], current=None, dod=None, wow=None,
chart_data=None, categories=None, calendar_data=None, selected_month=None,
datetime_now=datetime_now_str)
# 2. 讀取資料(使用快取)
cache_key = f'{table_name}_daily'
if cache_key in _SALES_PROCESSED_CACHE:
df = _SALES_PROCESSED_CACHE[cache_key]['df']
else:
# V-Security: 使用安全的 SQL 讀取函式
df = safe_read_sql(table_name, engine=engine)
if df.empty:
return render_template('daily_sales.html',
error="資料表為空,請先匯入當日業績資料。",
selected_date=None, available_dates=[], current=None, dod=None, wow=None,
chart_data=None, categories=None, calendar_data=None, selected_month=None,
datetime_now=datetime_now_str)
# 3. 資料前處理(欄位識別、型別轉換)
df = preprocess_daily_sales_data(df)
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
# 4. 取得可用日期列表
available_dates = sorted(df['snapshot_date'].unique(), reverse=True)
available_dates_str = [d.strftime('%Y-%m-%d') if isinstance(d, pd.Timestamp) else str(d) for d in available_dates]
# 5. 取得選擇的日期(從 URL 參數或使用最新日期)
selected_date_param = request.args.get('date')
if selected_date_param:
selected_date = pd.to_datetime(selected_date_param)
else:
selected_date = df['snapshot_date'].max()
# 6. 取得選擇的月份(用於行事曆顯示)
selected_month_param = request.args.get('month')
if selected_month_param:
selected_month = pd.to_datetime(selected_month_param)
else:
selected_month = selected_date
# V-New 2026-01-15: 判斷是否為月概覽模式(沒有選擇特定日期)
is_month_view = not selected_date_param and not request.args.get('month')
# 如果只有 month 參數沒有 date 參數,也是月概覽模式
if selected_month_param and not selected_date_param:
is_month_view = True
# 7. 計算 KPI
current_kpi = calculate_daily_kpis(df, selected_date)
dod_kpi = calculate_dod(df, selected_date)
wow_kpi = calculate_wow(df, selected_date)
# V-New 2026-01-15: 計算月度總計 KPI
month_start = selected_month.replace(day=1)
month_end = (month_start + pd.DateOffset(months=1)) - pd.Timedelta(days=1)
month_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)]
# V-Fix 2026-01-15: 使用 find_col 動態獲取正確欄位名稱
cols = month_df.columns.tolist()
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
col_profit = find_col(cols, ['毛利', 'Profit'])
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
col_name = find_col(cols, ['商品名稱', '品名', 'Name'])
month_kpi = {
'total_revenue': float(month_df[col_amount].sum()) if col_amount else 0,
'total_cost': float(month_df[col_cost].sum()) if col_cost else 0,
'gross_margin': float(month_df[col_profit].sum()) if col_profit else 0,
'total_qty': float(month_df[col_qty].sum()) if col_qty else 0,
'sku_count': int(month_df[col_name].nunique()) if col_name else 0,
'days_with_data': int(month_df['snapshot_date'].nunique())
}
# 若無毛利欄位,用業績減成本計算
if not col_profit and col_amount and col_cost:
month_kpi['gross_margin'] = month_kpi['total_revenue'] - month_kpi['total_cost']
# 計算月度毛利率
if month_kpi['total_revenue'] > 0:
month_kpi['margin_rate'] = month_kpi['gross_margin'] / month_kpi['total_revenue'] * 100
else:
month_kpi['margin_rate'] = 0
# 計算月度客單價
if month_kpi['total_qty'] > 0:
month_kpi['avg_price'] = month_kpi['total_revenue'] / month_kpi['total_qty']
else:
month_kpi['avg_price'] = 0
# 8. 準備圖表數據(根據選擇的日期)
chart_data = prepare_daily_charts(df, selected_date, days=30)
# 9. 準備分類聚合列表
# V-Fix 2026-01-15: 根據檢視模式(單日/月度)決定聚合範圍
category_list = prepare_category_summary(
df,
date_str=selected_date,
is_month_view=is_month_view,
month_start=month_start if is_month_view else None,
month_end=month_end if is_month_view else None
)
# 10. 準備行事曆數據
calendar_data = prepare_calendar_data(df, selected_month)
# 11. V-New: 準備行銷活動業績數據
marketing_data = prepare_marketing_summary(
df,
selected_date=selected_date if not is_month_view else None,
is_month_view=is_month_view,
month_start=month_start if is_month_view else None,
month_end=month_end if is_month_view else None
)
# 12. 回傳模板
return render_template('daily_sales.html',
selected_date=selected_date.strftime('%Y-%m-%d') if isinstance(selected_date, pd.Timestamp) else selected_date,
available_dates=available_dates_str,
current=current_kpi,
dod=dod_kpi,
wow=wow_kpi,
month_kpi=month_kpi, # V-New: 月度總計
is_month_view=is_month_view, # V-New: 月概覽模式標誌
chart_data=chart_data,
categories=category_list,
calendar_data=calendar_data,
marketing_data=marketing_data, # V-New: 行銷活動數據
selected_month=selected_month.strftime('%Y-%m') if isinstance(selected_month, pd.Timestamp) else selected_month,
datetime_now=datetime_now_str)
except Exception as e:
sys_log.error(f"[Web] [DailySales] Error: {e}")
import traceback
traceback.print_exc()
return render_template('daily_sales.html',
error=f"系統錯誤: {str(e)}",
selected_date=None,
available_dates=[],
current=None,
dod=None,
wow=None,
month_kpi=None,
is_month_view=False,
chart_data=None,
categories=None,
calendar_data=None,
marketing_data=None,
selected_month=None,
datetime_now=datetime_now_str)
@app.route('/daily_sales/export')
def export_daily_sales_category():
"""匯出當日業績分類明細為 Excel"""
try:
from datetime import datetime
import io
from flask import send_file
db = DatabaseManager()
engine = db.engine
table_name = 'daily_sales_snapshot'
# 檢查資料表是否存在
inspector = inspect(engine)
if table_name not in inspector.get_table_names():
return "資料表不存在", 404
# 讀取資料
cache_key = f'{table_name}_daily'
if cache_key in _SALES_PROCESSED_CACHE:
df = _SALES_PROCESSED_CACHE[cache_key]['df']
else:
# V-Security: 使用安全的 SQL 讀取函式
df = safe_read_sql(table_name, engine=engine)
df = preprocess_daily_sales_data(df)
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
# 取得選擇的日期
selected_date = request.args.get('date')
if not selected_date:
available_dates = sorted(df['snapshot_date'].unique(), reverse=True)
if available_dates:
selected_date = str(available_dates[0])
else:
return "無可用日期", 404
# 準備分類資料
categories = prepare_category_summary(df, selected_date)
if not categories:
return "無資料可匯出", 404
# 轉為 DataFrame
export_df = pd.DataFrame(categories)
# 重新排列欄位順序並重新命名為中文
column_mapping = {
'category': '分類',
'vendor': '廠商',
'revenue': '總業績',
'cost': '總成本',
'profit': '毛利',
'margin_rate': '毛利率(%)',
'qty': '總銷量',
'sku_count': 'SKU數',
'avg_price': '平均單價'
}
# 只保留存在的欄位
export_columns = [col for col in column_mapping.keys() if col in export_df.columns]
export_df = export_df[export_columns]
export_df = export_df.rename(columns=column_mapping)
# 格式化數值欄位
for col in export_df.columns:
if col in ['總業績', '總成本', '毛利', '總銷量', 'SKU數', '平均單價']:
export_df[col] = export_df[col].apply(lambda x: f"{x:,.0f}" if pd.notna(x) else "0")
elif col == '毛利率(%)':
export_df[col] = export_df[col].apply(lambda x: f"{x:.1f}" if pd.notna(x) else "0.0")
# 產生檔案名稱
filename = f"當日業績_分類明細_{selected_date}.xlsx"
# 寫入 Excel
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
export_df.to_excel(writer, index=False, sheet_name='分類業績明細')
# 調整欄寬
worksheet = writer.sheets['分類業績明細']
for idx, col in enumerate(export_df.columns, 1):
max_length = max(
export_df[col].astype(str).apply(len).max(),
len(col)
) + 2
worksheet.column_dimensions[chr(64 + idx)].width = min(max_length, 50)
output.seek(0)
sys_log.info(f"[Web] [DailySales] Excel 匯出成功: {filename}")
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
sys_log.error(f"[Web] [DailySales] Excel 匯出失敗: {e}")
import traceback
traceback.print_exc()
return f"匯出失敗: {str(e)}", 500
# V-New 2026-01-15: 行銷活動業績匯出 API
@app.route('/daily_sales/export_marketing')
def export_marketing_summary_excel():
"""匯出行銷活動業績明細為 Excel"""
try:
import io
from flask import send_file
db = DatabaseManager()
engine = db.engine
table_name = 'daily_sales_snapshot'
# 讀取資料
cache_key = f'{table_name}_daily'
if cache_key in _SALES_PROCESSED_CACHE:
df = _SALES_PROCESSED_CACHE[cache_key]['df']
else:
# V-Security: 使用安全的 SQL 讀取函式
df = safe_read_sql(table_name, engine=engine)
df = preprocess_daily_sales_data(df)
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
# 取得參數
activity_type = request.args.get('type', 'all') # coupon, discount, bonus, click, all
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
selected_date = request.args.get('date')
# 額外篩選參數 (與 sales_analysis 同步)
selected_category = request.args.get('category', 'all')
selected_brand = request.args.get('brand', 'all')
selected_vendor = request.args.get('vendor', 'all')
keyword = request.args.get('keyword', '')
# 決定日期範圍
if start_date and end_date:
df = df[(df['snapshot_date'] >= pd.to_datetime(start_date)) &
(df['snapshot_date'] <= pd.to_datetime(end_date))]
date_label = f"{start_date}_{end_date}"
elif selected_date:
df = df[df['snapshot_date'] == pd.to_datetime(selected_date)]
date_label = selected_date
else:
date_label = "全部"
# 應用額外篩選
cols = df.columns.tolist()
col_category = find_col(cols, ['館別', '商品館', '分類', 'Category'])
col_brand = find_col(cols, ['品牌', 'Brand'])
col_vendor = find_col(cols, ['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier'])
col_name = find_col(cols, ['商品名稱', '品名'])
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
if selected_category != 'all' and col_category:
df = df[df[col_category] == selected_category]
if selected_brand != 'all' and col_brand:
df = df[df[col_brand] == selected_brand]
if selected_vendor != 'all' and col_vendor:
df = df[df[col_vendor] == selected_vendor]
if keyword and col_name:
df = df[df[col_name].str.contains(keyword, case=False, na=False)]
# 定義行銷活動欄位
marketing_cols = {
'coupon': ('折價券活動名稱', '折價券活動'),
'discount': ('折扣活動名稱', '折扣活動'),
'bonus': ('滿額再折扣活動名稱', '滿額再折扣'),
'click': ('點我再折扣', '點我再折扣')
}
# 準備 Excel 輸出
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
# 如果是 all循環所有類型
types_to_export = [activity_type] if activity_type != 'all' else ['coupon', 'discount', 'bonus', 'click']
summary_rows = []
for t in types_to_export:
if t not in marketing_cols: continue
col_internal, sheet_label = marketing_cols[t]
if col_internal not in df.columns:
continue
# 聚合數據
# V-Fix: 排除空值和 0
m_df = df[df[col_internal].notna() & (df[col_internal] != '') & (df[col_internal] != '0') & (df[col_internal] != 0)]
if m_df.empty:
continue
grouped = m_df.groupby(col_internal).agg({
col_amount: 'sum',
col_qty: 'sum',
col_name: 'count' # 訂單筆數/商品筆數
}).reset_index()
# 重命名
grouped.columns = ['活動名稱', '總業績', '總銷量', '項目筆數']
grouped = grouped.sort_values(by='總業績', ascending=False)
# 寫入工作表
grouped.to_excel(writer, sheet_name=sheet_label[:31], index=False)
# 加入到總表數據
grouped['活動類型'] = sheet_label
summary_rows.append(grouped)
# 建立總表工作表 (如果有多個類型)
if len(summary_rows) > 1:
all_m_df = pd.concat(summary_rows).sort_values(by='總業績', ascending=False)
all_m_df = all_m_df[['活動類型', '活動名稱', '總業績', '總銷量', '項目筆數']]
all_m_df.to_excel(writer, sheet_name='合併總表', index=False)
output.seek(0)
output.seek(0)
filename = f"行銷活動分析_{date_label}.xlsx"
# 處理中文檔名編碼
from urllib.parse import quote
encoded_filename = quote(filename)
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename,
conditional=True
)
except Exception as e:
sys_log.error(f"[Web] [Marketing] Excel 匯出失敗: {e}")
import traceback
traceback.print_exc()
return f"匯出失敗: {str(e)}", 500
def preprocess_daily_sales_data(df):
"""前處理當日業績資料:欄位識別、型別轉換"""
cols = df.columns.tolist()
# 欄位自動識別(使用現有的 find_col 函式)
col_amount = find_col(cols, ['銷售金額', '業績', '金額', 'Amount', '總業績'])
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
col_profit = find_col(cols, ['毛利', 'Profit'])
col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量'])
# 型別轉換
if col_amount:
df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0)
if col_cost:
df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0)
if col_profit:
df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0)
if col_qty:
df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0)
# 日期轉換
df['snapshot_date'] = pd.to_datetime(df['snapshot_date'], errors='coerce')
return df
def calculate_daily_kpis(df, date_str):
"""計算單日 6 個 KPI"""
day_df = df[df['snapshot_date'] == date_str]
cols = day_df.columns.tolist()
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
col_profit = find_col(cols, ['毛利', 'Profit'])
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
col_name = find_col(cols, ['商品名稱', '品名', 'Name'])
total_revenue = float(day_df[col_amount].sum()) if col_amount else 0
total_cost = float(day_df[col_cost].sum()) if col_cost else 0
gross_margin = float(day_df[col_profit].sum()) if col_profit else (total_revenue - total_cost)
total_qty = float(day_df[col_qty].sum()) if col_qty else 0
sku_count = int(day_df[col_name].nunique()) if col_name else 0
avg_price = total_revenue / total_qty if total_qty > 0 else 0
return {
'total_revenue': total_revenue,
'total_cost': total_cost,
'gross_margin': gross_margin,
'total_qty': total_qty,
'sku_count': sku_count,
'avg_price': avg_price
}
def calculate_dod(df, current_date):
"""計算 Day-over-Day 變化率"""
current = calculate_daily_kpis(df, current_date)
prev_date = current_date - timedelta(days=1)
if prev_date not in df['snapshot_date'].values:
return {k: 0.0 for k in current.keys()}
previous = calculate_daily_kpis(df, prev_date)
dod = {}
for key in current:
if previous[key] > 0:
dod[key] = ((current[key] - previous[key]) / previous[key]) * 100
else:
dod[key] = 0.0
return dod
def calculate_wow(df, current_date):
"""計算 Week-over-Week 變化率"""
current = calculate_daily_kpis(df, current_date)
prev_week_date = current_date - timedelta(days=7)
if prev_week_date not in df['snapshot_date'].values:
return {k: 0.0 for k in current.keys()}
previous = calculate_daily_kpis(df, prev_week_date)
wow = {}
for key in current:
if previous[key] > 0:
wow[key] = ((current[key] - previous[key]) / previous[key]) * 100
else:
wow[key] = 0.0
return wow
def prepare_daily_charts(df, selected_date, days=30):
"""準備 4 個圖表的數據(根據選擇的日期)"""
# 取選擇日期前 N 天的數據
start_date = selected_date - timedelta(days=days)
df_range = df[(df['snapshot_date'] >= start_date) & (df['snapshot_date'] <= selected_date)]
# 按日期聚合
cols = df_range.columns.tolist()
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
col_cost = find_col(cols, ['成本', '總成本'])
col_profit = find_col(cols, ['毛利'])
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
col_name = find_col(cols, ['商品名稱', '品名'])
# 日期聚合
agg_dict = {}
if col_amount:
agg_dict[col_amount] = 'sum'
if col_cost:
agg_dict[col_cost] = 'sum'
if col_profit:
agg_dict[col_profit] = 'sum'
if col_qty:
agg_dict[col_qty] = 'sum'
daily_agg = df_range.groupby('snapshot_date').agg(agg_dict).reset_index()
# 計算或取得毛利(如果沒有毛利欄位,用業績-成本計算)
if col_profit and col_profit in daily_agg.columns:
daily_agg['profit'] = daily_agg[col_profit]
elif col_amount and col_cost and col_amount in daily_agg.columns and col_cost in daily_agg.columns:
daily_agg['profit'] = daily_agg[col_amount] - daily_agg[col_cost]
else:
daily_agg['profit'] = 0
# 計算客單價
if col_amount and col_qty and col_amount in daily_agg.columns and col_qty in daily_agg.columns:
daily_agg['avg_price'] = (daily_agg[col_amount] / daily_agg[col_qty]).fillna(0)
else:
daily_agg['avg_price'] = 0
# 計算 DoD (Day-over-Day) 變化率 - 多個維度
if col_amount and col_amount in daily_agg.columns:
daily_agg['dod_revenue'] = daily_agg[col_amount].pct_change() * 100
if 'profit' in daily_agg.columns:
daily_agg['dod_profit'] = daily_agg['profit'].pct_change() * 100
if 'avg_price' in daily_agg.columns:
daily_agg['dod_avg_price'] = daily_agg['avg_price'].pct_change() * 100
if col_qty and col_qty in daily_agg.columns:
daily_agg['dod_qty'] = daily_agg[col_qty].pct_change() * 100
# 計算 WoW (Week-over-Week) 變化率 - 多個維度
if col_amount and col_amount in daily_agg.columns:
daily_agg['wow_revenue'] = daily_agg[col_amount].pct_change(periods=7) * 100
if 'profit' in daily_agg.columns:
daily_agg['wow_profit'] = daily_agg['profit'].pct_change(periods=7) * 100
if 'avg_price' in daily_agg.columns:
daily_agg['wow_avg_price'] = daily_agg['avg_price'].pct_change(periods=7) * 100
if col_qty and col_qty in daily_agg.columns:
daily_agg['wow_qty'] = daily_agg[col_qty].pct_change(periods=7) * 100
# Top 10 商品(選擇的日期,包含廠商)
selected_df = df[df['snapshot_date'] == selected_date]
top10_labels = []
top10_values = []
if col_name and col_amount:
col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier'])
if col_vendor:
# 如果有廠商欄位,按商品+廠商聚合
top10_df = selected_df.groupby([col_name, col_vendor])[col_amount].sum().nlargest(10).reset_index()
top10_labels = [f"{row[col_name]} ({row[col_vendor]})" for _, row in top10_df.iterrows()]
top10_values = top10_df[col_amount].tolist()
else:
# 沒有廠商欄位,只按商品聚合
top10 = selected_df.groupby(col_name)[col_amount].sum().nlargest(10)
top10_labels = top10.index.tolist()
top10_values = top10.values.tolist()
return {
'labels': daily_agg['snapshot_date'].dt.strftime('%m/%d').tolist() if not daily_agg.empty else [],
'revenue': daily_agg[col_amount].tolist() if col_amount and col_amount in daily_agg.columns and not daily_agg.empty else [],
'cost': daily_agg[col_cost].tolist() if col_cost and col_cost in daily_agg.columns and not daily_agg.empty else [],
'profit': daily_agg['profit'].tolist() if 'profit' in daily_agg.columns and not daily_agg.empty else [],
'qty': daily_agg[col_qty].tolist() if col_qty and col_qty in daily_agg.columns and not daily_agg.empty else [],
'avg_price': daily_agg['avg_price'].tolist() if 'avg_price' in daily_agg.columns and not daily_agg.empty else [],
# DoD 多維度
'dod_revenue': daily_agg['dod_revenue'].fillna(0).tolist() if 'dod_revenue' in daily_agg.columns and not daily_agg.empty else [],
'dod_profit': daily_agg['dod_profit'].fillna(0).tolist() if 'dod_profit' in daily_agg.columns and not daily_agg.empty else [],
'dod_avg_price': daily_agg['dod_avg_price'].fillna(0).tolist() if 'dod_avg_price' in daily_agg.columns and not daily_agg.empty else [],
'dod_qty': daily_agg['dod_qty'].fillna(0).tolist() if 'dod_qty' in daily_agg.columns and not daily_agg.empty else [],
# WoW 多維度
'wow_revenue': daily_agg['wow_revenue'].fillna(0).tolist() if 'wow_revenue' in daily_agg.columns and not daily_agg.empty else [],
'wow_profit': daily_agg['wow_profit'].fillna(0).tolist() if 'wow_profit' in daily_agg.columns and not daily_agg.empty else [],
'wow_avg_price': daily_agg['wow_avg_price'].fillna(0).tolist() if 'wow_avg_price' in daily_agg.columns and not daily_agg.empty else [],
'wow_qty': daily_agg['wow_qty'].fillna(0).tolist() if 'wow_qty' in daily_agg.columns and not daily_agg.empty else [],
'top10_labels': top10_labels,
'top10_values': top10_values
}
def prepare_category_summary(df, date_str=None, is_month_view=False, month_start=None, month_end=None):
"""準備分類聚合列表 (支援單日或月度範圍)"""
if is_month_view and month_start is not None and month_end is not None:
day_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)]
else:
day_df = df[df['snapshot_date'] == date_str]
cols = day_df.columns.tolist()
col_category = find_col(cols, ['館別', '分類', 'Category'])
col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier'])
col_amount = find_col(cols, ['銷售金額', '業績', '總業績'])
col_cost = find_col(cols, ['成本', '總成本'])
col_profit = find_col(cols, ['毛利'])
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
col_name = find_col(cols, ['商品名稱', '品名'])
if not col_category or not col_amount:
return []
# 分類 + 廠商聚合
agg_dict = {col_amount: 'sum'}
if col_cost:
agg_dict[col_cost] = 'sum'
if col_profit:
agg_dict[col_profit] = 'sum'
if col_qty:
agg_dict[col_qty] = 'sum'
if col_name:
agg_dict[col_name] = 'nunique'
# 如果有廠商欄位,按分類+廠商聚合;否則只按分類聚合
if col_vendor:
category_df = day_df.groupby([col_category, col_vendor]).agg(agg_dict).reset_index()
else:
category_df = day_df.groupby(col_category).agg(agg_dict).reset_index()
# 計算毛利(如果資料中沒有毛利欄位,自動計算)
if col_profit and col_profit in category_df.columns:
# 資料中有毛利欄位,直接使用
pass
elif col_amount and col_cost and col_amount in category_df.columns and col_cost in category_df.columns:
# 資料中沒有毛利欄位,用 業績 - 成本 計算
category_df['profit_calculated'] = category_df[col_amount] - category_df[col_cost]
col_profit = 'profit_calculated'
else:
col_profit = None
# 計算毛利率
if col_profit and col_profit in category_df.columns and col_amount and col_amount in category_df.columns:
category_df['margin_rate'] = (category_df[col_profit] / category_df[col_amount] * 100).fillna(0)
else:
category_df['margin_rate'] = 0
# 計算均價
if col_qty and col_amount:
category_df['avg_price'] = (category_df[col_amount] / category_df[col_qty]).fillna(0)
else:
category_df['avg_price'] = 0
# 重新命名欄位以便模板使用
rename_dict = {col_category: 'category', col_amount: 'revenue'}
if col_vendor:
rename_dict[col_vendor] = 'vendor'
if col_cost:
rename_dict[col_cost] = 'cost'
if col_profit and col_profit in category_df.columns:
rename_dict[col_profit] = 'profit'
if col_qty:
rename_dict[col_qty] = 'qty'
if col_name:
rename_dict[col_name] = 'sku_count'
category_df = category_df.rename(columns=rename_dict)
# 確保 profit 欄位存在,如果不存在則設為 0
if 'profit' not in category_df.columns:
category_df['profit'] = 0
# 轉為字典列表
return category_df.to_dict('records')
# V-New 2026-01-15: 行銷活動業績聚合函數
def prepare_marketing_summary(df, selected_date=None, is_month_view=False, month_start=None, month_end=None, sort_by='revenue'):
"""
準備行銷活動業績貢獻數據
支援單日模式和月度模式,並可指定排序維度 (revenue, qty, profit)
"""
# 決定使用的數據範圍
if is_month_view and month_start is not None and month_end is not None:
target_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)]
elif selected_date is not None:
target_df = df[df['snapshot_date'] == selected_date]
else:
target_df = df
if target_df.empty:
return {'coupon': [], 'discount': [], 'bonus': [], 'click': []}
cols = target_df.columns.tolist()
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
col_qty = find_col(cols, ['銷售數量', '銷量', '數量', 'Qty'])
col_profit = find_col(cols, ['毛利', 'Profit', '利潤'])
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
if not col_amount:
return {'coupon': [], 'discount': [], 'bonus': [], 'click': []}
# 定義四種行銷活動欄位
marketing_cols = {
'coupon': '折價券活動名稱', # 折價券活動
'discount': '折扣活動名稱', # 折扣活動
'bonus': '滿額再折扣活動名稱', # 滿額再折扣
'click': '點我再折扣' # 點我再折扣
}
result = {}
# 確保 sort_by 欄位存在,否則退回 revenue
actual_sort_key = sort_by if sort_by in ['revenue', 'qty', 'profit'] else 'revenue'
for key, col_name in marketing_cols.items():
if col_name not in cols:
result[key] = []
continue
# 篩選有該行銷活動的記錄
activity_df = target_df[
(target_df[col_name].notna()) &
(target_df[col_name] != '') &
(target_df[col_name] != '0') &
(target_df[col_name] != 0)
]
if activity_df.empty:
result[key] = []
continue
# 聚合計算
agg_args = {
'revenue': (col_amount, 'sum'),
'order_count': (col_amount, 'count')
}
if col_qty: agg_args['qty'] = (col_qty, 'sum')
if col_profit: agg_args['profit'] = (col_profit, 'sum')
grouped = activity_df.groupby(col_name).agg(**agg_args).reset_index()
# 若需要手動計算毛利 (金額 - 成本)
if 'profit' not in agg_args and col_cost:
cost_agg = activity_df.groupby(col_name)[col_cost].sum().reset_index()
grouped = grouped.merge(cost_agg, on=col_name)
grouped['profit'] = grouped['revenue'] - grouped[col_cost]
grouped = grouped.rename(columns={col_name: 'name'})
# 動態排序
sort_col = actual_sort_key if actual_sort_key in grouped.columns else 'revenue'
grouped = grouped.sort_values(sort_col, ascending=False).head(15)
# 轉為字典列表
records = []
for _, row in grouped.iterrows():
record = {
'name': str(row['name'])[:50],
'revenue': float(row['revenue']),
'order_count': int(row['order_count'])
}
if 'qty' in row: record['qty'] = float(row['qty'])
if 'profit' in row: record['profit'] = float(row['profit'])
records.append(record)
result[key] = records
return result
def get_taiwan_holiday(date):
"""判斷是否為台灣國定假日,回傳 (is_holiday, holiday_name)"""
year = date.year
month = date.month
day = date.day
# 2026年台灣國定假日根據人事行政總處公佈
holidays_2026 = {
(1, 1): '元旦',
# 春節連假 (2/14-2/22共9天)
(2, 14): '春節連假',
(2, 15): '小年夜',
(2, 16): '除夕',
(2, 17): '春節 (初一)',
(2, 18): '春節 (初二)',
(2, 19): '春節 (初三)',
(2, 20): '春節連假',
(2, 21): '春節連假',
(2, 22): '春節連假',
# 和平紀念日 (2/28-3/2共3天)
(2, 28): '和平紀念日',
(3, 2): '和平紀念日補假',
# 兒童節+清明節 (4/3-4/6共4天)
(4, 3): '兒童節補假',
(4, 4): '清明節',
(4, 5): '清明節連假',
(4, 6): '清明節補假',
# 勞動節 (5/1-5/3共3天)
(5, 1): '勞動節',
# 端午節 (6/19-6/21共3天)
(6, 19): '端午節',
# 中秋節+教師節 (9/25-9/28共4天)
(9, 25): '中秋節',
(9, 28): '教師節',
# 國慶日 (10/9-10/11共3天)
(10, 9): '國慶日補假',
(10, 10): '國慶日',
# 光復節 (10/25-10/26共2天)
(10, 25): '臺灣光復節',
(10, 26): '光復節補假',
# 行憲紀念日 (12/25-12/27共3天)
(12, 25): '行憲紀念日',
}
# 2027年台灣國定假日預先計算部分
holidays_2027 = {
(1, 1): '元旦',
(2, 11): '春節 (除夕)',
(2, 12): '春節 (初一)',
(2, 13): '春節 (初二)',
(2, 14): '春節 (初三)',
(2, 15): '春節 (初四)',
(2, 16): '春節 (初五)',
(2, 17): '春節 (初六)',
(2, 28): '和平紀念日',
(4, 4): '清明節',
(4, 5): '清明節連假',
(6, 14): '端午節',
(9, 21): '中秋節',
(10, 10): '國慶日',
(10, 11): '國慶日連假',
}
holidays = holidays_2026 if year == 2026 else (holidays_2027 if year == 2027 else {})
holiday_name = holidays.get((month, day))
return (True, holiday_name) if holiday_name else (False, None)
def prepare_calendar_data(df, selected_month):
"""準備行事曆數據豐富版顯示總業績、毛利、SKU數 + DoD%"""
import calendar
# 取得該月份的年月
year = selected_month.year
month = selected_month.month
# 計算該月第一天和最後一天
first_day = pd.Timestamp(year=year, month=month, day=1)
last_day = pd.Timestamp(year=year, month=month, day=calendar.monthrange(year, month)[1])
# 計算行事曆顯示範圍(包含前後月份的日期以填滿週)
# 取得該月第一天是星期幾 (0=Monday, 6=Sunday)
first_weekday = first_day.weekday()
# 計算行事曆起始日(從週一開始)
calendar_start = first_day - timedelta(days=first_weekday)
# 計算該月最後一天是星期幾
last_weekday = last_day.weekday()
# 計算行事曆結束日(到週日結束)
calendar_end = last_day + timedelta(days=(6 - last_weekday))
# 取得該月份及前後各一天的所有資料(用於計算 DoD
data_start = first_day - timedelta(days=1)
data_end = last_day
month_df = df[(df['snapshot_date'] >= data_start) & (df['snapshot_date'] <= data_end)]
# 取得欄位
cols = df.columns.tolist()
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
col_cost = find_col(cols, ['成本', 'Cost'])
col_profit = find_col(cols, ['毛利', 'Profit'])
col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量'])
col_name = find_col(cols, ['商品名稱', '品名'])
# 為每一天計算 KPI
calendar_days = []
current_date = calendar_start
while current_date <= calendar_end:
# 取得星期0=週一, 6=週日)
weekday = current_date.weekday()
weekday_names = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
# 判斷是否為國定假日
is_holiday, holiday_name = get_taiwan_holiday(current_date)
day_data = {
'date': current_date.strftime('%Y-%m-%d'),
'day': current_date.day,
'weekday': weekday_names[weekday],
'is_weekend': weekday >= 5, # 週六或週日
'is_holiday': is_holiday,
'holiday_name': holiday_name,
'is_current_month': current_date.month == month,
'has_data': False,
'revenue': 0,
'profit': 0,
'margin_rate': 0,
'sku_count': 0,
'qty': 0,
'avg_price': 0,
'dod_percent': 0,
'dod_direction': 'neutral' # 'up', 'down', 'neutral'
}
# 如果該日期在當前月份範圍內,計算 KPI
if first_day <= current_date <= last_day:
day_df = month_df[month_df['snapshot_date'] == current_date]
if not day_df.empty:
day_data['has_data'] = True
# 計算總業績
if col_amount:
day_data['revenue'] = float(day_df[col_amount].sum())
# 計算毛利(優先使用毛利欄位,否則用業績-成本計算)
if col_profit:
day_data['profit'] = float(day_df[col_profit].sum())
elif col_cost and col_amount:
total_cost = float(day_df[col_cost].sum())
day_data['profit'] = day_data['revenue'] - total_cost
# 計算毛利率
if day_data['revenue'] > 0:
day_data['margin_rate'] = (day_data['profit'] / day_data['revenue']) * 100
# 計算銷量
if col_qty:
day_data['qty'] = float(day_df[col_qty].sum())
# 計算客單價(總業績 / 總銷量)
if day_data['qty'] > 0:
day_data['avg_price'] = day_data['revenue'] / day_data['qty']
# 計算 SKU 數
if col_name:
day_data['sku_count'] = int(day_df[col_name].nunique())
# 計算 DoD%
prev_date = current_date - timedelta(days=1)
prev_df = month_df[month_df['snapshot_date'] == prev_date]
if not prev_df.empty and col_amount:
prev_revenue = float(prev_df[col_amount].sum())
if prev_revenue > 0:
dod = ((day_data['revenue'] - prev_revenue) / prev_revenue) * 100
day_data['dod_percent'] = round(dod, 1)
day_data['dod_direction'] = 'up' if dod >= 0 else 'down'
calendar_days.append(day_data)
current_date += timedelta(days=1)
# 組織成週結構(每週 7 天)
weeks = []
for i in range(0, len(calendar_days), 7):
weeks.append(calendar_days[i:i+7])
# 計算上個月和下個月的年月
prev_month = selected_month - pd.DateOffset(months=1)
next_month = selected_month + pd.DateOffset(months=1)
return {
'year': year,
'month': month,
'month_name': selected_month.strftime('%Y年%m月'),
'weeks': weeks,
'prev_month': prev_month.strftime('%Y-%m'),
'next_month': next_month.strftime('%Y-%m')
}
# ================= ⚙️ 5. 服務啟動邏輯 =================
def run_schedule():
"""在背景執行緒中運行排程"""
sys_log.info("🚀 排程服務已啟動,等待任務...")
while True:
schedule.run_pending()
time.sleep(1)
def init_scheduler():
"""初始化排程任務Gunicorn 模式下也會執行)"""
schedule.every(1).hours.do(run_momo_task)
schedule.every(1).hours.do(run_edm_task)
schedule.every(1).hours.do(run_festival_task)
sys_log.info(f"📅 已設定每小時執行主站、EDM與購物節爬蟲任務")
schedule.every(30).minutes.do(run_auto_import_task)
sys_log.info(f"📅 已設定每 30 分鐘執行 Google Drive 自動匯入任務")
schedule.every(30).minutes.do(run_whitepage_check)
sys_log.info(f"📅 已設定每 30 分鐘執行網頁白頁監控任務")
# 啟動排程執行緒
scheduler_thread = threading.Thread(target=run_schedule, daemon=True)
scheduler_thread.start()
sys_log.info("✅ 排程器已在背景執行緒中啟動")
# V-New: 在模組載入時自動初始化排程Gunicorn 模式下也會執行)
# 🚩 V-Fix 2026-01-14: 停用自動排程器以避免多個 gunicorn workers 重複執行任務
# 原因:每個 worker 都會啟動排程器,導致 4x 資源消耗4 workers × 3 爬蟲任務 = 12 Chrome 實例同時運行)
# 解決方案:改用獨立的 run_scheduler.py 或透過 Web UI 手動觸發任務
# try:
# init_scheduler()
# except Exception as e:
# sys_log.error(f"❌ 排程器初始化失敗: {e}")
sys_log.info(" 自動排程器已停用(避免重複執行),請使用 run_scheduler.py 或 Web UI 手動觸發")
def start_flask():
sys_log.info("🚀 Web 服務正在啟動於 port 80...")
app.run(host='0.0.0.0', port=80, use_reloader=False)
def scheduled_job_wrapper():
"""執行 MOMO 爬蟲任務並發送通知"""
timestamp = datetime.now(TAIPEI_TZ).strftime('%H:%M:%S')
sys_log.info(f"⏰ [{timestamp}] 啟動背景抓取執行緒...")
def job():
# 1. 執行爬蟲
run_momo_task()
# 2. 發送通知 (僅發送今日異動)
try:
# 重新載入通知模組
import importlib
import scheduler
import services.notification_manager
importlib.reload(scheduler)
importlib.reload(services.notification_manager)
from services.notification_manager import NotificationManager
stats = get_dashboard_stats()
# 只要有任何異動數據就發送通知
if any(stats.values()):
screenshot_path = scheduler.capture_page_screenshot("http://127.0.0.1/", "momo_dashboard")
NotificationManager().send_momo_report(stats, screenshot_path)
except Exception as e:
sys_log.error(f"[Scheduler] ❌ 發送通知失敗: {e}")
threading.Thread(target=job, daemon=True).start()
# ==========================================
# 🔧 路由清理:移除被模組化路由覆蓋的重複端點
# ==========================================
def cleanup_duplicate_routes():
"""
當模組化路由啟用時,移除 app.py 中定義的重複路由
這確保 Blueprint 中的路由優先被使用
"""
from routes import MODULAR_ENDPOINTS
if not MODULAR_ENDPOINTS:
return # 沒有啟用任何模組化路由
# 找出需要移除的端點app.py 中定義的,沒有藍圖前綴)
endpoints_to_remove = set()
rules_to_remove = []
for rule in list(app.url_map.iter_rules()):
# 只移除 app.py 中定義的路由(沒有藍圖前綴)
if '.' not in rule.endpoint and rule.endpoint in MODULAR_ENDPOINTS:
endpoints_to_remove.add(rule.endpoint)
rules_to_remove.append(rule)
if not endpoints_to_remove:
return
# 從 view_functions 中移除重複的端點
for endpoint in endpoints_to_remove:
if endpoint in app.view_functions:
del app.view_functions[endpoint]
# 從 url_map._rules 中移除對應的規則
# 注意:這是直接操作內部結構,需要同時更新索引
for rule in rules_to_remove:
try:
app.url_map._rules.remove(rule)
# 同時從 _rules_by_endpoint 中移除
if rule.endpoint in app.url_map._rules_by_endpoint:
endpoint_rules = app.url_map._rules_by_endpoint[rule.endpoint]
if rule in endpoint_rules:
endpoint_rules.remove(rule)
if not endpoint_rules:
del app.url_map._rules_by_endpoint[rule.endpoint]
except (ValueError, KeyError):
pass # 規則可能已被移除
sys_log.info(f"[Routes] 🧹 已清理 {len(endpoints_to_remove)} 條重複路由 (模組化路由優先)")
# 執行路由清理
cleanup_duplicate_routes()
# ==========================================
# 🔧 端點別名:確保模組化路由啟用後 url_for 仍可使用舊名稱
# ==========================================
def register_endpoint_aliases():
"""
當模組化路由啟用時,為藍圖端點建立別名
這樣模板中的 url_for('index') 仍可正常運作
"""
from routes import MODULAR_ENDPOINTS
from werkzeug.routing import Rule
if not MODULAR_ENDPOINTS:
return
# 端點別名對應表:舊名稱 -> (藍圖端點, URL路徑, 方法)
aliases = {
# dashboard_routes
'index': ('dashboard.index', '/', ['GET']),
'brand_assets': ('dashboard.brand_assets', '/brand_assets', ['GET']),
# system_routes
'health_check': ('system.health_check', '/health', ['GET']),
'prometheus_metrics': ('system.prometheus_metrics', '/metrics', ['GET']),
'settings': ('system.settings', '/settings', ['GET']),
'system_settings_page': ('system.system_settings_page', '/system_settings', ['GET']),
'show_logs': ('system.show_logs', '/logs', ['GET']),
'get_logs_api': ('system.get_logs_api', '/api/logs', ['GET']),
'add_category': ('system.add_category', '/api/categories', ['POST']),
'update_category': ('system.update_category', '/api/categories/<int:category_id>', ['PUT']),
'delete_category': ('system.delete_category', '/api/categories/<int:category_id>', ['DELETE']),
'test_url': ('system.test_url', '/api/test_url', ['POST']),
'trigger_backup': ('system.trigger_backup', '/api/backup', ['POST']),
'download_backup': ('system.download_backup', '/api/backup/download/<path:filename>', ['GET']),
# edm_routes
'edm_dashboard': ('edm.edm_dashboard', '/edm', ['GET']),
'festival_dashboard': ('edm.festival_dashboard', '/festival', ['GET']),
# monthly_routes
'monthly_summary_analysis_page': ('monthly.monthly_summary_analysis_page', '/monthly_summary_analysis', ['GET']),
'get_monthly_summary_data': ('monthly.get_monthly_summary_data', '/api/monthly_summary_data', ['GET']),
# daily_sales_routes
'daily_sales': ('daily_sales.daily_sales', '/daily_sales', ['GET']),
'export_daily_sales_category': ('daily_sales.export_daily_sales_category', '/daily_sales/export', ['GET']),
'export_marketing_summary_excel': ('daily_sales.export_marketing_summary_excel', '/daily_sales/export_marketing', ['GET']),
}
registered_count = 0
for old_name, (new_name, url_path, methods) in aliases.items():
# 只有當舊端點不存在且新端點存在時才建立別名
if old_name not in app.view_functions and new_name in app.view_functions:
# 1. 複製 view function
app.view_functions[old_name] = app.view_functions[new_name]
# 2. 建立 URL 規則(讓 url_for 可以找到)
rule = Rule(url_path, endpoint=old_name, methods=methods)
app.url_map.add(rule)
registered_count += 1
if registered_count > 0:
sys_log.info(f"[Routes] 🔗 已建立 {registered_count} 個端點別名 (相容舊版 url_for)")
# 執行端點別名註冊
register_endpoint_aliases()
if __name__ == "__main__":
banner = f" MOMO 專業數據管理系統 {SYSTEM_VERSION} "
sys_log.info(f"{ '='*20} {banner} {'='*20}")
# 啟動前先檢查資料庫結構
repair_database_schema()
# 使用生產環境域名
public_url = "https://momo.wooo.work"
sys_log.info(f"✅ 使用固定網址: {public_url}")
# 🚩 V9.7 將公開 URL 寫入設定檔,供其他模組使用
try:
url_config_path = os.path.join(BASE_DIR, 'data', 'url_config.json')
with open(url_config_path, 'w') as f:
json.dump({"public_url": public_url}, f)
except Exception as file_err:
sys_log.error(f"⚠️ URL 設定檔寫入失敗 (不影響服務運行,可能磁碟已滿): {file_err}")
web_server = threading.Thread(target=start_flask)
web_server.daemon = True
web_server.start()
# 排程器已在模組載入時自動初始化(見 init_scheduler() 函式)
sys_log.info(" 排程器已在全域範圍初始化完成")
try:
while True:
time.sleep(3600)
except KeyboardInterrupt:
sys_log.info("🔌 Web 服務已關閉")
try:
ngrok.disconnect(public_url)
except Exception as e:
sys_log.info(f" Ngrok 關閉時無需額外操作: {e}")