4800 lines
227 KiB
Python
4800 lines
227 KiB
Python
# ================= TODO LIST (待辦事項 - 重開機後請依序執行) =================
|
||
# 1. [驗證] 重啟 app.py 後,重新匯入 Excel,確認「自動去重」功能是否生效 (重複匯入應顯示 0 筆新增)。
|
||
# 2. [檢查] 前往 /sales_analysis 頁面,確認 '狀態' 欄位是否正確顯示 'F' (目前為原始匯入模式)。
|
||
# 3. [決策] 若資料顯示正常,評估是否需要恢復「智慧資料清理」邏輯 (目前程式碼第 1160 行左右已註解)。
|
||
# 4. [備份] 確認系統運作正常後,執行系統備份。
|
||
# =======================================================================
|
||
|
||
import os
|
||
import sys
|
||
import time
|
||
import threading
|
||
import math
|
||
import json
|
||
import hashlib
|
||
import shutil
|
||
import zipfile
|
||
import re
|
||
import io # V-New: 用於 Excel 匯出
|
||
import traceback # V-Fix: 用於錯誤追蹤
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
# ================= 🔧 1. 環境與路徑鎖定 =================
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
# 確保專案根目錄在 sys.path 的最前面,優先讀取本地模組
|
||
sys.path.insert(0, BASE_DIR)
|
||
|
||
# 自動檢核並建立必要目錄
|
||
try:
|
||
for folder in ['database', 'services', 'crawler', 'logs', 'data', 'web/templates', 'web/static']:
|
||
folder_path = os.path.join(BASE_DIR, folder)
|
||
if not os.path.exists(folder_path):
|
||
os.makedirs(folder_path)
|
||
|
||
# 僅針對 Python 套件目錄建立 __init__.py
|
||
if 'web' not in folder:
|
||
init_file = os.path.join(folder_path, '__init__.py')
|
||
if not os.path.exists(init_file):
|
||
with open(init_file, 'w') as f: pass
|
||
except OSError as e:
|
||
print(f"❌ 系統初始化失敗: 無法建立目錄或檔案 (磁碟可能已滿) - {e}")
|
||
|
||
# ================= 🔧 2. 核心模組導入 =================
|
||
try:
|
||
from flask import Flask, render_template, jsonify, request, send_file, redirect, url_for, send_from_directory, flash, session
|
||
from werkzeug.utils import secure_filename
|
||
from pyngrok import ngrok, conf
|
||
import schedule
|
||
from sqlalchemy import desc, and_, func, text, literal, case
|
||
from sqlalchemy import inspect # V-New: 用於檢查資料表是否存在
|
||
from sqlalchemy.orm import joinedload
|
||
import pandas as pd # type: ignore
|
||
from pandas.api.types import is_numeric_dtype # type: ignore
|
||
import numpy as np # type: ignore # V-Opt: 引入 numpy 進行向量化運算加速
|
||
|
||
# 導入自定義模組
|
||
try:
|
||
from scheduler import run_momo_task, run_edm_task, run_festival_task, run_auto_import_task, run_whitepage_check, run_competitor_price_feeder_task
|
||
from database.manager import DatabaseManager
|
||
from database.models import Base, Product, PriceRecord, MonthlySummaryAnalysis
|
||
from database.edm_models import PromoProduct
|
||
except ImportError as e:
|
||
print(f"❌ 專案內部檔案缺失: {e}\n請檢查 database/ 或 services/ 目錄下的 .py 檔案是否存在。")
|
||
sys.exit(1)
|
||
|
||
from services.logger_manager import SystemLogger
|
||
from services.exporter import Exporter # 🚩 導入匯出模組
|
||
except ImportError as e:
|
||
print(f"❌ 關鍵套件導入失敗: {e}")
|
||
sys.exit(1)
|
||
|
||
# ================= 🔧 3. 系統核心配置 =================
|
||
# 從 config.py 匯入必要的設定
|
||
from config import EXCEL_EXPORT_DIR, DATABASE_TYPE, validate_critical_config
|
||
|
||
sys_log = SystemLogger("Web_Server").get_logger()
|
||
|
||
# 驗證選用配置,缺少時輸出 warning(非 fatal)
|
||
for _warn in validate_critical_config():
|
||
sys_log.warning(_warn)
|
||
|
||
# 🚩 V-Opt: 全域資料快取 (用於加速業績分析)
|
||
_SALES_DF_CACHE = {} # 已棄用,保留相容性
|
||
_SALES_PROCESSED_CACHE = {} # V-Opt: 處理後資料快取
|
||
_SALES_CACHE_MAX_ENTRIES = 10 # V-Opt (2026-01-23): 快取最大條目數
|
||
_SALES_CACHE_TTL = 600 # V-Opt (2026-01-23): 快取有效期 10 分鐘
|
||
|
||
|
||
def _cleanup_sales_cache():
|
||
"""清理過期和過多的快取條目"""
|
||
global _SALES_PROCESSED_CACHE
|
||
current_time = time.time()
|
||
|
||
# 1. 清理過期條目
|
||
expired_keys = [
|
||
k for k, v in _SALES_PROCESSED_CACHE.items()
|
||
if v.get('time') and current_time - v['time'] > _SALES_CACHE_TTL
|
||
]
|
||
for k in expired_keys:
|
||
del _SALES_PROCESSED_CACHE[k]
|
||
|
||
# 2. 如果仍超過限制,刪除最舊的條目
|
||
if len(_SALES_PROCESSED_CACHE) > _SALES_CACHE_MAX_ENTRIES:
|
||
sorted_items = sorted(
|
||
[(k, v.get('time', 0)) for k, v in _SALES_PROCESSED_CACHE.items()],
|
||
key=lambda x: x[1]
|
||
)
|
||
# 保留最新的 _SALES_CACHE_MAX_ENTRIES 條
|
||
keys_to_delete = [k for k, _ in sorted_items[:-_SALES_CACHE_MAX_ENTRIES]]
|
||
for k in keys_to_delete:
|
||
del _SALES_PROCESSED_CACHE[k]
|
||
|
||
if expired_keys or len(_SALES_PROCESSED_CACHE) > _SALES_CACHE_MAX_ENTRIES - 2:
|
||
sys_log.debug(f"[Cache] 清理快取: 移除 {len(expired_keys)} 條過期, 剩餘 {len(_SALES_PROCESSED_CACHE)} 條")
|
||
|
||
# 🚩 V-New: 商品看板資料快取 (用於加速首頁載入)
|
||
_DASHBOARD_DATA_CACHE = {
|
||
'consolidated_data': None, # get_consolidated_data() 結果
|
||
'consolidated_timestamp': None, # 快取時間戳記
|
||
'stats_data': None, # 統計資料
|
||
'stats_timestamp': None # 統計資料時間戳記
|
||
}
|
||
_DASHBOARD_CACHE_TTL = 300 # 快取有效期 5 分鐘(秒)
|
||
|
||
# 🚩 檢查磁碟空間 (V9.52 新增)
|
||
try:
|
||
total, used, free = shutil.disk_usage(BASE_DIR)
|
||
if free < 200 * 1024 * 1024: # 小於 200MB
|
||
sys_log.critical(f"[System] [DISK_CHECK] 🚨 嚴重警告: 磁碟空間極低 | Free: {free // (1024*1024)} MB")
|
||
elif free < 1024 * 1024 * 1024: # 小於 1GB
|
||
sys_log.warning(f"[System] [DISK_CHECK] ⚠️ 警告: 磁碟空間不足 1GB | Free: {free // (1024*1024)} MB")
|
||
except Exception as e:
|
||
sys_log.error(f"無法檢測磁碟空間: {e}")
|
||
|
||
# 🚩 系統版本定義 (備份與顯示用)
|
||
# 🚩 2026-04-19 V10.3: 技術債清零 — Migration 010/011、retry queue 持久化、
|
||
# NemoTron store_insight 雙寫、import 前置欄位防禦、時間衰減 RAG
|
||
SYSTEM_VERSION = "V10.3"
|
||
|
||
# ==========================================
|
||
# 🔒 SQL Injection 防護函數
|
||
# ==========================================
|
||
|
||
# 允許的資料表白名單
|
||
# 安全工具:實作已搬至 utils/security.py,此處 re-export 維持向後相容
|
||
from utils.security import ( # noqa: E402
|
||
ALLOWED_TABLES,
|
||
validate_table_name,
|
||
validate_column_names,
|
||
)
|
||
|
||
# 安全工具:路徑遍歷 + 檔案上傳驗證 + safe_read_sql 已搬至 utils/security.py
|
||
from utils.security import ( # noqa: E402
|
||
safe_read_sql,
|
||
safe_join,
|
||
ALLOWED_UPLOAD_EXTENSIONS,
|
||
ALLOWED_MIME_TYPES,
|
||
secure_filename_unicode,
|
||
allowed_file,
|
||
validate_upload_file,
|
||
)
|
||
|
||
# 🚩 資料庫結構自動修復 (V9.53 新增) — 實作搬至 database/schema_repair.py
|
||
from database.schema_repair import repair_database_schema # noqa: E402, F401
|
||
|
||
# 從環境變數讀取 NGROK_AUTH_TOKEN(如果未設定則使用原值,但會發出警告)
|
||
NGROK_AUTH_TOKEN = os.getenv('NGROK_AUTH_TOKEN', '36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF')
|
||
if NGROK_AUTH_TOKEN == '36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF':
|
||
sys_log.warning("[Security] ⚠️ 使用預設 NGROK_AUTH_TOKEN,請設定環境變數")
|
||
conf.get_default().auth_token = NGROK_AUTH_TOKEN
|
||
|
||
TEMPLATE_DIR = BASE_DIR # 修正:根據檔案結構,模板位於根目錄
|
||
TEMPLATE_DIR_NEW = os.path.join(BASE_DIR, 'templates') # 新模板路徑(模組化)
|
||
STATIC_DIR = os.path.join(BASE_DIR, 'web/static')
|
||
|
||
# 檢查關鍵模板是否存在
|
||
if not os.path.exists(os.path.join(BASE_DIR, 'dashboard.html')):
|
||
sys_log.warning(f"[Web] [Template] ⚠️ 警告: 找不到 dashboard.html | Path: {TEMPLATE_DIR}")
|
||
|
||
app = Flask(__name__,
|
||
template_folder=TEMPLATE_DIR,
|
||
static_folder=STATIC_DIR)
|
||
|
||
# 設定多路徑模板載入器(同時支援根目錄和 templates/ 目錄)
|
||
from jinja2 import FileSystemLoader, ChoiceLoader
|
||
app.jinja_loader = ChoiceLoader([
|
||
FileSystemLoader(TEMPLATE_DIR_NEW), # templates/ 目錄優先
|
||
FileSystemLoader(TEMPLATE_DIR), # 根目錄備用
|
||
])
|
||
|
||
# ==========================================
|
||
# 🔒 Flask 安全配置
|
||
# ==========================================
|
||
|
||
# 從 config.py 導入 SECRET_KEY
|
||
from config import SECRET_KEY
|
||
|
||
# 基本配置
|
||
app.config['SECRET_KEY'] = SECRET_KEY
|
||
|
||
# Session 安全配置
|
||
app.config['SESSION_COOKIE_HTTPONLY'] = True # 防止 JavaScript 存取 cookie(防 XSS)
|
||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # 防止 CSRF 攻擊
|
||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=24) # Session 有效期 24 小時(延長避免長時間閒置斷線)
|
||
|
||
# 如果使用 HTTPS,啟用 SECURE cookie(本地開發時應設為 False)
|
||
# 注意:如果您的系統部署在 HTTPS 環境,請將 .env 中的 USE_HTTPS 設為 true
|
||
USE_HTTPS = os.getenv('USE_HTTPS', 'false').lower() == 'true'
|
||
if USE_HTTPS:
|
||
app.config['SESSION_COOKIE_SECURE'] = True
|
||
sys_log.info("[Security] ✅ HTTPS 模式已啟用,Session cookie 僅透過 HTTPS 傳輸")
|
||
else:
|
||
app.config['SESSION_COOKIE_SECURE'] = False
|
||
sys_log.warning("[Security] ⚠️ HTTP 模式(開發環境),Session cookie 未強制 HTTPS")
|
||
|
||
# 檔案上傳大小限制(10MB)
|
||
# V-New: 提高檔案上傳大小限制 (從 10MB 提高到 100MB)
|
||
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024
|
||
|
||
sys_log.info("[Security] ✅ Flask 安全配置已載入")
|
||
sys_log.info(f"[Security] • Session 有效期: 2 小時")
|
||
sys_log.info(f"[Security] • 檔案上傳限制: 10 MB")
|
||
sys_log.info(f"[Security] • CSRF 防護: SameSite=Lax")
|
||
sys_log.info(f"[Security] • XSS 防護: HttpOnly=True")
|
||
|
||
# ==========================================
|
||
# 🔒 CSRF 防護配置
|
||
# ==========================================
|
||
|
||
from flask_wtf.csrf import CSRFProtect
|
||
|
||
csrf = CSRFProtect(app)
|
||
sys_log.info("[Security] ✅ CSRF 防護已啟用 (Flask-WTF)")
|
||
|
||
# ==========================================
|
||
# 🔧 Blueprint 註冊 - 廠商缺貨系統
|
||
# ==========================================
|
||
from routes.vendor_routes import vendor_bp
|
||
app.register_blueprint(vendor_bp)
|
||
sys_log.info("[Blueprint] ✅ 廠商缺貨系統 Blueprint 已註冊")
|
||
|
||
# ==========================================
|
||
# 🔧 Blueprint 註冊 - Google Drive 自動匯入
|
||
# ==========================================
|
||
from routes.auto_import_routes import auto_import_bp
|
||
app.register_blueprint(auto_import_bp)
|
||
csrf.exempt(auto_import_bp)
|
||
sys_log.info("[Blueprint] ✅ Google Drive 自動匯入 Blueprint 已註冊 (CSRF 已豁免)")
|
||
|
||
# ==========================================
|
||
# 🔧 Blueprint 註冊 - 爬蟲管理系統
|
||
# ==========================================
|
||
from routes.crawler_management_routes import crawler_bp
|
||
app.register_blueprint(crawler_bp)
|
||
sys_log.info("[Blueprint] ✅ 爬蟲管理系統 Blueprint 已註冊")
|
||
|
||
# ==========================================
|
||
# 🔧 Blueprint 註冊 - AI 智慧文案系統
|
||
# ==========================================
|
||
from routes.ai_routes import ai_bp
|
||
app.register_blueprint(ai_bp)
|
||
csrf.exempt(ai_bp) # ICAIM API 使用內部呼叫,不需要 CSRF
|
||
sys_log.info("[Blueprint] ✅ AI 智慧文案系統 Blueprint 已註冊")
|
||
|
||
# ==========================================
|
||
# 🔧 Blueprint 註冊 - CI/CD Dashboard
|
||
# ==========================================
|
||
from routes.cicd_routes import cicd_bp
|
||
app.register_blueprint(cicd_bp)
|
||
csrf.exempt(cicd_bp) # CI/CD API doesn't need CSRF
|
||
sys_log.info("[Blueprint] CI/CD Dashboard Blueprint registered")
|
||
|
||
# ==========================================
|
||
# 🔧 Blueprint 註冊 - Code Review 系統
|
||
# ==========================================
|
||
try:
|
||
from routes.code_review_routes import code_review_bp
|
||
app.register_blueprint(code_review_bp)
|
||
csrf.exempt(code_review_bp) # Code Review API 使用內部認證,不需要 CSRF
|
||
sys_log.info("[Blueprint] ✅ Code Review 系統 Blueprint 已註冊 (CSRF 已豁免)")
|
||
except Exception as _e:
|
||
sys_log.warning(f"[Blueprint] ⚠️ Code Review 系統 Blueprint 註冊失敗: {_e}")
|
||
|
||
# ==========================================
|
||
# 🔧 Blueprint 註冊 - 趨勢資料系統
|
||
# ==========================================
|
||
from routes.trend_routes import trend_bp
|
||
app.register_blueprint(trend_bp)
|
||
sys_log.info("[Blueprint] ✅ 趨勢資料系統 Blueprint 已註冊")
|
||
|
||
# ==========================================
|
||
# 🔒 Auth 路由註冊 - 登入/登出
|
||
# ==========================================
|
||
from auth import init_auth_routes, login_required
|
||
init_auth_routes(app)
|
||
sys_log.info("[Auth] ✅ 登入/登出路由已註冊")
|
||
|
||
# ==========================================
|
||
# 🔧 Blueprint 註冊 - 用戶管理系統
|
||
# ==========================================
|
||
from routes.user_routes import user_bp
|
||
app.register_blueprint(user_bp)
|
||
sys_log.info("[Blueprint] ✅ 用戶管理系統 Blueprint 已註冊")
|
||
|
||
# ==========================================
|
||
# 🚨 Blueprint 註冊 - 系統告警
|
||
# ==========================================
|
||
from routes.alert_routes import alert_bp
|
||
app.register_blueprint(alert_bp)
|
||
csrf.exempt(alert_bp)
|
||
sys_log.info("[Blueprint] ✅ 系統告警 Blueprint 已註冊 (CSRF 已豁免)")
|
||
|
||
# ==========================================
|
||
# 系統管理路由 Blueprint
|
||
# ==========================================
|
||
from routes.system_routes import system_bp
|
||
app.register_blueprint(system_bp)
|
||
csrf.exempt(system_bp) # n8n API 需要豁免 CSRF
|
||
sys_log.info("[Blueprint] ✅ 系統管理 Blueprint 已註冊 (CSRF 已豁免)")
|
||
|
||
from routes.category_routes import category_bp
|
||
app.register_blueprint(category_bp)
|
||
sys_log.info("[Blueprint] ✅ 分類 CRUD Blueprint 已註冊")
|
||
|
||
from routes.misc_routes import misc_bp
|
||
app.register_blueprint(misc_bp)
|
||
sys_log.info("[Blueprint] ✅ 雜項 Routes Blueprint 已註冊 (/api/test_url, /brand_assets)")
|
||
|
||
# ==========================================
|
||
# 通知模板管理 Blueprint
|
||
# ==========================================
|
||
from routes.notification_routes import notification_bp
|
||
app.register_blueprint(notification_bp)
|
||
csrf.exempt(notification_bp) # n8n API 需要豁免 CSRF
|
||
sys_log.info("[Blueprint] ✅ 通知模板管理 Blueprint 已註冊")
|
||
|
||
# ==========================================
|
||
# Bot API Blueprint (Clawdbot 整合)
|
||
# ==========================================
|
||
from routes.bot_api_routes import bot_api_bp
|
||
app.register_blueprint(bot_api_bp)
|
||
csrf.exempt(bot_api_bp) # Bot API 使用 Token 認證,不需要 CSRF
|
||
sys_log.info("[Blueprint] ✅ Bot API Blueprint 已註冊")
|
||
|
||
|
||
# ==========================================
|
||
# Elephant Alpha AI Agent Super Orchestrator Blueprint
|
||
# ==========================================
|
||
try:
|
||
from routes.elephant_alpha_routes import elephant_alpha_bp
|
||
app.register_blueprint(elephant_alpha_bp)
|
||
csrf.exempt(elephant_alpha_bp) # Elephant Alpha API uses internal auth
|
||
sys_log.info("[Blueprint] Elephant Alpha AI Agent Super Orchestrator Blueprint registered")
|
||
except Exception as _e:
|
||
sys_log.warning(f"[Blueprint] Elephant Alpha registration failed: {_e}")
|
||
sys_log.info("[Blueprint] Elephant Alpha features will be unavailable")
|
||
|
||
# [2026-04-18 台北] OpenClaw Bot Blueprint — 修復 /menu 啞巴 (/bot/telegram/webhook 404)
|
||
# 原因:routes/openclaw_bot_routes.py 有 5000+ 行完整 telegram bot handler,但 app.py 從未 register
|
||
# 效果:Telegram 送進來的 update (包含 /menu) 能被正確接收與處理
|
||
try:
|
||
from routes.openclaw_bot_routes import openclaw_bot_bp
|
||
app.register_blueprint(openclaw_bot_bp)
|
||
csrf.exempt(openclaw_bot_bp) # Telegram webhook 不需要 CSRF
|
||
sys_log.info("[Blueprint] ✅ OpenClaw Bot Blueprint 已註冊 (Telegram /menu 復活)")
|
||
except Exception as _e:
|
||
sys_log.error(f"[Blueprint] ❌ OpenClaw Bot Blueprint 註冊失敗: {_e}")
|
||
|
||
# P0-12 修復:補齊缺少的 Blueprint 註冊
|
||
for _bp_module, _bp_name in [
|
||
('routes.api_routes', 'api_bp'),
|
||
('routes.edm_routes', 'edm_bp'),
|
||
('routes.sales_routes', 'sales_bp'),
|
||
('routes.monthly_routes', 'monthly_bp'),
|
||
('routes.price_comparison_routes', 'price_comparison_bp'),
|
||
('routes.export_routes', 'export_bp'),
|
||
('routes.daily_sales_routes', 'daily_sales_bp'),
|
||
('routes.dashboard_routes', 'dashboard_bp'),
|
||
('routes.import_routes', 'import_bp'),
|
||
('routes.pchome_routes', 'pchome_bp'),
|
||
]:
|
||
try:
|
||
import importlib as _il
|
||
_mod = _il.import_module(_bp_module)
|
||
_bp = getattr(_mod, _bp_name)
|
||
app.register_blueprint(_bp)
|
||
sys_log.info(f"[Blueprint] ✅ {_bp_name} 已註冊")
|
||
except Exception as _e:
|
||
sys_log.error(f"[Blueprint] ❌ {_bp_name} 註冊失敗: {_e}")
|
||
|
||
# V-Fix: 註冊 slugify 函數供模板使用(實作搬至 utils/text_helpers.py)
|
||
from utils.text_helpers import slugify # noqa: E402
|
||
|
||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||
public_url = "服務啟動中..."
|
||
|
||
# 🚩 時區設定:台北時間 (UTC+8)
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
EXPECTED_METADATA_TABLES = {
|
||
'categories', 'products', 'price_records', 'monthly_summary_analysis',
|
||
'users', 'login_history', 'permissions', 'user_permissions',
|
||
'promo_products', 'trend_records', 'trend_keywords', 'trend_analysis',
|
||
'web_search_cache', 'telegram_users',
|
||
'ai_generation_history', 'ai_prompt_templates', 'ai_usage_tracking', 'ai_insights',
|
||
'agent_context', 'action_plans', 'action_outcomes', 'agent_strategy_weights',
|
||
'incidents', 'playbooks', 'heal_logs',
|
||
'import_jobs', 'import_config', 'notification_templates', 'ppt_reports',
|
||
'vendor_stockout', 'vendor_list', 'vendor_emails', 'email_send_log',
|
||
'realtime_sales_monthly',
|
||
}
|
||
|
||
|
||
def verify_metadata_tables():
|
||
missing = EXPECTED_METADATA_TABLES - set(Base.metadata.tables.keys())
|
||
if missing:
|
||
raise SystemExit(f"Base.metadata 漏表: {sorted(missing)}")
|
||
|
||
|
||
verify_metadata_tables()
|
||
|
||
# ==========================================
|
||
# 🔧 全域模板變數注入 (Context Processor)
|
||
# ==========================================
|
||
from config import METABASE_URL, GRIST_URL
|
||
|
||
@app.context_processor
|
||
def inject_global_vars():
|
||
"""注入全域變數到所有模板"""
|
||
return {
|
||
'metabase_url': METABASE_URL,
|
||
'grist_url': GRIST_URL,
|
||
'datetime_now': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'),
|
||
}
|
||
|
||
sys_log.info("[Template] ✅ 全域模板變數已注入 (metabase_url, grist_url)")
|
||
|
||
# ================= 🛠️ V9.72: 分類設定管理核心 =================
|
||
CATEGORIES_JSON_PATH = os.path.join(BASE_DIR, 'data', 'categories.json')
|
||
|
||
# JSON 持久化:實作搬至 services/json_storage.py
|
||
from services.json_storage import ( # noqa: E402, F401
|
||
load_categories,
|
||
save_categories,
|
||
load_scheduler_stats,
|
||
)
|
||
|
||
# ================= 🛠️ 數據處理核心 (封裝) =================
|
||
|
||
# 純工具:實作已搬至 utils/text_helpers.py
|
||
from utils.text_helpers import ( # noqa: E402
|
||
get_color_for_string,
|
||
extract_snapshot_date_from_filename,
|
||
number_format as _number_format,
|
||
)
|
||
|
||
|
||
@app.template_filter('number_format')
|
||
def number_format_filter(value):
|
||
"""Jinja filter wrapper — 實作見 utils.text_helpers.number_format。"""
|
||
return _number_format(value)
|
||
|
||
# V-Refactor: 將 find_col 移至全域,方便多個函式共用
|
||
from utils.df_helpers import find_col # noqa: E402
|
||
|
||
def get_consolidated_data():
|
||
"""🚩 統一封裝:獲取全分類去重後的當前數據、昨日對比及差值 (V-Opt: 優化查詢效能 + 快取)"""
|
||
global _DASHBOARD_DATA_CACHE
|
||
|
||
# V-New: 檢查快取是否有效
|
||
now = datetime.now(TAIPEI_TZ)
|
||
if (_DASHBOARD_DATA_CACHE['consolidated_data'] is not None and
|
||
_DASHBOARD_DATA_CACHE['consolidated_timestamp'] is not None):
|
||
cache_age = (now.timestamp() - _DASHBOARD_DATA_CACHE['consolidated_timestamp'])
|
||
if cache_age < _DASHBOARD_CACHE_TTL:
|
||
sys_log.debug(f"[Dashboard] [Cache] ✅ 使用快取資料 | 快取年齡: {cache_age:.1f}秒")
|
||
return _DASHBOARD_DATA_CACHE['consolidated_data'], _DASHBOARD_DATA_CACHE['today_start']
|
||
|
||
sys_log.debug("[Dashboard] [Cache] 🔄 快取過期或不存在,重新查詢資料庫")
|
||
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
seven_days_ago = today_start - timedelta(days=7)
|
||
thirty_days_ago = today_start - timedelta(days=30)
|
||
|
||
try:
|
||
# Query 1: Get the latest price record for every product. This is our main list of items.
|
||
latest_price_subq = session.query(
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
latest_records = session.query(PriceRecord).options(
|
||
joinedload(PriceRecord.product)
|
||
).join(latest_price_subq, PriceRecord.id == latest_price_subq.c.max_id).all()
|
||
|
||
product_ids = [r.product_id for r in latest_records]
|
||
if not product_ids:
|
||
session.close() # 提前關閉連線
|
||
return [], today_start
|
||
|
||
# Query 2: Get yesterday's closing prices for all products in one go
|
||
yesterday_prices_subq = session.query(
|
||
PriceRecord.product_id,
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).filter(
|
||
PriceRecord.product_id.in_(product_ids),
|
||
PriceRecord.timestamp < today_start
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
yesterday_prices_q = session.query(
|
||
PriceRecord.product_id, PriceRecord.price
|
||
).join(
|
||
yesterday_prices_subq,
|
||
PriceRecord.id == yesterday_prices_subq.c.max_id
|
||
)
|
||
yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q}
|
||
|
||
# Query 3: Get specific historical price points (7 days ago and 30 days ago)
|
||
# Instead of fetching ALL history, we fetch only the records closest to the target dates.
|
||
# This is a significant optimization.
|
||
|
||
# Helper to get price map for a specific date (start of day)
|
||
def get_price_map_before(target_date):
|
||
subq = session.query(
|
||
PriceRecord.product_id,
|
||
func.max(PriceRecord.timestamp).label('max_ts')
|
||
).filter(
|
||
PriceRecord.product_id.in_(product_ids),
|
||
PriceRecord.timestamp < target_date
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
q = session.query(PriceRecord.product_id, PriceRecord.price).join(
|
||
subq,
|
||
and_(PriceRecord.product_id == subq.c.product_id, PriceRecord.timestamp == subq.c.max_ts)
|
||
)
|
||
return {pid: price for pid, price in q}
|
||
|
||
prices_7d_ago_map = get_price_map_before(seven_days_ago + timedelta(days=1)) # Approximate 7 days ago
|
||
prices_30d_ago_map = get_price_map_before(thirty_days_ago + timedelta(days=1)) # Approximate 30 days ago
|
||
|
||
# Query 4: Get TODAY's records only (for sparkline/intraday change)
|
||
today_records_q = session.query(PriceRecord).filter(
|
||
PriceRecord.product_id.in_(product_ids),
|
||
PriceRecord.timestamp >= today_start
|
||
).order_by(PriceRecord.product_id, PriceRecord.timestamp).all()
|
||
|
||
today_map = {}
|
||
for r in today_records_q:
|
||
if r.product_id not in today_map: today_map[r.product_id] = []
|
||
today_map[r.product_id].append(r)
|
||
|
||
# Final Assembly (in-memory, no more DB queries)
|
||
unique_items = []
|
||
for r in latest_records:
|
||
pid = r.product_id
|
||
|
||
# 7d/30d stats
|
||
price_7d = prices_7d_ago_map.get(pid)
|
||
price_30d = prices_30d_ago_map.get(pid)
|
||
|
||
stats_7d_diff = r.price - price_7d if price_7d is not None else 0
|
||
stats_30d_diff = r.price - price_30d if price_30d is not None else 0
|
||
|
||
# Today's stats
|
||
today_records = today_map.get(pid, [])
|
||
today_diff = 0
|
||
today_changes = []
|
||
if len(today_records) > 1:
|
||
today_diff = today_records[-1].price - today_records[0].price
|
||
|
||
# Yesterday diff
|
||
y_price = yesterday_prices_map.get(pid)
|
||
yesterday_diff = r.price - y_price if y_price is not None else 0
|
||
|
||
status = "NONE"
|
||
if yesterday_diff > 0:
|
||
status = "PRICE_UP"
|
||
elif yesterday_diff < 0:
|
||
status = "PRICE_DOWN"
|
||
|
||
# Today's changes details
|
||
last_p = y_price if y_price is not None else (today_records[0].price if today_records else r.price)
|
||
for tr in today_records:
|
||
if tr.price != last_p:
|
||
diff = tr.price - last_p
|
||
today_changes.append({
|
||
'time': tr.timestamp.strftime('%H:%M'),
|
||
'price': tr.price,
|
||
'diff': diff
|
||
})
|
||
last_p = tr.price
|
||
|
||
unique_items.append({
|
||
'record': r,
|
||
'stats': {'7d_diff': stats_7d_diff, '30d_diff': stats_30d_diff, '1d_diff': today_diff},
|
||
'yesterday_diff': yesterday_diff,
|
||
'today_changes': today_changes,
|
||
'status': status
|
||
})
|
||
|
||
# V-New: 更新快取
|
||
_DASHBOARD_DATA_CACHE['consolidated_data'] = unique_items
|
||
_DASHBOARD_DATA_CACHE['consolidated_timestamp'] = now.timestamp()
|
||
_DASHBOARD_DATA_CACHE['today_start'] = today_start
|
||
sys_log.debug(f"[Dashboard] [Cache] 💾 快取已更新 | 商品數: {len(unique_items)}")
|
||
|
||
return unique_items, today_start
|
||
finally:
|
||
session.close()
|
||
|
||
def get_dashboard_stats():
|
||
"""計算看板統計數據 (供通知使用)"""
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
unique_items, today_start = get_consolidated_data()
|
||
today_start_db = today_start.replace(tzinfo=None)
|
||
|
||
# 1. 漲跌
|
||
increase_count = sum(1 for item in unique_items if item['yesterday_diff'] > 0)
|
||
decrease_count = sum(1 for item in unique_items if item['yesterday_diff'] < 0)
|
||
|
||
# 2. 今日新增 (使用與 index 路由相同的邏輯)
|
||
new_pids_query = session.query(PriceRecord.product_id).group_by(PriceRecord.product_id).having(func.min(PriceRecord.timestamp) >= today_start_db)
|
||
new_product_ids = {r[0] for r in new_pids_query.all()}
|
||
new_count = len(new_product_ids)
|
||
|
||
# 3. 今日下架
|
||
today_delisted_count = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start_db
|
||
).count()
|
||
|
||
return {'new': new_count, 'up': increase_count, 'down': decrease_count, 'delisted': today_delisted_count}
|
||
except Exception as e:
|
||
sys_log.error(f"[Stats] ❌ 計算統計失敗: {e}")
|
||
return {'new': 0, 'up': 0, 'down': 0, 'delisted': 0}
|
||
finally:
|
||
session.close()
|
||
|
||
# ================= 🛣️ 4. Flask 路由 =================
|
||
|
||
# Session 自動續期機制
|
||
@app.before_request
|
||
def refresh_session():
|
||
"""
|
||
在每次請求時自動刷新 Session,避免長時間閒置後突然斷線
|
||
只要用戶有任何操作,Session 就會自動延長
|
||
"""
|
||
if session.get('logged_in'):
|
||
session.modified = True # 標記 Session 已修改,觸發 Cookie 更新
|
||
|
||
@app.route('/health')
|
||
def health_check():
|
||
"""健康檢查端點 - 供 Nginx 和 Docker healthcheck 使用"""
|
||
try:
|
||
# 簡單檢查資料庫連線
|
||
from config import DATABASE_TYPE
|
||
return jsonify({
|
||
'status': 'healthy',
|
||
'database': DATABASE_TYPE,
|
||
'version': SYSTEM_VERSION
|
||
}), 200
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'unhealthy',
|
||
'error': str(e)
|
||
}), 500
|
||
|
||
|
||
@app.route('/metrics')
|
||
def prometheus_metrics():
|
||
"""Prometheus 指標端點 - 供 Prometheus 抓取監控資料"""
|
||
try:
|
||
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Counter, Gauge, CollectorRegistry
|
||
from config import DATABASE_TYPE
|
||
|
||
# 建立獨立的 registry 以避免重複註冊
|
||
registry = CollectorRegistry()
|
||
|
||
# 應用程式資訊
|
||
app_info = Gauge('momo_app_info', '應用程式資訊', ['version', 'database_type'], registry=registry)
|
||
app_info.labels(version=SYSTEM_VERSION, database_type=DATABASE_TYPE).set(1)
|
||
|
||
# 應用程式健康狀態 (1=健康, 0=不健康)
|
||
app_health = Gauge('momo_app_health', '應用程式健康狀態', registry=registry)
|
||
|
||
# 資料庫連線狀態
|
||
db_status = Gauge('momo_database_up', '資料庫連線狀態', registry=registry)
|
||
|
||
try:
|
||
db = DatabaseManager()
|
||
with db.engine.connect() as conn:
|
||
conn.execute(text("SELECT 1"))
|
||
db_status.set(1)
|
||
app_health.set(1)
|
||
except Exception:
|
||
db_status.set(0)
|
||
app_health.set(0)
|
||
|
||
# 資料庫記錄數
|
||
try:
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
|
||
# 商品數量
|
||
product_count = Gauge('momo_products_total', '商品總數', registry=registry)
|
||
product_count.set(session.query(Product).count())
|
||
|
||
# 價格記錄數量
|
||
price_record_count = Gauge('momo_price_records_total', '價格記錄總數', registry=registry)
|
||
price_record_count.set(session.query(PriceRecord).count())
|
||
|
||
# 業績資料筆數
|
||
from database.realtime_sales_models import RealtimeSalesMonthly
|
||
sales_count = Gauge('momo_sales_records_total', '業績資料總數', registry=registry)
|
||
sales_count.set(session.query(RealtimeSalesMonthly).count())
|
||
|
||
session.close()
|
||
except Exception as e:
|
||
sys_log.warning(f"[Metrics] 無法取得資料庫統計: {e}")
|
||
|
||
# 返回 Prometheus 格式
|
||
from flask import Response
|
||
return Response(generate_latest(registry), mimetype=CONTENT_TYPE_LATEST)
|
||
|
||
except ImportError:
|
||
# prometheus_client 未安裝時的備用方案
|
||
metrics_text = """# HELP momo_app_health 應用程式健康狀態
|
||
# TYPE momo_app_health gauge
|
||
momo_app_health 1
|
||
# HELP momo_app_info 應用程式資訊
|
||
# TYPE momo_app_info gauge
|
||
momo_app_info{version="9.4",database_type="postgresql"} 1
|
||
"""
|
||
from flask import Response
|
||
return Response(metrics_text, mimetype='text/plain; charset=utf-8')
|
||
except Exception as e:
|
||
sys_log.error(f"[Metrics] 指標生成錯誤: {e}")
|
||
from flask import Response
|
||
return Response(f"# Error: {e}\n", mimetype='text/plain; charset=utf-8'), 500
|
||
|
||
|
||
@app.route('/')
|
||
def index():
|
||
db = DatabaseManager()
|
||
|
||
session = db.get_session()
|
||
page = request.args.get('page', 1, type=int)
|
||
category_filter = request.args.get('category', 'all')
|
||
sort_by = request.args.get('sort_by', 'timestamp') # 預設按時間排序
|
||
filter_type = request.args.get('filter', 'all') # 🚩 新增:狀態篩選 (increase, decrease, delisted)
|
||
order = request.args.get('order', 'desc')
|
||
search_query = request.args.get('q', '').strip() # 🚩 新增:搜尋關鍵字
|
||
per_page = 50
|
||
|
||
# 🚩 取得台北時間的今日起始點 (用於資料庫查詢比較)
|
||
# 注意:若資料庫內存的是 naive time (無時區),則需轉為 naive 進行比較
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
today_start_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
try:
|
||
# 🚩 1. 使用封裝函式獲取數據
|
||
unique_items, today_start = get_consolidated_data()
|
||
|
||
# --- 計算今日漲跌統計 ---
|
||
increase_items = [item for item in unique_items if item['yesterday_diff'] > 0]
|
||
decrease_items = [item for item in unique_items if item['yesterday_diff'] < 0]
|
||
|
||
# --- V-New: 取得所有分類並加上筆數統計 ---
|
||
cat_counts = {}
|
||
for item in unique_items:
|
||
c = item['record'].product.category
|
||
if c:
|
||
cat_counts[c] = cat_counts.get(c, 0) + 1
|
||
|
||
all_categories = [f"{cat} ({count}筆)" for cat, count in sorted(cat_counts.items())]
|
||
|
||
# V-Fix: 預先計算今日新增的商品 ID (不依賴 Product.created_at)
|
||
new_product_ids = set()
|
||
try:
|
||
# 找出最早一筆價格紀錄是在今天的商品
|
||
new_pids_query = session.query(PriceRecord.product_id).group_by(PriceRecord.product_id).having(func.min(PriceRecord.timestamp) >= today_start_db)
|
||
new_product_ids = {r[0] for r in new_pids_query.all()}
|
||
except Exception: pass
|
||
|
||
# --- 看板統計數據 ---
|
||
total_products_history = session.query(Product).count()
|
||
today_new_products = session.query(func.count(Product.id)).filter(
|
||
Product.id.in_(
|
||
session.query(PriceRecord.product_id)
|
||
.group_by(PriceRecord.product_id)
|
||
.having(func.min(PriceRecord.timestamp) >= today_start_db)
|
||
)
|
||
).scalar()
|
||
total_price_records = session.query(PriceRecord).count()
|
||
today_updates = session.query(PriceRecord).filter(PriceRecord.timestamp >= today_start_db).count()
|
||
|
||
# 🚩 新增:今日下架商品統計 (狀態為 INACTIVE 且 最後更新時間 >= 今天零點)
|
||
today_delisted_query = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start_db
|
||
)
|
||
raw_delisted_items = today_delisted_query.all()
|
||
today_delisted_count = len(raw_delisted_items)
|
||
|
||
# 🚩 V-Opt: 為下架商品補上最後價格(優化:一次查詢取得所有價格,避免 N+1 問題)
|
||
today_delisted_items = []
|
||
if raw_delisted_items:
|
||
# 取得所有下架商品的 ID
|
||
delisted_ids = [p.id for p in raw_delisted_items]
|
||
|
||
# 一次性查詢所有下架商品的最後價格
|
||
last_prices_subq = session.query(
|
||
PriceRecord.product_id,
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).filter(
|
||
PriceRecord.product_id.in_(delisted_ids)
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
last_prices_q = session.query(
|
||
PriceRecord.product_id,
|
||
PriceRecord.price
|
||
).join(
|
||
last_prices_subq,
|
||
PriceRecord.id == last_prices_subq.c.max_id
|
||
)
|
||
|
||
# 建立 product_id -> price 的映射
|
||
price_map = {pid: price for pid, price in last_prices_q}
|
||
|
||
# 組合結果
|
||
for p in raw_delisted_items:
|
||
price = price_map.get(p.id, 0)
|
||
today_delisted_items.append({'product': p, 'last_price': price})
|
||
|
||
# ========== V9.2: 新增 KPI 計算 ==========
|
||
|
||
# 1. 平均漲跌幅
|
||
avg_increase = sum(item['yesterday_diff'] for item in increase_items) / len(increase_items) if increase_items else 0
|
||
avg_decrease = sum(item['yesterday_diff'] for item in decrease_items) / len(decrease_items) if decrease_items else 0
|
||
|
||
# 2. 今日活躍度(有價格變動的商品百分比)
|
||
active_count = len(increase_items) + len(decrease_items)
|
||
activity_rate = (active_count / total_products_history * 100) if total_products_history > 0 else 0
|
||
|
||
# 3. 最大變動(絕對值最大的價格變動)
|
||
max_change_item = None
|
||
max_change_value = 0
|
||
for item in unique_items:
|
||
if abs(item['yesterday_diff']) > abs(max_change_value):
|
||
max_change_value = item['yesterday_diff']
|
||
max_change_item = item
|
||
|
||
# 4. 週增長 (過去 7 天新增的商品數)
|
||
week_ago_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=7)
|
||
week_ago_db = week_ago_db.replace(tzinfo=None)
|
||
week_new_products = session.query(func.count(Product.id)).filter(
|
||
Product.id.in_(
|
||
session.query(PriceRecord.product_id)
|
||
.group_by(PriceRecord.product_id)
|
||
.having(func.min(PriceRecord.timestamp) >= week_ago_db)
|
||
)
|
||
).scalar() or 0
|
||
|
||
# 5. 價格穩定商品數(7 天內無變價)- V9.3 效能優化版
|
||
seven_days_ago = now_taipei - timedelta(days=7)
|
||
seven_days_ago_db = seven_days_ago.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
|
||
|
||
# 使用 GROUP BY 一次性統計所有商品的不同價格數量(避免 N+1 查詢)
|
||
try:
|
||
stable_count = session.query(PriceRecord.product_id).filter(
|
||
PriceRecord.timestamp >= seven_days_ago_db
|
||
).group_by(PriceRecord.product_id).having(
|
||
func.count(func.distinct(PriceRecord.price)) == 1
|
||
).count()
|
||
except Exception:
|
||
stable_count = 0
|
||
|
||
# 6. 最活躍分類(今日變動商品數最多的分類)
|
||
category_activity = {}
|
||
for item in increase_items + decrease_items:
|
||
cat = item['record'].product.category
|
||
if cat:
|
||
category_activity[cat] = category_activity.get(cat, 0) + 1
|
||
|
||
most_active_category = None
|
||
most_active_count = 0
|
||
if category_activity:
|
||
most_active_category = max(category_activity.items(), key=lambda x: x[1])
|
||
most_active_count = most_active_category[1]
|
||
most_active_category = most_active_category[0]
|
||
|
||
# 🚩 讀取系統狀態 (用於紅綠燈顯示)
|
||
system_status = {"status": "UNKNOWN", "message": "尚無執行紀錄", "timestamp": "-"}
|
||
status_path = os.path.join(BASE_DIR, 'data/system_status.json')
|
||
if os.path.exists(status_path):
|
||
try:
|
||
with open(status_path, 'r', encoding='utf-8') as f:
|
||
system_status = json.load(f)
|
||
except: pass
|
||
|
||
# --- 取得所有分類用於篩選器 ---
|
||
# (已在上方取得)
|
||
|
||
# 🚩 2. 後端篩選 (Server-side Filtering)
|
||
scheduler_stats = load_scheduler_stats()
|
||
|
||
# V-Fix: Handle old scheduler stats format (dict) by converting to list to prevent template errors
|
||
if scheduler_stats.get('momo_task') and isinstance(scheduler_stats.get('momo_task'), dict):
|
||
scheduler_stats['momo_task'] = [scheduler_stats['momo_task']]
|
||
if scheduler_stats.get('edm_task') and isinstance(scheduler_stats.get('edm_task'), dict):
|
||
scheduler_stats['edm_task'] = [scheduler_stats['edm_task']]
|
||
|
||
filtered_items = []
|
||
|
||
# 0. 先處理搜尋 (若有)
|
||
if search_query:
|
||
search_lower = search_query.lower()
|
||
# V9.81: 搜尋功能修復,支援搜尋商品名稱與 i_code
|
||
base_items = [
|
||
item for item in unique_items
|
||
if (item['record'].product.name and search_lower in item['record'].product.name.lower()) or
|
||
(item['record'].product.i_code and search_lower in str(item['record'].product.i_code))
|
||
]
|
||
else:
|
||
base_items = unique_items
|
||
|
||
# A. 先處理狀態篩選 (漲/跌/下架)
|
||
if filter_type == 'increase':
|
||
filtered_items = [i for i in base_items if i in increase_items]
|
||
elif filter_type == 'decrease':
|
||
filtered_items = [i for i in base_items if i in decrease_items]
|
||
elif filter_type == 'new':
|
||
# V-New: 新上架篩選 (今日新增的商品)
|
||
filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids]
|
||
elif filter_type == 'delisted':
|
||
# 特殊處理:將下架商品轉換為列表格式以便顯示
|
||
for item in today_delisted_items:
|
||
# 模擬 record 物件結構
|
||
class MockRecord:
|
||
def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at
|
||
|
||
if not search_query or search_query.lower() in item['product'].name.lower():
|
||
filtered_items.append({
|
||
'record': MockRecord(item['product'], item['last_price']),
|
||
'stats': {'1d_diff': 0, '7d_diff': 0, '30d_diff': 0}, # 模擬 stats 結構
|
||
'yesterday_diff': 0,
|
||
'today_changes': [], # 確保結構一致
|
||
'status': 'DELISTED' # 新增狀態
|
||
})
|
||
else:
|
||
# B. 若無狀態篩選,則處理分類篩選
|
||
if category_filter != 'all':
|
||
# V-New: 處理帶有筆數的分類名稱,例如 "化妝水 (50筆)" -> "化妝水"
|
||
real_category = category_filter
|
||
if "(" in category_filter and "筆)" in category_filter:
|
||
real_category = category_filter.rsplit(" (", 1)[0]
|
||
filtered_items = [item for item in base_items if item['record'].product.category == real_category]
|
||
else:
|
||
filtered_items = base_items
|
||
|
||
# 🚩 3. 後端排序 (Server-side Sorting)
|
||
reverse = (order == 'desc')
|
||
def get_sort_key(item):
|
||
# 處理 None 值,確保排序時不會出錯
|
||
def safe_get(value, default=0):
|
||
return default if value is None else value
|
||
|
||
if sort_by == 'i_code': return int(safe_get(item['record'].product.i_code, 0))
|
||
if sort_by == 'category': return safe_get(item['record'].product.category, '')
|
||
if sort_by == 'name': return safe_get(item['record'].product.name, '')
|
||
if sort_by == 'price': return safe_get(item['record'].price, 0)
|
||
if sort_by == 'today_change': return safe_get(item['stats']['1d_diff'], 0) # 今日內波動
|
||
if sort_by == 'yesterday_change': return safe_get(item['yesterday_diff'], 0)
|
||
if sort_by == 'week_change': return safe_get(item['stats']['7d_diff'], 0)
|
||
return item['record'].timestamp # 預設
|
||
|
||
sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse)
|
||
|
||
# 🚩 4. 分頁 (Pagination) - 在篩選和排序之後執行
|
||
total_items = len(sorted_items)
|
||
total_pages = math.ceil(total_items / per_page)
|
||
|
||
start_idx = (page - 1) * per_page
|
||
paged_items = sorted_items[start_idx : start_idx + per_page]
|
||
|
||
# V-Fix: 為前端準備安全的 created_at 屬性
|
||
for item in paged_items:
|
||
item['safe_created_at'] = getattr(item['record'].product, 'created_at', None)
|
||
|
||
# 🚩 5. 為當前頁面項目添加顏色
|
||
for item in paged_items:
|
||
category_name = item['record'].product.category
|
||
item['category_color'] = get_color_for_string(category_name)
|
||
|
||
return render_template('dashboard.html',
|
||
total_products=total_products_history,
|
||
today_new_products=today_new_products,
|
||
total_price_records=total_price_records,
|
||
cnt_increase=len(increase_items),
|
||
cnt_decrease=len(decrease_items), # 傳遞跌價數
|
||
today_delisted_count=today_delisted_count,
|
||
today_delisted_items=today_delisted_items,
|
||
system_status=system_status,
|
||
items=paged_items,
|
||
categories=all_categories,
|
||
current_page=page,
|
||
total_pages=total_pages, # V-New: 傳遞總項目數
|
||
total_items=total_items,
|
||
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), # 顯示台北時間
|
||
today_date=now_taipei.strftime('%Y-%m-%d'), # 傳遞今日日期
|
||
public_url=public_url,
|
||
current_category=category_filter,
|
||
current_filter=filter_type, # 傳遞當前篩選狀態
|
||
search_query=search_query, # 傳遞搜尋關鍵字
|
||
current_sort=sort_by,
|
||
current_order=order,
|
||
scheduler_stats=scheduler_stats,
|
||
# V9.2: 新增 KPI 數據
|
||
avg_increase=avg_increase,
|
||
avg_decrease=avg_decrease,
|
||
activity_rate=activity_rate,
|
||
active_count=active_count,
|
||
max_change_item=max_change_item,
|
||
max_change_value=max_change_value,
|
||
week_new_products=week_new_products,
|
||
stable_count=stable_count,
|
||
most_active_category=most_active_category,
|
||
most_active_count=most_active_count)
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Dashboard] 🚨 渲染錯誤 | Error: {e}")
|
||
return f"系統維護中,錯誤詳情:{e}"
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/settings')
|
||
def settings():
|
||
"""分類設定頁面"""
|
||
categories = load_categories()
|
||
return render_template('settings.html',
|
||
categories=categories,
|
||
public_url=public_url,
|
||
system_version=SYSTEM_VERSION)
|
||
|
||
@app.route('/system_settings')
|
||
def system_settings_page():
|
||
"""系統設定與匯入頁面"""
|
||
return render_template('system_settings.html', system_version=SYSTEM_VERSION)
|
||
|
||
@app.route('/edm')
|
||
def edm_dashboard():
|
||
"""🚩 新增:MOMO 限時搶購 (EDM) 專屬儀表板"""
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
|
||
# V-New: 排序參數
|
||
sort_by = request.args.get('sort_by', 'default')
|
||
order = request.args.get('order', 'desc')
|
||
|
||
try:
|
||
# 1. 基礎統計
|
||
# 取得最後更新時間
|
||
last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
|
||
last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料"
|
||
|
||
# 🚩 V9.29 新增:取得最新的活動時間文字
|
||
latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
|
||
activity_time = getattr(latest_entry, 'activity_time_text', '限時搶購') if latest_entry else "限時搶購"
|
||
|
||
# 2. 查詢資料 (V9.44: 只顯示最新批次的資料)
|
||
# 找出最新的 batch_id
|
||
latest_batch = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
|
||
current_batch_id = latest_batch[0] if latest_batch else None
|
||
|
||
# 🚩 V9.55 修正:改為查詢「全商品的最新狀態快照」,而非僅查詢最新批次
|
||
# 因為 Scheduler 現在只記錄異動,若只查最新批次,未變動的商品會消失
|
||
subq = session.query(
|
||
func.max(PromoProduct.id).label('max_id')
|
||
).filter(PromoProduct.page_type == 'edm').group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery()
|
||
|
||
latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all()
|
||
|
||
# 過濾顯示列表:顯示「上架中」、「本批次剛下架」或「今日結束時段」的商品
|
||
items_in_batch = []
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
for item in latest_records:
|
||
# V9.60: 隱藏自然結束的時段商品
|
||
# V-New: 如果商品是今天才結束的,則依然顯示在儀表板上,方便回查
|
||
# V-Fix: 確保時區比較一致
|
||
item_crawled_at = item.crawled_at
|
||
if item_crawled_at and item_crawled_at.tzinfo is None:
|
||
# V-Fix: 使用 replace 而非 localize (datetime.timezone 不支援 localize 方法)
|
||
item_crawled_at = item_crawled_at.replace(tzinfo=TAIPEI_TZ)
|
||
|
||
if item.status_change == 'SLOT_END' and item_crawled_at < today_start:
|
||
continue
|
||
|
||
# V-New: 如果是下架狀態,只有當它是「今天」下架的才顯示
|
||
if item.status_change == 'DELISTED' and item_crawled_at < today_start:
|
||
continue
|
||
items_in_batch.append(item)
|
||
|
||
# V9.45: 按時段分組
|
||
grouped_items = {}
|
||
for item in items_in_batch:
|
||
if item.time_slot not in grouped_items:
|
||
grouped_items[item.time_slot] = []
|
||
grouped_items[item.time_slot].append(item)
|
||
|
||
# 按時段鍵值排序 (e.g., 00:00, 07:00, ...)
|
||
sorted_grouped_items = dict(sorted(grouped_items.items()))
|
||
|
||
# V9.45: 決定預設顯示的頁籤
|
||
def get_current_time_slot():
|
||
hour = datetime.now(TAIPEI_TZ).hour
|
||
available_slots = sorted([int(s.split(':')[0]) for s in sorted_grouped_items.keys() if s and ':' in s]) if sorted_grouped_items else [0, 7, 11, 14, 18, 22]
|
||
current_slot_hour = 0
|
||
for s in available_slots:
|
||
if hour >= s:
|
||
current_slot_hour = s
|
||
return f"{current_slot_hour:02d}:00"
|
||
|
||
active_tab = get_current_time_slot()
|
||
if active_tab not in sorted_grouped_items and sorted_grouped_items:
|
||
active_tab = next(iter(sorted_grouped_items))
|
||
|
||
# V-New: 計算在架天數與總銷量
|
||
all_icodes_in_batch = [item.i_code for item in items_in_batch]
|
||
product_categories = {}
|
||
days_on_shelf_map = {}
|
||
total_sold_map = {}
|
||
|
||
if all_icodes_in_batch:
|
||
# 從主商品表 (products) 查詢這些 i_code 對應的分類
|
||
main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all()
|
||
product_categories = {p.i_code: p.category for p in main_products}
|
||
|
||
# 計算上架天數 (days_on_shelf)
|
||
# V-Fix: 使用 CAST 轉換為 DATE,兼容 PostgreSQL 和 SQLite
|
||
from sqlalchemy import cast, Date
|
||
days_on_shelf_q = session.query(
|
||
PromoProduct.i_code,
|
||
func.count(func.distinct(cast(PromoProduct.crawled_at, Date)))
|
||
).filter( # V-New: 增加 page_type 過濾
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.page_type == 'edm'
|
||
).group_by(PromoProduct.i_code).all()
|
||
days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q}
|
||
|
||
# 計算總銷量
|
||
# 1. 找出每個商品第一次有庫存紀錄的 ID
|
||
first_qty_subq = session.query(
|
||
PromoProduct.i_code,
|
||
func.min(PromoProduct.id).label('min_id')
|
||
).filter(
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.remain_qty.isnot(None),
|
||
PromoProduct.page_type == 'edm'
|
||
).group_by(PromoProduct.i_code).subquery()
|
||
|
||
# 2. 根據 ID 取得當時的庫存
|
||
first_qty_records = session.query(
|
||
PromoProduct.i_code, PromoProduct.remain_qty
|
||
).join(first_qty_subq, PromoProduct.id == first_qty_subq.c.min_id).all()
|
||
first_qty_map = {r[0]: r[1] for r in first_qty_records}
|
||
|
||
# 3. 計算總銷量 (初始庫存 - 當前庫存)
|
||
for item in items_in_batch:
|
||
# 確保該商品有初始庫存紀錄,且當前庫存也存在
|
||
if item.i_code in first_qty_map and item.remain_qty is not None:
|
||
initial_qty = first_qty_map[item.i_code]
|
||
current_qty = item.remain_qty
|
||
# 只有在初始庫存大於當前庫存時才計算,避免負數
|
||
if initial_qty > current_qty:
|
||
total_sold_map[item.i_code] = initial_qty - current_qty
|
||
|
||
# V-Fix: 修正 NameError: name 'history_map' is not defined
|
||
# 準備銷售歷程資料
|
||
history_map = {}
|
||
if all_icodes_in_batch:
|
||
all_history_records = session.query(
|
||
PromoProduct.i_code,
|
||
PromoProduct.time_slot,
|
||
PromoProduct.remain_qty,
|
||
PromoProduct.crawled_at
|
||
).filter(
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.crawled_at >= today_start
|
||
).order_by(PromoProduct.crawled_at).all()
|
||
|
||
for rec in all_history_records:
|
||
key = (rec.i_code, rec.time_slot)
|
||
if key not in history_map:
|
||
history_map[key] = []
|
||
|
||
if rec.remain_qty is not None:
|
||
if not history_map[key] or (history_map[key] and history_map[key][-1]['qty'] != rec.remain_qty):
|
||
history_map[key].append({'time': rec.crawled_at.strftime('%H:%M'), 'qty': rec.remain_qty})
|
||
|
||
# 將查到的分類資訊附加到每個 item 物件上
|
||
for item in items_in_batch:
|
||
item.main_category = product_categories.get(item.i_code)
|
||
if item.main_category:
|
||
item.category_color = get_color_for_string(item.main_category)
|
||
# V-New: 附加在架天數與總銷量
|
||
item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1)
|
||
item.total_sold = total_sold_map.get(item.i_code, 0)
|
||
# V-New: Attach quantity history
|
||
item.qty_history = history_map.get((item.i_code, item.time_slot), [])
|
||
|
||
# V9.46: 排序邏輯優化 (中文註解)
|
||
# 排序規則:
|
||
# 1. 有貼標 (main_category 存在) 的商品優先
|
||
# 2. 有狀態變更 (NEW, 漲價, 降價) 的商品次之
|
||
# 3. 已下架的商品再次之
|
||
# 4. 最後按價格由高到低排序
|
||
reverse = (order == 'desc')
|
||
for time_slot in sorted_grouped_items:
|
||
if sort_by == 'name':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse)
|
||
elif sort_by == 'remain_qty':
|
||
# 將 None 視為 -1,確保排序時在最下方
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.remain_qty if x.remain_qty is not None else -1, reverse=reverse)
|
||
elif sort_by == 'price':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse)
|
||
else: # 預設排序
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: (
|
||
1 if x.main_category else 0,
|
||
2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0),
|
||
x.price if x.price is not None else -1
|
||
), reverse=True)
|
||
|
||
# 🚩 V-Fix: 修正時段統計,使其能區分「當前狀態」與「上次異動」
|
||
# V-New: 重構時段統計邏輯,確保統計所有今日異動
|
||
slot_stats = {}
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
# 1. 取得今日所有異動紀錄
|
||
today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == 'edm').all()
|
||
|
||
# 2. 取得所有相關時段的鍵 (今日異動的 + 當前在架的)
|
||
slots_from_changes = {rec.time_slot for rec in today_change_records}
|
||
slots_from_display = set(sorted_grouped_items.keys())
|
||
all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display)))
|
||
|
||
# 3. 初始化所有相關時段的統計數據
|
||
for slot in all_relevant_slots:
|
||
slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0}
|
||
|
||
# 4. 累加新品、漲價、降價、下架的數量 (從今日歷史紀錄)
|
||
for rec in today_change_records:
|
||
if rec.time_slot in slot_stats:
|
||
if rec.status_change == 'NEW':
|
||
slot_stats[rec.time_slot]['new'] += 1
|
||
elif rec.status_change == 'PRICE_UP':
|
||
slot_stats[rec.time_slot]['up'] += 1
|
||
elif rec.status_change == 'PRICE_DOWN':
|
||
slot_stats[rec.time_slot]['down'] += 1
|
||
elif rec.status_change in ['DELISTED', 'SLOT_END']:
|
||
slot_stats[rec.time_slot]['delisted_last_run'] += 1
|
||
|
||
# 5. 計算在架與下架總數 (從當前顯示的商品快照)
|
||
for slot, items in sorted_grouped_items.items():
|
||
if slot in slot_stats:
|
||
on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END'])
|
||
delisted_total_count = len(items) - on_shelf_count
|
||
slot_stats[slot]['on_shelf'] = on_shelf_count
|
||
slot_stats[slot]['delisted_total'] = delisted_total_count
|
||
|
||
# V-New: 建立儀表板頁籤
|
||
promo_pages = [
|
||
{'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
|
||
{'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}
|
||
]
|
||
|
||
scheduler_stats = load_scheduler_stats()
|
||
|
||
return render_template('edm_dashboard.html',
|
||
promo_pages=promo_pages,
|
||
current_promo_page='edm',
|
||
page_title='MOMO 限時搶購',
|
||
grouped_items=sorted_grouped_items,
|
||
slot_stats=slot_stats,
|
||
total_edm_products=len(items_in_batch),
|
||
last_update=last_update_str,
|
||
activity_time=activity_time,
|
||
active_tab=active_tab,
|
||
current_batch_id=current_batch_id,
|
||
public_url=public_url,
|
||
scheduler_stats=scheduler_stats,
|
||
current_sort=sort_by,
|
||
current_order=order,
|
||
slugify=slugify)
|
||
except Exception as e:
|
||
sys_log.error(f"🚨 EDM Dashboard 渲染錯誤: {e}")
|
||
return f"系統錯誤: {e}"
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/festival')
|
||
def festival_dashboard():
|
||
"""🚩 新增:1.1 狂歡購物節專屬儀表板"""
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
|
||
PAGE_TYPE = "festival"
|
||
PAGE_NAME = "1.1狂歡購物節"
|
||
|
||
sort_by = request.args.get('sort_by', 'default')
|
||
order = request.args.get('order', 'desc')
|
||
|
||
try:
|
||
# 1. 基礎統計
|
||
last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first()
|
||
last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料"
|
||
|
||
latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first()
|
||
activity_time = getattr(latest_entry, 'activity_time_text', PAGE_NAME) if latest_entry else PAGE_NAME
|
||
|
||
# 2. 查詢資料
|
||
subq = session.query(
|
||
func.max(PromoProduct.id).label('max_id')
|
||
).filter(PromoProduct.page_type == PAGE_TYPE).group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery()
|
||
|
||
latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all()
|
||
|
||
items_in_batch = []
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
for item in latest_records:
|
||
if item.status_change == 'SLOT_END' and item.crawled_at < today_start:
|
||
continue
|
||
if item.status_change == 'DELISTED' and item.crawled_at < today_start:
|
||
continue
|
||
items_in_batch.append(item)
|
||
|
||
# 此頁面使用區塊標題作為分組依據
|
||
grouped_items = {}
|
||
for item in items_in_batch:
|
||
if item.time_slot not in grouped_items:
|
||
grouped_items[item.time_slot] = []
|
||
grouped_items[item.time_slot].append(item)
|
||
|
||
sorted_grouped_items = dict(sorted(grouped_items.items()))
|
||
|
||
# 預設顯示第一個頁籤
|
||
active_tab = next(iter(sorted_grouped_items)) if sorted_grouped_items else ""
|
||
|
||
all_icodes_in_batch = [item.i_code for item in items_in_batch]
|
||
product_categories = {}
|
||
days_on_shelf_map = {}
|
||
total_sold_map = {}
|
||
|
||
if all_icodes_in_batch:
|
||
main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all()
|
||
product_categories = {p.i_code: p.category for p in main_products}
|
||
|
||
# V-Fix: 使用 CAST 轉換為 DATE,兼容 PostgreSQL 和 SQLite
|
||
from sqlalchemy import cast, Date
|
||
days_on_shelf_q = session.query(
|
||
PromoProduct.i_code,
|
||
func.count(func.distinct(cast(PromoProduct.crawled_at, Date)))
|
||
).filter(
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.page_type == PAGE_TYPE
|
||
).group_by(PromoProduct.i_code).all()
|
||
days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q}
|
||
|
||
# 將查到的分類資訊附加到每個 item 物件上
|
||
for item in items_in_batch:
|
||
item.main_category = product_categories.get(item.i_code)
|
||
if item.main_category:
|
||
item.category_color = get_color_for_string(item.main_category)
|
||
item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1)
|
||
# V-Fix: 為 festival 頁面提供預設值,避免共用模板在渲染 total_sold 和 qty_history 時出錯
|
||
item.total_sold = 0
|
||
item.qty_history = []
|
||
|
||
# 排序邏輯
|
||
reverse = (order == 'desc')
|
||
for time_slot in sorted_grouped_items:
|
||
if sort_by == 'name':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse)
|
||
elif sort_by == 'price':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse)
|
||
else: # 預設排序
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: (
|
||
1 if x.main_category else 0,
|
||
2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0),
|
||
x.price if x.price is not None else -1
|
||
), reverse=True)
|
||
|
||
# 時段統計
|
||
slot_stats = {}
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == PAGE_TYPE).all()
|
||
|
||
slots_from_changes = {rec.time_slot for rec in today_change_records}
|
||
slots_from_display = set(sorted_grouped_items.keys())
|
||
all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display)))
|
||
|
||
for slot in all_relevant_slots:
|
||
slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0}
|
||
|
||
for rec in today_change_records:
|
||
if rec.time_slot in slot_stats:
|
||
if rec.status_change == 'NEW': slot_stats[rec.time_slot]['new'] += 1
|
||
elif rec.status_change == 'PRICE_UP': slot_stats[rec.time_slot]['up'] += 1
|
||
elif rec.status_change == 'PRICE_DOWN': slot_stats[rec.time_slot]['down'] += 1
|
||
elif rec.status_change in ['DELISTED', 'SLOT_END']: slot_stats[rec.time_slot]['delisted_last_run'] += 1
|
||
|
||
for slot, items in sorted_grouped_items.items():
|
||
if slot in slot_stats:
|
||
on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END'])
|
||
delisted_total_count = len(items) - on_shelf_count
|
||
slot_stats[slot]['on_shelf'] = on_shelf_count
|
||
slot_stats[slot]['delisted_total'] = delisted_total_count
|
||
|
||
scheduler_stats = load_scheduler_stats()
|
||
|
||
# 建立儀表板頁籤
|
||
promo_pages = [
|
||
{'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
|
||
{'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}
|
||
]
|
||
|
||
# 注意:這裡我們重複使用 edm_dashboard.html 範本
|
||
# 您需要建立一個它的複本,命名為 festival.html
|
||
return render_template('edm_dashboard.html',
|
||
promo_pages=promo_pages,
|
||
current_promo_page='festival',
|
||
page_title=PAGE_NAME,
|
||
grouped_items=sorted_grouped_items,
|
||
slot_stats=slot_stats,
|
||
total_edm_products=len(items_in_batch),
|
||
last_update=last_update_str,
|
||
activity_time=activity_time,
|
||
active_tab=active_tab,
|
||
public_url=public_url,
|
||
scheduler_stats=scheduler_stats,
|
||
current_sort=sort_by,
|
||
current_order=order,
|
||
slugify=slugify)
|
||
except Exception as e:
|
||
sys_log.error(f"🚨 {PAGE_NAME} Dashboard 渲染錯誤: {e}")
|
||
return f"系統錯誤: {e}"
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/abc_analysis/detail')
|
||
def abc_analysis_detail():
|
||
"""ABC 分析詳細報表頁面"""
|
||
try:
|
||
target_class = request.args.get('class', 'A') # 預設 A 類
|
||
table_name = 'realtime_sales_monthly'
|
||
|
||
# 1. 生成與主頁面一致的 cache_key
|
||
data_range_months = int(request.args.get('data_range', '0') or '0')
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
|
||
# 2. 使用共用篩選函式取得資料
|
||
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
|
||
|
||
# V-Fix: 如果 cache_key 不存在,嘗試後補使用 table_name 固定鍵值
|
||
if err and table_name in _SALES_PROCESSED_CACHE:
|
||
target_df, cols_map, err = _get_filtered_sales_data(table_name)
|
||
|
||
if err:
|
||
# V-Fix: 如果自動重載也失敗,則顯示稍後再試,並引導回主頁面
|
||
return f'''
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>數據加載中 - WOOO TECH</title>
|
||
<style>
|
||
body {{ font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f8f9fa; }}
|
||
.card {{ background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); text-align: center; }}
|
||
.spinner {{ border: 3px solid #f3f3f3; border-top: 3px solid #1e3c72; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 0 auto 1rem; }}
|
||
@keyframes spin {{ 0% {{ transform: rotate(0deg); }} 100% {{ transform: rotate(360deg); }} }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<div class="spinner"></div>
|
||
<h3>數據準備中</h3>
|
||
<p>正在自動重新加載數據,請稍後...</p>
|
||
<script>
|
||
// 1.5 秒後嘗試重載當前頁面
|
||
setTimeout(function() {{
|
||
window.location.reload();
|
||
}}, 1500);
|
||
|
||
// 若重試 3 次仍失敗,引導回主頁
|
||
let retryCount = parseInt(sessionStorage.getItem('abc_retry') || '0');
|
||
if (retryCount > 3) {{
|
||
sessionStorage.removeItem('abc_retry');
|
||
alert('數據載入過久,請先在業績分析主頁重新整理。');
|
||
window.location.href = '/sales_analysis';
|
||
}} else {{
|
||
sessionStorage.setItem('abc_retry', retryCount + 1);
|
||
}}
|
||
</script>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
''', 200
|
||
|
||
# 恢復欄位變數
|
||
col_name = cols_map.get('name')
|
||
col_amount = cols_map.get('amount')
|
||
col_qty = cols_map.get('qty')
|
||
col_category = cols_map.get('category')
|
||
col_brand = cols_map.get('brand')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_price = cols_map.get('price')
|
||
col_cost = cols_map.get('cost')
|
||
col_profit = cols_map.get('profit')
|
||
col_date = cols_map.get('date')
|
||
col_pid = cols_map.get('pid')
|
||
|
||
|
||
# 3. 執行 ABC 分類
|
||
items = []
|
||
total_revenue = 0
|
||
if col_amount and not target_df.empty:
|
||
# V-Fix: 先針對商品進行聚合,確保 ABC 分析是基於「商品總銷量」而非「單筆訂單」
|
||
agg_rules = {col_amount: 'sum'}
|
||
if col_qty: agg_rules[col_qty] = 'sum'
|
||
if col_cost: agg_rules[col_cost] = 'sum'
|
||
if col_profit: agg_rules[col_profit] = 'sum'
|
||
if col_category: agg_rules[col_category] = 'first'
|
||
if col_vendor: agg_rules[col_vendor] = 'first'
|
||
if col_brand: agg_rules[col_brand] = 'first' # V-New: 加入品牌
|
||
if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID
|
||
if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique()))
|
||
|
||
df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index()
|
||
|
||
# 重新計算聚合後的毛利率
|
||
if col_profit:
|
||
df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
|
||
elif col_cost:
|
||
df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
|
||
else:
|
||
df_agg['calculated_margin_rate'] = 0.0
|
||
df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
|
||
|
||
# 執行 ABC 排序與計算
|
||
df_agg = df_agg.sort_values(by=col_amount, ascending=False)
|
||
df_agg['cumulative_revenue'] = df_agg[col_amount].cumsum()
|
||
total_revenue = df_agg[col_amount].sum()
|
||
df_agg['cumulative_pct'] = (df_agg['cumulative_revenue'] / total_revenue) * 100
|
||
|
||
conditions = [(df_agg['cumulative_pct'] <= 80), (df_agg['cumulative_pct'] <= 95)]
|
||
choices = ['A', 'B']
|
||
df_agg['ABC_Class'] = np.select(conditions, choices, default='C')
|
||
|
||
# 4. 篩選特定類別
|
||
class_df = df_agg[df_agg['ABC_Class'] == target_class].copy()
|
||
|
||
# V-New: 計算平均單價與庫存建議
|
||
if col_qty:
|
||
class_df['avg_unit_price'] = (class_df[col_amount] / class_df[col_qty]).fillna(0)
|
||
|
||
# V-New: 處理動態補貨係數
|
||
custom_factor = request.args.get('factor')
|
||
current_factor = 0.0
|
||
|
||
if custom_factor:
|
||
try:
|
||
current_factor = float(custom_factor)
|
||
except:
|
||
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
|
||
else:
|
||
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
|
||
|
||
class_df['suggested_restock'] = (class_df[col_qty] * current_factor).astype(int)
|
||
|
||
items = class_df.to_dict('records')
|
||
|
||
# 準備標題與描述
|
||
class_info = {
|
||
'A': {'title': 'A 類 - 核心商品', 'desc': '營收佔比前 80% 的主力商品,建議重點備貨與監控。', 'color': 'danger'},
|
||
'B': {'title': 'B 類 - 次要商品', 'desc': '營收佔比 80%~95% 的輔助商品,維持正常庫存。', 'color': 'warning'},
|
||
'C': {'title': 'C 類 - 長尾商品', 'desc': '營收佔比最後 5% 的長尾商品,建議評估清倉或縮減 SKU。', 'color': 'success'}
|
||
}
|
||
info = class_info.get(target_class, {'title': f'{target_class} 類', 'desc': '', 'color': 'secondary'})
|
||
|
||
# 計算 DataTables 預設排序欄位 (銷售金額) 的索引
|
||
# 欄位順序: Rank(0), [PID], Name, [Brand], [Vendor], [Cat], [Margin], [AvgPrice, Qty, Restock], Amount
|
||
sort_col_index = 1 # Rank
|
||
if col_pid: sort_col_index += 1
|
||
sort_col_index += 1 # Name
|
||
if col_brand: sort_col_index += 1
|
||
if col_vendor: sort_col_index += 1
|
||
if col_category: sort_col_index += 1
|
||
if col_cost or col_profit: sort_col_index += 1
|
||
if col_qty: sort_col_index += 3
|
||
# 此時 sort_col_index 即為 Amount 欄位的索引
|
||
|
||
return render_template('abc_analysis_detail.html',
|
||
items=items,
|
||
info=info,
|
||
target_class=target_class,
|
||
current_factor=current_factor, # V-New: 傳遞當前係數
|
||
total_revenue=total_revenue,
|
||
sort_col_index=sort_col_index, # V-New: 傳遞排序欄位索引
|
||
cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category,
|
||
'vendor': col_vendor, 'brand': col_brand, 'cost': col_cost, 'profit': col_profit, 'date': col_date, 'pid': col_pid},
|
||
# 傳遞當前查詢參數以供匯出連結使用
|
||
query_string=request.query_string.decode())
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"ABC Detail Error: {e}")
|
||
return f"系統錯誤: {e}"
|
||
|
||
@app.route('/logs')
|
||
def show_logs():
|
||
return render_template('logs.html')
|
||
|
||
@app.route('/api/logs')
|
||
def get_logs_api():
|
||
if os.path.exists(LOG_FILE_PATH):
|
||
try:
|
||
with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f:
|
||
return jsonify({"logs": "".join(f.readlines()[-60:])})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Logs] ❌ 日誌 API 讀取異常 | Error: {e}")
|
||
return jsonify({"logs": "讀取日誌異常"})
|
||
return jsonify({"logs": "等待系統啟動中..."})
|
||
|
||
@app.route('/api/backup', methods=['POST'])
|
||
@login_required
|
||
def trigger_backup():
|
||
"""API: 觸發系統完整備份"""
|
||
# Note: [功能] 尚未實作「系統還原」功能 (Restore),需評估安全性後加入
|
||
try:
|
||
sys_log.info("[System] [Backup] 💾 開始執行系統完整備份...")
|
||
backup_dir = os.path.join(BASE_DIR, 'backups')
|
||
if not os.path.exists(backup_dir):
|
||
os.makedirs(backup_dir)
|
||
|
||
timestamp = datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')
|
||
zip_filename = f"momo_system_backup_{SYSTEM_VERSION}_{timestamp}.zip"
|
||
zip_filepath = os.path.join(backup_dir, zip_filename)
|
||
|
||
with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||
for root, dirs, files in os.walk(BASE_DIR):
|
||
# 排除不必要的目錄
|
||
dirs[:] = [d for d in dirs if d not in ['backups', '__pycache__', 'venv', '.git', '.idea', '.vscode', 'node_modules']]
|
||
|
||
for file in files:
|
||
if file == zip_filename: continue # 跳過正在寫入的檔案
|
||
if file.endswith('.pyc') or file.endswith('.DS_Store'): continue
|
||
|
||
file_path = os.path.join(root, file)
|
||
arcname = os.path.relpath(file_path, BASE_DIR)
|
||
zipf.write(file_path, arcname)
|
||
|
||
sys_log.info(f"[System] [Backup] ✅ 系統備份完成 | File: {zip_filename}")
|
||
|
||
# V-New: 回傳下載連結
|
||
download_url = url_for('download_backup', filename=zip_filename)
|
||
|
||
return jsonify({
|
||
"status": "success",
|
||
"message": f"備份成功!\n檔案已儲存為: {zip_filename}\n即將開始下載...",
|
||
"download_url": download_url
|
||
})
|
||
except Exception as e:
|
||
sys_log.error(f"[System] [Backup] ❌ 備份失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/backup/download/<path:filename>')
|
||
@login_required
|
||
def download_backup(filename):
|
||
"""
|
||
API: 下載備份檔案(已加入路徑遍歷防護)
|
||
"""
|
||
try:
|
||
backup_dir = os.path.join(BASE_DIR, 'backups')
|
||
# 使用 safe_join 驗證路徑,防止路徑遍歷攻擊
|
||
safe_path = safe_join(backup_dir, filename)
|
||
|
||
# 確保檔案存在
|
||
if not safe_path.exists():
|
||
sys_log.warning(f"[Security] 備份檔案不存在 | File: {filename}")
|
||
return jsonify({'error': '檔案不存在'}), 404
|
||
|
||
# 確保是檔案而非目錄
|
||
if not safe_path.is_file():
|
||
sys_log.warning(f"[Security] 嘗試下載非檔案路徑 | Path: {filename}")
|
||
return jsonify({'error': '非法路徑'}), 400
|
||
|
||
return send_from_directory(backup_dir, safe_path.name, as_attachment=True)
|
||
|
||
except ValueError as e:
|
||
# safe_join 偵測到路徑遍歷嘗試
|
||
sys_log.error(f"[Security] 路徑遍歷攻擊嘗試被阻擋 | Filename: {filename} | Error: {e}")
|
||
return jsonify({'error': '非法路徑'}), 400
|
||
except Exception as e:
|
||
sys_log.error(f"[System] 下載備份失敗 | Error: {e}")
|
||
return jsonify({'error': '下載失敗'}), 500
|
||
|
||
# ================= 📊 V-New: 業績分析報表 =================
|
||
|
||
|
||
def _get_filtered_sales_data(cache_key):
|
||
"""
|
||
🚩 共用函式:從快取讀取資料並根據 request.args 進行篩選
|
||
回傳: (target_df, cols_map, error_message)
|
||
參數: cache_key - 快取鍵值 (例如: "realtime_sales_monthly_3m")
|
||
"""
|
||
db = DatabaseManager()
|
||
|
||
# 1. 檢查資料表與快取
|
||
df = None
|
||
cols_map = {}
|
||
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
cache_data = _SALES_PROCESSED_CACHE[cache_key]
|
||
df = cache_data['df']
|
||
cols_map = cache_data['cols']
|
||
else:
|
||
# 快取不存在時,直接回傳錯誤讓呼叫端顯示 spinner 導回 sales_analysis
|
||
# 不在此發起全表 DB 查詢(748k 行會 hang Gunicorn worker)
|
||
sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),回傳錯誤讓 UI 導回 sales_analysis")
|
||
return None, {}, f"快取未就緒,請先從業績分析主頁載入資料 (cache_key={cache_key})"
|
||
|
||
if False: # 保留舊冷快取重載邏輯(已停用,避免全表掃描 hang)
|
||
sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),試圖重新從資料庫載入...")
|
||
try:
|
||
# V-Fix: 從 cache_key 提取 table_name
|
||
# 格式: realtime_sales_monthly_3m 或 realtime_sales_monthly_custom_2025-01-01_2025-01-31
|
||
if "_custom_" in cache_key:
|
||
table_name = cache_key.split('_custom_')[0] # realtime_sales_monthly
|
||
else:
|
||
# 移除最後的 _Xm 部分
|
||
parts = cache_key.rsplit('_', 1)
|
||
table_name = parts[0] if len(parts) > 1 else 'realtime_sales_monthly'
|
||
|
||
# 判斷是自訂區間還是標配區間
|
||
if "_custom_" in cache_key:
|
||
# 格式: realtime_sales_monthly_custom_2025-01-01_2025-01-31
|
||
parts = cache_key.split('_custom_')
|
||
dates = parts[1].split('_')
|
||
start_d, end_d = dates[0], dates[1]
|
||
# 呼叫資料庫讀取 (不傳入 view, 會自動處理欄位映射)
|
||
result_df, result_cols = db.get_sales_data(table_name=table_name, start_date=start_d, end_date=end_d)
|
||
else:
|
||
# 格式: realtime_sales_monthly_1m;months=0 表示全時段但上限 12 個月避免全表掃描 hang
|
||
months = int(cache_key.split('_')[-1].replace('m', '') or '12')
|
||
if months == 0:
|
||
months = 12
|
||
result_df, result_cols = db.get_sales_data(table_name=table_name, months=months)
|
||
|
||
if result_df is not None and not result_df.empty:
|
||
# V-Fix (2026-01-23): 補回所有日期維度欄位供後續篩選 (_dow, _hour, _month_str)
|
||
if '日期' in result_df.columns:
|
||
# 先轉換為 datetime
|
||
result_df['_parsed_date'] = pd.to_datetime(result_df['日期'], errors='coerce')
|
||
result_df['_month_str'] = result_df['_parsed_date'].dt.strftime('%Y-%m')
|
||
result_df['_dow'] = result_df['_parsed_date'].dt.dayofweek
|
||
|
||
# 小時需要從「時間」欄位提取
|
||
if '時間' in result_df.columns:
|
||
result_df['_hour'] = pd.to_datetime(result_df['時間'], format='%H:%M:%S', errors='coerce').dt.hour
|
||
else:
|
||
result_df['_hour'] = 0 # 如果沒有時間欄位,預設為 0
|
||
|
||
# 清理臨時欄位
|
||
result_df.drop(columns=['_parsed_date'], inplace=True, errors='ignore')
|
||
|
||
# 自動存入快取
|
||
_SALES_PROCESSED_CACHE[cache_key] = {'df': result_df, 'cols': result_cols, 'time': time.time()}
|
||
df = result_df
|
||
cols_map = result_cols
|
||
sys_log.info(f"[Sales Analysis] ✅ 快取成功自動重載 | 筆數: {len(df)}")
|
||
else:
|
||
return None, None, "資料庫無可用資料,請確認匯入狀態"
|
||
except Exception as ex:
|
||
sys_log.error(f"[Sales Analysis] 🚨 自動重載失敗: {ex}")
|
||
return None, None, f"快取失效且無法重載: {ex}"
|
||
|
||
# 恢復欄位變數
|
||
col_name = cols_map.get('name')
|
||
col_category = cols_map.get('category')
|
||
col_brand = cols_map.get('brand')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_activity = cols_map.get('activity')
|
||
col_payment = cols_map.get('payment')
|
||
col_price = cols_map.get('price')
|
||
col_date = cols_map.get('date')
|
||
col_return_qty = cols_map.get('return_qty') # V-New: 取得退貨欄位
|
||
|
||
# 2. 取得篩選參數
|
||
selected_category = request.args.get('category', 'all')
|
||
selected_brand = request.args.get('brand', 'all')
|
||
selected_vendor = request.args.get('vendor', 'all')
|
||
selected_activity = request.args.get('activity', 'all')
|
||
selected_payment = request.args.get('payment', 'all')
|
||
selected_dow = request.args.get('dow', 'all')
|
||
selected_hour = request.args.get('hour', 'all')
|
||
selected_month = request.args.get('month', 'all')
|
||
keyword = request.args.get('keyword', '').strip()
|
||
min_price = request.args.get('min_price', '')
|
||
max_price = request.args.get('max_price', '')
|
||
min_margin = request.args.get('min_margin', '')
|
||
max_margin = request.args.get('max_margin', '')
|
||
|
||
# 3. 執行篩選
|
||
target_df = df
|
||
|
||
# Top N 分類處理 (用於 '其他' 篩選)
|
||
TOP_N_CATS = 12
|
||
top_cats_names = []
|
||
if col_category:
|
||
# 注意:這裡為了效能,簡單重算一次 Top N,或可考慮也快取起來
|
||
cat_group_all = df.groupby(col_category)[cols_map.get('amount')].sum().sort_values(ascending=False)
|
||
if len(cat_group_all) > TOP_N_CATS:
|
||
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
|
||
|
||
if selected_category != 'all' and col_category:
|
||
if selected_category == '其他' and top_cats_names:
|
||
target_df = target_df[~target_df[col_category].isin(top_cats_names)]
|
||
else:
|
||
target_df = target_df[target_df[col_category] == selected_category]
|
||
|
||
if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand]
|
||
if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor]
|
||
if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity]
|
||
if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment]
|
||
|
||
if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)]
|
||
if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)]
|
||
if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month]
|
||
|
||
if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)]
|
||
|
||
if col_price:
|
||
if min_price: target_df = target_df[target_df[col_price] >= float(min_price)]
|
||
if max_price: target_df = target_df[target_df[col_price] <= float(max_price)]
|
||
|
||
if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)]
|
||
if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)]
|
||
|
||
return target_df, cols_map, None
|
||
|
||
@app.route('/sales_analysis')
|
||
def sales_analysis():
|
||
"""業績分析報表頁面"""
|
||
try:
|
||
db = DatabaseManager()
|
||
table_name = 'realtime_sales_monthly'
|
||
|
||
# 1. 檢查資料表是否存在
|
||
inspector = inspect(db.engine)
|
||
if table_name not in inspector.get_table_names():
|
||
return render_template('sales_analysis.html',
|
||
error="尚未匯入「即時業績(全月)」資料,請先至設定頁面匯入 Excel。",
|
||
table_name=table_name,
|
||
selected_metric='amount',
|
||
no_filter=False,
|
||
data_range_months=0,
|
||
start_date='',
|
||
end_date='',
|
||
total_records=0,
|
||
db_data_range='')
|
||
|
||
# V-New: 查詢資料庫的資料期間範圍
|
||
db_data_range = ''
|
||
try:
|
||
# 取得日期欄位的最小值和最大值
|
||
from sqlalchemy import text
|
||
date_query = text(f"SELECT MIN(日期) as min_date, MAX(日期) as max_date FROM {table_name}")
|
||
# V-Fix: SQLAlchemy 2.0 需要使用 connection 對象
|
||
with db.engine.connect() as conn:
|
||
result = conn.execute(date_query).fetchone()
|
||
if result and result[0] and result[1]:
|
||
min_date = result[0]
|
||
max_date = result[1]
|
||
# 格式化為 YYYY年MM月 格式
|
||
if isinstance(min_date, str):
|
||
from datetime import datetime
|
||
try:
|
||
min_date_obj = datetime.strptime(min_date.split()[0], '%Y-%m-%d')
|
||
max_date_obj = datetime.strptime(max_date.split()[0], '%Y-%m-%d')
|
||
db_data_range = f"{min_date_obj.year}年{min_date_obj.month}月 ~ {max_date_obj.year}年{max_date_obj.month}月"
|
||
except:
|
||
db_data_range = f"{min_date} ~ {max_date}"
|
||
else:
|
||
db_data_range = f"{min_date.year}年{min_date.month}月 ~ {max_date.year}年{max_date.month}月"
|
||
except Exception as e:
|
||
sys_log.warning(f"[Sales Analysis] 無法取得資料期間範圍: {e}")
|
||
|
||
# V-New: 取得篩選參數
|
||
data_range_param = request.args.get('data_range', '') # 不再設預設值
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
|
||
# V-New: 按需載入 - 如果沒有任何篩選條件,顯示引導頁面
|
||
if not data_range_param and not start_date and not end_date:
|
||
sys_log.info("[Sales Analysis] 👋 首次進入頁面,等待用戶選擇篩選條件")
|
||
|
||
# V-Fix: 即使在引導頁面,也要提供下拉選單選項
|
||
# V-Opt: 讀取較多筆數(1000筆)以獲得更完整的月份資訊
|
||
preview_df = pd.read_sql(f"SELECT * FROM {table_name} LIMIT 1000", db.engine)
|
||
preview_categories = []
|
||
preview_brands = []
|
||
preview_vendors = []
|
||
preview_activities = []
|
||
preview_payments = []
|
||
preview_months = [] # V-New: 新增月份列表
|
||
|
||
if not preview_df.empty:
|
||
cols = preview_df.columns.tolist()
|
||
def find_col(keywords):
|
||
for k in keywords:
|
||
for col in cols:
|
||
if k in str(col): return col
|
||
return None
|
||
|
||
col_category = find_col(['館別', '商品館', '分類', 'Category'])
|
||
col_brand = find_col(['品牌', 'Brand'])
|
||
col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier'])
|
||
# V-Fix: 優先匹配具體的活動欄位名稱
|
||
col_activity = find_col(['折扣活動名稱', '折價券活動名稱', '滿額再折扣活動名稱', '活動', 'Activity', 'Campaign'])
|
||
col_payment = find_col(['付款', 'Payment', 'Pay'])
|
||
# V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期)
|
||
col_date_part = find_col(['日期', '交易日期', 'Date', 'Day'])
|
||
col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created'])
|
||
|
||
# V-Fix: 篩選掉空字串,只保留有效數據
|
||
if col_category:
|
||
preview_categories = sorted([x for x in preview_df[col_category].dropna().astype(str).unique().tolist() if x and x.strip()])
|
||
if col_brand:
|
||
preview_brands = sorted([x for x in preview_df[col_brand].dropna().astype(str).unique().tolist() if x and x.strip()])
|
||
if col_vendor:
|
||
preview_vendors = sorted([x for x in preview_df[col_vendor].dropna().astype(str).unique().tolist() if x and x.strip()])
|
||
if col_activity:
|
||
preview_activities = sorted([x for x in preview_df[col_activity].dropna().astype(str).unique().tolist() if x and x.strip()])
|
||
if col_payment:
|
||
preview_payments = sorted([x for x in preview_df[col_payment].dropna().astype(str).unique().tolist() if x and x.strip()])
|
||
|
||
# V-Fix: 從數據庫直接查詢所有月份(而不是從預覽數據提取,避免只獲得部分月份)
|
||
if col_date_part:
|
||
try:
|
||
from sqlalchemy import text
|
||
with db.engine.connect() as conn:
|
||
result = conn.execute(text(f"""
|
||
SELECT DISTINCT replace(substr("{col_date_part}", 1, 7), '/', '-') as month
|
||
FROM {table_name}
|
||
WHERE "{col_date_part}" IS NOT NULL AND "{col_date_part}" != ''
|
||
ORDER BY month
|
||
""")).fetchall()
|
||
preview_months = [row[0] for row in result if row[0] and '-' in str(row[0])]
|
||
sys_log.info(f"[Sales Analysis] 預覽模式從「{col_date_part}」欄位提取到 {len(preview_months)} 個月份")
|
||
except Exception as e:
|
||
sys_log.warning(f"[Sales Analysis] 無法從「{col_date_part}」欄位提取月份: {e}")
|
||
pass
|
||
|
||
# 傳遞必要的變數以避免模板錯誤
|
||
selected_metric = request.args.get('metric', 'amount')
|
||
# 建立空的數據結構
|
||
empty_data = {'labels': [], 'chart_values': [], 'values': [], 'metric_label': ''}
|
||
return render_template('sales_analysis.html',
|
||
no_filter=True,
|
||
table_name=table_name,
|
||
selected_metric=selected_metric,
|
||
total_records=0,
|
||
items=[],
|
||
kpi={'revenue': 0, 'qty': 0, 'count': 0, 'cost': 0, 'gross_margin': 0, 'gross_margin_rate': 0, 'avg_price': 0},
|
||
insights={},
|
||
abc_stats={},
|
||
vendor_stats=[],
|
||
seasonality_data={'datasets': [], 'yLabels': [], 'xLabels': []},
|
||
bar_data=empty_data,
|
||
cat_data=empty_data,
|
||
price_dist_data=empty_data,
|
||
scatter_data=[],
|
||
bcg_data=[],
|
||
dow_data=empty_data,
|
||
hourly_data=empty_data,
|
||
monthly_data=empty_data,
|
||
weekly_data=empty_data,
|
||
heatmap_data=[],
|
||
treemap_data=[],
|
||
cols={'name': True, 'amount': True, 'qty': True, 'cat': True, 'date': True,
|
||
'cost': True, 'profit': True, 'vendor': True, 'brand': True,
|
||
'return_qty': True, 'pid': True},
|
||
all_categories=preview_categories, all_brands=preview_brands, all_vendors=preview_vendors,
|
||
all_activities=preview_activities, all_payments=preview_payments, all_months=preview_months,
|
||
selected_category='all', selected_brand='all', selected_vendor='all',
|
||
selected_activity='all', selected_payment='all', selected_dow='all',
|
||
selected_hour='all', selected_month='all',
|
||
keyword='', min_price='', max_price='', min_margin='', max_margin='',
|
||
data_range_months=0, start_date='', end_date='',
|
||
db_data_range=db_data_range,
|
||
marketing_data=None)
|
||
|
||
# 解析 data_range_months(有篩選時才處理)
|
||
data_range_months = int(data_range_param or '0')
|
||
|
||
# V-New: 如果有自訂日期區間,則優先使用
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
|
||
# 2. 讀取與處理資料 (V-Opt: 使用二級快取機制 Raw -> Processed)
|
||
df = None
|
||
cols_map = {}
|
||
|
||
# A. 優先檢查是否已有處理好的快取 (最快)
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
cache_data = _SALES_PROCESSED_CACHE[cache_key]
|
||
df = cache_data['df']
|
||
cols_map = cache_data['cols']
|
||
|
||
# 恢復欄位變數
|
||
col_name = cols_map.get('name')
|
||
col_date = cols_map.get('date')
|
||
col_amount = cols_map.get('amount')
|
||
col_qty = cols_map.get('qty')
|
||
col_category = cols_map.get('category')
|
||
col_brand = cols_map.get('brand')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_activity = cols_map.get('activity')
|
||
col_payment = cols_map.get('payment')
|
||
col_pid = cols_map.get('pid') # V-New: 取得 PID 欄位
|
||
col_price = cols_map.get('price')
|
||
col_cost = cols_map.get('cost')
|
||
col_profit = cols_map.get('profit')
|
||
col_return_qty = cols_map.get('return_qty')
|
||
|
||
cached_pie_data = cache_data.get('pie_data', {'labels': [], 'chart_values': []}) # V-Opt: 讀取圓餅圖快取
|
||
else:
|
||
# B. 若無處理後快取,則從 Raw Cache 或 DB 讀取並處理
|
||
# V-Opt: 加入日期範圍篩選以減少記憶體使用
|
||
# (data_range_months 已在上方定義)
|
||
|
||
# 先讀取小樣本以識別日期欄位
|
||
sample_df = pd.read_sql(f"SELECT * FROM {table_name} LIMIT 100", db.engine)
|
||
if sample_df.empty:
|
||
return render_template('sales_analysis.html',
|
||
error="資料表為空,請重新匯入。",
|
||
table_name=table_name,
|
||
selected_metric=request.args.get('metric', 'amount'),
|
||
no_filter=False,
|
||
data_range_months=data_range_months,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
total_records=0,
|
||
db_data_range=db_data_range,
|
||
marketing_data=None)
|
||
|
||
# 自動識別日期欄位(V-Fix: 優先匹配「日期」,因為「訂單日期」可能是固定文字)
|
||
sample_cols = sample_df.columns.tolist()
|
||
date_col_name = None
|
||
for col in sample_cols:
|
||
if any(keyword in str(col) for keyword in ['日期', '交易日期', 'Date', '訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']):
|
||
date_col_name = col
|
||
break
|
||
|
||
# 根據是否有日期欄位決定查詢方式
|
||
if date_col_name:
|
||
from datetime import datetime, timedelta, timezone
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
# V-New: 優先處理自訂日期區間
|
||
if start_date or end_date:
|
||
# V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01)
|
||
start_date_slash = start_date.replace('-', '/') if start_date else ''
|
||
end_date_slash = end_date.replace('-', '/') if end_date else ''
|
||
|
||
# 有自訂日期區間 - 使用 BETWEEN 或單邊範圍
|
||
if start_date and end_date:
|
||
sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" BETWEEN '{start_date_slash}' AND '{end_date_slash}'"
|
||
sys_log.info(f"[Sales Analysis] 📅 使用自訂日期範圍: {start_date} ~ {end_date} (DB格式: {start_date_slash} ~ {end_date_slash})")
|
||
elif start_date:
|
||
sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" >= '{start_date_slash}'"
|
||
sys_log.info(f"[Sales Analysis] 📅 使用自訂開始日期: >= {start_date} (DB格式: {start_date_slash})")
|
||
else: # only end_date
|
||
sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" <= '{end_date_slash}'"
|
||
sys_log.info(f"[Sales Analysis] 📅 使用自訂結束日期: <= {end_date} (DB格式: {end_date_slash})")
|
||
elif data_range_months > 0:
|
||
# 使用相對日期範圍(最近N個月)
|
||
# V-Fix: 使用斜線格式以匹配資料庫格式
|
||
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
|
||
sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" >= '{cutoff_date}'"
|
||
sys_log.info(f"[Sales Analysis] 📊 使用日期範圍篩選: 最近 {data_range_months} 個月 (>= {cutoff_date})")
|
||
else:
|
||
# data_range_months == 0,載入全部資料
|
||
sql_query = f"SELECT * FROM {table_name}"
|
||
sys_log.info(f"[Sales Analysis] 📊 載入全部資料(用戶選擇)")
|
||
else:
|
||
# 無日期欄位 - 載入全部
|
||
sql_query = f"SELECT * FROM {table_name}"
|
||
sys_log.info(f"[Sales Analysis] ⚠️ 未找到日期欄位,載入全部資料")
|
||
|
||
# V-Opt (2026-01-23): 優先使用 PostgreSQL 聚合視圖 (mv_sales_summary)
|
||
# 聚合視圖已預先計算:資料量 -20%, 大小 -73%, 欄位類型已轉換
|
||
from sqlalchemy import text as sql_text
|
||
from config import DATABASE_TYPE
|
||
|
||
use_materialized_view = False
|
||
if DATABASE_TYPE == 'postgresql':
|
||
# 檢查聚合視圖是否存在
|
||
try:
|
||
with db.engine.connect() as conn:
|
||
check_mv = conn.execute(sql_text(
|
||
"SELECT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'mv_sales_summary')"
|
||
)).fetchone()
|
||
use_materialized_view = check_mv[0] if check_mv else False
|
||
except:
|
||
use_materialized_view = False
|
||
|
||
if use_materialized_view:
|
||
# 使用聚合視圖 - 欄位已標準化為英文
|
||
sys_log.info(f"[Sales Analysis] 📊 使用 PostgreSQL 聚合視圖 (mv_sales_summary)")
|
||
|
||
# 構建日期篩選條件
|
||
mv_where = ""
|
||
if start_date or end_date:
|
||
if start_date and end_date:
|
||
mv_where = f"WHERE sale_date BETWEEN '{start_date}' AND '{end_date}'"
|
||
elif start_date:
|
||
mv_where = f"WHERE sale_date >= '{start_date}'"
|
||
else:
|
||
mv_where = f"WHERE sale_date <= '{end_date}'"
|
||
elif data_range_months > 0:
|
||
from datetime import datetime, timedelta, timezone
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
cutoff = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y-%m-%d')
|
||
mv_where = f"WHERE sale_date >= '{cutoff}'"
|
||
|
||
mv_query = f"""
|
||
SELECT
|
||
sale_date as "日期",
|
||
product_id as "商品ID",
|
||
product_name as "商品名稱",
|
||
category as "商品館",
|
||
brand as "品牌",
|
||
vendor_name as "廠商名稱",
|
||
payment as "付款",
|
||
total_revenue as "總業績",
|
||
total_qty as "數量",
|
||
total_cost as "總成本",
|
||
order_count
|
||
FROM mv_sales_summary
|
||
{mv_where}
|
||
"""
|
||
df = pd.read_sql(mv_query, db.engine)
|
||
sys_log.info(f"[Sales Analysis] 📊 聚合視圖載入完成: {len(df):,} 筆記錄")
|
||
else:
|
||
# 原始邏輯:使用原始表
|
||
sys_log.info(f"[Sales Analysis] 📊 使用原始表載入...")
|
||
df = pd.read_sql(sql_query, db.engine)
|
||
sys_log.info(f"[Sales Analysis] 📊 載入完成: {len(df):,} 筆記錄")
|
||
|
||
# 聚合模式標記
|
||
is_aggregated_mode = use_materialized_view
|
||
|
||
# V-Opt: 不再快取完整 DataFrame 到 _SALES_DF_CACHE (避免記憶體累積)
|
||
# 改用輕量級處理後快取 (_SALES_PROCESSED_CACHE)
|
||
|
||
if df.empty:
|
||
return render_template('sales_analysis.html',
|
||
error="資料表為空,請重新匯入。",
|
||
table_name=table_name,
|
||
selected_metric=request.args.get('metric', 'amount'),
|
||
no_filter=False,
|
||
data_range_months=data_range_months,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
total_records=0,
|
||
db_data_range=db_data_range,
|
||
marketing_data=None)
|
||
|
||
# 3. 自動識別關鍵欄位 (模糊比對)
|
||
cols = df.columns.tolist()
|
||
def find_col(keywords):
|
||
# V-Opt: 改為優先遍歷關鍵字,確保優先匹配更精確的名稱 (例如 '廠商名稱' 優於 '廠商')
|
||
for k in keywords:
|
||
for col in cols:
|
||
if k in str(col): return col
|
||
return None
|
||
|
||
col_name = find_col(['商品名稱', '品名', 'Name', 'Product'])
|
||
col_pid = find_col(['商品ID', 'Product ID', 'ID', 'i_code', 'Item Code']) # V-New: 偵測商品ID欄位
|
||
|
||
# V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期)
|
||
col_date_part = find_col(['日期', '交易日期', 'Date', 'Day'])
|
||
col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created'])
|
||
|
||
col_brand = find_col(['品牌', 'Brand']) # V-New: 品牌欄位
|
||
col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) # V-Opt: 優先抓取名稱
|
||
col_activity = find_col(['活動', '折扣', 'Activity', 'Campaign', 'Promotion', '專案']) # V-New: 活動欄位
|
||
col_payment = find_col(['付款方式', 'Payment', 'Pay']) # V-New: 付款方式欄位
|
||
col_price = find_col(['單價', 'Price', '價格', 'Avg Price']) # V-New: 嘗試尋找單價欄位
|
||
col_cost = find_col(['成本', 'Cost', '進價', 'Cost Price', 'Wholesale']) # V-New: 成本欄位
|
||
col_profit = find_col(['毛利', 'Profit', '利潤']) # V-New: 直接尋找毛利欄位 (若有)
|
||
col_return_qty = find_col(['退貨數量', 'Return Qty', '退貨']) # V-New: 退貨欄位
|
||
col_amount = find_col(['銷售金額', '業績', '金額', 'Amount', 'Sales', 'Total'])
|
||
col_qty = find_col(['銷售數量', '銷量', '數量', 'Qty', 'Quantity'])
|
||
col_category = find_col(['館別', '分類', 'Category'])
|
||
|
||
if not col_name or not col_amount:
|
||
return render_template('sales_analysis.html',
|
||
error=f"無法自動識別關鍵欄位 (需包含 '名稱' 與 '金額')。偵測到的欄位: {cols}",
|
||
table_name=table_name,
|
||
selected_metric=request.args.get('metric', 'amount'),
|
||
no_filter=False,
|
||
data_range_months=data_range_months,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
total_records=0,
|
||
db_data_range=db_data_range,
|
||
marketing_data=None)
|
||
|
||
# 4. 資料處理 (Heavy Lifting - 只在快取建立時執行一次)
|
||
# 確保金額與數量是數字
|
||
df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0)
|
||
if col_qty:
|
||
df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0)
|
||
|
||
if col_cost:
|
||
df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0)
|
||
|
||
if col_profit:
|
||
df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0)
|
||
|
||
if col_return_qty:
|
||
df[col_return_qty] = pd.to_numeric(df[col_return_qty], errors='coerce').fillna(0)
|
||
|
||
# V-Fix: 智慧日期時間合併邏輯 (聚合模式下跳過)
|
||
col_date = None
|
||
if not is_aggregated_mode:
|
||
if col_date_part and col_time_part:
|
||
# 兩者都有,嘗試合併
|
||
try:
|
||
df['combined_dt'] = pd.to_datetime(df[col_date_part].astype(str) + ' ' + df[col_time_part].astype(str), errors='coerce')
|
||
col_date = 'combined_dt'
|
||
except:
|
||
# 合併失敗,退回使用時間欄位 (假設包含日期) 或日期欄位
|
||
col_date = col_time_part or col_date_part
|
||
elif col_time_part:
|
||
# 只有時間欄位 (可能包含日期)
|
||
df[col_time_part] = pd.to_datetime(df[col_time_part], errors='coerce')
|
||
col_date = col_time_part
|
||
elif col_date_part:
|
||
# 只有日期欄位
|
||
df[col_date_part] = pd.to_datetime(df[col_date_part], errors='coerce')
|
||
col_date = col_date_part
|
||
|
||
# V-New: 若無明確單價欄位,則自動計算 (金額 / 數量)
|
||
if not col_price and col_amount and col_qty:
|
||
col_price = 'calculated_price'
|
||
# V-Opt: 使用 numpy 向量化運算加速 (取代 apply)
|
||
df[col_price] = np.where(df[col_qty] > 0, df[col_amount] / df[col_qty], 0)
|
||
|
||
if col_price:
|
||
df[col_price] = pd.to_numeric(df[col_price], errors='coerce').fillna(0)
|
||
|
||
# V-New: 預先計算毛利率 (Margin Rate) 用於篩選
|
||
# 邏輯: (毛利 / 金額) * 100
|
||
col_margin_rate = 'calculated_margin_rate'
|
||
with np.errstate(divide='ignore', invalid='ignore'):
|
||
if col_profit:
|
||
df[col_margin_rate] = (df[col_profit] / df[col_amount]) * 100
|
||
elif col_cost:
|
||
df[col_margin_rate] = ((df[col_amount] - df[col_cost]) / df[col_amount]) * 100
|
||
else:
|
||
df[col_margin_rate] = 0.0
|
||
# 處理無限大與 NaN (轉為 0)
|
||
df[col_margin_rate] = df[col_margin_rate].replace([np.inf, -np.inf, np.nan], 0)
|
||
|
||
# === V-Opt: 效能優化預計算 (V9.98) ===
|
||
# 1. 日期維度 (加速篩選與聚合,避免重複呼叫 .dt 存取器)
|
||
# 聚合模式下跳過日期維度計算
|
||
if col_date and not is_aggregated_mode:
|
||
df['_dow'] = df[col_date].dt.dayofweek
|
||
df['_hour'] = df[col_date].dt.hour
|
||
df['_week'] = df[col_date].dt.strftime('%G-W%V')
|
||
df['_month_str'] = df[col_date].dt.strftime('%Y-%m') # V-New: 月份維度 (YYYY-MM)
|
||
|
||
# 2. 毛利額 (加速 Top 3 分析,避免 runtime 計算)
|
||
if col_profit:
|
||
df['calculated_profit'] = df[col_profit]
|
||
elif col_cost:
|
||
df['calculated_profit'] = df[col_amount] - df[col_cost]
|
||
else:
|
||
df['calculated_profit'] = 0.0
|
||
|
||
# 3. 全站分類圓餅圖 (已移至下方使用 target_df 計算)
|
||
|
||
|
||
# 建立/更新處理後快取
|
||
cache_entry = {
|
||
'df': df,
|
||
'cols': {
|
||
'name': col_name, 'date': col_date, 'amount': col_amount,
|
||
'qty': col_qty, 'category': col_category, 'brand': col_brand,
|
||
'vendor': col_vendor, 'activity': col_activity, 'payment': col_payment,
|
||
'price': col_price, 'cost': col_cost, 'profit': col_profit,
|
||
'return_qty': col_return_qty,
|
||
'pid': col_pid # V-New: 儲存商品ID欄位
|
||
},
|
||
'pid': col_pid # V-New: 儲存商品ID欄位
|
||
}
|
||
_SALES_PROCESSED_CACHE[cache_key] = cache_entry
|
||
# V-Fix: 同時使用固定的 table_name 作為 key 儲存副本,供其他路由使用
|
||
_SALES_PROCESSED_CACHE[table_name] = cache_entry
|
||
# V-Opt (2026-01-23): 定期清理過期快取
|
||
_cleanup_sales_cache()
|
||
|
||
# 🚩 V-Opt: 使用共用篩選函式
|
||
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
|
||
if err:
|
||
# V-Fix: 若快取失效,重新導向自己以觸發重新讀取(保留所有查詢參數)
|
||
params = {k: v for k, v in request.args.items()}
|
||
return redirect(url_for('sales_analysis', **params))
|
||
|
||
# 重新取得變數 (因為 _get_filtered_sales_data 內部使用了 cols_map)
|
||
col_name = cols_map.get('name')
|
||
col_amount = cols_map.get('amount')
|
||
col_qty = cols_map.get('qty')
|
||
col_category = cols_map.get('category')
|
||
col_brand = cols_map.get('brand')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_activity = cols_map.get('activity')
|
||
col_payment = cols_map.get('payment')
|
||
col_price = cols_map.get('price')
|
||
col_cost = cols_map.get('cost')
|
||
col_profit = cols_map.get('profit')
|
||
col_return_qty = cols_map.get('return_qty')
|
||
col_date = cols_map.get('date')
|
||
col_pid = cols_map.get('pid')
|
||
|
||
# V-Fix: 準備前端需要的下拉選單資料
|
||
# V-Opt: 從數據庫直接查詢所有可用選項,而不是只從篩選後的快取中讀取
|
||
# 這樣可以確保下拉選單顯示完整的可選項,即使當前篩選範圍很小
|
||
all_categories = []
|
||
all_brands = []
|
||
all_vendors = []
|
||
all_activities = []
|
||
all_payments = []
|
||
all_months = []
|
||
|
||
try:
|
||
from sqlalchemy import text
|
||
# V-Fix: SQLAlchemy 2.0 需要使用 connection 對象
|
||
with db.engine.connect() as conn:
|
||
# 讀取完整資料表的所有可用選項(使用 DISTINCT 以提升效能)
|
||
# V-Fix: 使用單引號空字串,兼容 PostgreSQL
|
||
if col_category:
|
||
sql = f"SELECT DISTINCT \"{col_category}\" FROM {table_name} WHERE \"{col_category}\" IS NOT NULL AND \"{col_category}\" <> ''"
|
||
result = conn.execute(text(sql)).fetchall()
|
||
all_categories = sorted([str(row[0]) for row in result if row[0]])
|
||
|
||
if col_brand:
|
||
sql = f"SELECT DISTINCT \"{col_brand}\" FROM {table_name} WHERE \"{col_brand}\" IS NOT NULL AND \"{col_brand}\" <> ''"
|
||
result = conn.execute(text(sql)).fetchall()
|
||
all_brands = sorted([str(row[0]) for row in result if row[0]])
|
||
|
||
if col_vendor:
|
||
sql = f"SELECT DISTINCT \"{col_vendor}\" FROM {table_name} WHERE \"{col_vendor}\" IS NOT NULL AND \"{col_vendor}\" <> ''"
|
||
result = conn.execute(text(sql)).fetchall()
|
||
all_vendors = sorted([str(row[0]) for row in result if row[0]])
|
||
|
||
if col_activity:
|
||
sql = f"SELECT DISTINCT \"{col_activity}\" FROM {table_name} WHERE \"{col_activity}\" IS NOT NULL AND \"{col_activity}\" <> ''"
|
||
result = conn.execute(text(sql)).fetchall()
|
||
all_activities = sorted([str(row[0]) for row in result if row[0]])
|
||
|
||
if col_payment:
|
||
sql = f"SELECT DISTINCT \"{col_payment}\" FROM {table_name} WHERE \"{col_payment}\" IS NOT NULL AND \"{col_payment}\" <> ''"
|
||
result = conn.execute(text(sql)).fetchall()
|
||
all_payments = sorted([str(row[0]) for row in result if row[0]])
|
||
|
||
# V-Fix: 從數據庫提取所有月份(格式:YYYY-MM)
|
||
if col_date:
|
||
# 從日期欄位提取月份(支援多種日期欄位名稱)
|
||
date_fields = ['日期', '訂單日期', '時間']
|
||
for field in date_fields:
|
||
try:
|
||
# V-Fix: 使用 substr 提取年月部分,並將斜線替換為橫線
|
||
# 數據庫格式: "2025/07/01" -> 提取前7個字符 "2025/07" -> 替換斜線 "2025-07"
|
||
result = conn.execute(text(f"""
|
||
SELECT DISTINCT replace(substr(\"{field}\", 1, 7), '/', '-') as month
|
||
FROM {table_name}
|
||
WHERE \"{field}\" IS NOT NULL AND \"{field}\" != ''
|
||
ORDER BY month
|
||
""")).fetchall()
|
||
if result and len(result) > 0:
|
||
all_months = [row[0] for row in result if row[0] and '-' in str(row[0])]
|
||
if all_months: # 如果成功提取到月份,就使用這個欄位
|
||
sys_log.info(f"[Sales Analysis] 從欄位 {field} 提取到 {len(all_months)} 個月份: {all_months}")
|
||
break
|
||
except Exception as ex:
|
||
sys_log.warning(f"[Sales Analysis] 從欄位 {field} 提取月份失敗: {ex}")
|
||
continue
|
||
except Exception as e:
|
||
sys_log.warning(f"[Sales Analysis] 從數據庫查詢下拉選項失敗: {e}")
|
||
# 如果查詢失敗,回退到從快取讀取
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
original_df = _SALES_PROCESSED_CACHE[cache_key]['df']
|
||
elif table_name in _SALES_PROCESSED_CACHE:
|
||
original_df = _SALES_PROCESSED_CACHE[table_name]['df']
|
||
else:
|
||
original_df = pd.DataFrame()
|
||
|
||
if not original_df.empty:
|
||
all_categories = sorted(original_df[col_category].dropna().astype(str).unique().tolist()) if col_category else []
|
||
all_brands = sorted(original_df[col_brand].dropna().astype(str).unique().tolist()) if col_brand else []
|
||
all_vendors = sorted(original_df[col_vendor].dropna().astype(str).unique().tolist()) if col_vendor else []
|
||
all_activities = sorted(original_df[col_activity].dropna().astype(str).unique().tolist()) if col_activity else []
|
||
all_payments = sorted(original_df[col_payment].dropna().astype(str).unique().tolist()) if col_payment else []
|
||
all_months = sorted(original_df['_month_str'].dropna().unique().tolist()) if col_date and '_month_str' in original_df.columns else []
|
||
|
||
# 取得前端參數供模板回填
|
||
selected_category = request.args.get('category', 'all')
|
||
selected_metric = request.args.get('metric', 'amount')
|
||
selected_brand = request.args.get('brand', 'all')
|
||
selected_vendor = request.args.get('vendor', 'all')
|
||
selected_activity = request.args.get('activity', 'all')
|
||
selected_payment = request.args.get('payment', 'all')
|
||
selected_dow = request.args.get('dow', 'all')
|
||
selected_hour = request.args.get('hour', 'all')
|
||
selected_month = request.args.get('month', 'all')
|
||
keyword = request.args.get('keyword', '').strip()
|
||
min_price = request.args.get('min_price', '')
|
||
max_price = request.args.get('max_price', '')
|
||
min_margin = request.args.get('min_margin', '')
|
||
max_margin = request.args.get('max_margin', '')
|
||
|
||
# 決定排序欄位
|
||
sort_col = col_amount
|
||
if selected_metric == 'qty' and col_qty:
|
||
sort_col = col_qty
|
||
|
||
target_df = target_df.sort_values(by=sort_col, ascending=False)
|
||
|
||
# 📊 KPI 計算 (針對篩選後的資料)
|
||
total_revenue = float(target_df[col_amount].sum())
|
||
total_qty = float(target_df[col_qty].sum()) if col_qty else 0
|
||
total_count = int(len(target_df)) # 訂單筆數
|
||
# V-Fix 2026-01-15: SKU 數應計算唯一商品數,而非記錄筆數
|
||
sku_count = int(target_df[col_name].nunique()) if col_name else total_count
|
||
|
||
# V-New: 成本與毛利計算
|
||
total_cost = float(target_df[col_cost].sum()) if col_cost else 0
|
||
if col_profit:
|
||
gross_margin = float(target_df[col_profit].sum())
|
||
else:
|
||
gross_margin = total_revenue - total_cost
|
||
|
||
gross_margin_rate = (gross_margin / total_revenue * 100) if total_revenue > 0 else 0
|
||
|
||
avg_price = total_revenue / total_qty if total_qty > 0 else 0
|
||
|
||
# 📊 V-New: 商業洞察 (Top 3 Analysis)
|
||
insights = {
|
||
'rev_cats': [], 'rev_prods': [],
|
||
'margin_cats': [], 'margin_prods': [],
|
||
'qty_cats': [], 'qty_prods': []
|
||
}
|
||
|
||
# Helper function to get top 3
|
||
# Helper function to get top 3
|
||
def get_top_3(groupby_col, metric_col, is_margin=False, is_qty=False):
|
||
if not groupby_col or not metric_col: return []
|
||
|
||
# V-Opt: 直接使用 target_df 與預計算欄位,避免 copy() 與 assign()
|
||
target_metric = metric_col
|
||
if is_margin:
|
||
target_metric = 'calculated_profit'
|
||
|
||
try:
|
||
# 直接聚合並取前3名
|
||
# V-Fix 2026-01-15: 若 groupby_col 是 list (例如 [PID, Name]),結果 index 會是 MultiIndex
|
||
grouped = target_df.groupby(groupby_col)[target_metric].sum()
|
||
|
||
def get_name(k):
|
||
# 如果是 Tuple (MultiIndex),通常最後一個是 Name,取之
|
||
return str(k[-1]) if isinstance(k, tuple) else str(k)
|
||
|
||
return [{'name': get_name(k), 'value': float(v)} for k, v in grouped.nlargest(3).items() if v > 0]
|
||
except Exception:
|
||
return []
|
||
|
||
insights['rev_cats'] = get_top_3(col_category, col_amount)
|
||
# V-Fix: 商品聚合改用 [PID, Name] 避免同名不同ID商品被合併
|
||
product_groupby = [col_pid, col_name] if col_pid else col_name
|
||
insights['rev_prods'] = get_top_3(product_groupby, col_amount)
|
||
insights['qty_cats'] = get_top_3(col_category, col_qty, is_qty=True)
|
||
insights['qty_prods'] = get_top_3(product_groupby, col_qty, is_qty=True)
|
||
|
||
if col_cost or col_profit:
|
||
insights['margin_cats'] = get_top_3(col_category, col_amount, is_margin=True)
|
||
insights['margin_prods'] = get_top_3(product_groupby, col_amount, is_margin=True)
|
||
|
||
# 📊 V-Opt: 改為橫向長條圖數據 (Top 20)
|
||
top_chart = target_df.head(20)
|
||
bar_data = {
|
||
'labels': [str(n)[:20] + '...' if len(str(n)) > 20 else str(n) for n in top_chart[col_name]], # 稍微放寬長度限制
|
||
'chart_values': [float(x) for x in top_chart[sort_col]],
|
||
'metric_label': '銷售金額 ($)' if selected_metric == 'amount' else '銷售數量'
|
||
}
|
||
|
||
# 📋 V-Opt: 列表資料改為 AJAX 載入,這裡只傳空列表以加快初始渲染
|
||
table_items = []
|
||
|
||
# 準備類別圓餅圖資料
|
||
# V-Fix: 使用 target_df (篩選後資料) 動態計算
|
||
cat_data = {'labels': [], 'chart_values': []}
|
||
if col_category and not target_df.empty:
|
||
cat_group_all = target_df.groupby(col_category)[col_amount].sum().sort_values(ascending=False)
|
||
TOP_N_CATS = 12
|
||
if len(cat_group_all) > TOP_N_CATS:
|
||
top_cats = cat_group_all.head(TOP_N_CATS)
|
||
other_val = cat_group_all.iloc[TOP_N_CATS:].sum()
|
||
cat_data['labels'] = [str(x) for x in top_cats.index.tolist()] + ['其他']
|
||
cat_data['chart_values'] = [float(x) for x in top_cats.tolist()] + [float(other_val)]
|
||
else:
|
||
cat_data['labels'] = [str(x) for x in cat_group_all.index.tolist()]
|
||
cat_data['chart_values'] = [float(x) for x in cat_group_all.tolist()]
|
||
|
||
# 📊 V-New: 價格帶分析 (Price Range Analysis)
|
||
price_dist_data = {'labels': [], 'chart_values': []}
|
||
if col_price and not target_df.empty:
|
||
# 定義價格區間 (0-500, 500-1000, 1000-2000, 2000-5000, 5000-10000, 10000+)
|
||
bins = [0, 500, 1000, 2000, 5000, 10000, float('inf')]
|
||
labels = ['0-499', '500-999', '1,000-1,999', '2,000-4,999', '5,000-9,999', '10,000+']
|
||
|
||
# V-Opt: 使用 pd.cut 進行分組,但不修改 target_df (避免污染快取)
|
||
# right=False 表示包含左邊界,例如 500 在 500-999 這一組
|
||
price_bins = pd.cut(target_df[col_price], bins=bins, labels=labels, right=False)
|
||
|
||
# 統計各區間的「銷售金額」貢獻 (直接使用外部 Series 進行 groupby)
|
||
range_group = target_df.groupby(price_bins, observed=False)[col_amount].sum()
|
||
|
||
price_dist_data['labels'] = labels
|
||
price_dist_data['chart_values'] = [float(range_group.get(l, 0)) for l in labels]
|
||
|
||
# 📊 V-New: 價格 vs 銷量 散佈圖 (Scatter Plot)
|
||
scatter_data = []
|
||
if col_price and col_qty and not target_df.empty:
|
||
# 取前 300 筆主要商品,避免圖表過於密集導致瀏覽器卡頓
|
||
scatter_source = target_df.head(300)
|
||
for _, row in scatter_source.iterrows():
|
||
# V-Fix (2026-01-23): 處理 NaN 值
|
||
price_val = row[col_price] if pd.notna(row[col_price]) else 0
|
||
qty_val = row[col_qty] if pd.notna(row[col_qty]) else 0
|
||
amt_val = row[col_amount] if pd.notna(row[col_amount]) else 0
|
||
scatter_data.append({
|
||
'x': float(price_val),
|
||
'y': float(qty_val),
|
||
'name': str(row[col_name]) if pd.notna(row[col_name]) else '',
|
||
'amt': float(amt_val) # 用於 tooltip 顯示金額
|
||
})
|
||
|
||
# 📊 V-New: BCG 矩陣分析 (BCG Matrix)
|
||
# X軸: 銷量 (Qty), Y軸: 毛利率 (Margin %)
|
||
bcg_data = {'datasets': [], 'thresholds': {'x': 0, 'y': 0}}
|
||
# V-Fix: 確保 calculated_margin_rate 欄位存在
|
||
if col_qty and (col_cost or col_profit) and not target_df.empty and 'calculated_margin_rate' in target_df.columns:
|
||
# 1. 計算閾值 (使用中位數,避免極端值影響)
|
||
# 過濾掉銷量為 0 的商品,避免干擾閾值計算
|
||
active_products = target_df[target_df[col_qty] > 0]
|
||
if not active_products.empty and 'calculated_margin_rate' in active_products.columns:
|
||
median_qty = active_products[col_qty].median()
|
||
median_margin = active_products['calculated_margin_rate'].median()
|
||
|
||
# 若中位數為 0 (例如大部分商品沒銷量),則給一個預設值以利顯示
|
||
if median_qty == 0: median_qty = 1
|
||
|
||
bcg_data['thresholds'] = {'x': float(median_qty), 'y': float(median_margin)}
|
||
|
||
# 2. 分類商品 (四象限)
|
||
# Stars (明星): High Qty, High Margin
|
||
stars = active_products[(active_products[col_qty] >= median_qty) & (active_products['calculated_margin_rate'] >= median_margin)]
|
||
# Cows (金牛): High Qty, Low Margin
|
||
cows = active_products[(active_products[col_qty] >= median_qty) & (active_products['calculated_margin_rate'] < median_margin)]
|
||
# Questions (問題): Low Qty, High Margin
|
||
questions = active_products[(active_products[col_qty] < median_qty) & (active_products['calculated_margin_rate'] >= median_margin)]
|
||
# Dogs (瘦狗): Low Qty, Low Margin
|
||
dogs = active_products[(active_products[col_qty] < median_qty) & (active_products['calculated_margin_rate'] < median_margin)]
|
||
|
||
def format_bcg_points(df_segment):
|
||
# 限制點數,避免前端卡頓 (各象限最多 100 點)
|
||
return [{'x': float(row[col_qty]), 'y': float(row['calculated_margin_rate']), 'name': str(row[col_name]), 'amt': float(row[col_amount])} for _, row in df_segment.head(100).iterrows()]
|
||
|
||
bcg_data['datasets'] = [
|
||
{'label': '明星商品 (Stars)', 'data': format_bcg_points(stars), 'backgroundColor': 'rgba(255, 206, 86, 0.8)', 'borderColor': 'rgba(255, 206, 86, 1)'}, # Yellow
|
||
{'label': '金牛商品 (Cows)', 'data': format_bcg_points(cows), 'backgroundColor': 'rgba(75, 192, 192, 0.8)', 'borderColor': 'rgba(75, 192, 192, 1)'}, # Green
|
||
{'label': '問題商品 (Questions)', 'data': format_bcg_points(questions), 'backgroundColor': 'rgba(54, 162, 235, 0.8)', 'borderColor': 'rgba(54, 162, 235, 1)'}, # Blue
|
||
{'label': '瘦狗商品 (Dogs)', 'data': format_bcg_points(dogs), 'backgroundColor': 'rgba(201, 203, 207, 0.8)', 'borderColor': 'rgba(201, 203, 207, 1)'} # Grey
|
||
]
|
||
|
||
# 📊 V-New: 時間維度分析 (Time Analysis)
|
||
dow_data = {'labels': ['週一', '週二', '週三', '週四', '週五', '週六', '週日'], 'chart_values': [0]*7}
|
||
hourly_data = {'labels': [f"{i:02d}:00" for i in range(24)], 'chart_values': [0]*24}
|
||
weekly_data = {'labels': [], 'chart_values': []} # V-New: 每週趨勢
|
||
monthly_data = {'labels': [], 'chart_values': []} # V-New: 每月趨勢
|
||
heatmap_data = [] # V-New: 多維度熱力圖 (Day x Hour)
|
||
treemap_data = [] # V-New: 板塊圖數據
|
||
|
||
if col_date:
|
||
# 過濾掉日期無效的資料
|
||
# V-Opt: 使用預計算欄位進行分組,速度更快
|
||
if not target_df.empty:
|
||
# 1. 星期分析 (Day of Week)
|
||
dow_group = target_df.groupby('_dow')[col_amount].sum()
|
||
for day, val in dow_group.items():
|
||
if not np.isnan(day):
|
||
dow_data['chart_values'][int(day)] = float(val)
|
||
|
||
# 2. 小時分析 (Hourly)
|
||
hour_group = target_df.groupby('_hour')[col_amount].sum()
|
||
for hour, val in hour_group.items():
|
||
if not np.isnan(hour):
|
||
hourly_data['chart_values'][int(hour)] = float(val)
|
||
|
||
# 3. 每月趨勢 (Monthly Trend) - V-New
|
||
month_group = target_df.groupby('_month_str')[col_amount].sum().sort_index()
|
||
monthly_data['labels'] = month_group.index.tolist()
|
||
# V-Fix (2026-01-23): 處理 NaN 值避免 JSON 序列化失敗
|
||
monthly_data['chart_values'] = [float(x) if not np.isnan(x) else 0 for x in month_group.tolist()]
|
||
|
||
# 3. 每週趨勢 (Weekly Trend) - V-New
|
||
week_group = target_df.groupby('_week')[col_amount].sum().sort_index()
|
||
# V-Opt: 解除 12 週限制,顯示完整年度趨勢 (因應一年份數據需求)
|
||
weekly_data['labels'] = week_group.index.tolist()
|
||
# V-Fix (2026-01-23): 處理 NaN 值避免 JSON 序列化失敗
|
||
weekly_data['chart_values'] = [float(x) if not np.isnan(x) else 0 for x in week_group.tolist()]
|
||
|
||
# 4. 多維度熱力圖 (Day x Hour) - V-Fix: 確保數據完整性
|
||
dh_group = target_df.groupby(['_dow', '_hour'])[col_amount].sum()
|
||
# V-Opt: 正規化氣泡大小 (Normalize Bubble Size) 以提升可讀性
|
||
max_val = dh_group.max() if not dh_group.empty else 1
|
||
|
||
for (day, hour), val in dh_group.items():
|
||
# V-Fix (2026-01-23): 處理 NaN 值
|
||
if np.isnan(val):
|
||
val = 0
|
||
# 將數值映射到 3~25px 的半徑範圍,確保視覺可辨識
|
||
radius = 3 + (math.sqrt(val) / math.sqrt(max_val)) * 22 if val > 0 else 0
|
||
heatmap_data.append({
|
||
'x': int(hour), # X軸: 小時 (0-23)
|
||
'y': int(day), # Y軸: 星期 (0-6)
|
||
'r': float(radius) if not np.isnan(radius) else 0, # V-Adj: 正規化後半徑
|
||
'v': float(val) # 實際數值 (用於 Tooltip)
|
||
})
|
||
|
||
# 📊 V-New: 板塊圖 (Treemap) 數據準備
|
||
# 結構: Root -> Category -> Product (Top 5 per cat)
|
||
if col_category and col_name and col_amount and not target_df.empty:
|
||
# V-Opt: 優化聚合邏輯,先聚合再篩選,避免在迴圈中重複過濾大表
|
||
# 1. 先聚合 Category + Product (大幅減少資料量)
|
||
cat_prod_group = target_df.groupby([col_category, col_name])[col_amount].sum().reset_index()
|
||
|
||
# 2. 找出前 10 大分類
|
||
top_cats = cat_prod_group.groupby(col_category)[col_amount].sum().nlargest(10).index.tolist()
|
||
|
||
# 3. 針對前 10 大分類,各取前 5 大商品
|
||
for cat in top_cats:
|
||
if not cat: continue
|
||
# 在縮減後的資料中篩選,速度極快
|
||
cat_subset = cat_prod_group[cat_prod_group[col_category] == cat]
|
||
top_prods = cat_subset.nlargest(5, col_amount)
|
||
|
||
for _, row in top_prods.iterrows():
|
||
# V-Fix (2026-01-23): 處理 NaN 值
|
||
amount_val = row[col_amount]
|
||
if pd.isna(amount_val):
|
||
amount_val = 0
|
||
treemap_data.append({
|
||
'category': str(cat),
|
||
'product': str(row[col_name]) if pd.notna(row[col_name]) else '',
|
||
'value': float(amount_val),
|
||
'color': get_color_for_string(str(cat)) # V-Fix: 增加顏色參數,確保與分類顏色一致且清晰
|
||
})
|
||
|
||
# 📊 V-New: ABC 分析 (Pareto Analysis) - TODO #8
|
||
# A類: 累積營收 0-80% (核心商品)
|
||
# B類: 累積營收 80-95% (次要商品)
|
||
# C類: 累積營收 95-100% (長尾商品)
|
||
abc_stats = {'A': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0},
|
||
'B': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0},
|
||
'C': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}}
|
||
|
||
if not target_df.empty and col_amount:
|
||
# 使用 numpy 加速累積計算
|
||
sorted_rev = target_df[col_amount].values # 已在上方排序過
|
||
cumsum_rev = np.cumsum(sorted_rev)
|
||
total_rev_abc = cumsum_rev[-1] if len(cumsum_rev) > 0 else 0
|
||
|
||
if total_rev_abc > 0:
|
||
pct_cumsum = cumsum_rev / total_rev_abc * 100
|
||
|
||
# 找出分界點索引
|
||
idx_a = np.searchsorted(pct_cumsum, 80)
|
||
idx_b = np.searchsorted(pct_cumsum, 95)
|
||
|
||
# A類: 0 ~ idx_a
|
||
count_a = idx_a + 1
|
||
rev_a = cumsum_rev[idx_a] if idx_a < len(cumsum_rev) else total_rev_abc
|
||
|
||
# B類: idx_a+1 ~ idx_b
|
||
count_b = max(0, idx_b - idx_a)
|
||
rev_b = (cumsum_rev[idx_b] - cumsum_rev[idx_a]) if idx_b < len(cumsum_rev) else (total_rev_abc - cumsum_rev[idx_a])
|
||
|
||
# C類: idx_b+1 ~ end
|
||
count_c = max(0, len(cumsum_rev) - 1 - idx_b)
|
||
rev_c = total_rev_abc - cumsum_rev[idx_b] if idx_b < len(cumsum_rev) else 0
|
||
|
||
abc_stats['A'] = {'count': int(count_a), 'revenue': float(rev_a), 'pct_rev': float(rev_a/total_rev_abc*100), 'pct_sku': float(count_a/total_count*100)}
|
||
abc_stats['B'] = {'count': int(count_b), 'revenue': float(rev_b), 'pct_rev': float(rev_b/total_rev_abc*100), 'pct_sku': float(count_b/total_count*100)}
|
||
abc_stats['C'] = {'count': int(count_c), 'revenue': float(rev_c), 'pct_rev': float(rev_c/total_rev_abc*100), 'pct_sku': float(count_c/total_count*100)}
|
||
|
||
# 📊 V-New: 廠商獲利能力排行 (Vendor Profitability) - TODO #9
|
||
vendor_stats = []
|
||
if col_vendor and col_amount and not target_df.empty:
|
||
# Group by vendor
|
||
agg_dict = {col_amount: 'sum', col_name: 'nunique'} # nunique 計算不重複商品數 (SKU)
|
||
if col_qty: agg_dict[col_qty] = 'sum' # V-New: 累加銷量
|
||
if col_profit:
|
||
agg_dict[col_profit] = 'sum'
|
||
elif col_cost:
|
||
agg_dict[col_cost] = 'sum'
|
||
|
||
# 使用 groupby 聚合
|
||
vendor_group = target_df.groupby(col_vendor).agg(agg_dict).reset_index()
|
||
|
||
# 計算毛利與毛利率
|
||
if col_profit:
|
||
vendor_group['total_profit'] = vendor_group[col_profit]
|
||
elif col_cost:
|
||
vendor_group['total_profit'] = vendor_group[col_amount] - vendor_group[col_cost]
|
||
else:
|
||
vendor_group['total_profit'] = 0
|
||
|
||
# 計算營收佔比 (Share %)
|
||
total_vendor_revenue = vendor_group[col_amount].sum()
|
||
if total_vendor_revenue > 0:
|
||
vendor_group['revenue_share'] = (vendor_group[col_amount] / total_vendor_revenue * 100)
|
||
else:
|
||
vendor_group['revenue_share'] = 0.0
|
||
|
||
# 避免除以零
|
||
vendor_group['margin_rate'] = np.where(vendor_group[col_amount] > 0, (vendor_group['total_profit'] / vendor_group[col_amount] * 100), 0)
|
||
|
||
# 計算平均客單價 (ASP)
|
||
if col_qty:
|
||
vendor_group['asp'] = np.where(vendor_group[col_qty] > 0, vendor_group[col_amount] / vendor_group[col_qty], 0)
|
||
|
||
# 排序:預設按總業績降序
|
||
vendor_group = vendor_group.sort_values(by=col_amount, ascending=False)
|
||
|
||
# 格式化輸出 (Top 100)
|
||
for _, row in vendor_group.head(100).iterrows():
|
||
vendor_stats.append({
|
||
'name': str(row[col_vendor]),
|
||
'revenue': float(row[col_amount]),
|
||
'share': float(row['revenue_share']), # V-New
|
||
'qty': float(row[col_qty]) if col_qty else 0, # V-New
|
||
'asp': float(row.get('asp', 0)), # V-New
|
||
'profit': float(row['total_profit']),
|
||
'margin_rate': float(row['margin_rate']),
|
||
'sku_count': int(row[col_name])
|
||
})
|
||
|
||
# 📊 V-New: 淡旺季熱力圖 (Seasonality Analysis) - TODO #10
|
||
seasonality_data = None
|
||
if col_date and col_category and col_amount and not target_df.empty:
|
||
# 1. 取得前 10 大分類 (避免圖表過大)
|
||
# 使用 target_df (受篩選影響),這樣可以看特定品牌下的分類季節性
|
||
top_cats_season = target_df.groupby(col_category)[col_amount].sum().nlargest(10).index.tolist()
|
||
|
||
# 2. 聚合數據 (Month x Category)
|
||
season_group = target_df[target_df[col_category].isin(top_cats_season)].groupby(['_month_str', col_category])[col_amount].sum().reset_index()
|
||
|
||
# 3. 轉換為 Bubble Chart 格式
|
||
# X軸: 月份 (需解析 _month_str 取得順序)
|
||
# Y軸: 分類 (使用 top_cats_season 的索引)
|
||
|
||
# 取得所有月份並排序
|
||
all_months_sorted = sorted(target_df['_month_str'].unique())
|
||
month_map = {m: i for i, m in enumerate(all_months_sorted)}
|
||
cat_map = {c: i for i, c in enumerate(top_cats_season)}
|
||
|
||
points = []
|
||
max_val_season = season_group[col_amount].max() if not season_group.empty else 1
|
||
|
||
for _, row in season_group.iterrows():
|
||
m_str = row['_month_str']
|
||
cat = row[col_category]
|
||
val = row[col_amount]
|
||
|
||
if m_str in month_map and cat in cat_map:
|
||
# 正規化大小 (3~25px)
|
||
radius = 3 + (math.sqrt(val) / math.sqrt(max_val_season)) * 25 if val > 0 else 0
|
||
points.append({
|
||
'x': month_map[m_str],
|
||
'y': cat_map[cat],
|
||
'r': radius,
|
||
'v': float(val),
|
||
'm': m_str,
|
||
'c': cat
|
||
})
|
||
|
||
seasonality_data = {
|
||
'datasets': [{
|
||
'label': '淡旺季熱點',
|
||
'data': points,
|
||
# 顏色將在前端動態生成
|
||
}],
|
||
'yLabels': top_cats_season,
|
||
'xLabels': all_months_sorted
|
||
}
|
||
|
||
# 📊 V-New 2026-01-15: 行銷活動業績貢獻 (Marketing Campaign Contribution)
|
||
marketing_data = None
|
||
if not target_df.empty:
|
||
marketing_data = prepare_marketing_summary(target_df, sort_by=selected_metric)
|
||
|
||
return render_template('sales_analysis.html',
|
||
marketing_data=marketing_data, # V-New: 傳遞行銷活動數據
|
||
items=table_items,
|
||
kpi={
|
||
'revenue': total_revenue,
|
||
'qty': total_qty,
|
||
'count': total_count,
|
||
'sku_count': sku_count, # V-Fix 2026-01-15: 唯一商品數
|
||
'cost': total_cost,
|
||
'gross_margin': gross_margin,
|
||
'gross_margin_rate': gross_margin_rate,
|
||
'avg_price': avg_price
|
||
},
|
||
insights=insights,
|
||
abc_stats=abc_stats, # V-New: 傳遞 ABC 分析數據
|
||
vendor_stats=vendor_stats, # V-New: 傳遞廠商排行數據
|
||
seasonality_data=seasonality_data, # V-New: 傳遞淡旺季數據
|
||
bar_data=bar_data,
|
||
cat_data=cat_data,
|
||
price_dist_data=price_dist_data,
|
||
scatter_data=scatter_data,
|
||
bcg_data=bcg_data, # V-New: 傳遞 BCG 數據
|
||
dow_data=dow_data,
|
||
hourly_data=hourly_data,
|
||
monthly_data=monthly_data,
|
||
weekly_data=weekly_data,
|
||
heatmap_data=heatmap_data,
|
||
treemap_data=treemap_data,
|
||
all_categories=all_categories,
|
||
all_brands=all_brands, all_vendors=all_vendors, all_activities=all_activities, all_payments=all_payments,
|
||
all_months=all_months, # V-New: 傳遞月份列表
|
||
selected_category=selected_category,
|
||
selected_brand=selected_brand, selected_vendor=selected_vendor,
|
||
selected_activity=selected_activity, selected_payment=selected_payment,
|
||
selected_dow=selected_dow, selected_hour=selected_hour,
|
||
selected_month=selected_month,
|
||
selected_metric=selected_metric,
|
||
keyword=keyword, min_price=min_price, max_price=max_price,
|
||
min_margin=min_margin, max_margin=max_margin,
|
||
cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category, 'date': col_date, 'cost': col_cost, 'profit': col_profit, 'vendor': col_vendor, 'brand': col_brand, 'return_qty': col_return_qty, 'pid': col_pid},
|
||
table_name=table_name,
|
||
data_range_months=data_range_months,
|
||
start_date=start_date, # V-New: 傳遞自訂開始日期
|
||
end_date=end_date, # V-New: 傳遞自訂結束日期
|
||
total_records=len(df),
|
||
db_data_range=db_data_range) # V-New: 傳遞資料庫資料期間
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"Sales Analysis Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
# 提供完整的變數以避免模板錯誤
|
||
return render_template('sales_analysis.html',
|
||
error=f"系統發生錯誤: {str(e)}",
|
||
marketing_data=None,
|
||
insights=None,
|
||
abc_stats=None,
|
||
vendor_stats=None,
|
||
seasonality_data=None,
|
||
scatter_data=None,
|
||
bcg_data=None,
|
||
dow_data=None,
|
||
hourly_data=None,
|
||
monthly_data=None,
|
||
weekly_data=None,
|
||
heatmap_data=None,
|
||
treemap_data=None,
|
||
all_categories=[],
|
||
all_brands=[], all_vendors=[], all_activities=[], all_payments=[],
|
||
all_months=[],
|
||
selected_category='all',
|
||
selected_brand='all', selected_vendor='all',
|
||
selected_activity='all', selected_payment='all',
|
||
selected_dow='all', selected_hour='all',
|
||
selected_month='all',
|
||
selected_metric=request.args.get('metric', 'amount'),
|
||
keyword='', min_price='', max_price='',
|
||
min_margin='', max_margin='',
|
||
cols={},
|
||
table_name='realtime_sales_monthly',
|
||
no_filter=False,
|
||
data_range_months=int(request.args.get('data_range', '0') or '0'),
|
||
start_date=request.args.get('start_date', ''),
|
||
end_date=request.args.get('end_date', ''),
|
||
total_records=0,
|
||
db_data_range='')
|
||
|
||
# V-Opt: API 層級快取 (減少重複查詢)
|
||
_TABLE_DATA_CACHE = {}
|
||
_TABLE_DATA_CACHE_TTL = 60 # 快取 60 秒
|
||
|
||
@app.route('/api/sales_analysis/table_data')
|
||
def get_sales_table_data():
|
||
"""API: 取得業績分析的詳細列表資料 (Server-side AJAX) - 使用 SQL 聚合優化"""
|
||
try:
|
||
import hashlib
|
||
from datetime import datetime, timedelta, timezone
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
# V-Opt: 產生查詢快取 key (根據所有篩選條件)
|
||
cache_params = request.args.to_dict()
|
||
cache_key = hashlib.md5(str(sorted(cache_params.items())).encode(), usedforsecurity=False).hexdigest()
|
||
|
||
# V-Opt: 檢查快取
|
||
if cache_key in _TABLE_DATA_CACHE:
|
||
cached = _TABLE_DATA_CACHE[cache_key]
|
||
if time.time() - cached['time'] < _TABLE_DATA_CACHE_TTL:
|
||
sys_log.debug(f"[API] Table Data: 使用快取 (key={cache_key[:8]})")
|
||
return jsonify(cached['data'])
|
||
|
||
table_name = 'realtime_sales_monthly'
|
||
data_range_months = int(request.args.get('data_range', '1') or '1')
|
||
start_date = request.args.get('start_date', '') # V-New: 自訂開始日期
|
||
end_date = request.args.get('end_date', '') # V-New: 自訂結束日期
|
||
|
||
# V-Fix: 取得所有篩選參數
|
||
category_filter = request.args.get('category', 'all')
|
||
brand_filter = request.args.get('brand', 'all') # V-Fix: 品牌篩選
|
||
vendor_filter = request.args.get('vendor', 'all') # V-Fix: 廠商篩選
|
||
activity_filter = request.args.get('activity', 'all') # V-Fix: 活動篩選
|
||
payment_filter = request.args.get('payment', 'all') # V-Fix: 付款方式篩選
|
||
month_filter = request.args.get('month', 'all')
|
||
dow_filter = request.args.get('dow', 'all') # 星期篩選
|
||
hour_filter = request.args.get('hour', 'all') # 小時篩選
|
||
min_price_str = request.args.get('min_price', '')
|
||
max_price_str = request.args.get('max_price', '')
|
||
min_margin_str = request.args.get('min_margin', '')
|
||
max_margin_str = request.args.get('max_margin', '')
|
||
keyword = request.args.get('keyword', '').strip()
|
||
|
||
db = DatabaseManager()
|
||
|
||
# V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
|
||
# 嘗試從快取讀取欄位名稱
|
||
cols_map = {}
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {})
|
||
elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key
|
||
cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {})
|
||
|
||
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
|
||
# V-Fix (2026-01-23): 使用 or 確保不會得到 None 值
|
||
col_name = cols_map.get('name') or '商品名稱'
|
||
col_pid = cols_map.get('pid') or '商品ID'
|
||
col_brand = cols_map.get('brand') or '品牌'
|
||
col_vendor = cols_map.get('vendor') or '廠商名稱'
|
||
col_category = cols_map.get('category') or '商品館'
|
||
col_amount = cols_map.get('amount') or '總業績'
|
||
col_qty = cols_map.get('qty') or '數量'
|
||
col_cost = cols_map.get('cost') or '總成本'
|
||
col_return_qty = cols_map.get('return_qty') or '退貨數量'
|
||
|
||
# V-Opt: 使用純 SQL 聚合查詢,避免載入完整資料集
|
||
# 建立日期篩選條件
|
||
date_filter = ""
|
||
# V-New: 優先處理自訂日期區間
|
||
if start_date or end_date:
|
||
# V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01)
|
||
start_date_slash = start_date.replace('-', '/') if start_date else ''
|
||
end_date_slash = end_date.replace('-', '/') if end_date else ''
|
||
|
||
# V-Fix: 只使用「日期」欄位(「訂單日期」欄位是固定文字「訂單日期」,不是實際日期)
|
||
if start_date and end_date:
|
||
date_filter = f"""AND ("日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}')"""
|
||
elif start_date:
|
||
date_filter = f"""AND ("日期" >= '{start_date_slash}')"""
|
||
else: # only end_date
|
||
date_filter = f"""AND ("日期" <= '{end_date_slash}')"""
|
||
elif data_range_months > 0:
|
||
# V-Fix: 使用斜線格式以匹配資料庫格式
|
||
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
|
||
# V-Fix: 只使用「日期」欄位進行篩選(「訂單日期」是固定文字,不是實際日期)
|
||
date_filter = f"""AND ("日期" >= '{cutoff_date}')"""
|
||
|
||
# V-Fix: 建立其他篩選條件
|
||
additional_filters = []
|
||
|
||
# 分類篩選
|
||
if category_filter and category_filter != 'all' and col_category:
|
||
additional_filters.append(f""""{col_category}" = '{category_filter}'""")
|
||
|
||
# V-Fix: 品牌篩選
|
||
if brand_filter and brand_filter != 'all' and col_brand:
|
||
additional_filters.append(f""""{col_brand}" = '{brand_filter}'""")
|
||
|
||
# V-Fix: 廠商篩選
|
||
if vendor_filter and vendor_filter != 'all' and col_vendor:
|
||
additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""")
|
||
|
||
# V-Fix: 活動篩選
|
||
col_activity = cols_map.get('activity')
|
||
if activity_filter and activity_filter != 'all' and col_activity:
|
||
additional_filters.append(f""""{col_activity}" = '{activity_filter}'""")
|
||
|
||
# V-Fix: 付款方式篩選
|
||
col_payment = cols_map.get('payment')
|
||
if payment_filter and payment_filter != 'all' and col_payment:
|
||
additional_filters.append(f""""{col_payment}" = '{payment_filter}'""")
|
||
|
||
# 月份篩選
|
||
if month_filter and month_filter != 'all':
|
||
# V-Fix: 月份格式例如 "2025-01",但資料庫可能使用斜線格式 "2025/01"
|
||
# 只使用「日期」欄位(「訂單日期」是固定文字,「時間」只包含時間)
|
||
month_filter_slash = month_filter.replace('-', '/') # "2025-01" -> "2025/01"
|
||
# 同時匹配橫線和斜線格式
|
||
additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""")
|
||
|
||
# 星期篩選 (需要從日期計算)
|
||
if dow_filter and dow_filter != 'all':
|
||
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite 兩種資料庫
|
||
# Pandas dt.dayofweek: 0=Monday, 6=Sunday
|
||
pandas_dow = int(dow_filter)
|
||
if DATABASE_TYPE == 'postgresql':
|
||
# PostgreSQL: EXTRACT(DOW FROM date) 0=Sunday, 6=Saturday
|
||
# Pandas 0(Mon) -> PostgreSQL 1(Mon), Pandas 6(Sun) -> PostgreSQL 0(Sun)
|
||
pg_dow = (pandas_dow + 1) % 7
|
||
# 日期格式可能是 2025/01/01,需要轉換為 YYYY-MM-DD
|
||
additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""")
|
||
else:
|
||
# SQLite: strftime('%w', date) 0=Sunday, 6=Saturday
|
||
sqlite_dow = str((pandas_dow + 1) % 7)
|
||
additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""")
|
||
|
||
# 小時篩選 (需要從時間欄位提取)
|
||
if hour_filter and hour_filter != 'all':
|
||
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite 兩種資料庫
|
||
hour_val = int(hour_filter)
|
||
if DATABASE_TYPE == 'postgresql':
|
||
# PostgreSQL: 使用 SUBSTRING 或 CAST
|
||
additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""")
|
||
else:
|
||
# SQLite: 使用 substr
|
||
additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""")
|
||
|
||
# 關鍵字篩選
|
||
if keyword:
|
||
keyword_escaped = keyword.replace("'", "''") # SQL 注入防護
|
||
keyword_conditions = []
|
||
if col_name:
|
||
keyword_conditions.append(f""""{col_name}" LIKE '%{keyword_escaped}%'""")
|
||
if col_pid:
|
||
keyword_conditions.append(f""""{col_pid}" LIKE '%{keyword_escaped}%'""")
|
||
if col_brand:
|
||
keyword_conditions.append(f""""{col_brand}" LIKE '%{keyword_escaped}%'""")
|
||
if col_vendor:
|
||
keyword_conditions.append(f""""{col_vendor}" LIKE '%{keyword_escaped}%'""")
|
||
if keyword_conditions:
|
||
additional_filters.append(f"({' OR '.join(keyword_conditions)})")
|
||
|
||
# V-New: 價格區間篩選 (Price Range)
|
||
if (min_price_str or max_price_str) and col_qty and col_amount:
|
||
# 假設單價 = 總業績 / 數量 (防止除以零)
|
||
price_cal_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)'
|
||
if min_price_str:
|
||
additional_filters.append(f"{price_cal_sql} >= {float(min_price_str)}")
|
||
if max_price_str:
|
||
additional_filters.append(f"{price_cal_sql} <= {float(max_price_str)}")
|
||
|
||
# V-New: 毛利率區間篩選 (Margin Range)
|
||
if (min_margin_str or max_margin_str) and col_amount:
|
||
# 計算毛利額 SQL
|
||
if col_profit:
|
||
profit_cal_sql = f'"{col_profit}"'
|
||
elif col_cost:
|
||
profit_cal_sql = f'("{col_amount}" - "{col_cost}")'
|
||
else:
|
||
profit_cal_sql = "0"
|
||
|
||
# 計算毛利率 SQL: (毛利 / 業績) * 100
|
||
margin_cal_sql = f'({profit_cal_sql} * 100.0 / NULLIF("{col_amount}", 0))'
|
||
|
||
if min_margin_str:
|
||
additional_filters.append(f"{margin_cal_sql} >= {float(min_margin_str)}")
|
||
if max_margin_str:
|
||
additional_filters.append(f"{margin_cal_sql} <= {float(max_margin_str)}")
|
||
|
||
# 組合所有篩選條件
|
||
all_filters = date_filter
|
||
if additional_filters:
|
||
all_filters += " AND " + " AND ".join(additional_filters)
|
||
|
||
# SQL 聚合查詢 - 直接在資料庫層級完成聚合
|
||
# V-Fix: 使用動態欄位名稱
|
||
group_by_cols = []
|
||
if col_pid: group_by_cols.append(f'"{col_pid}"')
|
||
if col_name: group_by_cols.append(f'"{col_name}"')
|
||
if col_brand: group_by_cols.append(f'"{col_brand}"')
|
||
if col_vendor: group_by_cols.append(f'"{col_vendor}"')
|
||
if col_category: group_by_cols.append(f'"{col_category}"')
|
||
group_by_clause = ', '.join(group_by_cols) if group_by_cols else '"商品ID"'
|
||
|
||
sql_query = f"""
|
||
SELECT
|
||
{f'"{col_pid}" as product_id' if col_pid else "'未知' as product_id"},
|
||
{f'"{col_name}" as name' if col_name else "'未知' as name"},
|
||
{f'"{col_brand}" as brand' if col_brand else "'' as brand"},
|
||
{f'"{col_vendor}" as vendor' if col_vendor else "'' as vendor"},
|
||
{f'"{col_category}" as category' if col_category else "'' as category"},
|
||
{f'SUM(CAST("{col_amount}" AS REAL)) as amount' if col_amount else '0 as amount'},
|
||
{f'SUM(CAST("{col_qty}" AS REAL)) as qty' if col_qty else '0 as qty'},
|
||
{f'SUM(CAST("{col_cost}" AS REAL)) as cost' if col_cost else '0 as cost'},
|
||
{f'SUM(CAST("{col_return_qty}" AS REAL)) as return_qty' if col_return_qty else '0 as return_qty'},
|
||
COUNT(*) as order_count
|
||
FROM {table_name}
|
||
WHERE 1=1 {all_filters}
|
||
GROUP BY {group_by_clause}
|
||
ORDER BY amount DESC
|
||
LIMIT 300
|
||
"""
|
||
|
||
df_agg = pd.read_sql(sql_query, db.engine)
|
||
sys_log.info(f"[API] Table Data: SQL聚合查詢返回 {len(df_agg)} 筆商品 (篩選: category={category_filter}, month={month_filter}, dow={dow_filter}, hour={hour_filter}, keyword={keyword})")
|
||
|
||
if df_agg.empty:
|
||
return jsonify({'data': []})
|
||
|
||
# 計算衍生欄位
|
||
df_agg['margin_rate'] = ((df_agg['amount'] - df_agg['cost']) / df_agg['amount'] * 100).fillna(0)
|
||
df_agg['margin_rate'] = df_agg['margin_rate'].replace([np.inf, -np.inf], 0)
|
||
df_agg['avg_price'] = (df_agg['amount'] / df_agg['qty']).fillna(0)
|
||
df_agg['return_rate'] = (df_agg['return_qty'] / df_agg['qty'] * 100).fillna(0)
|
||
|
||
# V-Fix: 應用價格區間篩選 (在計算欄位後才能篩選)
|
||
if min_price_str:
|
||
try:
|
||
min_price = float(min_price_str)
|
||
df_agg = df_agg[df_agg['avg_price'] >= min_price]
|
||
except ValueError:
|
||
pass
|
||
|
||
if max_price_str:
|
||
try:
|
||
max_price = float(max_price_str)
|
||
df_agg = df_agg[df_agg['avg_price'] <= max_price]
|
||
except ValueError:
|
||
pass
|
||
|
||
# V-Fix: 應用毛利區間篩選 (在計算欄位後才能篩選)
|
||
if min_margin_str:
|
||
try:
|
||
min_margin = float(min_margin_str)
|
||
df_agg = df_agg[df_agg['margin_rate'] >= min_margin]
|
||
except ValueError:
|
||
pass
|
||
|
||
if max_margin_str:
|
||
try:
|
||
max_margin = float(max_margin_str)
|
||
df_agg = df_agg[df_agg['margin_rate'] <= max_margin]
|
||
except ValueError:
|
||
pass
|
||
|
||
# 重新排序並限制到 300 筆 (減少前端渲染負擔)
|
||
df_agg = df_agg.sort_values('amount', ascending=False).head(300)
|
||
|
||
# V-Opt: 使用向量化操作取代逐列迴圈
|
||
df_agg['rank'] = range(1, len(df_agg) + 1)
|
||
df_agg['month_str'] = '' # SQL聚合模式不需要月份字串
|
||
|
||
# 重新命名欄位以符合前端格式
|
||
result_df = df_agg.rename(columns={
|
||
'product_id': 'product_id',
|
||
'name': 'name',
|
||
'brand': 'brand',
|
||
'vendor': 'vendor',
|
||
'category': 'category',
|
||
'margin_rate': 'margin_rate',
|
||
'avg_price': 'avg_price',
|
||
'return_rate': 'return_rate',
|
||
'qty': 'qty',
|
||
'amount': 'amount'
|
||
})
|
||
|
||
# 選擇需要的欄位並轉換為字典列表
|
||
columns = ['rank', 'product_id', 'name', 'brand', 'vendor', 'category',
|
||
'margin_rate', 'month_str', 'avg_price', 'return_rate', 'qty', 'amount']
|
||
|
||
# V-Fix (2026-01-23): 確保所有數值欄位無 NaN/Infinity,避免 JSON 序列化失敗
|
||
numeric_cols = ['margin_rate', 'avg_price', 'return_rate', 'qty', 'amount']
|
||
for col in numeric_cols:
|
||
if col in result_df.columns:
|
||
result_df[col] = result_df[col].replace([np.inf, -np.inf], 0).fillna(0)
|
||
|
||
# V-Fix (2026-01-23): 確保字串欄位無 None,避免 JSON 序列化失敗
|
||
string_cols = ['product_id', 'name', 'brand', 'vendor', 'category', 'month_str']
|
||
for col in string_cols:
|
||
if col in result_df.columns:
|
||
result_df[col] = result_df[col].fillna('').astype(str)
|
||
|
||
data = result_df[columns].to_dict('records')
|
||
|
||
response_data = {'data': data}
|
||
|
||
# V-Opt: 儲存到快取
|
||
_TABLE_DATA_CACHE[cache_key] = {'data': response_data, 'time': time.time()}
|
||
|
||
# V-Opt: 清理過期快取 (保留最近 50 個)
|
||
if len(_TABLE_DATA_CACHE) > 50:
|
||
sorted_keys = sorted(_TABLE_DATA_CACHE.keys(),
|
||
key=lambda k: _TABLE_DATA_CACHE[k]['time'])
|
||
for old_key in sorted_keys[:-50]:
|
||
del _TABLE_DATA_CACHE[old_key]
|
||
|
||
return jsonify(response_data)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[API] Table Data Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return jsonify({'error': str(e)}), 500
|
||
|
||
|
||
# V-Old: 保留舊版本以防需要回滾
|
||
@app.route('/api/sales_analysis/table_data_pandas')
|
||
def get_sales_table_data_pandas():
|
||
"""API: 取得業績分析的詳細列表資料 (使用 pandas 聚合 - 舊版本)"""
|
||
try:
|
||
table_name = 'realtime_sales_monthly'
|
||
data_range_months = int(request.args.get('data_range', '1'))
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
|
||
|
||
if err or target_df is None:
|
||
sys_log.warning(f"[API] Table Data: 快取不存在 ({cache_key}),返回空資料")
|
||
return jsonify({'data': []})
|
||
|
||
if target_df.empty:
|
||
return jsonify({'data': []})
|
||
|
||
col_name = cols_map.get('name')
|
||
col_amount = cols_map.get('amount')
|
||
col_qty = cols_map.get('qty')
|
||
col_cost = cols_map.get('cost')
|
||
col_profit = cols_map.get('profit')
|
||
col_category = cols_map.get('category')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_date = cols_map.get('date')
|
||
col_brand = cols_map.get('brand')
|
||
col_return_qty = cols_map.get('return_qty')
|
||
|
||
selected_metric = request.args.get('metric', 'amount')
|
||
|
||
# 執行聚合 (V-Opt: 多維度聚合,增加精確度)
|
||
agg_rules = {col_amount: 'sum'}
|
||
if col_qty: agg_rules[col_qty] = 'sum'
|
||
if col_cost: agg_rules[col_cost] = 'sum'
|
||
if col_profit: agg_rules[col_profit] = 'sum'
|
||
if col_return_qty: agg_rules[col_return_qty] = 'sum'
|
||
if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique()))
|
||
|
||
# Group By 鍵值:商品名稱 + 品牌 + 廠商 + 分類 (確保唯一性)
|
||
group_cols = [col_name]
|
||
if col_brand: group_cols.append(col_brand)
|
||
if col_vendor: group_cols.append(col_vendor)
|
||
if col_category: group_cols.append(col_category)
|
||
|
||
df_agg = target_df.groupby(group_cols).agg(agg_rules).reset_index()
|
||
|
||
# 計算毛利率
|
||
if col_profit:
|
||
df_agg['agg_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
|
||
elif col_cost:
|
||
df_agg['agg_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
|
||
else:
|
||
df_agg['agg_margin_rate'] = 0.0
|
||
df_agg['agg_margin_rate'] = df_agg['agg_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
|
||
|
||
# V-New: 計算平均單價與退貨率
|
||
if col_qty:
|
||
df_agg['avg_price'] = (df_agg[col_amount] / df_agg[col_qty]).fillna(0)
|
||
if col_return_qty:
|
||
df_agg['return_rate'] = (df_agg[col_return_qty] / df_agg[col_qty] * 100).fillna(0)
|
||
|
||
# 排序
|
||
sort_col_agg = col_amount
|
||
if selected_metric == 'qty' and col_qty:
|
||
sort_col_agg = col_qty
|
||
|
||
df_agg = df_agg.sort_values(by=sort_col_agg, ascending=False).head(1000) # 限制前 1000 筆
|
||
|
||
# 轉換為 DataTables 需要的格式
|
||
data = []
|
||
for i, row in enumerate(df_agg.to_dict('records')):
|
||
data.append({
|
||
'rank': i + 1,
|
||
'name': row.get(col_name, ''),
|
||
'brand': row.get(col_brand, ''),
|
||
'vendor': row.get(col_vendor, ''),
|
||
'category': row.get(col_category, ''),
|
||
'margin_rate': row.get('agg_margin_rate', 0),
|
||
'month_str': row.get('_month_str', ''),
|
||
'avg_price': row.get('avg_price', 0),
|
||
'return_rate': row.get('return_rate', 0),
|
||
'qty': row.get(col_qty, 0),
|
||
'amount': row.get(col_amount, 0)
|
||
})
|
||
|
||
return jsonify({'data': data})
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"Table Data API Error: {e}")
|
||
return jsonify({'error': str(e)}), 500
|
||
|
||
# ================= 💎 V-New: Top 3 Highlights 詳細列表 API =================
|
||
@app.route('/api/sales_analysis/top_detail')
|
||
def get_top_detail():
|
||
"""API: 取得 Top N 詳細列表(業績貢獻王/獲利金雞母/人氣引流款)"""
|
||
try:
|
||
from datetime import datetime, timedelta, timezone
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
table_name = 'realtime_sales_monthly'
|
||
data_range_months = int(request.args.get('data_range', '1') or '1')
|
||
start_date = request.args.get('start_date', '') # V-New: 自訂開始日期
|
||
end_date = request.args.get('end_date', '') # V-New: 自訂結束日期
|
||
top_type = request.args.get('type', 'revenue') # revenue/margin/quantity
|
||
metric = request.args.get('metric', 'amount') # amount/profit/qty
|
||
view_type = request.args.get('view', 'product') # product/category
|
||
|
||
db = DatabaseManager()
|
||
|
||
# V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
|
||
# 嘗試從快取讀取欄位名稱
|
||
cols_map = {}
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {})
|
||
elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key
|
||
cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {})
|
||
|
||
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
|
||
# V-Fix (2026-01-23): 使用 or 確保不會得到 None 值
|
||
col_name = cols_map.get('name') or '商品名稱'
|
||
col_brand = cols_map.get('brand') or '品牌'
|
||
col_vendor = cols_map.get('vendor') or '廠商名稱'
|
||
col_category = cols_map.get('category') or '商品館'
|
||
col_amount = cols_map.get('amount') or '總業績'
|
||
col_qty = cols_map.get('qty') or '數量'
|
||
col_cost = cols_map.get('cost') or '總成本'
|
||
col_profit = cols_map.get('profit') # 可以為 None
|
||
col_activity = cols_map.get('activity') or '活動名稱'
|
||
col_payment = cols_map.get('payment') or '付款方式'
|
||
|
||
# 建立日期篩選條件
|
||
date_filter = ""
|
||
# V-New: 優先處理自訂日期區間
|
||
if start_date or end_date:
|
||
# V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01)
|
||
start_date_slash = start_date.replace('-', '/') if start_date else ''
|
||
end_date_slash = end_date.replace('-', '/') if end_date else ''
|
||
|
||
if start_date and end_date:
|
||
date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'"""
|
||
elif start_date:
|
||
date_filter = f"""AND "日期" >= '{start_date_slash}'"""
|
||
else: # only end_date
|
||
date_filter = f"""AND "日期" <= '{end_date_slash}'"""
|
||
elif data_range_months > 0:
|
||
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
|
||
date_filter = f"""AND "日期" >= '{cutoff_date}'"""
|
||
|
||
# V-Fix: 補上其他所有篩選條件 (與 get_sales_table_data 一致)
|
||
category_filter = request.args.get('category', 'all')
|
||
brand_filter = request.args.get('brand', 'all')
|
||
vendor_filter = request.args.get('vendor', 'all')
|
||
activity_filter = request.args.get('activity', 'all')
|
||
payment_filter = request.args.get('payment', 'all')
|
||
month_filter = request.args.get('month', 'all')
|
||
dow_filter = request.args.get('dow', 'all')
|
||
hour_filter = request.args.get('hour', 'all')
|
||
min_price_str = request.args.get('min_price', '')
|
||
max_price_str = request.args.get('max_price', '')
|
||
min_margin_str = request.args.get('min_margin', '')
|
||
max_margin_str = request.args.get('max_margin', '')
|
||
keyword = request.args.get('keyword', '').strip()
|
||
|
||
additional_filters = []
|
||
|
||
if category_filter and category_filter != 'all':
|
||
additional_filters.append(f""""{col_category}" = '{category_filter}'""")
|
||
if brand_filter and brand_filter != 'all':
|
||
additional_filters.append(f""""{col_brand}" = '{brand_filter}'""")
|
||
if vendor_filter and vendor_filter != 'all':
|
||
additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""")
|
||
if activity_filter and activity_filter != 'all':
|
||
additional_filters.append(f""""{col_activity}" = '{activity_filter}'""")
|
||
if payment_filter and payment_filter != 'all':
|
||
additional_filters.append(f""""{col_payment}" = '{payment_filter}'""")
|
||
|
||
# 時間維度
|
||
if month_filter and month_filter != 'all':
|
||
month_filter_slash = month_filter.replace('-', '/')
|
||
# 使用「日期」欄位 (這似乎是系統內部固定欄位,不需 dynamic map,除非資料表結構也變了)
|
||
# 假設 "日期" 是固定欄位
|
||
additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""")
|
||
|
||
if dow_filter and dow_filter != 'all':
|
||
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API)
|
||
pandas_dow = int(dow_filter)
|
||
if DATABASE_TYPE == 'postgresql':
|
||
pg_dow = (pandas_dow + 1) % 7
|
||
additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""")
|
||
else:
|
||
sqlite_dow = str((pandas_dow + 1) % 7)
|
||
additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""")
|
||
|
||
if hour_filter and hour_filter != 'all':
|
||
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API)
|
||
hour_val = int(hour_filter)
|
||
if DATABASE_TYPE == 'postgresql':
|
||
additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""")
|
||
else:
|
||
additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""")
|
||
|
||
# 關鍵字
|
||
if keyword:
|
||
keyword_escaped = keyword.replace("'", "''")
|
||
k_conds = []
|
||
for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]:
|
||
k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""")
|
||
additional_filters.append(f"({' OR '.join(k_conds)})")
|
||
|
||
if (min_price_str or max_price_str):
|
||
price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)'
|
||
if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}")
|
||
if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}")
|
||
|
||
if (min_margin_str or max_margin_str):
|
||
if col_profit:
|
||
profit_sql = f'"{col_profit}"'
|
||
else:
|
||
profit_sql = f'("{col_amount}" - "{col_cost}")'
|
||
|
||
margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))'
|
||
if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}")
|
||
if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}")
|
||
|
||
if additional_filters:
|
||
date_filter += " AND " + " AND ".join(additional_filters)
|
||
|
||
# V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合)
|
||
if col_profit:
|
||
profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))'
|
||
else:
|
||
profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))'
|
||
|
||
# 根據檢視類型和指標建立 SQL 查詢
|
||
if view_type == 'category':
|
||
# 分類排行
|
||
if metric == 'qty':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as name,
|
||
SUM(CAST("{col_qty}" AS REAL)) as value
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 50
|
||
"""
|
||
elif metric == 'profit':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as name,
|
||
{profit_select_sql} as value,
|
||
CASE
|
||
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
|
||
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
|
||
ELSE 0
|
||
END as margin_rate
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 50
|
||
"""
|
||
else: # amount
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as name,
|
||
SUM(CAST("{col_amount}" AS REAL)) as value
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 50
|
||
"""
|
||
else:
|
||
# 商品排行(包含商品ID)
|
||
pid_col_sql = f'"{cols_map.get("pid", "商品ID")}"' # 商品ID 欄位
|
||
if metric == 'qty':
|
||
sql_query = f"""
|
||
SELECT
|
||
{pid_col_sql} as product_id,
|
||
"{col_name}" as name,
|
||
"{col_brand}" as brand,
|
||
"{col_vendor}" as vendor,
|
||
"{col_category}" as category,
|
||
SUM(CAST("{col_qty}" AS REAL)) as value
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 100
|
||
"""
|
||
elif metric == 'profit':
|
||
sql_query = f"""
|
||
SELECT
|
||
{pid_col_sql} as product_id,
|
||
"{col_name}" as name,
|
||
"{col_brand}" as brand,
|
||
"{col_vendor}" as vendor,
|
||
"{col_category}" as category,
|
||
{profit_select_sql} as value,
|
||
CASE
|
||
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
|
||
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
|
||
ELSE 0
|
||
END as margin_rate
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 100
|
||
"""
|
||
else: # amount
|
||
sql_query = f"""
|
||
SELECT
|
||
{pid_col_sql} as product_id,
|
||
"{col_name}" as name,
|
||
"{col_brand}" as brand,
|
||
"{col_vendor}" as vendor,
|
||
"{col_category}" as category,
|
||
SUM(CAST("{col_amount}" AS REAL)) as value
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 100
|
||
"""
|
||
|
||
# 執行查詢
|
||
df = pd.read_sql(sql_query, db.engine)
|
||
sys_log.info(f"[API] Top Detail: {top_type}/{view_type} 返回 {len(df)} 筆資料")
|
||
|
||
if df.empty:
|
||
return jsonify({'items': []})
|
||
|
||
# V-Fix (2026-01-23): 確保數值欄位無 NaN/Infinity,避免 JSON 序列化失敗
|
||
numeric_cols = ['value', 'margin_rate']
|
||
for col in numeric_cols:
|
||
if col in df.columns:
|
||
df[col] = df[col].replace([np.inf, -np.inf], 0).fillna(0)
|
||
|
||
# V-Fix (2026-01-23): 確保字串欄位無 None
|
||
string_cols = ['product_id', 'name', 'brand', 'vendor', 'category']
|
||
for col in string_cols:
|
||
if col in df.columns:
|
||
df[col] = df[col].fillna('').astype(str)
|
||
|
||
# 轉換為 JSON
|
||
items = df.to_dict('records')
|
||
return jsonify({'items': items})
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[API] Top Detail Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return jsonify({'error': str(e)}), 500
|
||
|
||
@app.route('/api/sales_analysis/export_top_detail')
|
||
def export_top_detail():
|
||
"""API: 匯出 Top N 詳細列表為 Excel"""
|
||
try:
|
||
from datetime import datetime, timedelta, timezone
|
||
import io
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
table_name = 'realtime_sales_monthly'
|
||
data_range_months = int(request.args.get('data_range', '1') or '1')
|
||
start_date = request.args.get('start_date', '') # V-New: 自訂開始日期
|
||
end_date = request.args.get('end_date', '') # V-New: 自訂結束日期
|
||
top_type = request.args.get('type', 'revenue')
|
||
metric = request.args.get('metric', 'amount')
|
||
view_type = request.args.get('view', 'product')
|
||
|
||
db = DatabaseManager()
|
||
|
||
# V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
|
||
# 嘗試從快取讀取欄位名稱
|
||
cols_map = {}
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {})
|
||
elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key
|
||
cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {})
|
||
|
||
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
|
||
# V-Fix (2026-01-23): 使用 or 確保不會得到 None 值
|
||
col_name = cols_map.get('name') or '商品名稱'
|
||
col_brand = cols_map.get('brand') or '品牌'
|
||
col_vendor = cols_map.get('vendor') or '廠商名稱'
|
||
col_category = cols_map.get('category') or '商品館'
|
||
col_amount = cols_map.get('amount') or '總業績'
|
||
col_qty = cols_map.get('qty') or '數量'
|
||
col_cost = cols_map.get('cost') or '總成本'
|
||
col_profit = cols_map.get('profit') # 可以為 None
|
||
col_activity = cols_map.get('activity') or '活動名稱'
|
||
col_payment = cols_map.get('payment') or '付款方式'
|
||
|
||
# 建立日期篩選條件
|
||
date_filter = ""
|
||
# V-New: 優先處理自訂日期區間
|
||
if start_date or end_date:
|
||
# V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01)
|
||
start_date_slash = start_date.replace('-', '/') if start_date else ''
|
||
end_date_slash = end_date.replace('-', '/') if end_date else ''
|
||
|
||
if start_date and end_date:
|
||
date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'"""
|
||
elif start_date:
|
||
date_filter = f"""AND "日期" >= '{start_date_slash}'"""
|
||
else: # only end_date
|
||
date_filter = f"""AND "日期" <= '{end_date_slash}'"""
|
||
elif data_range_months > 0:
|
||
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
|
||
date_filter = f"""AND "日期" >= '{cutoff_date}'"""
|
||
|
||
# V-Fix: 補上其他所有篩選條件 (與 get_top_detail 一致)
|
||
category_filter = request.args.get('category', 'all')
|
||
brand_filter = request.args.get('brand', 'all')
|
||
vendor_filter = request.args.get('vendor', 'all')
|
||
activity_filter = request.args.get('activity', 'all')
|
||
payment_filter = request.args.get('payment', 'all')
|
||
month_filter = request.args.get('month', 'all')
|
||
dow_filter = request.args.get('dow', 'all')
|
||
hour_filter = request.args.get('hour', 'all')
|
||
min_price_str = request.args.get('min_price', '')
|
||
max_price_str = request.args.get('max_price', '')
|
||
min_margin_str = request.args.get('min_margin', '')
|
||
max_margin_str = request.args.get('max_margin', '')
|
||
keyword = request.args.get('keyword', '').strip()
|
||
|
||
additional_filters = []
|
||
|
||
if category_filter and category_filter != 'all':
|
||
additional_filters.append(f""""{col_category}" = '{category_filter}'""")
|
||
if brand_filter and brand_filter != 'all':
|
||
additional_filters.append(f""""{col_brand}" = '{brand_filter}'""")
|
||
if vendor_filter and vendor_filter != 'all':
|
||
additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""")
|
||
if activity_filter and activity_filter != 'all':
|
||
additional_filters.append(f""""{col_activity}" = '{activity_filter}'""")
|
||
if payment_filter and payment_filter != 'all':
|
||
additional_filters.append(f""""{col_payment}" = '{payment_filter}'""")
|
||
|
||
if month_filter and month_filter != 'all':
|
||
month_filter_slash = month_filter.replace('-', '/')
|
||
additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""")
|
||
|
||
if dow_filter and dow_filter != 'all':
|
||
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API)
|
||
pandas_dow = int(dow_filter)
|
||
if DATABASE_TYPE == 'postgresql':
|
||
pg_dow = (pandas_dow + 1) % 7
|
||
additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""")
|
||
else:
|
||
sqlite_dow = str((pandas_dow + 1) % 7)
|
||
additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""")
|
||
|
||
if hour_filter and hour_filter != 'all':
|
||
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API)
|
||
hour_val = int(hour_filter)
|
||
if DATABASE_TYPE == 'postgresql':
|
||
additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""")
|
||
else:
|
||
additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""")
|
||
|
||
if keyword:
|
||
keyword_escaped = keyword.replace("'", "''")
|
||
k_conds = []
|
||
for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]:
|
||
k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""")
|
||
additional_filters.append(f"({' OR '.join(k_conds)})")
|
||
|
||
if (min_price_str or max_price_str):
|
||
price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)'
|
||
if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}")
|
||
if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}")
|
||
|
||
if (min_margin_str or max_margin_str):
|
||
if col_profit:
|
||
profit_sql = f'"{col_profit}"'
|
||
else:
|
||
profit_sql = f'("{col_amount}" - "{col_cost}")'
|
||
|
||
margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))'
|
||
if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}")
|
||
if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}")
|
||
|
||
if additional_filters:
|
||
date_filter += " AND " + " AND ".join(additional_filters)
|
||
|
||
# V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合)
|
||
if col_profit:
|
||
profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))'
|
||
else:
|
||
profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))'
|
||
|
||
# 根據檢視類型和指標建立 SQL 查詢(與上面相同)
|
||
if view_type == 'category':
|
||
if metric == 'qty':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as 分類名稱,
|
||
SUM(CAST("{col_qty}" AS REAL)) as 銷售數量
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY 銷售數量 DESC
|
||
LIMIT 50
|
||
"""
|
||
elif metric == 'profit':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as 分類名稱,
|
||
{profit_select_sql} as 毛利金額,
|
||
CASE
|
||
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
|
||
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
|
||
ELSE 0
|
||
END as 毛利率
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY 毛利金額 DESC
|
||
LIMIT 50
|
||
"""
|
||
else: # amount
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as 分類名稱,
|
||
SUM(CAST("{col_amount}" AS REAL)) as 銷售金額
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY 銷售金額 DESC
|
||
LIMIT 50
|
||
"""
|
||
else:
|
||
# 商品排行(包含商品ID)
|
||
if metric == 'qty':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{cols_map.get("pid", "商品ID")}" as 商品ID,
|
||
"{col_name}" as 商品名稱,
|
||
"{col_brand}" as 品牌,
|
||
"{col_vendor}" as 廠商名稱,
|
||
"{col_category}" as 分類名稱,
|
||
SUM(CAST("{col_qty}" AS REAL)) as 銷售數量
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY 銷售數量 DESC
|
||
LIMIT 100
|
||
"""
|
||
elif metric == 'profit':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{cols_map.get("pid", "商品ID")}" as 商品ID,
|
||
"{col_name}" as 商品名稱,
|
||
"{col_brand}" as 品牌,
|
||
"{col_vendor}" as 廠商名稱,
|
||
"{col_category}" as 分類名稱,
|
||
{profit_select_sql} as 毛利金額,
|
||
CASE
|
||
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
|
||
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
|
||
ELSE 0
|
||
END as 毛利率
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY 毛利金額 DESC
|
||
LIMIT 100
|
||
"""
|
||
else: # amount
|
||
sql_query = f"""
|
||
SELECT
|
||
"{cols_map.get("pid", "商品ID")}" as 商品ID,
|
||
"{col_name}" as 商品名稱,
|
||
"{col_brand}" as 品牌,
|
||
"{col_vendor}" as 廠商名稱,
|
||
"{col_category}" as 分類名稱,
|
||
SUM(CAST("{col_amount}" AS REAL)) as 銷售金額
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY 銷售金額 DESC
|
||
LIMIT 100
|
||
"""
|
||
|
||
# 執行查詢並匯出
|
||
df = pd.read_sql(sql_query, db.engine)
|
||
|
||
if df.empty:
|
||
return "無資料可匯出", 400
|
||
|
||
# 生成 Excel
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
df.to_excel(writer, index=False, sheet_name='Top排行')
|
||
output.seek(0)
|
||
|
||
# 生成檔案名稱
|
||
type_names = {'revenue': '業績貢獻王', 'margin': '獲利金雞母', 'quantity': '人氣引流款'}
|
||
view_names = {'product': '商品排行', 'category': '分類排行'}
|
||
filename = f"{type_names.get(top_type, '排行')}_{view_names.get(view_type, '')}_{datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')}.xlsx"
|
||
|
||
sys_log.info(f"[Export] Top Detail: {filename} ({len(df)} 筆)")
|
||
|
||
return send_file(
|
||
output,
|
||
as_attachment=True,
|
||
download_name=filename,
|
||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Export] Top Detail Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
# ================= 📈 V-New: 年度對比 (Year-over-Year Comparison) =================
|
||
@app.route('/api/sales_analysis/yoy_comparison')
|
||
def yoy_comparison():
|
||
"""
|
||
API: 年度對比分析 (YoY Comparison)
|
||
|
||
參數:
|
||
year1: 基準年 (例如 2024)
|
||
year2: 對比年 (例如 2025)
|
||
month: 月份 (可選,1-12,不帶則為全年)
|
||
metric: 指標 (revenue/qty/profit)
|
||
|
||
回傳:
|
||
JSON with year1 total, year2 total, growth rate, and monthly breakdown
|
||
"""
|
||
try:
|
||
from datetime import datetime, timedelta, timezone
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
table_name = 'realtime_sales_monthly'
|
||
year1 = request.args.get('year1', '2024')
|
||
year2 = request.args.get('year2', '2025')
|
||
month = request.args.get('month', '') # 可選,1-12
|
||
metric = request.args.get('metric', 'revenue') # revenue/qty/profit
|
||
|
||
db = DatabaseManager()
|
||
|
||
# 欄位名稱
|
||
col_amount = '總業績'
|
||
col_qty = '數量'
|
||
col_cost = '總成本'
|
||
col_date = '日期'
|
||
|
||
# 根據指標決定聚合欄位
|
||
if metric == 'qty':
|
||
agg_sql = f'SUM(CAST("{col_qty}" AS REAL))'
|
||
metric_label = '銷售數量'
|
||
elif metric == 'profit':
|
||
agg_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))'
|
||
metric_label = '毛利金額'
|
||
else: # revenue
|
||
agg_sql = f'SUM(CAST("{col_amount}" AS REAL))'
|
||
metric_label = '銷售金額'
|
||
|
||
# 建立年度篩選條件
|
||
# 日期格式為 2025/01/01 或 2025-01-01
|
||
def build_year_filter(year, month_filter=''):
|
||
if month_filter:
|
||
month_str = month_filter.zfill(2)
|
||
return f"""("{col_date}" LIKE '{year}/{month_str}%' OR "{col_date}" LIKE '{year}-{month_str}%')"""
|
||
else:
|
||
return f"""("{col_date}" LIKE '{year}/%' OR "{col_date}" LIKE '{year}-%')"""
|
||
|
||
year1_filter = build_year_filter(year1, month)
|
||
year2_filter = build_year_filter(year2, month)
|
||
|
||
# 查詢年度總計
|
||
sql_year1 = f"""
|
||
SELECT {agg_sql} as total
|
||
FROM {table_name}
|
||
WHERE {year1_filter}
|
||
"""
|
||
sql_year2 = f"""
|
||
SELECT {agg_sql} as total
|
||
FROM {table_name}
|
||
WHERE {year2_filter}
|
||
"""
|
||
|
||
# V-Fix: SQLAlchemy 2.0 需要使用 text() 包裹 SQL 字串
|
||
from sqlalchemy import text
|
||
result_year1 = pd.read_sql(text(sql_year1), db.engine)
|
||
result_year2 = pd.read_sql(text(sql_year2), db.engine)
|
||
|
||
total_year1 = float(result_year1['total'].iloc[0] or 0)
|
||
total_year2 = float(result_year2['total'].iloc[0] or 0)
|
||
|
||
# 計算成長率
|
||
if total_year1 > 0:
|
||
growth_rate = ((total_year2 - total_year1) / total_year1) * 100
|
||
else:
|
||
growth_rate = 0 if total_year2 == 0 else 100
|
||
|
||
# 月度明細 (如果沒有指定月份,則查詢 12 個月的明細)
|
||
monthly_breakdown = []
|
||
if not month:
|
||
for m in range(1, 13):
|
||
m_str = str(m).zfill(2)
|
||
y1_filter = build_year_filter(year1, m_str)
|
||
y2_filter = build_year_filter(year2, m_str)
|
||
|
||
sql_m1 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y1_filter}"
|
||
sql_m2 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y2_filter}"
|
||
|
||
# V-Fix: SQLAlchemy 2.0 需要使用 text()
|
||
r1 = pd.read_sql(text(sql_m1), db.engine)
|
||
r2 = pd.read_sql(text(sql_m2), db.engine)
|
||
|
||
v1 = float(r1['total'].iloc[0] or 0)
|
||
v2 = float(r2['total'].iloc[0] or 0)
|
||
|
||
m_growth = ((v2 - v1) / v1 * 100) if v1 > 0 else (0 if v2 == 0 else 100)
|
||
|
||
monthly_breakdown.append({
|
||
'month': m,
|
||
'month_label': f'{m}月',
|
||
'year1_value': v1,
|
||
'year2_value': v2,
|
||
'growth_rate': round(m_growth, 2)
|
||
})
|
||
|
||
response = {
|
||
'year1': {
|
||
'label': f'{year1}年' + (f'{month}月' if month else ''),
|
||
'total': total_year1
|
||
},
|
||
'year2': {
|
||
'label': f'{year2}年' + (f'{month}月' if month else ''),
|
||
'total': total_year2
|
||
},
|
||
'growth_rate': round(growth_rate, 2),
|
||
'metric': metric,
|
||
'metric_label': metric_label,
|
||
'monthly_breakdown': monthly_breakdown
|
||
}
|
||
|
||
sys_log.info(f"[YoY] {year1} vs {year2}: {total_year1:,.0f} -> {total_year2:,.0f} ({growth_rate:+.1f}%)")
|
||
|
||
return jsonify(response)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[YoY] Error: {e}")
|
||
traceback.print_exc()
|
||
return jsonify({'error': str(e)}), 500
|
||
|
||
# ================= 📈 V-New: 營運成長報表 (Growth Strategy) =================
|
||
@app.route('/growth_analysis')
|
||
def growth_analysis():
|
||
"""營運成長策略報表 (MoM, YoY, AOV, YTD)"""
|
||
try:
|
||
from sqlalchemy import text as sa_text
|
||
db = DatabaseManager()
|
||
table_name = 'realtime_sales_monthly'
|
||
|
||
# 1. 檢查資料表
|
||
inspector = inspect(db.engine)
|
||
if table_name not in inspector.get_table_names():
|
||
return f"尚未匯入業績資料 ({table_name})", 404
|
||
|
||
# 2. SQL 月度聚合(避免全量 748k 行讀進 pandas)
|
||
monthly_sql = sa_text("""
|
||
SELECT
|
||
date_trunc('month', TO_DATE("日期", 'YYYY/MM/DD'))::date AS month,
|
||
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS amount,
|
||
SUM(COALESCE(NULLIF("總成本", '')::numeric, 0)) AS cost,
|
||
COUNT(DISTINCT "訂單編號") AS orders
|
||
FROM realtime_sales_monthly
|
||
WHERE "日期" IS NOT NULL AND length("日期") >= 8
|
||
GROUP BY 1
|
||
ORDER BY 1
|
||
""")
|
||
ytd_sql = sa_text("""
|
||
SELECT
|
||
EXTRACT(YEAR FROM TO_DATE("日期", 'YYYY/MM/DD'))::int AS yr,
|
||
EXTRACT(DOY FROM TO_DATE("日期", 'YYYY/MM/DD'))::int AS doy,
|
||
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS amount
|
||
FROM realtime_sales_monthly
|
||
WHERE "日期" IS NOT NULL AND length("日期") >= 8
|
||
AND EXTRACT(YEAR FROM TO_DATE("日期", 'YYYY/MM/DD'))
|
||
>= EXTRACT(YEAR FROM CURRENT_DATE) - 1
|
||
GROUP BY 1, 2
|
||
ORDER BY 1, 2
|
||
""")
|
||
recent_sql = sa_text("""
|
||
SELECT
|
||
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS revenue,
|
||
COUNT(DISTINCT "訂單編號") AS orders
|
||
FROM realtime_sales_monthly
|
||
WHERE "日期" IS NOT NULL AND length("日期") >= 8
|
||
AND TO_DATE("日期", 'YYYY/MM/DD') >= CURRENT_DATE - INTERVAL '30 days'
|
||
""")
|
||
|
||
with db.engine.connect() as conn:
|
||
monthly_df = pd.read_sql(monthly_sql, conn)
|
||
if monthly_df.empty:
|
||
return f"資料表 {table_name} 為空", 404
|
||
ytd_df = pd.read_sql(ytd_sql, conn)
|
||
recent_r = conn.execute(recent_sql).fetchone()
|
||
|
||
# 3. 月度指標計算
|
||
monthly_df['month'] = pd.to_datetime(monthly_df['month'])
|
||
monthly_df = monthly_df.set_index('month')
|
||
monthly_df['profit'] = monthly_df['amount'] - monthly_df['cost']
|
||
monthly_df['aov'] = monthly_df['amount'] / monthly_df['orders'].replace(0, pd.NA)
|
||
monthly_df['margin_rate'] = (monthly_df['profit'] / monthly_df['amount'].replace(0, pd.NA)) * 100
|
||
monthly_df['mom'] = monthly_df['amount'].pct_change() * 100
|
||
monthly_df['yoy'] = monthly_df['amount'].pct_change(periods=12) * 100
|
||
monthly_df = monthly_df.fillna(0)
|
||
|
||
labels = monthly_df.index.strftime('%Y-%m').tolist()
|
||
chart_data = {
|
||
'labels': labels,
|
||
'revenue': monthly_df['amount'].tolist(),
|
||
'profit': monthly_df['profit'].tolist(),
|
||
'orders': monthly_df['orders'].tolist(),
|
||
'aov': monthly_df['aov'].round(0).tolist(),
|
||
'mom': monthly_df['mom'].round(2).tolist(),
|
||
'yoy': monthly_df['yoy'].round(2).tolist(),
|
||
'margin_rate': monthly_df['margin_rate'].round(1).tolist(),
|
||
}
|
||
|
||
# 4. KPI: YTD
|
||
current_year = int(pd.Timestamp.now().year)
|
||
last_year = current_year - 1
|
||
max_doy = ytd_df[ytd_df['yr'] == current_year]['doy'].max() if not ytd_df.empty else 365
|
||
|
||
ytd_revenue = float(ytd_df[ytd_df['yr'] == current_year]['amount'].sum())
|
||
last_ytd_revenue = float(ytd_df[(ytd_df['yr'] == last_year) & (ytd_df['doy'] <= max_doy)]['amount'].sum())
|
||
ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue * 100) if last_ytd_revenue > 0 else 0
|
||
|
||
recent_revenue = float(recent_r.revenue or 0) if recent_r else 0
|
||
recent_orders = int(recent_r.orders or 0) if recent_r else 0
|
||
recent_aov = recent_revenue / recent_orders if recent_orders > 0 else 0
|
||
|
||
kpi = {
|
||
'ytd_revenue': ytd_revenue,
|
||
'ytd_growth': ytd_growth,
|
||
'current_year': current_year,
|
||
'recent_aov': recent_aov,
|
||
'total_orders': int(monthly_df['orders'].sum()),
|
||
}
|
||
|
||
return render_template('growth_analysis.html', chart_data=chart_data, kpi=kpi)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"Growth Analysis Error: {e}")
|
||
return f"系統錯誤: {e}"
|
||
|
||
def preprocess_daily_sales_data(df):
|
||
"""前處理當日業績資料:欄位識別、型別轉換"""
|
||
cols = df.columns.tolist()
|
||
|
||
# 欄位自動識別(使用現有的 find_col 函式)
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', 'Amount', '總業績'])
|
||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量'])
|
||
|
||
# 型別轉換
|
||
if col_amount:
|
||
df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0)
|
||
if col_cost:
|
||
df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0)
|
||
if col_profit:
|
||
df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0)
|
||
if col_qty:
|
||
df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0)
|
||
|
||
# 日期轉換
|
||
df['snapshot_date'] = pd.to_datetime(df['snapshot_date'], errors='coerce')
|
||
|
||
return df
|
||
|
||
def calculate_daily_kpis(df, date_str):
|
||
"""計算單日 6 個 KPI"""
|
||
day_df = df[df['snapshot_date'] == date_str]
|
||
cols = day_df.columns.tolist()
|
||
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名', 'Name'])
|
||
|
||
total_revenue = float(day_df[col_amount].sum()) if col_amount else 0
|
||
total_cost = float(day_df[col_cost].sum()) if col_cost else 0
|
||
gross_margin = float(day_df[col_profit].sum()) if col_profit else (total_revenue - total_cost)
|
||
total_qty = float(day_df[col_qty].sum()) if col_qty else 0
|
||
sku_count = int(day_df[col_name].nunique()) if col_name else 0
|
||
avg_price = total_revenue / total_qty if total_qty > 0 else 0
|
||
|
||
return {
|
||
'total_revenue': total_revenue,
|
||
'total_cost': total_cost,
|
||
'gross_margin': gross_margin,
|
||
'total_qty': total_qty,
|
||
'sku_count': sku_count,
|
||
'avg_price': avg_price
|
||
}
|
||
|
||
def calculate_dod(df, current_date):
|
||
"""計算 Day-over-Day 變化率"""
|
||
current = calculate_daily_kpis(df, current_date)
|
||
prev_date = current_date - timedelta(days=1)
|
||
|
||
if prev_date not in df['snapshot_date'].values:
|
||
return {k: 0.0 for k in current.keys()}
|
||
|
||
previous = calculate_daily_kpis(df, prev_date)
|
||
|
||
dod = {}
|
||
for key in current:
|
||
if previous[key] > 0:
|
||
dod[key] = ((current[key] - previous[key]) / previous[key]) * 100
|
||
else:
|
||
dod[key] = 0.0
|
||
return dod
|
||
|
||
def calculate_wow(df, current_date):
|
||
"""計算 Week-over-Week 變化率"""
|
||
current = calculate_daily_kpis(df, current_date)
|
||
prev_week_date = current_date - timedelta(days=7)
|
||
|
||
if prev_week_date not in df['snapshot_date'].values:
|
||
return {k: 0.0 for k in current.keys()}
|
||
|
||
previous = calculate_daily_kpis(df, prev_week_date)
|
||
|
||
wow = {}
|
||
for key in current:
|
||
if previous[key] > 0:
|
||
wow[key] = ((current[key] - previous[key]) / previous[key]) * 100
|
||
else:
|
||
wow[key] = 0.0
|
||
return wow
|
||
|
||
def prepare_daily_charts(df, selected_date, days=30):
|
||
"""準備 4 個圖表的數據(根據選擇的日期)"""
|
||
# 取選擇日期前 N 天的數據
|
||
start_date = selected_date - timedelta(days=days)
|
||
df_range = df[(df['snapshot_date'] >= start_date) & (df['snapshot_date'] <= selected_date)]
|
||
|
||
# 按日期聚合
|
||
cols = df_range.columns.tolist()
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_cost = find_col(cols, ['成本', '總成本'])
|
||
col_profit = find_col(cols, ['毛利'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||
|
||
# 日期聚合
|
||
agg_dict = {}
|
||
if col_amount:
|
||
agg_dict[col_amount] = 'sum'
|
||
if col_cost:
|
||
agg_dict[col_cost] = 'sum'
|
||
if col_profit:
|
||
agg_dict[col_profit] = 'sum'
|
||
if col_qty:
|
||
agg_dict[col_qty] = 'sum'
|
||
|
||
daily_agg = df_range.groupby('snapshot_date').agg(agg_dict).reset_index()
|
||
|
||
# 計算或取得毛利(如果沒有毛利欄位,用業績-成本計算)
|
||
if col_profit and col_profit in daily_agg.columns:
|
||
daily_agg['profit'] = daily_agg[col_profit]
|
||
elif col_amount and col_cost and col_amount in daily_agg.columns and col_cost in daily_agg.columns:
|
||
daily_agg['profit'] = daily_agg[col_amount] - daily_agg[col_cost]
|
||
else:
|
||
daily_agg['profit'] = 0
|
||
|
||
# 計算客單價
|
||
if col_amount and col_qty and col_amount in daily_agg.columns and col_qty in daily_agg.columns:
|
||
daily_agg['avg_price'] = (daily_agg[col_amount] / daily_agg[col_qty]).fillna(0)
|
||
else:
|
||
daily_agg['avg_price'] = 0
|
||
|
||
# 計算 DoD (Day-over-Day) 變化率 - 多個維度
|
||
if col_amount and col_amount in daily_agg.columns:
|
||
daily_agg['dod_revenue'] = daily_agg[col_amount].pct_change() * 100
|
||
if 'profit' in daily_agg.columns:
|
||
daily_agg['dod_profit'] = daily_agg['profit'].pct_change() * 100
|
||
if 'avg_price' in daily_agg.columns:
|
||
daily_agg['dod_avg_price'] = daily_agg['avg_price'].pct_change() * 100
|
||
if col_qty and col_qty in daily_agg.columns:
|
||
daily_agg['dod_qty'] = daily_agg[col_qty].pct_change() * 100
|
||
|
||
# 計算 WoW (Week-over-Week) 變化率 - 多個維度
|
||
if col_amount and col_amount in daily_agg.columns:
|
||
daily_agg['wow_revenue'] = daily_agg[col_amount].pct_change(periods=7) * 100
|
||
if 'profit' in daily_agg.columns:
|
||
daily_agg['wow_profit'] = daily_agg['profit'].pct_change(periods=7) * 100
|
||
if 'avg_price' in daily_agg.columns:
|
||
daily_agg['wow_avg_price'] = daily_agg['avg_price'].pct_change(periods=7) * 100
|
||
if col_qty and col_qty in daily_agg.columns:
|
||
daily_agg['wow_qty'] = daily_agg[col_qty].pct_change(periods=7) * 100
|
||
|
||
# Top 10 商品(選擇的日期,包含廠商)
|
||
selected_df = df[df['snapshot_date'] == selected_date]
|
||
top10_labels = []
|
||
top10_values = []
|
||
|
||
if col_name and col_amount:
|
||
col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier'])
|
||
|
||
if col_vendor:
|
||
# 如果有廠商欄位,按商品+廠商聚合
|
||
top10_df = selected_df.groupby([col_name, col_vendor])[col_amount].sum().nlargest(10).reset_index()
|
||
top10_labels = [f"{row[col_name]} ({row[col_vendor]})" for _, row in top10_df.iterrows()]
|
||
top10_values = top10_df[col_amount].tolist()
|
||
else:
|
||
# 沒有廠商欄位,只按商品聚合
|
||
top10 = selected_df.groupby(col_name)[col_amount].sum().nlargest(10)
|
||
top10_labels = top10.index.tolist()
|
||
top10_values = top10.values.tolist()
|
||
|
||
return {
|
||
'labels': daily_agg['snapshot_date'].dt.strftime('%m/%d').tolist() if not daily_agg.empty else [],
|
||
'revenue': daily_agg[col_amount].tolist() if col_amount and col_amount in daily_agg.columns and not daily_agg.empty else [],
|
||
'cost': daily_agg[col_cost].tolist() if col_cost and col_cost in daily_agg.columns and not daily_agg.empty else [],
|
||
'profit': daily_agg['profit'].tolist() if 'profit' in daily_agg.columns and not daily_agg.empty else [],
|
||
'qty': daily_agg[col_qty].tolist() if col_qty and col_qty in daily_agg.columns and not daily_agg.empty else [],
|
||
'avg_price': daily_agg['avg_price'].tolist() if 'avg_price' in daily_agg.columns and not daily_agg.empty else [],
|
||
|
||
# DoD 多維度
|
||
'dod_revenue': daily_agg['dod_revenue'].fillna(0).tolist() if 'dod_revenue' in daily_agg.columns and not daily_agg.empty else [],
|
||
'dod_profit': daily_agg['dod_profit'].fillna(0).tolist() if 'dod_profit' in daily_agg.columns and not daily_agg.empty else [],
|
||
'dod_avg_price': daily_agg['dod_avg_price'].fillna(0).tolist() if 'dod_avg_price' in daily_agg.columns and not daily_agg.empty else [],
|
||
'dod_qty': daily_agg['dod_qty'].fillna(0).tolist() if 'dod_qty' in daily_agg.columns and not daily_agg.empty else [],
|
||
|
||
# WoW 多維度
|
||
'wow_revenue': daily_agg['wow_revenue'].fillna(0).tolist() if 'wow_revenue' in daily_agg.columns and not daily_agg.empty else [],
|
||
'wow_profit': daily_agg['wow_profit'].fillna(0).tolist() if 'wow_profit' in daily_agg.columns and not daily_agg.empty else [],
|
||
'wow_avg_price': daily_agg['wow_avg_price'].fillna(0).tolist() if 'wow_avg_price' in daily_agg.columns and not daily_agg.empty else [],
|
||
'wow_qty': daily_agg['wow_qty'].fillna(0).tolist() if 'wow_qty' in daily_agg.columns and not daily_agg.empty else [],
|
||
|
||
'top10_labels': top10_labels,
|
||
'top10_values': top10_values
|
||
}
|
||
|
||
def prepare_category_summary(df, date_str=None, is_month_view=False, month_start=None, month_end=None):
|
||
"""準備分類聚合列表 (支援單日或月度範圍)"""
|
||
if is_month_view and month_start is not None and month_end is not None:
|
||
day_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)]
|
||
else:
|
||
day_df = df[df['snapshot_date'] == date_str]
|
||
cols = day_df.columns.tolist()
|
||
|
||
col_category = find_col(cols, ['館別', '分類', 'Category'])
|
||
col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier'])
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '總業績'])
|
||
col_cost = find_col(cols, ['成本', '總成本'])
|
||
col_profit = find_col(cols, ['毛利'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||
|
||
if not col_category or not col_amount:
|
||
return []
|
||
|
||
# 分類 + 廠商聚合
|
||
agg_dict = {col_amount: 'sum'}
|
||
if col_cost:
|
||
agg_dict[col_cost] = 'sum'
|
||
if col_profit:
|
||
agg_dict[col_profit] = 'sum'
|
||
if col_qty:
|
||
agg_dict[col_qty] = 'sum'
|
||
if col_name:
|
||
agg_dict[col_name] = 'nunique'
|
||
|
||
# 如果有廠商欄位,按分類+廠商聚合;否則只按分類聚合
|
||
if col_vendor:
|
||
category_df = day_df.groupby([col_category, col_vendor]).agg(agg_dict).reset_index()
|
||
else:
|
||
category_df = day_df.groupby(col_category).agg(agg_dict).reset_index()
|
||
|
||
# 計算毛利(如果資料中沒有毛利欄位,自動計算)
|
||
if col_profit and col_profit in category_df.columns:
|
||
# 資料中有毛利欄位,直接使用
|
||
pass
|
||
elif col_amount and col_cost and col_amount in category_df.columns and col_cost in category_df.columns:
|
||
# 資料中沒有毛利欄位,用 業績 - 成本 計算
|
||
category_df['profit_calculated'] = category_df[col_amount] - category_df[col_cost]
|
||
col_profit = 'profit_calculated'
|
||
else:
|
||
col_profit = None
|
||
|
||
# 計算毛利率
|
||
if col_profit and col_profit in category_df.columns and col_amount and col_amount in category_df.columns:
|
||
category_df['margin_rate'] = (category_df[col_profit] / category_df[col_amount] * 100).fillna(0)
|
||
else:
|
||
category_df['margin_rate'] = 0
|
||
|
||
# 計算均價
|
||
if col_qty and col_amount:
|
||
category_df['avg_price'] = (category_df[col_amount] / category_df[col_qty]).fillna(0)
|
||
else:
|
||
category_df['avg_price'] = 0
|
||
|
||
# 重新命名欄位以便模板使用
|
||
rename_dict = {col_category: 'category', col_amount: 'revenue'}
|
||
if col_vendor:
|
||
rename_dict[col_vendor] = 'vendor'
|
||
if col_cost:
|
||
rename_dict[col_cost] = 'cost'
|
||
if col_profit and col_profit in category_df.columns:
|
||
rename_dict[col_profit] = 'profit'
|
||
if col_qty:
|
||
rename_dict[col_qty] = 'qty'
|
||
if col_name:
|
||
rename_dict[col_name] = 'sku_count'
|
||
|
||
category_df = category_df.rename(columns=rename_dict)
|
||
|
||
# 確保 profit 欄位存在,如果不存在則設為 0
|
||
if 'profit' not in category_df.columns:
|
||
category_df['profit'] = 0
|
||
|
||
# 轉為字典列表
|
||
return category_df.to_dict('records')
|
||
|
||
# V-New 2026-01-15: 行銷活動業績聚合函數
|
||
def prepare_marketing_summary(df, selected_date=None, is_month_view=False, month_start=None, month_end=None, sort_by='revenue'):
|
||
"""
|
||
準備行銷活動業績貢獻數據
|
||
支援單日模式和月度模式,並可指定排序維度 (revenue, qty, profit)
|
||
"""
|
||
# 決定使用的數據範圍
|
||
if is_month_view and month_start is not None and month_end is not None:
|
||
target_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)]
|
||
elif selected_date is not None:
|
||
target_df = df[df['snapshot_date'] == selected_date]
|
||
else:
|
||
target_df = df
|
||
|
||
if target_df.empty:
|
||
return {'coupon': [], 'discount': [], 'bonus': [], 'click': []}
|
||
|
||
cols = target_df.columns.tolist()
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量', 'Qty'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit', '利潤'])
|
||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||
|
||
if not col_amount:
|
||
return {'coupon': [], 'discount': [], 'bonus': [], 'click': []}
|
||
|
||
# 定義四種行銷活動欄位
|
||
marketing_cols = {
|
||
'coupon': '折價券活動名稱', # 折價券活動
|
||
'discount': '折扣活動名稱', # 折扣活動
|
||
'bonus': '滿額再折扣活動名稱', # 滿額再折扣
|
||
'click': '點我再折扣' # 點我再折扣
|
||
}
|
||
|
||
result = {}
|
||
|
||
# 確保 sort_by 欄位存在,否則退回 revenue
|
||
actual_sort_key = sort_by if sort_by in ['revenue', 'qty', 'profit'] else 'revenue'
|
||
|
||
for key, col_name in marketing_cols.items():
|
||
if col_name not in cols:
|
||
result[key] = []
|
||
continue
|
||
|
||
# 篩選有該行銷活動的記錄
|
||
activity_df = target_df[
|
||
(target_df[col_name].notna()) &
|
||
(target_df[col_name] != '') &
|
||
(target_df[col_name] != '0') &
|
||
(target_df[col_name] != 0)
|
||
]
|
||
|
||
if activity_df.empty:
|
||
result[key] = []
|
||
continue
|
||
|
||
# 聚合計算
|
||
agg_args = {
|
||
'revenue': (col_amount, 'sum'),
|
||
'order_count': (col_amount, 'count')
|
||
}
|
||
if col_qty: agg_args['qty'] = (col_qty, 'sum')
|
||
if col_profit: agg_args['profit'] = (col_profit, 'sum')
|
||
|
||
grouped = activity_df.groupby(col_name).agg(**agg_args).reset_index()
|
||
|
||
# 若需要手動計算毛利 (金額 - 成本)
|
||
if 'profit' not in agg_args and col_cost:
|
||
cost_agg = activity_df.groupby(col_name)[col_cost].sum().reset_index()
|
||
grouped = grouped.merge(cost_agg, on=col_name)
|
||
grouped['profit'] = grouped['revenue'] - grouped[col_cost]
|
||
|
||
grouped = grouped.rename(columns={col_name: 'name'})
|
||
|
||
# 動態排序
|
||
sort_col = actual_sort_key if actual_sort_key in grouped.columns else 'revenue'
|
||
grouped = grouped.sort_values(sort_col, ascending=False).head(15)
|
||
|
||
# 轉為字典列表
|
||
records = []
|
||
for _, row in grouped.iterrows():
|
||
record = {
|
||
'name': str(row['name'])[:50],
|
||
'revenue': float(row['revenue']),
|
||
'order_count': int(row['order_count'])
|
||
}
|
||
if 'qty' in row: record['qty'] = float(row['qty'])
|
||
if 'profit' in row: record['profit'] = float(row['profit'])
|
||
records.append(record)
|
||
|
||
result[key] = records
|
||
|
||
return result
|
||
|
||
|
||
def get_taiwan_holiday(date):
|
||
"""判斷是否為台灣國定假日,回傳 (is_holiday, holiday_name)"""
|
||
year = date.year
|
||
month = date.month
|
||
day = date.day
|
||
|
||
# 2026年台灣國定假日(根據人事行政總處公佈)
|
||
holidays_2026 = {
|
||
(1, 1): '元旦',
|
||
# 春節連假 (2/14-2/22,共9天)
|
||
(2, 14): '春節連假',
|
||
(2, 15): '小年夜',
|
||
(2, 16): '除夕',
|
||
(2, 17): '春節 (初一)',
|
||
(2, 18): '春節 (初二)',
|
||
(2, 19): '春節 (初三)',
|
||
(2, 20): '春節連假',
|
||
(2, 21): '春節連假',
|
||
(2, 22): '春節連假',
|
||
# 和平紀念日 (2/28-3/2,共3天)
|
||
(2, 28): '和平紀念日',
|
||
(3, 2): '和平紀念日補假',
|
||
# 兒童節+清明節 (4/3-4/6,共4天)
|
||
(4, 3): '兒童節補假',
|
||
(4, 4): '清明節',
|
||
(4, 5): '清明節連假',
|
||
(4, 6): '清明節補假',
|
||
# 勞動節 (5/1-5/3,共3天)
|
||
(5, 1): '勞動節',
|
||
# 端午節 (6/19-6/21,共3天)
|
||
(6, 19): '端午節',
|
||
# 中秋節+教師節 (9/25-9/28,共4天)
|
||
(9, 25): '中秋節',
|
||
(9, 28): '教師節',
|
||
# 國慶日 (10/9-10/11,共3天)
|
||
(10, 9): '國慶日補假',
|
||
(10, 10): '國慶日',
|
||
# 光復節 (10/25-10/26,共2天)
|
||
(10, 25): '臺灣光復節',
|
||
(10, 26): '光復節補假',
|
||
# 行憲紀念日 (12/25-12/27,共3天)
|
||
(12, 25): '行憲紀念日',
|
||
}
|
||
|
||
# 2027年台灣國定假日(預先計算部分)
|
||
holidays_2027 = {
|
||
(1, 1): '元旦',
|
||
(2, 11): '春節 (除夕)',
|
||
(2, 12): '春節 (初一)',
|
||
(2, 13): '春節 (初二)',
|
||
(2, 14): '春節 (初三)',
|
||
(2, 15): '春節 (初四)',
|
||
(2, 16): '春節 (初五)',
|
||
(2, 17): '春節 (初六)',
|
||
(2, 28): '和平紀念日',
|
||
(4, 4): '清明節',
|
||
(4, 5): '清明節連假',
|
||
(6, 14): '端午節',
|
||
(9, 21): '中秋節',
|
||
(10, 10): '國慶日',
|
||
(10, 11): '國慶日連假',
|
||
}
|
||
|
||
holidays = holidays_2026 if year == 2026 else (holidays_2027 if year == 2027 else {})
|
||
|
||
holiday_name = holidays.get((month, day))
|
||
return (True, holiday_name) if holiday_name else (False, None)
|
||
|
||
def prepare_calendar_data(df, selected_month):
|
||
"""準備行事曆數據(豐富版:顯示總業績、毛利、SKU數 + DoD%)"""
|
||
import calendar
|
||
|
||
# 取得該月份的年月
|
||
year = selected_month.year
|
||
month = selected_month.month
|
||
|
||
# 計算該月第一天和最後一天
|
||
first_day = pd.Timestamp(year=year, month=month, day=1)
|
||
last_day = pd.Timestamp(year=year, month=month, day=calendar.monthrange(year, month)[1])
|
||
|
||
# 計算行事曆顯示範圍(包含前後月份的日期以填滿週)
|
||
# 取得該月第一天是星期幾 (0=Monday, 6=Sunday)
|
||
first_weekday = first_day.weekday()
|
||
|
||
# 計算行事曆起始日(從週一開始)
|
||
calendar_start = first_day - timedelta(days=first_weekday)
|
||
|
||
# 計算該月最後一天是星期幾
|
||
last_weekday = last_day.weekday()
|
||
|
||
# 計算行事曆結束日(到週日結束)
|
||
calendar_end = last_day + timedelta(days=(6 - last_weekday))
|
||
|
||
# 取得該月份及前後各一天的所有資料(用於計算 DoD)
|
||
data_start = first_day - timedelta(days=1)
|
||
data_end = last_day
|
||
month_df = df[(df['snapshot_date'] >= data_start) & (df['snapshot_date'] <= data_end)]
|
||
|
||
# 取得欄位
|
||
cols = df.columns.tolist()
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_cost = find_col(cols, ['成本', 'Cost'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||
|
||
# 為每一天計算 KPI
|
||
calendar_days = []
|
||
current_date = calendar_start
|
||
|
||
while current_date <= calendar_end:
|
||
# 取得星期(0=週一, 6=週日)
|
||
weekday = current_date.weekday()
|
||
weekday_names = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
|
||
|
||
# 判斷是否為國定假日
|
||
is_holiday, holiday_name = get_taiwan_holiday(current_date)
|
||
|
||
day_data = {
|
||
'date': current_date.strftime('%Y-%m-%d'),
|
||
'day': current_date.day,
|
||
'weekday': weekday_names[weekday],
|
||
'is_weekend': weekday >= 5, # 週六或週日
|
||
'is_holiday': is_holiday,
|
||
'holiday_name': holiday_name,
|
||
'is_current_month': current_date.month == month,
|
||
'has_data': False,
|
||
'revenue': 0,
|
||
'profit': 0,
|
||
'margin_rate': 0,
|
||
'sku_count': 0,
|
||
'qty': 0,
|
||
'avg_price': 0,
|
||
'dod_percent': 0,
|
||
'dod_direction': 'neutral' # 'up', 'down', 'neutral'
|
||
}
|
||
|
||
# 如果該日期在當前月份範圍內,計算 KPI
|
||
if first_day <= current_date <= last_day:
|
||
day_df = month_df[month_df['snapshot_date'] == current_date]
|
||
|
||
if not day_df.empty:
|
||
day_data['has_data'] = True
|
||
|
||
# 計算總業績
|
||
if col_amount:
|
||
day_data['revenue'] = float(day_df[col_amount].sum())
|
||
|
||
# 計算毛利(優先使用毛利欄位,否則用業績-成本計算)
|
||
if col_profit:
|
||
day_data['profit'] = float(day_df[col_profit].sum())
|
||
elif col_cost and col_amount:
|
||
total_cost = float(day_df[col_cost].sum())
|
||
day_data['profit'] = day_data['revenue'] - total_cost
|
||
|
||
# 計算毛利率
|
||
if day_data['revenue'] > 0:
|
||
day_data['margin_rate'] = (day_data['profit'] / day_data['revenue']) * 100
|
||
|
||
# 計算銷量
|
||
if col_qty:
|
||
day_data['qty'] = float(day_df[col_qty].sum())
|
||
|
||
# 計算客單價(總業績 / 總銷量)
|
||
if day_data['qty'] > 0:
|
||
day_data['avg_price'] = day_data['revenue'] / day_data['qty']
|
||
|
||
# 計算 SKU 數
|
||
if col_name:
|
||
day_data['sku_count'] = int(day_df[col_name].nunique())
|
||
|
||
# 計算 DoD%
|
||
prev_date = current_date - timedelta(days=1)
|
||
prev_df = month_df[month_df['snapshot_date'] == prev_date]
|
||
|
||
if not prev_df.empty and col_amount:
|
||
prev_revenue = float(prev_df[col_amount].sum())
|
||
if prev_revenue > 0:
|
||
dod = ((day_data['revenue'] - prev_revenue) / prev_revenue) * 100
|
||
day_data['dod_percent'] = round(dod, 1)
|
||
day_data['dod_direction'] = 'up' if dod >= 0 else 'down'
|
||
|
||
calendar_days.append(day_data)
|
||
current_date += timedelta(days=1)
|
||
|
||
# 組織成週結構(每週 7 天)
|
||
weeks = []
|
||
for i in range(0, len(calendar_days), 7):
|
||
weeks.append(calendar_days[i:i+7])
|
||
|
||
# 計算上個月和下個月的年月
|
||
prev_month = selected_month - pd.DateOffset(months=1)
|
||
next_month = selected_month + pd.DateOffset(months=1)
|
||
|
||
return {
|
||
'year': year,
|
||
'month': month,
|
||
'month_name': selected_month.strftime('%Y年%m月'),
|
||
'weeks': weeks,
|
||
'prev_month': prev_month.strftime('%Y-%m'),
|
||
'next_month': next_month.strftime('%Y-%m')
|
||
}
|
||
|
||
# ================= ⚙️ 5. 服務啟動邏輯 =================
|
||
|
||
def run_schedule():
|
||
"""在背景執行緒中運行排程"""
|
||
sys_log.info("🚀 排程服務已啟動,等待任務...")
|
||
while True:
|
||
schedule.run_pending()
|
||
time.sleep(1)
|
||
|
||
def init_scheduler():
|
||
"""初始化排程任務(Gunicorn 模式下也會執行)"""
|
||
schedule.every(1).hours.do(run_momo_task)
|
||
schedule.every(1).hours.do(run_edm_task)
|
||
schedule.every(1).hours.do(run_festival_task)
|
||
sys_log.info(f"📅 已設定每小時執行主站、EDM與購物節爬蟲任務")
|
||
|
||
schedule.every(30).minutes.do(run_auto_import_task)
|
||
sys_log.info(f"📅 已設定每 30 分鐘執行 Google Drive 自動匯入任務")
|
||
|
||
schedule.every(30).minutes.do(run_whitepage_check)
|
||
sys_log.info(f"📅 已設定每 30 分鐘執行網頁白頁監控任務")
|
||
|
||
schedule.every(4).hours.do(run_competitor_price_feeder_task)
|
||
sys_log.info(f"📅 已設定每 4 小時執行 PChome 競品價格抓取任務")
|
||
|
||
# 啟動排程執行緒
|
||
scheduler_thread = threading.Thread(target=run_schedule, daemon=True)
|
||
scheduler_thread.start()
|
||
sys_log.info("✅ 排程器已在背景執行緒中啟動")
|
||
|
||
# V-New: 在模組載入時自動初始化排程(Gunicorn 模式下也會執行)
|
||
# 🚩 V-Fix 2026-01-14: 停用自動排程器以避免多個 gunicorn workers 重複執行任務
|
||
# 原因:每個 worker 都會啟動排程器,導致 4x 資源消耗(4 workers × 3 爬蟲任務 = 12 Chrome 實例同時運行)
|
||
# 解決方案:改用獨立的 run_scheduler.py 或透過 Web UI 手動觸發任務
|
||
# try:
|
||
# init_scheduler()
|
||
# except Exception as e:
|
||
# sys_log.error(f"❌ 排程器初始化失敗: {e}")
|
||
sys_log.info("ℹ️ 自動排程器已停用(避免重複執行),請使用 run_scheduler.py 或 Web UI 手動觸發")
|
||
|
||
def start_flask():
|
||
sys_log.info("🚀 Web 服務正在啟動於 port 80...")
|
||
app.run(host='0.0.0.0', port=80, use_reloader=False)
|
||
|
||
def scheduled_job_wrapper():
|
||
"""執行 MOMO 爬蟲任務並發送通知"""
|
||
timestamp = datetime.now(TAIPEI_TZ).strftime('%H:%M:%S')
|
||
sys_log.info(f"⏰ [{timestamp}] 啟動背景抓取執行緒...")
|
||
|
||
def job():
|
||
# 1. 執行爬蟲
|
||
run_momo_task()
|
||
|
||
# 2. 發送通知 (僅發送今日異動)
|
||
try:
|
||
# 重新載入通知模組
|
||
import importlib
|
||
import scheduler
|
||
import services.notification_manager
|
||
importlib.reload(scheduler)
|
||
importlib.reload(services.notification_manager)
|
||
from services.notification_manager import NotificationManager
|
||
|
||
stats = get_dashboard_stats()
|
||
|
||
# 只要有任何異動數據就發送通知
|
||
if any(stats.values()):
|
||
screenshot_path = scheduler.capture_page_screenshot("http://127.0.0.1/", "momo_dashboard")
|
||
NotificationManager().send_momo_report(stats, screenshot_path)
|
||
except Exception as e:
|
||
sys_log.error(f"[Scheduler] ❌ 發送通知失敗: {e}")
|
||
|
||
threading.Thread(target=job, daemon=True).start()
|
||
|
||
if __name__ == "__main__":
|
||
banner = f" MOMO 專業數據管理系統 {SYSTEM_VERSION} "
|
||
sys_log.info(f"{ '='*20} {banner} {'='*20}")
|
||
|
||
# 啟動前先檢查資料庫結構
|
||
repair_database_schema()
|
||
|
||
# 使用生產環境域名
|
||
public_url = "https://mo.wooo.work"
|
||
sys_log.info(f"✅ 使用固定網址: {public_url}")
|
||
|
||
# 🚩 V9.7 將公開 URL 寫入設定檔,供其他模組使用
|
||
try:
|
||
url_config_path = os.path.join(BASE_DIR, 'data', 'url_config.json')
|
||
with open(url_config_path, 'w') as f:
|
||
json.dump({"public_url": public_url}, f)
|
||
except Exception as file_err:
|
||
sys_log.error(f"⚠️ URL 設定檔寫入失敗 (不影響服務運行,可能磁碟已滿): {file_err}")
|
||
|
||
web_server = threading.Thread(target=start_flask)
|
||
web_server.daemon = True
|
||
web_server.start()
|
||
|
||
# 排程器已在模組載入時自動初始化(見 init_scheduler() 函式)
|
||
sys_log.info("ℹ️ 排程器已在全域範圍初始化完成")
|
||
|
||
try:
|
||
while True:
|
||
time.sleep(3600)
|
||
except KeyboardInterrupt:
|
||
sys_log.info("🔌 Web 服務已關閉")
|
||
try:
|
||
ngrok.disconnect(public_url)
|
||
except Exception as e:
|
||
sys_log.info(f"ℹ️ Ngrok 關閉時無需額外操作: {e}")
|