Files
ewoooc/app.py
OoO 8fefea05da
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
fix(daily_sales): 啟用 bp 版改進邏輯 + import 後跨 worker 清 cache,根除 #24 隱形 bug
- 從 app.py 刪除 396 行的 /daily_sales、/daily_sales/export、/daily_sales/export_marketing
  三條 @app.route(行 5911-6306),讓 routes/daily_sales_routes.py 的 daily_sales_bp
  生效(first-registered wins,原 app.py 版本 shadow 了 bp)。
- bp 版改進點:_is_cache_valid() 帶 5 分鐘 TTL、/api/daily_sales/clear_cache 端點、
  完整模板參數(datetime_now / active_page)。
- services/import_service.py process_daily_sales_import return True 前,
  新增跨 gunicorn worker 清 daily_sales cache 邏輯:依 GUNICORN_WORKERS 次數呼叫
  internal /api/daily_sales/clear_cache,避免 4 worker 各持 5 分鐘舊快取
  導致「匯入 15323 筆但當日業績看不到」隱形 bug。

[P7-COMPLETION]
- 方案正確: 雙重佐證(refactor-specialist + web-researcher)確認 Flask first-registered
  wins,刪 app.py 內 route 即可讓 bp 接管;helper 函式(preprocess_daily_sales_data 等)
  為 dead code 但保守保留不影響執行。
- 影響完整: 全 repo grep 確認 _SALES_PROCESSED_CACHE 在 app.py 仍有 30+ 處使用
  (sales_analysis 等其他路由),未動到;helper 函式無外部 caller。
- Regression 風險: 低,bp 版簽名與行為相容;新 cache 清除走 internal HTTP 帶 try/except
  不影響主流程;若 GUNICORN_WORKERS 未設則默認 4 與生產一致。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:18:17 +08:00

