# ================= TODO LIST (待辦事項 - 重開機後請依序執行) ================= # 1. [驗證] 重啟 app.py 後,重新匯入 Excel,確認「自動去重」功能是否生效 (重複匯入應顯示 0 筆新增)。 # 2. [檢查] 前往 /sales_analysis 頁面,確認 '狀態' 欄位是否正確顯示 'F' (目前為原始匯入模式)。 # 3. [決策] 若資料顯示正常,評估是否需要恢復「智慧資料清理」邏輯 (目前程式碼第 1160 行左右已註解)。 # 4. [備份] 確認系統運作正常後,執行系統備份。 # ======================================================================= import os import sys import time import threading import math import json import hashlib import shutil import zipfile import re import io # V-New: 用於 Excel 匯出 import traceback # V-Fix: 用於錯誤追蹤 from datetime import datetime, timedelta, timezone # ================= 🔧 1. 環境與路徑鎖定 ================= BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # 確保專案根目錄在 sys.path 的最前面,優先讀取本地模組 sys.path.insert(0, BASE_DIR) # 自動檢核並建立必要目錄 try: for folder in ['database', 'services', 'crawler', 'logs', 'data', 'web/templates', 'web/static']: folder_path = os.path.join(BASE_DIR, folder) if not os.path.exists(folder_path): os.makedirs(folder_path) # 僅針對 Python 套件目錄建立 __init__.py if 'web' not in folder: init_file = os.path.join(folder_path, '__init__.py') if not os.path.exists(init_file): with open(init_file, 'w') as f: pass except OSError as e: print(f"❌ 系統初始化失敗: 無法建立目錄或檔案 (磁碟可能已滿) - {e}") # ================= 🔧 2. 核心模組導入 ================= try: from flask import Flask, render_template, jsonify, request, send_file, redirect, url_for, send_from_directory, flash, session from werkzeug.utils import secure_filename from pyngrok import ngrok, conf import schedule from sqlalchemy import desc, and_, func, text, literal, case from sqlalchemy import inspect # V-New: 用於檢查資料表是否存在 from sqlalchemy.orm import joinedload import pandas as pd # type: ignore from pandas.api.types import is_numeric_dtype # type: ignore import numpy as np # type: ignore # V-Opt: 引入 numpy 進行向量化運算加速 # 導入自定義模組 try: from scheduler import run_momo_task, run_edm_task, run_festival_task, run_auto_import_task, run_whitepage_check, run_competitor_price_feeder_task from database.manager import DatabaseManager from database.models import Base, Product, PriceRecord, MonthlySummaryAnalysis from database.edm_models import PromoProduct except ImportError as e: print(f"❌ 專案內部檔案缺失: {e}\n請檢查 database/ 或 services/ 目錄下的 .py 檔案是否存在。") sys.exit(1) from services.logger_manager import SystemLogger from services.exporter import Exporter # 🚩 導入匯出模組 except ImportError as e: print(f"❌ 關鍵套件導入失敗: {e}") sys.exit(1) # ================= 🔧 3. 系統核心配置 ================= # 從 config.py 匯入必要的設定 from config import EXCEL_EXPORT_DIR, DATABASE_TYPE, validate_critical_config sys_log = SystemLogger("Web_Server").get_logger() # 驗證選用配置,缺少時輸出 warning(非 fatal) for _warn in validate_critical_config(): sys_log.warning(_warn) # 🚩 V-Opt: 全域資料快取 (用於加速業績分析) _SALES_DF_CACHE = {} # 已棄用,保留相容性 _SALES_PROCESSED_CACHE = {} # V-Opt: 處理後資料快取 _SALES_CACHE_MAX_ENTRIES = 10 # V-Opt (2026-01-23): 快取最大條目數 _SALES_CACHE_TTL = 600 # V-Opt (2026-01-23): 快取有效期 10 分鐘 def _cleanup_sales_cache(): """清理過期和過多的快取條目""" global _SALES_PROCESSED_CACHE current_time = time.time() # 1. 清理過期條目 expired_keys = [ k for k, v in _SALES_PROCESSED_CACHE.items() if v.get('time') and current_time - v['time'] > _SALES_CACHE_TTL ] for k in expired_keys: del _SALES_PROCESSED_CACHE[k] # 2. 如果仍超過限制,刪除最舊的條目 if len(_SALES_PROCESSED_CACHE) > _SALES_CACHE_MAX_ENTRIES: sorted_items = sorted( [(k, v.get('time', 0)) for k, v in _SALES_PROCESSED_CACHE.items()], key=lambda x: x[1] ) # 保留最新的 _SALES_CACHE_MAX_ENTRIES 條 keys_to_delete = [k for k, _ in sorted_items[:-_SALES_CACHE_MAX_ENTRIES]] for k in keys_to_delete: del _SALES_PROCESSED_CACHE[k] if expired_keys or len(_SALES_PROCESSED_CACHE) > _SALES_CACHE_MAX_ENTRIES - 2: sys_log.debug(f"[Cache] 清理快取: 移除 {len(expired_keys)} 條過期, 剩餘 {len(_SALES_PROCESSED_CACHE)} 條") # 🚩 V-New: 商品看板資料快取 (用於加速首頁載入) _DASHBOARD_DATA_CACHE = { 'consolidated_data': None, # get_consolidated_data() 結果 'consolidated_timestamp': None, # 快取時間戳記 'stats_data': None, # 統計資料 'stats_timestamp': None # 統計資料時間戳記 } _DASHBOARD_CACHE_TTL = 300 # 快取有效期 5 分鐘(秒) # 🚩 檢查磁碟空間 (V9.52 新增) try: total, used, free = shutil.disk_usage(BASE_DIR) if free < 200 * 1024 * 1024: # 小於 200MB sys_log.critical(f"[System] [DISK_CHECK] 🚨 嚴重警告: 磁碟空間極低 | Free: {free // (1024*1024)} MB") elif free < 1024 * 1024 * 1024: # 小於 1GB sys_log.warning(f"[System] [DISK_CHECK] ⚠️ 警告: 磁碟空間不足 1GB | Free: {free // (1024*1024)} MB") except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) # 🚩 2026-04-19 V10.3: 技術債清零 — Migration 010/011、retry queue 持久化、 # NemoTron store_insight 雙寫、import 前置欄位防禦、時間衰減 RAG SYSTEM_VERSION = "V10.3" # ========================================== # 🔒 SQL Injection 防護函數 # ========================================== # 允許的資料表白名單 # 安全工具:實作已搬至 utils/security.py,此處 re-export 維持向後相容 from utils.security import ( # noqa: E402 ALLOWED_TABLES, validate_table_name, validate_column_names, ) # 安全工具:路徑遍歷 + 檔案上傳驗證 + safe_read_sql 已搬至 utils/security.py from utils.security import ( # noqa: E402 safe_read_sql, safe_join, ALLOWED_UPLOAD_EXTENSIONS, ALLOWED_MIME_TYPES, secure_filename_unicode, allowed_file, validate_upload_file, ) # 🚩 資料庫結構自動修復 (V9.53 新增) — 實作搬至 database/schema_repair.py from database.schema_repair import repair_database_schema # noqa: E402, F401 # 從環境變數讀取 NGROK_AUTH_TOKEN(如果未設定則使用原值,但會發出警告) NGROK_AUTH_TOKEN = os.getenv('NGROK_AUTH_TOKEN', '36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF') if NGROK_AUTH_TOKEN == '36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF': sys_log.warning("[Security] ⚠️ 使用預設 NGROK_AUTH_TOKEN,請設定環境變數") conf.get_default().auth_token = NGROK_AUTH_TOKEN TEMPLATE_DIR = BASE_DIR # 修正:根據檔案結構,模板位於根目錄 TEMPLATE_DIR_NEW = os.path.join(BASE_DIR, 'templates') # 新模板路徑(模組化) STATIC_DIR = os.path.join(BASE_DIR, 'web/static') # 檢查關鍵模板是否存在 if not os.path.exists(os.path.join(BASE_DIR, 'dashboard.html')): sys_log.warning(f"[Web] [Template] ⚠️ 警告: 找不到 dashboard.html | Path: {TEMPLATE_DIR}") app = Flask(__name__, template_folder=TEMPLATE_DIR, static_folder=STATIC_DIR) # 設定多路徑模板載入器(同時支援根目錄和 templates/ 目錄) from jinja2 import FileSystemLoader, ChoiceLoader app.jinja_loader = ChoiceLoader([ FileSystemLoader(TEMPLATE_DIR_NEW), # templates/ 目錄優先 FileSystemLoader(TEMPLATE_DIR), # 根目錄備用 ]) # ========================================== # 🔒 Flask 安全配置 # ========================================== # 從 config.py 導入 SECRET_KEY from config import SECRET_KEY # 基本配置 app.config['SECRET_KEY'] = SECRET_KEY # Session 安全配置 app.config['SESSION_COOKIE_HTTPONLY'] = True # 防止 JavaScript 存取 cookie(防 XSS) app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # 防止 CSRF 攻擊 app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=24) # Session 有效期 24 小時(延長避免長時間閒置斷線) # 如果使用 HTTPS,啟用 SECURE cookie(本地開發時應設為 False) # 注意:如果您的系統部署在 HTTPS 環境,請將 .env 中的 USE_HTTPS 設為 true USE_HTTPS = os.getenv('USE_HTTPS', 'false').lower() == 'true' if USE_HTTPS: app.config['SESSION_COOKIE_SECURE'] = True sys_log.info("[Security] ✅ HTTPS 模式已啟用,Session cookie 僅透過 HTTPS 傳輸") else: app.config['SESSION_COOKIE_SECURE'] = False sys_log.warning("[Security] ⚠️ HTTP 模式(開發環境),Session cookie 未強制 HTTPS") # 檔案上傳大小限制(10MB) # V-New: 提高檔案上傳大小限制 (從 10MB 提高到 100MB) app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 sys_log.info("[Security] ✅ Flask 安全配置已載入") sys_log.info(f"[Security] • Session 有效期: 2 小時") sys_log.info(f"[Security] • 檔案上傳限制: 10 MB") sys_log.info(f"[Security] • CSRF 防護: SameSite=Lax") sys_log.info(f"[Security] • XSS 防護: HttpOnly=True") # ========================================== # 🔒 CSRF 防護配置 # ========================================== from flask_wtf.csrf import CSRFProtect csrf = CSRFProtect(app) sys_log.info("[Security] ✅ CSRF 防護已啟用 (Flask-WTF)") # ========================================== # 🔧 Blueprint 註冊 - 廠商缺貨系統 # ========================================== from routes.vendor_routes import vendor_bp app.register_blueprint(vendor_bp) sys_log.info("[Blueprint] ✅ 廠商缺貨系統 Blueprint 已註冊") # ========================================== # 🔧 Blueprint 註冊 - Google Drive 自動匯入 # ========================================== from routes.auto_import_routes import auto_import_bp app.register_blueprint(auto_import_bp) csrf.exempt(auto_import_bp) sys_log.info("[Blueprint] ✅ Google Drive 自動匯入 Blueprint 已註冊 (CSRF 已豁免)") # ========================================== # 🔧 Blueprint 註冊 - 爬蟲管理系統 # ========================================== from routes.crawler_management_routes import crawler_bp app.register_blueprint(crawler_bp) sys_log.info("[Blueprint] ✅ 爬蟲管理系統 Blueprint 已註冊") # ========================================== # 🔧 Blueprint 註冊 - AI 智慧文案系統 # ========================================== from routes.ai_routes import ai_bp app.register_blueprint(ai_bp) csrf.exempt(ai_bp) # ICAIM API 使用內部呼叫,不需要 CSRF sys_log.info("[Blueprint] ✅ AI 智慧文案系統 Blueprint 已註冊") # ========================================== # 🔧 Blueprint 註冊 - CI/CD Dashboard # ========================================== from routes.cicd_routes import cicd_bp app.register_blueprint(cicd_bp) csrf.exempt(cicd_bp) # CI/CD API doesn't need CSRF sys_log.info("[Blueprint] CI/CD Dashboard Blueprint registered") # ========================================== # 🔧 Blueprint 註冊 - Code Review 系統 # ========================================== try: from routes.code_review_routes import code_review_bp app.register_blueprint(code_review_bp) csrf.exempt(code_review_bp) # Code Review API 使用內部認證,不需要 CSRF sys_log.info("[Blueprint] ✅ Code Review 系統 Blueprint 已註冊 (CSRF 已豁免)") except Exception as _e: sys_log.warning(f"[Blueprint] ⚠️ Code Review 系統 Blueprint 註冊失敗: {_e}") # ========================================== # 🔧 Blueprint 註冊 - 趨勢資料系統 # ========================================== from routes.trend_routes import trend_bp app.register_blueprint(trend_bp) sys_log.info("[Blueprint] ✅ 趨勢資料系統 Blueprint 已註冊") # ========================================== # 🔒 Auth 路由註冊 - 登入/登出 # ========================================== from auth import init_auth_routes, login_required init_auth_routes(app) sys_log.info("[Auth] ✅ 登入/登出路由已註冊") # ========================================== # 🔧 Blueprint 註冊 - 用戶管理系統 # ========================================== from routes.user_routes import user_bp app.register_blueprint(user_bp) sys_log.info("[Blueprint] ✅ 用戶管理系統 Blueprint 已註冊") # ========================================== # 🚨 Blueprint 註冊 - 系統告警 # ========================================== from routes.alert_routes import alert_bp app.register_blueprint(alert_bp) csrf.exempt(alert_bp) sys_log.info("[Blueprint] ✅ 系統告警 Blueprint 已註冊 (CSRF 已豁免)") # ========================================== # 系統管理路由 Blueprint # ========================================== from routes.system_routes import system_bp app.register_blueprint(system_bp) csrf.exempt(system_bp) # n8n API 需要豁免 CSRF sys_log.info("[Blueprint] ✅ 系統管理 Blueprint 已註冊 (CSRF 已豁免)") from routes.category_routes import category_bp app.register_blueprint(category_bp) sys_log.info("[Blueprint] ✅ 分類 CRUD Blueprint 已註冊") from routes.misc_routes import misc_bp app.register_blueprint(misc_bp) sys_log.info("[Blueprint] ✅ 雜項 Routes Blueprint 已註冊 (/api/test_url, /brand_assets)") # ========================================== # 通知模板管理 Blueprint # ========================================== from routes.notification_routes import notification_bp app.register_blueprint(notification_bp) csrf.exempt(notification_bp) # n8n API 需要豁免 CSRF sys_log.info("[Blueprint] ✅ 通知模板管理 Blueprint 已註冊") # ========================================== # Bot API Blueprint (Clawdbot 整合) # ========================================== from routes.bot_api_routes import bot_api_bp app.register_blueprint(bot_api_bp) csrf.exempt(bot_api_bp) # Bot API 使用 Token 認證,不需要 CSRF sys_log.info("[Blueprint] ✅ Bot API Blueprint 已註冊") # ========================================== # Elephant Alpha AI Agent Super Orchestrator Blueprint # ========================================== try: from routes.elephant_alpha_routes import elephant_alpha_bp app.register_blueprint(elephant_alpha_bp) csrf.exempt(elephant_alpha_bp) # Elephant Alpha API uses internal auth sys_log.info("[Blueprint] Elephant Alpha AI Agent Super Orchestrator Blueprint registered") except Exception as _e: sys_log.warning(f"[Blueprint] Elephant Alpha registration failed: {_e}") sys_log.info("[Blueprint] Elephant Alpha features will be unavailable") # [2026-04-18 台北] OpenClaw Bot Blueprint — 修復 /menu 啞巴 (/bot/telegram/webhook 404) # 原因:routes/openclaw_bot_routes.py 有 5000+ 行完整 telegram bot handler,但 app.py 從未 register # 效果:Telegram 送進來的 update (包含 /menu) 能被正確接收與處理 try: from routes.openclaw_bot_routes import openclaw_bot_bp app.register_blueprint(openclaw_bot_bp) csrf.exempt(openclaw_bot_bp) # Telegram webhook 不需要 CSRF sys_log.info("[Blueprint] ✅ OpenClaw Bot Blueprint 已註冊 (Telegram /menu 復活)") except Exception as _e: sys_log.error(f"[Blueprint] ❌ OpenClaw Bot Blueprint 註冊失敗: {_e}") # P0-12 修復:補齊缺少的 Blueprint 註冊 for _bp_module, _bp_name in [ ('routes.api_routes', 'api_bp'), ('routes.edm_routes', 'edm_bp'), ('routes.sales_routes', 'sales_bp'), ('routes.monthly_routes', 'monthly_bp'), ('routes.price_comparison_routes', 'price_comparison_bp'), ('routes.export_routes', 'export_bp'), ('routes.daily_sales_routes', 'daily_sales_bp'), ('routes.dashboard_routes', 'dashboard_bp'), ('routes.import_routes', 'import_bp'), ('routes.pchome_routes', 'pchome_bp'), ]: try: import importlib as _il _mod = _il.import_module(_bp_module) _bp = getattr(_mod, _bp_name) app.register_blueprint(_bp) sys_log.info(f"[Blueprint] ✅ {_bp_name} 已註冊") except Exception as _e: sys_log.error(f"[Blueprint] ❌ {_bp_name} 註冊失敗: {_e}") # V-Fix: 註冊 slugify 函數供模板使用(實作搬至 utils/text_helpers.py) from utils.text_helpers import slugify # noqa: E402 LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = "服務啟動中..." # 🚩 時區設定:台北時間 (UTC+8) TAIPEI_TZ = timezone(timedelta(hours=8)) EXPECTED_METADATA_TABLES = { 'categories', 'products', 'price_records', 'monthly_summary_analysis', 'users', 'login_history', 'permissions', 'user_permissions', 'promo_products', 'trend_records', 'trend_keywords', 'trend_analysis', 'web_search_cache', 'telegram_users', 'ai_generation_history', 'ai_prompt_templates', 'ai_usage_tracking', 'ai_insights', 'agent_context', 'action_plans', 'action_outcomes', 'agent_strategy_weights', 'incidents', 'playbooks', 'heal_logs', 'import_jobs', 'import_config', 'notification_templates', 'ppt_reports', 'vendor_stockout', 'vendor_list', 'vendor_emails', 'email_send_log', 'realtime_sales_monthly', } def verify_metadata_tables(): missing = EXPECTED_METADATA_TABLES - set(Base.metadata.tables.keys()) if missing: raise SystemExit(f"Base.metadata 漏表: {sorted(missing)}") verify_metadata_tables() # ========================================== # 🔧 全域模板變數注入 (Context Processor) # ========================================== from config import METABASE_URL, GRIST_URL @app.context_processor def inject_global_vars(): """注入全域變數到所有模板""" return { 'metabase_url': METABASE_URL, 'grist_url': GRIST_URL, 'datetime_now': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'), } sys_log.info("[Template] ✅ 全域模板變數已注入 (metabase_url, grist_url)") # ================= 🛠️ V9.72: 分類設定管理核心 ================= CATEGORIES_JSON_PATH = os.path.join(BASE_DIR, 'data', 'categories.json') # JSON 持久化:實作搬至 services/json_storage.py from services.json_storage import ( # noqa: E402, F401 load_categories, save_categories, load_scheduler_stats, ) # ================= 🛠️ 數據處理核心 (封裝) ================= # 純工具:實作已搬至 utils/text_helpers.py from utils.text_helpers import ( # noqa: E402 get_color_for_string, extract_snapshot_date_from_filename, number_format as _number_format, ) @app.template_filter('number_format') def number_format_filter(value): """Jinja filter wrapper — 實作見 utils.text_helpers.number_format。""" return _number_format(value) # V-Refactor: 將 find_col 移至全域,方便多個函式共用 from utils.df_helpers import find_col # noqa: E402 def get_consolidated_data(): """🚩 統一封裝:獲取全分類去重後的當前數據、昨日對比及差值 (V-Opt: 優化查詢效能 + 快取)""" global _DASHBOARD_DATA_CACHE # V-New: 檢查快取是否有效 now = datetime.now(TAIPEI_TZ) if (_DASHBOARD_DATA_CACHE['consolidated_data'] is not None and _DASHBOARD_DATA_CACHE['consolidated_timestamp'] is not None): cache_age = (now.timestamp() - _DASHBOARD_DATA_CACHE['consolidated_timestamp']) if cache_age < _DASHBOARD_CACHE_TTL: sys_log.debug(f"[Dashboard] [Cache] ✅ 使用快取資料 | 快取年齡: {cache_age:.1f}秒") return _DASHBOARD_DATA_CACHE['consolidated_data'], _DASHBOARD_DATA_CACHE['today_start'] sys_log.debug("[Dashboard] [Cache] 🔄 快取過期或不存在,重新查詢資料庫") db = DatabaseManager() session = db.get_session() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) seven_days_ago = today_start - timedelta(days=7) thirty_days_ago = today_start - timedelta(days=30) try: # Query 1: Get the latest price record for every product. This is our main list of items. latest_price_subq = session.query( func.max(PriceRecord.id).label('max_id') ).group_by(PriceRecord.product_id).subquery() latest_records = session.query(PriceRecord).options( joinedload(PriceRecord.product) ).join(latest_price_subq, PriceRecord.id == latest_price_subq.c.max_id).all() product_ids = [r.product_id for r in latest_records] if not product_ids: session.close() # 提前關閉連線 return [], today_start # Query 2: Get yesterday's closing prices for all products in one go yesterday_prices_subq = session.query( PriceRecord.product_id, func.max(PriceRecord.id).label('max_id') ).filter( PriceRecord.product_id.in_(product_ids), PriceRecord.timestamp < today_start ).group_by(PriceRecord.product_id).subquery() yesterday_prices_q = session.query( PriceRecord.product_id, PriceRecord.price ).join( yesterday_prices_subq, PriceRecord.id == yesterday_prices_subq.c.max_id ) yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q} # Query 3: Get specific historical price points (7 days ago and 30 days ago) # Instead of fetching ALL history, we fetch only the records closest to the target dates. # This is a significant optimization. # Helper to get price map for a specific date (start of day) def get_price_map_before(target_date): subq = session.query( PriceRecord.product_id, func.max(PriceRecord.timestamp).label('max_ts') ).filter( PriceRecord.product_id.in_(product_ids), PriceRecord.timestamp < target_date ).group_by(PriceRecord.product_id).subquery() q = session.query(PriceRecord.product_id, PriceRecord.price).join( subq, and_(PriceRecord.product_id == subq.c.product_id, PriceRecord.timestamp == subq.c.max_ts) ) return {pid: price for pid, price in q} prices_7d_ago_map = get_price_map_before(seven_days_ago + timedelta(days=1)) # Approximate 7 days ago prices_30d_ago_map = get_price_map_before(thirty_days_ago + timedelta(days=1)) # Approximate 30 days ago # Query 4: Get TODAY's records only (for sparkline/intraday change) today_records_q = session.query(PriceRecord).filter( PriceRecord.product_id.in_(product_ids), PriceRecord.timestamp >= today_start ).order_by(PriceRecord.product_id, PriceRecord.timestamp).all() today_map = {} for r in today_records_q: if r.product_id not in today_map: today_map[r.product_id] = [] today_map[r.product_id].append(r) # Final Assembly (in-memory, no more DB queries) unique_items = [] for r in latest_records: pid = r.product_id # 7d/30d stats price_7d = prices_7d_ago_map.get(pid) price_30d = prices_30d_ago_map.get(pid) stats_7d_diff = r.price - price_7d if price_7d is not None else 0 stats_30d_diff = r.price - price_30d if price_30d is not None else 0 # Today's stats today_records = today_map.get(pid, []) today_diff = 0 today_changes = [] if len(today_records) > 1: today_diff = today_records[-1].price - today_records[0].price # Yesterday diff y_price = yesterday_prices_map.get(pid) yesterday_diff = r.price - y_price if y_price is not None else 0 status = "NONE" if yesterday_diff > 0: status = "PRICE_UP" elif yesterday_diff < 0: status = "PRICE_DOWN" # Today's changes details last_p = y_price if y_price is not None else (today_records[0].price if today_records else r.price) for tr in today_records: if tr.price != last_p: diff = tr.price - last_p today_changes.append({ 'time': tr.timestamp.strftime('%H:%M'), 'price': tr.price, 'diff': diff }) last_p = tr.price unique_items.append({ 'record': r, 'stats': {'7d_diff': stats_7d_diff, '30d_diff': stats_30d_diff, '1d_diff': today_diff}, 'yesterday_diff': yesterday_diff, 'today_changes': today_changes, 'status': status }) # V-New: 更新快取 _DASHBOARD_DATA_CACHE['consolidated_data'] = unique_items _DASHBOARD_DATA_CACHE['consolidated_timestamp'] = now.timestamp() _DASHBOARD_DATA_CACHE['today_start'] = today_start sys_log.debug(f"[Dashboard] [Cache] 💾 快取已更新 | 商品數: {len(unique_items)}") return unique_items, today_start finally: session.close() def get_dashboard_stats(): """計算看板統計數據 (供通知使用)""" db = DatabaseManager() session = db.get_session() try: unique_items, today_start = get_consolidated_data() today_start_db = today_start.replace(tzinfo=None) # 1. 漲跌 increase_count = sum(1 for item in unique_items if item['yesterday_diff'] > 0) decrease_count = sum(1 for item in unique_items if item['yesterday_diff'] < 0) # 2. 今日新增 (使用與 index 路由相同的邏輯) new_pids_query = session.query(PriceRecord.product_id).group_by(PriceRecord.product_id).having(func.min(PriceRecord.timestamp) >= today_start_db) new_product_ids = {r[0] for r in new_pids_query.all()} new_count = len(new_product_ids) # 3. 今日下架 today_delisted_count = session.query(Product).filter( Product.status == 'INACTIVE', Product.updated_at >= today_start_db ).count() return {'new': new_count, 'up': increase_count, 'down': decrease_count, 'delisted': today_delisted_count} except Exception as e: sys_log.error(f"[Stats] ❌ 計算統計失敗: {e}") return {'new': 0, 'up': 0, 'down': 0, 'delisted': 0} finally: session.close() # ================= 🛣️ 4. Flask 路由 ================= # Session 自動續期機制 @app.before_request def refresh_session(): """ 在每次請求時自動刷新 Session,避免長時間閒置後突然斷線 只要用戶有任何操作,Session 就會自動延長 """ if session.get('logged_in'): session.modified = True # 標記 Session 已修改,觸發 Cookie 更新 @app.route('/health') def health_check(): """健康檢查端點 - 供 Nginx 和 Docker healthcheck 使用""" try: # 簡單檢查資料庫連線 from config import DATABASE_TYPE return jsonify({ 'status': 'healthy', 'database': DATABASE_TYPE, 'version': SYSTEM_VERSION }), 200 except Exception as e: return jsonify({ 'status': 'unhealthy', 'error': str(e) }), 500 @app.route('/metrics') def prometheus_metrics(): """Prometheus 指標端點 - 供 Prometheus 抓取監控資料""" try: from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Counter, Gauge, CollectorRegistry from config import DATABASE_TYPE # 建立獨立的 registry 以避免重複註冊 registry = CollectorRegistry() # 應用程式資訊 app_info = Gauge('momo_app_info', '應用程式資訊', ['version', 'database_type'], registry=registry) app_info.labels(version=SYSTEM_VERSION, database_type=DATABASE_TYPE).set(1) # 應用程式健康狀態 (1=健康, 0=不健康) app_health = Gauge('momo_app_health', '應用程式健康狀態', registry=registry) # 資料庫連線狀態 db_status = Gauge('momo_database_up', '資料庫連線狀態', registry=registry) try: db = DatabaseManager() with db.engine.connect() as conn: conn.execute(text("SELECT 1")) db_status.set(1) app_health.set(1) except Exception: db_status.set(0) app_health.set(0) # 資料庫記錄數 try: db = DatabaseManager() session = db.get_session() # 商品數量 product_count = Gauge('momo_products_total', '商品總數', registry=registry) product_count.set(session.query(Product).count()) # 價格記錄數量 price_record_count = Gauge('momo_price_records_total', '價格記錄總數', registry=registry) price_record_count.set(session.query(PriceRecord).count()) # 業績資料筆數 from database.realtime_sales_models import RealtimeSalesMonthly sales_count = Gauge('momo_sales_records_total', '業績資料總數', registry=registry) sales_count.set(session.query(RealtimeSalesMonthly).count()) session.close() except Exception as e: sys_log.warning(f"[Metrics] 無法取得資料庫統計: {e}") # 返回 Prometheus 格式 from flask import Response return Response(generate_latest(registry), mimetype=CONTENT_TYPE_LATEST) except ImportError: # prometheus_client 未安裝時的備用方案 metrics_text = """# HELP momo_app_health 應用程式健康狀態 # TYPE momo_app_health gauge momo_app_health 1 # HELP momo_app_info 應用程式資訊 # TYPE momo_app_info gauge momo_app_info{version="9.4",database_type="postgresql"} 1 """ from flask import Response return Response(metrics_text, mimetype='text/plain; charset=utf-8') except Exception as e: sys_log.error(f"[Metrics] 指標生成錯誤: {e}") from flask import Response return Response(f"# Error: {e}\n", mimetype='text/plain; charset=utf-8'), 500 @app.route('/settings') def settings(): """分類設定頁面""" categories = load_categories() return render_template('settings.html', categories=categories, public_url=public_url, system_version=SYSTEM_VERSION) @app.route('/system_settings') def system_settings_page(): """系統設定與匯入頁面""" return render_template('system_settings.html', system_version=SYSTEM_VERSION) @app.route('/abc_analysis/detail') def abc_analysis_detail(): """ABC 分析詳細報表頁面""" try: target_class = request.args.get('class', 'A') # 預設 A 類 table_name = 'realtime_sales_monthly' # 1. 生成與主頁面一致的 cache_key data_range_months = int(request.args.get('data_range', '0') or '0') start_date = request.args.get('start_date', '') end_date = request.args.get('end_date', '') if start_date or end_date: cache_key = f"{table_name}_custom_{start_date}_{end_date}" else: cache_key = f"{table_name}_{data_range_months}m" # 2. 使用共用篩選函式取得資料 target_df, cols_map, err = _get_filtered_sales_data(cache_key) # V-Fix: 如果 cache_key 不存在,嘗試後補使用 table_name 固定鍵值 if err and table_name in _SALES_PROCESSED_CACHE: target_df, cols_map, err = _get_filtered_sales_data(table_name) if err: # V-Fix: 如果自動重載也失敗,則顯示稍後再試,並引導回主頁面 return f''' 數據加載中 - WOOO TECH

