All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s
- 新增 routes/category_routes.py(46 行,3 routes:POST/PUT/DELETE) - app.py 7053 → 7012(-41 行) - 沿用 services.json_storage.load_categories/save_categories - 註冊位置貼齊 system_bp 後方 Phase 3e route handlers Blueprint 化首棒,邊界最小、無共用狀態
7013 lines
335 KiB
Python
7013 lines
335 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 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 已註冊")
|
||
|
||
# ==========================================
|
||
# 通知模板管理 Blueprint
|
||
# ==========================================
|
||
from routes.notification_routes import notification_bp
|
||
app.register_blueprint(notification_bp)
|
||
csrf.exempt(notification_bp) # n8n API 需要豁免 CSRF
|
||
sys_log.info("[Blueprint] ✅ 通知模板管理 Blueprint 已註冊")
|
||
|
||
# ==========================================
|
||
# Bot API Blueprint (Clawdbot 整合)
|
||
# ==========================================
|
||
from routes.bot_api_routes import bot_api_bp
|
||
app.register_blueprint(bot_api_bp)
|
||
csrf.exempt(bot_api_bp) # Bot API 使用 Token 認證,不需要 CSRF
|
||
sys_log.info("[Blueprint] ✅ Bot API Blueprint 已註冊")
|
||
|
||
|
||
# ==========================================
|
||
# Elephant Alpha AI Agent Super Orchestrator Blueprint
|
||
# ==========================================
|
||
try:
|
||
from routes.elephant_alpha_routes import elephant_alpha_bp
|
||
app.register_blueprint(elephant_alpha_bp)
|
||
csrf.exempt(elephant_alpha_bp) # Elephant Alpha API uses internal auth
|
||
sys_log.info("[Blueprint] Elephant Alpha AI Agent Super Orchestrator Blueprint registered")
|
||
except Exception as _e:
|
||
sys_log.warning(f"[Blueprint] Elephant Alpha registration failed: {_e}")
|
||
sys_log.info("[Blueprint] Elephant Alpha features will be unavailable")
|
||
|
||
# [2026-04-18 台北] OpenClaw Bot Blueprint — 修復 /menu 啞巴 (/bot/telegram/webhook 404)
|
||
# 原因:routes/openclaw_bot_routes.py 有 5000+ 行完整 telegram bot handler,但 app.py 從未 register
|
||
# 效果:Telegram 送進來的 update (包含 /menu) 能被正確接收與處理
|
||
try:
|
||
from routes.openclaw_bot_routes import openclaw_bot_bp
|
||
app.register_blueprint(openclaw_bot_bp)
|
||
csrf.exempt(openclaw_bot_bp) # Telegram webhook 不需要 CSRF
|
||
sys_log.info("[Blueprint] ✅ OpenClaw Bot Blueprint 已註冊 (Telegram /menu 復活)")
|
||
except Exception as _e:
|
||
sys_log.error(f"[Blueprint] ❌ OpenClaw Bot Blueprint 註冊失敗: {_e}")
|
||
|
||
# P0-12 修復:補齊缺少的 Blueprint 註冊
|
||
for _bp_module, _bp_name in [
|
||
('routes.api_routes', 'api_bp'),
|
||
('routes.edm_routes', 'edm_bp'),
|
||
('routes.sales_routes', 'sales_bp'),
|
||
('routes.monthly_routes', 'monthly_bp'),
|
||
('routes.price_comparison_routes', 'price_comparison_bp'),
|
||
('routes.export_routes', 'export_bp'),
|
||
('routes.daily_sales_routes', 'daily_sales_bp'),
|
||
('routes.dashboard_routes', 'dashboard_bp'),
|
||
('routes.import_routes', 'import_bp'),
|
||
('routes.pchome_routes', 'pchome_bp'),
|
||
]:
|
||
try:
|
||
import importlib as _il
|
||
_mod = _il.import_module(_bp_module)
|
||
_bp = getattr(_mod, _bp_name)
|
||
app.register_blueprint(_bp)
|
||
sys_log.info(f"[Blueprint] ✅ {_bp_name} 已註冊")
|
||
except Exception as _e:
|
||
sys_log.error(f"[Blueprint] ❌ {_bp_name} 註冊失敗: {_e}")
|
||
|
||
# V-Fix: 註冊 slugify 函數供模板使用(實作搬至 utils/text_helpers.py)
|
||
from utils.text_helpers import slugify # noqa: E402
|
||
|
||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||
public_url = "服務啟動中..."
|
||
|
||
# 🚩 時區設定:台北時間 (UTC+8)
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
# ==========================================
|
||
# 🔧 全域模板變數注入 (Context Processor)
|
||
# ==========================================
|
||
from config import METABASE_URL, GRIST_URL
|
||
|
||
@app.context_processor
|
||
def inject_global_vars():
|
||
"""注入全域變數到所有模板"""
|
||
return {
|
||
'metabase_url': METABASE_URL,
|
||
'grist_url': GRIST_URL,
|
||
'datetime_now': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'),
|
||
}
|
||
|
||
sys_log.info("[Template] ✅ 全域模板變數已注入 (metabase_url, grist_url)")
|
||
|
||
# ================= 🛠️ V9.72: 分類設定管理核心 =================
|
||
CATEGORIES_JSON_PATH = os.path.join(BASE_DIR, 'data', 'categories.json')
|
||
|
||
# JSON 持久化:實作搬至 services/json_storage.py
|
||
from services.json_storage import ( # noqa: E402, F401
|
||
load_categories,
|
||
save_categories,
|
||
load_scheduler_stats,
|
||
)
|
||
|
||
# ================= 🛠️ 數據處理核心 (封裝) =================
|
||
|
||
# 純工具:實作已搬至 utils/text_helpers.py
|
||
from utils.text_helpers import ( # noqa: E402
|
||
get_color_for_string,
|
||
extract_snapshot_date_from_filename,
|
||
number_format as _number_format,
|
||
)
|
||
|
||
|
||
@app.template_filter('number_format')
|
||
def number_format_filter(value):
|
||
"""Jinja filter wrapper — 實作見 utils.text_helpers.number_format。"""
|
||
return _number_format(value)
|
||
|
||
# V-Refactor: 將 find_col 移至全域,方便多個函式共用
|
||
from utils.df_helpers import find_col # noqa: E402
|
||
|
||
def get_consolidated_data():
|
||
"""🚩 統一封裝:獲取全分類去重後的當前數據、昨日對比及差值 (V-Opt: 優化查詢效能 + 快取)"""
|
||
global _DASHBOARD_DATA_CACHE
|
||
|
||
# V-New: 檢查快取是否有效
|
||
now = datetime.now(TAIPEI_TZ)
|
||
if (_DASHBOARD_DATA_CACHE['consolidated_data'] is not None and
|
||
_DASHBOARD_DATA_CACHE['consolidated_timestamp'] is not None):
|
||
cache_age = (now.timestamp() - _DASHBOARD_DATA_CACHE['consolidated_timestamp'])
|
||
if cache_age < _DASHBOARD_CACHE_TTL:
|
||
sys_log.debug(f"[Dashboard] [Cache] ✅ 使用快取資料 | 快取年齡: {cache_age:.1f}秒")
|
||
return _DASHBOARD_DATA_CACHE['consolidated_data'], _DASHBOARD_DATA_CACHE['today_start']
|
||
|
||
sys_log.debug("[Dashboard] [Cache] 🔄 快取過期或不存在,重新查詢資料庫")
|
||
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
seven_days_ago = today_start - timedelta(days=7)
|
||
thirty_days_ago = today_start - timedelta(days=30)
|
||
|
||
try:
|
||
# Query 1: Get the latest price record for every product. This is our main list of items.
|
||
latest_price_subq = session.query(
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
latest_records = session.query(PriceRecord).options(
|
||
joinedload(PriceRecord.product)
|
||
).join(latest_price_subq, PriceRecord.id == latest_price_subq.c.max_id).all()
|
||
|
||
product_ids = [r.product_id for r in latest_records]
|
||
if not product_ids:
|
||
session.close() # 提前關閉連線
|
||
return [], today_start
|
||
|
||
# Query 2: Get yesterday's closing prices for all products in one go
|
||
yesterday_prices_subq = session.query(
|
||
PriceRecord.product_id,
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).filter(
|
||
PriceRecord.product_id.in_(product_ids),
|
||
PriceRecord.timestamp < today_start
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
yesterday_prices_q = session.query(
|
||
PriceRecord.product_id, PriceRecord.price
|
||
).join(
|
||
yesterday_prices_subq,
|
||
PriceRecord.id == yesterday_prices_subq.c.max_id
|
||
)
|
||
yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q}
|
||
|
||
# Query 3: Get specific historical price points (7 days ago and 30 days ago)
|
||
# Instead of fetching ALL history, we fetch only the records closest to the target dates.
|
||
# This is a significant optimization.
|
||
|
||
# Helper to get price map for a specific date (start of day)
|
||
def get_price_map_before(target_date):
|
||
subq = session.query(
|
||
PriceRecord.product_id,
|
||
func.max(PriceRecord.timestamp).label('max_ts')
|
||
).filter(
|
||
PriceRecord.product_id.in_(product_ids),
|
||
PriceRecord.timestamp < target_date
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
q = session.query(PriceRecord.product_id, PriceRecord.price).join(
|
||
subq,
|
||
and_(PriceRecord.product_id == subq.c.product_id, PriceRecord.timestamp == subq.c.max_ts)
|
||
)
|
||
return {pid: price for pid, price in q}
|
||
|
||
prices_7d_ago_map = get_price_map_before(seven_days_ago + timedelta(days=1)) # Approximate 7 days ago
|
||
prices_30d_ago_map = get_price_map_before(thirty_days_ago + timedelta(days=1)) # Approximate 30 days ago
|
||
|
||
# Query 4: Get TODAY's records only (for sparkline/intraday change)
|
||
today_records_q = session.query(PriceRecord).filter(
|
||
PriceRecord.product_id.in_(product_ids),
|
||
PriceRecord.timestamp >= today_start
|
||
).order_by(PriceRecord.product_id, PriceRecord.timestamp).all()
|
||
|
||
today_map = {}
|
||
for r in today_records_q:
|
||
if r.product_id not in today_map: today_map[r.product_id] = []
|
||
today_map[r.product_id].append(r)
|
||
|
||
# Final Assembly (in-memory, no more DB queries)
|
||
unique_items = []
|
||
for r in latest_records:
|
||
pid = r.product_id
|
||
|
||
# 7d/30d stats
|
||
price_7d = prices_7d_ago_map.get(pid)
|
||
price_30d = prices_30d_ago_map.get(pid)
|
||
|
||
stats_7d_diff = r.price - price_7d if price_7d is not None else 0
|
||
stats_30d_diff = r.price - price_30d if price_30d is not None else 0
|
||
|
||
# Today's stats
|
||
today_records = today_map.get(pid, [])
|
||
today_diff = 0
|
||
today_changes = []
|
||
if len(today_records) > 1:
|
||
today_diff = today_records[-1].price - today_records[0].price
|
||
|
||
# Yesterday diff
|
||
y_price = yesterday_prices_map.get(pid)
|
||
yesterday_diff = r.price - y_price if y_price is not None else 0
|
||
|
||
status = "NONE"
|
||
if yesterday_diff > 0:
|
||
status = "PRICE_UP"
|
||
elif yesterday_diff < 0:
|
||
status = "PRICE_DOWN"
|
||
|
||
# Today's changes details
|
||
last_p = y_price if y_price is not None else (today_records[0].price if today_records else r.price)
|
||
for tr in today_records:
|
||
if tr.price != last_p:
|
||
diff = tr.price - last_p
|
||
today_changes.append({
|
||
'time': tr.timestamp.strftime('%H:%M'),
|
||
'price': tr.price,
|
||
'diff': diff
|
||
})
|
||
last_p = tr.price
|
||
|
||
unique_items.append({
|
||
'record': r,
|
||
'stats': {'7d_diff': stats_7d_diff, '30d_diff': stats_30d_diff, '1d_diff': today_diff},
|
||
'yesterday_diff': yesterday_diff,
|
||
'today_changes': today_changes,
|
||
'status': status
|
||
})
|
||
|
||
# V-New: 更新快取
|
||
_DASHBOARD_DATA_CACHE['consolidated_data'] = unique_items
|
||
_DASHBOARD_DATA_CACHE['consolidated_timestamp'] = now.timestamp()
|
||
_DASHBOARD_DATA_CACHE['today_start'] = today_start
|
||
sys_log.debug(f"[Dashboard] [Cache] 💾 快取已更新 | 商品數: {len(unique_items)}")
|
||
|
||
return unique_items, today_start
|
||
finally:
|
||
session.close()
|
||
|
||
def get_dashboard_stats():
|
||
"""計算看板統計數據 (供通知使用)"""
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
unique_items, today_start = get_consolidated_data()
|
||
today_start_db = today_start.replace(tzinfo=None)
|
||
|
||
# 1. 漲跌
|
||
increase_count = sum(1 for item in unique_items if item['yesterday_diff'] > 0)
|
||
decrease_count = sum(1 for item in unique_items if item['yesterday_diff'] < 0)
|
||
|
||
# 2. 今日新增 (使用與 index 路由相同的邏輯)
|
||
new_pids_query = session.query(PriceRecord.product_id).group_by(PriceRecord.product_id).having(func.min(PriceRecord.timestamp) >= today_start_db)
|
||
new_product_ids = {r[0] for r in new_pids_query.all()}
|
||
new_count = len(new_product_ids)
|
||
|
||
# 3. 今日下架
|
||
today_delisted_count = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start_db
|
||
).count()
|
||
|
||
return {'new': new_count, 'up': increase_count, 'down': decrease_count, 'delisted': today_delisted_count}
|
||
except Exception as e:
|
||
sys_log.error(f"[Stats] ❌ 計算統計失敗: {e}")
|
||
return {'new': 0, 'up': 0, 'down': 0, 'delisted': 0}
|
||
finally:
|
||
session.close()
|
||
|
||
# ================= 🛣️ 4. Flask 路由 =================
|
||
|
||
# Session 自動續期機制
|
||
@app.before_request
|
||
def refresh_session():
|
||
"""
|
||
在每次請求時自動刷新 Session,避免長時間閒置後突然斷線
|
||
只要用戶有任何操作,Session 就會自動延長
|
||
"""
|
||
if session.get('logged_in'):
|
||
session.modified = True # 標記 Session 已修改,觸發 Cookie 更新
|
||
|
||
@app.route('/health')
|
||
def health_check():
|
||
"""健康檢查端點 - 供 Nginx 和 Docker healthcheck 使用"""
|
||
try:
|
||
# 簡單檢查資料庫連線
|
||
from config import DATABASE_TYPE
|
||
return jsonify({
|
||
'status': 'healthy',
|
||
'database': DATABASE_TYPE,
|
||
'version': SYSTEM_VERSION
|
||
}), 200
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'unhealthy',
|
||
'error': str(e)
|
||
}), 500
|
||
|
||
|
||
@app.route('/metrics')
|
||
def prometheus_metrics():
|
||
"""Prometheus 指標端點 - 供 Prometheus 抓取監控資料"""
|
||
try:
|
||
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Counter, Gauge, CollectorRegistry
|
||
from config import DATABASE_TYPE
|
||
|
||
# 建立獨立的 registry 以避免重複註冊
|
||
registry = CollectorRegistry()
|
||
|
||
# 應用程式資訊
|
||
app_info = Gauge('momo_app_info', '應用程式資訊', ['version', 'database_type'], registry=registry)
|
||
app_info.labels(version=SYSTEM_VERSION, database_type=DATABASE_TYPE).set(1)
|
||
|
||
# 應用程式健康狀態 (1=健康, 0=不健康)
|
||
app_health = Gauge('momo_app_health', '應用程式健康狀態', registry=registry)
|
||
|
||
# 資料庫連線狀態
|
||
db_status = Gauge('momo_database_up', '資料庫連線狀態', registry=registry)
|
||
|
||
try:
|
||
db = DatabaseManager()
|
||
with db.engine.connect() as conn:
|
||
conn.execute(text("SELECT 1"))
|
||
db_status.set(1)
|
||
app_health.set(1)
|
||
except Exception:
|
||
db_status.set(0)
|
||
app_health.set(0)
|
||
|
||
# 資料庫記錄數
|
||
try:
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
|
||
# 商品數量
|
||
product_count = Gauge('momo_products_total', '商品總數', registry=registry)
|
||
product_count.set(session.query(Product).count())
|
||
|
||
# 價格記錄數量
|
||
price_record_count = Gauge('momo_price_records_total', '價格記錄總數', registry=registry)
|
||
price_record_count.set(session.query(PriceRecord).count())
|
||
|
||
# 業績資料筆數
|
||
from database.realtime_sales_models import RealtimeSalesMonthly
|
||
sales_count = Gauge('momo_sales_records_total', '業績資料總數', registry=registry)
|
||
sales_count.set(session.query(RealtimeSalesMonthly).count())
|
||
|
||
session.close()
|
||
except Exception as e:
|
||
sys_log.warning(f"[Metrics] 無法取得資料庫統計: {e}")
|
||
|
||
# 返回 Prometheus 格式
|
||
from flask import Response
|
||
return Response(generate_latest(registry), mimetype=CONTENT_TYPE_LATEST)
|
||
|
||
except ImportError:
|
||
# prometheus_client 未安裝時的備用方案
|
||
metrics_text = """# HELP momo_app_health 應用程式健康狀態
|
||
# TYPE momo_app_health gauge
|
||
momo_app_health 1
|
||
# HELP momo_app_info 應用程式資訊
|
||
# TYPE momo_app_info gauge
|
||
momo_app_info{version="9.4",database_type="postgresql"} 1
|
||
"""
|
||
from flask import Response
|
||
return Response(metrics_text, mimetype='text/plain; charset=utf-8')
|
||
except Exception as e:
|
||
sys_log.error(f"[Metrics] 指標生成錯誤: {e}")
|
||
from flask import Response
|
||
return Response(f"# Error: {e}\n", mimetype='text/plain; charset=utf-8'), 500
|
||
|
||
|
||
@app.route('/')
|
||
def index():
|
||
db = DatabaseManager()
|
||
|
||
session = db.get_session()
|
||
page = request.args.get('page', 1, type=int)
|
||
category_filter = request.args.get('category', 'all')
|
||
sort_by = request.args.get('sort_by', 'timestamp') # 預設按時間排序
|
||
filter_type = request.args.get('filter', 'all') # 🚩 新增:狀態篩選 (increase, decrease, delisted)
|
||
order = request.args.get('order', 'desc')
|
||
search_query = request.args.get('q', '').strip() # 🚩 新增:搜尋關鍵字
|
||
per_page = 50
|
||
|
||
# 🚩 取得台北時間的今日起始點 (用於資料庫查詢比較)
|
||
# 注意:若資料庫內存的是 naive time (無時區),則需轉為 naive 進行比較
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
today_start_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
try:
|
||
# 🚩 1. 使用封裝函式獲取數據
|
||
unique_items, today_start = get_consolidated_data()
|
||
|
||
# --- 計算今日漲跌統計 ---
|
||
increase_items = [item for item in unique_items if item['yesterday_diff'] > 0]
|
||
decrease_items = [item for item in unique_items if item['yesterday_diff'] < 0]
|
||
|
||
# --- V-New: 取得所有分類並加上筆數統計 ---
|
||
cat_counts = {}
|
||
for item in unique_items:
|
||
c = item['record'].product.category
|
||
if c:
|
||
cat_counts[c] = cat_counts.get(c, 0) + 1
|
||
|
||
all_categories = [f"{cat} ({count}筆)" for cat, count in sorted(cat_counts.items())]
|
||
|
||
# V-Fix: 預先計算今日新增的商品 ID (不依賴 Product.created_at)
|
||
new_product_ids = set()
|
||
try:
|
||
# 找出最早一筆價格紀錄是在今天的商品
|
||
new_pids_query = session.query(PriceRecord.product_id).group_by(PriceRecord.product_id).having(func.min(PriceRecord.timestamp) >= today_start_db)
|
||
new_product_ids = {r[0] for r in new_pids_query.all()}
|
||
except Exception: pass
|
||
|
||
# --- 看板統計數據 ---
|
||
total_products_history = session.query(Product).count()
|
||
today_new_products = session.query(func.count(Product.id)).filter(
|
||
Product.id.in_(
|
||
session.query(PriceRecord.product_id)
|
||
.group_by(PriceRecord.product_id)
|
||
.having(func.min(PriceRecord.timestamp) >= today_start_db)
|
||
)
|
||
).scalar()
|
||
total_price_records = session.query(PriceRecord).count()
|
||
today_updates = session.query(PriceRecord).filter(PriceRecord.timestamp >= today_start_db).count()
|
||
|
||
# 🚩 新增:今日下架商品統計 (狀態為 INACTIVE 且 最後更新時間 >= 今天零點)
|
||
today_delisted_query = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start_db
|
||
)
|
||
raw_delisted_items = today_delisted_query.all()
|
||
today_delisted_count = len(raw_delisted_items)
|
||
|
||
# 🚩 V-Opt: 為下架商品補上最後價格(優化:一次查詢取得所有價格,避免 N+1 問題)
|
||
today_delisted_items = []
|
||
if raw_delisted_items:
|
||
# 取得所有下架商品的 ID
|
||
delisted_ids = [p.id for p in raw_delisted_items]
|
||
|
||
# 一次性查詢所有下架商品的最後價格
|
||
last_prices_subq = session.query(
|
||
PriceRecord.product_id,
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).filter(
|
||
PriceRecord.product_id.in_(delisted_ids)
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
last_prices_q = session.query(
|
||
PriceRecord.product_id,
|
||
PriceRecord.price
|
||
).join(
|
||
last_prices_subq,
|
||
PriceRecord.id == last_prices_subq.c.max_id
|
||
)
|
||
|
||
# 建立 product_id -> price 的映射
|
||
price_map = {pid: price for pid, price in last_prices_q}
|
||
|
||
# 組合結果
|
||
for p in raw_delisted_items:
|
||
price = price_map.get(p.id, 0)
|
||
today_delisted_items.append({'product': p, 'last_price': price})
|
||
|
||
# ========== V9.2: 新增 KPI 計算 ==========
|
||
|
||
# 1. 平均漲跌幅
|
||
avg_increase = sum(item['yesterday_diff'] for item in increase_items) / len(increase_items) if increase_items else 0
|
||
avg_decrease = sum(item['yesterday_diff'] for item in decrease_items) / len(decrease_items) if decrease_items else 0
|
||
|
||
# 2. 今日活躍度(有價格變動的商品百分比)
|
||
active_count = len(increase_items) + len(decrease_items)
|
||
activity_rate = (active_count / total_products_history * 100) if total_products_history > 0 else 0
|
||
|
||
# 3. 最大變動(絕對值最大的價格變動)
|
||
max_change_item = None
|
||
max_change_value = 0
|
||
for item in unique_items:
|
||
if abs(item['yesterday_diff']) > abs(max_change_value):
|
||
max_change_value = item['yesterday_diff']
|
||
max_change_item = item
|
||
|
||
# 4. 週增長 (過去 7 天新增的商品數)
|
||
week_ago_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=7)
|
||
week_ago_db = week_ago_db.replace(tzinfo=None)
|
||
week_new_products = session.query(func.count(Product.id)).filter(
|
||
Product.id.in_(
|
||
session.query(PriceRecord.product_id)
|
||
.group_by(PriceRecord.product_id)
|
||
.having(func.min(PriceRecord.timestamp) >= week_ago_db)
|
||
)
|
||
).scalar() or 0
|
||
|
||
# 5. 價格穩定商品數(7 天內無變價)- V9.3 效能優化版
|
||
seven_days_ago = now_taipei - timedelta(days=7)
|
||
seven_days_ago_db = seven_days_ago.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
|
||
|
||
# 使用 GROUP BY 一次性統計所有商品的不同價格數量(避免 N+1 查詢)
|
||
try:
|
||
stable_count = session.query(PriceRecord.product_id).filter(
|
||
PriceRecord.timestamp >= seven_days_ago_db
|
||
).group_by(PriceRecord.product_id).having(
|
||
func.count(func.distinct(PriceRecord.price)) == 1
|
||
).count()
|
||
except Exception:
|
||
stable_count = 0
|
||
|
||
# 6. 最活躍分類(今日變動商品數最多的分類)
|
||
category_activity = {}
|
||
for item in increase_items + decrease_items:
|
||
cat = item['record'].product.category
|
||
if cat:
|
||
category_activity[cat] = category_activity.get(cat, 0) + 1
|
||
|
||
most_active_category = None
|
||
most_active_count = 0
|
||
if category_activity:
|
||
most_active_category = max(category_activity.items(), key=lambda x: x[1])
|
||
most_active_count = most_active_category[1]
|
||
most_active_category = most_active_category[0]
|
||
|
||
# 🚩 讀取系統狀態 (用於紅綠燈顯示)
|
||
system_status = {"status": "UNKNOWN", "message": "尚無執行紀錄", "timestamp": "-"}
|
||
status_path = os.path.join(BASE_DIR, 'data/system_status.json')
|
||
if os.path.exists(status_path):
|
||
try:
|
||
with open(status_path, 'r', encoding='utf-8') as f:
|
||
system_status = json.load(f)
|
||
except: pass
|
||
|
||
# --- 取得所有分類用於篩選器 ---
|
||
# (已在上方取得)
|
||
|
||
# 🚩 2. 後端篩選 (Server-side Filtering)
|
||
scheduler_stats = load_scheduler_stats()
|
||
|
||
# V-Fix: Handle old scheduler stats format (dict) by converting to list to prevent template errors
|
||
if scheduler_stats.get('momo_task') and isinstance(scheduler_stats.get('momo_task'), dict):
|
||
scheduler_stats['momo_task'] = [scheduler_stats['momo_task']]
|
||
if scheduler_stats.get('edm_task') and isinstance(scheduler_stats.get('edm_task'), dict):
|
||
scheduler_stats['edm_task'] = [scheduler_stats['edm_task']]
|
||
|
||
filtered_items = []
|
||
|
||
# 0. 先處理搜尋 (若有)
|
||
if search_query:
|
||
search_lower = search_query.lower()
|
||
# V9.81: 搜尋功能修復,支援搜尋商品名稱與 i_code
|
||
base_items = [
|
||
item for item in unique_items
|
||
if (item['record'].product.name and search_lower in item['record'].product.name.lower()) or
|
||
(item['record'].product.i_code and search_lower in str(item['record'].product.i_code))
|
||
]
|
||
else:
|
||
base_items = unique_items
|
||
|
||
# A. 先處理狀態篩選 (漲/跌/下架)
|
||
if filter_type == 'increase':
|
||
filtered_items = [i for i in base_items if i in increase_items]
|
||
elif filter_type == 'decrease':
|
||
filtered_items = [i for i in base_items if i in decrease_items]
|
||
elif filter_type == 'new':
|
||
# V-New: 新上架篩選 (今日新增的商品)
|
||
filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids]
|
||
elif filter_type == 'delisted':
|
||
# 特殊處理:將下架商品轉換為列表格式以便顯示
|
||
for item in today_delisted_items:
|
||
# 模擬 record 物件結構
|
||
class MockRecord:
|
||
def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at
|
||
|
||
if not search_query or search_query.lower() in item['product'].name.lower():
|
||
filtered_items.append({
|
||
'record': MockRecord(item['product'], item['last_price']),
|
||
'stats': {'1d_diff': 0, '7d_diff': 0, '30d_diff': 0}, # 模擬 stats 結構
|
||
'yesterday_diff': 0,
|
||
'today_changes': [], # 確保結構一致
|
||
'status': 'DELISTED' # 新增狀態
|
||
})
|
||
else:
|
||
# B. 若無狀態篩選,則處理分類篩選
|
||
if category_filter != 'all':
|
||
# V-New: 處理帶有筆數的分類名稱,例如 "化妝水 (50筆)" -> "化妝水"
|
||
real_category = category_filter
|
||
if "(" in category_filter and "筆)" in category_filter:
|
||
real_category = category_filter.rsplit(" (", 1)[0]
|
||
filtered_items = [item for item in base_items if item['record'].product.category == real_category]
|
||
else:
|
||
filtered_items = base_items
|
||
|
||
# 🚩 3. 後端排序 (Server-side Sorting)
|
||
reverse = (order == 'desc')
|
||
def get_sort_key(item):
|
||
# 處理 None 值,確保排序時不會出錯
|
||
def safe_get(value, default=0):
|
||
return default if value is None else value
|
||
|
||
if sort_by == 'i_code': return int(safe_get(item['record'].product.i_code, 0))
|
||
if sort_by == 'category': return safe_get(item['record'].product.category, '')
|
||
if sort_by == 'name': return safe_get(item['record'].product.name, '')
|
||
if sort_by == 'price': return safe_get(item['record'].price, 0)
|
||
if sort_by == 'today_change': return safe_get(item['stats']['1d_diff'], 0) # 今日內波動
|
||
if sort_by == 'yesterday_change': return safe_get(item['yesterday_diff'], 0)
|
||
if sort_by == 'week_change': return safe_get(item['stats']['7d_diff'], 0)
|
||
return item['record'].timestamp # 預設
|
||
|
||
sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse)
|
||
|
||
# 🚩 4. 分頁 (Pagination) - 在篩選和排序之後執行
|
||
total_items = len(sorted_items)
|
||
total_pages = math.ceil(total_items / per_page)
|
||
|
||
start_idx = (page - 1) * per_page
|
||
paged_items = sorted_items[start_idx : start_idx + per_page]
|
||
|
||
# V-Fix: 為前端準備安全的 created_at 屬性
|
||
for item in paged_items:
|
||
item['safe_created_at'] = getattr(item['record'].product, 'created_at', None)
|
||
|
||
# 🚩 5. 為當前頁面項目添加顏色
|
||
for item in paged_items:
|
||
category_name = item['record'].product.category
|
||
item['category_color'] = get_color_for_string(category_name)
|
||
|
||
return render_template('dashboard.html',
|
||
total_products=total_products_history,
|
||
today_new_products=today_new_products,
|
||
total_price_records=total_price_records,
|
||
cnt_increase=len(increase_items),
|
||
cnt_decrease=len(decrease_items), # 傳遞跌價數
|
||
today_delisted_count=today_delisted_count,
|
||
today_delisted_items=today_delisted_items,
|
||
system_status=system_status,
|
||
items=paged_items,
|
||
categories=all_categories,
|
||
current_page=page,
|
||
total_pages=total_pages, # V-New: 傳遞總項目數
|
||
total_items=total_items,
|
||
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), # 顯示台北時間
|
||
today_date=now_taipei.strftime('%Y-%m-%d'), # 傳遞今日日期
|
||
public_url=public_url,
|
||
current_category=category_filter,
|
||
current_filter=filter_type, # 傳遞當前篩選狀態
|
||
search_query=search_query, # 傳遞搜尋關鍵字
|
||
current_sort=sort_by,
|
||
current_order=order,
|
||
scheduler_stats=scheduler_stats,
|
||
# V9.2: 新增 KPI 數據
|
||
avg_increase=avg_increase,
|
||
avg_decrease=avg_decrease,
|
||
activity_rate=activity_rate,
|
||
active_count=active_count,
|
||
max_change_item=max_change_item,
|
||
max_change_value=max_change_value,
|
||
week_new_products=week_new_products,
|
||
stable_count=stable_count,
|
||
most_active_category=most_active_category,
|
||
most_active_count=most_active_count)
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Dashboard] 🚨 渲染錯誤 | Error: {e}")
|
||
return f"系統維護中,錯誤詳情:{e}"
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/settings')
|
||
def settings():
|
||
"""分類設定頁面"""
|
||
categories = load_categories()
|
||
return render_template('settings.html',
|
||
categories=categories,
|
||
public_url=public_url,
|
||
system_version=SYSTEM_VERSION)
|
||
|
||
@app.route('/system_settings')
|
||
def system_settings_page():
|
||
"""系統設定與匯入頁面"""
|
||
return render_template('system_settings.html', system_version=SYSTEM_VERSION)
|
||
|
||
@app.route('/api/test_url', methods=['POST'])
|
||
def test_url():
|
||
"""API: 測試網址是否有效"""
|
||
try:
|
||
data = request.get_json()
|
||
url = data.get('url')
|
||
if not url:
|
||
return jsonify({"status": "error", "message": "網址不能為空"}), 400
|
||
|
||
import requests
|
||
headers = {
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||
}
|
||
# 設定 10 秒超時,避免卡住
|
||
response = requests.get(url, headers=headers, timeout=10)
|
||
|
||
if response.status_code == 200:
|
||
return jsonify({"status": "success", "message": f"✅ 連結有效 (Status: 200)"})
|
||
else:
|
||
return jsonify({"status": "warning", "message": f"⚠️ 連結回應異常 (Status: {response.status_code})"})
|
||
|
||
except Exception as e:
|
||
return jsonify({"status": "error", "message": f"❌ 連線失敗: {str(e)}"}), 500
|
||
|
||
|
||
@app.route('/brand_assets')
|
||
def brand_assets():
|
||
"""顯示品牌資產庫"""
|
||
return render_template('brand_assets.html')
|
||
|
||
@app.route('/edm')
|
||
def edm_dashboard():
|
||
"""🚩 新增:MOMO 限時搶購 (EDM) 專屬儀表板"""
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
|
||
# V-New: 排序參數
|
||
sort_by = request.args.get('sort_by', 'default')
|
||
order = request.args.get('order', 'desc')
|
||
|
||
try:
|
||
# 1. 基礎統計
|
||
# 取得最後更新時間
|
||
last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
|
||
last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料"
|
||
|
||
# 🚩 V9.29 新增:取得最新的活動時間文字
|
||
latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
|
||
activity_time = getattr(latest_entry, 'activity_time_text', '限時搶購') if latest_entry else "限時搶購"
|
||
|
||
# 2. 查詢資料 (V9.44: 只顯示最新批次的資料)
|
||
# 找出最新的 batch_id
|
||
latest_batch = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
|
||
current_batch_id = latest_batch[0] if latest_batch else None
|
||
|
||
# 🚩 V9.55 修正:改為查詢「全商品的最新狀態快照」,而非僅查詢最新批次
|
||
# 因為 Scheduler 現在只記錄異動,若只查最新批次,未變動的商品會消失
|
||
subq = session.query(
|
||
func.max(PromoProduct.id).label('max_id')
|
||
).filter(PromoProduct.page_type == 'edm').group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery()
|
||
|
||
latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all()
|
||
|
||
# 過濾顯示列表:顯示「上架中」、「本批次剛下架」或「今日結束時段」的商品
|
||
items_in_batch = []
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
for item in latest_records:
|
||
# V9.60: 隱藏自然結束的時段商品
|
||
# V-New: 如果商品是今天才結束的,則依然顯示在儀表板上,方便回查
|
||
# V-Fix: 確保時區比較一致
|
||
item_crawled_at = item.crawled_at
|
||
if item_crawled_at and item_crawled_at.tzinfo is None:
|
||
# V-Fix: 使用 replace 而非 localize (datetime.timezone 不支援 localize 方法)
|
||
item_crawled_at = item_crawled_at.replace(tzinfo=TAIPEI_TZ)
|
||
|
||
if item.status_change == 'SLOT_END' and item_crawled_at < today_start:
|
||
continue
|
||
|
||
# V-New: 如果是下架狀態,只有當它是「今天」下架的才顯示
|
||
if item.status_change == 'DELISTED' and item_crawled_at < today_start:
|
||
continue
|
||
items_in_batch.append(item)
|
||
|
||
# V9.45: 按時段分組
|
||
grouped_items = {}
|
||
for item in items_in_batch:
|
||
if item.time_slot not in grouped_items:
|
||
grouped_items[item.time_slot] = []
|
||
grouped_items[item.time_slot].append(item)
|
||
|
||
# 按時段鍵值排序 (e.g., 00:00, 07:00, ...)
|
||
sorted_grouped_items = dict(sorted(grouped_items.items()))
|
||
|
||
# V9.45: 決定預設顯示的頁籤
|
||
def get_current_time_slot():
|
||
hour = datetime.now(TAIPEI_TZ).hour
|
||
available_slots = sorted([int(s.split(':')[0]) for s in sorted_grouped_items.keys() if s and ':' in s]) if sorted_grouped_items else [0, 7, 11, 14, 18, 22]
|
||
current_slot_hour = 0
|
||
for s in available_slots:
|
||
if hour >= s:
|
||
current_slot_hour = s
|
||
return f"{current_slot_hour:02d}:00"
|
||
|
||
active_tab = get_current_time_slot()
|
||
if active_tab not in sorted_grouped_items and sorted_grouped_items:
|
||
active_tab = next(iter(sorted_grouped_items))
|
||
|
||
# V-New: 計算在架天數與總銷量
|
||
all_icodes_in_batch = [item.i_code for item in items_in_batch]
|
||
product_categories = {}
|
||
days_on_shelf_map = {}
|
||
total_sold_map = {}
|
||
|
||
if all_icodes_in_batch:
|
||
# 從主商品表 (products) 查詢這些 i_code 對應的分類
|
||
main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all()
|
||
product_categories = {p.i_code: p.category for p in main_products}
|
||
|
||
# 計算上架天數 (days_on_shelf)
|
||
# V-Fix: 使用 CAST 轉換為 DATE,兼容 PostgreSQL 和 SQLite
|
||
from sqlalchemy import cast, Date
|
||
days_on_shelf_q = session.query(
|
||
PromoProduct.i_code,
|
||
func.count(func.distinct(cast(PromoProduct.crawled_at, Date)))
|
||
).filter( # V-New: 增加 page_type 過濾
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.page_type == 'edm'
|
||
).group_by(PromoProduct.i_code).all()
|
||
days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q}
|
||
|
||
# 計算總銷量
|
||
# 1. 找出每個商品第一次有庫存紀錄的 ID
|
||
first_qty_subq = session.query(
|
||
PromoProduct.i_code,
|
||
func.min(PromoProduct.id).label('min_id')
|
||
).filter(
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.remain_qty.isnot(None),
|
||
PromoProduct.page_type == 'edm'
|
||
).group_by(PromoProduct.i_code).subquery()
|
||
|
||
# 2. 根據 ID 取得當時的庫存
|
||
first_qty_records = session.query(
|
||
PromoProduct.i_code, PromoProduct.remain_qty
|
||
).join(first_qty_subq, PromoProduct.id == first_qty_subq.c.min_id).all()
|
||
first_qty_map = {r[0]: r[1] for r in first_qty_records}
|
||
|
||
# 3. 計算總銷量 (初始庫存 - 當前庫存)
|
||
for item in items_in_batch:
|
||
# 確保該商品有初始庫存紀錄,且當前庫存也存在
|
||
if item.i_code in first_qty_map and item.remain_qty is not None:
|
||
initial_qty = first_qty_map[item.i_code]
|
||
current_qty = item.remain_qty
|
||
# 只有在初始庫存大於當前庫存時才計算,避免負數
|
||
if initial_qty > current_qty:
|
||
total_sold_map[item.i_code] = initial_qty - current_qty
|
||
|
||
# V-Fix: 修正 NameError: name 'history_map' is not defined
|
||
# 準備銷售歷程資料
|
||
history_map = {}
|
||
if all_icodes_in_batch:
|
||
all_history_records = session.query(
|
||
PromoProduct.i_code,
|
||
PromoProduct.time_slot,
|
||
PromoProduct.remain_qty,
|
||
PromoProduct.crawled_at
|
||
).filter(
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.crawled_at >= today_start
|
||
).order_by(PromoProduct.crawled_at).all()
|
||
|
||
for rec in all_history_records:
|
||
key = (rec.i_code, rec.time_slot)
|
||
if key not in history_map:
|
||
history_map[key] = []
|
||
|
||
if rec.remain_qty is not None:
|
||
if not history_map[key] or (history_map[key] and history_map[key][-1]['qty'] != rec.remain_qty):
|
||
history_map[key].append({'time': rec.crawled_at.strftime('%H:%M'), 'qty': rec.remain_qty})
|
||
|
||
# 將查到的分類資訊附加到每個 item 物件上
|
||
for item in items_in_batch:
|
||
item.main_category = product_categories.get(item.i_code)
|
||
if item.main_category:
|
||
item.category_color = get_color_for_string(item.main_category)
|
||
# V-New: 附加在架天數與總銷量
|
||
item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1)
|
||
item.total_sold = total_sold_map.get(item.i_code, 0)
|
||
# V-New: Attach quantity history
|
||
item.qty_history = history_map.get((item.i_code, item.time_slot), [])
|
||
|
||
# V9.46: 排序邏輯優化 (中文註解)
|
||
# 排序規則:
|
||
# 1. 有貼標 (main_category 存在) 的商品優先
|
||
# 2. 有狀態變更 (NEW, 漲價, 降價) 的商品次之
|
||
# 3. 已下架的商品再次之
|
||
# 4. 最後按價格由高到低排序
|
||
reverse = (order == 'desc')
|
||
for time_slot in sorted_grouped_items:
|
||
if sort_by == 'name':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse)
|
||
elif sort_by == 'remain_qty':
|
||
# 將 None 視為 -1,確保排序時在最下方
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.remain_qty if x.remain_qty is not None else -1, reverse=reverse)
|
||
elif sort_by == 'price':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse)
|
||
else: # 預設排序
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: (
|
||
1 if x.main_category else 0,
|
||
2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0),
|
||
x.price if x.price is not None else -1
|
||
), reverse=True)
|
||
|
||
# 🚩 V-Fix: 修正時段統計,使其能區分「當前狀態」與「上次異動」
|
||
# V-New: 重構時段統計邏輯,確保統計所有今日異動
|
||
slot_stats = {}
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
# 1. 取得今日所有異動紀錄
|
||
today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == 'edm').all()
|
||
|
||
# 2. 取得所有相關時段的鍵 (今日異動的 + 當前在架的)
|
||
slots_from_changes = {rec.time_slot for rec in today_change_records}
|
||
slots_from_display = set(sorted_grouped_items.keys())
|
||
all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display)))
|
||
|
||
# 3. 初始化所有相關時段的統計數據
|
||
for slot in all_relevant_slots:
|
||
slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0}
|
||
|
||
# 4. 累加新品、漲價、降價、下架的數量 (從今日歷史紀錄)
|
||
for rec in today_change_records:
|
||
if rec.time_slot in slot_stats:
|
||
if rec.status_change == 'NEW':
|
||
slot_stats[rec.time_slot]['new'] += 1
|
||
elif rec.status_change == 'PRICE_UP':
|
||
slot_stats[rec.time_slot]['up'] += 1
|
||
elif rec.status_change == 'PRICE_DOWN':
|
||
slot_stats[rec.time_slot]['down'] += 1
|
||
elif rec.status_change in ['DELISTED', 'SLOT_END']:
|
||
slot_stats[rec.time_slot]['delisted_last_run'] += 1
|
||
|
||
# 5. 計算在架與下架總數 (從當前顯示的商品快照)
|
||
for slot, items in sorted_grouped_items.items():
|
||
if slot in slot_stats:
|
||
on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END'])
|
||
delisted_total_count = len(items) - on_shelf_count
|
||
slot_stats[slot]['on_shelf'] = on_shelf_count
|
||
slot_stats[slot]['delisted_total'] = delisted_total_count
|
||
|
||
# V-New: 建立儀表板頁籤
|
||
promo_pages = [
|
||
{'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
|
||
{'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}
|
||
]
|
||
|
||
scheduler_stats = load_scheduler_stats()
|
||
|
||
return render_template('edm_dashboard.html',
|
||
promo_pages=promo_pages,
|
||
current_promo_page='edm',
|
||
page_title='MOMO 限時搶購',
|
||
grouped_items=sorted_grouped_items,
|
||
slot_stats=slot_stats,
|
||
total_edm_products=len(items_in_batch),
|
||
last_update=last_update_str,
|
||
activity_time=activity_time,
|
||
active_tab=active_tab,
|
||
current_batch_id=current_batch_id,
|
||
public_url=public_url,
|
||
scheduler_stats=scheduler_stats,
|
||
current_sort=sort_by,
|
||
current_order=order,
|
||
slugify=slugify)
|
||
except Exception as e:
|
||
sys_log.error(f"🚨 EDM Dashboard 渲染錯誤: {e}")
|
||
return f"系統錯誤: {e}"
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/festival')
|
||
def festival_dashboard():
|
||
"""🚩 新增:1.1 狂歡購物節專屬儀表板"""
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
|
||
PAGE_TYPE = "festival"
|
||
PAGE_NAME = "1.1狂歡購物節"
|
||
|
||
sort_by = request.args.get('sort_by', 'default')
|
||
order = request.args.get('order', 'desc')
|
||
|
||
try:
|
||
# 1. 基礎統計
|
||
last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first()
|
||
last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料"
|
||
|
||
latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first()
|
||
activity_time = getattr(latest_entry, 'activity_time_text', PAGE_NAME) if latest_entry else PAGE_NAME
|
||
|
||
# 2. 查詢資料
|
||
subq = session.query(
|
||
func.max(PromoProduct.id).label('max_id')
|
||
).filter(PromoProduct.page_type == PAGE_TYPE).group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery()
|
||
|
||
latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all()
|
||
|
||
items_in_batch = []
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
for item in latest_records:
|
||
if item.status_change == 'SLOT_END' and item.crawled_at < today_start:
|
||
continue
|
||
if item.status_change == 'DELISTED' and item.crawled_at < today_start:
|
||
continue
|
||
items_in_batch.append(item)
|
||
|
||
# 此頁面使用區塊標題作為分組依據
|
||
grouped_items = {}
|
||
for item in items_in_batch:
|
||
if item.time_slot not in grouped_items:
|
||
grouped_items[item.time_slot] = []
|
||
grouped_items[item.time_slot].append(item)
|
||
|
||
sorted_grouped_items = dict(sorted(grouped_items.items()))
|
||
|
||
# 預設顯示第一個頁籤
|
||
active_tab = next(iter(sorted_grouped_items)) if sorted_grouped_items else ""
|
||
|
||
all_icodes_in_batch = [item.i_code for item in items_in_batch]
|
||
product_categories = {}
|
||
days_on_shelf_map = {}
|
||
total_sold_map = {}
|
||
|
||
if all_icodes_in_batch:
|
||
main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all()
|
||
product_categories = {p.i_code: p.category for p in main_products}
|
||
|
||
# V-Fix: 使用 CAST 轉換為 DATE,兼容 PostgreSQL 和 SQLite
|
||
from sqlalchemy import cast, Date
|
||
days_on_shelf_q = session.query(
|
||
PromoProduct.i_code,
|
||
func.count(func.distinct(cast(PromoProduct.crawled_at, Date)))
|
||
).filter(
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.page_type == PAGE_TYPE
|
||
).group_by(PromoProduct.i_code).all()
|
||
days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q}
|
||
|
||
# 將查到的分類資訊附加到每個 item 物件上
|
||
for item in items_in_batch:
|
||
item.main_category = product_categories.get(item.i_code)
|
||
if item.main_category:
|
||
item.category_color = get_color_for_string(item.main_category)
|
||
item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1)
|
||
# V-Fix: 為 festival 頁面提供預設值,避免共用模板在渲染 total_sold 和 qty_history 時出錯
|
||
item.total_sold = 0
|
||
item.qty_history = []
|
||
|
||
# 排序邏輯
|
||
reverse = (order == 'desc')
|
||
for time_slot in sorted_grouped_items:
|
||
if sort_by == 'name':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse)
|
||
elif sort_by == 'price':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse)
|
||
else: # 預設排序
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: (
|
||
1 if x.main_category else 0,
|
||
2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0),
|
||
x.price if x.price is not None else -1
|
||
), reverse=True)
|
||
|
||
# 時段統計
|
||
slot_stats = {}
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == PAGE_TYPE).all()
|
||
|
||
slots_from_changes = {rec.time_slot for rec in today_change_records}
|
||
slots_from_display = set(sorted_grouped_items.keys())
|
||
all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display)))
|
||
|
||
for slot in all_relevant_slots:
|
||
slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0}
|
||
|
||
for rec in today_change_records:
|
||
if rec.time_slot in slot_stats:
|
||
if rec.status_change == 'NEW': slot_stats[rec.time_slot]['new'] += 1
|
||
elif rec.status_change == 'PRICE_UP': slot_stats[rec.time_slot]['up'] += 1
|
||
elif rec.status_change == 'PRICE_DOWN': slot_stats[rec.time_slot]['down'] += 1
|
||
elif rec.status_change in ['DELISTED', 'SLOT_END']: slot_stats[rec.time_slot]['delisted_last_run'] += 1
|
||
|
||
for slot, items in sorted_grouped_items.items():
|
||
if slot in slot_stats:
|
||
on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END'])
|
||
delisted_total_count = len(items) - on_shelf_count
|
||
slot_stats[slot]['on_shelf'] = on_shelf_count
|
||
slot_stats[slot]['delisted_total'] = delisted_total_count
|
||
|
||
scheduler_stats = load_scheduler_stats()
|
||
|
||
# 建立儀表板頁籤
|
||
promo_pages = [
|
||
{'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
|
||
{'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}
|
||
]
|
||
|
||
# 注意:這裡我們重複使用 edm_dashboard.html 範本
|
||
# 您需要建立一個它的複本,命名為 festival.html
|
||
return render_template('edm_dashboard.html',
|
||
promo_pages=promo_pages,
|
||
current_promo_page='festival',
|
||
page_title=PAGE_NAME,
|
||
grouped_items=sorted_grouped_items,
|
||
slot_stats=slot_stats,
|
||
total_edm_products=len(items_in_batch),
|
||
last_update=last_update_str,
|
||
activity_time=activity_time,
|
||
active_tab=active_tab,
|
||
public_url=public_url,
|
||
scheduler_stats=scheduler_stats,
|
||
current_sort=sort_by,
|
||
current_order=order,
|
||
slugify=slugify)
|
||
except Exception as e:
|
||
sys_log.error(f"🚨 {PAGE_NAME} Dashboard 渲染錯誤: {e}")
|
||
return f"系統錯誤: {e}"
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/api/export/all_categories')
|
||
def export_all_categories():
|
||
"""🚩 需求 A:處理全分類報表匯出請求"""
|
||
try:
|
||
sys_log.info("📊 執行全分類 CSV 數據導出...")
|
||
|
||
# 1. 獲取與看板一致的整合數據
|
||
items, _ = get_consolidated_data()
|
||
|
||
# 2. 呼叫匯出服務
|
||
exporter = Exporter()
|
||
file_path = exporter.generate_all_categories_report() # 此函式內部已處理按分類分 Sheet
|
||
|
||
if file_path:
|
||
# 🚩 強制轉為絕對路徑,解決 CWD 與 Flask Root Path 不一致導致的 404 問題
|
||
abs_file_path = os.path.abspath(file_path)
|
||
|
||
if os.path.exists(abs_file_path):
|
||
sys_log.info(f"✅ 報表匯出成功,準備下載: {abs_file_path}")
|
||
return send_file(abs_file_path, as_attachment=True)
|
||
|
||
return "匯出失敗:資料庫內尚無足夠數據", 404
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ 全分類報表匯出異常 | Error: {e}")
|
||
return f"匯出失敗,錯誤詳情:{e}", 500
|
||
|
||
# 🚩 V9.90: 新增 Excel 匯出路由
|
||
@app.route('/api/export/excel/all')
|
||
def export_excel_all():
|
||
try:
|
||
items, _ = get_consolidated_data()
|
||
exporter = Exporter()
|
||
file_path = exporter.generate_all_products_excel(items)
|
||
if file_path and os.path.exists(file_path):
|
||
return send_file(file_path, as_attachment=True)
|
||
return "匯出失敗", 500
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (All) | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/excel/changes')
|
||
def export_excel_changes():
|
||
try:
|
||
items, _ = get_consolidated_data()
|
||
increase = [i for i in items if i['yesterday_diff'] > 0]
|
||
decrease = [i for i in items if i['yesterday_diff'] < 0]
|
||
|
||
exporter = Exporter()
|
||
file_path = exporter.generate_changes_excel(increase, decrease)
|
||
if file_path and os.path.exists(file_path):
|
||
return send_file(file_path, as_attachment=True)
|
||
return "匯出失敗", 500
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Changes) | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/excel/delisted')
|
||
def export_excel_delisted():
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
_, today_start = get_consolidated_data()
|
||
today_delisted_query = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start.replace(tzinfo=None)
|
||
)
|
||
raw_items = today_delisted_query.all()
|
||
delisted_items = [{'product': p, 'last_price': (session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first().price if session.query(PriceRecord).filter_by(product_id=p.id).first() else 0)} for p in raw_items]
|
||
|
||
exporter = Exporter()
|
||
file_path = exporter.generate_delisted_excel(delisted_items)
|
||
return send_file(file_path, as_attachment=True)
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Delisted) | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/api/export/price_changes')
|
||
def export_price_changes():
|
||
"""V9.4 更新:匯出今日價格異動明細 (支援篩選) - 修正:改用與儀表板相同的邏輯"""
|
||
import openpyxl
|
||
from openpyxl.styles import Font, Alignment, PatternFill
|
||
|
||
filter_type = request.args.get('type', '')
|
||
filter_category = request.args.get('category', '')
|
||
|
||
try:
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
|
||
# 使用與 /api/price_change_details 相同的邏輯
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
|
||
|
||
# 基礎查詢:取得所有商品的最新記錄
|
||
latest_records_subq = session.query(
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
query = session.query(PriceRecord, Product).join(
|
||
latest_records_subq,
|
||
PriceRecord.id == latest_records_subq.c.max_id
|
||
).join(Product, PriceRecord.product_id == Product.id)
|
||
|
||
# 一次性查詢所有商品的「今日之前最後價格」
|
||
product_ids = [r[0] for r in session.query(PriceRecord.product_id).join(
|
||
latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id
|
||
).all()]
|
||
|
||
yesterday_prices_subq = session.query(
|
||
PriceRecord.product_id,
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).filter(
|
||
PriceRecord.product_id.in_(product_ids),
|
||
PriceRecord.timestamp < today_start
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
yesterday_prices_q = session.query(
|
||
PriceRecord.product_id, PriceRecord.price
|
||
).join(
|
||
yesterday_prices_subq,
|
||
PriceRecord.id == yesterday_prices_subq.c.max_id
|
||
)
|
||
yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q}
|
||
|
||
products = []
|
||
|
||
# 根據 filter_type 篩選
|
||
if filter_type == 'increase':
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price > old_price:
|
||
products.append((product, record, old_price))
|
||
|
||
elif filter_type == 'decrease':
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price < old_price:
|
||
products.append((product, record, old_price))
|
||
|
||
elif filter_type == 'delisted':
|
||
today_delisted = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start
|
||
).all()
|
||
for product in today_delisted:
|
||
last_record = session.query(PriceRecord).filter(
|
||
PriceRecord.product_id == product.id
|
||
).order_by(PriceRecord.timestamp.desc()).first()
|
||
if last_record:
|
||
products.append((product, last_record, last_record.price))
|
||
|
||
elif filter_type == 'active':
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append((product, record, old_price))
|
||
|
||
elif filter_type == 'category' and filter_category:
|
||
for record, product in query.filter(Product.category == filter_category).all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append((product, record, old_price))
|
||
|
||
else:
|
||
# 預設:所有變動商品
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append((product, record, old_price))
|
||
|
||
session.close()
|
||
|
||
if not products:
|
||
return "無符合條件的商品資料", 404
|
||
|
||
# 建立 Excel
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "價格變動明細"
|
||
|
||
# 標題列
|
||
headers = ['商品ID', '商品名稱', '分類', '原價格', '現價格', '變動金額', '變動百分比', '更新時間', '商品網址']
|
||
ws.append(headers)
|
||
|
||
# 設定標題列樣式
|
||
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
|
||
header_font = Font(bold=True, color='FFFFFF')
|
||
for cell in ws[1]:
|
||
cell.fill = header_fill
|
||
cell.font = header_font
|
||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||
|
||
# 填充資料
|
||
for product, record, old_price in products:
|
||
change = record.price - old_price
|
||
change_pct = (change / old_price * 100) if old_price > 0 else 0
|
||
ws.append([
|
||
product.i_code,
|
||
product.name,
|
||
product.category or '未分類',
|
||
old_price,
|
||
record.price,
|
||
change,
|
||
f"{change_pct:.2f}%",
|
||
record.timestamp.strftime('%Y-%m-%d %H:%M'),
|
||
product.url
|
||
])
|
||
|
||
# 調整欄寬
|
||
ws.column_dimensions['A'].width = 12
|
||
ws.column_dimensions['B'].width = 40
|
||
ws.column_dimensions['C'].width = 15
|
||
ws.column_dimensions['D'].width = 12
|
||
ws.column_dimensions['E'].width = 12
|
||
ws.column_dimensions['F'].width = 12
|
||
ws.column_dimensions['G'].width = 12
|
||
ws.column_dimensions['H'].width = 18
|
||
ws.column_dimensions['I'].width = 50
|
||
|
||
# 儲存檔案
|
||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
filename = f"價格變動明細_{filter_type or 'all'}_{timestamp}.xlsx"
|
||
filepath = os.path.join(EXCEL_EXPORT_DIR, filename)
|
||
|
||
os.makedirs(EXCEL_EXPORT_DIR, exist_ok=True)
|
||
wb.save(filepath)
|
||
|
||
return send_file(filepath, as_attachment=True, download_name=filename)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ 異動報表匯出失敗 | Type: {filter_type} | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/low_prices')
|
||
def export_low_prices():
|
||
"""🚩 新增:匯出歷史低價商品"""
|
||
try:
|
||
exporter = Exporter()
|
||
file_path = exporter.generate_low_price_report()
|
||
|
||
if file_path and os.path.exists(file_path):
|
||
return send_file(file_path, as_attachment=True)
|
||
return "目前無歷史低價商品", 404
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ 低價報表匯出失敗 | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/changes')
|
||
def export_changes():
|
||
"""🚩 需求:匯出篩選後的資料 (漲/跌/下架)"""
|
||
filter_type = request.args.get('type')
|
||
exporter = Exporter()
|
||
file_path = None
|
||
|
||
try:
|
||
unique_items, today_start = get_consolidated_data()
|
||
|
||
if filter_type == 'increase':
|
||
target_items = [i for i in unique_items if i['yesterday_diff'] > 0]
|
||
file_path = exporter.generate_custom_report(target_items, "今日漲價商品")
|
||
elif filter_type == 'decrease':
|
||
target_items = [i for i in unique_items if i['yesterday_diff'] < 0]
|
||
file_path = exporter.generate_custom_report(target_items, "今日跌價商品")
|
||
elif filter_type == 'delisted':
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
today_start_naive = today_start.replace(tzinfo=None)
|
||
today_delisted_query = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start_naive
|
||
)
|
||
raw_delisted_items = today_delisted_query.all()
|
||
|
||
delisted_items_with_price = []
|
||
|
||
# 定義模擬物件 (移至迴圈外以提升效率)
|
||
class MockRecord:
|
||
def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at
|
||
|
||
for p in raw_delisted_items:
|
||
last_rec = session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first()
|
||
price = last_rec.price if last_rec else 0
|
||
delisted_items_with_price.append({'product': p, 'last_price': price})
|
||
|
||
file_path = exporter.generate_delisted_report(delisted_items_with_price, "今日下架商品")
|
||
finally:
|
||
session.close()
|
||
|
||
if file_path and os.path.exists(file_path):
|
||
return send_file(file_path, as_attachment=True)
|
||
return "無資料可匯出", 404
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ 篩選匯出失敗 | Type: {filter_type} | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/excel/abc')
|
||
def export_abc_analysis():
|
||
"""API: 匯出 ABC 分析報表 (Excel)"""
|
||
try:
|
||
db = DatabaseManager()
|
||
table_name = 'realtime_sales_monthly'
|
||
|
||
# 1. 嘗試從快取讀取資料 (與 sales_analysis 共用快取)
|
||
df = None
|
||
cols_map = {}
|
||
|
||
if table_name in _SALES_PROCESSED_CACHE:
|
||
cache_data = _SALES_PROCESSED_CACHE[table_name]
|
||
df = cache_data['df']
|
||
cols_map = cache_data['cols']
|
||
else:
|
||
return "請先瀏覽「業績分析」頁面以載入資料與快取。", 400
|
||
|
||
# 恢復欄位變數
|
||
col_name = cols_map.get('name')
|
||
col_amount = cols_map.get('amount')
|
||
col_qty = cols_map.get('qty')
|
||
col_category = cols_map.get('category')
|
||
col_brand = cols_map.get('brand')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_activity = cols_map.get('activity')
|
||
col_payment = cols_map.get('payment')
|
||
col_price = cols_map.get('price')
|
||
col_cost = cols_map.get('cost')
|
||
col_profit = cols_map.get('profit')
|
||
col_date = cols_map.get('date')
|
||
col_pid = cols_map.get('pid') # V-New: 取得商品ID欄位
|
||
|
||
# 2. 篩選資料 (複製 sales_analysis 的篩選邏輯以確保結果一致)
|
||
selected_category = request.args.get('category', 'all')
|
||
selected_brand = request.args.get('brand', 'all')
|
||
selected_vendor = request.args.get('vendor', 'all')
|
||
selected_activity = request.args.get('activity', 'all')
|
||
selected_payment = request.args.get('payment', 'all')
|
||
selected_dow = request.args.get('dow', 'all')
|
||
selected_hour = request.args.get('hour', 'all')
|
||
selected_month = request.args.get('month', 'all')
|
||
keyword = request.args.get('keyword', '').strip()
|
||
min_price = request.args.get('min_price', '')
|
||
max_price = request.args.get('max_price', '')
|
||
min_margin = request.args.get('min_margin', '')
|
||
max_margin = request.args.get('max_margin', '')
|
||
|
||
target_df = df.copy() # 複製一份以免修改到快取
|
||
|
||
# 重新計算 Top N 分類 (用於 '其他' 篩選)
|
||
TOP_N_CATS = 12
|
||
top_cats_names = []
|
||
if col_category:
|
||
cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False)
|
||
if len(cat_group_all) > TOP_N_CATS:
|
||
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
|
||
|
||
if selected_category != 'all' and col_category:
|
||
if selected_category == '其他' and top_cats_names:
|
||
target_df = target_df[~target_df[col_category].isin(top_cats_names)]
|
||
else:
|
||
target_df = target_df[target_df[col_category] == selected_category]
|
||
|
||
if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand]
|
||
if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor]
|
||
if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity]
|
||
if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment]
|
||
if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)]
|
||
if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)]
|
||
if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month]
|
||
if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)]
|
||
if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)]
|
||
if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)]
|
||
if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)]
|
||
if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)]
|
||
|
||
# 3. 執行 ABC 分析與匯出
|
||
if col_amount and not target_df.empty:
|
||
# V-Fix: 同步 abc_analysis_detail 的聚合邏輯,確保匯出數據與網頁一致
|
||
agg_rules = {col_amount: 'sum'}
|
||
if col_qty: agg_rules[col_qty] = 'sum'
|
||
if col_cost: agg_rules[col_cost] = 'sum'
|
||
if col_profit: agg_rules[col_profit] = 'sum'
|
||
if col_category: agg_rules[col_category] = 'first'
|
||
if col_vendor: agg_rules[col_vendor] = 'first'
|
||
if col_brand: agg_rules[col_brand] = 'first'
|
||
if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID
|
||
|
||
# 執行聚合
|
||
df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index()
|
||
|
||
# 重新計算聚合後的毛利率
|
||
if col_profit:
|
||
df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
|
||
elif col_cost:
|
||
df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
|
||
else:
|
||
df_agg['calculated_margin_rate'] = 0.0
|
||
df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
|
||
|
||
# 排序與 ABC 分類
|
||
target_df = df_agg.sort_values(by=col_amount, ascending=False)
|
||
target_df['cumulative_revenue'] = target_df[col_amount].cumsum()
|
||
total_revenue = target_df[col_amount].sum()
|
||
target_df['cumulative_pct'] = (target_df['cumulative_revenue'] / total_revenue) * 100
|
||
|
||
conditions = [(target_df['cumulative_pct'] <= 80), (target_df['cumulative_pct'] <= 95)]
|
||
choices = ['A', 'B']
|
||
target_df['ABC_Class'] = np.select(conditions, choices, default='C')
|
||
|
||
# V-New: 支援依類別篩選匯出 (例如只匯出 A 類)
|
||
filter_class = request.args.get('class')
|
||
if filter_class:
|
||
target_df = target_df[target_df['ABC_Class'] == filter_class]
|
||
|
||
# V-New: 計算平均單價 (Avg Unit Price)
|
||
if col_qty:
|
||
target_df['avg_unit_price'] = (target_df[col_amount] / target_df[col_qty]).fillna(0)
|
||
|
||
# V-New: 計算建議補貨量 (支援自訂係數)
|
||
if col_qty:
|
||
custom_factor = request.args.get('factor')
|
||
if custom_factor:
|
||
try:
|
||
factor = float(custom_factor)
|
||
# 若有指定係數,則全體套用 (通常用於單一類別匯出)
|
||
target_df['suggested_restock'] = (target_df[col_qty] * factor).astype(int)
|
||
except:
|
||
# 格式錯誤則回退至預設邏輯
|
||
conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')]
|
||
choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2]
|
||
target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int)
|
||
else:
|
||
# 預設邏輯 (A=1.5, B=1.2, C=0)
|
||
conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')]
|
||
choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2]
|
||
target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int)
|
||
|
||
# 整理匯出欄位
|
||
export_cols = []
|
||
header_map = {}
|
||
if col_pid: export_cols.append(col_pid); header_map[col_pid] = '商品ID' # V-New: 匯出商品ID
|
||
if col_name: export_cols.append(col_name); header_map[col_name] = '商品名稱'
|
||
if col_category: export_cols.append(col_category); header_map[col_category] = '分類'
|
||
if col_brand: export_cols.append(col_brand); header_map[col_brand] = '品牌'
|
||
if col_vendor: export_cols.append(col_vendor); header_map[col_vendor] = '廠商'
|
||
export_cols.append('ABC_Class'); header_map['ABC_Class'] = 'ABC分類'
|
||
if col_amount: export_cols.append(col_amount); header_map[col_amount] = '銷售金額'
|
||
if col_qty: export_cols.append(col_qty); header_map[col_qty] = '銷售數量'
|
||
# V-Fix: 移除 col_price 匯出,因為聚合後的資料表不包含原始單價欄位 (已由 avg_unit_price 取代)
|
||
if 'avg_unit_price' in target_df.columns:
|
||
export_cols.append('avg_unit_price'); header_map['avg_unit_price'] = '平均單價'
|
||
if col_cost: export_cols.append(col_cost); header_map[col_cost] = '成本'
|
||
if col_profit: export_cols.append(col_profit); header_map[col_profit] = '毛利'
|
||
if 'calculated_margin_rate' in target_df.columns:
|
||
export_cols.append('calculated_margin_rate'); header_map['calculated_margin_rate'] = '毛利率(%)'
|
||
if 'suggested_restock' in target_df.columns:
|
||
export_cols.append('suggested_restock')
|
||
header_map['suggested_restock'] = '建議補貨量'
|
||
|
||
export_df = target_df[export_cols].rename(columns=header_map)
|
||
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
export_df.to_excel(writer, index=False, sheet_name='ABC分析')
|
||
output.seek(0)
|
||
|
||
filename_prefix = f"ABC_Analysis_{filter_class}_" if filter_class else "ABC_Analysis_"
|
||
return send_file(output, as_attachment=True, download_name=f"{filename_prefix}{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||
|
||
return "無資料可匯出", 404
|
||
except Exception as e:
|
||
sys_log.error(f"ABC Export Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/excel/vendor')
|
||
def export_vendor_analysis():
|
||
"""API: 匯出廠商獲利能力排行 (Excel)"""
|
||
try:
|
||
db = DatabaseManager()
|
||
table_name = 'realtime_sales_monthly'
|
||
|
||
# 1. 嘗試從快取讀取資料
|
||
df = None
|
||
cols_map = {}
|
||
|
||
if table_name in _SALES_PROCESSED_CACHE:
|
||
cache_data = _SALES_PROCESSED_CACHE[table_name]
|
||
df = cache_data['df']
|
||
cols_map = cache_data['cols']
|
||
else:
|
||
# V-Fix: 快取失效時,重定向到 sales_analysis 以重新載入資料
|
||
params = {k: v for k, v in request.args.items()}
|
||
flash('資料快取已失效,請稍候重新載入資料後再匯出。', 'warning')
|
||
return redirect(url_for('sales_analysis', **params))
|
||
|
||
# 恢復欄位變數
|
||
col_name = cols_map.get('name')
|
||
col_amount = cols_map.get('amount')
|
||
col_qty = cols_map.get('qty')
|
||
col_category = cols_map.get('category')
|
||
col_brand = cols_map.get('brand')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_activity = cols_map.get('activity')
|
||
col_payment = cols_map.get('payment')
|
||
col_price = cols_map.get('price')
|
||
col_cost = cols_map.get('cost')
|
||
col_profit = cols_map.get('profit')
|
||
col_date = cols_map.get('date')
|
||
|
||
if not col_vendor:
|
||
return "無法識別廠商欄位,無法匯出。", 400
|
||
|
||
# 2. 篩選資料 (複製 sales_analysis 的篩選邏輯)
|
||
selected_category = request.args.get('category', 'all')
|
||
selected_brand = request.args.get('brand', 'all')
|
||
selected_vendor = request.args.get('vendor', 'all')
|
||
selected_activity = request.args.get('activity', 'all')
|
||
selected_payment = request.args.get('payment', 'all')
|
||
selected_dow = request.args.get('dow', 'all')
|
||
selected_hour = request.args.get('hour', 'all')
|
||
selected_month = request.args.get('month', 'all')
|
||
keyword = request.args.get('keyword', '').strip()
|
||
min_price = request.args.get('min_price', '')
|
||
max_price = request.args.get('max_price', '')
|
||
min_margin = request.args.get('min_margin', '')
|
||
max_margin = request.args.get('max_margin', '')
|
||
|
||
target_df = df.copy()
|
||
|
||
# Top N 分類處理
|
||
TOP_N_CATS = 12
|
||
top_cats_names = []
|
||
if col_category:
|
||
cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False)
|
||
if len(cat_group_all) > TOP_N_CATS:
|
||
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
|
||
|
||
if selected_category != 'all' and col_category:
|
||
if selected_category == '其他' and top_cats_names:
|
||
target_df = target_df[~target_df[col_category].isin(top_cats_names)]
|
||
else:
|
||
target_df = target_df[target_df[col_category] == selected_category]
|
||
|
||
if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand]
|
||
if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor]
|
||
if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity]
|
||
if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment]
|
||
if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)]
|
||
if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)]
|
||
if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month]
|
||
if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)]
|
||
if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)]
|
||
if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)]
|
||
if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)]
|
||
if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)]
|
||
|
||
# 3. 執行廠商聚合
|
||
if col_amount and not target_df.empty:
|
||
agg_dict = {col_amount: 'sum', col_name: 'nunique'}
|
||
if col_qty: agg_dict[col_qty] = 'sum' # V-Fix: 加入銷量聚合,否則無法計算 ASP
|
||
if col_profit:
|
||
agg_dict[col_profit] = 'sum'
|
||
elif col_cost:
|
||
agg_dict[col_cost] = 'sum'
|
||
|
||
vendor_group = target_df.groupby(col_vendor).agg(agg_dict).reset_index()
|
||
|
||
if col_profit:
|
||
vendor_group['total_profit'] = vendor_group[col_profit]
|
||
elif col_cost:
|
||
vendor_group['total_profit'] = vendor_group[col_amount] - vendor_group[col_cost]
|
||
else:
|
||
vendor_group['total_profit'] = 0
|
||
|
||
# V-Fix: 計算營收佔比 (Share %)
|
||
total_vendor_revenue = vendor_group[col_amount].sum()
|
||
vendor_group['revenue_share'] = (vendor_group[col_amount] / total_vendor_revenue * 100)
|
||
|
||
vendor_group['margin_rate'] = np.where(vendor_group[col_amount] > 0, (vendor_group['total_profit'] / vendor_group[col_amount] * 100), 0)
|
||
|
||
# V-Fix: 計算平均客單價 (ASP)
|
||
if col_qty:
|
||
vendor_group['asp'] = np.where(vendor_group[col_qty] > 0, vendor_group[col_amount] / vendor_group[col_qty], 0)
|
||
|
||
vendor_group['avg_sku_revenue'] = np.where(vendor_group[col_name] > 0, vendor_group[col_amount] / vendor_group[col_name], 0)
|
||
vendor_group = vendor_group.sort_values(by=col_amount, ascending=False)
|
||
|
||
# V-Fix: 更新匯出欄位以匹配儀表板
|
||
export_cols = [col_vendor, col_amount, 'revenue_share']
|
||
header_map = {col_vendor: '廠商名稱', col_amount: '總業績', 'revenue_share': '佔比(%)'}
|
||
|
||
if col_qty:
|
||
export_cols.extend([col_qty, 'asp'])
|
||
header_map.update({col_qty: '總銷量', 'asp': '平均客單(ASP)'})
|
||
|
||
export_cols.extend(['total_profit', 'margin_rate', col_name, 'avg_sku_revenue'])
|
||
header_map.update({'total_profit': '毛利額', 'margin_rate': '毛利率(%)', col_name: '商品數(SKU)', 'avg_sku_revenue': '平均單品產值'})
|
||
|
||
export_df = vendor_group[export_cols].rename(columns=header_map)
|
||
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
export_df.to_excel(writer, index=False, sheet_name='廠商排行')
|
||
output.seek(0)
|
||
return send_file(output, as_attachment=True, download_name=f"Vendor_Ranking_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||
return "無資料可匯出", 404
|
||
except Exception as e:
|
||
sys_log.error(f"Vendor Export Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/abc_analysis/detail')
|
||
def abc_analysis_detail():
|
||
"""ABC 分析詳細報表頁面"""
|
||
try:
|
||
target_class = request.args.get('class', 'A') # 預設 A 類
|
||
table_name = 'realtime_sales_monthly'
|
||
|
||
# 1. 生成與主頁面一致的 cache_key
|
||
data_range_months = int(request.args.get('data_range', '0') or '0')
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
|
||
# 2. 使用共用篩選函式取得資料
|
||
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
|
||
|
||
# V-Fix: 如果 cache_key 不存在,嘗試後補使用 table_name 固定鍵值
|
||
if err and table_name in _SALES_PROCESSED_CACHE:
|
||
target_df, cols_map, err = _get_filtered_sales_data(table_name)
|
||
|
||
if err:
|
||
# V-Fix: 如果自動重載也失敗,則顯示稍後再試,並引導回主頁面
|
||
return f'''
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>數據加載中 - WOOO TECH</title>
|
||
<style>
|
||
body {{ font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f8f9fa; }}
|
||
.card {{ background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); text-align: center; }}
|
||
.spinner {{ border: 3px solid #f3f3f3; border-top: 3px solid #1e3c72; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 0 auto 1rem; }}
|
||
@keyframes spin {{ 0% {{ transform: rotate(0deg); }} 100% {{ transform: rotate(360deg); }} }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<div class="spinner"></div>
|
||
<h3>數據準備中</h3>
|
||
<p>正在自動重新加載數據,請稍後...</p>
|
||
<script>
|
||
// 1.5 秒後嘗試重載當前頁面
|
||
setTimeout(function() {{
|
||
window.location.reload();
|
||
}}, 1500);
|
||
|
||
// 若重試 3 次仍失敗,引導回主頁
|
||
let retryCount = parseInt(sessionStorage.getItem('abc_retry') || '0');
|
||
if (retryCount > 3) {{
|
||
sessionStorage.removeItem('abc_retry');
|
||
alert('數據載入過久,請先在業績分析主頁重新整理。');
|
||
window.location.href = '/sales_analysis';
|
||
}} else {{
|
||
sessionStorage.setItem('abc_retry', retryCount + 1);
|
||
}}
|
||
</script>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
''', 200
|
||
|
||
# 恢復欄位變數
|
||
col_name = cols_map.get('name')
|
||
col_amount = cols_map.get('amount')
|
||
col_qty = cols_map.get('qty')
|
||
col_category = cols_map.get('category')
|
||
col_brand = cols_map.get('brand')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_price = cols_map.get('price')
|
||
col_cost = cols_map.get('cost')
|
||
col_profit = cols_map.get('profit')
|
||
col_date = cols_map.get('date')
|
||
col_pid = cols_map.get('pid')
|
||
|
||
|
||
# 3. 執行 ABC 分類
|
||
items = []
|
||
total_revenue = 0
|
||
if col_amount and not target_df.empty:
|
||
# V-Fix: 先針對商品進行聚合,確保 ABC 分析是基於「商品總銷量」而非「單筆訂單」
|
||
agg_rules = {col_amount: 'sum'}
|
||
if col_qty: agg_rules[col_qty] = 'sum'
|
||
if col_cost: agg_rules[col_cost] = 'sum'
|
||
if col_profit: agg_rules[col_profit] = 'sum'
|
||
if col_category: agg_rules[col_category] = 'first'
|
||
if col_vendor: agg_rules[col_vendor] = 'first'
|
||
if col_brand: agg_rules[col_brand] = 'first' # V-New: 加入品牌
|
||
if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID
|
||
if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique()))
|
||
|
||
df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index()
|
||
|
||
# 重新計算聚合後的毛利率
|
||
if col_profit:
|
||
df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
|
||
elif col_cost:
|
||
df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
|
||
else:
|
||
df_agg['calculated_margin_rate'] = 0.0
|
||
df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
|
||
|
||
# 執行 ABC 排序與計算
|
||
df_agg = df_agg.sort_values(by=col_amount, ascending=False)
|
||
df_agg['cumulative_revenue'] = df_agg[col_amount].cumsum()
|
||
total_revenue = df_agg[col_amount].sum()
|
||
df_agg['cumulative_pct'] = (df_agg['cumulative_revenue'] / total_revenue) * 100
|
||
|
||
conditions = [(df_agg['cumulative_pct'] <= 80), (df_agg['cumulative_pct'] <= 95)]
|
||
choices = ['A', 'B']
|
||
df_agg['ABC_Class'] = np.select(conditions, choices, default='C')
|
||
|
||
# 4. 篩選特定類別
|
||
class_df = df_agg[df_agg['ABC_Class'] == target_class].copy()
|
||
|
||
# V-New: 計算平均單價與庫存建議
|
||
if col_qty:
|
||
class_df['avg_unit_price'] = (class_df[col_amount] / class_df[col_qty]).fillna(0)
|
||
|
||
# V-New: 處理動態補貨係數
|
||
custom_factor = request.args.get('factor')
|
||
current_factor = 0.0
|
||
|
||
if custom_factor:
|
||
try:
|
||
current_factor = float(custom_factor)
|
||
except:
|
||
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
|
||
else:
|
||
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
|
||
|
||
class_df['suggested_restock'] = (class_df[col_qty] * current_factor).astype(int)
|
||
|
||
items = class_df.to_dict('records')
|
||
|
||
# 準備標題與描述
|
||
class_info = {
|
||
'A': {'title': 'A 類 - 核心商品', 'desc': '營收佔比前 80% 的主力商品,建議重點備貨與監控。', 'color': 'danger'},
|
||
'B': {'title': 'B 類 - 次要商品', 'desc': '營收佔比 80%~95% 的輔助商品,維持正常庫存。', 'color': 'warning'},
|
||
'C': {'title': 'C 類 - 長尾商品', 'desc': '營收佔比最後 5% 的長尾商品,建議評估清倉或縮減 SKU。', 'color': 'success'}
|
||
}
|
||
info = class_info.get(target_class, {'title': f'{target_class} 類', 'desc': '', 'color': 'secondary'})
|
||
|
||
# 計算 DataTables 預設排序欄位 (銷售金額) 的索引
|
||
# 欄位順序: Rank(0), [PID], Name, [Brand], [Vendor], [Cat], [Margin], [AvgPrice, Qty, Restock], Amount
|
||
sort_col_index = 1 # Rank
|
||
if col_pid: sort_col_index += 1
|
||
sort_col_index += 1 # Name
|
||
if col_brand: sort_col_index += 1
|
||
if col_vendor: sort_col_index += 1
|
||
if col_category: sort_col_index += 1
|
||
if col_cost or col_profit: sort_col_index += 1
|
||
if col_qty: sort_col_index += 3
|
||
# 此時 sort_col_index 即為 Amount 欄位的索引
|
||
|
||
return render_template('abc_analysis_detail.html',
|
||
items=items,
|
||
info=info,
|
||
target_class=target_class,
|
||
current_factor=current_factor, # V-New: 傳遞當前係數
|
||
total_revenue=total_revenue,
|
||
sort_col_index=sort_col_index, # V-New: 傳遞排序欄位索引
|
||
cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category,
|
||
'vendor': col_vendor, 'brand': col_brand, 'cost': col_cost, 'profit': col_profit, 'date': col_date, 'pid': col_pid},
|
||
# 傳遞當前查詢參數以供匯出連結使用
|
||
query_string=request.query_string.decode())
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"ABC Detail Error: {e}")
|
||
return f"系統錯誤: {e}"
|
||
|
||
@app.route('/logs')
|
||
def show_logs():
|
||
return render_template('logs.html')
|
||
|
||
@app.route('/api/run_task', methods=['POST'])
|
||
@login_required
|
||
def trigger_task():
|
||
try:
|
||
client_ip = request.remote_addr
|
||
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動執行請求 | IP: {client_ip}")
|
||
scheduled_job_wrapper()
|
||
return jsonify({"status": "success", "message": "爬蟲任務已在背景啟動"})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Task] ❌ 手動觸發任務失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/run_edm_task', methods=['POST'])
|
||
@login_required
|
||
def trigger_edm_task():
|
||
"""🚩 新增:手動觸發 EDM 爬蟲任務"""
|
||
try:
|
||
target_lpn = "O1K5FBOqsvN" # 預設活動代碼
|
||
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 EDM 執行請求 | LPN: {target_lpn}")
|
||
|
||
# V-Fix: 強制重載 scheduler 模組,確保讀取到最新的截圖與通知邏輯
|
||
import importlib
|
||
import scheduler
|
||
importlib.reload(scheduler)
|
||
|
||
# 使用執行緒啟動,避免卡住 Web Server
|
||
task_thread = threading.Thread(target=scheduler.run_edm_task, args=(target_lpn,))
|
||
task_thread.daemon = True
|
||
task_thread.start()
|
||
|
||
return jsonify({"status": "success", "message": f"EDM 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Task] ❌ 手動觸發 EDM 任務失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/run_festival_task', methods=['POST'])
|
||
def trigger_festival_task():
|
||
"""🚩 新增:手動觸發 1.1 狂歡購物節爬蟲任務"""
|
||
try:
|
||
target_lpn = "O7ylWfihYUM"
|
||
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 Festival 執行請求 | LPN: {target_lpn}")
|
||
|
||
# 使用執行緒啟動,避免卡住 Web Server
|
||
task_thread = threading.Thread(target=run_festival_task, args=(target_lpn,))
|
||
task_thread.daemon = True
|
||
task_thread.start()
|
||
|
||
return jsonify({"status": "success", "message": f"Festival 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Task] ❌ 手動觸發 Festival 任務失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/trigger_momo_notification', methods=['POST'])
|
||
@login_required
|
||
def trigger_momo_notification():
|
||
"""🚩 新增:手動觸發商品看板通知"""
|
||
try:
|
||
# 強制重載通知模組
|
||
import importlib
|
||
import scheduler
|
||
import services.notification_manager
|
||
importlib.reload(scheduler)
|
||
importlib.reload(services.notification_manager)
|
||
from services.notification_manager import NotificationManager
|
||
|
||
# 1. 取得統計數據
|
||
stats = get_dashboard_stats()
|
||
|
||
# 2. 截取儀表板畫面
|
||
dashboard_url = "http://127.0.0.1/"
|
||
screenshot_path = scheduler.capture_page_screenshot(dashboard_url, "momo_dashboard")
|
||
|
||
# 3. 發送通知
|
||
notifier = NotificationManager()
|
||
sys_log.info(f"[Web] [Notification] 📢 手動觸發 MOMO 通知")
|
||
notifier.send_momo_report(stats, screenshot_path)
|
||
|
||
return jsonify({"status": "success", "message": "已發送商品看板通知"})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/trigger_edm_notification', methods=['POST'])
|
||
def trigger_edm_notification():
|
||
"""🚩 新增:手動觸發 EDM 比價通知 (不重爬,僅重發)"""
|
||
try:
|
||
# V-Fix: 強制重新載入設定與通知模組,確保讀取到最新的 LINE ID (避免快取舊資料)
|
||
import importlib
|
||
import config
|
||
import services.notification_manager
|
||
import services.edm_notifier # V-New: 導入新的通知模組
|
||
importlib.reload(config)
|
||
importlib.reload(services.notification_manager)
|
||
importlib.reload(services.edm_notifier)
|
||
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
# V-Fix: 改為只抓取最新一批次的異動資料,避免訊息過長
|
||
# 1. 找出最新的 batch_id
|
||
latest_batch_tuple = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
|
||
|
||
if not latest_batch_tuple:
|
||
return jsonify({"status": "warning", "message": "目前無 EDM 商品資料,請先執行爬蟲"}), 400
|
||
|
||
latest_batch_id = latest_batch_tuple[0]
|
||
|
||
# 2. 取得最新批次的所有異動商品
|
||
products = session.query(PromoProduct).filter(PromoProduct.batch_id == latest_batch_id).all()
|
||
|
||
if not products:
|
||
return jsonify({"status": "info", "message": "最新一輪掃描中無任何商品異動"}), 200
|
||
|
||
# V-Fix: 手動觸發時,嘗試尋找對應的截圖檔案
|
||
screenshot_path = None
|
||
try:
|
||
filename = f"edm_{latest_batch_id}.png"
|
||
potential_path = os.path.join(BASE_DIR, 'web/static/screenshots', filename)
|
||
if os.path.exists(potential_path):
|
||
screenshot_path = potential_path
|
||
except Exception: pass
|
||
|
||
from services.edm_notifier import EdmNotifier
|
||
notifier = EdmNotifier()
|
||
sys_log.info(f"[Web] [Notification] 📢 手動觸發 EDM 通知 | Count: {len(products)} | BatchID: {latest_batch_id}")
|
||
notifier.send_edm_report(products, screenshot_path)
|
||
|
||
return jsonify({"status": "success", "message": f"已針對最新批次的 {len(products)} 筆商品異動發送通知"})
|
||
finally:
|
||
session.close()
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/test_notification', methods=['POST'])
|
||
def test_notification():
|
||
"""🚩 新增:測試訊息通知功能"""
|
||
try:
|
||
from services.notification_manager import NotificationManager
|
||
import config
|
||
import requests
|
||
notifier = NotificationManager()
|
||
|
||
# --- 🕵️♂️ V9.13 更新:Messaging API 診斷邏輯 ---
|
||
sys_log.info("[Web] [Notification] 🕵️♂️ 執行手動通知發送測試 (Line/Telegram/Email)...")
|
||
|
||
token = getattr(config, 'LINE_CHANNEL_ACCESS_TOKEN', None)
|
||
target_id = getattr(config, 'LINE_GROUP_ID', None)
|
||
|
||
if token and target_id:
|
||
sys_log.info(f"[Web] [Notification] 🔑 偵測到 Channel Token: {token[:4]}...{token[-4:]}")
|
||
sys_log.info(f"[Web] [Notification] 🎯 目標 ID: {target_id}")
|
||
|
||
# 2. 嘗試直接發送請求
|
||
try:
|
||
headers = {
|
||
"Authorization": f"Bearer {token}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
payload = {
|
||
"to": target_id,
|
||
"messages": [
|
||
{
|
||
"type": "text",
|
||
"text": "🧪 這是系統診斷測試訊息 (Messaging API)\n\n✅ 連線測試成功!"
|
||
}
|
||
]
|
||
}
|
||
|
||
sys_log.info("[Web] [Notification] 📡 正在嘗試連線至 Line Messaging API (push)...")
|
||
resp = requests.post("https://api.line.me/v2/bot/message/push", headers=headers, json=payload, timeout=10)
|
||
|
||
sys_log.info(f"[Web] [Notification] 📩 Line API 回應 | Code: {resp.status_code}")
|
||
sys_log.info(f"[Web] [Notification] 📄 Line API 內容 | Body: {resp.text}")
|
||
|
||
if resp.status_code != 200:
|
||
return jsonify({"status": "error", "message": f"❌ Line API 拒絕連線: {resp.status_code} - {resp.text}"}), 400
|
||
except Exception as req_err:
|
||
sys_log.error(f"[Web] [Notification] ❌ 直接連線測試發生異常 | Error: {req_err}")
|
||
return jsonify({"status": "error", "message": f"連線異常: {req_err}"}), 500
|
||
else:
|
||
sys_log.warning("[Web] [Notification] ⚠️ 無法偵測到 Messaging API 設定 (Token 或 Group ID 缺失)")
|
||
return jsonify({"status": "error", "message": "設定檔缺少 LINE_CHANNEL_ACCESS_TOKEN 或 LINE_GROUP_ID"}), 400
|
||
|
||
# 🚩 V9.14 修改:呼叫真實的日報發送邏輯
|
||
notifier.send_daily_report()
|
||
|
||
return jsonify({"status": "success", "message": "✅ 當日異動通知已發送 (Line/Telegram/Email)"})
|
||
except ImportError:
|
||
return jsonify({"status": "error", "message": "❌ 找不到 NotificationManager 模組"}), 500
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Notification] ❌ 測試通知失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": f"發送失敗: {str(e)}"}), 500
|
||
|
||
@app.route('/api/logs')
|
||
def get_logs_api():
|
||
if os.path.exists(LOG_FILE_PATH):
|
||
try:
|
||
with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f:
|
||
return jsonify({"logs": "".join(f.readlines()[-60:])})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Logs] ❌ 日誌 API 讀取異常 | Error: {e}")
|
||
return jsonify({"logs": "讀取日誌異常"})
|
||
return jsonify({"logs": "等待系統啟動中..."})
|
||
|
||
# 🚩 V9.82: 新增歷史價格 API
|
||
@app.route('/api/history/<int:product_id>')
|
||
def get_price_history(product_id):
|
||
"""API: 取得商品過去 180 天的價格歷史"""
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
# 計算 180 天前的日期
|
||
start_date = datetime.now(TAIPEI_TZ).replace(tzinfo=None) - timedelta(days=180)
|
||
|
||
records = session.query(PriceRecord).filter(
|
||
PriceRecord.product_id == product_id,
|
||
PriceRecord.timestamp >= start_date
|
||
).order_by(PriceRecord.timestamp).all()
|
||
|
||
data = [{
|
||
't': r.timestamp.strftime('%Y-%m-%d %H:%M'),
|
||
'p': r.price
|
||
} for r in records]
|
||
|
||
return jsonify(data)
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [History] ❌ 獲取歷史價格失敗 | ProductID: {product_id} | Error: {e}")
|
||
return jsonify([]), 500
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/api/price_change_details')
|
||
def get_price_change_details():
|
||
"""API: V9.4 取得價格變動商品明細 (供彈窗使用) - 修正:改用與儀表板相同的邏輯"""
|
||
filter_type = request.args.get('type', '')
|
||
filter_category = request.args.get('category', '')
|
||
filter_product_id = request.args.get('product_id', '')
|
||
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
# 取得今日起始時間
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
|
||
|
||
# 基礎查詢:取得所有商品的最新記錄 (與儀表板相同邏輯)
|
||
latest_records_subq = session.query(
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
query = session.query(PriceRecord, Product).join(
|
||
latest_records_subq,
|
||
PriceRecord.id == latest_records_subq.c.max_id
|
||
).join(Product, PriceRecord.product_id == Product.id)
|
||
|
||
# 一次性查詢所有商品的「今日之前最後價格」(yesterday_prices_map)
|
||
product_ids = [r[0] for r in session.query(PriceRecord.product_id).join(
|
||
latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id
|
||
).all()]
|
||
|
||
yesterday_prices_subq = session.query(
|
||
PriceRecord.product_id,
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).filter(
|
||
PriceRecord.product_id.in_(product_ids),
|
||
PriceRecord.timestamp < today_start
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
yesterday_prices_q = session.query(
|
||
PriceRecord.product_id, PriceRecord.price
|
||
).join(
|
||
yesterday_prices_subq,
|
||
PriceRecord.id == yesterday_prices_subq.c.max_id
|
||
)
|
||
yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q}
|
||
|
||
# 根據 filter_type 進行篩選
|
||
products = []
|
||
|
||
if filter_type == 'increase':
|
||
# 漲價商品 - 比對今日最新價格與今日之前的最後價格
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price > old_price:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': old_price,
|
||
'current_price': record.price,
|
||
'change': record.price - old_price,
|
||
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
|
||
elif filter_type == 'decrease':
|
||
# 降價商品
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price < old_price:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': old_price,
|
||
'current_price': record.price,
|
||
'change': record.price - old_price,
|
||
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
|
||
elif filter_type == 'delisted':
|
||
# 下架商品 (今日狀態為 INACTIVE 且今天更新的)
|
||
today_delisted = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start
|
||
).all()
|
||
|
||
for product in today_delisted:
|
||
last_record = session.query(PriceRecord).filter(
|
||
PriceRecord.product_id == product.id
|
||
).order_by(PriceRecord.timestamp.desc()).first()
|
||
|
||
if last_record:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': last_record.price,
|
||
'current_price': 0,
|
||
'change': 0,
|
||
'update_time': last_record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
|
||
elif filter_type == 'active':
|
||
# 活躍商品 (今日有價格變動的)
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': old_price,
|
||
'current_price': record.price,
|
||
'change': record.price - old_price,
|
||
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
|
||
elif filter_type == 'category' and filter_category:
|
||
# 特定分類的變動商品
|
||
for record, product in query.filter(Product.category == filter_category).all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': old_price,
|
||
'current_price': record.price,
|
||
'change': record.price - old_price,
|
||
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
|
||
elif filter_type == 'max_change' and filter_product_id:
|
||
# 最大變動商品 - 只顯示指定的單一商品
|
||
for record, product in query.filter(Product.i_code == filter_product_id).all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': old_price,
|
||
'current_price': record.price,
|
||
'change': record.price - old_price,
|
||
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
break # 只需要一件商品
|
||
|
||
return jsonify({'products': products})
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [PriceChangeDetails] ❌ 獲取價格變動明細失敗 | Type: {filter_type} | Error: {e}")
|
||
return jsonify({'products': []}), 500
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/api/backup', methods=['POST'])
|
||
@login_required
|
||
def trigger_backup():
|
||
"""API: 觸發系統完整備份"""
|
||
# Note: [功能] 尚未實作「系統還原」功能 (Restore),需評估安全性後加入
|
||
try:
|
||
sys_log.info("[System] [Backup] 💾 開始執行系統完整備份...")
|
||
backup_dir = os.path.join(BASE_DIR, 'backups')
|
||
if not os.path.exists(backup_dir):
|
||
os.makedirs(backup_dir)
|
||
|
||
timestamp = datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')
|
||
zip_filename = f"momo_system_backup_{SYSTEM_VERSION}_{timestamp}.zip"
|
||
zip_filepath = os.path.join(backup_dir, zip_filename)
|
||
|
||
with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||
for root, dirs, files in os.walk(BASE_DIR):
|
||
# 排除不必要的目錄
|
||
dirs[:] = [d for d in dirs if d not in ['backups', '__pycache__', 'venv', '.git', '.idea', '.vscode', 'node_modules']]
|
||
|
||
for file in files:
|
||
if file == zip_filename: continue # 跳過正在寫入的檔案
|
||
if file.endswith('.pyc') or file.endswith('.DS_Store'): continue
|
||
|
||
file_path = os.path.join(root, file)
|
||
arcname = os.path.relpath(file_path, BASE_DIR)
|
||
zipf.write(file_path, arcname)
|
||
|
||
sys_log.info(f"[System] [Backup] ✅ 系統備份完成 | File: {zip_filename}")
|
||
|
||
# V-New: 回傳下載連結
|
||
download_url = url_for('download_backup', filename=zip_filename)
|
||
|
||
return jsonify({
|
||
"status": "success",
|
||
"message": f"備份成功!\n檔案已儲存為: {zip_filename}\n即將開始下載...",
|
||
"download_url": download_url
|
||
})
|
||
except Exception as e:
|
||
sys_log.error(f"[System] [Backup] ❌ 備份失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/backup/download/<path:filename>')
|
||
@login_required
|
||
def download_backup(filename):
|
||
"""
|
||
API: 下載備份檔案(已加入路徑遍歷防護)
|
||
"""
|
||
try:
|
||
backup_dir = os.path.join(BASE_DIR, 'backups')
|
||
# 使用 safe_join 驗證路徑,防止路徑遍歷攻擊
|
||
safe_path = safe_join(backup_dir, filename)
|
||
|
||
# 確保檔案存在
|
||
if not safe_path.exists():
|
||
sys_log.warning(f"[Security] 備份檔案不存在 | File: {filename}")
|
||
return jsonify({'error': '檔案不存在'}), 404
|
||
|
||
# 確保是檔案而非目錄
|
||
if not safe_path.is_file():
|
||
sys_log.warning(f"[Security] 嘗試下載非檔案路徑 | Path: {filename}")
|
||
return jsonify({'error': '非法路徑'}), 400
|
||
|
||
return send_from_directory(backup_dir, safe_path.name, as_attachment=True)
|
||
|
||
except ValueError as e:
|
||
# safe_join 偵測到路徑遍歷嘗試
|
||
sys_log.error(f"[Security] 路徑遍歷攻擊嘗試被阻擋 | Filename: {filename} | Error: {e}")
|
||
return jsonify({'error': '非法路徑'}), 400
|
||
except Exception as e:
|
||
sys_log.error(f"[System] 下載備份失敗 | Error: {e}")
|
||
return jsonify({'error': '下載失敗'}), 500
|
||
|
||
@app.route('/api/import_excel', methods=['POST'])
|
||
@login_required
|
||
def import_excel():
|
||
"""
|
||
API: 匯入 Excel/CSV 並自動建表
|
||
已加入檔案上傳安全驗證 (副檔名白名單、檔案名稱清理)
|
||
"""
|
||
try:
|
||
# 1. 檢查是否有上傳檔案
|
||
if 'file' not in request.files:
|
||
return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400
|
||
|
||
file = request.files['file']
|
||
|
||
# 2. 使用安全驗證函數
|
||
is_valid, error_msg, safe_name = validate_upload_file(file)
|
||
if not is_valid:
|
||
sys_log.warning(f"[Security] 檔案上傳驗證失敗 | Filename: {file.filename} | Error: {error_msg}")
|
||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||
|
||
sys_log.info(f"[Web] [Import] 檔案上傳驗證通過 | Original: {file.filename} | Safe: {safe_name}")
|
||
|
||
# 3. 根據副檔名讀取檔案
|
||
df = None
|
||
filename_lower = safe_name.lower()
|
||
|
||
if filename_lower.endswith(('.xlsx', '.xls')):
|
||
try:
|
||
df = pd.read_excel(file, engine='openpyxl', dtype=str)
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500
|
||
elif filename_lower.endswith('.csv'):
|
||
try:
|
||
# V-New: 嘗試用多種編碼讀取 CSV
|
||
try:
|
||
df = pd.read_csv(file, dtype=str)
|
||
except UnicodeDecodeError:
|
||
file.seek(0) # 重置文件指針
|
||
df = pd.read_csv(file, encoding='big5', dtype=str)
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': f'CSV 讀取失敗: {str(e)}'}), 500
|
||
else:
|
||
# 理論上不會到這裡,因為 validate_upload_file 已經檢查過
|
||
return jsonify({'status': 'error', 'message': '不支援的檔案格式'}), 400
|
||
|
||
if df is None:
|
||
return jsonify({'status': 'error', 'message': '無法讀取檔案內容'}), 500
|
||
|
||
# V-New: 增加日誌以確認目前為原始匯入模式 (提醒使用者已略過清理)
|
||
sys_log.info("[Web] [Import] ⚠️ 偵測到原始匯入模式 (Raw Import Mode) - 已略過智慧清理")
|
||
|
||
# V-Fix: 1. 先標準化欄位名稱,確保後續關鍵字比對準確
|
||
# df.columns = [str(c).strip().replace(' ', '_').replace('-', '_').replace('(', '').replace(')', '').replace('/', '_') for c in df.columns]
|
||
|
||
# V-Fix: 2. 執行智慧資料清理 (v3 保守模式 - 解決 'F' 被強制轉 0 的問題)
|
||
# sys_log.info("[Web] [Import] 執行智慧資料清理程序 (v3 保守模式)...")
|
||
|
||
# 定義必須是數值的欄位關鍵字 (這些欄位必須是數字,髒資料轉 0 以免影響計算)
|
||
# numeric_keywords = ['序號', '數量', '單價', '金額', '成本', '毛利', '售價', '應收', '營收',
|
||
# 'Quantity', 'Qty', 'Price', 'Amount', 'Cost', 'Profit', 'Sales', 'Revenue']
|
||
|
||
# for col in df.columns:
|
||
# # 判斷是否為強制數值欄位
|
||
# is_force_numeric = any(k in col for k in numeric_keywords)
|
||
|
||
# if df[col].dtype == 'object':
|
||
# if is_force_numeric:
|
||
# # 策略 A: 強制數值欄位 -> 激進清理 (保留數字,其餘轉 0)
|
||
# # 先移除千分位逗號等非數值字符
|
||
# cleaned_series = df[col].astype(str).str.replace(r'[^\d.-]', '', regex=True)
|
||
# converted_series = pd.to_numeric(cleaned_series, errors='coerce')
|
||
# df[col] = converted_series.fillna(0)
|
||
# sys_log.info(f"[Web] [Import] 強制清理數值欄位 '{col}' (髒資料已轉為 0)")
|
||
# else:
|
||
# # 策略 B: 一般欄位 -> 保守檢查 (保留 'F' 等文字)
|
||
# # 直接嘗試轉換,不移除文字
|
||
# converted_series = pd.to_numeric(df[col], errors='coerce')
|
||
|
||
# # 檢查有多少值變成了 NaN (原本不是 NaN/空字串,但轉換後變成 NaN 的)
|
||
# original_valid_mask = df[col].notna() & (df[col].astype(str).str.strip() != '')
|
||
# converted_valid_mask = converted_series.notna()
|
||
# loss_count = (original_valid_mask & ~converted_valid_mask).sum()
|
||
|
||
# if loss_count == 0:
|
||
# # 如果沒有資料損失 (代表全是數字或空值),才轉換
|
||
# df[col] = converted_series
|
||
# else:
|
||
# # 有資料損失 (例如包含 'F'),保留為文字
|
||
# sys_log.info(f"[Web] [Import] 欄位 '{col}' 保留為文字 (含 {loss_count} 筆非數值資料,如 'F')")
|
||
|
||
# 識別檔案類型
|
||
is_daily_sales = '即時業績' in file.filename and '當日' in file.filename
|
||
is_sales_report = '即時業績' in file.filename and '全月' in file.filename
|
||
|
||
if is_daily_sales:
|
||
table_name = 'daily_sales_snapshot'
|
||
|
||
# V-New: 智慧匯入 - 根據 Excel 內的日期欄位自動拆分 snapshot_date
|
||
date_col = None
|
||
for possible_col in ['日期', '訂單日期', '交易日期', 'Date']:
|
||
if possible_col in df.columns:
|
||
date_col = possible_col
|
||
break
|
||
|
||
if date_col:
|
||
# 使用 Excel 內的日期欄位作為 snapshot_date
|
||
sys_log.info(f"[Web] [Import] 使用 Excel 內的「{date_col}」欄位作為快照日期")
|
||
|
||
# 將日期欄位轉換為標準格式 YYYY-MM-DD
|
||
df['snapshot_date'] = pd.to_datetime(df[date_col], errors='coerce').dt.strftime('%Y-%m-%d')
|
||
|
||
# 移除無效日期的資料
|
||
invalid_count = df['snapshot_date'].isna().sum()
|
||
if invalid_count > 0:
|
||
sys_log.warning(f"[Web] [Import] 發現 {invalid_count} 筆無效日期資料,已移除")
|
||
df = df.dropna(subset=['snapshot_date'])
|
||
|
||
unique_dates = df['snapshot_date'].nunique()
|
||
sys_log.info(f"[Web] [Import] 識別為當日業績報表,包含 {unique_dates} 個不同日期")
|
||
else:
|
||
# 備用方案:從檔名提取日期
|
||
snapshot_date = extract_snapshot_date_from_filename(file.filename)
|
||
if not snapshot_date:
|
||
return jsonify({'status': 'error', 'message': '無法從檔名提取日期,且 Excel 中無日期欄位'}), 400
|
||
df['snapshot_date'] = snapshot_date
|
||
sys_log.info(f"[Web] [Import] Excel 無日期欄位,使用檔名日期: {snapshot_date}")
|
||
elif is_sales_report:
|
||
table_name = 'realtime_sales_monthly'
|
||
else:
|
||
filename_no_ext = os.path.splitext(file.filename)[0]
|
||
table_name = re.sub(r'[^\w\u4e00-\u9fff]+', '_', filename_no_ext).strip('_')
|
||
|
||
if not table_name: table_name = f"import_{int(time.time())}"
|
||
|
||
# S4 \u5b89\u5168\u4fee\u5fa9\uff1atable_name \u767d\u540d\u55ae\u9a57\u8b49\uff0c\u9632\u6b62 SQL Injection
|
||
# \u5141\u8a31\uff1a\u5b57\u6bcd\u3001\u6578\u5b57\u3001\u5e95\u7dda\u3001\u4e2d\u6587\u5b57\uff08\u4e0d\u5141\u8a31\u7a7a\u683c\u3001\u5f15\u865f\u3001\u6ce8\u5165\u7b26\u865f\uff09
|
||
import re as _re_sec
|
||
if not _re_sec.match(r'^[\w\u4e00-\u9fff]+$', table_name):
|
||
sys_log.error(f"[Web] [Import] \u274c \u975e\u6cd5\u8cc7\u6599\u8868\u540d\u7a31\u88ab\u62d2\u7d55\uff1a{table_name!r}")
|
||
return jsonify({'status': 'error', 'message': '\u975e\u6cd5\u7684\u8cc7\u6599\u8868\u540d\u7a31\uff0c\u532f\u5165\u4e2d\u6b62\u3002'}), 400
|
||
|
||
db = DatabaseManager()
|
||
engine = db.engine
|
||
|
||
# V-Debug: 顯示實際寫入的資料庫路徑
|
||
sys_log.info(f"[Web] [Import] 正在寫入資料庫: {engine.url}")
|
||
|
||
if table_name in ['realtime_sales_monthly', 'daily_sales_snapshot']:
|
||
try:
|
||
# V-Fix: 實作自動去重邏輯 (Deduplication)
|
||
# 1. 檢查資料表是否存在
|
||
inspector = inspect(engine)
|
||
if not inspector.has_table(table_name):
|
||
sys_log.info(f"[Web] [Import] 資料表不存在,建立新表: {table_name}")
|
||
df.to_sql(table_name, con=engine, if_exists='replace', index=False)
|
||
rows_imported = len(df)
|
||
message = f'匯入成功!已建立新資料表並寫入 {rows_imported} 筆資料。'
|
||
else:
|
||
sys_log.info(f"[Web] [Import] 資料表已存在,執行自動去重 (Deduplication)...")
|
||
|
||
# 2. 讀取現有資料(優化:僅讀取相關日期的資料以進行去重)
|
||
try:
|
||
# 嘗試根據 incoming df 的日期範圍來過濾現有資料
|
||
filter_clause = ""
|
||
if '日期' in df.columns:
|
||
# V-Fix: 確保日期格式與資料庫一致 (YYYY/MM/DD) 以便 SQL IN 查詢能正確比對
|
||
# 有時 Pandas 會將其轉換為 datetime 或 2024-01-01 格式
|
||
temp_dates = pd.to_datetime(df['日期'], errors='coerce')
|
||
unique_dates = temp_dates.dropna().dt.strftime('%Y/%m/%d').unique()
|
||
|
||
if len(unique_dates) > 0:
|
||
# S4 修復:格式驗證日期值(YYYY/MM/DD 格式,防止注入)
|
||
safe_dates = [str(d) for d in unique_dates if _re_sec.match(r'^[\d/\-: ]+$', str(d))]
|
||
if safe_dates:
|
||
filter_clause = ("日期", safe_dates)
|
||
sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(safe_dates)} 個日期相關的現有資料 (範例: {safe_dates[0] if safe_dates else 'N/A'})")
|
||
elif 'snapshot_date' in df.columns:
|
||
unique_dates = df['snapshot_date'].dropna().unique()
|
||
if len(unique_dates) > 0:
|
||
# S4 修復:date 值來自 DataFrame,仍做格式驗證防止隱式注入
|
||
safe_dates = [str(d) for d in unique_dates if _re_sec.match(r'^[\d/\-: ]+$', str(d))]
|
||
if safe_dates:
|
||
filter_clause = ("snapshot_dates", safe_dates)
|
||
sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(safe_dates)} 個快照日期相關的現有資料")
|
||
|
||
if filter_clause:
|
||
# S4 修復:使用安全的 IN 查詢(table_name 已白名單驗證,dates 格式已驗證)
|
||
if isinstance(filter_clause, tuple):
|
||
col_name, date_vals = filter_clause
|
||
placeholders = ",".join([f"'{d}'" for d in date_vals])
|
||
df_existing = pd.read_sql(
|
||
f'SELECT * FROM {table_name} WHERE {col_name} IN ({placeholders})',
|
||
con=engine
|
||
)
|
||
else:
|
||
df_existing = pd.read_sql(f"SELECT * FROM {table_name}{filter_clause}", con=engine)
|
||
else:
|
||
# 備用方案:若無日期欄位,仍讀取全表
|
||
sys_log.warning(f"[Web] [Import] ⚠️ 無法根據日期過濾,讀取全表進行去重 (可能效能較差)")
|
||
df_existing = safe_read_sql(table_name, engine=engine)
|
||
|
||
except Exception as e:
|
||
sys_log.warning(f"[Web] [Import] ⚠️ 讀取舊資料失敗 ({e}),略過去重直接累加。")
|
||
df_existing = pd.DataFrame()
|
||
|
||
rows_to_write = df
|
||
|
||
if not df_existing.empty:
|
||
# 3. 執行比對 (找出共有欄位)
|
||
common_cols = list(set(df.columns) & set(df_existing.columns))
|
||
|
||
# 針對 daily_sales_snapshot 使用特定去重鍵
|
||
if table_name == 'daily_sales_snapshot':
|
||
# 優先使用 snapshot_date + 訂單編號
|
||
if 'snapshot_date' in common_cols and '訂單編號' in common_cols:
|
||
common_cols = ['snapshot_date', '訂單編號']
|
||
sys_log.info(f"[Web] [Import] 使用去重鍵: snapshot_date + 訂單編號")
|
||
elif 'snapshot_date' in common_cols:
|
||
# 備用方案:使用所有共有欄位
|
||
sys_log.info(f"[Web] [Import] 使用全欄位去重 (共 {len(common_cols)} 個欄位)")
|
||
|
||
if common_cols:
|
||
# 轉換為字串以確保比對準確 (處理 NaN 與型別差異)
|
||
# V-Fix: 加強去重邏輯,處理 '100.0' vs '100' 的問題
|
||
def normalize_series(s):
|
||
return s.astype(str).str.strip().str.replace(r'\.0$', '', regex=True)
|
||
|
||
df_str = df[common_cols].apply(normalize_series).fillna('')
|
||
existing_str = df_existing[common_cols].apply(normalize_series).fillna('')
|
||
|
||
# 移除 df_existing 中的重複項 (優化 merge 效能)
|
||
existing_str = existing_str.drop_duplicates()
|
||
|
||
# 使用 merge 找出 df 中已存在的資料
|
||
merged = df_str.merge(existing_str, on=common_cols, how='left', indicator=True)
|
||
|
||
# 只保留 'left_only' 的資料 (即新資料)
|
||
rows_to_write = df[merged['_merge'] == 'left_only']
|
||
|
||
duplicates_count = len(df) - len(rows_to_write)
|
||
sys_log.info(f"[Web] [Import] 🔍 自動去重: 發現 {duplicates_count} 筆重複資料,已忽略。")
|
||
|
||
# 4. 寫入新資料
|
||
if not rows_to_write.empty:
|
||
rows_to_write.to_sql(table_name, con=engine, if_exists='append', index=False)
|
||
rows_imported = len(rows_to_write)
|
||
message = f'匯入成功!已去重並新增 {rows_imported} 筆資料。'
|
||
else:
|
||
rows_imported = 0
|
||
message = '匯入完成,但所有資料皆已存在 (重複),無新增數據。'
|
||
|
||
# V-Fix: 無條件清除快取,確保行事曆能夠顯示最新資料
|
||
# 原問題:只有 rows_imported > 0 時才清除快取,導致匯入後行事曆不更新
|
||
if table_name in _SALES_DF_CACHE:
|
||
del _SALES_DF_CACHE[table_name]
|
||
sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}")
|
||
|
||
# V-Opt: 清除所有相關的處理後快取(包含不同 data_range 的快取)
|
||
cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)]
|
||
for cache_key in cache_keys_to_delete:
|
||
del _SALES_PROCESSED_CACHE[cache_key]
|
||
sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}")
|
||
|
||
return jsonify({'status': 'success', 'message': message, 'rows': rows_imported, 'table': table_name})
|
||
|
||
except Exception as de:
|
||
sys_log.error(f"[Web] [Import] 業績報表匯入去重或寫入時發生錯誤: {de}")
|
||
return jsonify({'status': 'error', 'message': f'業績報表匯入失敗: {de}'}), 500
|
||
else:
|
||
# 對於非業績報表,維持覆蓋邏輯
|
||
sys_log.info(f"[Web] [Import] 使用覆蓋模式 (replace)寫入資料表: {table_name}")
|
||
df.to_sql(table_name, con=engine, if_exists='replace', index=False)
|
||
|
||
if table_name in _SALES_DF_CACHE:
|
||
del _SALES_DF_CACHE[table_name]
|
||
sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}")
|
||
|
||
# V-Opt: 清除所有相關的處理後快取
|
||
cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)]
|
||
for cache_key in cache_keys_to_delete:
|
||
del _SALES_PROCESSED_CACHE[cache_key]
|
||
sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}")
|
||
|
||
return jsonify({'status': 'success', 'message': f'通用匯入成功!資料已覆蓋至 {table_name}。', 'rows': len(df), 'table': table_name})
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Import] ❌ 檔案匯入發生嚴重錯誤 | Error: {str(e)}")
|
||
return jsonify({'status': 'error', 'message': f'檔案匯入失敗: {str(e)}'}), 500
|
||
|
||
@app.route('/api/import/monthly_summary', methods=['POST'])
|
||
def import_monthly_summary():
|
||
"""API: 匯入月份總表數據分析"""
|
||
try:
|
||
if 'file' not in request.files:
|
||
return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400
|
||
|
||
file = request.files['file']
|
||
is_valid, error_msg, safe_name = validate_upload_file(file)
|
||
if not is_valid:
|
||
sys_log.warning(f"[Security] 月份總表上傳驗證失敗: {error_msg}")
|
||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||
|
||
# 讀取 Excel
|
||
try:
|
||
df = pd.read_excel(file, engine='openpyxl')
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500
|
||
|
||
if df.empty:
|
||
return jsonify({'status': 'error', 'message': '檔案內容為空'}), 400
|
||
|
||
# 欄位對照表 (對應 Excel 繁體中文標題與資料庫英文欄位)
|
||
mapping = {
|
||
'年': 'year', '月': 'month', '商品部': 'department', '3C百貨': 'category_3c',
|
||
'處別': 'division', '科別': 'section', '區ID': 'area_id', '區名稱': 'area_name',
|
||
'商品_PM': 'pm_name', '品牌名稱_合併': 'brand_name', '廠商編號': 'vendor_id',
|
||
'廠商名稱': 'vendor_name', '借採轉': 'trade_type', '件單價': 'unit_price',
|
||
'銷售額_本月': 'sales_amt_curr', '銷售額_上月': 'sales_amt_prev', '銷售額_去年同期': 'sales_amt_yoa',
|
||
'毛1額_本月': 'profit_amt_curr', '毛1額_上月': 'profit_amt_prev', '毛1額_去年同期': 'profit_amt_yoa',
|
||
'折扣金額_本月': 'discount_amt_curr', '折扣金額_上月': 'discount_amt_prev', '折扣金額_去年同期': 'discount_amt_yoa',
|
||
'折價券_本月': 'coupon_amt_curr', '折價券_上月': 'coupon_amt_prev', '折價券_去年同期': 'coupon_amt_yoa',
|
||
'其他行銷活動_本月': 'other_mkt_curr', '其他行銷活動_上月': 'other_mkt_prev', '其他行銷活動_去年同期': 'other_mkt_yoa',
|
||
'點我折_本月': 'spot_disc_curr', '點我折_上月': 'spot_disc_prev', '點我折_去年同期': 'spot_disc_yoa',
|
||
'點數折抵_本月': 'point_disc_curr', '點數折抵_上月': 'point_disc_prev', '點數折抵_去年同期': 'point_disc_yoa',
|
||
'銷售量_本月': 'sales_vol_curr', '銷售量_上月': 'sales_vol_prev', '銷售量_去年同期': 'sales_vol_yoa',
|
||
'轉換率': 'conv_rate', '瀏覽數_本月': 'views_curr', '瀏覽數_上月': 'views_prev', '瀏覽數_去年同期': 'views_yoa'
|
||
}
|
||
|
||
# 檢查必備欄位 (寬鬆檢查:只要有 mapping 中的欄位就匯入)
|
||
current_cols = df.columns.tolist()
|
||
import_mapping = {k: v for k, v in mapping.items() if k in current_cols}
|
||
|
||
if len(import_mapping) < 5: # 至少要有幾個維度
|
||
return jsonify({'status': 'error', 'message': '檔案欄位不符,請確認是否為正確的月份業績總表'}), 400
|
||
|
||
# 重新命名與清理資料
|
||
target_df = df[list(import_mapping.keys())].rename(columns=import_mapping)
|
||
|
||
# 轉換數值欄位,填補 NaN
|
||
numeric_cols = [v for k, v in import_mapping.items() if v not in [
|
||
'department', 'category_3c', 'division', 'section', 'area_id', 'area_name',
|
||
'pm_name', 'brand_name', 'vendor_name', 'trade_type'
|
||
]]
|
||
for col in numeric_cols:
|
||
target_df[col] = pd.to_numeric(target_df[col], errors='coerce').fillna(0)
|
||
|
||
# 寫入資料庫 - 優化效能版本 (Phase 9 Optimization)
|
||
db = DatabaseManager()
|
||
engine = db.engine
|
||
|
||
try:
|
||
# 取得要匯入的年月份,用於先行刪除重複資料
|
||
years_months = target_df[['year', 'month']].drop_duplicates()
|
||
|
||
with engine.begin() as conn:
|
||
# 1. 刪除該月份舊資料 (Transaction 開始)
|
||
for _, row in years_months.iterrows():
|
||
conn.execute(text("DELETE FROM monthly_summary_analysis WHERE year = :y AND month = :m"),
|
||
{'y': int(row['year']), 'm': int(row['month'])})
|
||
|
||
# 2. 批量寫入 (使用 multi 方法加速,SQLite chunksize 建議 2000 避免參數過多)
|
||
# 比照 realtime_sales_monthly 的優化方式
|
||
target_df.to_sql('monthly_summary_analysis',
|
||
con=conn,
|
||
if_exists='append',
|
||
index=False,
|
||
chunksize=2000,
|
||
method='multi')
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Import] 匯入資料庫失敗: {e}")
|
||
raise e
|
||
|
||
|
||
sys_log.info(f"[Web] [Import] 🚀 月份總表資料匯入成功 | 筆數: {len(target_df)}")
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': f'成功匯入 {len(target_df)} 筆分析數據。',
|
||
'rows': len(target_df)
|
||
})
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Import] ❌ 月份總表匯入嚴重失敗: {str(e)}")
|
||
return jsonify({'status': 'error', 'message': f'匯入失敗: {str(e)}'}), 500
|
||
|
||
@app.route('/monthly_summary_analysis')
|
||
def monthly_summary_analysis_page():
|
||
"""月份總表數據分析展示頁 (Phase 9)"""
|
||
return render_template('monthly_summary_analysis.html',
|
||
datetime_now=datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'),
|
||
system_version=SYSTEM_VERSION)
|
||
|
||
@app.route('/api/monthly_summary_data')
|
||
def get_monthly_summary_data():
|
||
"""API: 取得月份總表數據與分析指標 (Phase 9)"""
|
||
year = request.args.get('year', type=int)
|
||
month = request.args.get('month', type=int)
|
||
division = request.args.get('division')
|
||
pm_name = request.args.get('pm_name')
|
||
brand_name = request.args.get('brand_name')
|
||
vendor_name = request.args.get('vendor')
|
||
area_name = request.args.get('area_name')
|
||
trade_type = request.args.get('trade_type')
|
||
limit = request.args.get('limit', default=1000, type=int)
|
||
|
||
# DEBUG LOGGING
|
||
import logging
|
||
debug_logger = logging.getLogger('app')
|
||
debug_logger.info(f"🔍 [API Debug] Request Args: {request.args}")
|
||
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
# 基礎查詢
|
||
query = session.query(MonthlySummaryAnalysis)
|
||
|
||
# 套用過濾
|
||
if year: query = query.filter(MonthlySummaryAnalysis.year == year)
|
||
if month: query = query.filter(MonthlySummaryAnalysis.month == month)
|
||
if division: query = query.filter(MonthlySummaryAnalysis.division == division)
|
||
if pm_name: query = query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
|
||
if brand_name: query = query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
|
||
if vendor_name: query = query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
|
||
if area_name:
|
||
if ',' in area_name:
|
||
query = query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
|
||
else:
|
||
query = query.filter(MonthlySummaryAnalysis.area_name == area_name)
|
||
if trade_type: query = query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
|
||
|
||
# 取得統計數據 (KPIs)
|
||
kpi_query = session.query(
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('total_sales'),
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_prev).label('total_sales_prev'),
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('total_sales_yoa'),
|
||
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('total_profit'),
|
||
func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('total_vol'),
|
||
func.sum(MonthlySummaryAnalysis.views_curr).label('total_views')
|
||
)
|
||
|
||
# 同樣套用過濾到 KPI
|
||
if year: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.year == year)
|
||
if month: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.month == month)
|
||
if division: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.division == division)
|
||
if pm_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
|
||
if brand_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
|
||
if vendor_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
|
||
if area_name:
|
||
if ',' in area_name:
|
||
kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
|
||
else:
|
||
kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name == area_name)
|
||
if trade_type: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
|
||
|
||
kpi_res = kpi_query.one()
|
||
|
||
# 取得總筆數與月數
|
||
total_rows = session.query(func.count(MonthlySummaryAnalysis.id))
|
||
total_months_query = session.query(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).distinct()
|
||
|
||
if year:
|
||
total_rows = total_rows.filter(MonthlySummaryAnalysis.year == year)
|
||
total_months_query = total_months_query.filter(MonthlySummaryAnalysis.year == year)
|
||
if month:
|
||
total_rows = total_rows.filter(MonthlySummaryAnalysis.month == month)
|
||
|
||
total_rows = total_rows.scalar()
|
||
total_months = total_months_query.count()
|
||
|
||
# 取得趨勢數據 (按月加總)
|
||
trend_query = session.query(
|
||
MonthlySummaryAnalysis.year,
|
||
MonthlySummaryAnalysis.month,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
|
||
).group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
|
||
|
||
if division: trend_query = trend_query.filter(MonthlySummaryAnalysis.division == division)
|
||
if pm_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
|
||
if brand_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
|
||
if vendor_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
|
||
if area_name:
|
||
if ',' in area_name:
|
||
trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
|
||
else:
|
||
trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name == area_name)
|
||
if trade_type: trend_query = trend_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
|
||
|
||
# 取得排行榜 (Top 10 Brands)
|
||
rank_query = session.query(
|
||
MonthlySummaryAnalysis.brand_name,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
|
||
).group_by(MonthlySummaryAnalysis.brand_name)
|
||
|
||
if year: rank_query = rank_query.filter(MonthlySummaryAnalysis.year == year)
|
||
if month: rank_query = rank_query.filter(MonthlySummaryAnalysis.month == month)
|
||
if division: rank_query = rank_query.filter(MonthlySummaryAnalysis.division == division)
|
||
if pm_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
|
||
if brand_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
|
||
if vendor_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
|
||
if area_name:
|
||
if ',' in area_name:
|
||
rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
|
||
else:
|
||
rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name == area_name)
|
||
if trade_type: rank_query = rank_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
|
||
|
||
rank_query = rank_query.order_by(desc('sales')).limit(10)
|
||
|
||
# 取得明細資料
|
||
rows_query = query.order_by(
|
||
MonthlySummaryAnalysis.year.desc(),
|
||
MonthlySummaryAnalysis.month.desc(),
|
||
MonthlySummaryAnalysis.sales_amt_curr.desc()
|
||
).limit(limit)
|
||
|
||
# --- 📊 V-New: 進階分析子查詢 (Phase 17) ---
|
||
def apply_filters(q, ignore_year=False):
|
||
if year and not ignore_year: q = q.filter(MonthlySummaryAnalysis.year == year)
|
||
if month: q = q.filter(MonthlySummaryAnalysis.month == month)
|
||
if division: q = q.filter(MonthlySummaryAnalysis.division == division)
|
||
if pm_name: q = q.filter(MonthlySummaryAnalysis.pm_name == pm_name)
|
||
if brand_name: q = q.filter(MonthlySummaryAnalysis.brand_name == brand_name)
|
||
if vendor_name: q = q.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
|
||
if area_name:
|
||
if ',' in area_name:
|
||
q = q.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
|
||
else:
|
||
q = q.filter(MonthlySummaryAnalysis.area_name == area_name)
|
||
if trade_type: q = q.filter(MonthlySummaryAnalysis.trade_type == trade_type)
|
||
return q
|
||
|
||
# 廠商排行
|
||
# 廠商排行 (Top 20, 分年度)
|
||
# 廠商排行 (Top 20, 分年度)
|
||
vendor_rank_q = session.query(
|
||
MonthlySummaryAnalysis.vendor_name,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025'),
|
||
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2024'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2025'),
|
||
).group_by(MonthlySummaryAnalysis.vendor_name)
|
||
|
||
vendor_rank_q = apply_filters(vendor_rank_q, ignore_year=True)
|
||
vendor_rank_q = vendor_rank_q.order_by(desc('sales')).limit(20)
|
||
|
||
# 分類分佈 (按 Division, Top 12, 分年度)
|
||
div_dist_q = session.query(
|
||
MonthlySummaryAnalysis.division,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
|
||
).group_by(MonthlySummaryAnalysis.division)
|
||
|
||
div_dist_q = apply_filters(div_dist_q, ignore_year=True)
|
||
div_dist_q = div_dist_q.order_by(desc('sales')).limit(12)
|
||
|
||
# 價格帶貢獻 (分年度)
|
||
price_cont_q = session.query(
|
||
case(
|
||
(MonthlySummaryAnalysis.unit_price < 500, '0-499'),
|
||
(MonthlySummaryAnalysis.unit_price < 1000, '500-999'),
|
||
(MonthlySummaryAnalysis.unit_price < 2000, '1,000-1,999'),
|
||
(MonthlySummaryAnalysis.unit_price < 5000, '2,000-4,999'),
|
||
(MonthlySummaryAnalysis.unit_price < 10000, '5,000-9,999'),
|
||
else_='10,000+'
|
||
).label('price_range'),
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
|
||
).group_by('price_range')
|
||
price_cont_q = apply_filters(price_cont_q, ignore_year=True)
|
||
|
||
# BCG 矩陣 (品牌 x 區域)
|
||
bcg_q = session.query(
|
||
MonthlySummaryAnalysis.brand_name,
|
||
MonthlySummaryAnalysis.area_name,
|
||
func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('vol'),
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
|
||
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit')
|
||
).group_by(MonthlySummaryAnalysis.brand_name, MonthlySummaryAnalysis.area_name)\
|
||
.having(func.sum(MonthlySummaryAnalysis.sales_amt_curr) > 0)
|
||
|
||
bcg_q = apply_filters(bcg_q)
|
||
bcg_q = bcg_q.order_by(desc('sales')).limit(100)
|
||
|
||
# 熱力圖 (月份 x 分類)
|
||
# 熱力圖 (月份 x 分類)
|
||
# 為了保持一致性,這裡我們應該只取 Top 12 的 Division
|
||
# 先取得 Top 12 Division 的名稱列表
|
||
top_12_divs = [r.division for r in div_dist_q.all()]
|
||
|
||
heatmap_q = session.query(
|
||
MonthlySummaryAnalysis.year,
|
||
MonthlySummaryAnalysis.month,
|
||
MonthlySummaryAnalysis.division,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
|
||
).filter(MonthlySummaryAnalysis.division.in_(top_12_divs))\
|
||
.group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month, MonthlySummaryAnalysis.division)\
|
||
.order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
|
||
heatmap_q = apply_filters(heatmap_q, ignore_year=True)
|
||
|
||
# Highlights (Top 3)
|
||
def get_highlights_q(metric_col):
|
||
q = session.query(MonthlySummaryAnalysis.brand_name, func.sum(metric_col).label('val'))
|
||
q = apply_filters(q)
|
||
q = q.group_by(MonthlySummaryAnalysis.brand_name).order_by(desc('val')).limit(3)
|
||
return q
|
||
|
||
rev_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_amt_curr)
|
||
profit_top_q = get_highlights_q(MonthlySummaryAnalysis.profit_amt_curr)
|
||
vol_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_vol_curr)
|
||
|
||
# 區域排行
|
||
area_rank_q = session.query(
|
||
MonthlySummaryAnalysis.area_name,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
|
||
).group_by(MonthlySummaryAnalysis.area_name)
|
||
|
||
area_rank_q = apply_filters(area_rank_q, ignore_year=True)
|
||
area_rank_q = area_rank_q.order_by(desc('sales'))
|
||
|
||
# 年度對比趨勢 (需要包含本期與去年同期)
|
||
# 年度對比趨勢 (需要包含本期與去年同期)
|
||
yoy_trend_q = session.query(
|
||
MonthlySummaryAnalysis.year,
|
||
MonthlySummaryAnalysis.month,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales_curr'),
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('sales_yoa')
|
||
)
|
||
yoy_trend_q = apply_filters(yoy_trend_q)
|
||
yoy_trend_q = yoy_trend_q.group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
|
||
|
||
rows = []
|
||
for r in rows_query.all():
|
||
rows.append({
|
||
'year': r.year,
|
||
'month': r.month,
|
||
'division': r.division,
|
||
'pm_name': r.pm_name,
|
||
'area_name': r.area_name,
|
||
'brand_name': r.brand_name,
|
||
'vendor_name': r.vendor_name,
|
||
'trade_type': r.trade_type,
|
||
'sales_amt_curr': r.sales_amt_curr,
|
||
'sales_amt_yoa': r.sales_amt_yoa,
|
||
'sales_vol_curr': r.sales_vol_curr,
|
||
'profit_amt_curr': r.profit_amt_curr,
|
||
'views_curr': r.views_curr
|
||
})
|
||
|
||
# 取得不重複的維度列表
|
||
years_list = [r[0] for r in session.query(MonthlySummaryAnalysis.year).distinct().all()]
|
||
months_list = [r[0] for r in session.query(MonthlySummaryAnalysis.month).distinct().all()]
|
||
divisions_list = [r[0] for r in session.query(MonthlySummaryAnalysis.division).distinct().all() if r[0]]
|
||
pms_list = [r[0] for r in session.query(MonthlySummaryAnalysis.pm_name).distinct().all() if r[0]]
|
||
areas_list = [r[0] for r in session.query(MonthlySummaryAnalysis.area_name).distinct().all() if r[0]]
|
||
vendors_list = [r[0] for r in session.query(MonthlySummaryAnalysis.vendor_name).distinct().all() if r[0]]
|
||
trades_list = [r[0] for r in session.query(MonthlySummaryAnalysis.trade_type).distinct().all() if r[0]]
|
||
# DEBUG LOGGING FOR RESULTS
|
||
debug_logger.info(f"🔍 [API Debug] Result Counts: Area={len(area_rank_q.all())}, Vendor={len(vendor_rank_q.all())}, Div={len(div_dist_q.all())}, Price={len(price_cont_q.all())}")
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'total_rows': total_rows,
|
||
'total_months': total_months,
|
||
'kpis': {
|
||
'sales': int(kpi_res.total_sales or 0),
|
||
'sales_prev': int(kpi_res.total_sales_prev or 0),
|
||
'sales_yoa': int(kpi_res.total_sales_yoa or 0),
|
||
'profit': int(kpi_res.total_profit or 0),
|
||
'vol': int(kpi_res.total_vol or 0),
|
||
'views': int(kpi_res.total_views or 0),
|
||
'margin': round((kpi_res.total_profit / kpi_res.total_sales * 100), 2) if kpi_res.total_sales and kpi_res.total_profit else 0
|
||
},
|
||
'trend': [{'date': f"{r.year}/{r.month}", 'sales': int(r.sales or 0)} for r in trend_query.all()],
|
||
'yoy_trend': [{'date': f"{r.year}/{r.month}", 'curr': int(r.sales_curr or 0), 'yoa': int(r.sales_yoa or 0)} for r in yoy_trend_q.all()],
|
||
'rankings': [{'brand': r.brand_name, 'sales': int(r.sales or 0)} for r in rank_query.all()],
|
||
'rankings': [{'brand': r.brand_name, 'sales': int(r.sales or 0)} for r in rank_query.all()],
|
||
'area_ranking': [
|
||
{
|
||
'name': r.area_name,
|
||
'sales': int(r.sales or 0),
|
||
'sales_2024': int(r.sales_2024 or 0),
|
||
'sales_2025': int(r.sales_2025 or 0)
|
||
}
|
||
for r in area_rank_q.all()
|
||
],
|
||
'vendor_ranking': [
|
||
{
|
||
'name': r.vendor_name,
|
||
'sales': int(r.sales or 0),
|
||
'sales_2024': int(r.sales_2024 or 0),
|
||
'sales_2025': int(r.sales_2025 or 0),
|
||
'profit': int(r.profit or 0),
|
||
'profit_2024': int(r.profit_2024 or 0),
|
||
'profit_2025': int(r.profit_2025 or 0),
|
||
'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0
|
||
}
|
||
for r in vendor_rank_q.all()
|
||
],
|
||
'division_dist': [
|
||
{
|
||
'name': r.division,
|
||
'value': int(r.sales or 0),
|
||
'sales_2024': int(r.sales_2024 or 0),
|
||
'sales_2025': int(r.sales_2025 or 0)
|
||
}
|
||
for r in div_dist_q.all()
|
||
],
|
||
'price_contribution': [
|
||
{
|
||
'range': r.price_range,
|
||
'sales': int(r.sales or 0),
|
||
'sales_2024': int(r.sales_2024 or 0),
|
||
'sales_2025': int(r.sales_2025 or 0)
|
||
}
|
||
for r in price_cont_q.all()
|
||
],
|
||
'bcg_data': [
|
||
{'name': f"{r.brand_name}-{r.area_name}", 'qty': int(r.vol or 0), 'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0, 'sales': int(r.sales or 0)}
|
||
for r in bcg_q.all()
|
||
],
|
||
'heatmap_data': [
|
||
{'month': f"{r.year}-{r.month:02d}", 'category': r.division, 'sales': int(r.sales or 0)}
|
||
for r in heatmap_q.all()
|
||
],
|
||
'highlights': {
|
||
'rev_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in rev_top_q.all()],
|
||
'profit_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in profit_top_q.all()],
|
||
'vol_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in vol_top_q.all()]
|
||
},
|
||
'filters': {
|
||
'years': sorted(years_list, reverse=True),
|
||
'months': sorted(months_list),
|
||
'divisions': sorted(divisions_list),
|
||
'pms': sorted(pms_list),
|
||
'areas': sorted(areas_list),
|
||
'vendors': sorted(vendors_list),
|
||
'trades': sorted(trades_list)
|
||
},
|
||
'rows': rows
|
||
})
|
||
|
||
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"取得月份總表數據失敗: {e}")
|
||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
|
||
# ================= 📊 V-New: 業績分析報表 =================
|
||
|
||
|
||
def _get_filtered_sales_data(cache_key):
|
||
"""
|
||
🚩 共用函式:從快取讀取資料並根據 request.args 進行篩選
|
||
回傳: (target_df, cols_map, error_message)
|
||
參數: cache_key - 快取鍵值 (例如: "realtime_sales_monthly_3m")
|
||
"""
|
||
db = DatabaseManager()
|
||
|
||
# 1. 檢查資料表與快取
|
||
df = None
|
||
cols_map = {}
|
||
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
cache_data = _SALES_PROCESSED_CACHE[cache_key]
|
||
df = cache_data['df']
|
||
cols_map = cache_data['cols']
|
||
else:
|
||
# 快取不存在時,直接回傳錯誤讓呼叫端顯示 spinner 導回 sales_analysis
|
||
# 不在此發起全表 DB 查詢(748k 行會 hang Gunicorn worker)
|
||
sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),回傳錯誤讓 UI 導回 sales_analysis")
|
||
return None, {}, f"快取未就緒,請先從業績分析主頁載入資料 (cache_key={cache_key})"
|
||
|
||
if False: # 保留舊冷快取重載邏輯(已停用,避免全表掃描 hang)
|
||
sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),試圖重新從資料庫載入...")
|
||
try:
|
||
# V-Fix: 從 cache_key 提取 table_name
|
||
# 格式: realtime_sales_monthly_3m 或 realtime_sales_monthly_custom_2025-01-01_2025-01-31
|
||
if "_custom_" in cache_key:
|
||
table_name = cache_key.split('_custom_')[0] # realtime_sales_monthly
|
||
else:
|
||
# 移除最後的 _Xm 部分
|
||
parts = cache_key.rsplit('_', 1)
|
||
table_name = parts[0] if len(parts) > 1 else 'realtime_sales_monthly'
|
||
|
||
# 判斷是自訂區間還是標配區間
|
||
if "_custom_" in cache_key:
|
||
# 格式: realtime_sales_monthly_custom_2025-01-01_2025-01-31
|
||
parts = cache_key.split('_custom_')
|
||
dates = parts[1].split('_')
|
||
start_d, end_d = dates[0], dates[1]
|
||
# 呼叫資料庫讀取 (不傳入 view, 會自動處理欄位映射)
|
||
result_df, result_cols = db.get_sales_data(table_name=table_name, start_date=start_d, end_date=end_d)
|
||
else:
|
||
# 格式: realtime_sales_monthly_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
|
||
|
||
@app.route('/api/export/excel/seasonality_detail')
|
||
def export_seasonality_detail():
|
||
"""API: 匯出淡旺季熱力圖的詳細資料 (點擊氣泡觸發)"""
|
||
try:
|
||
table_name = 'realtime_sales_monthly'
|
||
data_range_months = int(request.args.get('data_range', '1') or '1')
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
|
||
|
||
# V-Fix: 如果 cache_key 不存在,嘗試使用固定的 table_name
|
||
if err and table_name in _SALES_PROCESSED_CACHE:
|
||
target_df, cols_map, err = _get_filtered_sales_data(table_name)
|
||
|
||
if err: return f"匯出失敗: {err}", 400
|
||
|
||
# 取得額外參數
|
||
target_month = request.args.get('target_month')
|
||
target_category = request.args.get('target_category')
|
||
|
||
if not target_month or not target_category:
|
||
return "缺少必要參數 (month, category)", 400
|
||
|
||
col_category = cols_map.get('category')
|
||
|
||
# 進一步篩選
|
||
export_df = target_df[
|
||
(target_df['_month_str'] == target_month) &
|
||
(target_df[col_category] == target_category)
|
||
]
|
||
|
||
if export_df.empty:
|
||
return "該月份與分類無資料", 404
|
||
|
||
# 使用 BytesIO 直接在記憶體中產生 Excel (避免 Exporter 的類型不相容)
|
||
import io
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
export_df.to_excel(writer, index=False, sheet_name='明細')
|
||
output.seek(0)
|
||
|
||
filename = f"Seasonality_{target_category}_{target_month}.xlsx"
|
||
return send_file(
|
||
output,
|
||
as_attachment=True,
|
||
download_name=filename,
|
||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"Seasonality Export Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
# ================= 💎 V-New: Top 3 Highlights 詳細列表 API =================
|
||
@app.route('/api/sales_analysis/top_detail')
|
||
def get_top_detail():
|
||
"""API: 取得 Top N 詳細列表(業績貢獻王/獲利金雞母/人氣引流款)"""
|
||
try:
|
||
from datetime import datetime, timedelta, timezone
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
table_name = 'realtime_sales_monthly'
|
||
data_range_months = int(request.args.get('data_range', '1') or '1')
|
||
start_date = request.args.get('start_date', '') # V-New: 自訂開始日期
|
||
end_date = request.args.get('end_date', '') # V-New: 自訂結束日期
|
||
top_type = request.args.get('type', 'revenue') # revenue/margin/quantity
|
||
metric = request.args.get('metric', 'amount') # amount/profit/qty
|
||
view_type = request.args.get('view', 'product') # product/category
|
||
|
||
db = DatabaseManager()
|
||
|
||
# V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
|
||
# 嘗試從快取讀取欄位名稱
|
||
cols_map = {}
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {})
|
||
elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key
|
||
cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {})
|
||
|
||
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
|
||
# V-Fix (2026-01-23): 使用 or 確保不會得到 None 值
|
||
col_name = cols_map.get('name') or '商品名稱'
|
||
col_brand = cols_map.get('brand') or '品牌'
|
||
col_vendor = cols_map.get('vendor') or '廠商名稱'
|
||
col_category = cols_map.get('category') or '商品館'
|
||
col_amount = cols_map.get('amount') or '總業績'
|
||
col_qty = cols_map.get('qty') or '數量'
|
||
col_cost = cols_map.get('cost') or '總成本'
|
||
col_profit = cols_map.get('profit') # 可以為 None
|
||
col_activity = cols_map.get('activity') or '活動名稱'
|
||
col_payment = cols_map.get('payment') or '付款方式'
|
||
|
||
# 建立日期篩選條件
|
||
date_filter = ""
|
||
# V-New: 優先處理自訂日期區間
|
||
if start_date or end_date:
|
||
# V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01)
|
||
start_date_slash = start_date.replace('-', '/') if start_date else ''
|
||
end_date_slash = end_date.replace('-', '/') if end_date else ''
|
||
|
||
if start_date and end_date:
|
||
date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'"""
|
||
elif start_date:
|
||
date_filter = f"""AND "日期" >= '{start_date_slash}'"""
|
||
else: # only end_date
|
||
date_filter = f"""AND "日期" <= '{end_date_slash}'"""
|
||
elif data_range_months > 0:
|
||
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
|
||
date_filter = f"""AND "日期" >= '{cutoff_date}'"""
|
||
|
||
# V-Fix: 補上其他所有篩選條件 (與 get_sales_table_data 一致)
|
||
category_filter = request.args.get('category', 'all')
|
||
brand_filter = request.args.get('brand', 'all')
|
||
vendor_filter = request.args.get('vendor', 'all')
|
||
activity_filter = request.args.get('activity', 'all')
|
||
payment_filter = request.args.get('payment', 'all')
|
||
month_filter = request.args.get('month', 'all')
|
||
dow_filter = request.args.get('dow', 'all')
|
||
hour_filter = request.args.get('hour', 'all')
|
||
min_price_str = request.args.get('min_price', '')
|
||
max_price_str = request.args.get('max_price', '')
|
||
min_margin_str = request.args.get('min_margin', '')
|
||
max_margin_str = request.args.get('max_margin', '')
|
||
keyword = request.args.get('keyword', '').strip()
|
||
|
||
additional_filters = []
|
||
|
||
if category_filter and category_filter != 'all':
|
||
additional_filters.append(f""""{col_category}" = '{category_filter}'""")
|
||
if brand_filter and brand_filter != 'all':
|
||
additional_filters.append(f""""{col_brand}" = '{brand_filter}'""")
|
||
if vendor_filter and vendor_filter != 'all':
|
||
additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""")
|
||
if activity_filter and activity_filter != 'all':
|
||
additional_filters.append(f""""{col_activity}" = '{activity_filter}'""")
|
||
if payment_filter and payment_filter != 'all':
|
||
additional_filters.append(f""""{col_payment}" = '{payment_filter}'""")
|
||
|
||
# 時間維度
|
||
if month_filter and month_filter != 'all':
|
||
month_filter_slash = month_filter.replace('-', '/')
|
||
# 使用「日期」欄位 (這似乎是系統內部固定欄位,不需 dynamic map,除非資料表結構也變了)
|
||
# 假設 "日期" 是固定欄位
|
||
additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""")
|
||
|
||
if dow_filter and dow_filter != 'all':
|
||
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API)
|
||
pandas_dow = int(dow_filter)
|
||
if DATABASE_TYPE == 'postgresql':
|
||
pg_dow = (pandas_dow + 1) % 7
|
||
additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""")
|
||
else:
|
||
sqlite_dow = str((pandas_dow + 1) % 7)
|
||
additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""")
|
||
|
||
if hour_filter and hour_filter != 'all':
|
||
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API)
|
||
hour_val = int(hour_filter)
|
||
if DATABASE_TYPE == 'postgresql':
|
||
additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""")
|
||
else:
|
||
additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""")
|
||
|
||
# 關鍵字
|
||
if keyword:
|
||
keyword_escaped = keyword.replace("'", "''")
|
||
k_conds = []
|
||
for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]:
|
||
k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""")
|
||
additional_filters.append(f"({' OR '.join(k_conds)})")
|
||
|
||
if (min_price_str or max_price_str):
|
||
price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)'
|
||
if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}")
|
||
if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}")
|
||
|
||
if (min_margin_str or max_margin_str):
|
||
if col_profit:
|
||
profit_sql = f'"{col_profit}"'
|
||
else:
|
||
profit_sql = f'("{col_amount}" - "{col_cost}")'
|
||
|
||
margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))'
|
||
if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}")
|
||
if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}")
|
||
|
||
if additional_filters:
|
||
date_filter += " AND " + " AND ".join(additional_filters)
|
||
|
||
# V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合)
|
||
if col_profit:
|
||
profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))'
|
||
else:
|
||
profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))'
|
||
|
||
# 根據檢視類型和指標建立 SQL 查詢
|
||
if view_type == 'category':
|
||
# 分類排行
|
||
if metric == 'qty':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as name,
|
||
SUM(CAST("{col_qty}" AS REAL)) as value
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 50
|
||
"""
|
||
elif metric == 'profit':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as name,
|
||
{profit_select_sql} as value,
|
||
CASE
|
||
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
|
||
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
|
||
ELSE 0
|
||
END as margin_rate
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 50
|
||
"""
|
||
else: # amount
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as name,
|
||
SUM(CAST("{col_amount}" AS REAL)) as value
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 50
|
||
"""
|
||
else:
|
||
# 商品排行(包含商品ID)
|
||
pid_col_sql = f'"{cols_map.get("pid", "商品ID")}"' # 商品ID 欄位
|
||
if metric == 'qty':
|
||
sql_query = f"""
|
||
SELECT
|
||
{pid_col_sql} as product_id,
|
||
"{col_name}" as name,
|
||
"{col_brand}" as brand,
|
||
"{col_vendor}" as vendor,
|
||
"{col_category}" as category,
|
||
SUM(CAST("{col_qty}" AS REAL)) as value
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 100
|
||
"""
|
||
elif metric == 'profit':
|
||
sql_query = f"""
|
||
SELECT
|
||
{pid_col_sql} as product_id,
|
||
"{col_name}" as name,
|
||
"{col_brand}" as brand,
|
||
"{col_vendor}" as vendor,
|
||
"{col_category}" as category,
|
||
{profit_select_sql} as value,
|
||
CASE
|
||
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
|
||
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
|
||
ELSE 0
|
||
END as margin_rate
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 100
|
||
"""
|
||
else: # amount
|
||
sql_query = f"""
|
||
SELECT
|
||
{pid_col_sql} as product_id,
|
||
"{col_name}" as name,
|
||
"{col_brand}" as brand,
|
||
"{col_vendor}" as vendor,
|
||
"{col_category}" as category,
|
||
SUM(CAST("{col_amount}" AS REAL)) as value
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY value DESC
|
||
LIMIT 100
|
||
"""
|
||
|
||
# 執行查詢
|
||
df = pd.read_sql(sql_query, db.engine)
|
||
sys_log.info(f"[API] Top Detail: {top_type}/{view_type} 返回 {len(df)} 筆資料")
|
||
|
||
if df.empty:
|
||
return jsonify({'items': []})
|
||
|
||
# V-Fix (2026-01-23): 確保數值欄位無 NaN/Infinity,避免 JSON 序列化失敗
|
||
numeric_cols = ['value', 'margin_rate']
|
||
for col in numeric_cols:
|
||
if col in df.columns:
|
||
df[col] = df[col].replace([np.inf, -np.inf], 0).fillna(0)
|
||
|
||
# V-Fix (2026-01-23): 確保字串欄位無 None
|
||
string_cols = ['product_id', 'name', 'brand', 'vendor', 'category']
|
||
for col in string_cols:
|
||
if col in df.columns:
|
||
df[col] = df[col].fillna('').astype(str)
|
||
|
||
# 轉換為 JSON
|
||
items = df.to_dict('records')
|
||
return jsonify({'items': items})
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[API] Top Detail Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return jsonify({'error': str(e)}), 500
|
||
|
||
@app.route('/api/sales_analysis/export_top_detail')
|
||
def export_top_detail():
|
||
"""API: 匯出 Top N 詳細列表為 Excel"""
|
||
try:
|
||
from datetime import datetime, timedelta, timezone
|
||
import io
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
table_name = 'realtime_sales_monthly'
|
||
data_range_months = int(request.args.get('data_range', '1') or '1')
|
||
start_date = request.args.get('start_date', '') # V-New: 自訂開始日期
|
||
end_date = request.args.get('end_date', '') # V-New: 自訂結束日期
|
||
top_type = request.args.get('type', 'revenue')
|
||
metric = request.args.get('metric', 'amount')
|
||
view_type = request.args.get('view', 'product')
|
||
|
||
db = DatabaseManager()
|
||
|
||
# V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
|
||
# 嘗試從快取讀取欄位名稱
|
||
cols_map = {}
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {})
|
||
elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key
|
||
cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {})
|
||
|
||
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
|
||
# V-Fix (2026-01-23): 使用 or 確保不會得到 None 值
|
||
col_name = cols_map.get('name') or '商品名稱'
|
||
col_brand = cols_map.get('brand') or '品牌'
|
||
col_vendor = cols_map.get('vendor') or '廠商名稱'
|
||
col_category = cols_map.get('category') or '商品館'
|
||
col_amount = cols_map.get('amount') or '總業績'
|
||
col_qty = cols_map.get('qty') or '數量'
|
||
col_cost = cols_map.get('cost') or '總成本'
|
||
col_profit = cols_map.get('profit') # 可以為 None
|
||
col_activity = cols_map.get('activity') or '活動名稱'
|
||
col_payment = cols_map.get('payment') or '付款方式'
|
||
|
||
# 建立日期篩選條件
|
||
date_filter = ""
|
||
# V-New: 優先處理自訂日期區間
|
||
if start_date or end_date:
|
||
# V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01)
|
||
start_date_slash = start_date.replace('-', '/') if start_date else ''
|
||
end_date_slash = end_date.replace('-', '/') if end_date else ''
|
||
|
||
if start_date and end_date:
|
||
date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'"""
|
||
elif start_date:
|
||
date_filter = f"""AND "日期" >= '{start_date_slash}'"""
|
||
else: # only end_date
|
||
date_filter = f"""AND "日期" <= '{end_date_slash}'"""
|
||
elif data_range_months > 0:
|
||
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
|
||
date_filter = f"""AND "日期" >= '{cutoff_date}'"""
|
||
|
||
# V-Fix: 補上其他所有篩選條件 (與 get_top_detail 一致)
|
||
category_filter = request.args.get('category', 'all')
|
||
brand_filter = request.args.get('brand', 'all')
|
||
vendor_filter = request.args.get('vendor', 'all')
|
||
activity_filter = request.args.get('activity', 'all')
|
||
payment_filter = request.args.get('payment', 'all')
|
||
month_filter = request.args.get('month', 'all')
|
||
dow_filter = request.args.get('dow', 'all')
|
||
hour_filter = request.args.get('hour', 'all')
|
||
min_price_str = request.args.get('min_price', '')
|
||
max_price_str = request.args.get('max_price', '')
|
||
min_margin_str = request.args.get('min_margin', '')
|
||
max_margin_str = request.args.get('max_margin', '')
|
||
keyword = request.args.get('keyword', '').strip()
|
||
|
||
additional_filters = []
|
||
|
||
if category_filter and category_filter != 'all':
|
||
additional_filters.append(f""""{col_category}" = '{category_filter}'""")
|
||
if brand_filter and brand_filter != 'all':
|
||
additional_filters.append(f""""{col_brand}" = '{brand_filter}'""")
|
||
if vendor_filter and vendor_filter != 'all':
|
||
additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""")
|
||
if activity_filter and activity_filter != 'all':
|
||
additional_filters.append(f""""{col_activity}" = '{activity_filter}'""")
|
||
if payment_filter and payment_filter != 'all':
|
||
additional_filters.append(f""""{col_payment}" = '{payment_filter}'""")
|
||
|
||
if month_filter and month_filter != 'all':
|
||
month_filter_slash = month_filter.replace('-', '/')
|
||
additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""")
|
||
|
||
if dow_filter and dow_filter != 'all':
|
||
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API)
|
||
pandas_dow = int(dow_filter)
|
||
if DATABASE_TYPE == 'postgresql':
|
||
pg_dow = (pandas_dow + 1) % 7
|
||
additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""")
|
||
else:
|
||
sqlite_dow = str((pandas_dow + 1) % 7)
|
||
additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""")
|
||
|
||
if hour_filter and hour_filter != 'all':
|
||
# V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API)
|
||
hour_val = int(hour_filter)
|
||
if DATABASE_TYPE == 'postgresql':
|
||
additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""")
|
||
else:
|
||
additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""")
|
||
|
||
if keyword:
|
||
keyword_escaped = keyword.replace("'", "''")
|
||
k_conds = []
|
||
for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]:
|
||
k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""")
|
||
additional_filters.append(f"({' OR '.join(k_conds)})")
|
||
|
||
if (min_price_str or max_price_str):
|
||
price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)'
|
||
if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}")
|
||
if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}")
|
||
|
||
if (min_margin_str or max_margin_str):
|
||
if col_profit:
|
||
profit_sql = f'"{col_profit}"'
|
||
else:
|
||
profit_sql = f'("{col_amount}" - "{col_cost}")'
|
||
|
||
margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))'
|
||
if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}")
|
||
if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}")
|
||
|
||
if additional_filters:
|
||
date_filter += " AND " + " AND ".join(additional_filters)
|
||
|
||
# V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合)
|
||
if col_profit:
|
||
profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))'
|
||
else:
|
||
profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))'
|
||
|
||
# 根據檢視類型和指標建立 SQL 查詢(與上面相同)
|
||
if view_type == 'category':
|
||
if metric == 'qty':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as 分類名稱,
|
||
SUM(CAST("{col_qty}" AS REAL)) as 銷售數量
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY 銷售數量 DESC
|
||
LIMIT 50
|
||
"""
|
||
elif metric == 'profit':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as 分類名稱,
|
||
{profit_select_sql} as 毛利金額,
|
||
CASE
|
||
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
|
||
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
|
||
ELSE 0
|
||
END as 毛利率
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY 毛利金額 DESC
|
||
LIMIT 50
|
||
"""
|
||
else: # amount
|
||
sql_query = f"""
|
||
SELECT
|
||
"{col_category}" as 分類名稱,
|
||
SUM(CAST("{col_amount}" AS REAL)) as 銷售金額
|
||
FROM {table_name}
|
||
WHERE "{col_category}" IS NOT NULL {date_filter}
|
||
GROUP BY "{col_category}"
|
||
ORDER BY 銷售金額 DESC
|
||
LIMIT 50
|
||
"""
|
||
else:
|
||
# 商品排行(包含商品ID)
|
||
if metric == 'qty':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{cols_map.get("pid", "商品ID")}" as 商品ID,
|
||
"{col_name}" as 商品名稱,
|
||
"{col_brand}" as 品牌,
|
||
"{col_vendor}" as 廠商名稱,
|
||
"{col_category}" as 分類名稱,
|
||
SUM(CAST("{col_qty}" AS REAL)) as 銷售數量
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY 銷售數量 DESC
|
||
LIMIT 100
|
||
"""
|
||
elif metric == 'profit':
|
||
sql_query = f"""
|
||
SELECT
|
||
"{cols_map.get("pid", "商品ID")}" as 商品ID,
|
||
"{col_name}" as 商品名稱,
|
||
"{col_brand}" as 品牌,
|
||
"{col_vendor}" as 廠商名稱,
|
||
"{col_category}" as 分類名稱,
|
||
{profit_select_sql} as 毛利金額,
|
||
CASE
|
||
WHEN SUM(CAST("{col_amount}" AS REAL)) > 0
|
||
THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100
|
||
ELSE 0
|
||
END as 毛利率
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY 毛利金額 DESC
|
||
LIMIT 100
|
||
"""
|
||
else: # amount
|
||
sql_query = f"""
|
||
SELECT
|
||
"{cols_map.get("pid", "商品ID")}" as 商品ID,
|
||
"{col_name}" as 商品名稱,
|
||
"{col_brand}" as 品牌,
|
||
"{col_vendor}" as 廠商名稱,
|
||
"{col_category}" as 分類名稱,
|
||
SUM(CAST("{col_amount}" AS REAL)) as 銷售金額
|
||
FROM {table_name}
|
||
WHERE "{col_name}" IS NOT NULL {date_filter}
|
||
GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}"
|
||
ORDER BY 銷售金額 DESC
|
||
LIMIT 100
|
||
"""
|
||
|
||
# 執行查詢並匯出
|
||
df = pd.read_sql(sql_query, db.engine)
|
||
|
||
if df.empty:
|
||
return "無資料可匯出", 400
|
||
|
||
# 生成 Excel
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
df.to_excel(writer, index=False, sheet_name='Top排行')
|
||
output.seek(0)
|
||
|
||
# 生成檔案名稱
|
||
type_names = {'revenue': '業績貢獻王', 'margin': '獲利金雞母', 'quantity': '人氣引流款'}
|
||
view_names = {'product': '商品排行', 'category': '分類排行'}
|
||
filename = f"{type_names.get(top_type, '排行')}_{view_names.get(view_type, '')}_{datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')}.xlsx"
|
||
|
||
sys_log.info(f"[Export] Top Detail: {filename} ({len(df)} 筆)")
|
||
|
||
return send_file(
|
||
output,
|
||
as_attachment=True,
|
||
download_name=filename,
|
||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Export] Top Detail Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
# ================= 📈 V-New: 年度對比 (Year-over-Year Comparison) =================
|
||
@app.route('/api/sales_analysis/yoy_comparison')
|
||
def yoy_comparison():
|
||
"""
|
||
API: 年度對比分析 (YoY Comparison)
|
||
|
||
參數:
|
||
year1: 基準年 (例如 2024)
|
||
year2: 對比年 (例如 2025)
|
||
month: 月份 (可選,1-12,不帶則為全年)
|
||
metric: 指標 (revenue/qty/profit)
|
||
|
||
回傳:
|
||
JSON with year1 total, year2 total, growth rate, and monthly breakdown
|
||
"""
|
||
try:
|
||
from datetime import datetime, timedelta, timezone
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
table_name = 'realtime_sales_monthly'
|
||
year1 = request.args.get('year1', '2024')
|
||
year2 = request.args.get('year2', '2025')
|
||
month = request.args.get('month', '') # 可選,1-12
|
||
metric = request.args.get('metric', 'revenue') # revenue/qty/profit
|
||
|
||
db = DatabaseManager()
|
||
|
||
# 欄位名稱
|
||
col_amount = '總業績'
|
||
col_qty = '數量'
|
||
col_cost = '總成本'
|
||
col_date = '日期'
|
||
|
||
# 根據指標決定聚合欄位
|
||
if metric == 'qty':
|
||
agg_sql = f'SUM(CAST("{col_qty}" AS REAL))'
|
||
metric_label = '銷售數量'
|
||
elif metric == 'profit':
|
||
agg_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))'
|
||
metric_label = '毛利金額'
|
||
else: # revenue
|
||
agg_sql = f'SUM(CAST("{col_amount}" AS REAL))'
|
||
metric_label = '銷售金額'
|
||
|
||
# 建立年度篩選條件
|
||
# 日期格式為 2025/01/01 或 2025-01-01
|
||
def build_year_filter(year, month_filter=''):
|
||
if month_filter:
|
||
month_str = month_filter.zfill(2)
|
||
return f"""("{col_date}" LIKE '{year}/{month_str}%' OR "{col_date}" LIKE '{year}-{month_str}%')"""
|
||
else:
|
||
return f"""("{col_date}" LIKE '{year}/%' OR "{col_date}" LIKE '{year}-%')"""
|
||
|
||
year1_filter = build_year_filter(year1, month)
|
||
year2_filter = build_year_filter(year2, month)
|
||
|
||
# 查詢年度總計
|
||
sql_year1 = f"""
|
||
SELECT {agg_sql} as total
|
||
FROM {table_name}
|
||
WHERE {year1_filter}
|
||
"""
|
||
sql_year2 = f"""
|
||
SELECT {agg_sql} as total
|
||
FROM {table_name}
|
||
WHERE {year2_filter}
|
||
"""
|
||
|
||
# V-Fix: SQLAlchemy 2.0 需要使用 text() 包裹 SQL 字串
|
||
from sqlalchemy import text
|
||
result_year1 = pd.read_sql(text(sql_year1), db.engine)
|
||
result_year2 = pd.read_sql(text(sql_year2), db.engine)
|
||
|
||
total_year1 = float(result_year1['total'].iloc[0] or 0)
|
||
total_year2 = float(result_year2['total'].iloc[0] or 0)
|
||
|
||
# 計算成長率
|
||
if total_year1 > 0:
|
||
growth_rate = ((total_year2 - total_year1) / total_year1) * 100
|
||
else:
|
||
growth_rate = 0 if total_year2 == 0 else 100
|
||
|
||
# 月度明細 (如果沒有指定月份,則查詢 12 個月的明細)
|
||
monthly_breakdown = []
|
||
if not month:
|
||
for m in range(1, 13):
|
||
m_str = str(m).zfill(2)
|
||
y1_filter = build_year_filter(year1, m_str)
|
||
y2_filter = build_year_filter(year2, m_str)
|
||
|
||
sql_m1 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y1_filter}"
|
||
sql_m2 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y2_filter}"
|
||
|
||
# V-Fix: SQLAlchemy 2.0 需要使用 text()
|
||
r1 = pd.read_sql(text(sql_m1), db.engine)
|
||
r2 = pd.read_sql(text(sql_m2), db.engine)
|
||
|
||
v1 = float(r1['total'].iloc[0] or 0)
|
||
v2 = float(r2['total'].iloc[0] or 0)
|
||
|
||
m_growth = ((v2 - v1) / v1 * 100) if v1 > 0 else (0 if v2 == 0 else 100)
|
||
|
||
monthly_breakdown.append({
|
||
'month': m,
|
||
'month_label': f'{m}月',
|
||
'year1_value': v1,
|
||
'year2_value': v2,
|
||
'growth_rate': round(m_growth, 2)
|
||
})
|
||
|
||
response = {
|
||
'year1': {
|
||
'label': f'{year1}年' + (f'{month}月' if month else ''),
|
||
'total': total_year1
|
||
},
|
||
'year2': {
|
||
'label': f'{year2}年' + (f'{month}月' if month else ''),
|
||
'total': total_year2
|
||
},
|
||
'growth_rate': round(growth_rate, 2),
|
||
'metric': metric,
|
||
'metric_label': metric_label,
|
||
'monthly_breakdown': monthly_breakdown
|
||
}
|
||
|
||
sys_log.info(f"[YoY] {year1} vs {year2}: {total_year1:,.0f} -> {total_year2:,.0f} ({growth_rate:+.1f}%)")
|
||
|
||
return jsonify(response)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[YoY] Error: {e}")
|
||
traceback.print_exc()
|
||
return jsonify({'error': str(e)}), 500
|
||
|
||
# ================= 📈 V-New: 營運成長報表 (Growth Strategy) =================
|
||
@app.route('/growth_analysis')
|
||
def growth_analysis():
|
||
"""營運成長策略報表 (MoM, YoY, AOV, YTD)"""
|
||
try:
|
||
from sqlalchemy import text as sa_text
|
||
db = DatabaseManager()
|
||
table_name = 'realtime_sales_monthly'
|
||
|
||
# 1. 檢查資料表
|
||
inspector = inspect(db.engine)
|
||
if table_name not in inspector.get_table_names():
|
||
return f"尚未匯入業績資料 ({table_name})", 404
|
||
|
||
# 2. SQL 月度聚合(避免全量 748k 行讀進 pandas)
|
||
monthly_sql = sa_text("""
|
||
SELECT
|
||
date_trunc('month', TO_DATE("日期", 'YYYY/MM/DD'))::date AS month,
|
||
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS amount,
|
||
SUM(COALESCE(NULLIF("總成本", '')::numeric, 0)) AS cost,
|
||
COUNT(DISTINCT "訂單編號") AS orders
|
||
FROM realtime_sales_monthly
|
||
WHERE "日期" IS NOT NULL AND length("日期") >= 8
|
||
GROUP BY 1
|
||
ORDER BY 1
|
||
""")
|
||
ytd_sql = sa_text("""
|
||
SELECT
|
||
EXTRACT(YEAR FROM TO_DATE("日期", 'YYYY/MM/DD'))::int AS yr,
|
||
EXTRACT(DOY FROM TO_DATE("日期", 'YYYY/MM/DD'))::int AS doy,
|
||
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS amount
|
||
FROM realtime_sales_monthly
|
||
WHERE "日期" IS NOT NULL AND length("日期") >= 8
|
||
AND EXTRACT(YEAR FROM TO_DATE("日期", 'YYYY/MM/DD'))
|
||
>= EXTRACT(YEAR FROM CURRENT_DATE) - 1
|
||
GROUP BY 1, 2
|
||
ORDER BY 1, 2
|
||
""")
|
||
recent_sql = sa_text("""
|
||
SELECT
|
||
SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS revenue,
|
||
COUNT(DISTINCT "訂單編號") AS orders
|
||
FROM realtime_sales_monthly
|
||
WHERE "日期" IS NOT NULL AND length("日期") >= 8
|
||
AND TO_DATE("日期", 'YYYY/MM/DD') >= CURRENT_DATE - INTERVAL '30 days'
|
||
""")
|
||
|
||
with db.engine.connect() as conn:
|
||
monthly_df = pd.read_sql(monthly_sql, conn)
|
||
if monthly_df.empty:
|
||
return f"資料表 {table_name} 為空", 404
|
||
ytd_df = pd.read_sql(ytd_sql, conn)
|
||
recent_r = conn.execute(recent_sql).fetchone()
|
||
|
||
# 3. 月度指標計算
|
||
monthly_df['month'] = pd.to_datetime(monthly_df['month'])
|
||
monthly_df = monthly_df.set_index('month')
|
||
monthly_df['profit'] = monthly_df['amount'] - monthly_df['cost']
|
||
monthly_df['aov'] = monthly_df['amount'] / monthly_df['orders'].replace(0, pd.NA)
|
||
monthly_df['margin_rate'] = (monthly_df['profit'] / monthly_df['amount'].replace(0, pd.NA)) * 100
|
||
monthly_df['mom'] = monthly_df['amount'].pct_change() * 100
|
||
monthly_df['yoy'] = monthly_df['amount'].pct_change(periods=12) * 100
|
||
monthly_df = monthly_df.fillna(0)
|
||
|
||
labels = monthly_df.index.strftime('%Y-%m').tolist()
|
||
chart_data = {
|
||
'labels': labels,
|
||
'revenue': monthly_df['amount'].tolist(),
|
||
'profit': monthly_df['profit'].tolist(),
|
||
'orders': monthly_df['orders'].tolist(),
|
||
'aov': monthly_df['aov'].round(0).tolist(),
|
||
'mom': monthly_df['mom'].round(2).tolist(),
|
||
'yoy': monthly_df['yoy'].round(2).tolist(),
|
||
'margin_rate': monthly_df['margin_rate'].round(1).tolist(),
|
||
}
|
||
|
||
# 4. KPI: YTD
|
||
current_year = int(pd.Timestamp.now().year)
|
||
last_year = current_year - 1
|
||
max_doy = ytd_df[ytd_df['yr'] == current_year]['doy'].max() if not ytd_df.empty else 365
|
||
|
||
ytd_revenue = float(ytd_df[ytd_df['yr'] == current_year]['amount'].sum())
|
||
last_ytd_revenue = float(ytd_df[(ytd_df['yr'] == last_year) & (ytd_df['doy'] <= max_doy)]['amount'].sum())
|
||
ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue * 100) if last_ytd_revenue > 0 else 0
|
||
|
||
recent_revenue = float(recent_r.revenue or 0) if recent_r else 0
|
||
recent_orders = int(recent_r.orders or 0) if recent_r else 0
|
||
recent_aov = recent_revenue / recent_orders if recent_orders > 0 else 0
|
||
|
||
kpi = {
|
||
'ytd_revenue': ytd_revenue,
|
||
'ytd_growth': ytd_growth,
|
||
'current_year': current_year,
|
||
'recent_aov': recent_aov,
|
||
'total_orders': int(monthly_df['orders'].sum()),
|
||
}
|
||
|
||
return render_template('growth_analysis.html', chart_data=chart_data, kpi=kpi)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"Growth Analysis Error: {e}")
|
||
return f"系統錯誤: {e}"
|
||
|
||
# ================= 📅 V-New: 當日業績看板 =================
|
||
|
||
@app.route('/daily_sales')
|
||
def daily_sales():
|
||
"""當日業績看板 (Day-over-Day 與 Week-over-Week 分析)"""
|
||
try:
|
||
db = DatabaseManager()
|
||
engine = db.engine
|
||
table_name = 'daily_sales_snapshot'
|
||
|
||
# 1. 檢查資料表是否存在
|
||
inspector = inspect(engine)
|
||
if table_name not in inspector.get_table_names():
|
||
return render_template('daily_sales.html',
|
||
error="尚未匯入當日業績資料,請先至系統設定頁面匯入 Excel。",
|
||
selected_date=None, available_dates=[], current=None, dod=None, wow=None,
|
||
chart_data=None, categories=None, calendar_data=None, selected_month=None)
|
||
|
||
# 2. 讀取資料(使用快取)
|
||
cache_key = f'{table_name}_daily'
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
df = _SALES_PROCESSED_CACHE[cache_key]['df']
|
||
else:
|
||
df = pd.read_sql(f"SELECT * FROM {table_name}", engine)
|
||
if df.empty:
|
||
return render_template('daily_sales.html',
|
||
error="資料表為空,請先匯入當日業績資料。",
|
||
selected_date=None, available_dates=[], current=None, dod=None, wow=None,
|
||
chart_data=None, categories=None, calendar_data=None, selected_month=None)
|
||
|
||
# 3. 資料前處理(欄位識別、型別轉換)
|
||
df = preprocess_daily_sales_data(df)
|
||
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
|
||
|
||
# 4. 取得可用日期列表
|
||
available_dates = sorted(df['snapshot_date'].unique(), reverse=True)
|
||
available_dates_str = [d.strftime('%Y-%m-%d') if isinstance(d, pd.Timestamp) else str(d) for d in available_dates]
|
||
|
||
# 5. 取得選擇的日期(從 URL 參數或使用最新日期)
|
||
selected_date_param = request.args.get('date')
|
||
if selected_date_param:
|
||
selected_date = pd.to_datetime(selected_date_param)
|
||
else:
|
||
selected_date = df['snapshot_date'].max()
|
||
|
||
# 6. 取得選擇的月份(用於行事曆顯示)
|
||
selected_month_param = request.args.get('month')
|
||
if selected_month_param:
|
||
selected_month = pd.to_datetime(selected_month_param)
|
||
else:
|
||
selected_month = selected_date
|
||
|
||
# V-New 2026-01-15: 判斷是否為月概覽模式(沒有選擇特定日期)
|
||
is_month_view = not selected_date_param and not request.args.get('month')
|
||
# 如果只有 month 參數沒有 date 參數,也是月概覽模式
|
||
if selected_month_param and not selected_date_param:
|
||
is_month_view = True
|
||
|
||
# 7. 計算 KPI
|
||
current_kpi = calculate_daily_kpis(df, selected_date)
|
||
dod_kpi = calculate_dod(df, selected_date)
|
||
wow_kpi = calculate_wow(df, selected_date)
|
||
|
||
# V-New 2026-01-15: 計算月度總計 KPI
|
||
month_start = selected_month.replace(day=1)
|
||
month_end = (month_start + pd.DateOffset(months=1)) - pd.Timedelta(days=1)
|
||
month_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)]
|
||
|
||
# V-Fix 2026-01-15: 使用 find_col 動態獲取正確欄位名稱
|
||
cols = month_df.columns.tolist()
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名', 'Name'])
|
||
|
||
month_kpi = {
|
||
'total_revenue': float(month_df[col_amount].sum()) if col_amount else 0,
|
||
'total_cost': float(month_df[col_cost].sum()) if col_cost else 0,
|
||
'gross_margin': float(month_df[col_profit].sum()) if col_profit else 0,
|
||
'total_qty': float(month_df[col_qty].sum()) if col_qty else 0,
|
||
'sku_count': int(month_df[col_name].nunique()) if col_name else 0,
|
||
'days_with_data': int(month_df['snapshot_date'].nunique())
|
||
}
|
||
# 若無毛利欄位,用業績減成本計算
|
||
if not col_profit and col_amount and col_cost:
|
||
month_kpi['gross_margin'] = month_kpi['total_revenue'] - month_kpi['total_cost']
|
||
# 計算月度毛利率
|
||
if month_kpi['total_revenue'] > 0:
|
||
month_kpi['margin_rate'] = month_kpi['gross_margin'] / month_kpi['total_revenue'] * 100
|
||
else:
|
||
month_kpi['margin_rate'] = 0
|
||
# 計算月度客單價
|
||
if month_kpi['total_qty'] > 0:
|
||
month_kpi['avg_price'] = month_kpi['total_revenue'] / month_kpi['total_qty']
|
||
else:
|
||
month_kpi['avg_price'] = 0
|
||
|
||
# 8. 準備圖表數據(根據選擇的日期)
|
||
chart_data = prepare_daily_charts(df, selected_date, days=30)
|
||
|
||
# 9. 準備分類聚合列表
|
||
# V-Fix 2026-01-15: 根據檢視模式(單日/月度)決定聚合範圍
|
||
category_list = prepare_category_summary(
|
||
df,
|
||
date_str=selected_date,
|
||
is_month_view=is_month_view,
|
||
month_start=month_start if is_month_view else None,
|
||
month_end=month_end if is_month_view else None
|
||
)
|
||
|
||
# 10. 準備行事曆數據
|
||
calendar_data = prepare_calendar_data(df, selected_month)
|
||
|
||
# 11. V-New: 準備行銷活動業績數據
|
||
marketing_data = prepare_marketing_summary(
|
||
df,
|
||
selected_date=selected_date if not is_month_view else None,
|
||
is_month_view=is_month_view,
|
||
month_start=month_start if is_month_view else None,
|
||
month_end=month_end if is_month_view else None
|
||
)
|
||
|
||
# 12. 回傳模板
|
||
return render_template('daily_sales.html',
|
||
selected_date=selected_date.strftime('%Y-%m-%d') if isinstance(selected_date, pd.Timestamp) else selected_date,
|
||
available_dates=available_dates_str,
|
||
current=current_kpi,
|
||
dod=dod_kpi,
|
||
wow=wow_kpi,
|
||
month_kpi=month_kpi, # V-New: 月度總計
|
||
is_month_view=is_month_view, # V-New: 月概覽模式標誌
|
||
chart_data=chart_data,
|
||
categories=category_list,
|
||
calendar_data=calendar_data,
|
||
marketing_data=marketing_data, # V-New: 行銷活動數據
|
||
selected_month=selected_month.strftime('%Y-%m') if isinstance(selected_month, pd.Timestamp) else selected_month)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [DailySales] Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return render_template('daily_sales.html',
|
||
error=f"系統錯誤: {str(e)}",
|
||
selected_date=None,
|
||
available_dates=[],
|
||
current=None,
|
||
dod=None,
|
||
wow=None,
|
||
month_kpi=None,
|
||
is_month_view=False,
|
||
chart_data=None,
|
||
categories=None,
|
||
calendar_data=None,
|
||
marketing_data=None,
|
||
selected_month=None)
|
||
|
||
@app.route('/daily_sales/export')
|
||
def export_daily_sales_category():
|
||
"""匯出當日業績分類明細為 Excel"""
|
||
try:
|
||
from datetime import datetime
|
||
import io
|
||
from flask import send_file
|
||
|
||
db = DatabaseManager()
|
||
engine = db.engine
|
||
table_name = 'daily_sales_snapshot'
|
||
|
||
# 檢查資料表是否存在
|
||
inspector = inspect(engine)
|
||
if table_name not in inspector.get_table_names():
|
||
return "資料表不存在", 404
|
||
|
||
# 讀取資料
|
||
cache_key = f'{table_name}_daily'
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
df = _SALES_PROCESSED_CACHE[cache_key]['df']
|
||
else:
|
||
df = pd.read_sql(f"SELECT * FROM {table_name}", engine)
|
||
df = preprocess_daily_sales_data(df)
|
||
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
|
||
|
||
# 取得選擇的日期
|
||
selected_date = request.args.get('date')
|
||
if not selected_date:
|
||
available_dates = sorted(df['snapshot_date'].unique(), reverse=True)
|
||
if available_dates:
|
||
selected_date = str(available_dates[0])
|
||
else:
|
||
return "無可用日期", 404
|
||
|
||
# 準備分類資料
|
||
categories = prepare_category_summary(df, selected_date)
|
||
|
||
if not categories:
|
||
return "無資料可匯出", 404
|
||
|
||
# 轉為 DataFrame
|
||
export_df = pd.DataFrame(categories)
|
||
|
||
# 重新排列欄位順序並重新命名為中文
|
||
column_mapping = {
|
||
'category': '分類',
|
||
'vendor': '廠商',
|
||
'revenue': '總業績',
|
||
'cost': '總成本',
|
||
'profit': '毛利',
|
||
'margin_rate': '毛利率(%)',
|
||
'qty': '總銷量',
|
||
'sku_count': 'SKU數',
|
||
'avg_price': '平均單價'
|
||
}
|
||
|
||
# 只保留存在的欄位
|
||
export_columns = [col for col in column_mapping.keys() if col in export_df.columns]
|
||
export_df = export_df[export_columns]
|
||
export_df = export_df.rename(columns=column_mapping)
|
||
|
||
# 格式化數值欄位
|
||
for col in export_df.columns:
|
||
if col in ['總業績', '總成本', '毛利', '總銷量', 'SKU數', '平均單價']:
|
||
export_df[col] = export_df[col].apply(lambda x: f"{x:,.0f}" if pd.notna(x) else "0")
|
||
elif col == '毛利率(%)':
|
||
export_df[col] = export_df[col].apply(lambda x: f"{x:.1f}" if pd.notna(x) else "0.0")
|
||
|
||
# 產生檔案名稱
|
||
filename = f"當日業績_分類明細_{selected_date}.xlsx"
|
||
|
||
# 寫入 Excel
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
export_df.to_excel(writer, index=False, sheet_name='分類業績明細')
|
||
|
||
# 調整欄寬
|
||
worksheet = writer.sheets['分類業績明細']
|
||
for idx, col in enumerate(export_df.columns, 1):
|
||
max_length = max(
|
||
export_df[col].astype(str).apply(len).max(),
|
||
len(col)
|
||
) + 2
|
||
worksheet.column_dimensions[chr(64 + idx)].width = min(max_length, 50)
|
||
|
||
output.seek(0)
|
||
|
||
sys_log.info(f"[Web] [DailySales] Excel 匯出成功: {filename}")
|
||
|
||
return send_file(
|
||
output,
|
||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
as_attachment=True,
|
||
download_name=filename
|
||
)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [DailySales] Excel 匯出失敗: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return f"匯出失敗: {str(e)}", 500
|
||
|
||
# V-New 2026-01-15: 行銷活動業績匯出 API
|
||
@app.route('/daily_sales/export_marketing')
|
||
def export_marketing_summary_excel():
|
||
"""匯出行銷活動業績明細為 Excel"""
|
||
try:
|
||
import io
|
||
from flask import send_file
|
||
|
||
db = DatabaseManager()
|
||
engine = db.engine
|
||
table_name = 'daily_sales_snapshot'
|
||
|
||
# 讀取資料
|
||
cache_key = f'{table_name}_daily'
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
df = _SALES_PROCESSED_CACHE[cache_key]['df']
|
||
else:
|
||
df = pd.read_sql(f"SELECT * FROM {table_name}", engine)
|
||
df = preprocess_daily_sales_data(df)
|
||
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
|
||
|
||
# 取得參數
|
||
activity_type = request.args.get('type', 'all') # coupon, discount, bonus, click, all
|
||
start_date = request.args.get('start_date')
|
||
end_date = request.args.get('end_date')
|
||
selected_date = request.args.get('date')
|
||
|
||
# 額外篩選參數 (與 sales_analysis 同步)
|
||
selected_category = request.args.get('category', 'all')
|
||
selected_brand = request.args.get('brand', 'all')
|
||
selected_vendor = request.args.get('vendor', 'all')
|
||
keyword = request.args.get('keyword', '')
|
||
|
||
# 決定日期範圍
|
||
if start_date and end_date:
|
||
df = df[(df['snapshot_date'] >= pd.to_datetime(start_date)) &
|
||
(df['snapshot_date'] <= pd.to_datetime(end_date))]
|
||
date_label = f"{start_date}_{end_date}"
|
||
elif selected_date:
|
||
df = df[df['snapshot_date'] == pd.to_datetime(selected_date)]
|
||
date_label = selected_date
|
||
else:
|
||
date_label = "全部"
|
||
|
||
# 應用額外篩選
|
||
cols = df.columns.tolist()
|
||
col_category = find_col(cols, ['館別', '商品館', '分類', 'Category'])
|
||
col_brand = find_col(cols, ['品牌', 'Brand'])
|
||
col_vendor = find_col(cols, ['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier'])
|
||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
|
||
if selected_category != 'all' and col_category:
|
||
df = df[df[col_category] == selected_category]
|
||
if selected_brand != 'all' and col_brand:
|
||
df = df[df[col_brand] == selected_brand]
|
||
if selected_vendor != 'all' and col_vendor:
|
||
df = df[df[col_vendor] == selected_vendor]
|
||
if keyword and col_name:
|
||
df = df[df[col_name].str.contains(keyword, case=False, na=False)]
|
||
|
||
# 定義行銷活動欄位
|
||
marketing_cols = {
|
||
'coupon': ('折價券活動名稱', '折價券活動'),
|
||
'discount': ('折扣活動名稱', '折扣活動'),
|
||
'bonus': ('滿額再折扣活動名稱', '滿額再折扣'),
|
||
'click': ('點我再折扣', '點我再折扣')
|
||
}
|
||
|
||
# 準備 Excel 輸出
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
# 如果是 all,循環所有類型
|
||
types_to_export = [activity_type] if activity_type != 'all' else ['coupon', 'discount', 'bonus', 'click']
|
||
|
||
summary_rows = []
|
||
|
||
for t in types_to_export:
|
||
if t not in marketing_cols: continue
|
||
col_internal, sheet_label = marketing_cols[t]
|
||
if col_internal not in df.columns:
|
||
continue
|
||
|
||
# 聚合數據
|
||
# V-Fix: 排除空值和 0
|
||
m_df = df[df[col_internal].notna() & (df[col_internal] != '') & (df[col_internal] != '0') & (df[col_internal] != 0)]
|
||
|
||
if m_df.empty:
|
||
continue
|
||
|
||
grouped = m_df.groupby(col_internal).agg({
|
||
col_amount: 'sum',
|
||
col_qty: 'sum',
|
||
col_name: 'count' # 訂單筆數/商品筆數
|
||
}).reset_index()
|
||
|
||
# 重命名
|
||
grouped.columns = ['活動名稱', '總業績', '總銷量', '項目筆數']
|
||
grouped = grouped.sort_values(by='總業績', ascending=False)
|
||
|
||
# 寫入工作表
|
||
grouped.to_excel(writer, sheet_name=sheet_label[:31], index=False)
|
||
|
||
# 加入到總表數據
|
||
grouped['活動類型'] = sheet_label
|
||
summary_rows.append(grouped)
|
||
|
||
# 建立總表工作表 (如果有多個類型)
|
||
if len(summary_rows) > 1:
|
||
all_m_df = pd.concat(summary_rows).sort_values(by='總業績', ascending=False)
|
||
all_m_df = all_m_df[['活動類型', '活動名稱', '總業績', '總銷量', '項目筆數']]
|
||
all_m_df.to_excel(writer, sheet_name='合併總表', index=False)
|
||
|
||
output.seek(0)
|
||
output.seek(0)
|
||
|
||
filename = f"行銷活動分析_{date_label}.xlsx"
|
||
# 處理中文檔名編碼
|
||
from urllib.parse import quote
|
||
encoded_filename = quote(filename)
|
||
|
||
return send_file(
|
||
output,
|
||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
as_attachment=True,
|
||
download_name=filename,
|
||
conditional=True
|
||
)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Marketing] Excel 匯出失敗: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return f"匯出失敗: {str(e)}", 500
|
||
|
||
def preprocess_daily_sales_data(df):
|
||
"""前處理當日業績資料:欄位識別、型別轉換"""
|
||
cols = df.columns.tolist()
|
||
|
||
# 欄位自動識別(使用現有的 find_col 函式)
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', 'Amount', '總業績'])
|
||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量'])
|
||
|
||
# 型別轉換
|
||
if col_amount:
|
||
df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0)
|
||
if col_cost:
|
||
df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0)
|
||
if col_profit:
|
||
df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0)
|
||
if col_qty:
|
||
df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0)
|
||
|
||
# 日期轉換
|
||
df['snapshot_date'] = pd.to_datetime(df['snapshot_date'], errors='coerce')
|
||
|
||
return df
|
||
|
||
def calculate_daily_kpis(df, date_str):
|
||
"""計算單日 6 個 KPI"""
|
||
day_df = df[df['snapshot_date'] == date_str]
|
||
cols = day_df.columns.tolist()
|
||
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名', 'Name'])
|
||
|
||
total_revenue = float(day_df[col_amount].sum()) if col_amount else 0
|
||
total_cost = float(day_df[col_cost].sum()) if col_cost else 0
|
||
gross_margin = float(day_df[col_profit].sum()) if col_profit else (total_revenue - total_cost)
|
||
total_qty = float(day_df[col_qty].sum()) if col_qty else 0
|
||
sku_count = int(day_df[col_name].nunique()) if col_name else 0
|
||
avg_price = total_revenue / total_qty if total_qty > 0 else 0
|
||
|
||
return {
|
||
'total_revenue': total_revenue,
|
||
'total_cost': total_cost,
|
||
'gross_margin': gross_margin,
|
||
'total_qty': total_qty,
|
||
'sku_count': sku_count,
|
||
'avg_price': avg_price
|
||
}
|
||
|
||
def calculate_dod(df, current_date):
|
||
"""計算 Day-over-Day 變化率"""
|
||
current = calculate_daily_kpis(df, current_date)
|
||
prev_date = current_date - timedelta(days=1)
|
||
|
||
if prev_date not in df['snapshot_date'].values:
|
||
return {k: 0.0 for k in current.keys()}
|
||
|
||
previous = calculate_daily_kpis(df, prev_date)
|
||
|
||
dod = {}
|
||
for key in current:
|
||
if previous[key] > 0:
|
||
dod[key] = ((current[key] - previous[key]) / previous[key]) * 100
|
||
else:
|
||
dod[key] = 0.0
|
||
return dod
|
||
|
||
def calculate_wow(df, current_date):
|
||
"""計算 Week-over-Week 變化率"""
|
||
current = calculate_daily_kpis(df, current_date)
|
||
prev_week_date = current_date - timedelta(days=7)
|
||
|
||
if prev_week_date not in df['snapshot_date'].values:
|
||
return {k: 0.0 for k in current.keys()}
|
||
|
||
previous = calculate_daily_kpis(df, prev_week_date)
|
||
|
||
wow = {}
|
||
for key in current:
|
||
if previous[key] > 0:
|
||
wow[key] = ((current[key] - previous[key]) / previous[key]) * 100
|
||
else:
|
||
wow[key] = 0.0
|
||
return wow
|
||
|
||
def prepare_daily_charts(df, selected_date, days=30):
|
||
"""準備 4 個圖表的數據(根據選擇的日期)"""
|
||
# 取選擇日期前 N 天的數據
|
||
start_date = selected_date - timedelta(days=days)
|
||
df_range = df[(df['snapshot_date'] >= start_date) & (df['snapshot_date'] <= selected_date)]
|
||
|
||
# 按日期聚合
|
||
cols = df_range.columns.tolist()
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_cost = find_col(cols, ['成本', '總成本'])
|
||
col_profit = find_col(cols, ['毛利'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||
|
||
# 日期聚合
|
||
agg_dict = {}
|
||
if col_amount:
|
||
agg_dict[col_amount] = 'sum'
|
||
if col_cost:
|
||
agg_dict[col_cost] = 'sum'
|
||
if col_profit:
|
||
agg_dict[col_profit] = 'sum'
|
||
if col_qty:
|
||
agg_dict[col_qty] = 'sum'
|
||
|
||
daily_agg = df_range.groupby('snapshot_date').agg(agg_dict).reset_index()
|
||
|
||
# 計算或取得毛利(如果沒有毛利欄位,用業績-成本計算)
|
||
if col_profit and col_profit in daily_agg.columns:
|
||
daily_agg['profit'] = daily_agg[col_profit]
|
||
elif col_amount and col_cost and col_amount in daily_agg.columns and col_cost in daily_agg.columns:
|
||
daily_agg['profit'] = daily_agg[col_amount] - daily_agg[col_cost]
|
||
else:
|
||
daily_agg['profit'] = 0
|
||
|
||
# 計算客單價
|
||
if col_amount and col_qty and col_amount in daily_agg.columns and col_qty in daily_agg.columns:
|
||
daily_agg['avg_price'] = (daily_agg[col_amount] / daily_agg[col_qty]).fillna(0)
|
||
else:
|
||
daily_agg['avg_price'] = 0
|
||
|
||
# 計算 DoD (Day-over-Day) 變化率 - 多個維度
|
||
if col_amount and col_amount in daily_agg.columns:
|
||
daily_agg['dod_revenue'] = daily_agg[col_amount].pct_change() * 100
|
||
if 'profit' in daily_agg.columns:
|
||
daily_agg['dod_profit'] = daily_agg['profit'].pct_change() * 100
|
||
if 'avg_price' in daily_agg.columns:
|
||
daily_agg['dod_avg_price'] = daily_agg['avg_price'].pct_change() * 100
|
||
if col_qty and col_qty in daily_agg.columns:
|
||
daily_agg['dod_qty'] = daily_agg[col_qty].pct_change() * 100
|
||
|
||
# 計算 WoW (Week-over-Week) 變化率 - 多個維度
|
||
if col_amount and col_amount in daily_agg.columns:
|
||
daily_agg['wow_revenue'] = daily_agg[col_amount].pct_change(periods=7) * 100
|
||
if 'profit' in daily_agg.columns:
|
||
daily_agg['wow_profit'] = daily_agg['profit'].pct_change(periods=7) * 100
|
||
if 'avg_price' in daily_agg.columns:
|
||
daily_agg['wow_avg_price'] = daily_agg['avg_price'].pct_change(periods=7) * 100
|
||
if col_qty and col_qty in daily_agg.columns:
|
||
daily_agg['wow_qty'] = daily_agg[col_qty].pct_change(periods=7) * 100
|
||
|
||
# Top 10 商品(選擇的日期,包含廠商)
|
||
selected_df = df[df['snapshot_date'] == selected_date]
|
||
top10_labels = []
|
||
top10_values = []
|
||
|
||
if col_name and col_amount:
|
||
col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier'])
|
||
|
||
if col_vendor:
|
||
# 如果有廠商欄位,按商品+廠商聚合
|
||
top10_df = selected_df.groupby([col_name, col_vendor])[col_amount].sum().nlargest(10).reset_index()
|
||
top10_labels = [f"{row[col_name]} ({row[col_vendor]})" for _, row in top10_df.iterrows()]
|
||
top10_values = top10_df[col_amount].tolist()
|
||
else:
|
||
# 沒有廠商欄位,只按商品聚合
|
||
top10 = selected_df.groupby(col_name)[col_amount].sum().nlargest(10)
|
||
top10_labels = top10.index.tolist()
|
||
top10_values = top10.values.tolist()
|
||
|
||
return {
|
||
'labels': daily_agg['snapshot_date'].dt.strftime('%m/%d').tolist() if not daily_agg.empty else [],
|
||
'revenue': daily_agg[col_amount].tolist() if col_amount and col_amount in daily_agg.columns and not daily_agg.empty else [],
|
||
'cost': daily_agg[col_cost].tolist() if col_cost and col_cost in daily_agg.columns and not daily_agg.empty else [],
|
||
'profit': daily_agg['profit'].tolist() if 'profit' in daily_agg.columns and not daily_agg.empty else [],
|
||
'qty': daily_agg[col_qty].tolist() if col_qty and col_qty in daily_agg.columns and not daily_agg.empty else [],
|
||
'avg_price': daily_agg['avg_price'].tolist() if 'avg_price' in daily_agg.columns and not daily_agg.empty else [],
|
||
|
||
# DoD 多維度
|
||
'dod_revenue': daily_agg['dod_revenue'].fillna(0).tolist() if 'dod_revenue' in daily_agg.columns and not daily_agg.empty else [],
|
||
'dod_profit': daily_agg['dod_profit'].fillna(0).tolist() if 'dod_profit' in daily_agg.columns and not daily_agg.empty else [],
|
||
'dod_avg_price': daily_agg['dod_avg_price'].fillna(0).tolist() if 'dod_avg_price' in daily_agg.columns and not daily_agg.empty else [],
|
||
'dod_qty': daily_agg['dod_qty'].fillna(0).tolist() if 'dod_qty' in daily_agg.columns and not daily_agg.empty else [],
|
||
|
||
# WoW 多維度
|
||
'wow_revenue': daily_agg['wow_revenue'].fillna(0).tolist() if 'wow_revenue' in daily_agg.columns and not daily_agg.empty else [],
|
||
'wow_profit': daily_agg['wow_profit'].fillna(0).tolist() if 'wow_profit' in daily_agg.columns and not daily_agg.empty else [],
|
||
'wow_avg_price': daily_agg['wow_avg_price'].fillna(0).tolist() if 'wow_avg_price' in daily_agg.columns and not daily_agg.empty else [],
|
||
'wow_qty': daily_agg['wow_qty'].fillna(0).tolist() if 'wow_qty' in daily_agg.columns and not daily_agg.empty else [],
|
||
|
||
'top10_labels': top10_labels,
|
||
'top10_values': top10_values
|
||
}
|
||
|
||
def prepare_category_summary(df, date_str=None, is_month_view=False, month_start=None, month_end=None):
|
||
"""準備分類聚合列表 (支援單日或月度範圍)"""
|
||
if is_month_view and month_start is not None and month_end is not None:
|
||
day_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)]
|
||
else:
|
||
day_df = df[df['snapshot_date'] == date_str]
|
||
cols = day_df.columns.tolist()
|
||
|
||
col_category = find_col(cols, ['館別', '分類', 'Category'])
|
||
col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier'])
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '總業績'])
|
||
col_cost = find_col(cols, ['成本', '總成本'])
|
||
col_profit = find_col(cols, ['毛利'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||
|
||
if not col_category or not col_amount:
|
||
return []
|
||
|
||
# 分類 + 廠商聚合
|
||
agg_dict = {col_amount: 'sum'}
|
||
if col_cost:
|
||
agg_dict[col_cost] = 'sum'
|
||
if col_profit:
|
||
agg_dict[col_profit] = 'sum'
|
||
if col_qty:
|
||
agg_dict[col_qty] = 'sum'
|
||
if col_name:
|
||
agg_dict[col_name] = 'nunique'
|
||
|
||
# 如果有廠商欄位,按分類+廠商聚合;否則只按分類聚合
|
||
if col_vendor:
|
||
category_df = day_df.groupby([col_category, col_vendor]).agg(agg_dict).reset_index()
|
||
else:
|
||
category_df = day_df.groupby(col_category).agg(agg_dict).reset_index()
|
||
|
||
# 計算毛利(如果資料中沒有毛利欄位,自動計算)
|
||
if col_profit and col_profit in category_df.columns:
|
||
# 資料中有毛利欄位,直接使用
|
||
pass
|
||
elif col_amount and col_cost and col_amount in category_df.columns and col_cost in category_df.columns:
|
||
# 資料中沒有毛利欄位,用 業績 - 成本 計算
|
||
category_df['profit_calculated'] = category_df[col_amount] - category_df[col_cost]
|
||
col_profit = 'profit_calculated'
|
||
else:
|
||
col_profit = None
|
||
|
||
# 計算毛利率
|
||
if col_profit and col_profit in category_df.columns and col_amount and col_amount in category_df.columns:
|
||
category_df['margin_rate'] = (category_df[col_profit] / category_df[col_amount] * 100).fillna(0)
|
||
else:
|
||
category_df['margin_rate'] = 0
|
||
|
||
# 計算均價
|
||
if col_qty and col_amount:
|
||
category_df['avg_price'] = (category_df[col_amount] / category_df[col_qty]).fillna(0)
|
||
else:
|
||
category_df['avg_price'] = 0
|
||
|
||
# 重新命名欄位以便模板使用
|
||
rename_dict = {col_category: 'category', col_amount: 'revenue'}
|
||
if col_vendor:
|
||
rename_dict[col_vendor] = 'vendor'
|
||
if col_cost:
|
||
rename_dict[col_cost] = 'cost'
|
||
if col_profit and col_profit in category_df.columns:
|
||
rename_dict[col_profit] = 'profit'
|
||
if col_qty:
|
||
rename_dict[col_qty] = 'qty'
|
||
if col_name:
|
||
rename_dict[col_name] = 'sku_count'
|
||
|
||
category_df = category_df.rename(columns=rename_dict)
|
||
|
||
# 確保 profit 欄位存在,如果不存在則設為 0
|
||
if 'profit' not in category_df.columns:
|
||
category_df['profit'] = 0
|
||
|
||
# 轉為字典列表
|
||
return category_df.to_dict('records')
|
||
|
||
# V-New 2026-01-15: 行銷活動業績聚合函數
|
||
def prepare_marketing_summary(df, selected_date=None, is_month_view=False, month_start=None, month_end=None, sort_by='revenue'):
|
||
"""
|
||
準備行銷活動業績貢獻數據
|
||
支援單日模式和月度模式,並可指定排序維度 (revenue, qty, profit)
|
||
"""
|
||
# 決定使用的數據範圍
|
||
if is_month_view and month_start is not None and month_end is not None:
|
||
target_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)]
|
||
elif selected_date is not None:
|
||
target_df = df[df['snapshot_date'] == selected_date]
|
||
else:
|
||
target_df = df
|
||
|
||
if target_df.empty:
|
||
return {'coupon': [], 'discount': [], 'bonus': [], 'click': []}
|
||
|
||
cols = target_df.columns.tolist()
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量', 'Qty'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit', '利潤'])
|
||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||
|
||
if not col_amount:
|
||
return {'coupon': [], 'discount': [], 'bonus': [], 'click': []}
|
||
|
||
# 定義四種行銷活動欄位
|
||
marketing_cols = {
|
||
'coupon': '折價券活動名稱', # 折價券活動
|
||
'discount': '折扣活動名稱', # 折扣活動
|
||
'bonus': '滿額再折扣活動名稱', # 滿額再折扣
|
||
'click': '點我再折扣' # 點我再折扣
|
||
}
|
||
|
||
result = {}
|
||
|
||
# 確保 sort_by 欄位存在,否則退回 revenue
|
||
actual_sort_key = sort_by if sort_by in ['revenue', 'qty', 'profit'] else 'revenue'
|
||
|
||
for key, col_name in marketing_cols.items():
|
||
if col_name not in cols:
|
||
result[key] = []
|
||
continue
|
||
|
||
# 篩選有該行銷活動的記錄
|
||
activity_df = target_df[
|
||
(target_df[col_name].notna()) &
|
||
(target_df[col_name] != '') &
|
||
(target_df[col_name] != '0') &
|
||
(target_df[col_name] != 0)
|
||
]
|
||
|
||
if activity_df.empty:
|
||
result[key] = []
|
||
continue
|
||
|
||
# 聚合計算
|
||
agg_args = {
|
||
'revenue': (col_amount, 'sum'),
|
||
'order_count': (col_amount, 'count')
|
||
}
|
||
if col_qty: agg_args['qty'] = (col_qty, 'sum')
|
||
if col_profit: agg_args['profit'] = (col_profit, 'sum')
|
||
|
||
grouped = activity_df.groupby(col_name).agg(**agg_args).reset_index()
|
||
|
||
# 若需要手動計算毛利 (金額 - 成本)
|
||
if 'profit' not in agg_args and col_cost:
|
||
cost_agg = activity_df.groupby(col_name)[col_cost].sum().reset_index()
|
||
grouped = grouped.merge(cost_agg, on=col_name)
|
||
grouped['profit'] = grouped['revenue'] - grouped[col_cost]
|
||
|
||
grouped = grouped.rename(columns={col_name: 'name'})
|
||
|
||
# 動態排序
|
||
sort_col = actual_sort_key if actual_sort_key in grouped.columns else 'revenue'
|
||
grouped = grouped.sort_values(sort_col, ascending=False).head(15)
|
||
|
||
# 轉為字典列表
|
||
records = []
|
||
for _, row in grouped.iterrows():
|
||
record = {
|
||
'name': str(row['name'])[:50],
|
||
'revenue': float(row['revenue']),
|
||
'order_count': int(row['order_count'])
|
||
}
|
||
if 'qty' in row: record['qty'] = float(row['qty'])
|
||
if 'profit' in row: record['profit'] = float(row['profit'])
|
||
records.append(record)
|
||
|
||
result[key] = records
|
||
|
||
return result
|
||
|
||
|
||
def get_taiwan_holiday(date):
|
||
"""判斷是否為台灣國定假日,回傳 (is_holiday, holiday_name)"""
|
||
year = date.year
|
||
month = date.month
|
||
day = date.day
|
||
|
||
# 2026年台灣國定假日(根據人事行政總處公佈)
|
||
holidays_2026 = {
|
||
(1, 1): '元旦',
|
||
# 春節連假 (2/14-2/22,共9天)
|
||
(2, 14): '春節連假',
|
||
(2, 15): '小年夜',
|
||
(2, 16): '除夕',
|
||
(2, 17): '春節 (初一)',
|
||
(2, 18): '春節 (初二)',
|
||
(2, 19): '春節 (初三)',
|
||
(2, 20): '春節連假',
|
||
(2, 21): '春節連假',
|
||
(2, 22): '春節連假',
|
||
# 和平紀念日 (2/28-3/2,共3天)
|
||
(2, 28): '和平紀念日',
|
||
(3, 2): '和平紀念日補假',
|
||
# 兒童節+清明節 (4/3-4/6,共4天)
|
||
(4, 3): '兒童節補假',
|
||
(4, 4): '清明節',
|
||
(4, 5): '清明節連假',
|
||
(4, 6): '清明節補假',
|
||
# 勞動節 (5/1-5/3,共3天)
|
||
(5, 1): '勞動節',
|
||
# 端午節 (6/19-6/21,共3天)
|
||
(6, 19): '端午節',
|
||
# 中秋節+教師節 (9/25-9/28,共4天)
|
||
(9, 25): '中秋節',
|
||
(9, 28): '教師節',
|
||
# 國慶日 (10/9-10/11,共3天)
|
||
(10, 9): '國慶日補假',
|
||
(10, 10): '國慶日',
|
||
# 光復節 (10/25-10/26,共2天)
|
||
(10, 25): '臺灣光復節',
|
||
(10, 26): '光復節補假',
|
||
# 行憲紀念日 (12/25-12/27,共3天)
|
||
(12, 25): '行憲紀念日',
|
||
}
|
||
|
||
# 2027年台灣國定假日(預先計算部分)
|
||
holidays_2027 = {
|
||
(1, 1): '元旦',
|
||
(2, 11): '春節 (除夕)',
|
||
(2, 12): '春節 (初一)',
|
||
(2, 13): '春節 (初二)',
|
||
(2, 14): '春節 (初三)',
|
||
(2, 15): '春節 (初四)',
|
||
(2, 16): '春節 (初五)',
|
||
(2, 17): '春節 (初六)',
|
||
(2, 28): '和平紀念日',
|
||
(4, 4): '清明節',
|
||
(4, 5): '清明節連假',
|
||
(6, 14): '端午節',
|
||
(9, 21): '中秋節',
|
||
(10, 10): '國慶日',
|
||
(10, 11): '國慶日連假',
|
||
}
|
||
|
||
holidays = holidays_2026 if year == 2026 else (holidays_2027 if year == 2027 else {})
|
||
|
||
holiday_name = holidays.get((month, day))
|
||
return (True, holiday_name) if holiday_name else (False, None)
|
||
|
||
def prepare_calendar_data(df, selected_month):
|
||
"""準備行事曆數據(豐富版:顯示總業績、毛利、SKU數 + DoD%)"""
|
||
import calendar
|
||
|
||
# 取得該月份的年月
|
||
year = selected_month.year
|
||
month = selected_month.month
|
||
|
||
# 計算該月第一天和最後一天
|
||
first_day = pd.Timestamp(year=year, month=month, day=1)
|
||
last_day = pd.Timestamp(year=year, month=month, day=calendar.monthrange(year, month)[1])
|
||
|
||
# 計算行事曆顯示範圍(包含前後月份的日期以填滿週)
|
||
# 取得該月第一天是星期幾 (0=Monday, 6=Sunday)
|
||
first_weekday = first_day.weekday()
|
||
|
||
# 計算行事曆起始日(從週一開始)
|
||
calendar_start = first_day - timedelta(days=first_weekday)
|
||
|
||
# 計算該月最後一天是星期幾
|
||
last_weekday = last_day.weekday()
|
||
|
||
# 計算行事曆結束日(到週日結束)
|
||
calendar_end = last_day + timedelta(days=(6 - last_weekday))
|
||
|
||
# 取得該月份及前後各一天的所有資料(用於計算 DoD)
|
||
data_start = first_day - timedelta(days=1)
|
||
data_end = last_day
|
||
month_df = df[(df['snapshot_date'] >= data_start) & (df['snapshot_date'] <= data_end)]
|
||
|
||
# 取得欄位
|
||
cols = df.columns.tolist()
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_cost = find_col(cols, ['成本', 'Cost'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||
|
||
# 為每一天計算 KPI
|
||
calendar_days = []
|
||
current_date = calendar_start
|
||
|
||
while current_date <= calendar_end:
|
||
# 取得星期(0=週一, 6=週日)
|
||
weekday = current_date.weekday()
|
||
weekday_names = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
|
||
|
||
# 判斷是否為國定假日
|
||
is_holiday, holiday_name = get_taiwan_holiday(current_date)
|
||
|
||
day_data = {
|
||
'date': current_date.strftime('%Y-%m-%d'),
|
||
'day': current_date.day,
|
||
'weekday': weekday_names[weekday],
|
||
'is_weekend': weekday >= 5, # 週六或週日
|
||
'is_holiday': is_holiday,
|
||
'holiday_name': holiday_name,
|
||
'is_current_month': current_date.month == month,
|
||
'has_data': False,
|
||
'revenue': 0,
|
||
'profit': 0,
|
||
'margin_rate': 0,
|
||
'sku_count': 0,
|
||
'qty': 0,
|
||
'avg_price': 0,
|
||
'dod_percent': 0,
|
||
'dod_direction': 'neutral' # 'up', 'down', 'neutral'
|
||
}
|
||
|
||
# 如果該日期在當前月份範圍內,計算 KPI
|
||
if first_day <= current_date <= last_day:
|
||
day_df = month_df[month_df['snapshot_date'] == current_date]
|
||
|
||
if not day_df.empty:
|
||
day_data['has_data'] = True
|
||
|
||
# 計算總業績
|
||
if col_amount:
|
||
day_data['revenue'] = float(day_df[col_amount].sum())
|
||
|
||
# 計算毛利(優先使用毛利欄位,否則用業績-成本計算)
|
||
if col_profit:
|
||
day_data['profit'] = float(day_df[col_profit].sum())
|
||
elif col_cost and col_amount:
|
||
total_cost = float(day_df[col_cost].sum())
|
||
day_data['profit'] = day_data['revenue'] - total_cost
|
||
|
||
# 計算毛利率
|
||
if day_data['revenue'] > 0:
|
||
day_data['margin_rate'] = (day_data['profit'] / day_data['revenue']) * 100
|
||
|
||
# 計算銷量
|
||
if col_qty:
|
||
day_data['qty'] = float(day_df[col_qty].sum())
|
||
|
||
# 計算客單價(總業績 / 總銷量)
|
||
if day_data['qty'] > 0:
|
||
day_data['avg_price'] = day_data['revenue'] / day_data['qty']
|
||
|
||
# 計算 SKU 數
|
||
if col_name:
|
||
day_data['sku_count'] = int(day_df[col_name].nunique())
|
||
|
||
# 計算 DoD%
|
||
prev_date = current_date - timedelta(days=1)
|
||
prev_df = month_df[month_df['snapshot_date'] == prev_date]
|
||
|
||
if not prev_df.empty and col_amount:
|
||
prev_revenue = float(prev_df[col_amount].sum())
|
||
if prev_revenue > 0:
|
||
dod = ((day_data['revenue'] - prev_revenue) / prev_revenue) * 100
|
||
day_data['dod_percent'] = round(dod, 1)
|
||
day_data['dod_direction'] = 'up' if dod >= 0 else 'down'
|
||
|
||
calendar_days.append(day_data)
|
||
current_date += timedelta(days=1)
|
||
|
||
# 組織成週結構(每週 7 天)
|
||
weeks = []
|
||
for i in range(0, len(calendar_days), 7):
|
||
weeks.append(calendar_days[i:i+7])
|
||
|
||
# 計算上個月和下個月的年月
|
||
prev_month = selected_month - pd.DateOffset(months=1)
|
||
next_month = selected_month + pd.DateOffset(months=1)
|
||
|
||
return {
|
||
'year': year,
|
||
'month': month,
|
||
'month_name': selected_month.strftime('%Y年%m月'),
|
||
'weeks': weeks,
|
||
'prev_month': prev_month.strftime('%Y-%m'),
|
||
'next_month': next_month.strftime('%Y-%m')
|
||
}
|
||
|
||
# ================= ⚙️ 5. 服務啟動邏輯 =================
|
||
|
||
def run_schedule():
|
||
"""在背景執行緒中運行排程"""
|
||
sys_log.info("🚀 排程服務已啟動,等待任務...")
|
||
while True:
|
||
schedule.run_pending()
|
||
time.sleep(1)
|
||
|
||
def init_scheduler():
|
||
"""初始化排程任務(Gunicorn 模式下也會執行)"""
|
||
schedule.every(1).hours.do(run_momo_task)
|
||
schedule.every(1).hours.do(run_edm_task)
|
||
schedule.every(1).hours.do(run_festival_task)
|
||
sys_log.info(f"📅 已設定每小時執行主站、EDM與購物節爬蟲任務")
|
||
|
||
schedule.every(30).minutes.do(run_auto_import_task)
|
||
sys_log.info(f"📅 已設定每 30 分鐘執行 Google Drive 自動匯入任務")
|
||
|
||
schedule.every(30).minutes.do(run_whitepage_check)
|
||
sys_log.info(f"📅 已設定每 30 分鐘執行網頁白頁監控任務")
|
||
|
||
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}")
|