# ================= TODO LIST (待辦事項 - 重開機後請依序執行) =================
# 1. [驗證] 重啟 app.py 後,重新匯入 Excel,確認「自動去重」功能是否生效 (重複匯入應顯示 0 筆新增)。
# 2. [檢查] 前往 /sales_analysis 頁面,確認 '狀態' 欄位是否正確顯示 'F' (目前為原始匯入模式)。
# 3. [決策] 若資料顯示正常,評估是否需要恢復「智慧資料清理」邏輯 (目前程式碼第 1160 行左右已註解)。
# 4. [備份] 確認系統運作正常後,執行系統備份。
# =======================================================================
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 匯出
import traceback # V-Fix: 用於錯誤追蹤
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 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, run_competitor_price_feeder_task
from database.manager import DatabaseManager
from database.models import Base, 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, DATABASE_TYPE, validate_critical_config
sys_log = SystemLogger("Web_Server").get_logger()
# 驗證選用配置,缺少時輸出 warning(非 fatal)
for _warn in validate_critical_config():
sys_log.warning(_warn)
# 🚩 V-Opt: 全域資料快取 (用於加速業績分析)
_SALES_DF_CACHE = {} # 已棄用,保留相容性
_SALES_PROCESSED_CACHE = {} # V-Opt: 處理後資料快取
_SALES_CACHE_MAX_ENTRIES = 10 # V-Opt (2026-01-23): 快取最大條目數
_SALES_CACHE_TTL = 600 # V-Opt (2026-01-23): 快取有效期 10 分鐘
def _cleanup_sales_cache():
"""清理過期和過多的快取條目"""
global _SALES_PROCESSED_CACHE
current_time = time.time()
# 1. 清理過期條目
expired_keys = [
k for k, v in _SALES_PROCESSED_CACHE.items()
if v.get('time') and current_time - v['time'] > _SALES_CACHE_TTL
]
for k in expired_keys:
del _SALES_PROCESSED_CACHE[k]
# 2. 如果仍超過限制,刪除最舊的條目
if len(_SALES_PROCESSED_CACHE) > _SALES_CACHE_MAX_ENTRIES:
sorted_items = sorted(
[(k, v.get('time', 0)) for k, v in _SALES_PROCESSED_CACHE.items()],
key=lambda x: x[1]
)
# 保留最新的 _SALES_CACHE_MAX_ENTRIES 條
keys_to_delete = [k for k, _ in sorted_items[:-_SALES_CACHE_MAX_ENTRIES]]
for k in keys_to_delete:
del _SALES_PROCESSED_CACHE[k]
if expired_keys or len(_SALES_PROCESSED_CACHE) > _SALES_CACHE_MAX_ENTRIES - 2:
sys_log.debug(f"[Cache] 清理快取: 移除 {len(expired_keys)} 條過期, 剩餘 {len(_SALES_PROCESSED_CACHE)} 條")
# 🚩 V-New: 商品看板資料快取 (用於加速首頁載入)
_DASHBOARD_DATA_CACHE = {
'consolidated_data': None, # get_consolidated_data() 結果
'consolidated_timestamp': None, # 快取時間戳記
'stats_data': None, # 統計資料
'stats_timestamp': None # 統計資料時間戳記
}
_DASHBOARD_CACHE_TTL = 300 # 快取有效期 5 分鐘(秒)
# 🚩 檢查磁碟空間 (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}")
# 🚩 系統版本定義 (備份與顯示用)
# 🚩 2026-04-19 V10.3: 技術債清零 — Migration 010/011、retry queue 持久化、
# NemoTron store_insight 雙寫、import 前置欄位防禦、時間衰減 RAG
SYSTEM_VERSION = "V10.3"
# ==========================================
# 🔒 SQL Injection 防護函數
# ==========================================
# 允許的資料表白名單
# 安全工具:實作已搬至 utils/security.py,此處 re-export 維持向後相容
from utils.security import ( # noqa: E402
ALLOWED_TABLES,
validate_table_name,
validate_column_names,
)
# 安全工具:路徑遍歷 + 檔案上傳驗證 + safe_read_sql 已搬至 utils/security.py
from utils.security import ( # noqa: E402
safe_read_sql,
safe_join,
ALLOWED_UPLOAD_EXTENSIONS,
ALLOWED_MIME_TYPES,
secure_filename_unicode,
allowed_file,
validate_upload_file,
)
# 🚩 資料庫結構自動修復 (V9.53 新增) — 實作搬至 database/schema_repair.py
from database.schema_repair import repair_database_schema # noqa: E402, F401
# 從環境變數讀取 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') # 新模板路徑(模組化)
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}")
app = Flask(__name__,
template_folder=TEMPLATE_DIR,
static_folder=STATIC_DIR)
# 設定多路徑模板載入器(同時支援根目錄和 templates/ 目錄)
from jinja2 import FileSystemLoader, ChoiceLoader
app.jinja_loader = ChoiceLoader([
FileSystemLoader(TEMPLATE_DIR_NEW), # templates/ 目錄優先
FileSystemLoader(TEMPLATE_DIR), # 根目錄備用
])
# ==========================================
# 🔒 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 註冊 - 廠商缺貨系統
# ==========================================
from routes.vendor_routes import vendor_bp
app.register_blueprint(vendor_bp)
sys_log.info("[Blueprint] ✅ 廠商缺貨系統 Blueprint 已註冊")
# ==========================================
# 🔧 Blueprint 註冊 - Google Drive 自動匯入
# ==========================================
from routes.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 routes.crawler_management_routes import crawler_bp
app.register_blueprint(crawler_bp)
sys_log.info("[Blueprint] ✅ 爬蟲管理系統 Blueprint 已註冊")
# ==========================================
# 🔧 Blueprint 註冊 - AI 智慧文案系統
# ==========================================
from routes.ai_routes import ai_bp
app.register_blueprint(ai_bp)
csrf.exempt(ai_bp) # ICAIM API 使用內部呼叫,不需要 CSRF
sys_log.info("[Blueprint] ✅ AI 智慧文案系統 Blueprint 已註冊")
# ==========================================
# 🔧 Blueprint 註冊 - CI/CD Dashboard
# ==========================================
from routes.cicd_routes import cicd_bp
app.register_blueprint(cicd_bp)
csrf.exempt(cicd_bp) # CI/CD API doesn't need CSRF
sys_log.info("[Blueprint] CI/CD Dashboard Blueprint registered")
# ==========================================
# 🔧 Blueprint 註冊 - Code Review 系統
# ==========================================
try:
from routes.code_review_routes import code_review_bp
app.register_blueprint(code_review_bp)
csrf.exempt(code_review_bp) # Code Review API 使用內部認證,不需要 CSRF
sys_log.info("[Blueprint] ✅ Code Review 系統 Blueprint 已註冊 (CSRF 已豁免)")
except Exception as _e:
sys_log.warning(f"[Blueprint] ⚠️ Code Review 系統 Blueprint 註冊失敗: {_e}")
# ==========================================
# 🔧 Blueprint 註冊 - 趨勢資料系統
# ==========================================
from routes.trend_routes import trend_bp
app.register_blueprint(trend_bp)
sys_log.info("[Blueprint] ✅ 趨勢資料系統 Blueprint 已註冊")
# ==========================================
# 🔒 Auth 路由註冊 - 登入/登出
# ==========================================
from auth import init_auth_routes, login_required
init_auth_routes(app)
sys_log.info("[Auth] ✅ 登入/登出路由已註冊")
# ==========================================
# 🔧 Blueprint 註冊 - 用戶管理系統
# ==========================================
from routes.user_routes import user_bp
app.register_blueprint(user_bp)
sys_log.info("[Blueprint] ✅ 用戶管理系統 Blueprint 已註冊")
# ==========================================
# 🚨 Blueprint 註冊 - 系統告警
# ==========================================
from routes.alert_routes import alert_bp
app.register_blueprint(alert_bp)
csrf.exempt(alert_bp)
sys_log.info("[Blueprint] ✅ 系統告警 Blueprint 已註冊 (CSRF 已豁免)")
# ==========================================
# 系統管理路由 Blueprint
# ==========================================
from routes.system_routes import system_bp
app.register_blueprint(system_bp)
csrf.exempt(system_bp) # n8n API 需要豁免 CSRF
sys_log.info("[Blueprint] ✅ 系統管理 Blueprint 已註冊 (CSRF 已豁免)")
from routes.category_routes import category_bp
app.register_blueprint(category_bp)
sys_log.info("[Blueprint] ✅ 分類 CRUD Blueprint 已註冊")
from routes.misc_routes import misc_bp
app.register_blueprint(misc_bp)
sys_log.info("[Blueprint] ✅ 雜項 Routes Blueprint 已註冊 (/api/test_url, /brand_assets)")
# ==========================================
# 通知模板管理 Blueprint
# ==========================================
from routes.notification_routes import notification_bp
app.register_blueprint(notification_bp)
csrf.exempt(notification_bp) # n8n API 需要豁免 CSRF
sys_log.info("[Blueprint] ✅ 通知模板管理 Blueprint 已註冊")
# ==========================================
# Bot API Blueprint (Clawdbot 整合)
# ==========================================
from routes.bot_api_routes import bot_api_bp
app.register_blueprint(bot_api_bp)
csrf.exempt(bot_api_bp) # Bot API 使用 Token 認證,不需要 CSRF
sys_log.info("[Blueprint] ✅ Bot API Blueprint 已註冊")
# ==========================================
# Elephant Alpha AI Agent Super Orchestrator Blueprint
# ==========================================
try:
from routes.elephant_alpha_routes import elephant_alpha_bp
app.register_blueprint(elephant_alpha_bp)
csrf.exempt(elephant_alpha_bp) # Elephant Alpha API uses internal auth
sys_log.info("[Blueprint] Elephant Alpha AI Agent Super Orchestrator Blueprint registered")
except Exception as _e:
sys_log.warning(f"[Blueprint] Elephant Alpha registration failed: {_e}")
sys_log.info("[Blueprint] Elephant Alpha features will be unavailable")
# [2026-04-18 台北] OpenClaw Bot Blueprint — 修復 /menu 啞巴 (/bot/telegram/webhook 404)
# 原因:routes/openclaw_bot_routes.py 有 5000+ 行完整 telegram bot handler,但 app.py 從未 register
# 效果:Telegram 送進來的 update (包含 /menu) 能被正確接收與處理
try:
from routes.openclaw_bot_routes import openclaw_bot_bp
app.register_blueprint(openclaw_bot_bp)
csrf.exempt(openclaw_bot_bp) # Telegram webhook 不需要 CSRF
sys_log.info("[Blueprint] ✅ OpenClaw Bot Blueprint 已註冊 (Telegram /menu 復活)")
except Exception as _e:
sys_log.error(f"[Blueprint] ❌ OpenClaw Bot Blueprint 註冊失敗: {_e}")
# P0-12 修復:補齊缺少的 Blueprint 註冊
for _bp_module, _bp_name in [
('routes.api_routes', 'api_bp'),
('routes.edm_routes', 'edm_bp'),
('routes.sales_routes', 'sales_bp'),
('routes.monthly_routes', 'monthly_bp'),
('routes.price_comparison_routes', 'price_comparison_bp'),
('routes.export_routes', 'export_bp'),
('routes.daily_sales_routes', 'daily_sales_bp'),
('routes.dashboard_routes', 'dashboard_bp'),
('routes.import_routes', 'import_bp'),
('routes.pchome_routes', 'pchome_bp'),
]:
try:
import importlib as _il
_mod = _il.import_module(_bp_module)
_bp = getattr(_mod, _bp_name)
app.register_blueprint(_bp)
sys_log.info(f"[Blueprint] ✅ {_bp_name} 已註冊")
except Exception as _e:
sys_log.error(f"[Blueprint] ❌ {_bp_name} 註冊失敗: {_e}")
# V-Fix: 註冊 slugify 函數供模板使用(實作搬至 utils/text_helpers.py)
from utils.text_helpers import slugify # noqa: E402
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = "服務啟動中..."
# 🚩 時區設定:台北時間 (UTC+8)
TAIPEI_TZ = timezone(timedelta(hours=8))
EXPECTED_METADATA_TABLES = {
'categories', 'products', 'price_records', 'monthly_summary_analysis',
'users', 'login_history', 'permissions', 'user_permissions',
'promo_products', 'trend_records', 'trend_keywords', 'trend_analysis',
'web_search_cache', 'telegram_users',
'ai_generation_history', 'ai_prompt_templates', 'ai_usage_tracking', 'ai_insights',
'agent_context', 'action_plans', 'action_outcomes', 'agent_strategy_weights',
'incidents', 'playbooks', 'heal_logs',
'import_jobs', 'import_config', 'notification_templates', 'ppt_reports',
'vendor_stockout', 'vendor_list', 'vendor_emails', 'email_send_log',
'realtime_sales_monthly',
}
def verify_metadata_tables():
missing = EXPECTED_METADATA_TABLES - set(Base.metadata.tables.keys())
if missing:
raise SystemExit(f"Base.metadata 漏表: {sorted(missing)}")
verify_metadata_tables()
# ==========================================
# 🔧 全域模板變數注入 (Context Processor)
# ==========================================
from config import METABASE_URL, GRIST_URL
@app.context_processor
def inject_global_vars():
"""注入全域變數到所有模板"""
return {
'metabase_url': METABASE_URL,
'grist_url': GRIST_URL,
'datetime_now': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'),
}
sys_log.info("[Template] ✅ 全域模板變數已注入 (metabase_url, grist_url)")
# ================= 🛠️ V9.72: 分類設定管理核心 =================
CATEGORIES_JSON_PATH = os.path.join(BASE_DIR, 'data', 'categories.json')
# JSON 持久化:實作搬至 services/json_storage.py
from services.json_storage import ( # noqa: E402, F401
load_categories,
save_categories,
load_scheduler_stats,
)
# ================= 🛠️ 數據處理核心 (封裝) =================
# 純工具:實作已搬至 utils/text_helpers.py
from utils.text_helpers import ( # noqa: E402
get_color_for_string,
extract_snapshot_date_from_filename,
number_format as _number_format,
)
@app.template_filter('number_format')
def number_format_filter(value):
"""Jinja filter wrapper — 實作見 utils.text_helpers.number_format。"""
return _number_format(value)
# V-Refactor: 將 find_col 移至全域,方便多個函式共用
from utils.df_helpers import find_col # noqa: E402
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] 🔄 快取過期或不存在,重新查詢資料庫")
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
sys_log.debug(f"[Dashboard] [Cache] 💾 快取已更新 | 商品數: {len(unique_items)}")
return unique_items, today_start
finally:
session.close()
def get_dashboard_stats():
"""計算看板統計數據 (供通知使用)"""
db = DatabaseManager()
session = db.get_session()
try:
unique_items, today_start = get_consolidated_data()
today_start_db = today_start.replace(tzinfo=None)
# 1. 漲跌
increase_count = sum(1 for item in unique_items if item['yesterday_diff'] > 0)
decrease_count = sum(1 for item in unique_items if item['yesterday_diff'] < 0)
# 2. 今日新增 (使用與 index 路由相同的邏輯)
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()}
new_count = len(new_product_ids)
# 3. 今日下架
today_delisted_count = session.query(Product).filter(
Product.status == 'INACTIVE',
Product.updated_at >= today_start_db
).count()
return {'new': new_count, 'up': increase_count, 'down': decrease_count, 'delisted': today_delisted_count}
except Exception as e:
sys_log.error(f"[Stats] ❌ 計算統計失敗: {e}")
return {'new': 0, 'up': 0, 'down': 0, 'delisted': 0}
finally:
session.close()
# ================= 🛣️ 4. Flask 路由 =================
# Session 自動續期機制
@app.before_request
def refresh_session():
"""
在每次請求時自動刷新 Session,避免長時間閒置後突然斷線
只要用戶有任何操作,Session 就會自動延長
"""
if session.get('logged_in'):
session.modified = True # 標記 Session 已修改,觸發 Cookie 更新
@app.route('/health')
def health_check():
"""健康檢查端點 - 供 Nginx 和 Docker healthcheck 使用"""
try:
# 簡單檢查資料庫連線
from config import DATABASE_TYPE
return jsonify({
'status': 'healthy',
'database': DATABASE_TYPE,
'version': SYSTEM_VERSION
}), 200
except Exception as e:
return jsonify({
'status': 'unhealthy',
'error': str(e)
}), 500
@app.route('/metrics')
def prometheus_metrics():
"""Prometheus 指標端點 - 供 Prometheus 抓取監控資料"""
try:
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Counter, Gauge, CollectorRegistry
from config import DATABASE_TYPE
# 建立獨立的 registry 以避免重複註冊
registry = CollectorRegistry()
# 應用程式資訊
app_info = Gauge('momo_app_info', '應用程式資訊', ['version', 'database_type'], registry=registry)
app_info.labels(version=SYSTEM_VERSION, database_type=DATABASE_TYPE).set(1)
# 應用程式健康狀態 (1=健康, 0=不健康)
app_health = Gauge('momo_app_health', '應用程式健康狀態', registry=registry)
# 資料庫連線狀態
db_status = Gauge('momo_database_up', '資料庫連線狀態', registry=registry)
try:
db = DatabaseManager()
with db.engine.connect() as conn:
conn.execute(text("SELECT 1"))
db_status.set(1)
app_health.set(1)
except Exception:
db_status.set(0)
app_health.set(0)
# 資料庫記錄數
try:
db = DatabaseManager()
session = db.get_session()
# 商品數量
product_count = Gauge('momo_products_total', '商品總數', registry=registry)
product_count.set(session.query(Product).count())
# 價格記錄數量
price_record_count = Gauge('momo_price_records_total', '價格記錄總數', registry=registry)
price_record_count.set(session.query(PriceRecord).count())
# 業績資料筆數
from database.realtime_sales_models import RealtimeSalesMonthly
sales_count = Gauge('momo_sales_records_total', '業績資料總數', registry=registry)
sales_count.set(session.query(RealtimeSalesMonthly).count())
session.close()
except Exception as e:
sys_log.warning(f"[Metrics] 無法取得資料庫統計: {e}")
# 返回 Prometheus 格式
from flask import Response
return Response(generate_latest(registry), mimetype=CONTENT_TYPE_LATEST)
except ImportError:
# prometheus_client 未安裝時的備用方案
metrics_text = """# HELP momo_app_health 應用程式健康狀態
# TYPE momo_app_health gauge
momo_app_health 1
# HELP momo_app_info 應用程式資訊
# TYPE momo_app_info gauge
momo_app_info{version="9.4",database_type="postgresql"} 1
"""
from flask import Response
return Response(metrics_text, mimetype='text/plain; charset=utf-8')
except Exception as e:
sys_log.error(f"[Metrics] 指標生成錯誤: {e}")
from flask import Response
return Response(f"# Error: {e}\n", mimetype='text/plain; charset=utf-8'), 500
@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():
"""系統設定與匯入頁面"""
return render_template('system_settings.html', system_version=SYSTEM_VERSION)
@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'''
數據加載中 - WOOO TECH
''', 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():
return render_template('logs.html')
@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": "等待系統啟動中..."})
@app.route('/api/backup', methods=['POST'])
@login_required
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/')
@login_required
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
# ================= 📊 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:
# 快取不存在時,直接回傳錯誤讓呼叫端顯示 spinner 導回 sales_analysis
# 不在此發起全表 DB 查詢(748k 行會 hang Gunicorn worker)
sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),回傳錯誤讓 UI 導回 sales_analysis")
return None, {}, f"快取未就緒,請先從業績分析主頁載入資料 (cache_key={cache_key})"
if False: # 保留舊冷快取重載邏輯(已停用,避免全表掃描 hang)
sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),試圖重新從資料庫載入...")
try:
# V-Fix: 從 cache_key 提取 table_name
# 格式: realtime_sales_monthly_3m 或 realtime_sales_monthly_custom_2025-01-01_2025-01-31
if "_custom_" in cache_key:
table_name = cache_key.split('_custom_')[0] # realtime_sales_monthly
else:
# 移除最後的 _Xm 部分
parts = cache_key.rsplit('_', 1)
table_name = parts[0] if len(parts) > 1 else 'realtime_sales_monthly'
# 判斷是自訂區間還是標配區間
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=0 表示全時段但上限 12 個月避免全表掃描 hang
months = int(cache_key.split('_')[-1].replace('m', '') or '12')
if months == 0:
months = 12
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:
# V-Fix (2026-01-23): 補回所有日期維度欄位供後續篩選 (_dow, _hour, _month_str)
if '日期' in result_df.columns:
# 先轉換為 datetime
result_df['_parsed_date'] = pd.to_datetime(result_df['日期'], errors='coerce')
result_df['_month_str'] = result_df['_parsed_date'].dt.strftime('%Y-%m')
result_df['_dow'] = result_df['_parsed_date'].dt.dayofweek
# 小時需要從「時間」欄位提取
if '時間' in result_df.columns:
result_df['_hour'] = pd.to_datetime(result_df['時間'], format='%H:%M:%S', errors='coerce').dt.hour
else:
result_df['_hour'] = 0 # 如果沒有時間欄位,預設為 0
# 清理臨時欄位
result_df.drop(columns=['_parsed_date'], inplace=True, errors='ignore')
# 自動存入快取
_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:
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():
"""業績分析報表頁面"""
try:
db = DatabaseManager()
table_name = '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='')
# 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):
from datetime import datetime
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筆)以獲得更完整的月份資訊
preview_df = pd.read_sql(f"SELECT * FROM {table_name} LIMIT 1000", db.engine)
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,
items=[],
kpi={'revenue': 0, 'qty': 0, 'count': 0, 'cost': 0, 'gross_margin': 0, 'gross_margin_rate': 0, 'avg_price': 0},
insights={},
abc_stats={},
vendor_stats=[],
seasonality_data={'datasets': [], 'yLabels': [], 'xLabels': []},
bar_data=empty_data,
cat_data=empty_data,
price_dist_data=empty_data,
scatter_data=[],
bcg_data=[],
dow_data=empty_data,
hourly_data=empty_data,
monthly_data=empty_data,
weekly_data=empty_data,
heatmap_data=[],
treemap_data=[],
cols={'name': True, 'amount': True, 'qty': True, 'cat': True, 'date': True,
'cost': True, 'profit': True, 'vendor': True, 'brand': True,
'return_qty': True, 'pid': True},
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)
# 解析 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. 優先檢查是否已有處理好的快取 (最快)
if cache_key in _SALES_PROCESSED_CACHE:
cache_data = _SALES_PROCESSED_CACHE[cache_key]
df = cache_data['df']
cols_map = cache_data['cols']
# 恢復欄位變數
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 已在上方定義)
# 先讀取小樣本以識別日期欄位
sample_df = pd.read_sql(f"SELECT * FROM {table_name} LIMIT 100", db.engine)
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)
# 自動識別日期欄位(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
# 根據是否有日期欄位決定查詢方式
if date_col_name:
from datetime import datetime, timedelta, timezone
TAIPEI_TZ = timezone(timedelta(hours=8))
# 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:
sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" BETWEEN '{start_date_slash}' AND '{end_date_slash}'"
sys_log.info(f"[Sales Analysis] 📅 使用自訂日期範圍: {start_date} ~ {end_date} (DB格式: {start_date_slash} ~ {end_date_slash})")
elif start_date:
sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" >= '{start_date_slash}'"
sys_log.info(f"[Sales Analysis] 📅 使用自訂開始日期: >= {start_date} (DB格式: {start_date_slash})")
else: # only end_date
sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" <= '{end_date_slash}'"
sys_log.info(f"[Sales Analysis] 📅 使用自訂結束日期: <= {end_date} (DB格式: {end_date_slash})")
elif data_range_months > 0:
# 使用相對日期範圍(最近N個月)
# V-Fix: 使用斜線格式以匹配資料庫格式
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" >= '{cutoff_date}'"
sys_log.info(f"[Sales Analysis] 📊 使用日期範圍篩選: 最近 {data_range_months} 個月 (>= {cutoff_date})")
else:
# data_range_months == 0,載入全部資料
sql_query = f"SELECT * FROM {table_name}"
sys_log.info(f"[Sales Analysis] 📊 載入全部資料(用戶選擇)")
else:
# 無日期欄位 - 載入全部
sql_query = f"SELECT * FROM {table_name}"
sys_log.info(f"[Sales Analysis] ⚠️ 未找到日期欄位,載入全部資料")
# V-Opt (2026-01-23): 優先使用 PostgreSQL 聚合視圖 (mv_sales_summary)
# 聚合視圖已預先計算:資料量 -20%, 大小 -73%, 欄位類型已轉換
from sqlalchemy import text as sql_text
from config import DATABASE_TYPE
use_materialized_view = False
if DATABASE_TYPE == 'postgresql':
# 檢查聚合視圖是否存在
try:
with db.engine.connect() as conn:
check_mv = conn.execute(sql_text(
"SELECT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'mv_sales_summary')"
)).fetchone()
use_materialized_view = check_mv[0] if check_mv else False
except:
use_materialized_view = False
if use_materialized_view:
# 使用聚合視圖 - 欄位已標準化為英文
sys_log.info(f"[Sales Analysis] 📊 使用 PostgreSQL 聚合視圖 (mv_sales_summary)")
# 構建日期篩選條件
mv_where = ""
if start_date or end_date:
if start_date and end_date:
mv_where = f"WHERE sale_date BETWEEN '{start_date}' AND '{end_date}'"
elif start_date:
mv_where = f"WHERE sale_date >= '{start_date}'"
else:
mv_where = f"WHERE sale_date <= '{end_date}'"
elif data_range_months > 0:
from datetime import datetime, timedelta, timezone
TAIPEI_TZ = timezone(timedelta(hours=8))
cutoff = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y-%m-%d')
mv_where = f"WHERE sale_date >= '{cutoff}'"
mv_query = f"""
SELECT
sale_date as "日期",
product_id as "商品ID",
product_name as "商品名稱",
category as "商品館",
brand as "品牌",
vendor_name as "廠商名稱",
payment as "付款",
total_revenue as "總業績",
total_qty as "數量",
total_cost as "總成本",
order_count
FROM mv_sales_summary
{mv_where}
"""
df = pd.read_sql(mv_query, db.engine)
sys_log.info(f"[Sales Analysis] 📊 聚合視圖載入完成: {len(df):,} 筆記錄")
else:
# 原始邏輯:使用原始表
sys_log.info(f"[Sales Analysis] 📊 使用原始表載入...")
df = pd.read_sql(sql_query, db.engine)
sys_log.info(f"[Sales Analysis] 📊 載入完成: {len(df):,} 筆記錄")
# 聚合模式標記
is_aggregated_mode = use_materialized_view
# V-Opt: 不再快取完整 DataFrame 到 _SALES_DF_CACHE (避免記憶體累積)
# 改用輕量級處理後快取 (_SALES_PROCESSED_CACHE)
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)
# 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)
# 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 not is_aggregated_mode:
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 and not is_aggregated_mode:
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, '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欄位
}
_SALES_PROCESSED_CACHE[cache_key] = cache_entry
# V-Fix: 同時使用固定的 table_name 作為 key 儲存副本,供其他路由使用
_SALES_PROCESSED_CACHE[table_name] = cache_entry
# V-Opt (2026-01-23): 定期清理過期快取
_cleanup_sales_cache()
# 🚩 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: 從數據庫直接查詢所有可用選項,而不是只從篩選後的快取中讀取
# 這樣可以確保下拉選單顯示完整的可選項,即使當前篩選範圍很小
all_categories = []
all_brands = []
all_vendors = []
all_activities = []
all_payments = []
all_months = []
try:
from sqlalchemy import text
# V-Fix: SQLAlchemy 2.0 需要使用 connection 對象
with db.engine.connect() as conn:
# 讀取完整資料表的所有可用選項(使用 DISTINCT 以提升效能)
# V-Fix: 使用單引號空字串,兼容 PostgreSQL
if col_category:
sql = f"SELECT DISTINCT \"{col_category}\" FROM {table_name} WHERE \"{col_category}\" IS NOT NULL AND \"{col_category}\" <> ''"
result = conn.execute(text(sql)).fetchall()
all_categories = sorted([str(row[0]) for row in result if row[0]])
if col_brand:
sql = f"SELECT DISTINCT \"{col_brand}\" FROM {table_name} WHERE \"{col_brand}\" IS NOT NULL AND \"{col_brand}\" <> ''"
result = conn.execute(text(sql)).fetchall()
all_brands = sorted([str(row[0]) for row in result if row[0]])
if col_vendor:
sql = f"SELECT DISTINCT \"{col_vendor}\" FROM {table_name} WHERE \"{col_vendor}\" IS NOT NULL AND \"{col_vendor}\" <> ''"
result = conn.execute(text(sql)).fetchall()
all_vendors = sorted([str(row[0]) for row in result if row[0]])
if col_activity:
sql = f"SELECT DISTINCT \"{col_activity}\" FROM {table_name} WHERE \"{col_activity}\" IS NOT NULL AND \"{col_activity}\" <> ''"
result = conn.execute(text(sql)).fetchall()
all_activities = sorted([str(row[0]) for row in result if row[0]])
if col_payment:
sql = f"SELECT DISTINCT \"{col_payment}\" FROM {table_name} WHERE \"{col_payment}\" IS NOT NULL AND \"{col_payment}\" <> ''"
result = conn.execute(text(sql)).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:
# V-Fix: 使用 substr 提取年月部分,並將斜線替換為橫線
# 數據庫格式: "2025/07/01" -> 提取前7個字符 "2025/07" -> 替換斜線 "2025-07"
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 and len(result) > 0:
all_months = [row[0] for row in result if row[0] and '-' in str(row[0])]
if all_months: # 如果成功提取到月份,就使用這個欄位
sys_log.info(f"[Sales Analysis] 從欄位 {field} 提取到 {len(all_months)} 個月份: {all_months}")
break
except Exception as ex:
sys_log.warning(f"[Sales Analysis] 從欄位 {field} 提取月份失敗: {ex}")
continue
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', '')
# 決定排序欄位
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)
# 📊 KPI 計算 (針對篩選後的資料)
total_revenue = float(target_df[col_amount].sum())
total_qty = float(target_df[col_qty].sum()) if col_qty else 0
total_count = int(len(target_df)) # 訂單筆數
# V-Fix 2026-01-15: SKU 數應計算唯一商品數,而非記錄筆數
sku_count = int(target_df[col_name].nunique()) if col_name else total_count
# V-New: 成本與毛利計算
total_cost = float(target_df[col_cost].sum()) if col_cost else 0
if col_profit:
gross_margin = float(target_df[col_profit].sum())
else:
gross_margin = 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
# 📊 V-New: 商業洞察 (Top 3 Analysis)
insights = {
'rev_cats': [], 'rev_prods': [],
'margin_cats': [], 'margin_prods': [],
'qty_cats': [], 'qty_prods': []
}
# Helper function to get top 3
# Helper function to get top 3
def get_top_3(groupby_col, metric_col, is_margin=False, is_qty=False):
if not groupby_col or not metric_col: return []
# V-Opt: 直接使用 target_df 與預計算欄位,避免 copy() 與 assign()
target_metric = metric_col
if is_margin:
target_metric = 'calculated_profit'
try:
# 直接聚合並取前3名
# V-Fix 2026-01-15: 若 groupby_col 是 list (例如 [PID, Name]),結果 index 會是 MultiIndex
grouped = target_df.groupby(groupby_col)[target_metric].sum()
def get_name(k):
# 如果是 Tuple (MultiIndex),通常最後一個是 Name,取之
return str(k[-1]) if isinstance(k, tuple) else str(k)
return [{'name': get_name(k), 'value': float(v)} for k, v in grouped.nlargest(3).items() if v > 0]
except Exception:
return []
insights['rev_cats'] = get_top_3(col_category, col_amount)
# V-Fix: 商品聚合改用 [PID, Name] 避免同名不同ID商品被合併
product_groupby = [col_pid, col_name] if col_pid else col_name
insights['rev_prods'] = get_top_3(product_groupby, col_amount)
insights['qty_cats'] = get_top_3(col_category, col_qty, is_qty=True)
insights['qty_prods'] = get_top_3(product_groupby, col_qty, is_qty=True)
if col_cost or col_profit:
insights['margin_cats'] = get_top_3(col_category, col_amount, is_margin=True)
insights['margin_prods'] = get_top_3(product_groupby, col_amount, is_margin=True)
# 📊 V-Opt: 改為橫向長條圖數據 (Top 20)
top_chart = target_df.head(20)
bar_data = {
'labels': [str(n)[:20] + '...' if len(str(n)) > 20 else str(n) for n in top_chart[col_name]], # 稍微放寬長度限制
'chart_values': [float(x) for x in top_chart[sort_col]],
'metric_label': '銷售金額 ($)' if selected_metric == 'amount' else '銷售數量'
}
# 📋 V-Opt: 列表資料改為 AJAX 載入,這裡只傳空列表以加快初始渲染
table_items = []
# 準備類別圓餅圖資料
# V-Fix: 使用 target_df (篩選後資料) 動態計算
cat_data = {'labels': [], 'chart_values': []}
if col_category and not target_df.empty:
cat_group_all = target_df.groupby(col_category)[col_amount].sum().sort_values(ascending=False)
TOP_N_CATS = 12
if len(cat_group_all) > TOP_N_CATS:
top_cats = cat_group_all.head(TOP_N_CATS)
other_val = cat_group_all.iloc[TOP_N_CATS:].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 cat_group_all.index.tolist()]
cat_data['chart_values'] = [float(x) for x in cat_group_all.tolist()]
# 📊 V-New: 價格帶分析 (Price Range Analysis)
price_dist_data = {'labels': [], 'chart_values': []}
if col_price and not target_df.empty:
# 定義價格區間 (0-500, 500-1000, 1000-2000, 2000-5000, 5000-10000, 10000+)
bins = [0, 500, 1000, 2000, 5000, 10000, float('inf')]
labels = ['0-499', '500-999', '1,000-1,999', '2,000-4,999', '5,000-9,999', '10,000+']
# V-Opt: 使用 pd.cut 進行分組,但不修改 target_df (避免污染快取)
# right=False 表示包含左邊界,例如 500 在 500-999 這一組
price_bins = pd.cut(target_df[col_price], bins=bins, labels=labels, right=False)
# 統計各區間的「銷售金額」貢獻 (直接使用外部 Series 進行 groupby)
range_group = target_df.groupby(price_bins, observed=False)[col_amount].sum()
price_dist_data['labels'] = labels
price_dist_data['chart_values'] = [float(range_group.get(l, 0)) for l in labels]
# 📊 V-New: 價格 vs 銷量 散佈圖 (Scatter Plot)
scatter_data = []
if col_price and col_qty and not target_df.empty:
# 取前 300 筆主要商品,避免圖表過於密集導致瀏覽器卡頓
scatter_source = target_df.head(300)
for _, row in scatter_source.iterrows():
# V-Fix (2026-01-23): 處理 NaN 值
price_val = row[col_price] if pd.notna(row[col_price]) else 0
qty_val = row[col_qty] if pd.notna(row[col_qty]) else 0
amt_val = row[col_amount] if pd.notna(row[col_amount]) else 0
scatter_data.append({
'x': float(price_val),
'y': float(qty_val),
'name': str(row[col_name]) if pd.notna(row[col_name]) else '',
'amt': float(amt_val) # 用於 tooltip 顯示金額
})
# 📊 V-New: BCG 矩陣分析 (BCG Matrix)
# X軸: 銷量 (Qty), Y軸: 毛利率 (Margin %)
bcg_data = {'datasets': [], 'thresholds': {'x': 0, 'y': 0}}
# V-Fix: 確保 calculated_margin_rate 欄位存在
if col_qty and (col_cost or col_profit) and not target_df.empty and 'calculated_margin_rate' in target_df.columns:
# 1. 計算閾值 (使用中位數,避免極端值影響)
# 過濾掉銷量為 0 的商品,避免干擾閾值計算
active_products = target_df[target_df[col_qty] > 0]
if not active_products.empty and 'calculated_margin_rate' in active_products.columns:
median_qty = active_products[col_qty].median()
median_margin = active_products['calculated_margin_rate'].median()
# 若中位數為 0 (例如大部分商品沒銷量),則給一個預設值以利顯示
if median_qty == 0: median_qty = 1
bcg_data['thresholds'] = {'x': float(median_qty), 'y': float(median_margin)}
# 2. 分類商品 (四象限)
# Stars (明星): High Qty, High Margin
stars = active_products[(active_products[col_qty] >= median_qty) & (active_products['calculated_margin_rate'] >= median_margin)]
# Cows (金牛): High Qty, Low Margin
cows = active_products[(active_products[col_qty] >= median_qty) & (active_products['calculated_margin_rate'] < median_margin)]
# Questions (問題): Low Qty, High Margin
questions = active_products[(active_products[col_qty] < median_qty) & (active_products['calculated_margin_rate'] >= median_margin)]
# Dogs (瘦狗): Low Qty, Low Margin
dogs = active_products[(active_products[col_qty] < median_qty) & (active_products['calculated_margin_rate'] < median_margin)]
def format_bcg_points(df_segment):
# 限制點數,避免前端卡頓 (各象限最多 100 點)
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_segment.head(100).iterrows()]
bcg_data['datasets'] = [
{'label': '明星商品 (Stars)', 'data': format_bcg_points(stars), 'backgroundColor': 'rgba(255, 206, 86, 0.8)', 'borderColor': 'rgba(255, 206, 86, 1)'}, # Yellow
{'label': '金牛商品 (Cows)', 'data': format_bcg_points(cows), 'backgroundColor': 'rgba(75, 192, 192, 0.8)', 'borderColor': 'rgba(75, 192, 192, 1)'}, # Green
{'label': '問題商品 (Questions)', 'data': format_bcg_points(questions), 'backgroundColor': 'rgba(54, 162, 235, 0.8)', 'borderColor': 'rgba(54, 162, 235, 1)'}, # Blue
{'label': '瘦狗商品 (Dogs)', 'data': format_bcg_points(dogs), 'backgroundColor': 'rgba(201, 203, 207, 0.8)', 'borderColor': 'rgba(201, 203, 207, 1)'} # Grey
]
# 📊 V-New: 時間維度分析 (Time Analysis)
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': []} # V-New: 每週趨勢
monthly_data = {'labels': [], 'chart_values': []} # V-New: 每月趨勢
heatmap_data = [] # V-New: 多維度熱力圖 (Day x Hour)
treemap_data = [] # V-New: 板塊圖數據
if col_date:
# 過濾掉日期無效的資料
# V-Opt: 使用預計算欄位進行分組,速度更快
if not target_df.empty:
# 1. 星期分析 (Day of Week)
dow_group = target_df.groupby('_dow')[col_amount].sum()
for day, val in dow_group.items():
if not np.isnan(day):
dow_data['chart_values'][int(day)] = float(val)
# 2. 小時分析 (Hourly)
hour_group = target_df.groupby('_hour')[col_amount].sum()
for hour, val in hour_group.items():
if not np.isnan(hour):
hourly_data['chart_values'][int(hour)] = float(val)
# 3. 每月趨勢 (Monthly Trend) - V-New
month_group = target_df.groupby('_month_str')[col_amount].sum().sort_index()
monthly_data['labels'] = month_group.index.tolist()
# V-Fix (2026-01-23): 處理 NaN 值避免 JSON 序列化失敗
monthly_data['chart_values'] = [float(x) if not np.isnan(x) else 0 for x in month_group.tolist()]
# 3. 每週趨勢 (Weekly Trend) - V-New
week_group = target_df.groupby('_week')[col_amount].sum().sort_index()
# V-Opt: 解除 12 週限制,顯示完整年度趨勢 (因應一年份數據需求)
weekly_data['labels'] = week_group.index.tolist()
# V-Fix (2026-01-23): 處理 NaN 值避免 JSON 序列化失敗
weekly_data['chart_values'] = [float(x) if not np.isnan(x) else 0 for x in week_group.tolist()]
# 4. 多維度熱力圖 (Day x Hour) - V-Fix: 確保數據完整性
dh_group = target_df.groupby(['_dow', '_hour'])[col_amount].sum()
# V-Opt: 正規化氣泡大小 (Normalize Bubble Size) 以提升可讀性
max_val = dh_group.max() if not dh_group.empty else 1
for (day, hour), val in dh_group.items():
# V-Fix (2026-01-23): 處理 NaN 值
if np.isnan(val):
val = 0
# 將數值映射到 3~25px 的半徑範圍,確保視覺可辨識
radius = 3 + (math.sqrt(val) / math.sqrt(max_val)) * 22 if val > 0 else 0
heatmap_data.append({
'x': int(hour), # X軸: 小時 (0-23)
'y': int(day), # Y軸: 星期 (0-6)
'r': float(radius) if not np.isnan(radius) else 0, # V-Adj: 正規化後半徑
'v': float(val) # 實際數值 (用於 Tooltip)
})
# 📊 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():
# V-Fix (2026-01-23): 處理 NaN 值
amount_val = row[col_amount]
if pd.isna(amount_val):
amount_val = 0
treemap_data.append({
'category': str(cat),
'product': str(row[col_name]) if pd.notna(row[col_name]) else '',
'value': float(amount_val),
'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])
})
# 📊 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)
return render_template('sales_analysis.html',
marketing_data=marketing_data, # V-New: 傳遞行銷活動數據
items=table_items,
kpi={
'revenue': total_revenue,
'qty': total_qty,
'count': total_count,
'sku_count': sku_count, # V-Fix 2026-01-15: 唯一商品數
'cost': total_cost,
'gross_margin': gross_margin,
'gross_margin_rate': gross_margin_rate,
'avg_price': avg_price
},
insights=insights,
abc_stats=abc_stats, # V-New: 傳遞 ABC 分析數據
vendor_stats=vendor_stats, # V-New: 傳遞廠商排行數據
seasonality_data=seasonality_data, # V-New: 傳遞淡旺季數據
bar_data=bar_data,
cat_data=cat_data,
price_dist_data=price_dist_data,
scatter_data=scatter_data,
bcg_data=bcg_data, # V-New: 傳遞 BCG 數據
dow_data=dow_data,
hourly_data=hourly_data,
monthly_data=monthly_data,
weekly_data=weekly_data,
heatmap_data=heatmap_data,
treemap_data=treemap_data,
all_categories=all_categories,
all_brands=all_brands, all_vendors=all_vendors, all_activities=all_activities, all_payments=all_payments,
all_months=all_months, # V-New: 傳遞月份列表
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,
cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category, 'date': col_date, 'cost': col_cost, 'profit': col_profit, 'vendor': col_vendor, 'brand': col_brand, 'return_qty': col_return_qty, 'pid': col_pid},
table_name=table_name,
data_range_months=data_range_months,
start_date=start_date, # V-New: 傳遞自訂開始日期
end_date=end_date, # V-New: 傳遞自訂結束日期
total_records=len(df),
db_data_range=db_data_range) # V-New: 傳遞資料庫資料期間
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)}",
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='', min_price='', max_price='',
min_margin='', max_margin='',
cols={},
table_name='realtime_sales_monthly',
no_filter=False,
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', ''),
total_records=0,
db_data_range='')
# V-Opt: API 層級快取 (減少重複查詢)
_TABLE_DATA_CACHE = {}
_TABLE_DATA_CACHE_TTL = 60 # 快取 60 秒
@app.route('/api/sales_analysis/table_data')
def get_sales_table_data():
"""API: 取得業績分析的詳細列表資料 (Server-side AJAX) - 使用 SQL 聚合優化"""
try:
import hashlib
from datetime import datetime, timedelta, timezone
TAIPEI_TZ = timezone(timedelta(hours=8))
# V-Opt: 產生查詢快取 key (根據所有篩選條件)
cache_params = request.args.to_dict()
cache_key = hashlib.md5(str(sorted(cache_params.items())).encode(), usedforsecurity=False).hexdigest()
# V-Opt: 檢查快取
if cache_key in _TABLE_DATA_CACHE:
cached = _TABLE_DATA_CACHE[cache_key]
if time.time() - cached['time'] < _TABLE_DATA_CACHE_TTL:
sys_log.debug(f"[API] Table Data: 使用快取 (key={cache_key[:8]})")
return jsonify(cached['data'])
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', {})
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
# V-Fix (2026-01-23): 使用 or 確保不會得到 None 值
col_name = cols_map.get('name') or '商品名稱'
col_pid = cols_map.get('pid') or '商品ID'
col_brand = cols_map.get('brand') or '品牌'
col_vendor = cols_map.get('vendor') or '廠商名稱'
col_category = cols_map.get('category') or '商品館'
col_amount = cols_map.get('amount') or '總業績'
col_qty = cols_map.get('qty') or '數量'
col_cost = cols_map.get('cost') or '總成本'
col_return_qty = cols_map.get('return_qty') or '退貨數量'
# 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 = []
# 分類篩選
if category_filter and category_filter != 'all' and col_category:
additional_filters.append(f""""{col_category}" = '{category_filter}'""")
# V-Fix: 品牌篩選
if brand_filter and brand_filter != 'all' and col_brand:
additional_filters.append(f""""{col_brand}" = '{brand_filter}'""")
# V-Fix: 廠商篩選
if vendor_filter and vendor_filter != 'all' and col_vendor:
additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""")
# V-Fix: 活動篩選
col_activity = cols_map.get('activity')
if activity_filter and activity_filter != 'all' and col_activity:
additional_filters.append(f""""{col_activity}" = '{activity_filter}'""")
# V-Fix: 付款方式篩選
col_payment = cols_map.get('payment')
if payment_filter and payment_filter != 'all' and col_payment:
additional_filters.append(f""""{col_payment}" = '{payment_filter}'""")
# 月份篩選
if month_filter and month_filter != 'all':
# V-Fix: 月份格式例如 "2025-01",但資料庫可能使用斜線格式 "2025/01"
# 只使用「日期」欄位(「訂單日期」是固定文字,「時間」只包含時間)
month_filter_slash = month_filter.replace('-', '/') # "2025-01" -> "2025/01"
# 同時匹配橫線和斜線格式
additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""")
# 星期篩選 (需要從日期計算)
if dow_filter and dow_filter != 'all':
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite 兩種資料庫
# Pandas dt.dayofweek: 0=Monday, 6=Sunday
pandas_dow = int(dow_filter)
if DATABASE_TYPE == 'postgresql':
# PostgreSQL: EXTRACT(DOW FROM date) 0=Sunday, 6=Saturday
# Pandas 0(Mon) -> PostgreSQL 1(Mon), Pandas 6(Sun) -> PostgreSQL 0(Sun)
pg_dow = (pandas_dow + 1) % 7
# 日期格式可能是 2025/01/01,需要轉換為 YYYY-MM-DD
additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""")
else:
# SQLite: strftime('%w', date) 0=Sunday, 6=Saturday
sqlite_dow = str((pandas_dow + 1) % 7)
additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""")
# 小時篩選 (需要從時間欄位提取)
if hour_filter and hour_filter != 'all':
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite 兩種資料庫
hour_val = int(hour_filter)
if DATABASE_TYPE == 'postgresql':
# PostgreSQL: 使用 SUBSTRING 或 CAST
additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""")
else:
# SQLite: 使用 substr
additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""")
# 關鍵字篩選
if keyword:
keyword_escaped = keyword.replace("'", "''") # SQL 注入防護
keyword_conditions = []
if col_name:
keyword_conditions.append(f""""{col_name}" LIKE '%{keyword_escaped}%'""")
if col_pid:
keyword_conditions.append(f""""{col_pid}" LIKE '%{keyword_escaped}%'""")
if col_brand:
keyword_conditions.append(f""""{col_brand}" LIKE '%{keyword_escaped}%'""")
if col_vendor:
keyword_conditions.append(f""""{col_vendor}" LIKE '%{keyword_escaped}%'""")
if keyword_conditions:
additional_filters.append(f"({' OR '.join(keyword_conditions)})")
# 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 300
"""
df_agg = pd.read_sql(sql_query, db.engine)
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
# 重新排序並限制到 300 筆 (減少前端渲染負擔)
df_agg = df_agg.sort_values('amount', ascending=False).head(300)
# V-Opt: 使用向量化操作取代逐列迴圈
df_agg['rank'] = range(1, len(df_agg) + 1)
df_agg['month_str'] = '' # SQL聚合模式不需要月份字串
# 重新命名欄位以符合前端格式
result_df = df_agg.rename(columns={
'product_id': 'product_id',
'name': 'name',
'brand': 'brand',
'vendor': 'vendor',
'category': 'category',
'margin_rate': 'margin_rate',
'avg_price': 'avg_price',
'return_rate': 'return_rate',
'qty': 'qty',
'amount': 'amount'
})
# 選擇需要的欄位並轉換為字典列表
columns = ['rank', 'product_id', 'name', 'brand', 'vendor', 'category',
'margin_rate', 'month_str', 'avg_price', 'return_rate', 'qty', 'amount']
# V-Fix (2026-01-23): 確保所有數值欄位無 NaN/Infinity,避免 JSON 序列化失敗
numeric_cols = ['margin_rate', 'avg_price', 'return_rate', 'qty', 'amount']
for col in numeric_cols:
if col in result_df.columns:
result_df[col] = result_df[col].replace([np.inf, -np.inf], 0).fillna(0)
# V-Fix (2026-01-23): 確保字串欄位無 None,避免 JSON 序列化失敗
string_cols = ['product_id', 'name', 'brand', 'vendor', 'category', 'month_str']
for col in string_cols:
if col in result_df.columns:
result_df[col] = result_df[col].fillna('').astype(str)
data = result_df[columns].to_dict('records')
response_data = {'data': data}
# V-Opt: 儲存到快取
_TABLE_DATA_CACHE[cache_key] = {'data': response_data, 'time': time.time()}
# V-Opt: 清理過期快取 (保留最近 50 個)
if len(_TABLE_DATA_CACHE) > 50:
sorted_keys = sorted(_TABLE_DATA_CACHE.keys(),
key=lambda k: _TABLE_DATA_CACHE[k]['time'])
for old_key in sorted_keys[:-50]:
del _TABLE_DATA_CACHE[old_key]
return jsonify(response_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
# ================= 💎 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', {})
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
# V-Fix (2026-01-23): 使用 or 確保不會得到 None 值
col_name = cols_map.get('name') or '商品名稱'
col_brand = cols_map.get('brand') or '品牌'
col_vendor = cols_map.get('vendor') or '廠商名稱'
col_category = cols_map.get('category') or '商品館'
col_amount = cols_map.get('amount') or '總業績'
col_qty = cols_map.get('qty') or '數量'
col_cost = cols_map.get('cost') or '總成本'
col_profit = cols_map.get('profit') # 可以為 None
col_activity = cols_map.get('activity') or '活動名稱'
col_payment = cols_map.get('payment') or '付款方式'
# 建立日期篩選條件
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}" = '{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('-', '/')
# 使用「日期」欄位 (這似乎是系統內部固定欄位,不需 dynamic map,除非資料表結構也變了)
# 假設 "日期" 是固定欄位
additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""")
if dow_filter and dow_filter != 'all':
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API)
pandas_dow = int(dow_filter)
if DATABASE_TYPE == 'postgresql':
pg_dow = (pandas_dow + 1) % 7
additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""")
else:
sqlite_dow = str((pandas_dow + 1) % 7)
additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""")
if hour_filter and hour_filter != 'all':
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API)
hour_val = int(hour_filter)
if DATABASE_TYPE == 'postgresql':
additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""")
else:
additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""")
# 關鍵字
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(sql_query, db.engine)
sys_log.info(f"[API] Top Detail: {top_type}/{view_type} 返回 {len(df)} 筆資料")
if df.empty:
return jsonify({'items': []})
# V-Fix (2026-01-23): 確保數值欄位無 NaN/Infinity,避免 JSON 序列化失敗
numeric_cols = ['value', 'margin_rate']
for col in numeric_cols:
if col in df.columns:
df[col] = df[col].replace([np.inf, -np.inf], 0).fillna(0)
# V-Fix (2026-01-23): 確保字串欄位無 None
string_cols = ['product_id', 'name', 'brand', 'vendor', 'category']
for col in string_cols:
if col in df.columns:
df[col] = df[col].fillna('').astype(str)
# 轉換為 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', {})
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
# V-Fix (2026-01-23): 使用 or 確保不會得到 None 值
col_name = cols_map.get('name') or '商品名稱'
col_brand = cols_map.get('brand') or '品牌'
col_vendor = cols_map.get('vendor') or '廠商名稱'
col_category = cols_map.get('category') or '商品館'
col_amount = cols_map.get('amount') or '總業績'
col_qty = cols_map.get('qty') or '數量'
col_cost = cols_map.get('cost') or '總成本'
col_profit = cols_map.get('profit') # 可以為 None
col_activity = cols_map.get('activity') or '活動名稱'
col_payment = cols_map.get('payment') or '付款方式'
# 建立日期篩選條件
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':
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API)
pandas_dow = int(dow_filter)
if DATABASE_TYPE == 'postgresql':
pg_dow = (pandas_dow + 1) % 7
additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""")
else:
sqlite_dow = str((pandas_dow + 1) % 7)
additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""")
if hour_filter and hour_filter != 'all':
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API)
hour_val = int(hour_filter)
if DATABASE_TYPE == 'postgresql':
additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""")
else:
additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""")
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(sql_query, db.engine)
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) =================
@app.route('/api/sales_analysis/yoy_comparison')
def yoy_comparison():
"""
API: 年度對比分析 (YoY Comparison)
參數:
year1: 基準年 (例如 2024)
year2: 對比年 (例如 2025)
month: 月份 (可選,1-12,不帶則為全年)
metric: 指標 (revenue/qty/profit)
回傳:
JSON with year1 total, year2 total, growth rate, and monthly breakdown
"""
try:
from datetime import datetime, timedelta, timezone
TAIPEI_TZ = timezone(timedelta(hours=8))
table_name = 'realtime_sales_monthly'
year1 = request.args.get('year1', '2024')
year2 = request.args.get('year2', '2025')
month = request.args.get('month', '') # 可選,1-12
metric = request.args.get('metric', 'revenue') # revenue/qty/profit
db = DatabaseManager()
# 欄位名稱
col_amount = '總業績'
col_qty = '數量'
col_cost = '總成本'
col_date = '日期'
# 根據指標決定聚合欄位
if metric == 'qty':
agg_sql = f'SUM(CAST("{col_qty}" AS REAL))'
metric_label = '銷售數量'
elif metric == 'profit':
agg_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))'
metric_label = '毛利金額'
else: # revenue
agg_sql = f'SUM(CAST("{col_amount}" AS REAL))'
metric_label = '銷售金額'
# 建立年度篩選條件
# 日期格式為 2025/01/01 或 2025-01-01
def build_year_filter(year, month_filter=''):
if month_filter:
month_str = month_filter.zfill(2)
return f"""("{col_date}" LIKE '{year}/{month_str}%' OR "{col_date}" LIKE '{year}-{month_str}%')"""
else:
return f"""("{col_date}" LIKE '{year}/%' OR "{col_date}" LIKE '{year}-%')"""
year1_filter = build_year_filter(year1, month)
year2_filter = build_year_filter(year2, month)
# 查詢年度總計
sql_year1 = f"""
SELECT {agg_sql} as total
FROM {table_name}
WHERE {year1_filter}
"""
sql_year2 = f"""
SELECT {agg_sql} as total
FROM {table_name}
WHERE {year2_filter}
"""
# V-Fix: SQLAlchemy 2.0 需要使用 text() 包裹 SQL 字串
from sqlalchemy import text
result_year1 = pd.read_sql(text(sql_year1), db.engine)
result_year2 = pd.read_sql(text(sql_year2), db.engine)
total_year1 = float(result_year1['total'].iloc[0] or 0)
total_year2 = float(result_year2['total'].iloc[0] or 0)
# 計算成長率
if total_year1 > 0:
growth_rate = ((total_year2 - total_year1) / total_year1) * 100
else:
growth_rate = 0 if total_year2 == 0 else 100
# 月度明細 (如果沒有指定月份,則查詢 12 個月的明細)
monthly_breakdown = []
if not month:
for m in range(1, 13):
m_str = str(m).zfill(2)
y1_filter = build_year_filter(year1, m_str)
y2_filter = build_year_filter(year2, m_str)
sql_m1 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y1_filter}"
sql_m2 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y2_filter}"
# V-Fix: SQLAlchemy 2.0 需要使用 text()
r1 = pd.read_sql(text(sql_m1), db.engine)
r2 = pd.read_sql(text(sql_m2), db.engine)
v1 = float(r1['total'].iloc[0] or 0)
v2 = float(r2['total'].iloc[0] or 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
}
sys_log.info(f"[YoY] {year1} vs {year2}: {total_year1:,.0f} -> {total_year2:,.0f} ({growth_rate:+.1f}%)")
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:
from sqlalchemy import text as sa_text
db = DatabaseManager()
table_name = 'realtime_sales_monthly'
# 1. 檢查資料表
inspector = inspect(db.engine)
if table_name not in inspector.get_table_names():
return f"尚未匯入業績資料 ({table_name})", 404
# 2. SQL 月度聚合(避免全量 748k 行讀進 pandas)
monthly_sql = sa_text("""
SELECT
date_trunc('month', TO_DATE("日期", 'YYYY/MM/DD'))::date AS month,
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS amount,
SUM(COALESCE(NULLIF("總成本", '')::numeric, 0)) AS cost,
COUNT(DISTINCT "訂單編號") AS orders
FROM realtime_sales_monthly
WHERE "日期" IS NOT NULL AND length("日期") >= 8
GROUP BY 1
ORDER BY 1
""")
ytd_sql = sa_text("""
SELECT
EXTRACT(YEAR FROM TO_DATE("日期", 'YYYY/MM/DD'))::int AS yr,
EXTRACT(DOY FROM TO_DATE("日期", 'YYYY/MM/DD'))::int AS doy,
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS amount
FROM realtime_sales_monthly
WHERE "日期" IS NOT NULL AND length("日期") >= 8
AND EXTRACT(YEAR FROM TO_DATE("日期", 'YYYY/MM/DD'))
>= EXTRACT(YEAR FROM CURRENT_DATE) - 1
GROUP BY 1, 2
ORDER BY 1, 2
""")
recent_sql = sa_text("""
SELECT
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS revenue,
COUNT(DISTINCT "訂單編號") AS orders
FROM realtime_sales_monthly
WHERE "日期" IS NOT NULL AND length("日期") >= 8
AND TO_DATE("日期", 'YYYY/MM/DD') >= CURRENT_DATE - INTERVAL '30 days'
""")
with db.engine.connect() as conn:
monthly_df = pd.read_sql(monthly_sql, conn)
if monthly_df.empty:
return f"資料表 {table_name} 為空", 404
ytd_df = pd.read_sql(ytd_sql, conn)
recent_r = conn.execute(recent_sql).fetchone()
# 3. 月度指標計算
monthly_df['month'] = pd.to_datetime(monthly_df['month'])
monthly_df = monthly_df.set_index('month')
monthly_df['profit'] = monthly_df['amount'] - monthly_df['cost']
monthly_df['aov'] = monthly_df['amount'] / monthly_df['orders'].replace(0, pd.NA)
monthly_df['margin_rate'] = (monthly_df['profit'] / monthly_df['amount'].replace(0, pd.NA)) * 100
monthly_df['mom'] = monthly_df['amount'].pct_change() * 100
monthly_df['yoy'] = monthly_df['amount'].pct_change(periods=12) * 100
monthly_df = monthly_df.fillna(0)
labels = monthly_df.index.strftime('%Y-%m').tolist()
chart_data = {
'labels': labels,
'revenue': monthly_df['amount'].tolist(),
'profit': monthly_df['profit'].tolist(),
'orders': monthly_df['orders'].tolist(),
'aov': monthly_df['aov'].round(0).tolist(),
'mom': monthly_df['mom'].round(2).tolist(),
'yoy': monthly_df['yoy'].round(2).tolist(),
'margin_rate': monthly_df['margin_rate'].round(1).tolist(),
}
# 4. KPI: YTD
current_year = int(pd.Timestamp.now().year)
last_year = current_year - 1
max_doy = ytd_df[ytd_df['yr'] == current_year]['doy'].max() if not ytd_df.empty else 365
ytd_revenue = float(ytd_df[ytd_df['yr'] == current_year]['amount'].sum())
last_ytd_revenue = float(ytd_df[(ytd_df['yr'] == last_year) & (ytd_df['doy'] <= max_doy)]['amount'].sum())
ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue * 100) if last_ytd_revenue > 0 else 0
recent_revenue = float(recent_r.revenue or 0) if recent_r else 0
recent_orders = int(recent_r.orders or 0) if recent_r else 0
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': int(monthly_df['orders'].sum()),
}
return render_template('growth_analysis.html', chart_data=chart_data, kpi=kpi)
except Exception as e:
sys_log.error(f"Growth Analysis Error: {e}")
return f"系統錯誤: {e}"
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 分鐘執行網頁白頁監控任務")
schedule.every(4).hours.do(run_competitor_price_feeder_task)
sys_log.info(f"📅 已設定每 4 小時執行 PChome 競品價格抓取任務")
# 啟動排程執行緒
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()
if __name__ == "__main__":
banner = f" MOMO 專業數據管理系統 {SYSTEM_VERSION} "
sys_log.info(f"{ '='*20} {banner} {'='*20}")
# 啟動前先檢查資料庫結構
repair_database_schema()
# 使用生產環境域名
public_url = "https://mo.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}")