Files
ewoooc/app.py
OoO 1f88c2817b refactor(routes): 刪除 app.py import monthly 重複路由
ADR-017 Phase 3f-1 import/monthly sprint
2026-04-29 21:06:00 +08:00

4800 lines
227 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ================= TODO LIST (待辦事項 - 重開機後請依序執行) =================
# 1. [驗證] 重啟 app.py 後,重新匯入 Excel確認「自動去重」功能是否生效 (重複匯入應顯示 0 筆新增)。
# 2. [檢查] 前往 /sales_analysis 頁面,確認 '狀態' 欄位是否正確顯示 'F' (目前為原始匯入模式)。
# 3. [決策] 若資料顯示正常,評估是否需要恢復「智慧資料清理」邏輯 (目前程式碼第 1160 行左右已註解)。
# 4. [備份] 確認系統運作正常後,執行系統備份。
# =======================================================================
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('/')
def index():
db = DatabaseManager()
session = db.get_session()
page = request.args.get('page', 1, type=int)
category_filter = request.args.get('category', 'all')
sort_by = request.args.get('sort_by', 'timestamp') # 預設按時間排序
filter_type = request.args.get('filter', 'all') # 🚩 新增:狀態篩選 (increase, decrease, delisted)
order = request.args.get('order', 'desc')
search_query = request.args.get('q', '').strip() # 🚩 新增:搜尋關鍵字
per_page = 50
# 🚩 取得台北時間的今日起始點 (用於資料庫查詢比較)
# 注意:若資料庫內存的是 naive time (無時區),則需轉為 naive 進行比較
now_taipei = datetime.now(TAIPEI_TZ)
today_start_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
try:
# 🚩 1. 使用封裝函式獲取數據
unique_items, today_start = get_consolidated_data()
# --- 計算今日漲跌統計 ---
increase_items = [item for item in unique_items if item['yesterday_diff'] > 0]
decrease_items = [item for item in unique_items if item['yesterday_diff'] < 0]
# --- V-New: 取得所有分類並加上筆數統計 ---
cat_counts = {}
for item in unique_items:
c = item['record'].product.category
if c:
cat_counts[c] = cat_counts.get(c, 0) + 1
all_categories = [f"{cat} ({count}筆)" for cat, count in sorted(cat_counts.items())]
# V-Fix: 預先計算今日新增的商品 ID (不依賴 Product.created_at)
new_product_ids = set()
try:
# 找出最早一筆價格紀錄是在今天的商品
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()}
except Exception: pass
# --- 看板統計數據 ---
total_products_history = session.query(Product).count()
today_new_products = session.query(func.count(Product.id)).filter(
Product.id.in_(
session.query(PriceRecord.product_id)
.group_by(PriceRecord.product_id)
.having(func.min(PriceRecord.timestamp) >= today_start_db)
)
).scalar()
total_price_records = session.query(PriceRecord).count()
today_updates = session.query(PriceRecord).filter(PriceRecord.timestamp >= today_start_db).count()
# 🚩 新增:今日下架商品統計 (狀態為 INACTIVE 且 最後更新時間 >= 今天零點)
today_delisted_query = session.query(Product).filter(
Product.status == 'INACTIVE',
Product.updated_at >= today_start_db
)
raw_delisted_items = today_delisted_query.all()
today_delisted_count = len(raw_delisted_items)
# 🚩 V-Opt: 為下架商品補上最後價格(優化:一次查詢取得所有價格,避免 N+1 問題)
today_delisted_items = []
if raw_delisted_items:
# 取得所有下架商品的 ID
delisted_ids = [p.id for p in raw_delisted_items]
# 一次性查詢所有下架商品的最後價格
last_prices_subq = session.query(
PriceRecord.product_id,
func.max(PriceRecord.id).label('max_id')
).filter(
PriceRecord.product_id.in_(delisted_ids)
).group_by(PriceRecord.product_id).subquery()
last_prices_q = session.query(
PriceRecord.product_id,
PriceRecord.price
).join(
last_prices_subq,
PriceRecord.id == last_prices_subq.c.max_id
)
# 建立 product_id -> price 的映射
price_map = {pid: price for pid, price in last_prices_q}
# 組合結果
for p in raw_delisted_items:
price = price_map.get(p.id, 0)
today_delisted_items.append({'product': p, 'last_price': price})
# ========== V9.2: 新增 KPI 計算 ==========
# 1. 平均漲跌幅
avg_increase = sum(item['yesterday_diff'] for item in increase_items) / len(increase_items) if increase_items else 0
avg_decrease = sum(item['yesterday_diff'] for item in decrease_items) / len(decrease_items) if decrease_items else 0
# 2. 今日活躍度(有價格變動的商品百分比)
active_count = len(increase_items) + len(decrease_items)
activity_rate = (active_count / total_products_history * 100) if total_products_history > 0 else 0
# 3. 最大變動(絕對值最大的價格變動)
max_change_item = None
max_change_value = 0
for item in unique_items:
if abs(item['yesterday_diff']) > abs(max_change_value):
max_change_value = item['yesterday_diff']
max_change_item = item
# 4. 週增長 (過去 7 天新增的商品數)
week_ago_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=7)
week_ago_db = week_ago_db.replace(tzinfo=None)
week_new_products = session.query(func.count(Product.id)).filter(
Product.id.in_(
session.query(PriceRecord.product_id)
.group_by(PriceRecord.product_id)
.having(func.min(PriceRecord.timestamp) >= week_ago_db)
)
).scalar() or 0
# 5. 價格穩定商品數7 天內無變價)- V9.3 效能優化版
seven_days_ago = now_taipei - timedelta(days=7)
seven_days_ago_db = seven_days_ago.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
# 使用 GROUP BY 一次性統計所有商品的不同價格數量(避免 N+1 查詢)
try:
stable_count = session.query(PriceRecord.product_id).filter(
PriceRecord.timestamp >= seven_days_ago_db
).group_by(PriceRecord.product_id).having(
func.count(func.distinct(PriceRecord.price)) == 1
).count()
except Exception:
stable_count = 0
# 6. 最活躍分類(今日變動商品數最多的分類)
category_activity = {}
for item in increase_items + decrease_items:
cat = item['record'].product.category
if cat:
category_activity[cat] = category_activity.get(cat, 0) + 1
most_active_category = None
most_active_count = 0
if category_activity:
most_active_category = max(category_activity.items(), key=lambda x: x[1])
most_active_count = most_active_category[1]
most_active_category = most_active_category[0]
# 🚩 讀取系統狀態 (用於紅綠燈顯示)
system_status = {"status": "UNKNOWN", "message": "尚無執行紀錄", "timestamp": "-"}
status_path = os.path.join(BASE_DIR, 'data/system_status.json')
if os.path.exists(status_path):
try:
with open(status_path, 'r', encoding='utf-8') as f:
system_status = json.load(f)
except: pass
# --- 取得所有分類用於篩選器 ---
# (已在上方取得)
# 🚩 2. 後端篩選 (Server-side Filtering)
scheduler_stats = load_scheduler_stats()
# V-Fix: Handle old scheduler stats format (dict) by converting to list to prevent template errors
if scheduler_stats.get('momo_task') and isinstance(scheduler_stats.get('momo_task'), dict):
scheduler_stats['momo_task'] = [scheduler_stats['momo_task']]
if scheduler_stats.get('edm_task') and isinstance(scheduler_stats.get('edm_task'), dict):
scheduler_stats['edm_task'] = [scheduler_stats['edm_task']]
filtered_items = []
# 0. 先處理搜尋 (若有)
if search_query:
search_lower = search_query.lower()
# V9.81: 搜尋功能修復,支援搜尋商品名稱與 i_code
base_items = [
item for item in unique_items
if (item['record'].product.name and search_lower in item['record'].product.name.lower()) or
(item['record'].product.i_code and search_lower in str(item['record'].product.i_code))
]
else:
base_items = unique_items
# A. 先處理狀態篩選 (漲/跌/下架)
if filter_type == 'increase':
filtered_items = [i for i in base_items if i in increase_items]
elif filter_type == 'decrease':
filtered_items = [i for i in base_items if i in decrease_items]
elif filter_type == 'new':
# V-New: 新上架篩選 (今日新增的商品)
filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids]
elif filter_type == 'delisted':
# 特殊處理:將下架商品轉換為列表格式以便顯示
for item in today_delisted_items:
# 模擬 record 物件結構
class MockRecord:
def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at
if not search_query or search_query.lower() in item['product'].name.lower():
filtered_items.append({
'record': MockRecord(item['product'], item['last_price']),
'stats': {'1d_diff': 0, '7d_diff': 0, '30d_diff': 0}, # 模擬 stats 結構
'yesterday_diff': 0,
'today_changes': [], # 確保結構一致
'status': 'DELISTED' # 新增狀態
})
else:
# B. 若無狀態篩選,則處理分類篩選
if category_filter != 'all':
# V-New: 處理帶有筆數的分類名稱,例如 "化妝水 (50筆)" -> "化妝水"
real_category = category_filter
if "(" in category_filter and "筆)" in category_filter:
real_category = category_filter.rsplit(" (", 1)[0]
filtered_items = [item for item in base_items if item['record'].product.category == real_category]
else:
filtered_items = base_items
# 🚩 3. 後端排序 (Server-side Sorting)
reverse = (order == 'desc')
def get_sort_key(item):
# 處理 None 值,確保排序時不會出錯
def safe_get(value, default=0):
return default if value is None else value
if sort_by == 'i_code': return int(safe_get(item['record'].product.i_code, 0))
if sort_by == 'category': return safe_get(item['record'].product.category, '')
if sort_by == 'name': return safe_get(item['record'].product.name, '')
if sort_by == 'price': return safe_get(item['record'].price, 0)
if sort_by == 'today_change': return safe_get(item['stats']['1d_diff'], 0) # 今日內波動
if sort_by == 'yesterday_change': return safe_get(item['yesterday_diff'], 0)
if sort_by == 'week_change': return safe_get(item['stats']['7d_diff'], 0)
return item['record'].timestamp # 預設
sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse)
# 🚩 4. 分頁 (Pagination) - 在篩選和排序之後執行
total_items = len(sorted_items)
total_pages = math.ceil(total_items / per_page)
start_idx = (page - 1) * per_page
paged_items = sorted_items[start_idx : start_idx + per_page]
# V-Fix: 為前端準備安全的 created_at 屬性
for item in paged_items:
item['safe_created_at'] = getattr(item['record'].product, 'created_at', None)
# 🚩 5. 為當前頁面項目添加顏色
for item in paged_items:
category_name = item['record'].product.category
item['category_color'] = get_color_for_string(category_name)
return render_template('dashboard.html',
total_products=total_products_history,
today_new_products=today_new_products,
total_price_records=total_price_records,
cnt_increase=len(increase_items),
cnt_decrease=len(decrease_items), # 傳遞跌價數
today_delisted_count=today_delisted_count,
today_delisted_items=today_delisted_items,
system_status=system_status,
items=paged_items,
categories=all_categories,
current_page=page,
total_pages=total_pages, # V-New: 傳遞總項目數
total_items=total_items,
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), # 顯示台北時間
today_date=now_taipei.strftime('%Y-%m-%d'), # 傳遞今日日期
public_url=public_url,
current_category=category_filter,
current_filter=filter_type, # 傳遞當前篩選狀態
search_query=search_query, # 傳遞搜尋關鍵字
current_sort=sort_by,
current_order=order,
scheduler_stats=scheduler_stats,
# V9.2: 新增 KPI 數據
avg_increase=avg_increase,
avg_decrease=avg_decrease,
activity_rate=activity_rate,
active_count=active_count,
max_change_item=max_change_item,
max_change_value=max_change_value,
week_new_products=week_new_products,
stable_count=stable_count,
most_active_category=most_active_category,
most_active_count=most_active_count)
except Exception as e:
sys_log.error(f"[Web] [Dashboard] 🚨 渲染錯誤 | Error: {e}")
return f"系統維護中,錯誤詳情:{e}"
finally:
session.close()
@app.route('/settings')
def settings():
"""分類設定頁面"""
categories = load_categories()
return render_template('settings.html',
categories=categories,
public_url=public_url,
system_version=SYSTEM_VERSION)
@app.route('/system_settings')
def system_settings_page():
"""系統設定與匯入頁面"""
return render_template('system_settings.html', system_version=SYSTEM_VERSION)
@app.route('/edm')
def edm_dashboard():
"""🚩 新增MOMO 限時搶購 (EDM) 專屬儀表板"""
db = DatabaseManager()
session = db.get_session()
# V-New: 排序參數
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
try:
# 1. 基礎統計
# 取得最後更新時間
last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料"
# 🚩 V9.29 新增:取得最新的活動時間文字
latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
activity_time = getattr(latest_entry, 'activity_time_text', '限時搶購') if latest_entry else "限時搶購"
# 2. 查詢資料 (V9.44: 只顯示最新批次的資料)
# 找出最新的 batch_id
latest_batch = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
current_batch_id = latest_batch[0] if latest_batch else None
# 🚩 V9.55 修正:改為查詢「全商品的最新狀態快照」,而非僅查詢最新批次
# 因為 Scheduler 現在只記錄異動,若只查最新批次,未變動的商品會消失
subq = session.query(
func.max(PromoProduct.id).label('max_id')
).filter(PromoProduct.page_type == 'edm').group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery()
latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all()
# 過濾顯示列表:顯示「上架中」、「本批次剛下架」或「今日結束時段」的商品
items_in_batch = []
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
for item in latest_records:
# V9.60: 隱藏自然結束的時段商品
# V-New: 如果商品是今天才結束的,則依然顯示在儀表板上,方便回查
# V-Fix: 確保時區比較一致
item_crawled_at = item.crawled_at
if item_crawled_at and item_crawled_at.tzinfo is None:
# V-Fix: 使用 replace 而非 localize (datetime.timezone 不支援 localize 方法)
item_crawled_at = item_crawled_at.replace(tzinfo=TAIPEI_TZ)
if item.status_change == 'SLOT_END' and item_crawled_at < today_start:
continue
# V-New: 如果是下架狀態,只有當它是「今天」下架的才顯示
if item.status_change == 'DELISTED' and item_crawled_at < today_start:
continue
items_in_batch.append(item)
# V9.45: 按時段分組
grouped_items = {}
for item in items_in_batch:
if item.time_slot not in grouped_items:
grouped_items[item.time_slot] = []
grouped_items[item.time_slot].append(item)
# 按時段鍵值排序 (e.g., 00:00, 07:00, ...)
sorted_grouped_items = dict(sorted(grouped_items.items()))
# V9.45: 決定預設顯示的頁籤
def get_current_time_slot():
hour = datetime.now(TAIPEI_TZ).hour
available_slots = sorted([int(s.split(':')[0]) for s in sorted_grouped_items.keys() if s and ':' in s]) if sorted_grouped_items else [0, 7, 11, 14, 18, 22]
current_slot_hour = 0
for s in available_slots:
if hour >= s:
current_slot_hour = s
return f"{current_slot_hour:02d}:00"
active_tab = get_current_time_slot()
if active_tab not in sorted_grouped_items and sorted_grouped_items:
active_tab = next(iter(sorted_grouped_items))
# V-New: 計算在架天數與總銷量
all_icodes_in_batch = [item.i_code for item in items_in_batch]
product_categories = {}
days_on_shelf_map = {}
total_sold_map = {}
if all_icodes_in_batch:
# 從主商品表 (products) 查詢這些 i_code 對應的分類
main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all()
product_categories = {p.i_code: p.category for p in main_products}
# 計算上架天數 (days_on_shelf)
# V-Fix: 使用 CAST 轉換為 DATE兼容 PostgreSQL 和 SQLite
from sqlalchemy import cast, Date
days_on_shelf_q = session.query(
PromoProduct.i_code,
func.count(func.distinct(cast(PromoProduct.crawled_at, Date)))
).filter( # V-New: 增加 page_type 過濾
PromoProduct.i_code.in_(all_icodes_in_batch),
PromoProduct.page_type == 'edm'
).group_by(PromoProduct.i_code).all()
days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q}
# 計算總銷量
# 1. 找出每個商品第一次有庫存紀錄的 ID
first_qty_subq = session.query(
PromoProduct.i_code,
func.min(PromoProduct.id).label('min_id')
).filter(
PromoProduct.i_code.in_(all_icodes_in_batch),
PromoProduct.remain_qty.isnot(None),
PromoProduct.page_type == 'edm'
).group_by(PromoProduct.i_code).subquery()
# 2. 根據 ID 取得當時的庫存
first_qty_records = session.query(
PromoProduct.i_code, PromoProduct.remain_qty
).join(first_qty_subq, PromoProduct.id == first_qty_subq.c.min_id).all()
first_qty_map = {r[0]: r[1] for r in first_qty_records}
# 3. 計算總銷量 (初始庫存 - 當前庫存)
for item in items_in_batch:
# 確保該商品有初始庫存紀錄,且當前庫存也存在
if item.i_code in first_qty_map and item.remain_qty is not None:
initial_qty = first_qty_map[item.i_code]
current_qty = item.remain_qty
# 只有在初始庫存大於當前庫存時才計算,避免負數
if initial_qty > current_qty:
total_sold_map[item.i_code] = initial_qty - current_qty
# V-Fix: 修正 NameError: name 'history_map' is not defined
# 準備銷售歷程資料
history_map = {}
if all_icodes_in_batch:
all_history_records = session.query(
PromoProduct.i_code,
PromoProduct.time_slot,
PromoProduct.remain_qty,
PromoProduct.crawled_at
).filter(
PromoProduct.i_code.in_(all_icodes_in_batch),
PromoProduct.crawled_at >= today_start
).order_by(PromoProduct.crawled_at).all()
for rec in all_history_records:
key = (rec.i_code, rec.time_slot)
if key not in history_map:
history_map[key] = []
if rec.remain_qty is not None:
if not history_map[key] or (history_map[key] and history_map[key][-1]['qty'] != rec.remain_qty):
history_map[key].append({'time': rec.crawled_at.strftime('%H:%M'), 'qty': rec.remain_qty})
# 將查到的分類資訊附加到每個 item 物件上
for item in items_in_batch:
item.main_category = product_categories.get(item.i_code)
if item.main_category:
item.category_color = get_color_for_string(item.main_category)
# V-New: 附加在架天數與總銷量
item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1)
item.total_sold = total_sold_map.get(item.i_code, 0)
# V-New: Attach quantity history
item.qty_history = history_map.get((item.i_code, item.time_slot), [])
# V9.46: 排序邏輯優化 (中文註解)
# 排序規則:
# 1. 有貼標 (main_category 存在) 的商品優先
# 2. 有狀態變更 (NEW, 漲價, 降價) 的商品次之
# 3. 已下架的商品再次之
# 4. 最後按價格由高到低排序
reverse = (order == 'desc')
for time_slot in sorted_grouped_items:
if sort_by == 'name':
sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse)
elif sort_by == 'remain_qty':
# 將 None 視為 -1確保排序時在最下方
sorted_grouped_items[time_slot].sort(key=lambda x: x.remain_qty if x.remain_qty is not None else -1, reverse=reverse)
elif sort_by == 'price':
sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse)
else: # 預設排序
sorted_grouped_items[time_slot].sort(key=lambda x: (
1 if x.main_category else 0,
2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0),
x.price if x.price is not None else -1
), reverse=True)
# 🚩 V-Fix: 修正時段統計,使其能區分「當前狀態」與「上次異動」
# V-New: 重構時段統計邏輯,確保統計所有今日異動
slot_stats = {}
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
# 1. 取得今日所有異動紀錄
today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == 'edm').all()
# 2. 取得所有相關時段的鍵 (今日異動的 + 當前在架的)
slots_from_changes = {rec.time_slot for rec in today_change_records}
slots_from_display = set(sorted_grouped_items.keys())
all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display)))
# 3. 初始化所有相關時段的統計數據
for slot in all_relevant_slots:
slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0}
# 4. 累加新品、漲價、降價、下架的數量 (從今日歷史紀錄)
for rec in today_change_records:
if rec.time_slot in slot_stats:
if rec.status_change == 'NEW':
slot_stats[rec.time_slot]['new'] += 1
elif rec.status_change == 'PRICE_UP':
slot_stats[rec.time_slot]['up'] += 1
elif rec.status_change == 'PRICE_DOWN':
slot_stats[rec.time_slot]['down'] += 1
elif rec.status_change in ['DELISTED', 'SLOT_END']:
slot_stats[rec.time_slot]['delisted_last_run'] += 1
# 5. 計算在架與下架總數 (從當前顯示的商品快照)
for slot, items in sorted_grouped_items.items():
if slot in slot_stats:
on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END'])
delisted_total_count = len(items) - on_shelf_count
slot_stats[slot]['on_shelf'] = on_shelf_count
slot_stats[slot]['delisted_total'] = delisted_total_count
# V-New: 建立儀表板頁籤
promo_pages = [
{'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
{'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}
]
scheduler_stats = load_scheduler_stats()
return render_template('edm_dashboard.html',
promo_pages=promo_pages,
current_promo_page='edm',
page_title='MOMO 限時搶購',
grouped_items=sorted_grouped_items,
slot_stats=slot_stats,
total_edm_products=len(items_in_batch),
last_update=last_update_str,
activity_time=activity_time,
active_tab=active_tab,
current_batch_id=current_batch_id,
public_url=public_url,
scheduler_stats=scheduler_stats,
current_sort=sort_by,
current_order=order,
slugify=slugify)
except Exception as e:
sys_log.error(f"🚨 EDM Dashboard 渲染錯誤: {e}")
return f"系統錯誤: {e}"
finally:
session.close()
@app.route('/festival')
def festival_dashboard():
"""🚩 新增1.1 狂歡購物節專屬儀表板"""
db = DatabaseManager()
session = db.get_session()
PAGE_TYPE = "festival"
PAGE_NAME = "1.1狂歡購物節"
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
try:
# 1. 基礎統計
last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first()
last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料"
latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first()
activity_time = getattr(latest_entry, 'activity_time_text', PAGE_NAME) if latest_entry else PAGE_NAME
# 2. 查詢資料
subq = session.query(
func.max(PromoProduct.id).label('max_id')
).filter(PromoProduct.page_type == PAGE_TYPE).group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery()
latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all()
items_in_batch = []
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
for item in latest_records:
if item.status_change == 'SLOT_END' and item.crawled_at < today_start:
continue
if item.status_change == 'DELISTED' and item.crawled_at < today_start:
continue
items_in_batch.append(item)
# 此頁面使用區塊標題作為分組依據
grouped_items = {}
for item in items_in_batch:
if item.time_slot not in grouped_items:
grouped_items[item.time_slot] = []
grouped_items[item.time_slot].append(item)
sorted_grouped_items = dict(sorted(grouped_items.items()))
# 預設顯示第一個頁籤
active_tab = next(iter(sorted_grouped_items)) if sorted_grouped_items else ""
all_icodes_in_batch = [item.i_code for item in items_in_batch]
product_categories = {}
days_on_shelf_map = {}
total_sold_map = {}
if all_icodes_in_batch:
main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all()
product_categories = {p.i_code: p.category for p in main_products}
# V-Fix: 使用 CAST 轉換為 DATE兼容 PostgreSQL 和 SQLite
from sqlalchemy import cast, Date
days_on_shelf_q = session.query(
PromoProduct.i_code,
func.count(func.distinct(cast(PromoProduct.crawled_at, Date)))
).filter(
PromoProduct.i_code.in_(all_icodes_in_batch),
PromoProduct.page_type == PAGE_TYPE
).group_by(PromoProduct.i_code).all()
days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q}
# 將查到的分類資訊附加到每個 item 物件上
for item in items_in_batch:
item.main_category = product_categories.get(item.i_code)
if item.main_category:
item.category_color = get_color_for_string(item.main_category)
item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1)
# V-Fix: 為 festival 頁面提供預設值,避免共用模板在渲染 total_sold 和 qty_history 時出錯
item.total_sold = 0
item.qty_history = []
# 排序邏輯
reverse = (order == 'desc')
for time_slot in sorted_grouped_items:
if sort_by == 'name':
sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse)
elif sort_by == 'price':
sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse)
else: # 預設排序
sorted_grouped_items[time_slot].sort(key=lambda x: (
1 if x.main_category else 0,
2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0),
x.price if x.price is not None else -1
), reverse=True)
# 時段統計
slot_stats = {}
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == PAGE_TYPE).all()
slots_from_changes = {rec.time_slot for rec in today_change_records}
slots_from_display = set(sorted_grouped_items.keys())
all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display)))
for slot in all_relevant_slots:
slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0}
for rec in today_change_records:
if rec.time_slot in slot_stats:
if rec.status_change == 'NEW': slot_stats[rec.time_slot]['new'] += 1
elif rec.status_change == 'PRICE_UP': slot_stats[rec.time_slot]['up'] += 1
elif rec.status_change == 'PRICE_DOWN': slot_stats[rec.time_slot]['down'] += 1
elif rec.status_change in ['DELISTED', 'SLOT_END']: slot_stats[rec.time_slot]['delisted_last_run'] += 1
for slot, items in sorted_grouped_items.items():
if slot in slot_stats:
on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END'])
delisted_total_count = len(items) - on_shelf_count
slot_stats[slot]['on_shelf'] = on_shelf_count
slot_stats[slot]['delisted_total'] = delisted_total_count
scheduler_stats = load_scheduler_stats()
# 建立儀表板頁籤
promo_pages = [
{'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
{'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}
]
# 注意:這裡我們重複使用 edm_dashboard.html 範本
# 您需要建立一個它的複本,命名為 festival.html
return render_template('edm_dashboard.html',
promo_pages=promo_pages,
current_promo_page='festival',
page_title=PAGE_NAME,
grouped_items=sorted_grouped_items,
slot_stats=slot_stats,
total_edm_products=len(items_in_batch),
last_update=last_update_str,
activity_time=activity_time,
active_tab=active_tab,
public_url=public_url,
scheduler_stats=scheduler_stats,
current_sort=sort_by,
current_order=order,
slugify=slugify)
except Exception as e:
sys_log.error(f"🚨 {PAGE_NAME} Dashboard 渲染錯誤: {e}")
return f"系統錯誤: {e}"
finally:
session.close()
@app.route('/abc_analysis/detail')
def abc_analysis_detail():
"""ABC 分析詳細報表頁面"""
try:
target_class = request.args.get('class', 'A') # 預設 A 類
table_name = 'realtime_sales_monthly'
# 1. 生成與主頁面一致的 cache_key
data_range_months = int(request.args.get('data_range', '0') or '0')
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
if start_date or end_date:
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
else:
cache_key = f"{table_name}_{data_range_months}m"
# 2. 使用共用篩選函式取得資料
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
# V-Fix: 如果 cache_key 不存在,嘗試後補使用 table_name 固定鍵值
if err and table_name in _SALES_PROCESSED_CACHE:
target_df, cols_map, err = _get_filtered_sales_data(table_name)
if err:
# V-Fix: 如果自動重載也失敗,則顯示稍後再試,並引導回主頁面
return f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>數據加載中 - WOOO TECH</title>
<style>
body {{ font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f8f9fa; }}
.card {{ background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); text-align: center; }}
.spinner {{ border: 3px solid #f3f3f3; border-top: 3px solid #1e3c72; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 0 auto 1rem; }}
@keyframes spin {{ 0% {{ transform: rotate(0deg); }} 100% {{ transform: rotate(360deg); }} }}
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<h3>數據準備中</h3>
<p>正在自動重新加載數據,請稍後...</p>
<script>
// 1.5 秒後嘗試重載當前頁面
setTimeout(function() {{
window.location.reload();
}}, 1500);
// 若重試 3 次仍失敗,引導回主頁
let retryCount = parseInt(sessionStorage.getItem('abc_retry') || '0');
if (retryCount > 3) {{
sessionStorage.removeItem('abc_retry');
alert('數據載入過久,請先在業績分析主頁重新整理。');
window.location.href = '/sales_analysis';
}} else {{
sessionStorage.setItem('abc_retry', retryCount + 1);
}}
</script>
</div>
</body>
</html>
''', 200
# 恢復欄位變數
col_name = cols_map.get('name')
col_amount = cols_map.get('amount')
col_qty = cols_map.get('qty')
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_price = cols_map.get('price')
col_cost = cols_map.get('cost')
col_profit = cols_map.get('profit')
col_date = cols_map.get('date')
col_pid = cols_map.get('pid')
# 3. 執行 ABC 分類
items = []
total_revenue = 0
if col_amount and not target_df.empty:
# V-Fix: 先針對商品進行聚合,確保 ABC 分析是基於「商品總銷量」而非「單筆訂單」
agg_rules = {col_amount: 'sum'}
if col_qty: agg_rules[col_qty] = 'sum'
if col_cost: agg_rules[col_cost] = 'sum'
if col_profit: agg_rules[col_profit] = 'sum'
if col_category: agg_rules[col_category] = 'first'
if col_vendor: agg_rules[col_vendor] = 'first'
if col_brand: agg_rules[col_brand] = 'first' # V-New: 加入品牌
if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID
if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique()))
df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index()
# 重新計算聚合後的毛利率
if col_profit:
df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
elif col_cost:
df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
else:
df_agg['calculated_margin_rate'] = 0.0
df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
# 執行 ABC 排序與計算
df_agg = df_agg.sort_values(by=col_amount, ascending=False)
df_agg['cumulative_revenue'] = df_agg[col_amount].cumsum()
total_revenue = df_agg[col_amount].sum()
df_agg['cumulative_pct'] = (df_agg['cumulative_revenue'] / total_revenue) * 100
conditions = [(df_agg['cumulative_pct'] <= 80), (df_agg['cumulative_pct'] <= 95)]
choices = ['A', 'B']
df_agg['ABC_Class'] = np.select(conditions, choices, default='C')
# 4. 篩選特定類別
class_df = df_agg[df_agg['ABC_Class'] == target_class].copy()
# V-New: 計算平均單價與庫存建議
if col_qty:
class_df['avg_unit_price'] = (class_df[col_amount] / class_df[col_qty]).fillna(0)
# V-New: 處理動態補貨係數
custom_factor = request.args.get('factor')
current_factor = 0.0
if custom_factor:
try:
current_factor = float(custom_factor)
except:
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
else:
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
class_df['suggested_restock'] = (class_df[col_qty] * current_factor).astype(int)
items = class_df.to_dict('records')
# 準備標題與描述
class_info = {
'A': {'title': 'A 類 - 核心商品', 'desc': '營收佔比前 80% 的主力商品,建議重點備貨與監控。', 'color': 'danger'},
'B': {'title': 'B 類 - 次要商品', 'desc': '營收佔比 80%~95% 的輔助商品,維持正常庫存。', 'color': 'warning'},
'C': {'title': 'C 類 - 長尾商品', 'desc': '營收佔比最後 5% 的長尾商品,建議評估清倉或縮減 SKU。', 'color': 'success'}
}
info = class_info.get(target_class, {'title': f'{target_class}', 'desc': '', 'color': 'secondary'})
# 計算 DataTables 預設排序欄位 (銷售金額) 的索引
# 欄位順序: Rank(0), [PID], Name, [Brand], [Vendor], [Cat], [Margin], [AvgPrice, Qty, Restock], Amount
sort_col_index = 1 # Rank
if col_pid: sort_col_index += 1
sort_col_index += 1 # Name
if col_brand: sort_col_index += 1
if col_vendor: sort_col_index += 1
if col_category: sort_col_index += 1
if col_cost or col_profit: sort_col_index += 1
if col_qty: sort_col_index += 3
# 此時 sort_col_index 即為 Amount 欄位的索引
return render_template('abc_analysis_detail.html',
items=items,
info=info,
target_class=target_class,
current_factor=current_factor, # V-New: 傳遞當前係數
total_revenue=total_revenue,
sort_col_index=sort_col_index, # V-New: 傳遞排序欄位索引
cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category,
'vendor': col_vendor, 'brand': col_brand, 'cost': col_cost, 'profit': col_profit, 'date': col_date, 'pid': col_pid},
# 傳遞當前查詢參數以供匯出連結使用
query_string=request.query_string.decode())
except Exception as e:
sys_log.error(f"ABC Detail Error: {e}")
return f"系統錯誤: {e}"
@app.route('/logs')
def show_logs():
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/<path:filename>')
@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_1mmonths=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}")