數據準備中

正在自動重新加載數據,請稍後...

''', 200 # 恢復欄位變數 col_name = cols_map.get('name') col_amount = cols_map.get('amount') col_qty = cols_map.get('qty') col_category = cols_map.get('category') col_brand = cols_map.get('brand') col_vendor = cols_map.get('vendor') col_price = cols_map.get('price') col_cost = cols_map.get('cost') col_profit = cols_map.get('profit') col_date = cols_map.get('date') col_pid = cols_map.get('pid') # 3. 執行 ABC 分類 items = [] total_revenue = 0 if col_amount and not target_df.empty: # V-Fix: 先針對商品進行聚合,確保 ABC 分析是基於「商品總銷量」而非「單筆訂單」 agg_rules = {col_amount: 'sum'} if col_qty: agg_rules[col_qty] = 'sum' if col_cost: agg_rules[col_cost] = 'sum' if col_profit: agg_rules[col_profit] = 'sum' if col_category: agg_rules[col_category] = 'first' if col_vendor: agg_rules[col_vendor] = 'first' if col_brand: agg_rules[col_brand] = 'first' # V-New: 加入品牌 if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique())) df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index() # 重新計算聚合後的毛利率 if col_profit: df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100 elif col_cost: df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100 else: df_agg['calculated_margin_rate'] = 0.0 df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0) # 執行 ABC 排序與計算 df_agg = df_agg.sort_values(by=col_amount, ascending=False) df_agg['cumulative_revenue'] = df_agg[col_amount].cumsum() total_revenue = df_agg[col_amount].sum() df_agg['cumulative_pct'] = (df_agg['cumulative_revenue'] / total_revenue) * 100 conditions = [(df_agg['cumulative_pct'] <= 80), (df_agg['cumulative_pct'] <= 95)] choices = ['A', 'B'] df_agg['ABC_Class'] = np.select(conditions, choices, default='C') # 4. 篩選特定類別 class_df = df_agg[df_agg['ABC_Class'] == target_class].copy() # V-New: 計算平均單價與庫存建議 if col_qty: class_df['avg_unit_price'] = (class_df[col_amount] / class_df[col_qty]).fillna(0) # V-New: 處理動態補貨係數 custom_factor = request.args.get('factor') current_factor = 0.0 if custom_factor: try: current_factor = float(custom_factor) except: current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0) else: current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0) class_df['suggested_restock'] = (class_df[col_qty] * current_factor).astype(int) items = class_df.to_dict('records') # 準備標題與描述 class_info = { 'A': {'title': 'A 類 - 核心商品', 'desc': '營收佔比前 80% 的主力商品,建議重點備貨與監控。', 'color': 'danger'}, 'B': {'title': 'B 類 - 次要商品', 'desc': '營收佔比 80%~95% 的輔助商品,維持正常庫存。', 'color': 'warning'}, 'C': {'title': 'C 類 - 長尾商品', 'desc': '營收佔比最後 5% 的長尾商品,建議評估清倉或縮減 SKU。', 'color': 'success'} } info = class_info.get(target_class, {'title': f'{target_class} 類', 'desc': '', 'color': 'secondary'}) # 計算 DataTables 預設排序欄位 (銷售金額) 的索引 # 欄位順序: Rank(0), [PID], Name, [Brand], [Vendor], [Cat], [Margin], [AvgPrice, Qty, Restock], Amount sort_col_index = 1 # Rank if col_pid: sort_col_index += 1 sort_col_index += 1 # Name if col_brand: sort_col_index += 1 if col_vendor: sort_col_index += 1 if col_category: sort_col_index += 1 if col_cost or col_profit: sort_col_index += 1 if col_qty: sort_col_index += 3 # 此時 sort_col_index 即為 Amount 欄位的索引 return render_template('abc_analysis_detail.html', items=items, info=info, target_class=target_class, current_factor=current_factor, # V-New: 傳遞當前係數 total_revenue=total_revenue, sort_col_index=sort_col_index, # V-New: 傳遞排序欄位索引 cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category, 'vendor': col_vendor, 'brand': col_brand, 'cost': col_cost, 'profit': col_profit, 'date': col_date, 'pid': col_pid}, # 傳遞當前查詢參數以供匯出連結使用 query_string=request.query_string.decode()) except Exception as e: sys_log.error(f"ABC Detail Error: {e}") return f"系統錯誤: {e}" @app.route('/logs') def show_logs(): return render_template('logs.html') @app.route('/api/logs') def get_logs_api(): if os.path.exists(LOG_FILE_PATH): try: with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f: return jsonify({"logs": "".join(f.readlines()[-60:])}) except Exception as e: sys_log.error(f"[Web] [Logs] ❌ 日誌 API 讀取異常 | Error: {e}") return jsonify({"logs": "讀取日誌異常"}) return jsonify({"logs": "等待系統啟動中..."}) @app.route('/api/backup', methods=['POST']) @login_required def trigger_backup(): """API: 觸發系統完整備份""" # Note: [功能] 尚未實作「系統還原」功能 (Restore),需評估安全性後加入 try: sys_log.info("[System] [Backup] 💾 開始執行系統完整備份...") backup_dir = os.path.join(BASE_DIR, 'backups') if not os.path.exists(backup_dir): os.makedirs(backup_dir) timestamp = datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M') zip_filename = f"momo_system_backup_{SYSTEM_VERSION}_{timestamp}.zip" zip_filepath = os.path.join(backup_dir, zip_filename) with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf: for root, dirs, files in os.walk(BASE_DIR): # 排除不必要的目錄 dirs[:] = [d for d in dirs if d not in ['backups', '__pycache__', 'venv', '.git', '.idea', '.vscode', 'node_modules']] for file in files: if file == zip_filename: continue # 跳過正在寫入的檔案 if file.endswith('.pyc') or file.endswith('.DS_Store'): continue file_path = os.path.join(root, file) arcname = os.path.relpath(file_path, BASE_DIR) zipf.write(file_path, arcname) sys_log.info(f"[System] [Backup] ✅ 系統備份完成 | File: {zip_filename}") # V-New: 回傳下載連結 download_url = url_for('download_backup', filename=zip_filename) return jsonify({ "status": "success", "message": f"備份成功!\n檔案已儲存為: {zip_filename}\n即將開始下載...", "download_url": download_url }) except Exception as e: sys_log.error(f"[System] [Backup] ❌ 備份失敗 | Error: {e}") return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/api/backup/download/') @login_required def download_backup(filename): """ API: 下載備份檔案(已加入路徑遍歷防護) """ try: backup_dir = os.path.join(BASE_DIR, 'backups') # 使用 safe_join 驗證路徑,防止路徑遍歷攻擊 safe_path = safe_join(backup_dir, filename) # 確保檔案存在 if not safe_path.exists(): sys_log.warning(f"[Security] 備份檔案不存在 | File: {filename}") return jsonify({'error': '檔案不存在'}), 404 # 確保是檔案而非目錄 if not safe_path.is_file(): sys_log.warning(f"[Security] 嘗試下載非檔案路徑 | Path: {filename}") return jsonify({'error': '非法路徑'}), 400 return send_from_directory(backup_dir, safe_path.name, as_attachment=True) except ValueError as e: # safe_join 偵測到路徑遍歷嘗試 sys_log.error(f"[Security] 路徑遍歷攻擊嘗試被阻擋 | Filename: {filename} | Error: {e}") return jsonify({'error': '非法路徑'}), 400 except Exception as e: sys_log.error(f"[System] 下載備份失敗 | Error: {e}") return jsonify({'error': '下載失敗'}), 500 # ================= 📊 V-New: 業績分析報表 ================= def _get_filtered_sales_data(cache_key): """ 🚩 共用函式:從快取讀取資料並根據 request.args 進行篩選 回傳: (target_df, cols_map, error_message) 參數: cache_key - 快取鍵值 (例如: "realtime_sales_monthly_3m") """ db = DatabaseManager() # 1. 檢查資料表與快取 df = None cols_map = {} if cache_key in _SALES_PROCESSED_CACHE: cache_data = _SALES_PROCESSED_CACHE[cache_key] df = cache_data['df'] cols_map = cache_data['cols'] else: # 快取不存在時,直接回傳錯誤讓呼叫端顯示 spinner 導回 sales_analysis # 不在此發起全表 DB 查詢(748k 行會 hang Gunicorn worker) sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),回傳錯誤讓 UI 導回 sales_analysis") return None, {}, f"快取未就緒,請先從業績分析主頁載入資料 (cache_key={cache_key})" if False: # 保留舊冷快取重載邏輯(已停用,避免全表掃描 hang) sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),試圖重新從資料庫載入...") try: # V-Fix: 從 cache_key 提取 table_name # 格式: realtime_sales_monthly_3m 或 realtime_sales_monthly_custom_2025-01-01_2025-01-31 if "_custom_" in cache_key: table_name = cache_key.split('_custom_')[0] # realtime_sales_monthly else: # 移除最後的 _Xm 部分 parts = cache_key.rsplit('_', 1) table_name = parts[0] if len(parts) > 1 else 'realtime_sales_monthly' # 判斷是自訂區間還是標配區間 if "_custom_" in cache_key: # 格式: realtime_sales_monthly_custom_2025-01-01_2025-01-31 parts = cache_key.split('_custom_') dates = parts[1].split('_') start_d, end_d = dates[0], dates[1] # 呼叫資料庫讀取 (不傳入 view, 會自動處理欄位映射) result_df, result_cols = db.get_sales_data(table_name=table_name, start_date=start_d, end_date=end_d) else: # 格式: realtime_sales_monthly_1m;months=0 表示全時段但上限 12 個月避免全表掃描 hang months = int(cache_key.split('_')[-1].replace('m', '') or '12') if months == 0: months = 12 result_df, result_cols = db.get_sales_data(table_name=table_name, months=months) if result_df is not None and not result_df.empty: # V-Fix (2026-01-23): 補回所有日期維度欄位供後續篩選 (_dow, _hour, _month_str) if '日期' in result_df.columns: # 先轉換為 datetime result_df['_parsed_date'] = pd.to_datetime(result_df['日期'], errors='coerce') result_df['_month_str'] = result_df['_parsed_date'].dt.strftime('%Y-%m') result_df['_dow'] = result_df['_parsed_date'].dt.dayofweek # 小時需要從「時間」欄位提取 if '時間' in result_df.columns: result_df['_hour'] = pd.to_datetime(result_df['時間'], format='%H:%M:%S', errors='coerce').dt.hour else: result_df['_hour'] = 0 # 如果沒有時間欄位,預設為 0 # 清理臨時欄位 result_df.drop(columns=['_parsed_date'], inplace=True, errors='ignore') # 自動存入快取 _SALES_PROCESSED_CACHE[cache_key] = {'df': result_df, 'cols': result_cols, 'time': time.time()} df = result_df cols_map = result_cols sys_log.info(f"[Sales Analysis] ✅ 快取成功自動重載 | 筆數: {len(df)}") else: return None, None, "資料庫無可用資料,請確認匯入狀態" except Exception as ex: sys_log.error(f"[Sales Analysis] 🚨 自動重載失敗: {ex}") return None, None, f"快取失效且無法重載: {ex}" # 恢復欄位變數 col_name = cols_map.get('name') col_category = cols_map.get('category') col_brand = cols_map.get('brand') col_vendor = cols_map.get('vendor') col_activity = cols_map.get('activity') col_payment = cols_map.get('payment') col_price = cols_map.get('price') col_date = cols_map.get('date') col_return_qty = cols_map.get('return_qty') # V-New: 取得退貨欄位 # 2. 取得篩選參數 selected_category = request.args.get('category', 'all') selected_brand = request.args.get('brand', 'all') selected_vendor = request.args.get('vendor', 'all') selected_activity = request.args.get('activity', 'all') selected_payment = request.args.get('payment', 'all') selected_dow = request.args.get('dow', 'all') selected_hour = request.args.get('hour', 'all') selected_month = request.args.get('month', 'all') keyword = request.args.get('keyword', '').strip() min_price = request.args.get('min_price', '') max_price = request.args.get('max_price', '') min_margin = request.args.get('min_margin', '') max_margin = request.args.get('max_margin', '') # 3. 執行篩選 target_df = df # Top N 分類處理 (用於 '其他' 篩選) TOP_N_CATS = 12 top_cats_names = [] if col_category: # 注意:這裡為了效能,簡單重算一次 Top N,或可考慮也快取起來 cat_group_all = df.groupby(col_category)[cols_map.get('amount')].sum().sort_values(ascending=False) if len(cat_group_all) > TOP_N_CATS: top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist() if selected_category != 'all' and col_category: if selected_category == '其他' and top_cats_names: target_df = target_df[~target_df[col_category].isin(top_cats_names)] else: target_df = target_df[target_df[col_category] == selected_category] if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand] if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor] if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity] if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment] if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)] if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)] if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month] if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)] if col_price: if min_price: target_df = target_df[target_df[col_price] >= float(min_price)] if max_price: target_df = target_df[target_df[col_price] <= float(max_price)] if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)] if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)] return target_df, cols_map, None @app.route('/sales_analysis') def sales_analysis(): """業績分析報表頁面""" try: db = DatabaseManager() table_name = 'realtime_sales_monthly' # 1. 檢查資料表是否存在 inspector = inspect(db.engine) if table_name not in inspector.get_table_names(): return render_template('sales_analysis.html', error="尚未匯入「即時業績(全月)」資料,請先至設定頁面匯入 Excel。", table_name=table_name, selected_metric='amount', no_filter=False, data_range_months=0, start_date='', end_date='', total_records=0, db_data_range='') # V-New: 查詢資料庫的資料期間範圍 db_data_range = '' try: # 取得日期欄位的最小值和最大值 from sqlalchemy import text date_query = text(f"SELECT MIN(日期) as min_date, MAX(日期) as max_date FROM {table_name}") # V-Fix: SQLAlchemy 2.0 需要使用 connection 對象 with db.engine.connect() as conn: result = conn.execute(date_query).fetchone() if result and result[0] and result[1]: min_date = result[0] max_date = result[1] # 格式化為 YYYY年MM月 格式 if isinstance(min_date, str): from datetime import datetime try: min_date_obj = datetime.strptime(min_date.split()[0], '%Y-%m-%d') max_date_obj = datetime.strptime(max_date.split()[0], '%Y-%m-%d') db_data_range = f"{min_date_obj.year}年{min_date_obj.month}月 ~ {max_date_obj.year}年{max_date_obj.month}月" except: db_data_range = f"{min_date} ~ {max_date}" else: db_data_range = f"{min_date.year}年{min_date.month}月 ~ {max_date.year}年{max_date.month}月" except Exception as e: sys_log.warning(f"[Sales Analysis] 無法取得資料期間範圍: {e}") # V-New: 取得篩選參數 data_range_param = request.args.get('data_range', '') # 不再設預設值 start_date = request.args.get('start_date', '') end_date = request.args.get('end_date', '') # V-New: 按需載入 - 如果沒有任何篩選條件,顯示引導頁面 if not data_range_param and not start_date and not end_date: sys_log.info("[Sales Analysis] 👋 首次進入頁面,等待用戶選擇篩選條件") # V-Fix: 即使在引導頁面,也要提供下拉選單選項 # V-Opt: 讀取較多筆數(1000筆)以獲得更完整的月份資訊 preview_df = pd.read_sql(f"SELECT * FROM {table_name} LIMIT 1000", db.engine) preview_categories = [] preview_brands = [] preview_vendors = [] preview_activities = [] preview_payments = [] preview_months = [] # V-New: 新增月份列表 if not preview_df.empty: cols = preview_df.columns.tolist() def find_col(keywords): for k in keywords: for col in cols: if k in str(col): return col return None col_category = find_col(['館別', '商品館', '分類', 'Category']) col_brand = find_col(['品牌', 'Brand']) col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) # V-Fix: 優先匹配具體的活動欄位名稱 col_activity = find_col(['折扣活動名稱', '折價券活動名稱', '滿額再折扣活動名稱', '活動', 'Activity', 'Campaign']) col_payment = find_col(['付款', 'Payment', 'Pay']) # V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期) col_date_part = find_col(['日期', '交易日期', 'Date', 'Day']) col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']) # V-Fix: 篩選掉空字串,只保留有效數據 if col_category: preview_categories = sorted([x for x in preview_df[col_category].dropna().astype(str).unique().tolist() if x and x.strip()]) if col_brand: preview_brands = sorted([x for x in preview_df[col_brand].dropna().astype(str).unique().tolist() if x and x.strip()]) if col_vendor: preview_vendors = sorted([x for x in preview_df[col_vendor].dropna().astype(str).unique().tolist() if x and x.strip()]) if col_activity: preview_activities = sorted([x for x in preview_df[col_activity].dropna().astype(str).unique().tolist() if x and x.strip()]) if col_payment: preview_payments = sorted([x for x in preview_df[col_payment].dropna().astype(str).unique().tolist() if x and x.strip()]) # V-Fix: 從數據庫直接查詢所有月份(而不是從預覽數據提取,避免只獲得部分月份) if col_date_part: try: from sqlalchemy import text with db.engine.connect() as conn: result = conn.execute(text(f""" SELECT DISTINCT replace(substr("{col_date_part}", 1, 7), '/', '-') as month FROM {table_name} WHERE "{col_date_part}" IS NOT NULL AND "{col_date_part}" != '' ORDER BY month """)).fetchall() preview_months = [row[0] for row in result if row[0] and '-' in str(row[0])] sys_log.info(f"[Sales Analysis] 預覽模式從「{col_date_part}」欄位提取到 {len(preview_months)} 個月份") except Exception as e: sys_log.warning(f"[Sales Analysis] 無法從「{col_date_part}」欄位提取月份: {e}") pass # 傳遞必要的變數以避免模板錯誤 selected_metric = request.args.get('metric', 'amount') # 建立空的數據結構 empty_data = {'labels': [], 'chart_values': [], 'values': [], 'metric_label': ''} return render_template('sales_analysis.html', no_filter=True, table_name=table_name, selected_metric=selected_metric, total_records=0, items=[], kpi={'revenue': 0, 'qty': 0, 'count': 0, 'cost': 0, 'gross_margin': 0, 'gross_margin_rate': 0, 'avg_price': 0}, insights={}, abc_stats={}, vendor_stats=[], seasonality_data={'datasets': [], 'yLabels': [], 'xLabels': []}, bar_data=empty_data, cat_data=empty_data, price_dist_data=empty_data, scatter_data=[], bcg_data=[], dow_data=empty_data, hourly_data=empty_data, monthly_data=empty_data, weekly_data=empty_data, heatmap_data=[], treemap_data=[], cols={'name': True, 'amount': True, 'qty': True, 'cat': True, 'date': True, 'cost': True, 'profit': True, 'vendor': True, 'brand': True, 'return_qty': True, 'pid': True}, all_categories=preview_categories, all_brands=preview_brands, all_vendors=preview_vendors, all_activities=preview_activities, all_payments=preview_payments, all_months=preview_months, selected_category='all', selected_brand='all', selected_vendor='all', selected_activity='all', selected_payment='all', selected_dow='all', selected_hour='all', selected_month='all', keyword='', min_price='', max_price='', min_margin='', max_margin='', data_range_months=0, start_date='', end_date='', db_data_range=db_data_range, marketing_data=None) # 解析 data_range_months(有篩選時才處理) data_range_months = int(data_range_param or '0') # V-New: 如果有自訂日期區間,則優先使用 if start_date or end_date: cache_key = f"{table_name}_custom_{start_date}_{end_date}" else: cache_key = f"{table_name}_{data_range_months}m" # 2. 讀取與處理資料 (V-Opt: 使用二級快取機制 Raw -> Processed) df = None cols_map = {} # A. 優先檢查是否已有處理好的快取 (最快) if cache_key in _SALES_PROCESSED_CACHE: cache_data = _SALES_PROCESSED_CACHE[cache_key] df = cache_data['df'] cols_map = cache_data['cols'] # 恢復欄位變數 col_name = cols_map.get('name') col_date = cols_map.get('date') col_amount = cols_map.get('amount') col_qty = cols_map.get('qty') col_category = cols_map.get('category') col_brand = cols_map.get('brand') col_vendor = cols_map.get('vendor') col_activity = cols_map.get('activity') col_payment = cols_map.get('payment') col_pid = cols_map.get('pid') # V-New: 取得 PID 欄位 col_price = cols_map.get('price') col_cost = cols_map.get('cost') col_profit = cols_map.get('profit') col_return_qty = cols_map.get('return_qty') cached_pie_data = cache_data.get('pie_data', {'labels': [], 'chart_values': []}) # V-Opt: 讀取圓餅圖快取 else: # B. 若無處理後快取,則從 Raw Cache 或 DB 讀取並處理 # V-Opt: 加入日期範圍篩選以減少記憶體使用 # (data_range_months 已在上方定義) # 先讀取小樣本以識別日期欄位 sample_df = pd.read_sql(f"SELECT * FROM {table_name} LIMIT 100", db.engine) if sample_df.empty: return render_template('sales_analysis.html', error="資料表為空,請重新匯入。", table_name=table_name, selected_metric=request.args.get('metric', 'amount'), no_filter=False, data_range_months=data_range_months, start_date=start_date, end_date=end_date, total_records=0, db_data_range=db_data_range, marketing_data=None) # 自動識別日期欄位(V-Fix: 優先匹配「日期」,因為「訂單日期」可能是固定文字) sample_cols = sample_df.columns.tolist() date_col_name = None for col in sample_cols: if any(keyword in str(col) for keyword in ['日期', '交易日期', 'Date', '訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']): date_col_name = col break # 根據是否有日期欄位決定查詢方式 if date_col_name: from datetime import datetime, timedelta, timezone TAIPEI_TZ = timezone(timedelta(hours=8)) # V-New: 優先處理自訂日期區間 if start_date or end_date: # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) start_date_slash = start_date.replace('-', '/') if start_date else '' end_date_slash = end_date.replace('-', '/') if end_date else '' # 有自訂日期區間 - 使用 BETWEEN 或單邊範圍 if start_date and end_date: sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" BETWEEN '{start_date_slash}' AND '{end_date_slash}'" sys_log.info(f"[Sales Analysis] 📅 使用自訂日期範圍: {start_date} ~ {end_date} (DB格式: {start_date_slash} ~ {end_date_slash})") elif start_date: sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" >= '{start_date_slash}'" sys_log.info(f"[Sales Analysis] 📅 使用自訂開始日期: >= {start_date} (DB格式: {start_date_slash})") else: # only end_date sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" <= '{end_date_slash}'" sys_log.info(f"[Sales Analysis] 📅 使用自訂結束日期: <= {end_date} (DB格式: {end_date_slash})") elif data_range_months > 0: # 使用相對日期範圍(最近N個月) # V-Fix: 使用斜線格式以匹配資料庫格式 cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" >= '{cutoff_date}'" sys_log.info(f"[Sales Analysis] 📊 使用日期範圍篩選: 最近 {data_range_months} 個月 (>= {cutoff_date})") else: # data_range_months == 0,載入全部資料 sql_query = f"SELECT * FROM {table_name}" sys_log.info(f"[Sales Analysis] 📊 載入全部資料(用戶選擇)") else: # 無日期欄位 - 載入全部 sql_query = f"SELECT * FROM {table_name}" sys_log.info(f"[Sales Analysis] ⚠️ 未找到日期欄位,載入全部資料") # V-Opt (2026-01-23): 優先使用 PostgreSQL 聚合視圖 (mv_sales_summary) # 聚合視圖已預先計算:資料量 -20%, 大小 -73%, 欄位類型已轉換 from sqlalchemy import text as sql_text from config import DATABASE_TYPE use_materialized_view = False if DATABASE_TYPE == 'postgresql': # 檢查聚合視圖是否存在 try: with db.engine.connect() as conn: check_mv = conn.execute(sql_text( "SELECT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'mv_sales_summary')" )).fetchone() use_materialized_view = check_mv[0] if check_mv else False except: use_materialized_view = False if use_materialized_view: # 使用聚合視圖 - 欄位已標準化為英文 sys_log.info(f"[Sales Analysis] 📊 使用 PostgreSQL 聚合視圖 (mv_sales_summary)") # 構建日期篩選條件 mv_where = "" if start_date or end_date: if start_date and end_date: mv_where = f"WHERE sale_date BETWEEN '{start_date}' AND '{end_date}'" elif start_date: mv_where = f"WHERE sale_date >= '{start_date}'" else: mv_where = f"WHERE sale_date <= '{end_date}'" elif data_range_months > 0: from datetime import datetime, timedelta, timezone TAIPEI_TZ = timezone(timedelta(hours=8)) cutoff = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y-%m-%d') mv_where = f"WHERE sale_date >= '{cutoff}'" mv_query = f""" SELECT sale_date as "日期", product_id as "商品ID", product_name as "商品名稱", category as "商品館", brand as "品牌", vendor_name as "廠商名稱", payment as "付款", total_revenue as "總業績", total_qty as "數量", total_cost as "總成本", order_count FROM mv_sales_summary {mv_where} """ df = pd.read_sql(mv_query, db.engine) sys_log.info(f"[Sales Analysis] 📊 聚合視圖載入完成: {len(df):,} 筆記錄") else: # 原始邏輯:使用原始表 sys_log.info(f"[Sales Analysis] 📊 使用原始表載入...") df = pd.read_sql(sql_query, db.engine) sys_log.info(f"[Sales Analysis] 📊 載入完成: {len(df):,} 筆記錄") # 聚合模式標記 is_aggregated_mode = use_materialized_view # V-Opt: 不再快取完整 DataFrame 到 _SALES_DF_CACHE (避免記憶體累積) # 改用輕量級處理後快取 (_SALES_PROCESSED_CACHE) if df.empty: return render_template('sales_analysis.html', error="資料表為空,請重新匯入。", table_name=table_name, selected_metric=request.args.get('metric', 'amount'), no_filter=False, data_range_months=data_range_months, start_date=start_date, end_date=end_date, total_records=0, db_data_range=db_data_range, marketing_data=None) # 3. 自動識別關鍵欄位 (模糊比對) cols = df.columns.tolist() def find_col(keywords): # V-Opt: 改為優先遍歷關鍵字,確保優先匹配更精確的名稱 (例如 '廠商名稱' 優於 '廠商') for k in keywords: for col in cols: if k in str(col): return col return None col_name = find_col(['商品名稱', '品名', 'Name', 'Product']) col_pid = find_col(['商品ID', 'Product ID', 'ID', 'i_code', 'Item Code']) # V-New: 偵測商品ID欄位 # V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期) col_date_part = find_col(['日期', '交易日期', 'Date', 'Day']) col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']) col_brand = find_col(['品牌', 'Brand']) # V-New: 品牌欄位 col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) # V-Opt: 優先抓取名稱 col_activity = find_col(['活動', '折扣', 'Activity', 'Campaign', 'Promotion', '專案']) # V-New: 活動欄位 col_payment = find_col(['付款方式', 'Payment', 'Pay']) # V-New: 付款方式欄位 col_price = find_col(['單價', 'Price', '價格', 'Avg Price']) # V-New: 嘗試尋找單價欄位 col_cost = find_col(['成本', 'Cost', '進價', 'Cost Price', 'Wholesale']) # V-New: 成本欄位 col_profit = find_col(['毛利', 'Profit', '利潤']) # V-New: 直接尋找毛利欄位 (若有) col_return_qty = find_col(['退貨數量', 'Return Qty', '退貨']) # V-New: 退貨欄位 col_amount = find_col(['銷售金額', '業績', '金額', 'Amount', 'Sales', 'Total']) col_qty = find_col(['銷售數量', '銷量', '數量', 'Qty', 'Quantity']) col_category = find_col(['館別', '分類', 'Category']) if not col_name or not col_amount: return render_template('sales_analysis.html', error=f"無法自動識別關鍵欄位 (需包含 '名稱' 與 '金額')。偵測到的欄位: {cols}", table_name=table_name, selected_metric=request.args.get('metric', 'amount'), no_filter=False, data_range_months=data_range_months, start_date=start_date, end_date=end_date, total_records=0, db_data_range=db_data_range, marketing_data=None) # 4. 資料處理 (Heavy Lifting - 只在快取建立時執行一次) # 確保金額與數量是數字 df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0) if col_qty: df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0) if col_cost: df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0) if col_profit: df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0) if col_return_qty: df[col_return_qty] = pd.to_numeric(df[col_return_qty], errors='coerce').fillna(0) # V-Fix: 智慧日期時間合併邏輯 (聚合模式下跳過) col_date = None if not is_aggregated_mode: if col_date_part and col_time_part: # 兩者都有,嘗試合併 try: df['combined_dt'] = pd.to_datetime(df[col_date_part].astype(str) + ' ' + df[col_time_part].astype(str), errors='coerce') col_date = 'combined_dt' except: # 合併失敗,退回使用時間欄位 (假設包含日期) 或日期欄位 col_date = col_time_part or col_date_part elif col_time_part: # 只有時間欄位 (可能包含日期) df[col_time_part] = pd.to_datetime(df[col_time_part], errors='coerce') col_date = col_time_part elif col_date_part: # 只有日期欄位 df[col_date_part] = pd.to_datetime(df[col_date_part], errors='coerce') col_date = col_date_part # V-New: 若無明確單價欄位,則自動計算 (金額 / 數量) if not col_price and col_amount and col_qty: col_price = 'calculated_price' # V-Opt: 使用 numpy 向量化運算加速 (取代 apply) df[col_price] = np.where(df[col_qty] > 0, df[col_amount] / df[col_qty], 0) if col_price: df[col_price] = pd.to_numeric(df[col_price], errors='coerce').fillna(0) # V-New: 預先計算毛利率 (Margin Rate) 用於篩選 # 邏輯: (毛利 / 金額) * 100 col_margin_rate = 'calculated_margin_rate' with np.errstate(divide='ignore', invalid='ignore'): if col_profit: df[col_margin_rate] = (df[col_profit] / df[col_amount]) * 100 elif col_cost: df[col_margin_rate] = ((df[col_amount] - df[col_cost]) / df[col_amount]) * 100 else: df[col_margin_rate] = 0.0 # 處理無限大與 NaN (轉為 0) df[col_margin_rate] = df[col_margin_rate].replace([np.inf, -np.inf, np.nan], 0) # === V-Opt: 效能優化預計算 (V9.98) === # 1. 日期維度 (加速篩選與聚合,避免重複呼叫 .dt 存取器) # 聚合模式下跳過日期維度計算 if col_date and not is_aggregated_mode: df['_dow'] = df[col_date].dt.dayofweek df['_hour'] = df[col_date].dt.hour df['_week'] = df[col_date].dt.strftime('%G-W%V') df['_month_str'] = df[col_date].dt.strftime('%Y-%m') # V-New: 月份維度 (YYYY-MM) # 2. 毛利額 (加速 Top 3 分析,避免 runtime 計算) if col_profit: df['calculated_profit'] = df[col_profit] elif col_cost: df['calculated_profit'] = df[col_amount] - df[col_cost] else: df['calculated_profit'] = 0.0 # 3. 全站分類圓餅圖 (已移至下方使用 target_df 計算) # 建立/更新處理後快取 cache_entry = { 'df': df, 'cols': { 'name': col_name, 'date': col_date, 'amount': col_amount, 'qty': col_qty, 'category': col_category, 'brand': col_brand, 'vendor': col_vendor, 'activity': col_activity, 'payment': col_payment, 'price': col_price, 'cost': col_cost, 'profit': col_profit, 'return_qty': col_return_qty, 'pid': col_pid # V-New: 儲存商品ID欄位 }, 'pid': col_pid # V-New: 儲存商品ID欄位 } _SALES_PROCESSED_CACHE[cache_key] = cache_entry # V-Fix: 同時使用固定的 table_name 作為 key 儲存副本,供其他路由使用 _SALES_PROCESSED_CACHE[table_name] = cache_entry # V-Opt (2026-01-23): 定期清理過期快取 _cleanup_sales_cache() # 🚩 V-Opt: 使用共用篩選函式 target_df, cols_map, err = _get_filtered_sales_data(cache_key) if err: # V-Fix: 若快取失效,重新導向自己以觸發重新讀取(保留所有查詢參數) params = {k: v for k, v in request.args.items()} return redirect(url_for('sales_analysis', **params)) # 重新取得變數 (因為 _get_filtered_sales_data 內部使用了 cols_map) col_name = cols_map.get('name') col_amount = cols_map.get('amount') col_qty = cols_map.get('qty') col_category = cols_map.get('category') col_brand = cols_map.get('brand') col_vendor = cols_map.get('vendor') col_activity = cols_map.get('activity') col_payment = cols_map.get('payment') col_price = cols_map.get('price') col_cost = cols_map.get('cost') col_profit = cols_map.get('profit') col_return_qty = cols_map.get('return_qty') col_date = cols_map.get('date') col_pid = cols_map.get('pid') # V-Fix: 準備前端需要的下拉選單資料 # V-Opt: 從數據庫直接查詢所有可用選項,而不是只從篩選後的快取中讀取 # 這樣可以確保下拉選單顯示完整的可選項,即使當前篩選範圍很小 all_categories = [] all_brands = [] all_vendors = [] all_activities = [] all_payments = [] all_months = [] try: from sqlalchemy import text # V-Fix: SQLAlchemy 2.0 需要使用 connection 對象 with db.engine.connect() as conn: # 讀取完整資料表的所有可用選項(使用 DISTINCT 以提升效能) # V-Fix: 使用單引號空字串,兼容 PostgreSQL if col_category: sql = f"SELECT DISTINCT \"{col_category}\" FROM {table_name} WHERE \"{col_category}\" IS NOT NULL AND \"{col_category}\" <> ''" result = conn.execute(text(sql)).fetchall() all_categories = sorted([str(row[0]) for row in result if row[0]]) if col_brand: sql = f"SELECT DISTINCT \"{col_brand}\" FROM {table_name} WHERE \"{col_brand}\" IS NOT NULL AND \"{col_brand}\" <> ''" result = conn.execute(text(sql)).fetchall() all_brands = sorted([str(row[0]) for row in result if row[0]]) if col_vendor: sql = f"SELECT DISTINCT \"{col_vendor}\" FROM {table_name} WHERE \"{col_vendor}\" IS NOT NULL AND \"{col_vendor}\" <> ''" result = conn.execute(text(sql)).fetchall() all_vendors = sorted([str(row[0]) for row in result if row[0]]) if col_activity: sql = f"SELECT DISTINCT \"{col_activity}\" FROM {table_name} WHERE \"{col_activity}\" IS NOT NULL AND \"{col_activity}\" <> ''" result = conn.execute(text(sql)).fetchall() all_activities = sorted([str(row[0]) for row in result if row[0]]) if col_payment: sql = f"SELECT DISTINCT \"{col_payment}\" FROM {table_name} WHERE \"{col_payment}\" IS NOT NULL AND \"{col_payment}\" <> ''" result = conn.execute(text(sql)).fetchall() all_payments = sorted([str(row[0]) for row in result if row[0]]) # V-Fix: 從數據庫提取所有月份(格式:YYYY-MM) if col_date: # 從日期欄位提取月份(支援多種日期欄位名稱) date_fields = ['日期', '訂單日期', '時間'] for field in date_fields: try: # V-Fix: 使用 substr 提取年月部分,並將斜線替換為橫線 # 數據庫格式: "2025/07/01" -> 提取前7個字符 "2025/07" -> 替換斜線 "2025-07" result = conn.execute(text(f""" SELECT DISTINCT replace(substr(\"{field}\", 1, 7), '/', '-') as month FROM {table_name} WHERE \"{field}\" IS NOT NULL AND \"{field}\" != '' ORDER BY month """)).fetchall() if result and len(result) > 0: all_months = [row[0] for row in result if row[0] and '-' in str(row[0])] if all_months: # 如果成功提取到月份,就使用這個欄位 sys_log.info(f"[Sales Analysis] 從欄位 {field} 提取到 {len(all_months)} 個月份: {all_months}") break except Exception as ex: sys_log.warning(f"[Sales Analysis] 從欄位 {field} 提取月份失敗: {ex}") continue except Exception as e: sys_log.warning(f"[Sales Analysis] 從數據庫查詢下拉選項失敗: {e}") # 如果查詢失敗,回退到從快取讀取 if cache_key in _SALES_PROCESSED_CACHE: original_df = _SALES_PROCESSED_CACHE[cache_key]['df'] elif table_name in _SALES_PROCESSED_CACHE: original_df = _SALES_PROCESSED_CACHE[table_name]['df'] else: original_df = pd.DataFrame() if not original_df.empty: all_categories = sorted(original_df[col_category].dropna().astype(str).unique().tolist()) if col_category else [] all_brands = sorted(original_df[col_brand].dropna().astype(str).unique().tolist()) if col_brand else [] all_vendors = sorted(original_df[col_vendor].dropna().astype(str).unique().tolist()) if col_vendor else [] all_activities = sorted(original_df[col_activity].dropna().astype(str).unique().tolist()) if col_activity else [] all_payments = sorted(original_df[col_payment].dropna().astype(str).unique().tolist()) if col_payment else [] all_months = sorted(original_df['_month_str'].dropna().unique().tolist()) if col_date and '_month_str' in original_df.columns else [] # 取得前端參數供模板回填 selected_category = request.args.get('category', 'all') selected_metric = request.args.get('metric', 'amount') selected_brand = request.args.get('brand', 'all') selected_vendor = request.args.get('vendor', 'all') selected_activity = request.args.get('activity', 'all') selected_payment = request.args.get('payment', 'all') selected_dow = request.args.get('dow', 'all') selected_hour = request.args.get('hour', 'all') selected_month = request.args.get('month', 'all') keyword = request.args.get('keyword', '').strip() min_price = request.args.get('min_price', '') max_price = request.args.get('max_price', '') min_margin = request.args.get('min_margin', '') max_margin = request.args.get('max_margin', '') # 決定排序欄位 sort_col = col_amount if selected_metric == 'qty' and col_qty: sort_col = col_qty target_df = target_df.sort_values(by=sort_col, ascending=False) # 📊 KPI 計算 (針對篩選後的資料) total_revenue = float(target_df[col_amount].sum()) total_qty = float(target_df[col_qty].sum()) if col_qty else 0 total_count = int(len(target_df)) # 訂單筆數 # V-Fix 2026-01-15: SKU 數應計算唯一商品數,而非記錄筆數 sku_count = int(target_df[col_name].nunique()) if col_name else total_count # V-New: 成本與毛利計算 total_cost = float(target_df[col_cost].sum()) if col_cost else 0 if col_profit: gross_margin = float(target_df[col_profit].sum()) else: gross_margin = total_revenue - total_cost gross_margin_rate = (gross_margin / total_revenue * 100) if total_revenue > 0 else 0 avg_price = total_revenue / total_qty if total_qty > 0 else 0 # 📊 V-New: 商業洞察 (Top 3 Analysis) insights = { 'rev_cats': [], 'rev_prods': [], 'margin_cats': [], 'margin_prods': [], 'qty_cats': [], 'qty_prods': [] } # Helper function to get top 3 # Helper function to get top 3 def get_top_3(groupby_col, metric_col, is_margin=False, is_qty=False): if not groupby_col or not metric_col: return [] # V-Opt: 直接使用 target_df 與預計算欄位,避免 copy() 與 assign() target_metric = metric_col if is_margin: target_metric = 'calculated_profit' try: # 直接聚合並取前3名 # V-Fix 2026-01-15: 若 groupby_col 是 list (例如 [PID, Name]),結果 index 會是 MultiIndex grouped = target_df.groupby(groupby_col)[target_metric].sum() def get_name(k): # 如果是 Tuple (MultiIndex),通常最後一個是 Name,取之 return str(k[-1]) if isinstance(k, tuple) else str(k) return [{'name': get_name(k), 'value': float(v)} for k, v in grouped.nlargest(3).items() if v > 0] except Exception: return [] insights['rev_cats'] = get_top_3(col_category, col_amount) # V-Fix: 商品聚合改用 [PID, Name] 避免同名不同ID商品被合併 product_groupby = [col_pid, col_name] if col_pid else col_name insights['rev_prods'] = get_top_3(product_groupby, col_amount) insights['qty_cats'] = get_top_3(col_category, col_qty, is_qty=True) insights['qty_prods'] = get_top_3(product_groupby, col_qty, is_qty=True) if col_cost or col_profit: insights['margin_cats'] = get_top_3(col_category, col_amount, is_margin=True) insights['margin_prods'] = get_top_3(product_groupby, col_amount, is_margin=True) # 📊 V-Opt: 改為橫向長條圖數據 (Top 20) top_chart = target_df.head(20) bar_data = { 'labels': [str(n)[:20] + '...' if len(str(n)) > 20 else str(n) for n in top_chart[col_name]], # 稍微放寬長度限制 'chart_values': [float(x) for x in top_chart[sort_col]], 'metric_label': '銷售金額 ($)' if selected_metric == 'amount' else '銷售數量' } # 📋 V-Opt: 列表資料改為 AJAX 載入,這裡只傳空列表以加快初始渲染 table_items = [] # 準備類別圓餅圖資料 # V-Fix: 使用 target_df (篩選後資料) 動態計算 cat_data = {'labels': [], 'chart_values': []} if col_category and not target_df.empty: cat_group_all = target_df.groupby(col_category)[col_amount].sum().sort_values(ascending=False) TOP_N_CATS = 12 if len(cat_group_all) > TOP_N_CATS: top_cats = cat_group_all.head(TOP_N_CATS) other_val = cat_group_all.iloc[TOP_N_CATS:].sum() cat_data['labels'] = [str(x) for x in top_cats.index.tolist()] + ['其他'] cat_data['chart_values'] = [float(x) for x in top_cats.tolist()] + [float(other_val)] else: cat_data['labels'] = [str(x) for x in cat_group_all.index.tolist()] cat_data['chart_values'] = [float(x) for x in cat_group_all.tolist()] # 📊 V-New: 價格帶分析 (Price Range Analysis) price_dist_data = {'labels': [], 'chart_values': []} if col_price and not target_df.empty: # 定義價格區間 (0-500, 500-1000, 1000-2000, 2000-5000, 5000-10000, 10000+) bins = [0, 500, 1000, 2000, 5000, 10000, float('inf')] labels = ['0-499', '500-999', '1,000-1,999', '2,000-4,999', '5,000-9,999', '10,000+'] # V-Opt: 使用 pd.cut 進行分組,但不修改 target_df (避免污染快取) # right=False 表示包含左邊界,例如 500 在 500-999 這一組 price_bins = pd.cut(target_df[col_price], bins=bins, labels=labels, right=False) # 統計各區間的「銷售金額」貢獻 (直接使用外部 Series 進行 groupby) range_group = target_df.groupby(price_bins, observed=False)[col_amount].sum() price_dist_data['labels'] = labels price_dist_data['chart_values'] = [float(range_group.get(l, 0)) for l in labels] # 📊 V-New: 價格 vs 銷量 散佈圖 (Scatter Plot) scatter_data = [] if col_price and col_qty and not target_df.empty: # 取前 300 筆主要商品,避免圖表過於密集導致瀏覽器卡頓 scatter_source = target_df.head(300) for _, row in scatter_source.iterrows(): # V-Fix (2026-01-23): 處理 NaN 值 price_val = row[col_price] if pd.notna(row[col_price]) else 0 qty_val = row[col_qty] if pd.notna(row[col_qty]) else 0 amt_val = row[col_amount] if pd.notna(row[col_amount]) else 0 scatter_data.append({ 'x': float(price_val), 'y': float(qty_val), 'name': str(row[col_name]) if pd.notna(row[col_name]) else '', 'amt': float(amt_val) # 用於 tooltip 顯示金額 }) # 📊 V-New: BCG 矩陣分析 (BCG Matrix) # X軸: 銷量 (Qty), Y軸: 毛利率 (Margin %) bcg_data = {'datasets': [], 'thresholds': {'x': 0, 'y': 0}} # V-Fix: 確保 calculated_margin_rate 欄位存在 if col_qty and (col_cost or col_profit) and not target_df.empty and 'calculated_margin_rate' in target_df.columns: # 1. 計算閾值 (使用中位數,避免極端值影響) # 過濾掉銷量為 0 的商品,避免干擾閾值計算 active_products = target_df[target_df[col_qty] > 0] if not active_products.empty and 'calculated_margin_rate' in active_products.columns: median_qty = active_products[col_qty].median() median_margin = active_products['calculated_margin_rate'].median() # 若中位數為 0 (例如大部分商品沒銷量),則給一個預設值以利顯示 if median_qty == 0: median_qty = 1 bcg_data['thresholds'] = {'x': float(median_qty), 'y': float(median_margin)} # 2. 分類商品 (四象限) # Stars (明星): High Qty, High Margin stars = active_products[(active_products[col_qty] >= median_qty) & (active_products['calculated_margin_rate'] >= median_margin)] # Cows (金牛): High Qty, Low Margin cows = active_products[(active_products[col_qty] >= median_qty) & (active_products['calculated_margin_rate'] < median_margin)] # Questions (問題): Low Qty, High Margin questions = active_products[(active_products[col_qty] < median_qty) & (active_products['calculated_margin_rate'] >= median_margin)] # Dogs (瘦狗): Low Qty, Low Margin dogs = active_products[(active_products[col_qty] < median_qty) & (active_products['calculated_margin_rate'] < median_margin)] def format_bcg_points(df_segment): # 限制點數,避免前端卡頓 (各象限最多 100 點) return [{'x': float(row[col_qty]), 'y': float(row['calculated_margin_rate']), 'name': str(row[col_name]), 'amt': float(row[col_amount])} for _, row in df_segment.head(100).iterrows()] bcg_data['datasets'] = [ {'label': '明星商品 (Stars)', 'data': format_bcg_points(stars), 'backgroundColor': 'rgba(255, 206, 86, 0.8)', 'borderColor': 'rgba(255, 206, 86, 1)'}, # Yellow {'label': '金牛商品 (Cows)', 'data': format_bcg_points(cows), 'backgroundColor': 'rgba(75, 192, 192, 0.8)', 'borderColor': 'rgba(75, 192, 192, 1)'}, # Green {'label': '問題商品 (Questions)', 'data': format_bcg_points(questions), 'backgroundColor': 'rgba(54, 162, 235, 0.8)', 'borderColor': 'rgba(54, 162, 235, 1)'}, # Blue {'label': '瘦狗商品 (Dogs)', 'data': format_bcg_points(dogs), 'backgroundColor': 'rgba(201, 203, 207, 0.8)', 'borderColor': 'rgba(201, 203, 207, 1)'} # Grey ] # 📊 V-New: 時間維度分析 (Time Analysis) dow_data = {'labels': ['週一', '週二', '週三', '週四', '週五', '週六', '週日'], 'chart_values': [0]*7} hourly_data = {'labels': [f"{i:02d}:00" for i in range(24)], 'chart_values': [0]*24} weekly_data = {'labels': [], 'chart_values': []} # V-New: 每週趨勢 monthly_data = {'labels': [], 'chart_values': []} # V-New: 每月趨勢 heatmap_data = [] # V-New: 多維度熱力圖 (Day x Hour) treemap_data = [] # V-New: 板塊圖數據 if col_date: # 過濾掉日期無效的資料 # V-Opt: 使用預計算欄位進行分組,速度更快 if not target_df.empty: # 1. 星期分析 (Day of Week) dow_group = target_df.groupby('_dow')[col_amount].sum() for day, val in dow_group.items(): if not np.isnan(day): dow_data['chart_values'][int(day)] = float(val) # 2. 小時分析 (Hourly) hour_group = target_df.groupby('_hour')[col_amount].sum() for hour, val in hour_group.items(): if not np.isnan(hour): hourly_data['chart_values'][int(hour)] = float(val) # 3. 每月趨勢 (Monthly Trend) - V-New month_group = target_df.groupby('_month_str')[col_amount].sum().sort_index() monthly_data['labels'] = month_group.index.tolist() # V-Fix (2026-01-23): 處理 NaN 值避免 JSON 序列化失敗 monthly_data['chart_values'] = [float(x) if not np.isnan(x) else 0 for x in month_group.tolist()] # 3. 每週趨勢 (Weekly Trend) - V-New week_group = target_df.groupby('_week')[col_amount].sum().sort_index() # V-Opt: 解除 12 週限制,顯示完整年度趨勢 (因應一年份數據需求) weekly_data['labels'] = week_group.index.tolist() # V-Fix (2026-01-23): 處理 NaN 值避免 JSON 序列化失敗 weekly_data['chart_values'] = [float(x) if not np.isnan(x) else 0 for x in week_group.tolist()] # 4. 多維度熱力圖 (Day x Hour) - V-Fix: 確保數據完整性 dh_group = target_df.groupby(['_dow', '_hour'])[col_amount].sum() # V-Opt: 正規化氣泡大小 (Normalize Bubble Size) 以提升可讀性 max_val = dh_group.max() if not dh_group.empty else 1 for (day, hour), val in dh_group.items(): # V-Fix (2026-01-23): 處理 NaN 值 if np.isnan(val): val = 0 # 將數值映射到 3~25px 的半徑範圍,確保視覺可辨識 radius = 3 + (math.sqrt(val) / math.sqrt(max_val)) * 22 if val > 0 else 0 heatmap_data.append({ 'x': int(hour), # X軸: 小時 (0-23) 'y': int(day), # Y軸: 星期 (0-6) 'r': float(radius) if not np.isnan(radius) else 0, # V-Adj: 正規化後半徑 'v': float(val) # 實際數值 (用於 Tooltip) }) # 📊 V-New: 板塊圖 (Treemap) 數據準備 # 結構: Root -> Category -> Product (Top 5 per cat) if col_category and col_name and col_amount and not target_df.empty: # V-Opt: 優化聚合邏輯,先聚合再篩選,避免在迴圈中重複過濾大表 # 1. 先聚合 Category + Product (大幅減少資料量) cat_prod_group = target_df.groupby([col_category, col_name])[col_amount].sum().reset_index() # 2. 找出前 10 大分類 top_cats = cat_prod_group.groupby(col_category)[col_amount].sum().nlargest(10).index.tolist() # 3. 針對前 10 大分類,各取前 5 大商品 for cat in top_cats: if not cat: continue # 在縮減後的資料中篩選,速度極快 cat_subset = cat_prod_group[cat_prod_group[col_category] == cat] top_prods = cat_subset.nlargest(5, col_amount) for _, row in top_prods.iterrows(): # V-Fix (2026-01-23): 處理 NaN 值 amount_val = row[col_amount] if pd.isna(amount_val): amount_val = 0 treemap_data.append({ 'category': str(cat), 'product': str(row[col_name]) if pd.notna(row[col_name]) else '', 'value': float(amount_val), 'color': get_color_for_string(str(cat)) # V-Fix: 增加顏色參數,確保與分類顏色一致且清晰 }) # 📊 V-New: ABC 分析 (Pareto Analysis) - TODO #8 # A類: 累積營收 0-80% (核心商品) # B類: 累積營收 80-95% (次要商品) # C類: 累積營收 95-100% (長尾商品) abc_stats = {'A': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}, 'B': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}, 'C': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}} if not target_df.empty and col_amount: # 使用 numpy 加速累積計算 sorted_rev = target_df[col_amount].values # 已在上方排序過 cumsum_rev = np.cumsum(sorted_rev) total_rev_abc = cumsum_rev[-1] if len(cumsum_rev) > 0 else 0 if total_rev_abc > 0: pct_cumsum = cumsum_rev / total_rev_abc * 100 # 找出分界點索引 idx_a = np.searchsorted(pct_cumsum, 80) idx_b = np.searchsorted(pct_cumsum, 95) # A類: 0 ~ idx_a count_a = idx_a + 1 rev_a = cumsum_rev[idx_a] if idx_a < len(cumsum_rev) else total_rev_abc # B類: idx_a+1 ~ idx_b count_b = max(0, idx_b - idx_a) rev_b = (cumsum_rev[idx_b] - cumsum_rev[idx_a]) if idx_b < len(cumsum_rev) else (total_rev_abc - cumsum_rev[idx_a]) # C類: idx_b+1 ~ end count_c = max(0, len(cumsum_rev) - 1 - idx_b) rev_c = total_rev_abc - cumsum_rev[idx_b] if idx_b < len(cumsum_rev) else 0 abc_stats['A'] = {'count': int(count_a), 'revenue': float(rev_a), 'pct_rev': float(rev_a/total_rev_abc*100), 'pct_sku': float(count_a/total_count*100)} abc_stats['B'] = {'count': int(count_b), 'revenue': float(rev_b), 'pct_rev': float(rev_b/total_rev_abc*100), 'pct_sku': float(count_b/total_count*100)} abc_stats['C'] = {'count': int(count_c), 'revenue': float(rev_c), 'pct_rev': float(rev_c/total_rev_abc*100), 'pct_sku': float(count_c/total_count*100)} # 📊 V-New: 廠商獲利能力排行 (Vendor Profitability) - TODO #9 vendor_stats = [] if col_vendor and col_amount and not target_df.empty: # Group by vendor agg_dict = {col_amount: 'sum', col_name: 'nunique'} # nunique 計算不重複商品數 (SKU) if col_qty: agg_dict[col_qty] = 'sum' # V-New: 累加銷量 if col_profit: agg_dict[col_profit] = 'sum' elif col_cost: agg_dict[col_cost] = 'sum' # 使用 groupby 聚合 vendor_group = target_df.groupby(col_vendor).agg(agg_dict).reset_index() # 計算毛利與毛利率 if col_profit: vendor_group['total_profit'] = vendor_group[col_profit] elif col_cost: vendor_group['total_profit'] = vendor_group[col_amount] - vendor_group[col_cost] else: vendor_group['total_profit'] = 0 # 計算營收佔比 (Share %) total_vendor_revenue = vendor_group[col_amount].sum() if total_vendor_revenue > 0: vendor_group['revenue_share'] = (vendor_group[col_amount] / total_vendor_revenue * 100) else: vendor_group['revenue_share'] = 0.0 # 避免除以零 vendor_group['margin_rate'] = np.where(vendor_group[col_amount] > 0, (vendor_group['total_profit'] / vendor_group[col_amount] * 100), 0) # 計算平均客單價 (ASP) if col_qty: vendor_group['asp'] = np.where(vendor_group[col_qty] > 0, vendor_group[col_amount] / vendor_group[col_qty], 0) # 排序:預設按總業績降序 vendor_group = vendor_group.sort_values(by=col_amount, ascending=False) # 格式化輸出 (Top 100) for _, row in vendor_group.head(100).iterrows(): vendor_stats.append({ 'name': str(row[col_vendor]), 'revenue': float(row[col_amount]), 'share': float(row['revenue_share']), # V-New 'qty': float(row[col_qty]) if col_qty else 0, # V-New 'asp': float(row.get('asp', 0)), # V-New 'profit': float(row['total_profit']), 'margin_rate': float(row['margin_rate']), 'sku_count': int(row[col_name]) }) # 📊 V-New: 淡旺季熱力圖 (Seasonality Analysis) - TODO #10 seasonality_data = None if col_date and col_category and col_amount and not target_df.empty: # 1. 取得前 10 大分類 (避免圖表過大) # 使用 target_df (受篩選影響),這樣可以看特定品牌下的分類季節性 top_cats_season = target_df.groupby(col_category)[col_amount].sum().nlargest(10).index.tolist() # 2. 聚合數據 (Month x Category) season_group = target_df[target_df[col_category].isin(top_cats_season)].groupby(['_month_str', col_category])[col_amount].sum().reset_index() # 3. 轉換為 Bubble Chart 格式 # X軸: 月份 (需解析 _month_str 取得順序) # Y軸: 分類 (使用 top_cats_season 的索引) # 取得所有月份並排序 all_months_sorted = sorted(target_df['_month_str'].unique()) month_map = {m: i for i, m in enumerate(all_months_sorted)} cat_map = {c: i for i, c in enumerate(top_cats_season)} points = [] max_val_season = season_group[col_amount].max() if not season_group.empty else 1 for _, row in season_group.iterrows(): m_str = row['_month_str'] cat = row[col_category] val = row[col_amount] if m_str in month_map and cat in cat_map: # 正規化大小 (3~25px) radius = 3 + (math.sqrt(val) / math.sqrt(max_val_season)) * 25 if val > 0 else 0 points.append({ 'x': month_map[m_str], 'y': cat_map[cat], 'r': radius, 'v': float(val), 'm': m_str, 'c': cat }) seasonality_data = { 'datasets': [{ 'label': '淡旺季熱點', 'data': points, # 顏色將在前端動態生成 }], 'yLabels': top_cats_season, 'xLabels': all_months_sorted } # 📊 V-New 2026-01-15: 行銷活動業績貢獻 (Marketing Campaign Contribution) marketing_data = None if not target_df.empty: marketing_data = prepare_marketing_summary(target_df, sort_by=selected_metric) return render_template('sales_analysis.html', marketing_data=marketing_data, # V-New: 傳遞行銷活動數據 items=table_items, kpi={ 'revenue': total_revenue, 'qty': total_qty, 'count': total_count, 'sku_count': sku_count, # V-Fix 2026-01-15: 唯一商品數 'cost': total_cost, 'gross_margin': gross_margin, 'gross_margin_rate': gross_margin_rate, 'avg_price': avg_price }, insights=insights, abc_stats=abc_stats, # V-New: 傳遞 ABC 分析數據 vendor_stats=vendor_stats, # V-New: 傳遞廠商排行數據 seasonality_data=seasonality_data, # V-New: 傳遞淡旺季數據 bar_data=bar_data, cat_data=cat_data, price_dist_data=price_dist_data, scatter_data=scatter_data, bcg_data=bcg_data, # V-New: 傳遞 BCG 數據 dow_data=dow_data, hourly_data=hourly_data, monthly_data=monthly_data, weekly_data=weekly_data, heatmap_data=heatmap_data, treemap_data=treemap_data, all_categories=all_categories, all_brands=all_brands, all_vendors=all_vendors, all_activities=all_activities, all_payments=all_payments, all_months=all_months, # V-New: 傳遞月份列表 selected_category=selected_category, selected_brand=selected_brand, selected_vendor=selected_vendor, selected_activity=selected_activity, selected_payment=selected_payment, selected_dow=selected_dow, selected_hour=selected_hour, selected_month=selected_month, selected_metric=selected_metric, keyword=keyword, min_price=min_price, max_price=max_price, min_margin=min_margin, max_margin=max_margin, cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category, 'date': col_date, 'cost': col_cost, 'profit': col_profit, 'vendor': col_vendor, 'brand': col_brand, 'return_qty': col_return_qty, 'pid': col_pid}, table_name=table_name, data_range_months=data_range_months, start_date=start_date, # V-New: 傳遞自訂開始日期 end_date=end_date, # V-New: 傳遞自訂結束日期 total_records=len(df), db_data_range=db_data_range) # V-New: 傳遞資料庫資料期間 except Exception as e: sys_log.error(f"Sales Analysis Error: {e}") import traceback traceback.print_exc() # 提供完整的變數以避免模板錯誤 return render_template('sales_analysis.html', error=f"系統發生錯誤: {str(e)}", marketing_data=None, insights=None, abc_stats=None, vendor_stats=None, seasonality_data=None, scatter_data=None, bcg_data=None, dow_data=None, hourly_data=None, monthly_data=None, weekly_data=None, heatmap_data=None, treemap_data=None, all_categories=[], all_brands=[], all_vendors=[], all_activities=[], all_payments=[], all_months=[], selected_category='all', selected_brand='all', selected_vendor='all', selected_activity='all', selected_payment='all', selected_dow='all', selected_hour='all', selected_month='all', selected_metric=request.args.get('metric', 'amount'), keyword='', min_price='', max_price='', min_margin='', max_margin='', cols={}, table_name='realtime_sales_monthly', no_filter=False, data_range_months=int(request.args.get('data_range', '0') or '0'), start_date=request.args.get('start_date', ''), end_date=request.args.get('end_date', ''), total_records=0, db_data_range='') # V-Opt: API 層級快取 (減少重複查詢) _TABLE_DATA_CACHE = {} _TABLE_DATA_CACHE_TTL = 60 # 快取 60 秒 @app.route('/api/sales_analysis/table_data') def get_sales_table_data(): """API: 取得業績分析的詳細列表資料 (Server-side AJAX) - 使用 SQL 聚合優化""" try: import hashlib from datetime import datetime, timedelta, timezone TAIPEI_TZ = timezone(timedelta(hours=8)) # V-Opt: 產生查詢快取 key (根據所有篩選條件) cache_params = request.args.to_dict() cache_key = hashlib.md5(str(sorted(cache_params.items())).encode(), usedforsecurity=False).hexdigest() # V-Opt: 檢查快取 if cache_key in _TABLE_DATA_CACHE: cached = _TABLE_DATA_CACHE[cache_key] if time.time() - cached['time'] < _TABLE_DATA_CACHE_TTL: sys_log.debug(f"[API] Table Data: 使用快取 (key={cache_key[:8]})") return jsonify(cached['data']) table_name = 'realtime_sales_monthly' data_range_months = int(request.args.get('data_range', '1') or '1') start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 # V-Fix: 取得所有篩選參數 category_filter = request.args.get('category', 'all') brand_filter = request.args.get('brand', 'all') # V-Fix: 品牌篩選 vendor_filter = request.args.get('vendor', 'all') # V-Fix: 廠商篩選 activity_filter = request.args.get('activity', 'all') # V-Fix: 活動篩選 payment_filter = request.args.get('payment', 'all') # V-Fix: 付款方式篩選 month_filter = request.args.get('month', 'all') dow_filter = request.args.get('dow', 'all') # 星期篩選 hour_filter = request.args.get('hour', 'all') # 小時篩選 min_price_str = request.args.get('min_price', '') max_price_str = request.args.get('max_price', '') min_margin_str = request.args.get('min_margin', '') max_margin_str = request.args.get('max_margin', '') keyword = request.args.get('keyword', '').strip() db = DatabaseManager() # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 if start_date or end_date: cache_key = f"{table_name}_custom_{start_date}_{end_date}" else: cache_key = f"{table_name}_{data_range_months}m" # 嘗試從快取讀取欄位名稱 cols_map = {} if cache_key in _SALES_PROCESSED_CACHE: cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) # V-Fix (2026-01-23): 使用 or 確保不會得到 None 值 col_name = cols_map.get('name') or '商品名稱' col_pid = cols_map.get('pid') or '商品ID' col_brand = cols_map.get('brand') or '品牌' col_vendor = cols_map.get('vendor') or '廠商名稱' col_category = cols_map.get('category') or '商品館' col_amount = cols_map.get('amount') or '總業績' col_qty = cols_map.get('qty') or '數量' col_cost = cols_map.get('cost') or '總成本' col_return_qty = cols_map.get('return_qty') or '退貨數量' # V-Opt: 使用純 SQL 聚合查詢,避免載入完整資料集 # 建立日期篩選條件 date_filter = "" # V-New: 優先處理自訂日期區間 if start_date or end_date: # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) start_date_slash = start_date.replace('-', '/') if start_date else '' end_date_slash = end_date.replace('-', '/') if end_date else '' # V-Fix: 只使用「日期」欄位(「訂單日期」欄位是固定文字「訂單日期」,不是實際日期) if start_date and end_date: date_filter = f"""AND ("日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}')""" elif start_date: date_filter = f"""AND ("日期" >= '{start_date_slash}')""" else: # only end_date date_filter = f"""AND ("日期" <= '{end_date_slash}')""" elif data_range_months > 0: # V-Fix: 使用斜線格式以匹配資料庫格式 cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') # V-Fix: 只使用「日期」欄位進行篩選(「訂單日期」是固定文字,不是實際日期) date_filter = f"""AND ("日期" >= '{cutoff_date}')""" # V-Fix: 建立其他篩選條件 additional_filters = [] # 分類篩選 if category_filter and category_filter != 'all' and col_category: additional_filters.append(f""""{col_category}" = '{category_filter}'""") # V-Fix: 品牌篩選 if brand_filter and brand_filter != 'all' and col_brand: additional_filters.append(f""""{col_brand}" = '{brand_filter}'""") # V-Fix: 廠商篩選 if vendor_filter and vendor_filter != 'all' and col_vendor: additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""") # V-Fix: 活動篩選 col_activity = cols_map.get('activity') if activity_filter and activity_filter != 'all' and col_activity: additional_filters.append(f""""{col_activity}" = '{activity_filter}'""") # V-Fix: 付款方式篩選 col_payment = cols_map.get('payment') if payment_filter and payment_filter != 'all' and col_payment: additional_filters.append(f""""{col_payment}" = '{payment_filter}'""") # 月份篩選 if month_filter and month_filter != 'all': # V-Fix: 月份格式例如 "2025-01",但資料庫可能使用斜線格式 "2025/01" # 只使用「日期」欄位(「訂單日期」是固定文字,「時間」只包含時間) month_filter_slash = month_filter.replace('-', '/') # "2025-01" -> "2025/01" # 同時匹配橫線和斜線格式 additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""") # 星期篩選 (需要從日期計算) if dow_filter and dow_filter != 'all': # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite 兩種資料庫 # Pandas dt.dayofweek: 0=Monday, 6=Sunday pandas_dow = int(dow_filter) if DATABASE_TYPE == 'postgresql': # PostgreSQL: EXTRACT(DOW FROM date) 0=Sunday, 6=Saturday # Pandas 0(Mon) -> PostgreSQL 1(Mon), Pandas 6(Sun) -> PostgreSQL 0(Sun) pg_dow = (pandas_dow + 1) % 7 # 日期格式可能是 2025/01/01,需要轉換為 YYYY-MM-DD additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""") else: # SQLite: strftime('%w', date) 0=Sunday, 6=Saturday sqlite_dow = str((pandas_dow + 1) % 7) additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""") # 小時篩選 (需要從時間欄位提取) if hour_filter and hour_filter != 'all': # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite 兩種資料庫 hour_val = int(hour_filter) if DATABASE_TYPE == 'postgresql': # PostgreSQL: 使用 SUBSTRING 或 CAST additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""") else: # SQLite: 使用 substr additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""") # 關鍵字篩選 if keyword: keyword_escaped = keyword.replace("'", "''") # SQL 注入防護 keyword_conditions = [] if col_name: keyword_conditions.append(f""""{col_name}" LIKE '%{keyword_escaped}%'""") if col_pid: keyword_conditions.append(f""""{col_pid}" LIKE '%{keyword_escaped}%'""") if col_brand: keyword_conditions.append(f""""{col_brand}" LIKE '%{keyword_escaped}%'""") if col_vendor: keyword_conditions.append(f""""{col_vendor}" LIKE '%{keyword_escaped}%'""") if keyword_conditions: additional_filters.append(f"({' OR '.join(keyword_conditions)})") # V-New: 價格區間篩選 (Price Range) if (min_price_str or max_price_str) and col_qty and col_amount: # 假設單價 = 總業績 / 數量 (防止除以零) price_cal_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' if min_price_str: additional_filters.append(f"{price_cal_sql} >= {float(min_price_str)}") if max_price_str: additional_filters.append(f"{price_cal_sql} <= {float(max_price_str)}") # V-New: 毛利率區間篩選 (Margin Range) if (min_margin_str or max_margin_str) and col_amount: # 計算毛利額 SQL if col_profit: profit_cal_sql = f'"{col_profit}"' elif col_cost: profit_cal_sql = f'("{col_amount}" - "{col_cost}")' else: profit_cal_sql = "0" # 計算毛利率 SQL: (毛利 / 業績) * 100 margin_cal_sql = f'({profit_cal_sql} * 100.0 / NULLIF("{col_amount}", 0))' if min_margin_str: additional_filters.append(f"{margin_cal_sql} >= {float(min_margin_str)}") if max_margin_str: additional_filters.append(f"{margin_cal_sql} <= {float(max_margin_str)}") # 組合所有篩選條件 all_filters = date_filter if additional_filters: all_filters += " AND " + " AND ".join(additional_filters) # SQL 聚合查詢 - 直接在資料庫層級完成聚合 # V-Fix: 使用動態欄位名稱 group_by_cols = [] if col_pid: group_by_cols.append(f'"{col_pid}"') if col_name: group_by_cols.append(f'"{col_name}"') if col_brand: group_by_cols.append(f'"{col_brand}"') if col_vendor: group_by_cols.append(f'"{col_vendor}"') if col_category: group_by_cols.append(f'"{col_category}"') group_by_clause = ', '.join(group_by_cols) if group_by_cols else '"商品ID"' sql_query = f""" SELECT {f'"{col_pid}" as product_id' if col_pid else "'未知' as product_id"}, {f'"{col_name}" as name' if col_name else "'未知' as name"}, {f'"{col_brand}" as brand' if col_brand else "'' as brand"}, {f'"{col_vendor}" as vendor' if col_vendor else "'' as vendor"}, {f'"{col_category}" as category' if col_category else "'' as category"}, {f'SUM(CAST("{col_amount}" AS REAL)) as amount' if col_amount else '0 as amount'}, {f'SUM(CAST("{col_qty}" AS REAL)) as qty' if col_qty else '0 as qty'}, {f'SUM(CAST("{col_cost}" AS REAL)) as cost' if col_cost else '0 as cost'}, {f'SUM(CAST("{col_return_qty}" AS REAL)) as return_qty' if col_return_qty else '0 as return_qty'}, COUNT(*) as order_count FROM {table_name} WHERE 1=1 {all_filters} GROUP BY {group_by_clause} ORDER BY amount DESC LIMIT 300 """ df_agg = pd.read_sql(sql_query, db.engine) sys_log.info(f"[API] Table Data: SQL聚合查詢返回 {len(df_agg)} 筆商品 (篩選: category={category_filter}, month={month_filter}, dow={dow_filter}, hour={hour_filter}, keyword={keyword})") if df_agg.empty: return jsonify({'data': []}) # 計算衍生欄位 df_agg['margin_rate'] = ((df_agg['amount'] - df_agg['cost']) / df_agg['amount'] * 100).fillna(0) df_agg['margin_rate'] = df_agg['margin_rate'].replace([np.inf, -np.inf], 0) df_agg['avg_price'] = (df_agg['amount'] / df_agg['qty']).fillna(0) df_agg['return_rate'] = (df_agg['return_qty'] / df_agg['qty'] * 100).fillna(0) # V-Fix: 應用價格區間篩選 (在計算欄位後才能篩選) if min_price_str: try: min_price = float(min_price_str) df_agg = df_agg[df_agg['avg_price'] >= min_price] except ValueError: pass if max_price_str: try: max_price = float(max_price_str) df_agg = df_agg[df_agg['avg_price'] <= max_price] except ValueError: pass # V-Fix: 應用毛利區間篩選 (在計算欄位後才能篩選) if min_margin_str: try: min_margin = float(min_margin_str) df_agg = df_agg[df_agg['margin_rate'] >= min_margin] except ValueError: pass if max_margin_str: try: max_margin = float(max_margin_str) df_agg = df_agg[df_agg['margin_rate'] <= max_margin] except ValueError: pass # 重新排序並限制到 300 筆 (減少前端渲染負擔) df_agg = df_agg.sort_values('amount', ascending=False).head(300) # V-Opt: 使用向量化操作取代逐列迴圈 df_agg['rank'] = range(1, len(df_agg) + 1) df_agg['month_str'] = '' # SQL聚合模式不需要月份字串 # 重新命名欄位以符合前端格式 result_df = df_agg.rename(columns={ 'product_id': 'product_id', 'name': 'name', 'brand': 'brand', 'vendor': 'vendor', 'category': 'category', 'margin_rate': 'margin_rate', 'avg_price': 'avg_price', 'return_rate': 'return_rate', 'qty': 'qty', 'amount': 'amount' }) # 選擇需要的欄位並轉換為字典列表 columns = ['rank', 'product_id', 'name', 'brand', 'vendor', 'category', 'margin_rate', 'month_str', 'avg_price', 'return_rate', 'qty', 'amount'] # V-Fix (2026-01-23): 確保所有數值欄位無 NaN/Infinity,避免 JSON 序列化失敗 numeric_cols = ['margin_rate', 'avg_price', 'return_rate', 'qty', 'amount'] for col in numeric_cols: if col in result_df.columns: result_df[col] = result_df[col].replace([np.inf, -np.inf], 0).fillna(0) # V-Fix (2026-01-23): 確保字串欄位無 None,避免 JSON 序列化失敗 string_cols = ['product_id', 'name', 'brand', 'vendor', 'category', 'month_str'] for col in string_cols: if col in result_df.columns: result_df[col] = result_df[col].fillna('').astype(str) data = result_df[columns].to_dict('records') response_data = {'data': data} # V-Opt: 儲存到快取 _TABLE_DATA_CACHE[cache_key] = {'data': response_data, 'time': time.time()} # V-Opt: 清理過期快取 (保留最近 50 個) if len(_TABLE_DATA_CACHE) > 50: sorted_keys = sorted(_TABLE_DATA_CACHE.keys(), key=lambda k: _TABLE_DATA_CACHE[k]['time']) for old_key in sorted_keys[:-50]: del _TABLE_DATA_CACHE[old_key] return jsonify(response_data) except Exception as e: sys_log.error(f"[API] Table Data Error: {e}") import traceback traceback.print_exc() return jsonify({'error': str(e)}), 500 # V-Old: 保留舊版本以防需要回滾 @app.route('/api/sales_analysis/table_data_pandas') def get_sales_table_data_pandas(): """API: 取得業績分析的詳細列表資料 (使用 pandas 聚合 - 舊版本)""" try: table_name = 'realtime_sales_monthly' data_range_months = int(request.args.get('data_range', '1')) cache_key = f"{table_name}_{data_range_months}m" target_df, cols_map, err = _get_filtered_sales_data(cache_key) if err or target_df is None: sys_log.warning(f"[API] Table Data: 快取不存在 ({cache_key}),返回空資料") return jsonify({'data': []}) if target_df.empty: return jsonify({'data': []}) col_name = cols_map.get('name') col_amount = cols_map.get('amount') col_qty = cols_map.get('qty') col_cost = cols_map.get('cost') col_profit = cols_map.get('profit') col_category = cols_map.get('category') col_vendor = cols_map.get('vendor') col_date = cols_map.get('date') col_brand = cols_map.get('brand') col_return_qty = cols_map.get('return_qty') selected_metric = request.args.get('metric', 'amount') # 執行聚合 (V-Opt: 多維度聚合,增加精確度) agg_rules = {col_amount: 'sum'} if col_qty: agg_rules[col_qty] = 'sum' if col_cost: agg_rules[col_cost] = 'sum' if col_profit: agg_rules[col_profit] = 'sum' if col_return_qty: agg_rules[col_return_qty] = 'sum' if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique())) # Group By 鍵值:商品名稱 + 品牌 + 廠商 + 分類 (確保唯一性) group_cols = [col_name] if col_brand: group_cols.append(col_brand) if col_vendor: group_cols.append(col_vendor) if col_category: group_cols.append(col_category) df_agg = target_df.groupby(group_cols).agg(agg_rules).reset_index() # 計算毛利率 if col_profit: df_agg['agg_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100 elif col_cost: df_agg['agg_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100 else: df_agg['agg_margin_rate'] = 0.0 df_agg['agg_margin_rate'] = df_agg['agg_margin_rate'].replace([np.inf, -np.inf, np.nan], 0) # V-New: 計算平均單價與退貨率 if col_qty: df_agg['avg_price'] = (df_agg[col_amount] / df_agg[col_qty]).fillna(0) if col_return_qty: df_agg['return_rate'] = (df_agg[col_return_qty] / df_agg[col_qty] * 100).fillna(0) # 排序 sort_col_agg = col_amount if selected_metric == 'qty' and col_qty: sort_col_agg = col_qty df_agg = df_agg.sort_values(by=sort_col_agg, ascending=False).head(1000) # 限制前 1000 筆 # 轉換為 DataTables 需要的格式 data = [] for i, row in enumerate(df_agg.to_dict('records')): data.append({ 'rank': i + 1, 'name': row.get(col_name, ''), 'brand': row.get(col_brand, ''), 'vendor': row.get(col_vendor, ''), 'category': row.get(col_category, ''), 'margin_rate': row.get('agg_margin_rate', 0), 'month_str': row.get('_month_str', ''), 'avg_price': row.get('avg_price', 0), 'return_rate': row.get('return_rate', 0), 'qty': row.get(col_qty, 0), 'amount': row.get(col_amount, 0) }) return jsonify({'data': data}) except Exception as e: sys_log.error(f"Table Data API Error: {e}") return jsonify({'error': str(e)}), 500 # ================= 💎 V-New: Top 3 Highlights 詳細列表 API ================= @app.route('/api/sales_analysis/top_detail') def get_top_detail(): """API: 取得 Top N 詳細列表(業績貢獻王/獲利金雞母/人氣引流款)""" try: from datetime import datetime, timedelta, timezone TAIPEI_TZ = timezone(timedelta(hours=8)) table_name = 'realtime_sales_monthly' data_range_months = int(request.args.get('data_range', '1') or '1') start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 top_type = request.args.get('type', 'revenue') # revenue/margin/quantity metric = request.args.get('metric', 'amount') # amount/profit/qty view_type = request.args.get('view', 'product') # product/category db = DatabaseManager() # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 if start_date or end_date: cache_key = f"{table_name}_custom_{start_date}_{end_date}" else: cache_key = f"{table_name}_{data_range_months}m" # 嘗試從快取讀取欄位名稱 cols_map = {} if cache_key in _SALES_PROCESSED_CACHE: cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) # V-Fix (2026-01-23): 使用 or 確保不會得到 None 值 col_name = cols_map.get('name') or '商品名稱' col_brand = cols_map.get('brand') or '品牌' col_vendor = cols_map.get('vendor') or '廠商名稱' col_category = cols_map.get('category') or '商品館' col_amount = cols_map.get('amount') or '總業績' col_qty = cols_map.get('qty') or '數量' col_cost = cols_map.get('cost') or '總成本' col_profit = cols_map.get('profit') # 可以為 None col_activity = cols_map.get('activity') or '活動名稱' col_payment = cols_map.get('payment') or '付款方式' # 建立日期篩選條件 date_filter = "" # V-New: 優先處理自訂日期區間 if start_date or end_date: # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) start_date_slash = start_date.replace('-', '/') if start_date else '' end_date_slash = end_date.replace('-', '/') if end_date else '' if start_date and end_date: date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'""" elif start_date: date_filter = f"""AND "日期" >= '{start_date_slash}'""" else: # only end_date date_filter = f"""AND "日期" <= '{end_date_slash}'""" elif data_range_months > 0: cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') date_filter = f"""AND "日期" >= '{cutoff_date}'""" # V-Fix: 補上其他所有篩選條件 (與 get_sales_table_data 一致) category_filter = request.args.get('category', 'all') brand_filter = request.args.get('brand', 'all') vendor_filter = request.args.get('vendor', 'all') activity_filter = request.args.get('activity', 'all') payment_filter = request.args.get('payment', 'all') month_filter = request.args.get('month', 'all') dow_filter = request.args.get('dow', 'all') hour_filter = request.args.get('hour', 'all') min_price_str = request.args.get('min_price', '') max_price_str = request.args.get('max_price', '') min_margin_str = request.args.get('min_margin', '') max_margin_str = request.args.get('max_margin', '') keyword = request.args.get('keyword', '').strip() additional_filters = [] if category_filter and category_filter != 'all': additional_filters.append(f""""{col_category}" = '{category_filter}'""") if brand_filter and brand_filter != 'all': additional_filters.append(f""""{col_brand}" = '{brand_filter}'""") if vendor_filter and vendor_filter != 'all': additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""") if activity_filter and activity_filter != 'all': additional_filters.append(f""""{col_activity}" = '{activity_filter}'""") if payment_filter and payment_filter != 'all': additional_filters.append(f""""{col_payment}" = '{payment_filter}'""") # 時間維度 if month_filter and month_filter != 'all': month_filter_slash = month_filter.replace('-', '/') # 使用「日期」欄位 (這似乎是系統內部固定欄位,不需 dynamic map,除非資料表結構也變了) # 假設 "日期" 是固定欄位 additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""") if dow_filter and dow_filter != 'all': # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API) pandas_dow = int(dow_filter) if DATABASE_TYPE == 'postgresql': pg_dow = (pandas_dow + 1) % 7 additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""") else: sqlite_dow = str((pandas_dow + 1) % 7) additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""") if hour_filter and hour_filter != 'all': # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API) hour_val = int(hour_filter) if DATABASE_TYPE == 'postgresql': additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""") else: additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""") # 關鍵字 if keyword: keyword_escaped = keyword.replace("'", "''") k_conds = [] for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]: k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""") additional_filters.append(f"({' OR '.join(k_conds)})") if (min_price_str or max_price_str): price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}") if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}") if (min_margin_str or max_margin_str): if col_profit: profit_sql = f'"{col_profit}"' else: profit_sql = f'("{col_amount}" - "{col_cost}")' margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))' if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}") if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}") if additional_filters: date_filter += " AND " + " AND ".join(additional_filters) # V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合) if col_profit: profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))' else: profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))' # 根據檢視類型和指標建立 SQL 查詢 if view_type == 'category': # 分類排行 if metric == 'qty': sql_query = f""" SELECT "{col_category}" as name, SUM(CAST("{col_qty}" AS REAL)) as value FROM {table_name} WHERE "{col_category}" IS NOT NULL {date_filter} GROUP BY "{col_category}" ORDER BY value DESC LIMIT 50 """ elif metric == 'profit': sql_query = f""" SELECT "{col_category}" as name, {profit_select_sql} as value, CASE WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 ELSE 0 END as margin_rate FROM {table_name} WHERE "{col_category}" IS NOT NULL {date_filter} GROUP BY "{col_category}" ORDER BY value DESC LIMIT 50 """ else: # amount sql_query = f""" SELECT "{col_category}" as name, SUM(CAST("{col_amount}" AS REAL)) as value FROM {table_name} WHERE "{col_category}" IS NOT NULL {date_filter} GROUP BY "{col_category}" ORDER BY value DESC LIMIT 50 """ else: # 商品排行(包含商品ID) pid_col_sql = f'"{cols_map.get("pid", "商品ID")}"' # 商品ID 欄位 if metric == 'qty': sql_query = f""" SELECT {pid_col_sql} as product_id, "{col_name}" as name, "{col_brand}" as brand, "{col_vendor}" as vendor, "{col_category}" as category, SUM(CAST("{col_qty}" AS REAL)) as value FROM {table_name} WHERE "{col_name}" IS NOT NULL {date_filter} GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" ORDER BY value DESC LIMIT 100 """ elif metric == 'profit': sql_query = f""" SELECT {pid_col_sql} as product_id, "{col_name}" as name, "{col_brand}" as brand, "{col_vendor}" as vendor, "{col_category}" as category, {profit_select_sql} as value, CASE WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 ELSE 0 END as margin_rate FROM {table_name} WHERE "{col_name}" IS NOT NULL {date_filter} GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" ORDER BY value DESC LIMIT 100 """ else: # amount sql_query = f""" SELECT {pid_col_sql} as product_id, "{col_name}" as name, "{col_brand}" as brand, "{col_vendor}" as vendor, "{col_category}" as category, SUM(CAST("{col_amount}" AS REAL)) as value FROM {table_name} WHERE "{col_name}" IS NOT NULL {date_filter} GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" ORDER BY value DESC LIMIT 100 """ # 執行查詢 df = pd.read_sql(sql_query, db.engine) sys_log.info(f"[API] Top Detail: {top_type}/{view_type} 返回 {len(df)} 筆資料") if df.empty: return jsonify({'items': []}) # V-Fix (2026-01-23): 確保數值欄位無 NaN/Infinity,避免 JSON 序列化失敗 numeric_cols = ['value', 'margin_rate'] for col in numeric_cols: if col in df.columns: df[col] = df[col].replace([np.inf, -np.inf], 0).fillna(0) # V-Fix (2026-01-23): 確保字串欄位無 None string_cols = ['product_id', 'name', 'brand', 'vendor', 'category'] for col in string_cols: if col in df.columns: df[col] = df[col].fillna('').astype(str) # 轉換為 JSON items = df.to_dict('records') return jsonify({'items': items}) except Exception as e: sys_log.error(f"[API] Top Detail Error: {e}") import traceback traceback.print_exc() return jsonify({'error': str(e)}), 500 @app.route('/api/sales_analysis/export_top_detail') def export_top_detail(): """API: 匯出 Top N 詳細列表為 Excel""" try: from datetime import datetime, timedelta, timezone import io TAIPEI_TZ = timezone(timedelta(hours=8)) table_name = 'realtime_sales_monthly' data_range_months = int(request.args.get('data_range', '1') or '1') start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 top_type = request.args.get('type', 'revenue') metric = request.args.get('metric', 'amount') view_type = request.args.get('view', 'product') db = DatabaseManager() # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 if start_date or end_date: cache_key = f"{table_name}_custom_{start_date}_{end_date}" else: cache_key = f"{table_name}_{data_range_months}m" # 嘗試從快取讀取欄位名稱 cols_map = {} if cache_key in _SALES_PROCESSED_CACHE: cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) # V-Fix (2026-01-23): 使用 or 確保不會得到 None 值 col_name = cols_map.get('name') or '商品名稱' col_brand = cols_map.get('brand') or '品牌' col_vendor = cols_map.get('vendor') or '廠商名稱' col_category = cols_map.get('category') or '商品館' col_amount = cols_map.get('amount') or '總業績' col_qty = cols_map.get('qty') or '數量' col_cost = cols_map.get('cost') or '總成本' col_profit = cols_map.get('profit') # 可以為 None col_activity = cols_map.get('activity') or '活動名稱' col_payment = cols_map.get('payment') or '付款方式' # 建立日期篩選條件 date_filter = "" # V-New: 優先處理自訂日期區間 if start_date or end_date: # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) start_date_slash = start_date.replace('-', '/') if start_date else '' end_date_slash = end_date.replace('-', '/') if end_date else '' if start_date and end_date: date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'""" elif start_date: date_filter = f"""AND "日期" >= '{start_date_slash}'""" else: # only end_date date_filter = f"""AND "日期" <= '{end_date_slash}'""" elif data_range_months > 0: cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') date_filter = f"""AND "日期" >= '{cutoff_date}'""" # V-Fix: 補上其他所有篩選條件 (與 get_top_detail 一致) category_filter = request.args.get('category', 'all') brand_filter = request.args.get('brand', 'all') vendor_filter = request.args.get('vendor', 'all') activity_filter = request.args.get('activity', 'all') payment_filter = request.args.get('payment', 'all') month_filter = request.args.get('month', 'all') dow_filter = request.args.get('dow', 'all') hour_filter = request.args.get('hour', 'all') min_price_str = request.args.get('min_price', '') max_price_str = request.args.get('max_price', '') min_margin_str = request.args.get('min_margin', '') max_margin_str = request.args.get('max_margin', '') keyword = request.args.get('keyword', '').strip() additional_filters = [] if category_filter and category_filter != 'all': additional_filters.append(f""""{col_category}" = '{category_filter}'""") if brand_filter and brand_filter != 'all': additional_filters.append(f""""{col_brand}" = '{brand_filter}'""") if vendor_filter and vendor_filter != 'all': additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""") if activity_filter and activity_filter != 'all': additional_filters.append(f""""{col_activity}" = '{activity_filter}'""") if payment_filter and payment_filter != 'all': additional_filters.append(f""""{col_payment}" = '{payment_filter}'""") if month_filter and month_filter != 'all': month_filter_slash = month_filter.replace('-', '/') additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""") if dow_filter and dow_filter != 'all': # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API) pandas_dow = int(dow_filter) if DATABASE_TYPE == 'postgresql': pg_dow = (pandas_dow + 1) % 7 additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""") else: sqlite_dow = str((pandas_dow + 1) % 7) additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""") if hour_filter and hour_filter != 'all': # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API) hour_val = int(hour_filter) if DATABASE_TYPE == 'postgresql': additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""") else: additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""") if keyword: keyword_escaped = keyword.replace("'", "''") k_conds = [] for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]: k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""") additional_filters.append(f"({' OR '.join(k_conds)})") if (min_price_str or max_price_str): price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}") if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}") if (min_margin_str or max_margin_str): if col_profit: profit_sql = f'"{col_profit}"' else: profit_sql = f'("{col_amount}" - "{col_cost}")' margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))' if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}") if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}") if additional_filters: date_filter += " AND " + " AND ".join(additional_filters) # V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合) if col_profit: profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))' else: profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))' # 根據檢視類型和指標建立 SQL 查詢(與上面相同) if view_type == 'category': if metric == 'qty': sql_query = f""" SELECT "{col_category}" as 分類名稱, SUM(CAST("{col_qty}" AS REAL)) as 銷售數量 FROM {table_name} WHERE "{col_category}" IS NOT NULL {date_filter} GROUP BY "{col_category}" ORDER BY 銷售數量 DESC LIMIT 50 """ elif metric == 'profit': sql_query = f""" SELECT "{col_category}" as 分類名稱, {profit_select_sql} as 毛利金額, CASE WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 ELSE 0 END as 毛利率 FROM {table_name} WHERE "{col_category}" IS NOT NULL {date_filter} GROUP BY "{col_category}" ORDER BY 毛利金額 DESC LIMIT 50 """ else: # amount sql_query = f""" SELECT "{col_category}" as 分類名稱, SUM(CAST("{col_amount}" AS REAL)) as 銷售金額 FROM {table_name} WHERE "{col_category}" IS NOT NULL {date_filter} GROUP BY "{col_category}" ORDER BY 銷售金額 DESC LIMIT 50 """ else: # 商品排行(包含商品ID) if metric == 'qty': sql_query = f""" SELECT "{cols_map.get("pid", "商品ID")}" as 商品ID, "{col_name}" as 商品名稱, "{col_brand}" as 品牌, "{col_vendor}" as 廠商名稱, "{col_category}" as 分類名稱, SUM(CAST("{col_qty}" AS REAL)) as 銷售數量 FROM {table_name} WHERE "{col_name}" IS NOT NULL {date_filter} GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" ORDER BY 銷售數量 DESC LIMIT 100 """ elif metric == 'profit': sql_query = f""" SELECT "{cols_map.get("pid", "商品ID")}" as 商品ID, "{col_name}" as 商品名稱, "{col_brand}" as 品牌, "{col_vendor}" as 廠商名稱, "{col_category}" as 分類名稱, {profit_select_sql} as 毛利金額, CASE WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 ELSE 0 END as 毛利率 FROM {table_name} WHERE "{col_name}" IS NOT NULL {date_filter} GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" ORDER BY 毛利金額 DESC LIMIT 100 """ else: # amount sql_query = f""" SELECT "{cols_map.get("pid", "商品ID")}" as 商品ID, "{col_name}" as 商品名稱, "{col_brand}" as 品牌, "{col_vendor}" as 廠商名稱, "{col_category}" as 分類名稱, SUM(CAST("{col_amount}" AS REAL)) as 銷售金額 FROM {table_name} WHERE "{col_name}" IS NOT NULL {date_filter} GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" ORDER BY 銷售金額 DESC LIMIT 100 """ # 執行查詢並匯出 df = pd.read_sql(sql_query, db.engine) if df.empty: return "無資料可匯出", 400 # 生成 Excel output = io.BytesIO() with pd.ExcelWriter(output, engine='openpyxl') as writer: df.to_excel(writer, index=False, sheet_name='Top排行') output.seek(0) # 生成檔案名稱 type_names = {'revenue': '業績貢獻王', 'margin': '獲利金雞母', 'quantity': '人氣引流款'} view_names = {'product': '商品排行', 'category': '分類排行'} filename = f"{type_names.get(top_type, '排行')}_{view_names.get(view_type, '')}_{datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')}.xlsx" sys_log.info(f"[Export] Top Detail: {filename} ({len(df)} 筆)") return send_file( output, as_attachment=True, download_name=filename, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) except Exception as e: sys_log.error(f"[Export] Top Detail Error: {e}") return f"匯出失敗: {e}", 500 # ================= 📈 V-New: 年度對比 (Year-over-Year Comparison) ================= @app.route('/api/sales_analysis/yoy_comparison') def yoy_comparison(): """ API: 年度對比分析 (YoY Comparison) 參數: year1: 基準年 (例如 2024) year2: 對比年 (例如 2025) month: 月份 (可選,1-12,不帶則為全年) metric: 指標 (revenue/qty/profit) 回傳: JSON with year1 total, year2 total, growth rate, and monthly breakdown """ try: from datetime import datetime, timedelta, timezone TAIPEI_TZ = timezone(timedelta(hours=8)) table_name = 'realtime_sales_monthly' year1 = request.args.get('year1', '2024') year2 = request.args.get('year2', '2025') month = request.args.get('month', '') # 可選,1-12 metric = request.args.get('metric', 'revenue') # revenue/qty/profit db = DatabaseManager() # 欄位名稱 col_amount = '總業績' col_qty = '數量' col_cost = '總成本' col_date = '日期' # 根據指標決定聚合欄位 if metric == 'qty': agg_sql = f'SUM(CAST("{col_qty}" AS REAL))' metric_label = '銷售數量' elif metric == 'profit': agg_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))' metric_label = '毛利金額' else: # revenue agg_sql = f'SUM(CAST("{col_amount}" AS REAL))' metric_label = '銷售金額' # 建立年度篩選條件 # 日期格式為 2025/01/01 或 2025-01-01 def build_year_filter(year, month_filter=''): if month_filter: month_str = month_filter.zfill(2) return f"""("{col_date}" LIKE '{year}/{month_str}%' OR "{col_date}" LIKE '{year}-{month_str}%')""" else: return f"""("{col_date}" LIKE '{year}/%' OR "{col_date}" LIKE '{year}-%')""" year1_filter = build_year_filter(year1, month) year2_filter = build_year_filter(year2, month) # 查詢年度總計 sql_year1 = f""" SELECT {agg_sql} as total FROM {table_name} WHERE {year1_filter} """ sql_year2 = f""" SELECT {agg_sql} as total FROM {table_name} WHERE {year2_filter} """ # V-Fix: SQLAlchemy 2.0 需要使用 text() 包裹 SQL 字串 from sqlalchemy import text result_year1 = pd.read_sql(text(sql_year1), db.engine) result_year2 = pd.read_sql(text(sql_year2), db.engine) total_year1 = float(result_year1['total'].iloc[0] or 0) total_year2 = float(result_year2['total'].iloc[0] or 0) # 計算成長率 if total_year1 > 0: growth_rate = ((total_year2 - total_year1) / total_year1) * 100 else: growth_rate = 0 if total_year2 == 0 else 100 # 月度明細 (如果沒有指定月份,則查詢 12 個月的明細) monthly_breakdown = [] if not month: for m in range(1, 13): m_str = str(m).zfill(2) y1_filter = build_year_filter(year1, m_str) y2_filter = build_year_filter(year2, m_str) sql_m1 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y1_filter}" sql_m2 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y2_filter}" # V-Fix: SQLAlchemy 2.0 需要使用 text() r1 = pd.read_sql(text(sql_m1), db.engine) r2 = pd.read_sql(text(sql_m2), db.engine) v1 = float(r1['total'].iloc[0] or 0) v2 = float(r2['total'].iloc[0] or 0) m_growth = ((v2 - v1) / v1 * 100) if v1 > 0 else (0 if v2 == 0 else 100) monthly_breakdown.append({ 'month': m, 'month_label': f'{m}月', 'year1_value': v1, 'year2_value': v2, 'growth_rate': round(m_growth, 2) }) response = { 'year1': { 'label': f'{year1}年' + (f'{month}月' if month else ''), 'total': total_year1 }, 'year2': { 'label': f'{year2}年' + (f'{month}月' if month else ''), 'total': total_year2 }, 'growth_rate': round(growth_rate, 2), 'metric': metric, 'metric_label': metric_label, 'monthly_breakdown': monthly_breakdown } sys_log.info(f"[YoY] {year1} vs {year2}: {total_year1:,.0f} -> {total_year2:,.0f} ({growth_rate:+.1f}%)") return jsonify(response) except Exception as e: sys_log.error(f"[YoY] Error: {e}") traceback.print_exc() return jsonify({'error': str(e)}), 500 # ================= 📈 V-New: 營運成長報表 (Growth Strategy) ================= @app.route('/growth_analysis') def growth_analysis(): """營運成長策略報表 (MoM, YoY, AOV, YTD)""" try: from sqlalchemy import text as sa_text db = DatabaseManager() table_name = 'realtime_sales_monthly' # 1. 檢查資料表 inspector = inspect(db.engine) if table_name not in inspector.get_table_names(): return f"尚未匯入業績資料 ({table_name})", 404 # 2. SQL 月度聚合(避免全量 748k 行讀進 pandas) monthly_sql = sa_text(""" SELECT date_trunc('month', TO_DATE("日期", 'YYYY/MM/DD'))::date AS month, SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS amount, SUM(COALESCE(NULLIF("總成本", '')::numeric, 0)) AS cost, COUNT(DISTINCT "訂單編號") AS orders FROM realtime_sales_monthly WHERE "日期" IS NOT NULL AND length("日期") >= 8 GROUP BY 1 ORDER BY 1 """) ytd_sql = sa_text(""" SELECT EXTRACT(YEAR FROM TO_DATE("日期", 'YYYY/MM/DD'))::int AS yr, EXTRACT(DOY FROM TO_DATE("日期", 'YYYY/MM/DD'))::int AS doy, SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS amount FROM realtime_sales_monthly WHERE "日期" IS NOT NULL AND length("日期") >= 8 AND EXTRACT(YEAR FROM TO_DATE("日期", 'YYYY/MM/DD')) >= EXTRACT(YEAR FROM CURRENT_DATE) - 1 GROUP BY 1, 2 ORDER BY 1, 2 """) recent_sql = sa_text(""" SELECT SUM(COALESCE(NULLIF("總業績", '')::numeric, 0)) AS revenue, COUNT(DISTINCT "訂單編號") AS orders FROM realtime_sales_monthly WHERE "日期" IS NOT NULL AND length("日期") >= 8 AND TO_DATE("日期", 'YYYY/MM/DD') >= CURRENT_DATE - INTERVAL '30 days' """) with db.engine.connect() as conn: monthly_df = pd.read_sql(monthly_sql, conn) if monthly_df.empty: return f"資料表 {table_name} 為空", 404 ytd_df = pd.read_sql(ytd_sql, conn) recent_r = conn.execute(recent_sql).fetchone() # 3. 月度指標計算 monthly_df['month'] = pd.to_datetime(monthly_df['month']) monthly_df = monthly_df.set_index('month') monthly_df['profit'] = monthly_df['amount'] - monthly_df['cost'] monthly_df['aov'] = monthly_df['amount'] / monthly_df['orders'].replace(0, pd.NA) monthly_df['margin_rate'] = (monthly_df['profit'] / monthly_df['amount'].replace(0, pd.NA)) * 100 monthly_df['mom'] = monthly_df['amount'].pct_change() * 100 monthly_df['yoy'] = monthly_df['amount'].pct_change(periods=12) * 100 monthly_df = monthly_df.fillna(0) labels = monthly_df.index.strftime('%Y-%m').tolist() chart_data = { 'labels': labels, 'revenue': monthly_df['amount'].tolist(), 'profit': monthly_df['profit'].tolist(), 'orders': monthly_df['orders'].tolist(), 'aov': monthly_df['aov'].round(0).tolist(), 'mom': monthly_df['mom'].round(2).tolist(), 'yoy': monthly_df['yoy'].round(2).tolist(), 'margin_rate': monthly_df['margin_rate'].round(1).tolist(), } # 4. KPI: YTD current_year = int(pd.Timestamp.now().year) last_year = current_year - 1 max_doy = ytd_df[ytd_df['yr'] == current_year]['doy'].max() if not ytd_df.empty else 365 ytd_revenue = float(ytd_df[ytd_df['yr'] == current_year]['amount'].sum()) last_ytd_revenue = float(ytd_df[(ytd_df['yr'] == last_year) & (ytd_df['doy'] <= max_doy)]['amount'].sum()) ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue * 100) if last_ytd_revenue > 0 else 0 recent_revenue = float(recent_r.revenue or 0) if recent_r else 0 recent_orders = int(recent_r.orders or 0) if recent_r else 0 recent_aov = recent_revenue / recent_orders if recent_orders > 0 else 0 kpi = { 'ytd_revenue': ytd_revenue, 'ytd_growth': ytd_growth, 'current_year': current_year, 'recent_aov': recent_aov, 'total_orders': int(monthly_df['orders'].sum()), } return render_template('growth_analysis.html', chart_data=chart_data, kpi=kpi) except Exception as e: sys_log.error(f"Growth Analysis Error: {e}") return f"系統錯誤: {e}" def preprocess_daily_sales_data(df): """前處理當日業績資料:欄位識別、型別轉換""" cols = df.columns.tolist() # 欄位自動識別(使用現有的 find_col 函式) col_amount = find_col(cols, ['銷售金額', '業績', '金額', 'Amount', '總業績']) col_cost = find_col(cols, ['成本', 'Cost', '總成本']) col_profit = find_col(cols, ['毛利', 'Profit']) col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量']) # 型別轉換 if col_amount: df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0) if col_cost: df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0) if col_profit: df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0) if col_qty: df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0) # 日期轉換 df['snapshot_date'] = pd.to_datetime(df['snapshot_date'], errors='coerce') return df def calculate_daily_kpis(df, date_str): """計算單日 6 個 KPI""" day_df = df[df['snapshot_date'] == date_str] cols = day_df.columns.tolist() col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) col_cost = find_col(cols, ['成本', 'Cost', '總成本']) col_profit = find_col(cols, ['毛利', 'Profit']) col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) col_name = find_col(cols, ['商品名稱', '品名', 'Name']) total_revenue = float(day_df[col_amount].sum()) if col_amount else 0 total_cost = float(day_df[col_cost].sum()) if col_cost else 0 gross_margin = float(day_df[col_profit].sum()) if col_profit else (total_revenue - total_cost) total_qty = float(day_df[col_qty].sum()) if col_qty else 0 sku_count = int(day_df[col_name].nunique()) if col_name else 0 avg_price = total_revenue / total_qty if total_qty > 0 else 0 return { 'total_revenue': total_revenue, 'total_cost': total_cost, 'gross_margin': gross_margin, 'total_qty': total_qty, 'sku_count': sku_count, 'avg_price': avg_price } def calculate_dod(df, current_date): """計算 Day-over-Day 變化率""" current = calculate_daily_kpis(df, current_date) prev_date = current_date - timedelta(days=1) if prev_date not in df['snapshot_date'].values: return {k: 0.0 for k in current.keys()} previous = calculate_daily_kpis(df, prev_date) dod = {} for key in current: if previous[key] > 0: dod[key] = ((current[key] - previous[key]) / previous[key]) * 100 else: dod[key] = 0.0 return dod def calculate_wow(df, current_date): """計算 Week-over-Week 變化率""" current = calculate_daily_kpis(df, current_date) prev_week_date = current_date - timedelta(days=7) if prev_week_date not in df['snapshot_date'].values: return {k: 0.0 for k in current.keys()} previous = calculate_daily_kpis(df, prev_week_date) wow = {} for key in current: if previous[key] > 0: wow[key] = ((current[key] - previous[key]) / previous[key]) * 100 else: wow[key] = 0.0 return wow def prepare_daily_charts(df, selected_date, days=30): """準備 4 個圖表的數據(根據選擇的日期)""" # 取選擇日期前 N 天的數據 start_date = selected_date - timedelta(days=days) df_range = df[(df['snapshot_date'] >= start_date) & (df['snapshot_date'] <= selected_date)] # 按日期聚合 cols = df_range.columns.tolist() col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) col_cost = find_col(cols, ['成本', '總成本']) col_profit = find_col(cols, ['毛利']) col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) col_name = find_col(cols, ['商品名稱', '品名']) # 日期聚合 agg_dict = {} if col_amount: agg_dict[col_amount] = 'sum' if col_cost: agg_dict[col_cost] = 'sum' if col_profit: agg_dict[col_profit] = 'sum' if col_qty: agg_dict[col_qty] = 'sum' daily_agg = df_range.groupby('snapshot_date').agg(agg_dict).reset_index() # 計算或取得毛利(如果沒有毛利欄位,用業績-成本計算) if col_profit and col_profit in daily_agg.columns: daily_agg['profit'] = daily_agg[col_profit] elif col_amount and col_cost and col_amount in daily_agg.columns and col_cost in daily_agg.columns: daily_agg['profit'] = daily_agg[col_amount] - daily_agg[col_cost] else: daily_agg['profit'] = 0 # 計算客單價 if col_amount and col_qty and col_amount in daily_agg.columns and col_qty in daily_agg.columns: daily_agg['avg_price'] = (daily_agg[col_amount] / daily_agg[col_qty]).fillna(0) else: daily_agg['avg_price'] = 0 # 計算 DoD (Day-over-Day) 變化率 - 多個維度 if col_amount and col_amount in daily_agg.columns: daily_agg['dod_revenue'] = daily_agg[col_amount].pct_change() * 100 if 'profit' in daily_agg.columns: daily_agg['dod_profit'] = daily_agg['profit'].pct_change() * 100 if 'avg_price' in daily_agg.columns: daily_agg['dod_avg_price'] = daily_agg['avg_price'].pct_change() * 100 if col_qty and col_qty in daily_agg.columns: daily_agg['dod_qty'] = daily_agg[col_qty].pct_change() * 100 # 計算 WoW (Week-over-Week) 變化率 - 多個維度 if col_amount and col_amount in daily_agg.columns: daily_agg['wow_revenue'] = daily_agg[col_amount].pct_change(periods=7) * 100 if 'profit' in daily_agg.columns: daily_agg['wow_profit'] = daily_agg['profit'].pct_change(periods=7) * 100 if 'avg_price' in daily_agg.columns: daily_agg['wow_avg_price'] = daily_agg['avg_price'].pct_change(periods=7) * 100 if col_qty and col_qty in daily_agg.columns: daily_agg['wow_qty'] = daily_agg[col_qty].pct_change(periods=7) * 100 # Top 10 商品(選擇的日期,包含廠商) selected_df = df[df['snapshot_date'] == selected_date] top10_labels = [] top10_values = [] if col_name and col_amount: col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier']) if col_vendor: # 如果有廠商欄位,按商品+廠商聚合 top10_df = selected_df.groupby([col_name, col_vendor])[col_amount].sum().nlargest(10).reset_index() top10_labels = [f"{row[col_name]} ({row[col_vendor]})" for _, row in top10_df.iterrows()] top10_values = top10_df[col_amount].tolist() else: # 沒有廠商欄位,只按商品聚合 top10 = selected_df.groupby(col_name)[col_amount].sum().nlargest(10) top10_labels = top10.index.tolist() top10_values = top10.values.tolist() return { 'labels': daily_agg['snapshot_date'].dt.strftime('%m/%d').tolist() if not daily_agg.empty else [], 'revenue': daily_agg[col_amount].tolist() if col_amount and col_amount in daily_agg.columns and not daily_agg.empty else [], 'cost': daily_agg[col_cost].tolist() if col_cost and col_cost in daily_agg.columns and not daily_agg.empty else [], 'profit': daily_agg['profit'].tolist() if 'profit' in daily_agg.columns and not daily_agg.empty else [], 'qty': daily_agg[col_qty].tolist() if col_qty and col_qty in daily_agg.columns and not daily_agg.empty else [], 'avg_price': daily_agg['avg_price'].tolist() if 'avg_price' in daily_agg.columns and not daily_agg.empty else [], # DoD 多維度 'dod_revenue': daily_agg['dod_revenue'].fillna(0).tolist() if 'dod_revenue' in daily_agg.columns and not daily_agg.empty else [], 'dod_profit': daily_agg['dod_profit'].fillna(0).tolist() if 'dod_profit' in daily_agg.columns and not daily_agg.empty else [], 'dod_avg_price': daily_agg['dod_avg_price'].fillna(0).tolist() if 'dod_avg_price' in daily_agg.columns and not daily_agg.empty else [], 'dod_qty': daily_agg['dod_qty'].fillna(0).tolist() if 'dod_qty' in daily_agg.columns and not daily_agg.empty else [], # WoW 多維度 'wow_revenue': daily_agg['wow_revenue'].fillna(0).tolist() if 'wow_revenue' in daily_agg.columns and not daily_agg.empty else [], 'wow_profit': daily_agg['wow_profit'].fillna(0).tolist() if 'wow_profit' in daily_agg.columns and not daily_agg.empty else [], 'wow_avg_price': daily_agg['wow_avg_price'].fillna(0).tolist() if 'wow_avg_price' in daily_agg.columns and not daily_agg.empty else [], 'wow_qty': daily_agg['wow_qty'].fillna(0).tolist() if 'wow_qty' in daily_agg.columns and not daily_agg.empty else [], 'top10_labels': top10_labels, 'top10_values': top10_values } def prepare_category_summary(df, date_str=None, is_month_view=False, month_start=None, month_end=None): """準備分類聚合列表 (支援單日或月度範圍)""" if is_month_view and month_start is not None and month_end is not None: day_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)] else: day_df = df[df['snapshot_date'] == date_str] cols = day_df.columns.tolist() col_category = find_col(cols, ['館別', '分類', 'Category']) col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier']) col_amount = find_col(cols, ['銷售金額', '業績', '總業績']) col_cost = find_col(cols, ['成本', '總成本']) col_profit = find_col(cols, ['毛利']) col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) col_name = find_col(cols, ['商品名稱', '品名']) if not col_category or not col_amount: return [] # 分類 + 廠商聚合 agg_dict = {col_amount: 'sum'} if col_cost: agg_dict[col_cost] = 'sum' if col_profit: agg_dict[col_profit] = 'sum' if col_qty: agg_dict[col_qty] = 'sum' if col_name: agg_dict[col_name] = 'nunique' # 如果有廠商欄位,按分類+廠商聚合;否則只按分類聚合 if col_vendor: category_df = day_df.groupby([col_category, col_vendor]).agg(agg_dict).reset_index() else: category_df = day_df.groupby(col_category).agg(agg_dict).reset_index() # 計算毛利(如果資料中沒有毛利欄位,自動計算) if col_profit and col_profit in category_df.columns: # 資料中有毛利欄位,直接使用 pass elif col_amount and col_cost and col_amount in category_df.columns and col_cost in category_df.columns: # 資料中沒有毛利欄位,用 業績 - 成本 計算 category_df['profit_calculated'] = category_df[col_amount] - category_df[col_cost] col_profit = 'profit_calculated' else: col_profit = None # 計算毛利率 if col_profit and col_profit in category_df.columns and col_amount and col_amount in category_df.columns: category_df['margin_rate'] = (category_df[col_profit] / category_df[col_amount] * 100).fillna(0) else: category_df['margin_rate'] = 0 # 計算均價 if col_qty and col_amount: category_df['avg_price'] = (category_df[col_amount] / category_df[col_qty]).fillna(0) else: category_df['avg_price'] = 0 # 重新命名欄位以便模板使用 rename_dict = {col_category: 'category', col_amount: 'revenue'} if col_vendor: rename_dict[col_vendor] = 'vendor' if col_cost: rename_dict[col_cost] = 'cost' if col_profit and col_profit in category_df.columns: rename_dict[col_profit] = 'profit' if col_qty: rename_dict[col_qty] = 'qty' if col_name: rename_dict[col_name] = 'sku_count' category_df = category_df.rename(columns=rename_dict) # 確保 profit 欄位存在,如果不存在則設為 0 if 'profit' not in category_df.columns: category_df['profit'] = 0 # 轉為字典列表 return category_df.to_dict('records') # V-New 2026-01-15: 行銷活動業績聚合函數 def prepare_marketing_summary(df, selected_date=None, is_month_view=False, month_start=None, month_end=None, sort_by='revenue'): """ 準備行銷活動業績貢獻數據 支援單日模式和月度模式,並可指定排序維度 (revenue, qty, profit) """ # 決定使用的數據範圍 if is_month_view and month_start is not None and month_end is not None: target_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)] elif selected_date is not None: target_df = df[df['snapshot_date'] == selected_date] else: target_df = df if target_df.empty: return {'coupon': [], 'discount': [], 'bonus': [], 'click': []} cols = target_df.columns.tolist() col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) col_qty = find_col(cols, ['銷售數量', '銷量', '數量', 'Qty']) col_profit = find_col(cols, ['毛利', 'Profit', '利潤']) col_cost = find_col(cols, ['成本', 'Cost', '總成本']) if not col_amount: return {'coupon': [], 'discount': [], 'bonus': [], 'click': []} # 定義四種行銷活動欄位 marketing_cols = { 'coupon': '折價券活動名稱', # 折價券活動 'discount': '折扣活動名稱', # 折扣活動 'bonus': '滿額再折扣活動名稱', # 滿額再折扣 'click': '點我再折扣' # 點我再折扣 } result = {} # 確保 sort_by 欄位存在,否則退回 revenue actual_sort_key = sort_by if sort_by in ['revenue', 'qty', 'profit'] else 'revenue' for key, col_name in marketing_cols.items(): if col_name not in cols: result[key] = [] continue # 篩選有該行銷活動的記錄 activity_df = target_df[ (target_df[col_name].notna()) & (target_df[col_name] != '') & (target_df[col_name] != '0') & (target_df[col_name] != 0) ] if activity_df.empty: result[key] = [] continue # 聚合計算 agg_args = { 'revenue': (col_amount, 'sum'), 'order_count': (col_amount, 'count') } if col_qty: agg_args['qty'] = (col_qty, 'sum') if col_profit: agg_args['profit'] = (col_profit, 'sum') grouped = activity_df.groupby(col_name).agg(**agg_args).reset_index() # 若需要手動計算毛利 (金額 - 成本) if 'profit' not in agg_args and col_cost: cost_agg = activity_df.groupby(col_name)[col_cost].sum().reset_index() grouped = grouped.merge(cost_agg, on=col_name) grouped['profit'] = grouped['revenue'] - grouped[col_cost] grouped = grouped.rename(columns={col_name: 'name'}) # 動態排序 sort_col = actual_sort_key if actual_sort_key in grouped.columns else 'revenue' grouped = grouped.sort_values(sort_col, ascending=False).head(15) # 轉為字典列表 records = [] for _, row in grouped.iterrows(): record = { 'name': str(row['name'])[:50], 'revenue': float(row['revenue']), 'order_count': int(row['order_count']) } if 'qty' in row: record['qty'] = float(row['qty']) if 'profit' in row: record['profit'] = float(row['profit']) records.append(record) result[key] = records return result def get_taiwan_holiday(date): """判斷是否為台灣國定假日,回傳 (is_holiday, holiday_name)""" year = date.year month = date.month day = date.day # 2026年台灣國定假日(根據人事行政總處公佈) holidays_2026 = { (1, 1): '元旦', # 春節連假 (2/14-2/22,共9天) (2, 14): '春節連假', (2, 15): '小年夜', (2, 16): '除夕', (2, 17): '春節 (初一)', (2, 18): '春節 (初二)', (2, 19): '春節 (初三)', (2, 20): '春節連假', (2, 21): '春節連假', (2, 22): '春節連假', # 和平紀念日 (2/28-3/2,共3天) (2, 28): '和平紀念日', (3, 2): '和平紀念日補假', # 兒童節+清明節 (4/3-4/6,共4天) (4, 3): '兒童節補假', (4, 4): '清明節', (4, 5): '清明節連假', (4, 6): '清明節補假', # 勞動節 (5/1-5/3,共3天) (5, 1): '勞動節', # 端午節 (6/19-6/21,共3天) (6, 19): '端午節', # 中秋節+教師節 (9/25-9/28,共4天) (9, 25): '中秋節', (9, 28): '教師節', # 國慶日 (10/9-10/11,共3天) (10, 9): '國慶日補假', (10, 10): '國慶日', # 光復節 (10/25-10/26,共2天) (10, 25): '臺灣光復節', (10, 26): '光復節補假', # 行憲紀念日 (12/25-12/27,共3天) (12, 25): '行憲紀念日', } # 2027年台灣國定假日(預先計算部分) holidays_2027 = { (1, 1): '元旦', (2, 11): '春節 (除夕)', (2, 12): '春節 (初一)', (2, 13): '春節 (初二)', (2, 14): '春節 (初三)', (2, 15): '春節 (初四)', (2, 16): '春節 (初五)', (2, 17): '春節 (初六)', (2, 28): '和平紀念日', (4, 4): '清明節', (4, 5): '清明節連假', (6, 14): '端午節', (9, 21): '中秋節', (10, 10): '國慶日', (10, 11): '國慶日連假', } holidays = holidays_2026 if year == 2026 else (holidays_2027 if year == 2027 else {}) holiday_name = holidays.get((month, day)) return (True, holiday_name) if holiday_name else (False, None) def prepare_calendar_data(df, selected_month): """準備行事曆數據(豐富版:顯示總業績、毛利、SKU數 + DoD%)""" import calendar # 取得該月份的年月 year = selected_month.year month = selected_month.month # 計算該月第一天和最後一天 first_day = pd.Timestamp(year=year, month=month, day=1) last_day = pd.Timestamp(year=year, month=month, day=calendar.monthrange(year, month)[1]) # 計算行事曆顯示範圍(包含前後月份的日期以填滿週) # 取得該月第一天是星期幾 (0=Monday, 6=Sunday) first_weekday = first_day.weekday() # 計算行事曆起始日(從週一開始) calendar_start = first_day - timedelta(days=first_weekday) # 計算該月最後一天是星期幾 last_weekday = last_day.weekday() # 計算行事曆結束日(到週日結束) calendar_end = last_day + timedelta(days=(6 - last_weekday)) # 取得該月份及前後各一天的所有資料(用於計算 DoD) data_start = first_day - timedelta(days=1) data_end = last_day month_df = df[(df['snapshot_date'] >= data_start) & (df['snapshot_date'] <= data_end)] # 取得欄位 cols = df.columns.tolist() col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) col_cost = find_col(cols, ['成本', 'Cost']) col_profit = find_col(cols, ['毛利', 'Profit']) col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量']) col_name = find_col(cols, ['商品名稱', '品名']) # 為每一天計算 KPI calendar_days = [] current_date = calendar_start while current_date <= calendar_end: # 取得星期(0=週一, 6=週日) weekday = current_date.weekday() weekday_names = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'] # 判斷是否為國定假日 is_holiday, holiday_name = get_taiwan_holiday(current_date) day_data = { 'date': current_date.strftime('%Y-%m-%d'), 'day': current_date.day, 'weekday': weekday_names[weekday], 'is_weekend': weekday >= 5, # 週六或週日 'is_holiday': is_holiday, 'holiday_name': holiday_name, 'is_current_month': current_date.month == month, 'has_data': False, 'revenue': 0, 'profit': 0, 'margin_rate': 0, 'sku_count': 0, 'qty': 0, 'avg_price': 0, 'dod_percent': 0, 'dod_direction': 'neutral' # 'up', 'down', 'neutral' } # 如果該日期在當前月份範圍內,計算 KPI if first_day <= current_date <= last_day: day_df = month_df[month_df['snapshot_date'] == current_date] if not day_df.empty: day_data['has_data'] = True # 計算總業績 if col_amount: day_data['revenue'] = float(day_df[col_amount].sum()) # 計算毛利(優先使用毛利欄位,否則用業績-成本計算) if col_profit: day_data['profit'] = float(day_df[col_profit].sum()) elif col_cost and col_amount: total_cost = float(day_df[col_cost].sum()) day_data['profit'] = day_data['revenue'] - total_cost # 計算毛利率 if day_data['revenue'] > 0: day_data['margin_rate'] = (day_data['profit'] / day_data['revenue']) * 100 # 計算銷量 if col_qty: day_data['qty'] = float(day_df[col_qty].sum()) # 計算客單價(總業績 / 總銷量) if day_data['qty'] > 0: day_data['avg_price'] = day_data['revenue'] / day_data['qty'] # 計算 SKU 數 if col_name: day_data['sku_count'] = int(day_df[col_name].nunique()) # 計算 DoD% prev_date = current_date - timedelta(days=1) prev_df = month_df[month_df['snapshot_date'] == prev_date] if not prev_df.empty and col_amount: prev_revenue = float(prev_df[col_amount].sum()) if prev_revenue > 0: dod = ((day_data['revenue'] - prev_revenue) / prev_revenue) * 100 day_data['dod_percent'] = round(dod, 1) day_data['dod_direction'] = 'up' if dod >= 0 else 'down' calendar_days.append(day_data) current_date += timedelta(days=1) # 組織成週結構(每週 7 天) weeks = [] for i in range(0, len(calendar_days), 7): weeks.append(calendar_days[i:i+7]) # 計算上個月和下個月的年月 prev_month = selected_month - pd.DateOffset(months=1) next_month = selected_month + pd.DateOffset(months=1) return { 'year': year, 'month': month, 'month_name': selected_month.strftime('%Y年%m月'), 'weeks': weeks, 'prev_month': prev_month.strftime('%Y-%m'), 'next_month': next_month.strftime('%Y-%m') } # ================= ⚙️ 5. 服務啟動邏輯 ================= def run_schedule(): """在背景執行緒中運行排程""" sys_log.info("🚀 排程服務已啟動,等待任務...") while True: schedule.run_pending() time.sleep(1) def init_scheduler(): """初始化排程任務(Gunicorn 模式下也會執行)""" schedule.every(1).hours.do(run_momo_task) schedule.every(1).hours.do(run_edm_task) schedule.every(1).hours.do(run_festival_task) sys_log.info(f"📅 已設定每小時執行主站、EDM與購物節爬蟲任務") schedule.every(30).minutes.do(run_auto_import_task) sys_log.info(f"📅 已設定每 30 分鐘執行 Google Drive 自動匯入任務") schedule.every(30).minutes.do(run_whitepage_check) sys_log.info(f"📅 已設定每 30 分鐘執行網頁白頁監控任務") schedule.every(4).hours.do(run_competitor_price_feeder_task) sys_log.info(f"📅 已設定每 4 小時執行 PChome 競品價格抓取任務") # 啟動排程執行緒 scheduler_thread = threading.Thread(target=run_schedule, daemon=True) scheduler_thread.start() sys_log.info("✅ 排程器已在背景執行緒中啟動") # V-New: 在模組載入時自動初始化排程(Gunicorn 模式下也會執行) # 🚩 V-Fix 2026-01-14: 停用自動排程器以避免多個 gunicorn workers 重複執行任務 # 原因:每個 worker 都會啟動排程器,導致 4x 資源消耗(4 workers × 3 爬蟲任務 = 12 Chrome 實例同時運行) # 解決方案:改用獨立的 run_scheduler.py 或透過 Web UI 手動觸發任務 # try: # init_scheduler() # except Exception as e: # sys_log.error(f"❌ 排程器初始化失敗: {e}") sys_log.info("ℹ️ 自動排程器已停用(避免重複執行),請使用 run_scheduler.py 或 Web UI 手動觸發") def start_flask(): sys_log.info("🚀 Web 服務正在啟動於 port 80...") app.run(host='0.0.0.0', port=80, use_reloader=False) def scheduled_job_wrapper(): """執行 MOMO 爬蟲任務並發送通知""" timestamp = datetime.now(TAIPEI_TZ).strftime('%H:%M:%S') sys_log.info(f"⏰ [{timestamp}] 啟動背景抓取執行緒...") def job(): # 1. 執行爬蟲 run_momo_task() # 2. 發送通知 (僅發送今日異動) try: # 重新載入通知模組 import importlib import scheduler import services.notification_manager importlib.reload(scheduler) importlib.reload(services.notification_manager) from services.notification_manager import NotificationManager stats = get_dashboard_stats() # 只要有任何異動數據就發送通知 if any(stats.values()): screenshot_path = scheduler.capture_page_screenshot("http://127.0.0.1/", "momo_dashboard") NotificationManager().send_momo_report(stats, screenshot_path) except Exception as e: sys_log.error(f"[Scheduler] ❌ 發送通知失敗: {e}") threading.Thread(target=job, daemon=True).start() if __name__ == "__main__": banner = f" MOMO 專業數據管理系統 {SYSTEM_VERSION} " sys_log.info(f"{ '='*20} {banner} {'='*20}") # 啟動前先檢查資料庫結構 repair_database_schema() # 使用生產環境域名 public_url = "https://mo.wooo.work" sys_log.info(f"✅ 使用固定網址: {public_url}") # 🚩 V9.7 將公開 URL 寫入設定檔,供其他模組使用 try: url_config_path = os.path.join(BASE_DIR, 'data', 'url_config.json') with open(url_config_path, 'w') as f: json.dump({"public_url": public_url}, f) except Exception as file_err: sys_log.error(f"⚠️ URL 設定檔寫入失敗 (不影響服務運行,可能磁碟已滿): {file_err}") web_server = threading.Thread(target=start_flask) web_server.daemon = True web_server.start() # 排程器已在模組載入時自動初始化(見 init_scheduler() 函式) sys_log.info("ℹ️ 排程器已在全域範圍初始化完成") try: while True: time.sleep(3600) except KeyboardInterrupt: sys_log.info("🔌 Web 服務已關閉") try: ngrok.disconnect(public_url) except Exception as e: sys_log.info(f"ℹ️ Ngrok 關閉時無需額外操作: {e}")