6591 lines
318 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 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))
# ==========================================
# 🔧 全域模板變數注入 (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('/api/export/all_categories')
def export_all_categories():
"""🚩 需求 A處理全分類報表匯出請求"""
try:
sys_log.info("📊 執行全分類 CSV 數據導出...")
# 1. 獲取與看板一致的整合數據
items, _ = get_consolidated_data()
# 2. 呼叫匯出服務
exporter = Exporter()
file_path = exporter.generate_all_categories_report() # 此函式內部已處理按分類分 Sheet
if file_path:
# 🚩 強制轉為絕對路徑,解決 CWD 與 Flask Root Path 不一致導致的 404 問題
abs_file_path = os.path.abspath(file_path)
if os.path.exists(abs_file_path):
sys_log.info(f"✅ 報表匯出成功,準備下載: {abs_file_path}")
return send_file(abs_file_path, as_attachment=True)
return "匯出失敗:資料庫內尚無足夠數據", 404
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ 全分類報表匯出異常 | Error: {e}")
return f"匯出失敗,錯誤詳情:{e}", 500
# 🚩 V9.90: 新增 Excel 匯出路由
@app.route('/api/export/excel/all')
def export_excel_all():
try:
items, _ = get_consolidated_data()
exporter = Exporter()
file_path = exporter.generate_all_products_excel(items)
if file_path and os.path.exists(file_path):
return send_file(file_path, as_attachment=True)
return "匯出失敗", 500
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (All) | Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/excel/changes')
def export_excel_changes():
try:
items, _ = get_consolidated_data()
increase = [i for i in items if i['yesterday_diff'] > 0]
decrease = [i for i in items if i['yesterday_diff'] < 0]
exporter = Exporter()
file_path = exporter.generate_changes_excel(increase, decrease)
if file_path and os.path.exists(file_path):
return send_file(file_path, as_attachment=True)
return "匯出失敗", 500
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Changes) | Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/excel/delisted')
def export_excel_delisted():
db = DatabaseManager()
session = db.get_session()
try:
_, today_start = get_consolidated_data()
today_delisted_query = session.query(Product).filter(
Product.status == 'INACTIVE',
Product.updated_at >= today_start.replace(tzinfo=None)
)
raw_items = today_delisted_query.all()
delisted_items = [{'product': p, 'last_price': (session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first().price if session.query(PriceRecord).filter_by(product_id=p.id).first() else 0)} for p in raw_items]
exporter = Exporter()
file_path = exporter.generate_delisted_excel(delisted_items)
return send_file(file_path, as_attachment=True)
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Delisted) | Error: {e}")
return f"匯出失敗: {e}", 500
finally:
session.close()
@app.route('/api/export/price_changes')
def export_price_changes():
"""V9.4 更新:匯出今日價格異動明細 (支援篩選) - 修正:改用與儀表板相同的邏輯"""
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill
filter_type = request.args.get('type', '')
filter_category = request.args.get('category', '')
try:
db = DatabaseManager()
session = db.get_session()
# 使用與 /api/price_change_details 相同的邏輯
now_taipei = datetime.now(TAIPEI_TZ)
today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
# 基礎查詢:取得所有商品的最新記錄
latest_records_subq = session.query(
func.max(PriceRecord.id).label('max_id')
).group_by(PriceRecord.product_id).subquery()
query = session.query(PriceRecord, Product).join(
latest_records_subq,
PriceRecord.id == latest_records_subq.c.max_id
).join(Product, PriceRecord.product_id == Product.id)
# 一次性查詢所有商品的「今日之前最後價格」
product_ids = [r[0] for r in session.query(PriceRecord.product_id).join(
latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id
).all()]
yesterday_prices_subq = session.query(
PriceRecord.product_id,
func.max(PriceRecord.id).label('max_id')
).filter(
PriceRecord.product_id.in_(product_ids),
PriceRecord.timestamp < today_start
).group_by(PriceRecord.product_id).subquery()
yesterday_prices_q = session.query(
PriceRecord.product_id, PriceRecord.price
).join(
yesterday_prices_subq,
PriceRecord.id == yesterday_prices_subq.c.max_id
)
yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q}
products = []
# 根據 filter_type 篩選
if filter_type == 'increase':
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price > old_price:
products.append((product, record, old_price))
elif filter_type == 'decrease':
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price < old_price:
products.append((product, record, old_price))
elif filter_type == 'delisted':
today_delisted = session.query(Product).filter(
Product.status == 'INACTIVE',
Product.updated_at >= today_start
).all()
for product in today_delisted:
last_record = session.query(PriceRecord).filter(
PriceRecord.product_id == product.id
).order_by(PriceRecord.timestamp.desc()).first()
if last_record:
products.append((product, last_record, last_record.price))
elif filter_type == 'active':
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append((product, record, old_price))
elif filter_type == 'category' and filter_category:
for record, product in query.filter(Product.category == filter_category).all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append((product, record, old_price))
else:
# 預設:所有變動商品
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append((product, record, old_price))
session.close()
if not products:
return "無符合條件的商品資料", 404
# 建立 Excel
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "價格變動明細"
# 標題列
headers = ['商品ID', '商品名稱', '分類', '原價格', '現價格', '變動金額', '變動百分比', '更新時間', '商品網址']
ws.append(headers)
# 設定標題列樣式
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
header_font = Font(bold=True, color='FFFFFF')
for cell in ws[1]:
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center', vertical='center')
# 填充資料
for product, record, old_price in products:
change = record.price - old_price
change_pct = (change / old_price * 100) if old_price > 0 else 0
ws.append([
product.i_code,
product.name,
product.category or '未分類',
old_price,
record.price,
change,
f"{change_pct:.2f}%",
record.timestamp.strftime('%Y-%m-%d %H:%M'),
product.url
])
# 調整欄寬
ws.column_dimensions['A'].width = 12
ws.column_dimensions['B'].width = 40
ws.column_dimensions['C'].width = 15
ws.column_dimensions['D'].width = 12
ws.column_dimensions['E'].width = 12
ws.column_dimensions['F'].width = 12
ws.column_dimensions['G'].width = 12
ws.column_dimensions['H'].width = 18
ws.column_dimensions['I'].width = 50
# 儲存檔案
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"價格變動明細_{filter_type or 'all'}_{timestamp}.xlsx"
filepath = os.path.join(EXCEL_EXPORT_DIR, filename)
os.makedirs(EXCEL_EXPORT_DIR, exist_ok=True)
wb.save(filepath)
return send_file(filepath, as_attachment=True, download_name=filename)
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ 異動報表匯出失敗 | Type: {filter_type} | Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/low_prices')
def export_low_prices():
"""🚩 新增:匯出歷史低價商品"""
try:
exporter = Exporter()
file_path = exporter.generate_low_price_report()
if file_path and os.path.exists(file_path):
return send_file(file_path, as_attachment=True)
return "目前無歷史低價商品", 404
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ 低價報表匯出失敗 | Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/changes')
def export_changes():
"""🚩 需求:匯出篩選後的資料 (漲/跌/下架)"""
filter_type = request.args.get('type')
exporter = Exporter()
file_path = None
try:
unique_items, today_start = get_consolidated_data()
if filter_type == 'increase':
target_items = [i for i in unique_items if i['yesterday_diff'] > 0]
file_path = exporter.generate_custom_report(target_items, "今日漲價商品")
elif filter_type == 'decrease':
target_items = [i for i in unique_items if i['yesterday_diff'] < 0]
file_path = exporter.generate_custom_report(target_items, "今日跌價商品")
elif filter_type == 'delisted':
db = DatabaseManager()
session = db.get_session()
try:
today_start_naive = today_start.replace(tzinfo=None)
today_delisted_query = session.query(Product).filter(
Product.status == 'INACTIVE',
Product.updated_at >= today_start_naive
)
raw_delisted_items = today_delisted_query.all()
delisted_items_with_price = []
# 定義模擬物件 (移至迴圈外以提升效率)
class MockRecord:
def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at
for p in raw_delisted_items:
last_rec = session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first()
price = last_rec.price if last_rec else 0
delisted_items_with_price.append({'product': p, 'last_price': price})
file_path = exporter.generate_delisted_report(delisted_items_with_price, "今日下架商品")
finally:
session.close()
if file_path and os.path.exists(file_path):
return send_file(file_path, as_attachment=True)
return "無資料可匯出", 404
except Exception as e:
sys_log.error(f"[Web] [Export] ❌ 篩選匯出失敗 | Type: {filter_type} | Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/excel/abc')
def export_abc_analysis():
"""API: 匯出 ABC 分析報表 (Excel)"""
try:
db = DatabaseManager()
table_name = 'realtime_sales_monthly'
# 1. 嘗試從快取讀取資料 (與 sales_analysis 共用快取)
df = None
cols_map = {}
if table_name in _SALES_PROCESSED_CACHE:
cache_data = _SALES_PROCESSED_CACHE[table_name]
df = cache_data['df']
cols_map = cache_data['cols']
else:
return "請先瀏覽「業績分析」頁面以載入資料與快取。", 400
# 恢復欄位變數
col_name = cols_map.get('name')
col_amount = cols_map.get('amount')
col_qty = cols_map.get('qty')
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_activity = cols_map.get('activity')
col_payment = cols_map.get('payment')
col_price = cols_map.get('price')
col_cost = cols_map.get('cost')
col_profit = cols_map.get('profit')
col_date = cols_map.get('date')
col_pid = cols_map.get('pid') # V-New: 取得商品ID欄位
# 2. 篩選資料 (複製 sales_analysis 的篩選邏輯以確保結果一致)
selected_category = request.args.get('category', 'all')
selected_brand = request.args.get('brand', 'all')
selected_vendor = request.args.get('vendor', 'all')
selected_activity = request.args.get('activity', 'all')
selected_payment = request.args.get('payment', 'all')
selected_dow = request.args.get('dow', 'all')
selected_hour = request.args.get('hour', 'all')
selected_month = request.args.get('month', 'all')
keyword = request.args.get('keyword', '').strip()
min_price = request.args.get('min_price', '')
max_price = request.args.get('max_price', '')
min_margin = request.args.get('min_margin', '')
max_margin = request.args.get('max_margin', '')
target_df = df.copy() # 複製一份以免修改到快取
# 重新計算 Top N 分類 (用於 '其他' 篩選)
TOP_N_CATS = 12
top_cats_names = []
if col_category:
cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False)
if len(cat_group_all) > TOP_N_CATS:
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
if selected_category != 'all' and col_category:
if selected_category == '其他' and top_cats_names:
target_df = target_df[~target_df[col_category].isin(top_cats_names)]
else:
target_df = target_df[target_df[col_category] == selected_category]
if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand]
if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor]
if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity]
if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment]
if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)]
if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)]
if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month]
if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)]
if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)]
if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)]
if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)]
if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)]
# 3. 執行 ABC 分析與匯出
if col_amount and not target_df.empty:
# V-Fix: 同步 abc_analysis_detail 的聚合邏輯,確保匯出數據與網頁一致
agg_rules = {col_amount: 'sum'}
if col_qty: agg_rules[col_qty] = 'sum'
if col_cost: agg_rules[col_cost] = 'sum'
if col_profit: agg_rules[col_profit] = 'sum'
if col_category: agg_rules[col_category] = 'first'
if col_vendor: agg_rules[col_vendor] = 'first'
if col_brand: agg_rules[col_brand] = 'first'
if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID
# 執行聚合
df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index()
# 重新計算聚合後的毛利率
if col_profit:
df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
elif col_cost:
df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
else:
df_agg['calculated_margin_rate'] = 0.0
df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
# 排序與 ABC 分類
target_df = df_agg.sort_values(by=col_amount, ascending=False)
target_df['cumulative_revenue'] = target_df[col_amount].cumsum()
total_revenue = target_df[col_amount].sum()
target_df['cumulative_pct'] = (target_df['cumulative_revenue'] / total_revenue) * 100
conditions = [(target_df['cumulative_pct'] <= 80), (target_df['cumulative_pct'] <= 95)]
choices = ['A', 'B']
target_df['ABC_Class'] = np.select(conditions, choices, default='C')
# V-New: 支援依類別篩選匯出 (例如只匯出 A 類)
filter_class = request.args.get('class')
if filter_class:
target_df = target_df[target_df['ABC_Class'] == filter_class]
# V-New: 計算平均單價 (Avg Unit Price)
if col_qty:
target_df['avg_unit_price'] = (target_df[col_amount] / target_df[col_qty]).fillna(0)
# V-New: 計算建議補貨量 (支援自訂係數)
if col_qty:
custom_factor = request.args.get('factor')
if custom_factor:
try:
factor = float(custom_factor)
# 若有指定係數,則全體套用 (通常用於單一類別匯出)
target_df['suggested_restock'] = (target_df[col_qty] * factor).astype(int)
except:
# 格式錯誤則回退至預設邏輯
conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')]
choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2]
target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int)
else:
# 預設邏輯 (A=1.5, B=1.2, C=0)
conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')]
choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2]
target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int)
# 整理匯出欄位
export_cols = []
header_map = {}
if col_pid: export_cols.append(col_pid); header_map[col_pid] = '商品ID' # V-New: 匯出商品ID
if col_name: export_cols.append(col_name); header_map[col_name] = '商品名稱'
if col_category: export_cols.append(col_category); header_map[col_category] = '分類'
if col_brand: export_cols.append(col_brand); header_map[col_brand] = '品牌'
if col_vendor: export_cols.append(col_vendor); header_map[col_vendor] = '廠商'
export_cols.append('ABC_Class'); header_map['ABC_Class'] = 'ABC分類'
if col_amount: export_cols.append(col_amount); header_map[col_amount] = '銷售金額'
if col_qty: export_cols.append(col_qty); header_map[col_qty] = '銷售數量'
# V-Fix: 移除 col_price 匯出,因為聚合後的資料表不包含原始單價欄位 (已由 avg_unit_price 取代)
if 'avg_unit_price' in target_df.columns:
export_cols.append('avg_unit_price'); header_map['avg_unit_price'] = '平均單價'
if col_cost: export_cols.append(col_cost); header_map[col_cost] = '成本'
if col_profit: export_cols.append(col_profit); header_map[col_profit] = '毛利'
if 'calculated_margin_rate' in target_df.columns:
export_cols.append('calculated_margin_rate'); header_map['calculated_margin_rate'] = '毛利率(%)'
if 'suggested_restock' in target_df.columns:
export_cols.append('suggested_restock')
header_map['suggested_restock'] = '建議補貨量'
export_df = target_df[export_cols].rename(columns=header_map)
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
export_df.to_excel(writer, index=False, sheet_name='ABC分析')
output.seek(0)
filename_prefix = f"ABC_Analysis_{filter_class}_" if filter_class else "ABC_Analysis_"
return send_file(output, as_attachment=True, download_name=f"{filename_prefix}{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
return "無資料可匯出", 404
except Exception as e:
sys_log.error(f"ABC Export Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/api/export/excel/vendor')
def export_vendor_analysis():
"""API: 匯出廠商獲利能力排行 (Excel)"""
try:
db = DatabaseManager()
table_name = 'realtime_sales_monthly'
# 1. 嘗試從快取讀取資料
df = None
cols_map = {}
if table_name in _SALES_PROCESSED_CACHE:
cache_data = _SALES_PROCESSED_CACHE[table_name]
df = cache_data['df']
cols_map = cache_data['cols']
else:
# V-Fix: 快取失效時,重定向到 sales_analysis 以重新載入資料
params = {k: v for k, v in request.args.items()}
flash('資料快取已失效,請稍候重新載入資料後再匯出。', 'warning')
return redirect(url_for('sales_analysis', **params))
# 恢復欄位變數
col_name = cols_map.get('name')
col_amount = cols_map.get('amount')
col_qty = cols_map.get('qty')
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_activity = cols_map.get('activity')
col_payment = cols_map.get('payment')
col_price = cols_map.get('price')
col_cost = cols_map.get('cost')
col_profit = cols_map.get('profit')
col_date = cols_map.get('date')
if not col_vendor:
return "無法識別廠商欄位,無法匯出。", 400
# 2. 篩選資料 (複製 sales_analysis 的篩選邏輯)
selected_category = request.args.get('category', 'all')
selected_brand = request.args.get('brand', 'all')
selected_vendor = request.args.get('vendor', 'all')
selected_activity = request.args.get('activity', 'all')
selected_payment = request.args.get('payment', 'all')
selected_dow = request.args.get('dow', 'all')
selected_hour = request.args.get('hour', 'all')
selected_month = request.args.get('month', 'all')
keyword = request.args.get('keyword', '').strip()
min_price = request.args.get('min_price', '')
max_price = request.args.get('max_price', '')
min_margin = request.args.get('min_margin', '')
max_margin = request.args.get('max_margin', '')
target_df = df.copy()
# Top N 分類處理
TOP_N_CATS = 12
top_cats_names = []
if col_category:
cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False)
if len(cat_group_all) > TOP_N_CATS:
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
if selected_category != 'all' and col_category:
if selected_category == '其他' and top_cats_names:
target_df = target_df[~target_df[col_category].isin(top_cats_names)]
else:
target_df = target_df[target_df[col_category] == selected_category]
if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand]
if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor]
if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity]
if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment]
if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)]
if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)]
if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month]
if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)]
if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)]
if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)]
if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)]
if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)]
# 3. 執行廠商聚合
if col_amount and not target_df.empty:
agg_dict = {col_amount: 'sum', col_name: 'nunique'}
if col_qty: agg_dict[col_qty] = 'sum' # V-Fix: 加入銷量聚合,否則無法計算 ASP
if col_profit:
agg_dict[col_profit] = 'sum'
elif col_cost:
agg_dict[col_cost] = 'sum'
vendor_group = target_df.groupby(col_vendor).agg(agg_dict).reset_index()
if col_profit:
vendor_group['total_profit'] = vendor_group[col_profit]
elif col_cost:
vendor_group['total_profit'] = vendor_group[col_amount] - vendor_group[col_cost]
else:
vendor_group['total_profit'] = 0
# V-Fix: 計算營收佔比 (Share %)
total_vendor_revenue = vendor_group[col_amount].sum()
vendor_group['revenue_share'] = (vendor_group[col_amount] / total_vendor_revenue * 100)
vendor_group['margin_rate'] = np.where(vendor_group[col_amount] > 0, (vendor_group['total_profit'] / vendor_group[col_amount] * 100), 0)
# V-Fix: 計算平均客單價 (ASP)
if col_qty:
vendor_group['asp'] = np.where(vendor_group[col_qty] > 0, vendor_group[col_amount] / vendor_group[col_qty], 0)
vendor_group['avg_sku_revenue'] = np.where(vendor_group[col_name] > 0, vendor_group[col_amount] / vendor_group[col_name], 0)
vendor_group = vendor_group.sort_values(by=col_amount, ascending=False)
# V-Fix: 更新匯出欄位以匹配儀表板
export_cols = [col_vendor, col_amount, 'revenue_share']
header_map = {col_vendor: '廠商名稱', col_amount: '總業績', 'revenue_share': '佔比(%)'}
if col_qty:
export_cols.extend([col_qty, 'asp'])
header_map.update({col_qty: '總銷量', 'asp': '平均客單(ASP)'})
export_cols.extend(['total_profit', 'margin_rate', col_name, 'avg_sku_revenue'])
header_map.update({'total_profit': '毛利額', 'margin_rate': '毛利率(%)', col_name: '商品數(SKU)', 'avg_sku_revenue': '平均單品產值'})
export_df = vendor_group[export_cols].rename(columns=header_map)
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
export_df.to_excel(writer, index=False, sheet_name='廠商排行')
output.seek(0)
return send_file(output, as_attachment=True, download_name=f"Vendor_Ranking_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
return "無資料可匯出", 404
except Exception as e:
sys_log.error(f"Vendor Export Error: {e}")
return f"匯出失敗: {e}", 500
@app.route('/abc_analysis/detail')
def abc_analysis_detail():
"""ABC 分析詳細報表頁面"""
try:
target_class = request.args.get('class', 'A') # 預設 A 類
table_name = 'realtime_sales_monthly'
# 1. 生成與主頁面一致的 cache_key
data_range_months = int(request.args.get('data_range', '0') or '0')
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
if start_date or end_date:
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
else:
cache_key = f"{table_name}_{data_range_months}m"
# 2. 使用共用篩選函式取得資料
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
# V-Fix: 如果 cache_key 不存在,嘗試後補使用 table_name 固定鍵值
if err and table_name in _SALES_PROCESSED_CACHE:
target_df, cols_map, err = _get_filtered_sales_data(table_name)
if err:
# V-Fix: 如果自動重載也失敗,則顯示稍後再試,並引導回主頁面
return f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>數據加載中 - WOOO TECH</title>
<style>
body {{ font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f8f9fa; }}
.card {{ background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); text-align: center; }}
.spinner {{ border: 3px solid #f3f3f3; border-top: 3px solid #1e3c72; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 0 auto 1rem; }}
@keyframes spin {{ 0% {{ transform: rotate(0deg); }} 100% {{ transform: rotate(360deg); }} }}
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<h3>數據準備中</h3>
<p>正在自動重新加載數據,請稍後...</p>
<script>
// 1.5 秒後嘗試重載當前頁面
setTimeout(function() {{
window.location.reload();
}}, 1500);
// 若重試 3 次仍失敗,引導回主頁
let retryCount = parseInt(sessionStorage.getItem('abc_retry') || '0');
if (retryCount > 3) {{
sessionStorage.removeItem('abc_retry');
alert('數據載入過久,請先在業績分析主頁重新整理。');
window.location.href = '/sales_analysis';
}} else {{
sessionStorage.setItem('abc_retry', retryCount + 1);
}}
</script>
</div>
</body>
</html>
''', 200
# 恢復欄位變數
col_name = cols_map.get('name')
col_amount = cols_map.get('amount')
col_qty = cols_map.get('qty')
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_price = cols_map.get('price')
col_cost = cols_map.get('cost')
col_profit = cols_map.get('profit')
col_date = cols_map.get('date')
col_pid = cols_map.get('pid')
# 3. 執行 ABC 分類
items = []
total_revenue = 0
if col_amount and not target_df.empty:
# V-Fix: 先針對商品進行聚合,確保 ABC 分析是基於「商品總銷量」而非「單筆訂單」
agg_rules = {col_amount: 'sum'}
if col_qty: agg_rules[col_qty] = 'sum'
if col_cost: agg_rules[col_cost] = 'sum'
if col_profit: agg_rules[col_profit] = 'sum'
if col_category: agg_rules[col_category] = 'first'
if col_vendor: agg_rules[col_vendor] = 'first'
if col_brand: agg_rules[col_brand] = 'first' # V-New: 加入品牌
if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID
if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique()))
df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index()
# 重新計算聚合後的毛利率
if col_profit:
df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
elif col_cost:
df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
else:
df_agg['calculated_margin_rate'] = 0.0
df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
# 執行 ABC 排序與計算
df_agg = df_agg.sort_values(by=col_amount, ascending=False)
df_agg['cumulative_revenue'] = df_agg[col_amount].cumsum()
total_revenue = df_agg[col_amount].sum()
df_agg['cumulative_pct'] = (df_agg['cumulative_revenue'] / total_revenue) * 100
conditions = [(df_agg['cumulative_pct'] <= 80), (df_agg['cumulative_pct'] <= 95)]
choices = ['A', 'B']
df_agg['ABC_Class'] = np.select(conditions, choices, default='C')
# 4. 篩選特定類別
class_df = df_agg[df_agg['ABC_Class'] == target_class].copy()
# V-New: 計算平均單價與庫存建議
if col_qty:
class_df['avg_unit_price'] = (class_df[col_amount] / class_df[col_qty]).fillna(0)
# V-New: 處理動態補貨係數
custom_factor = request.args.get('factor')
current_factor = 0.0
if custom_factor:
try:
current_factor = float(custom_factor)
except:
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
else:
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
class_df['suggested_restock'] = (class_df[col_qty] * current_factor).astype(int)
items = class_df.to_dict('records')
# 準備標題與描述
class_info = {
'A': {'title': 'A 類 - 核心商品', 'desc': '營收佔比前 80% 的主力商品,建議重點備貨與監控。', 'color': 'danger'},
'B': {'title': 'B 類 - 次要商品', 'desc': '營收佔比 80%~95% 的輔助商品,維持正常庫存。', 'color': 'warning'},
'C': {'title': 'C 類 - 長尾商品', 'desc': '營收佔比最後 5% 的長尾商品,建議評估清倉或縮減 SKU。', 'color': 'success'}
}
info = class_info.get(target_class, {'title': f'{target_class}', 'desc': '', 'color': 'secondary'})
# 計算 DataTables 預設排序欄位 (銷售金額) 的索引
# 欄位順序: Rank(0), [PID], Name, [Brand], [Vendor], [Cat], [Margin], [AvgPrice, Qty, Restock], Amount
sort_col_index = 1 # Rank
if col_pid: sort_col_index += 1
sort_col_index += 1 # Name
if col_brand: sort_col_index += 1
if col_vendor: sort_col_index += 1
if col_category: sort_col_index += 1
if col_cost or col_profit: sort_col_index += 1
if col_qty: sort_col_index += 3
# 此時 sort_col_index 即為 Amount 欄位的索引
return render_template('abc_analysis_detail.html',
items=items,
info=info,
target_class=target_class,
current_factor=current_factor, # V-New: 傳遞當前係數
total_revenue=total_revenue,
sort_col_index=sort_col_index, # V-New: 傳遞排序欄位索引
cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category,
'vendor': col_vendor, 'brand': col_brand, 'cost': col_cost, 'profit': col_profit, 'date': col_date, 'pid': col_pid},
# 傳遞當前查詢參數以供匯出連結使用
query_string=request.query_string.decode())
except Exception as e:
sys_log.error(f"ABC Detail Error: {e}")
return f"系統錯誤: {e}"
@app.route('/logs')
def show_logs():
return render_template('logs.html')
@app.route('/api/run_task', methods=['POST'])
@login_required
def trigger_task():
try:
client_ip = request.remote_addr
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動執行請求 | IP: {client_ip}")
scheduled_job_wrapper()
return jsonify({"status": "success", "message": "爬蟲任務已在背景啟動"})
except Exception as e:
sys_log.error(f"[Web] [Task] ❌ 手動觸發任務失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/run_edm_task', methods=['POST'])
@login_required
def trigger_edm_task():
"""🚩 新增:手動觸發 EDM 爬蟲任務"""
try:
target_lpn = "O1K5FBOqsvN" # 預設活動代碼
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 EDM 執行請求 | LPN: {target_lpn}")
# V-Fix: 強制重載 scheduler 模組,確保讀取到最新的截圖與通知邏輯
import importlib
import scheduler
importlib.reload(scheduler)
# 使用執行緒啟動,避免卡住 Web Server
task_thread = threading.Thread(target=scheduler.run_edm_task, args=(target_lpn,))
task_thread.daemon = True
task_thread.start()
return jsonify({"status": "success", "message": f"EDM 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"})
except Exception as e:
sys_log.error(f"[Web] [Task] ❌ 手動觸發 EDM 任務失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/run_festival_task', methods=['POST'])
def trigger_festival_task():
"""🚩 新增:手動觸發 1.1 狂歡購物節爬蟲任務"""
try:
target_lpn = "O7ylWfihYUM"
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 Festival 執行請求 | LPN: {target_lpn}")
# 使用執行緒啟動,避免卡住 Web Server
task_thread = threading.Thread(target=run_festival_task, args=(target_lpn,))
task_thread.daemon = True
task_thread.start()
return jsonify({"status": "success", "message": f"Festival 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"})
except Exception as e:
sys_log.error(f"[Web] [Task] ❌ 手動觸發 Festival 任務失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/trigger_momo_notification', methods=['POST'])
@login_required
def trigger_momo_notification():
"""🚩 新增:手動觸發商品看板通知"""
try:
# 強制重載通知模組
import importlib
import scheduler
import services.notification_manager
importlib.reload(scheduler)
importlib.reload(services.notification_manager)
from services.notification_manager import NotificationManager
# 1. 取得統計數據
stats = get_dashboard_stats()
# 2. 截取儀表板畫面
dashboard_url = "http://127.0.0.1/"
screenshot_path = scheduler.capture_page_screenshot(dashboard_url, "momo_dashboard")
# 3. 發送通知
notifier = NotificationManager()
sys_log.info(f"[Web] [Notification] 📢 手動觸發 MOMO 通知")
notifier.send_momo_report(stats, screenshot_path)
return jsonify({"status": "success", "message": "已發送商品看板通知"})
except Exception as e:
sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/trigger_edm_notification', methods=['POST'])
def trigger_edm_notification():
"""🚩 新增:手動觸發 EDM 比價通知 (不重爬,僅重發)"""
try:
# V-Fix: 強制重新載入設定與通知模組,確保讀取到最新的 LINE ID (避免快取舊資料)
import importlib
import config
import services.notification_manager
import services.edm_notifier # V-New: 導入新的通知模組
importlib.reload(config)
importlib.reload(services.notification_manager)
importlib.reload(services.edm_notifier)
db = DatabaseManager()
session = db.get_session()
try:
# V-Fix: 改為只抓取最新一批次的異動資料,避免訊息過長
# 1. 找出最新的 batch_id
latest_batch_tuple = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
if not latest_batch_tuple:
return jsonify({"status": "warning", "message": "目前無 EDM 商品資料,請先執行爬蟲"}), 400
latest_batch_id = latest_batch_tuple[0]
# 2. 取得最新批次的所有異動商品
products = session.query(PromoProduct).filter(PromoProduct.batch_id == latest_batch_id).all()
if not products:
return jsonify({"status": "info", "message": "最新一輪掃描中無任何商品異動"}), 200
# V-Fix: 手動觸發時,嘗試尋找對應的截圖檔案
screenshot_path = None
try:
filename = f"edm_{latest_batch_id}.png"
potential_path = os.path.join(BASE_DIR, 'web/static/screenshots', filename)
if os.path.exists(potential_path):
screenshot_path = potential_path
except Exception: pass
from services.edm_notifier import EdmNotifier
notifier = EdmNotifier()
sys_log.info(f"[Web] [Notification] 📢 手動觸發 EDM 通知 | Count: {len(products)} | BatchID: {latest_batch_id}")
notifier.send_edm_report(products, screenshot_path)
return jsonify({"status": "success", "message": f"已針對最新批次的 {len(products)} 筆商品異動發送通知"})
finally:
session.close()
except Exception as e:
sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}")
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/test_notification', methods=['POST'])
def test_notification():
"""🚩 新增:測試訊息通知功能"""
try:
from services.notification_manager import NotificationManager
import config
import requests
notifier = NotificationManager()
# --- 🕵️‍♂️ V9.13 更新Messaging API 診斷邏輯 ---
sys_log.info("[Web] [Notification] 🕵️‍♂️ 執行手動通知發送測試 (Line/Telegram/Email)...")
token = getattr(config, 'LINE_CHANNEL_ACCESS_TOKEN', None)
target_id = getattr(config, 'LINE_GROUP_ID', None)
if token and target_id:
sys_log.info(f"[Web] [Notification] 🔑 偵測到 Channel Token: {token[:4]}...{token[-4:]}")
sys_log.info(f"[Web] [Notification] 🎯 目標 ID: {target_id}")
# 2. 嘗試直接發送請求
try:
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
payload = {
"to": target_id,
"messages": [
{
"type": "text",
"text": "🧪 這是系統診斷測試訊息 (Messaging API)\n\n✅ 連線測試成功!"
}
]
}
sys_log.info("[Web] [Notification] 📡 正在嘗試連線至 Line Messaging API (push)...")
resp = requests.post("https://api.line.me/v2/bot/message/push", headers=headers, json=payload, timeout=10)
sys_log.info(f"[Web] [Notification] 📩 Line API 回應 | Code: {resp.status_code}")
sys_log.info(f"[Web] [Notification] 📄 Line API 內容 | Body: {resp.text}")
if resp.status_code != 200:
return jsonify({"status": "error", "message": f"❌ Line API 拒絕連線: {resp.status_code} - {resp.text}"}), 400
except Exception as req_err:
sys_log.error(f"[Web] [Notification] ❌ 直接連線測試發生異常 | Error: {req_err}")
return jsonify({"status": "error", "message": f"連線異常: {req_err}"}), 500
else:
sys_log.warning("[Web] [Notification] ⚠️ 無法偵測到 Messaging API 設定 (Token 或 Group ID 缺失)")
return jsonify({"status": "error", "message": "設定檔缺少 LINE_CHANNEL_ACCESS_TOKEN 或 LINE_GROUP_ID"}), 400
# 🚩 V9.14 修改:呼叫真實的日報發送邏輯
notifier.send_daily_report()
return jsonify({"status": "success", "message": "✅ 當日異動通知已發送 (Line/Telegram/Email)"})
except ImportError:
return jsonify({"status": "error", "message": "❌ 找不到 NotificationManager 模組"}), 500
except Exception as e:
sys_log.error(f"[Web] [Notification] ❌ 測試通知失敗 | Error: {e}")
return jsonify({"status": "error", "message": f"發送失敗: {str(e)}"}), 500
@app.route('/api/logs')
def get_logs_api():
if os.path.exists(LOG_FILE_PATH):
try:
with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f:
return jsonify({"logs": "".join(f.readlines()[-60:])})
except Exception as e:
sys_log.error(f"[Web] [Logs] ❌ 日誌 API 讀取異常 | Error: {e}")
return jsonify({"logs": "讀取日誌異常"})
return jsonify({"logs": "等待系統啟動中..."})
# 🚩 V9.82: 新增歷史價格 API
@app.route('/api/history/<int:product_id>')
def get_price_history(product_id):
"""API: 取得商品過去 180 天的價格歷史"""
db = DatabaseManager()
session = db.get_session()
try:
# 計算 180 天前的日期
start_date = datetime.now(TAIPEI_TZ).replace(tzinfo=None) - timedelta(days=180)
records = session.query(PriceRecord).filter(
PriceRecord.product_id == product_id,
PriceRecord.timestamp >= start_date
).order_by(PriceRecord.timestamp).all()
data = [{
't': r.timestamp.strftime('%Y-%m-%d %H:%M'),
'p': r.price
} for r in records]
return jsonify(data)
except Exception as e:
sys_log.error(f"[Web] [History] ❌ 獲取歷史價格失敗 | ProductID: {product_id} | Error: {e}")
return jsonify([]), 500
finally:
session.close()
@app.route('/api/price_change_details')
def get_price_change_details():
"""API: V9.4 取得價格變動商品明細 (供彈窗使用) - 修正:改用與儀表板相同的邏輯"""
filter_type = request.args.get('type', '')
filter_category = request.args.get('category', '')
filter_product_id = request.args.get('product_id', '')
db = DatabaseManager()
session = db.get_session()
try:
# 取得今日起始時間
now_taipei = datetime.now(TAIPEI_TZ)
today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
# 基礎查詢:取得所有商品的最新記錄 (與儀表板相同邏輯)
latest_records_subq = session.query(
func.max(PriceRecord.id).label('max_id')
).group_by(PriceRecord.product_id).subquery()
query = session.query(PriceRecord, Product).join(
latest_records_subq,
PriceRecord.id == latest_records_subq.c.max_id
).join(Product, PriceRecord.product_id == Product.id)
# 一次性查詢所有商品的「今日之前最後價格」(yesterday_prices_map)
product_ids = [r[0] for r in session.query(PriceRecord.product_id).join(
latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id
).all()]
yesterday_prices_subq = session.query(
PriceRecord.product_id,
func.max(PriceRecord.id).label('max_id')
).filter(
PriceRecord.product_id.in_(product_ids),
PriceRecord.timestamp < today_start
).group_by(PriceRecord.product_id).subquery()
yesterday_prices_q = session.query(
PriceRecord.product_id, PriceRecord.price
).join(
yesterday_prices_subq,
PriceRecord.id == yesterday_prices_subq.c.max_id
)
yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q}
# 根據 filter_type 進行篩選
products = []
if filter_type == 'increase':
# 漲價商品 - 比對今日最新價格與今日之前的最後價格
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price > old_price:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': old_price,
'current_price': record.price,
'change': record.price - old_price,
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
})
elif filter_type == 'decrease':
# 降價商品
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price < old_price:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': old_price,
'current_price': record.price,
'change': record.price - old_price,
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
})
elif filter_type == 'delisted':
# 下架商品 (今日狀態為 INACTIVE 且今天更新的)
today_delisted = session.query(Product).filter(
Product.status == 'INACTIVE',
Product.updated_at >= today_start
).all()
for product in today_delisted:
last_record = session.query(PriceRecord).filter(
PriceRecord.product_id == product.id
).order_by(PriceRecord.timestamp.desc()).first()
if last_record:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': last_record.price,
'current_price': 0,
'change': 0,
'update_time': last_record.timestamp.strftime('%Y-%m-%d %H:%M')
})
elif filter_type == 'active':
# 活躍商品 (今日有價格變動的)
for record, product in query.all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': old_price,
'current_price': record.price,
'change': record.price - old_price,
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
})
elif filter_type == 'category' and filter_category:
# 特定分類的變動商品
for record, product in query.filter(Product.category == filter_category).all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': old_price,
'current_price': record.price,
'change': record.price - old_price,
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
})
elif filter_type == 'max_change' and filter_product_id:
# 最大變動商品 - 只顯示指定的單一商品
for record, product in query.filter(Product.i_code == filter_product_id).all():
old_price = yesterday_prices_map.get(product.id)
if old_price is not None and record.price != old_price:
products.append({
'product_id': product.i_code,
'name': product.name,
'category': product.category,
'url': product.url,
'image_url': product.image_url or '/static/placeholder.png',
'old_price': old_price,
'current_price': record.price,
'change': record.price - old_price,
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
})
break # 只需要一件商品
return jsonify({'products': products})
except Exception as e:
sys_log.error(f"[Web] [PriceChangeDetails] ❌ 獲取價格變動明細失敗 | Type: {filter_type} | Error: {e}")
return jsonify({'products': []}), 500
finally:
session.close()
@app.route('/api/backup', methods=['POST'])
@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
@app.route('/api/import_excel', methods=['POST'])
@login_required
def import_excel():
"""
API: 匯入 Excel/CSV 並自動建表
已加入檔案上傳安全驗證 (副檔名白名單、檔案名稱清理)
"""
try:
# 1. 檢查是否有上傳檔案
if 'file' not in request.files:
return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400
file = request.files['file']
# 2. 使用安全驗證函數
is_valid, error_msg, safe_name = validate_upload_file(file)
if not is_valid:
sys_log.warning(f"[Security] 檔案上傳驗證失敗 | Filename: {file.filename} | Error: {error_msg}")
return jsonify({'status': 'error', 'message': error_msg}), 400
sys_log.info(f"[Web] [Import] 檔案上傳驗證通過 | Original: {file.filename} | Safe: {safe_name}")
# 3. 根據副檔名讀取檔案
df = None
filename_lower = safe_name.lower()
if filename_lower.endswith(('.xlsx', '.xls')):
try:
df = pd.read_excel(file, engine='openpyxl', dtype=str)
except Exception as e:
return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500
elif filename_lower.endswith('.csv'):
try:
# V-New: 嘗試用多種編碼讀取 CSV
try:
df = pd.read_csv(file, dtype=str)
except UnicodeDecodeError:
file.seek(0) # 重置文件指針
df = pd.read_csv(file, encoding='big5', dtype=str)
except Exception as e:
return jsonify({'status': 'error', 'message': f'CSV 讀取失敗: {str(e)}'}), 500
else:
# 理論上不會到這裡,因為 validate_upload_file 已經檢查過
return jsonify({'status': 'error', 'message': '不支援的檔案格式'}), 400
if df is None:
return jsonify({'status': 'error', 'message': '無法讀取檔案內容'}), 500
# V-New: 增加日誌以確認目前為原始匯入模式 (提醒使用者已略過清理)
sys_log.info("[Web] [Import] ⚠️ 偵測到原始匯入模式 (Raw Import Mode) - 已略過智慧清理")
# V-Fix: 1. 先標準化欄位名稱,確保後續關鍵字比對準確
# df.columns = [str(c).strip().replace(' ', '_').replace('-', '_').replace('(', '').replace(')', '').replace('/', '_') for c in df.columns]
# V-Fix: 2. 執行智慧資料清理 (v3 保守模式 - 解決 'F' 被強制轉 0 的問題)
# sys_log.info("[Web] [Import] 執行智慧資料清理程序 (v3 保守模式)...")
# 定義必須是數值的欄位關鍵字 (這些欄位必須是數字,髒資料轉 0 以免影響計算)
# numeric_keywords = ['序號', '數量', '單價', '金額', '成本', '毛利', '售價', '應收', '營收',
# 'Quantity', 'Qty', 'Price', 'Amount', 'Cost', 'Profit', 'Sales', 'Revenue']
# for col in df.columns:
# # 判斷是否為強制數值欄位
# is_force_numeric = any(k in col for k in numeric_keywords)
# if df[col].dtype == 'object':
# if is_force_numeric:
# # 策略 A: 強制數值欄位 -> 激進清理 (保留數字,其餘轉 0)
# # 先移除千分位逗號等非數值字符
# cleaned_series = df[col].astype(str).str.replace(r'[^\d.-]', '', regex=True)
# converted_series = pd.to_numeric(cleaned_series, errors='coerce')
# df[col] = converted_series.fillna(0)
# sys_log.info(f"[Web] [Import] 強制清理數值欄位 '{col}' (髒資料已轉為 0)")
# else:
# # 策略 B: 一般欄位 -> 保守檢查 (保留 'F' 等文字)
# # 直接嘗試轉換,不移除文字
# converted_series = pd.to_numeric(df[col], errors='coerce')
# # 檢查有多少值變成了 NaN (原本不是 NaN/空字串,但轉換後變成 NaN 的)
# original_valid_mask = df[col].notna() & (df[col].astype(str).str.strip() != '')
# converted_valid_mask = converted_series.notna()
# loss_count = (original_valid_mask & ~converted_valid_mask).sum()
# if loss_count == 0:
# # 如果沒有資料損失 (代表全是數字或空值),才轉換
# df[col] = converted_series
# else:
# # 有資料損失 (例如包含 'F'),保留為文字
# sys_log.info(f"[Web] [Import] 欄位 '{col}' 保留為文字 (含 {loss_count} 筆非數值資料,如 'F')")
# 識別檔案類型
is_daily_sales = '即時業績' in file.filename and '當日' in file.filename
is_sales_report = '即時業績' in file.filename and '全月' in file.filename
if is_daily_sales:
table_name = 'daily_sales_snapshot'
# V-New: 智慧匯入 - 根據 Excel 內的日期欄位自動拆分 snapshot_date
date_col = None
for possible_col in ['日期', '訂單日期', '交易日期', 'Date']:
if possible_col in df.columns:
date_col = possible_col
break
if date_col:
# 使用 Excel 內的日期欄位作為 snapshot_date
sys_log.info(f"[Web] [Import] 使用 Excel 內的「{date_col}」欄位作為快照日期")
# 將日期欄位轉換為標準格式 YYYY-MM-DD
df['snapshot_date'] = pd.to_datetime(df[date_col], errors='coerce').dt.strftime('%Y-%m-%d')
# 移除無效日期的資料
invalid_count = df['snapshot_date'].isna().sum()
if invalid_count > 0:
sys_log.warning(f"[Web] [Import] 發現 {invalid_count} 筆無效日期資料,已移除")
df = df.dropna(subset=['snapshot_date'])
unique_dates = df['snapshot_date'].nunique()
sys_log.info(f"[Web] [Import] 識別為當日業績報表,包含 {unique_dates} 個不同日期")
else:
# 備用方案:從檔名提取日期
snapshot_date = extract_snapshot_date_from_filename(file.filename)
if not snapshot_date:
return jsonify({'status': 'error', 'message': '無法從檔名提取日期,且 Excel 中無日期欄位'}), 400
df['snapshot_date'] = snapshot_date
sys_log.info(f"[Web] [Import] Excel 無日期欄位,使用檔名日期: {snapshot_date}")
elif is_sales_report:
table_name = 'realtime_sales_monthly'
else:
filename_no_ext = os.path.splitext(file.filename)[0]
table_name = re.sub(r'[^\w\u4e00-\u9fff]+', '_', filename_no_ext).strip('_')
if not table_name: table_name = f"import_{int(time.time())}"
# S4 \u5b89\u5168\u4fee\u5fa9\uff1atable_name \u767d\u540d\u55ae\u9a57\u8b49\uff0c\u9632\u6b62 SQL Injection
# \u5141\u8a31\uff1a\u5b57\u6bcd\u3001\u6578\u5b57\u3001\u5e95\u7dda\u3001\u4e2d\u6587\u5b57\uff08\u4e0d\u5141\u8a31\u7a7a\u683c\u3001\u5f15\u865f\u3001\u6ce8\u5165\u7b26\u865f\uff09
import re as _re_sec
if not _re_sec.match(r'^[\w\u4e00-\u9fff]+$', table_name):
sys_log.error(f"[Web] [Import] \u274c \u975e\u6cd5\u8cc7\u6599\u8868\u540d\u7a31\u88ab\u62d2\u7d55\uff1a{table_name!r}")
return jsonify({'status': 'error', 'message': '\u975e\u6cd5\u7684\u8cc7\u6599\u8868\u540d\u7a31\uff0c\u532f\u5165\u4e2d\u6b62\u3002'}), 400
db = DatabaseManager()
engine = db.engine
# V-Debug: 顯示實際寫入的資料庫路徑
sys_log.info(f"[Web] [Import] 正在寫入資料庫: {engine.url}")
if table_name in ['realtime_sales_monthly', 'daily_sales_snapshot']:
try:
# V-Fix: 實作自動去重邏輯 (Deduplication)
# 1. 檢查資料表是否存在
inspector = inspect(engine)
if not inspector.has_table(table_name):
sys_log.info(f"[Web] [Import] 資料表不存在,建立新表: {table_name}")
df.to_sql(table_name, con=engine, if_exists='replace', index=False)
rows_imported = len(df)
message = f'匯入成功!已建立新資料表並寫入 {rows_imported} 筆資料。'
else:
sys_log.info(f"[Web] [Import] 資料表已存在,執行自動去重 (Deduplication)...")
# 2. 讀取現有資料(優化:僅讀取相關日期的資料以進行去重)
try:
# 嘗試根據 incoming df 的日期範圍來過濾現有資料
filter_clause = ""
if '日期' in df.columns:
# V-Fix: 確保日期格式與資料庫一致 (YYYY/MM/DD) 以便 SQL IN 查詢能正確比對
# 有時 Pandas 會將其轉換為 datetime 或 2024-01-01 格式
temp_dates = pd.to_datetime(df['日期'], errors='coerce')
unique_dates = temp_dates.dropna().dt.strftime('%Y/%m/%d').unique()
if len(unique_dates) > 0:
# S4 修復格式驗證日期值YYYY/MM/DD 格式,防止注入)
safe_dates = [str(d) for d in unique_dates if _re_sec.match(r'^[\d/\-: ]+$', str(d))]
if safe_dates:
filter_clause = ("日期", safe_dates)
sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(safe_dates)} 個日期相關的現有資料 (範例: {safe_dates[0] if safe_dates else 'N/A'})")
elif 'snapshot_date' in df.columns:
unique_dates = df['snapshot_date'].dropna().unique()
if len(unique_dates) > 0:
# S4 修復date 值來自 DataFrame仍做格式驗證防止隱式注入
safe_dates = [str(d) for d in unique_dates if _re_sec.match(r'^[\d/\-: ]+$', str(d))]
if safe_dates:
filter_clause = ("snapshot_dates", safe_dates)
sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(safe_dates)} 個快照日期相關的現有資料")
if filter_clause:
# S4 修復:使用安全的 IN 查詢table_name 已白名單驗證dates 格式已驗證)
if isinstance(filter_clause, tuple):
col_name, date_vals = filter_clause
placeholders = ",".join([f"'{d}'" for d in date_vals])
df_existing = pd.read_sql(
f'SELECT * FROM {table_name} WHERE {col_name} IN ({placeholders})',
con=engine
)
else:
df_existing = pd.read_sql(f"SELECT * FROM {table_name}{filter_clause}", con=engine)
else:
# 備用方案:若無日期欄位,仍讀取全表
sys_log.warning(f"[Web] [Import] ⚠️ 無法根據日期過濾,讀取全表進行去重 (可能效能較差)")
df_existing = safe_read_sql(table_name, engine=engine)
except Exception as e:
sys_log.warning(f"[Web] [Import] ⚠️ 讀取舊資料失敗 ({e}),略過去重直接累加。")
df_existing = pd.DataFrame()
rows_to_write = df
if not df_existing.empty:
# 3. 執行比對 (找出共有欄位)
common_cols = list(set(df.columns) & set(df_existing.columns))
# 針對 daily_sales_snapshot 使用特定去重鍵
if table_name == 'daily_sales_snapshot':
# 優先使用 snapshot_date + 訂單編號
if 'snapshot_date' in common_cols and '訂單編號' in common_cols:
common_cols = ['snapshot_date', '訂單編號']
sys_log.info(f"[Web] [Import] 使用去重鍵: snapshot_date + 訂單編號")
elif 'snapshot_date' in common_cols:
# 備用方案:使用所有共有欄位
sys_log.info(f"[Web] [Import] 使用全欄位去重 (共 {len(common_cols)} 個欄位)")
if common_cols:
# 轉換為字串以確保比對準確 (處理 NaN 與型別差異)
# V-Fix: 加強去重邏輯,處理 '100.0' vs '100' 的問題
def normalize_series(s):
return s.astype(str).str.strip().str.replace(r'\.0$', '', regex=True)
df_str = df[common_cols].apply(normalize_series).fillna('')
existing_str = df_existing[common_cols].apply(normalize_series).fillna('')
# 移除 df_existing 中的重複項 (優化 merge 效能)
existing_str = existing_str.drop_duplicates()
# 使用 merge 找出 df 中已存在的資料
merged = df_str.merge(existing_str, on=common_cols, how='left', indicator=True)
# 只保留 'left_only' 的資料 (即新資料)
rows_to_write = df[merged['_merge'] == 'left_only']
duplicates_count = len(df) - len(rows_to_write)
sys_log.info(f"[Web] [Import] 🔍 自動去重: 發現 {duplicates_count} 筆重複資料,已忽略。")
# 4. 寫入新資料
if not rows_to_write.empty:
rows_to_write.to_sql(table_name, con=engine, if_exists='append', index=False)
rows_imported = len(rows_to_write)
message = f'匯入成功!已去重並新增 {rows_imported} 筆資料。'
else:
rows_imported = 0
message = '匯入完成,但所有資料皆已存在 (重複),無新增數據。'
# V-Fix: 無條件清除快取,確保行事曆能夠顯示最新資料
# 原問題:只有 rows_imported > 0 時才清除快取,導致匯入後行事曆不更新
if table_name in _SALES_DF_CACHE:
del _SALES_DF_CACHE[table_name]
sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}")
# V-Opt: 清除所有相關的處理後快取(包含不同 data_range 的快取)
cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)]
for cache_key in cache_keys_to_delete:
del _SALES_PROCESSED_CACHE[cache_key]
sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}")
return jsonify({'status': 'success', 'message': message, 'rows': rows_imported, 'table': table_name})
except Exception as de:
sys_log.error(f"[Web] [Import] 業績報表匯入去重或寫入時發生錯誤: {de}")
return jsonify({'status': 'error', 'message': f'業績報表匯入失敗: {de}'}), 500
else:
# 對於非業績報表,維持覆蓋邏輯
sys_log.info(f"[Web] [Import] 使用覆蓋模式 (replace)寫入資料表: {table_name}")
df.to_sql(table_name, con=engine, if_exists='replace', index=False)
if table_name in _SALES_DF_CACHE:
del _SALES_DF_CACHE[table_name]
sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}")
# V-Opt: 清除所有相關的處理後快取
cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)]
for cache_key in cache_keys_to_delete:
del _SALES_PROCESSED_CACHE[cache_key]
sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}")
return jsonify({'status': 'success', 'message': f'通用匯入成功!資料已覆蓋至 {table_name}', 'rows': len(df), 'table': table_name})
except Exception as e:
sys_log.error(f"[Web] [Import] ❌ 檔案匯入發生嚴重錯誤 | Error: {str(e)}")
return jsonify({'status': 'error', 'message': f'檔案匯入失敗: {str(e)}'}), 500
@app.route('/api/import/monthly_summary', methods=['POST'])
def import_monthly_summary():
"""API: 匯入月份總表數據分析"""
try:
if 'file' not in request.files:
return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400
file = request.files['file']
is_valid, error_msg, safe_name = validate_upload_file(file)
if not is_valid:
sys_log.warning(f"[Security] 月份總表上傳驗證失敗: {error_msg}")
return jsonify({'status': 'error', 'message': error_msg}), 400
# 讀取 Excel
try:
df = pd.read_excel(file, engine='openpyxl')
except Exception as e:
return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500
if df.empty:
return jsonify({'status': 'error', 'message': '檔案內容為空'}), 400
# 欄位對照表 (對應 Excel 繁體中文標題與資料庫英文欄位)
mapping = {
'': 'year', '': 'month', '商品部': 'department', '3C百貨': 'category_3c',
'處別': 'division', '科別': 'section', '區ID': 'area_id', '區名稱': 'area_name',
'商品_PM': 'pm_name', '品牌名稱_合併': 'brand_name', '廠商編號': 'vendor_id',
'廠商名稱': 'vendor_name', '借採轉': 'trade_type', '件單價': 'unit_price',
'銷售額_本月': 'sales_amt_curr', '銷售額_上月': 'sales_amt_prev', '銷售額_去年同期': 'sales_amt_yoa',
'毛1額_本月': 'profit_amt_curr', '毛1額_上月': 'profit_amt_prev', '毛1額_去年同期': 'profit_amt_yoa',
'折扣金額_本月': 'discount_amt_curr', '折扣金額_上月': 'discount_amt_prev', '折扣金額_去年同期': 'discount_amt_yoa',
'折價券_本月': 'coupon_amt_curr', '折價券_上月': 'coupon_amt_prev', '折價券_去年同期': 'coupon_amt_yoa',
'其他行銷活動_本月': 'other_mkt_curr', '其他行銷活動_上月': 'other_mkt_prev', '其他行銷活動_去年同期': 'other_mkt_yoa',
'點我折_本月': 'spot_disc_curr', '點我折_上月': 'spot_disc_prev', '點我折_去年同期': 'spot_disc_yoa',
'點數折抵_本月': 'point_disc_curr', '點數折抵_上月': 'point_disc_prev', '點數折抵_去年同期': 'point_disc_yoa',
'銷售量_本月': 'sales_vol_curr', '銷售量_上月': 'sales_vol_prev', '銷售量_去年同期': 'sales_vol_yoa',
'轉換率': 'conv_rate', '瀏覽數_本月': 'views_curr', '瀏覽數_上月': 'views_prev', '瀏覽數_去年同期': 'views_yoa'
}
# 檢查必備欄位 (寬鬆檢查:只要有 mapping 中的欄位就匯入)
current_cols = df.columns.tolist()
import_mapping = {k: v for k, v in mapping.items() if k in current_cols}
if len(import_mapping) < 5: # 至少要有幾個維度
return jsonify({'status': 'error', 'message': '檔案欄位不符,請確認是否為正確的月份業績總表'}), 400
# 重新命名與清理資料
target_df = df[list(import_mapping.keys())].rename(columns=import_mapping)
# 轉換數值欄位,填補 NaN
numeric_cols = [v for k, v in import_mapping.items() if v not in [
'department', 'category_3c', 'division', 'section', 'area_id', 'area_name',
'pm_name', 'brand_name', 'vendor_name', 'trade_type'
]]
for col in numeric_cols:
target_df[col] = pd.to_numeric(target_df[col], errors='coerce').fillna(0)
# 寫入資料庫 - 優化效能版本 (Phase 9 Optimization)
db = DatabaseManager()
engine = db.engine
try:
# 取得要匯入的年月份,用於先行刪除重複資料
years_months = target_df[['year', 'month']].drop_duplicates()
with engine.begin() as conn:
# 1. 刪除該月份舊資料 (Transaction 開始)
for _, row in years_months.iterrows():
conn.execute(text("DELETE FROM monthly_summary_analysis WHERE year = :y AND month = :m"),
{'y': int(row['year']), 'm': int(row['month'])})
# 2. 批量寫入 (使用 multi 方法加速SQLite chunksize 建議 2000 避免參數過多)
# 比照 realtime_sales_monthly 的優化方式
target_df.to_sql('monthly_summary_analysis',
con=conn,
if_exists='append',
index=False,
chunksize=2000,
method='multi')
except Exception as e:
sys_log.error(f"[Web] [Import] 匯入資料庫失敗: {e}")
raise e
sys_log.info(f"[Web] [Import] 🚀 月份總表資料匯入成功 | 筆數: {len(target_df)}")
return jsonify({
'status': 'success',
'message': f'成功匯入 {len(target_df)} 筆分析數據。',
'rows': len(target_df)
})
except Exception as e:
sys_log.error(f"[Web] [Import] ❌ 月份總表匯入嚴重失敗: {str(e)}")
return jsonify({'status': 'error', 'message': f'匯入失敗: {str(e)}'}), 500
@app.route('/monthly_summary_analysis')
def monthly_summary_analysis_page():
"""月份總表數據分析展示頁 (Phase 9)"""
return render_template('monthly_summary_analysis.html',
datetime_now=datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'),
system_version=SYSTEM_VERSION)
@app.route('/api/monthly_summary_data')
def get_monthly_summary_data():
"""API: 取得月份總表數據與分析指標 (Phase 9)"""
year = request.args.get('year', type=int)
month = request.args.get('month', type=int)
division = request.args.get('division')
pm_name = request.args.get('pm_name')
brand_name = request.args.get('brand_name')
vendor_name = request.args.get('vendor')
area_name = request.args.get('area_name')
trade_type = request.args.get('trade_type')
limit = request.args.get('limit', default=1000, type=int)
# DEBUG LOGGING
import logging
debug_logger = logging.getLogger('app')
debug_logger.info(f"🔍 [API Debug] Request Args: {request.args}")
db = DatabaseManager()
session = db.get_session()
try:
# 基礎查詢
query = session.query(MonthlySummaryAnalysis)
# 套用過濾
if year: query = query.filter(MonthlySummaryAnalysis.year == year)
if month: query = query.filter(MonthlySummaryAnalysis.month == month)
if division: query = query.filter(MonthlySummaryAnalysis.division == division)
if pm_name: query = query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
if brand_name: query = query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
if vendor_name: query = query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
if area_name:
if ',' in area_name:
query = query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
else:
query = query.filter(MonthlySummaryAnalysis.area_name == area_name)
if trade_type: query = query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
# 取得統計數據 (KPIs)
kpi_query = session.query(
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('total_sales'),
func.sum(MonthlySummaryAnalysis.sales_amt_prev).label('total_sales_prev'),
func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('total_sales_yoa'),
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('total_profit'),
func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('total_vol'),
func.sum(MonthlySummaryAnalysis.views_curr).label('total_views')
)
# 同樣套用過濾到 KPI
if year: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.year == year)
if month: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.month == month)
if division: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.division == division)
if pm_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
if brand_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
if vendor_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
if area_name:
if ',' in area_name:
kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
else:
kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name == area_name)
if trade_type: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
kpi_res = kpi_query.one()
# 取得總筆數與月數
total_rows = session.query(func.count(MonthlySummaryAnalysis.id))
total_months_query = session.query(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).distinct()
if year:
total_rows = total_rows.filter(MonthlySummaryAnalysis.year == year)
total_months_query = total_months_query.filter(MonthlySummaryAnalysis.year == year)
if month:
total_rows = total_rows.filter(MonthlySummaryAnalysis.month == month)
total_rows = total_rows.scalar()
total_months = total_months_query.count()
# 取得趨勢數據 (按月加總)
trend_query = session.query(
MonthlySummaryAnalysis.year,
MonthlySummaryAnalysis.month,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
).group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
if division: trend_query = trend_query.filter(MonthlySummaryAnalysis.division == division)
if pm_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
if brand_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
if vendor_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
if area_name:
if ',' in area_name:
trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
else:
trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name == area_name)
if trade_type: trend_query = trend_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
# 取得排行榜 (Top 10 Brands)
rank_query = session.query(
MonthlySummaryAnalysis.brand_name,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
).group_by(MonthlySummaryAnalysis.brand_name)
if year: rank_query = rank_query.filter(MonthlySummaryAnalysis.year == year)
if month: rank_query = rank_query.filter(MonthlySummaryAnalysis.month == month)
if division: rank_query = rank_query.filter(MonthlySummaryAnalysis.division == division)
if pm_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
if brand_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
if vendor_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
if area_name:
if ',' in area_name:
rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
else:
rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name == area_name)
if trade_type: rank_query = rank_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
rank_query = rank_query.order_by(desc('sales')).limit(10)
# 取得明細資料
rows_query = query.order_by(
MonthlySummaryAnalysis.year.desc(),
MonthlySummaryAnalysis.month.desc(),
MonthlySummaryAnalysis.sales_amt_curr.desc()
).limit(limit)
# --- 📊 V-New: 進階分析子查詢 (Phase 17) ---
def apply_filters(q, ignore_year=False):
if year and not ignore_year: q = q.filter(MonthlySummaryAnalysis.year == year)
if month: q = q.filter(MonthlySummaryAnalysis.month == month)
if division: q = q.filter(MonthlySummaryAnalysis.division == division)
if pm_name: q = q.filter(MonthlySummaryAnalysis.pm_name == pm_name)
if brand_name: q = q.filter(MonthlySummaryAnalysis.brand_name == brand_name)
if vendor_name: q = q.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
if area_name:
if ',' in area_name:
q = q.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
else:
q = q.filter(MonthlySummaryAnalysis.area_name == area_name)
if trade_type: q = q.filter(MonthlySummaryAnalysis.trade_type == trade_type)
return q
# 廠商排行
# 廠商排行 (Top 20, 分年度)
# 廠商排行 (Top 20, 分年度)
vendor_rank_q = session.query(
MonthlySummaryAnalysis.vendor_name,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025'),
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit'),
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2024'),
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2025'),
).group_by(MonthlySummaryAnalysis.vendor_name)
vendor_rank_q = apply_filters(vendor_rank_q, ignore_year=True)
vendor_rank_q = vendor_rank_q.order_by(desc('sales')).limit(20)
# 分類分佈 (按 Division, Top 12, 分年度)
div_dist_q = session.query(
MonthlySummaryAnalysis.division,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
).group_by(MonthlySummaryAnalysis.division)
div_dist_q = apply_filters(div_dist_q, ignore_year=True)
div_dist_q = div_dist_q.order_by(desc('sales')).limit(12)
# 價格帶貢獻 (分年度)
price_cont_q = session.query(
case(
(MonthlySummaryAnalysis.unit_price < 500, '0-499'),
(MonthlySummaryAnalysis.unit_price < 1000, '500-999'),
(MonthlySummaryAnalysis.unit_price < 2000, '1,000-1,999'),
(MonthlySummaryAnalysis.unit_price < 5000, '2,000-4,999'),
(MonthlySummaryAnalysis.unit_price < 10000, '5,000-9,999'),
else_='10,000+'
).label('price_range'),
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
).group_by('price_range')
price_cont_q = apply_filters(price_cont_q, ignore_year=True)
# BCG 矩陣 (品牌 x 區域)
bcg_q = session.query(
MonthlySummaryAnalysis.brand_name,
MonthlySummaryAnalysis.area_name,
func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('vol'),
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit')
).group_by(MonthlySummaryAnalysis.brand_name, MonthlySummaryAnalysis.area_name)\
.having(func.sum(MonthlySummaryAnalysis.sales_amt_curr) > 0)
bcg_q = apply_filters(bcg_q)
bcg_q = bcg_q.order_by(desc('sales')).limit(100)
# 熱力圖 (月份 x 分類)
# 熱力圖 (月份 x 分類)
# 為了保持一致性,這裡我們應該只取 Top 12 的 Division
# 先取得 Top 12 Division 的名稱列表
top_12_divs = [r.division for r in div_dist_q.all()]
heatmap_q = session.query(
MonthlySummaryAnalysis.year,
MonthlySummaryAnalysis.month,
MonthlySummaryAnalysis.division,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
).filter(MonthlySummaryAnalysis.division.in_(top_12_divs))\
.group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month, MonthlySummaryAnalysis.division)\
.order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
heatmap_q = apply_filters(heatmap_q, ignore_year=True)
# Highlights (Top 3)
def get_highlights_q(metric_col):
q = session.query(MonthlySummaryAnalysis.brand_name, func.sum(metric_col).label('val'))
q = apply_filters(q)
q = q.group_by(MonthlySummaryAnalysis.brand_name).order_by(desc('val')).limit(3)
return q
rev_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_amt_curr)
profit_top_q = get_highlights_q(MonthlySummaryAnalysis.profit_amt_curr)
vol_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_vol_curr)
# 區域排行
area_rank_q = session.query(
MonthlySummaryAnalysis.area_name,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
).group_by(MonthlySummaryAnalysis.area_name)
area_rank_q = apply_filters(area_rank_q, ignore_year=True)
area_rank_q = area_rank_q.order_by(desc('sales'))
# 年度對比趨勢 (需要包含本期與去年同期)
# 年度對比趨勢 (需要包含本期與去年同期)
yoy_trend_q = session.query(
MonthlySummaryAnalysis.year,
MonthlySummaryAnalysis.month,
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales_curr'),
func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('sales_yoa')
)
yoy_trend_q = apply_filters(yoy_trend_q)
yoy_trend_q = yoy_trend_q.group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
rows = []
for r in rows_query.all():
rows.append({
'year': r.year,
'month': r.month,
'division': r.division,
'pm_name': r.pm_name,
'area_name': r.area_name,
'brand_name': r.brand_name,
'vendor_name': r.vendor_name,
'trade_type': r.trade_type,
'sales_amt_curr': r.sales_amt_curr,
'sales_amt_yoa': r.sales_amt_yoa,
'sales_vol_curr': r.sales_vol_curr,
'profit_amt_curr': r.profit_amt_curr,
'views_curr': r.views_curr
})
# 取得不重複的維度列表
years_list = [r[0] for r in session.query(MonthlySummaryAnalysis.year).distinct().all()]
months_list = [r[0] for r in session.query(MonthlySummaryAnalysis.month).distinct().all()]
divisions_list = [r[0] for r in session.query(MonthlySummaryAnalysis.division).distinct().all() if r[0]]
pms_list = [r[0] for r in session.query(MonthlySummaryAnalysis.pm_name).distinct().all() if r[0]]
areas_list = [r[0] for r in session.query(MonthlySummaryAnalysis.area_name).distinct().all() if r[0]]
vendors_list = [r[0] for r in session.query(MonthlySummaryAnalysis.vendor_name).distinct().all() if r[0]]
trades_list = [r[0] for r in session.query(MonthlySummaryAnalysis.trade_type).distinct().all() if r[0]]
# DEBUG LOGGING FOR RESULTS
debug_logger.info(f"🔍 [API Debug] Result Counts: Area={len(area_rank_q.all())}, Vendor={len(vendor_rank_q.all())}, Div={len(div_dist_q.all())}, Price={len(price_cont_q.all())}")
return jsonify({
'status': 'success',
'total_rows': total_rows,
'total_months': total_months,
'kpis': {
'sales': int(kpi_res.total_sales or 0),
'sales_prev': int(kpi_res.total_sales_prev or 0),
'sales_yoa': int(kpi_res.total_sales_yoa or 0),
'profit': int(kpi_res.total_profit or 0),
'vol': int(kpi_res.total_vol or 0),
'views': int(kpi_res.total_views or 0),
'margin': round((kpi_res.total_profit / kpi_res.total_sales * 100), 2) if kpi_res.total_sales and kpi_res.total_profit else 0
},
'trend': [{'date': f"{r.year}/{r.month}", 'sales': int(r.sales or 0)} for r in trend_query.all()],
'yoy_trend': [{'date': f"{r.year}/{r.month}", 'curr': int(r.sales_curr or 0), 'yoa': int(r.sales_yoa or 0)} for r in yoy_trend_q.all()],
'rankings': [{'brand': r.brand_name, 'sales': int(r.sales or 0)} for r in rank_query.all()],
'rankings': [{'brand': r.brand_name, 'sales': int(r.sales or 0)} for r in rank_query.all()],
'area_ranking': [
{
'name': r.area_name,
'sales': int(r.sales or 0),
'sales_2024': int(r.sales_2024 or 0),
'sales_2025': int(r.sales_2025 or 0)
}
for r in area_rank_q.all()
],
'vendor_ranking': [
{
'name': r.vendor_name,
'sales': int(r.sales or 0),
'sales_2024': int(r.sales_2024 or 0),
'sales_2025': int(r.sales_2025 or 0),
'profit': int(r.profit or 0),
'profit_2024': int(r.profit_2024 or 0),
'profit_2025': int(r.profit_2025 or 0),
'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0
}
for r in vendor_rank_q.all()
],
'division_dist': [
{
'name': r.division,
'value': int(r.sales or 0),
'sales_2024': int(r.sales_2024 or 0),
'sales_2025': int(r.sales_2025 or 0)
}
for r in div_dist_q.all()
],
'price_contribution': [
{
'range': r.price_range,
'sales': int(r.sales or 0),
'sales_2024': int(r.sales_2024 or 0),
'sales_2025': int(r.sales_2025 or 0)
}
for r in price_cont_q.all()
],
'bcg_data': [
{'name': f"{r.brand_name}-{r.area_name}", 'qty': int(r.vol or 0), 'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0, 'sales': int(r.sales or 0)}
for r in bcg_q.all()
],
'heatmap_data': [
{'month': f"{r.year}-{r.month:02d}", 'category': r.division, 'sales': int(r.sales or 0)}
for r in heatmap_q.all()
],
'highlights': {
'rev_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in rev_top_q.all()],
'profit_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in profit_top_q.all()],
'vol_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in vol_top_q.all()]
},
'filters': {
'years': sorted(years_list, reverse=True),
'months': sorted(months_list),
'divisions': sorted(divisions_list),
'pms': sorted(pms_list),
'areas': sorted(areas_list),
'vendors': sorted(vendors_list),
'trades': sorted(trades_list)
},
'rows': rows
})
except Exception as e:
sys_log.error(f"取得月份總表數據失敗: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
finally:
session.close()
# ================= 📊 V-New: 業績分析報表 =================
def _get_filtered_sales_data(cache_key):
"""
🚩 共用函式:從快取讀取資料並根據 request.args 進行篩選
回傳: (target_df, cols_map, error_message)
參數: cache_key - 快取鍵值 (例如: "realtime_sales_monthly_3m")
"""
db = DatabaseManager()
# 1. 檢查資料表與快取
df = None
cols_map = {}
if cache_key in _SALES_PROCESSED_CACHE:
cache_data = _SALES_PROCESSED_CACHE[cache_key]
df = cache_data['df']
cols_map = cache_data['cols']
else:
# 快取不存在時,直接回傳錯誤讓呼叫端顯示 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
@app.route('/api/export/excel/seasonality_detail')
def export_seasonality_detail():
"""API: 匯出淡旺季熱力圖的詳細資料 (點擊氣泡觸發)"""
try:
table_name = 'realtime_sales_monthly'
data_range_months = int(request.args.get('data_range', '1') or '1')
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
if start_date or end_date:
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
else:
cache_key = f"{table_name}_{data_range_months}m"
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
# V-Fix: 如果 cache_key 不存在,嘗試使用固定的 table_name
if err and table_name in _SALES_PROCESSED_CACHE:
target_df, cols_map, err = _get_filtered_sales_data(table_name)
if err: return f"匯出失敗: {err}", 400
# 取得額外參數
target_month = request.args.get('target_month')
target_category = request.args.get('target_category')
if not target_month or not target_category:
return "缺少必要參數 (month, category)", 400
col_category = cols_map.get('category')
# 進一步篩選
export_df = target_df[
(target_df['_month_str'] == target_month) &
(target_df[col_category] == target_category)
]
if export_df.empty:
return "該月份與分類無資料", 404
# 使用 BytesIO 直接在記憶體中產生 Excel (避免 Exporter 的類型不相容)
import io
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
export_df.to_excel(writer, index=False, sheet_name='明細')
output.seek(0)
filename = f"Seasonality_{target_category}_{target_month}.xlsx"
return send_file(
output,
as_attachment=True,
download_name=filename,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except Exception as e:
sys_log.error(f"Seasonality Export Error: {e}")
return f"匯出失敗: {e}", 500
# ================= 💎 V-New: Top 3 Highlights 詳細列表 API =================
@app.route('/api/sales_analysis/top_detail')
def get_top_detail():
"""API: 取得 Top N 詳細列表(業績貢獻王/獲利金雞母/人氣引流款)"""
try:
from datetime import datetime, timedelta, timezone
TAIPEI_TZ = timezone(timedelta(hours=8))
table_name = 'realtime_sales_monthly'
data_range_months = int(request.args.get('data_range', '1') or '1')
start_date = request.args.get('start_date', '') # V-New: 自訂開始日期
end_date = request.args.get('end_date', '') # V-New: 自訂結束日期
top_type = request.args.get('type', 'revenue') # revenue/margin/quantity
metric = request.args.get('metric', 'amount') # amount/profit/qty
view_type = request.args.get('view', 'product') # product/category
db = DatabaseManager()
# V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱
if start_date or end_date:
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
else:
cache_key = f"{table_name}_{data_range_months}m"
# 嘗試從快取讀取欄位名稱
cols_map = {}
if cache_key in _SALES_PROCESSED_CACHE:
cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {})
elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key
cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {})
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
# 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}")