Some checks failed
CD Pipeline / deploy (push) Failing after 59s
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml) - 部署模式: rsync Python 檔案至 188 → docker restart (volume mount) - Dockerfile/requirements 變動時自動重建 Docker image - 部署通知: Telegram (開始/成功/失敗) - 健康檢查: https://mo.wooo.work/health (最多 5 次重試) - 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7509 lines
353 KiB
Plaintext
7509 lines
353 KiB
Plaintext
# ================= TODO LIST (待辦事項 - 重開機後請依序執行) =================
|
||
# 1. [驗證] 重啟 app.py 後,重新匯入 Excel,確認「自動去重」功能是否生效 (重複匯入應顯示 0 筆新增)。
|
||
# 2. [檢查] 前往 /sales_analysis 頁面,確認 '狀態' 欄位是否正確顯示 'F' (目前為原始匯入模式)。
|
||
# 3. [決策] 若資料顯示正常,評估是否需要恢復「智慧資料清理」邏輯 (目前程式碼第 1160 行左右已註解)。
|
||
# 4. [備份] 確認系統運作正常後,執行系統備份。
|
||
# 5. [部署] UAT 驗證通過後,記得執行 `gcloud app deploy` 更新 GCP 正式環境 (Project: momo-pro-system)。
|
||
# =======================================================================
|
||
|
||
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 匯出
|
||
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 auth import login_required, init_auth_routes # V-Fix: 使用專案自定義的 login_required (不使用 flask_login)
|
||
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
|
||
from database.manager import DatabaseManager
|
||
from database.models import Product, PriceRecord, MonthlySummaryAnalysis
|
||
from database.edm_models import PromoProduct
|
||
except ImportError as e:
|
||
print(f"❌ 專案內部檔案缺失: {e}\n請檢查 database/ 或 services/ 目錄下的 .py 檔案是否存在。")
|
||
sys.exit(1)
|
||
|
||
from services.logger_manager import SystemLogger
|
||
from services.exporter import Exporter # 🚩 導入匯出模組
|
||
except ImportError as e:
|
||
print(f"❌ 關鍵套件導入失敗: {e}")
|
||
sys.exit(1)
|
||
|
||
# ================= 🔧 3. 系統核心配置 =================
|
||
# 從 config.py 匯入必要的設定
|
||
from config import EXCEL_EXPORT_DIR, USE_MODULAR_ROUTES
|
||
|
||
sys_log = SystemLogger("Web_Server").get_logger()
|
||
|
||
# 🚩 V-Opt: 全域資料快取 (用於加速業績分析)
|
||
_SALES_DF_CACHE = {}
|
||
_SALES_PROCESSED_CACHE = {} # V-Opt: 新增處理後資料快取 (二級快取)
|
||
|
||
# 🚩 V-New: 商品看板資料快取 (用於加速首頁載入)
|
||
_DASHBOARD_DATA_CACHE = {
|
||
'consolidated_data': None, # get_consolidated_data() 結果
|
||
'consolidated_timestamp': None,
|
||
'full_data': None, # 包含統計數據的完整結果
|
||
'full_timestamp': None
|
||
}
|
||
_DASHBOARD_CACHE_TTL = 1800 # V-Opt: 快取有效期 30 分鐘(高規格 UAT: 126GB RAM)
|
||
_SALES_CACHE_TTL = 3600 # V-Opt: 業績分析快取有效期 60 分鐘(高規格 UAT)
|
||
_SALES_OPTIONS_CACHE = {} # V-Opt: 儲存下拉選單選項 (類別、品牌、廠商等)
|
||
_SALES_OPTIONS_TTL = 21600 # V-Opt: 6 小時有效(高規格 UAT)
|
||
_SALES_ANALYSIS_RESULT_CACHE = {} # 🚩 V-Opt: 儲存過濾後的分析結果集 (Result Cache)
|
||
_SALES_RESULT_TTL = 3600 # V-Opt: 60 分鐘有效(高規格 UAT)
|
||
|
||
# 🚩 V-New: 慢查詢監控 (供 Prometheus 監控使用)
|
||
_SLOW_QUERY_STATS = {
|
||
'total_queries': 0, # 總查詢數
|
||
'slow_queries': 0, # 慢查詢數 (>1秒)
|
||
'very_slow_queries': 0, # 極慢查詢數 (>5秒)
|
||
'total_query_time_ms': 0, # 總查詢時間(毫秒)
|
||
'last_slow_query': None, # 最後一個慢查詢
|
||
'last_slow_query_time': None, # 最後慢查詢時間
|
||
}
|
||
_SLOW_QUERY_THRESHOLD_MS = 1000 # 慢查詢閾值: 1秒
|
||
_VERY_SLOW_QUERY_THRESHOLD_MS = 5000 # 極慢查詢閾值: 5秒
|
||
|
||
def track_query_time(query_name, duration_ms):
|
||
"""追蹤查詢時間,更新慢查詢統計"""
|
||
global _SLOW_QUERY_STATS
|
||
_SLOW_QUERY_STATS['total_queries'] += 1
|
||
_SLOW_QUERY_STATS['total_query_time_ms'] += duration_ms
|
||
|
||
if duration_ms >= _VERY_SLOW_QUERY_THRESHOLD_MS:
|
||
_SLOW_QUERY_STATS['very_slow_queries'] += 1
|
||
_SLOW_QUERY_STATS['slow_queries'] += 1
|
||
_SLOW_QUERY_STATS['last_slow_query'] = query_name
|
||
_SLOW_QUERY_STATS['last_slow_query_time'] = datetime.now(TAIPEI_TZ).isoformat()
|
||
elif duration_ms >= _SLOW_QUERY_THRESHOLD_MS:
|
||
_SLOW_QUERY_STATS['slow_queries'] += 1
|
||
_SLOW_QUERY_STATS['last_slow_query'] = query_name
|
||
_SLOW_QUERY_STATS['last_slow_query_time'] = datetime.now(TAIPEI_TZ).isoformat()
|
||
|
||
# 🚩 檢查磁碟空間 (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}")
|
||
|
||
# 🚩 系統版本定義 (備份與顯示用)
|
||
# TODO: 下次進行重大更新時,記得修改此版本號 (目前 V9.4)
|
||
SYSTEM_VERSION = "V9.4"
|
||
|
||
# ==========================================
|
||
# 🔒 SQL Injection 防護函數
|
||
# ==========================================
|
||
|
||
# 允許的資料表白名單
|
||
ALLOWED_TABLES = {
|
||
'realtime_sales_monthly',
|
||
'daily_sales_snapshot',
|
||
'products',
|
||
'price_records',
|
||
'promo_products',
|
||
'edm_products',
|
||
'festival_products'
|
||
}
|
||
|
||
def validate_table_name(table_name):
|
||
"""
|
||
驗證資料表名稱,防止 SQL Injection
|
||
|
||
Args:
|
||
table_name: 要驗證的資料表名稱
|
||
|
||
Returns:
|
||
str: 驗證通過的表名
|
||
|
||
Raises:
|
||
ValueError: 表名不在白名單中
|
||
"""
|
||
# 移除空白字符
|
||
table_name = str(table_name).strip()
|
||
|
||
# 檢查是否為空
|
||
if not table_name:
|
||
raise ValueError("表名不能為空")
|
||
|
||
# 檢查是否包含危險字符
|
||
if not re.match(r'^[a-zA-Z0-9_]+$', table_name):
|
||
raise ValueError(f"表名包含非法字符: {table_name}")
|
||
|
||
# 檢查是否在白名單中
|
||
if table_name not in ALLOWED_TABLES:
|
||
# 對於動態表名(從檔名生成),允許但記錄警告
|
||
sys_log.warning(f"[Security] 表名不在白名單中: {table_name}")
|
||
# 至少確保沒有 SQL 關鍵字
|
||
sql_keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'UNION', 'WHERE', 'FROM']
|
||
if any(keyword in table_name.upper() for keyword in sql_keywords):
|
||
raise ValueError(f"表名包含 SQL 關鍵字: {table_name}")
|
||
|
||
return table_name
|
||
|
||
def validate_column_names(column_names):
|
||
"""
|
||
驗證欄位名稱列表,防止 SQL Injection
|
||
|
||
Args:
|
||
column_names: 欄位名稱列表
|
||
|
||
Returns:
|
||
list: 驗證通過的欄位名稱列表
|
||
|
||
Raises:
|
||
ValueError: 欄位名稱包含非法字符
|
||
"""
|
||
if isinstance(column_names, str):
|
||
column_names = [column_names]
|
||
|
||
validated = []
|
||
for col in column_names:
|
||
col = str(col).strip()
|
||
# 允許中文、英文、數字、底線
|
||
if not re.match(r'^[\w\u4e00-\u9fff]+$', col):
|
||
raise ValueError(f"欄位名稱包含非法字符: {col}")
|
||
validated.append(col)
|
||
|
||
return validated
|
||
|
||
def safe_read_sql(table_name, columns=None, engine=None, where_clause=None, limit=None, params=None):
|
||
"""
|
||
安全的 SQL 查詢函數,防止 SQL Injection
|
||
|
||
Args:
|
||
table_name: 資料表名稱
|
||
columns: 欄位列表,None 表示 *
|
||
engine: SQLAlchemy engine
|
||
where_clause: WHERE 子句
|
||
limit: 限制筆數
|
||
params: 參數化查詢的參數字典
|
||
|
||
Returns:
|
||
DataFrame: 查詢結果
|
||
"""
|
||
from sqlalchemy import text
|
||
|
||
# 驗證表名
|
||
table_name = validate_table_name(table_name)
|
||
|
||
# 驗證欄位名
|
||
if columns:
|
||
columns = validate_column_names(columns)
|
||
col_str = ', '.join([f'"{col}"' for col in columns])
|
||
else:
|
||
col_str = '*'
|
||
|
||
# 使用 SQLAlchemy 的參數化查詢
|
||
# 注意:表名和欄位名不能參數化,所以必須先驗證
|
||
try:
|
||
query = f'SELECT {col_str} FROM "{table_name}"'
|
||
if where_clause:
|
||
query += f' WHERE {where_clause}'
|
||
|
||
if limit:
|
||
query += f' LIMIT {int(limit)}'
|
||
|
||
return pd.read_sql(text(query), engine, params=params)
|
||
except Exception as e:
|
||
sys_log.error(f"[Security] SQL 查詢失敗: {e}")
|
||
raise
|
||
|
||
# ==========================================
|
||
# 🔒 路徑遍歷防護函數
|
||
# ==========================================
|
||
|
||
from pathlib import Path
|
||
|
||
def safe_join(base, *paths):
|
||
"""
|
||
安全的路徑拼接,防止路徑遍歷攻擊
|
||
|
||
Args:
|
||
base: 基礎目錄(絕對路徑)
|
||
*paths: 子路徑組件
|
||
|
||
Returns:
|
||
Path: 安全的完整路徑
|
||
|
||
Raises:
|
||
ValueError: 偵測到路徑遍歷嘗試
|
||
"""
|
||
# 確保 base 是絕對路徑
|
||
base = Path(base).resolve()
|
||
|
||
# 檢查路徑組件中是否包含危險字符
|
||
for path_component in paths:
|
||
path_str = str(path_component)
|
||
|
||
# 阻擋包含 Windows 反斜線的路徑
|
||
if '\\' in path_str:
|
||
sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 (Windows 反斜線) | Base: {base} | Requested: {paths}")
|
||
raise ValueError(f"路徑遍歷偵測: 不允許使用反斜線")
|
||
|
||
# 阻擋包含連續點的路徑 (如 ...., ....//)
|
||
if '..' in path_str.replace('\\', '/'):
|
||
sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 (雙點) | Base: {base} | Requested: {paths}")
|
||
raise ValueError(f"路徑遍歷偵測: 不允許使用 '..'")
|
||
|
||
# 拼接並解析完整路徑
|
||
full_path = (base / Path(*paths)).resolve()
|
||
|
||
# 驗證最終路徑必須在基礎目錄內
|
||
try:
|
||
full_path.relative_to(base)
|
||
except ValueError:
|
||
sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 | Base: {base} | Requested: {paths}")
|
||
raise ValueError(f"路徑遍歷偵測: 不允許存取基礎目錄外的檔案")
|
||
|
||
return full_path
|
||
|
||
# ==========================================
|
||
# 🔒 檔案上傳安全驗證
|
||
# ==========================================
|
||
|
||
# 允許的檔案副檔名與 MIME types
|
||
ALLOWED_UPLOAD_EXTENSIONS = {'xlsx', 'xls', 'csv'}
|
||
ALLOWED_MIME_TYPES = {
|
||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xlsx
|
||
'application/vnd.ms-excel', # .xls
|
||
'text/csv', # .csv
|
||
'application/octet-stream' # CSV sometimes detected as this
|
||
}
|
||
|
||
def secure_filename_unicode(filename):
|
||
"""
|
||
支援中文的安全檔案名稱清理
|
||
|
||
Args:
|
||
filename: 原始檔案名稱
|
||
|
||
Returns:
|
||
str: 清理後的安全檔案名稱
|
||
"""
|
||
import re
|
||
import unicodedata
|
||
|
||
# 正規化 Unicode 字元
|
||
filename = unicodedata.normalize('NFKC', filename)
|
||
|
||
# 移除危險字元但保留中文、英文、數字、空格、括號、底線、連字號
|
||
# 允許的字元: 中文、英文字母、數字、空格、括號、底線、連字號、點
|
||
safe_chars = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s\(\)_\-\.]', '', filename)
|
||
|
||
# 將多個空格替換為單一空格
|
||
safe_chars = re.sub(r'\s+', ' ', safe_chars)
|
||
|
||
# 移除前後空格
|
||
safe_chars = safe_chars.strip()
|
||
|
||
return safe_chars
|
||
|
||
def allowed_file(filename):
|
||
"""
|
||
檢查檔案副檔名是否在白名單中
|
||
|
||
Args:
|
||
filename: 檔案名稱
|
||
|
||
Returns:
|
||
bool: 是否允許上傳
|
||
"""
|
||
if not filename or '.' not in filename:
|
||
return False
|
||
|
||
# 分割檔名和副檔名
|
||
parts = filename.rsplit('.', 1)
|
||
if len(parts) != 2:
|
||
return False
|
||
|
||
basename, ext = parts
|
||
|
||
# 拒絕純副檔名檔案(如 .xlsx)
|
||
if not basename or basename.strip() == '':
|
||
return False
|
||
|
||
# 檢查副檔名是否在白名單中
|
||
return ext.lower() in ALLOWED_UPLOAD_EXTENSIONS
|
||
|
||
def validate_upload_file(file):
|
||
"""
|
||
完整的檔案上傳驗證(副檔名、檔案名稱清理)
|
||
|
||
Args:
|
||
file: Flask request.files 物件
|
||
|
||
Returns:
|
||
tuple: (is_valid, error_message, safe_filename)
|
||
"""
|
||
# 檢查檔案是否存在
|
||
if not file or file.filename == '':
|
||
return False, '未選擇檔案', None
|
||
|
||
original_filename = file.filename
|
||
|
||
# 1. 在清理前先檢查路徑遍歷攻擊
|
||
# 檢查連續的雙點(路徑遍歷)
|
||
if '..' in original_filename:
|
||
sys_log.warning(f"[Security] 檔案上傳 - 偵測到路徑遍歷嘗試(雙點): {original_filename}")
|
||
return False, '檔案名稱包含非法字元', None
|
||
|
||
# 檢查絕對路徑或目錄分隔符(在檔名中間)
|
||
# 允許檔名開頭沒有分隔符,且允許 HTML 標籤中的斜線(不在路徑位置)
|
||
import os
|
||
if os.path.sep in original_filename or (os.path.altsep and os.path.altsep in original_filename):
|
||
# 進一步檢查是否真的是路徑分隔(而不是 HTML 標籤等)
|
||
# 如果檔名以 / 或 \ 開頭,或包含 ./ 或 .\\ 模式,則為路徑遍歷
|
||
if original_filename.startswith(('/','\\')) or './' in original_filename or '.\\' in original_filename:
|
||
sys_log.warning(f"[Security] 檔案上傳 - 偵測到路徑遍歷嘗試(路徑分隔符): {original_filename}")
|
||
return False, '檔案名稱包含非法字元', None
|
||
|
||
# 2. 檔案名稱清理(使用支援中文的版本)
|
||
safe_name = secure_filename_unicode(original_filename)
|
||
if not safe_name:
|
||
return False, '檔案名稱不合法', None
|
||
|
||
# 3. 副檔名驗證
|
||
if not allowed_file(safe_name):
|
||
return False, f'不支援的檔案格式,僅允許: {", ".join(ALLOWED_UPLOAD_EXTENSIONS)}', None
|
||
|
||
# 4. 檔案大小驗證(由 Flask MAX_CONTENT_LENGTH 自動處理,此處記錄)
|
||
# Flask 會在超過大小時自動拋出 413 錯誤
|
||
|
||
return True, None, safe_name
|
||
|
||
# 🚩 資料庫結構自動修復 (V9.53 新增)
|
||
def repair_database_schema():
|
||
db = DatabaseManager()
|
||
engine = db.engine
|
||
from sqlalchemy import inspect, text
|
||
try:
|
||
# 🚩 V9.96: 啟用 SQLite WAL 模式以解決 database is locked 問題
|
||
with engine.connect() as conn:
|
||
# 啟用 WAL 模式 (Write-Ahead Logging)
|
||
conn.execute(text("PRAGMA journal_mode=WAL"))
|
||
conn.commit()
|
||
sys_log.info("[Database] [WAL] ✅ SQLite WAL 模式已啟用 | 提升並發寫入效能")
|
||
|
||
inspector = inspect(engine)
|
||
# V9.70: 檢查 products 表
|
||
if 'products' in inspector.get_table_names():
|
||
product_columns = [c['name'] for c in inspector.get_columns('products')]
|
||
if 'image_url' not in product_columns:
|
||
sys_log.warning("[Database] [Schema] ⚠️ 偵測到 products 表缺少 image_url 欄位 | 正在自動修復...")
|
||
with engine.connect() as conn:
|
||
conn.execute(text("ALTER TABLE products ADD COLUMN image_url TEXT"))
|
||
conn.commit()
|
||
sys_log.info("[Database] [Schema] ✅ products.image_url 欄位修復完成")
|
||
|
||
if 'created_at' not in product_columns:
|
||
sys_log.warning("[Database] [Schema] ⚠️ 偵測到 products 表缺少 created_at 欄位 | 正在自動修復...")
|
||
with engine.connect() as conn:
|
||
conn.execute(text("ALTER TABLE products ADD COLUMN created_at DATETIME"))
|
||
conn.execute(text("UPDATE products SET created_at = updated_at WHERE created_at IS NULL"))
|
||
conn.commit()
|
||
sys_log.info("[Database] [Schema] ✅ products.created_at 欄位修復完成")
|
||
|
||
if 'promo_products' in inspector.get_table_names():
|
||
columns = [c['name'] for c in inspector.get_columns('promo_products')]
|
||
if 'url' not in columns:
|
||
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 url 欄位,正在自動修復...")
|
||
with engine.connect() as conn:
|
||
conn.execute(text("ALTER TABLE promo_products ADD COLUMN url TEXT"))
|
||
conn.commit()
|
||
sys_log.info("✅ url 欄位修復完成")
|
||
if 'image_url' not in columns:
|
||
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 image_url 欄位,正在自動修復...")
|
||
with engine.connect() as conn:
|
||
conn.execute(text("ALTER TABLE promo_products ADD COLUMN image_url TEXT"))
|
||
conn.commit()
|
||
sys_log.info("✅ image_url 欄位修復完成")
|
||
if 'previous_price' not in columns:
|
||
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 previous_price 欄位,正在自動修復...")
|
||
with engine.connect() as conn:
|
||
conn.execute(text("ALTER TABLE promo_products ADD COLUMN previous_price INTEGER"))
|
||
conn.commit()
|
||
sys_log.info("✅ previous_price 欄位修復完成")
|
||
if 'session_time_text' not in columns:
|
||
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 session_time_text 欄位,正在自動修復...")
|
||
with engine.connect() as conn:
|
||
conn.execute(text("ALTER TABLE promo_products ADD COLUMN session_time_text TEXT"))
|
||
conn.commit()
|
||
sys_log.info("✅ session_time_text 欄位修復完成")
|
||
if 'remain_qty' not in columns:
|
||
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 remain_qty 欄位,正在自動修復...")
|
||
with engine.connect() as conn:
|
||
conn.execute(text("ALTER TABLE promo_products ADD COLUMN remain_qty INTEGER"))
|
||
conn.commit()
|
||
sys_log.info("✅ remain_qty 欄位修復完成")
|
||
if 'discount_text' not in columns:
|
||
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 discount_text 欄位,正在自動修復...")
|
||
with engine.connect() as conn:
|
||
conn.execute(text("ALTER TABLE promo_products ADD COLUMN discount_text TEXT"))
|
||
conn.commit()
|
||
sys_log.info("✅ discount_text 欄位修復完成")
|
||
if 'page_type' not in columns:
|
||
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 page_type 欄位,正在自動修復...")
|
||
with engine.connect() as conn:
|
||
# 將既有資料預設為 'edm'
|
||
conn.execute(text("ALTER TABLE promo_products ADD COLUMN page_type TEXT DEFAULT 'edm'"))
|
||
conn.commit()
|
||
sys_log.info("✅ page_type 欄位修復完成")
|
||
except Exception as e:
|
||
sys_log.error(f"[Database] [Schema] ❌ 資料庫修復失敗 | Error: {e}")
|
||
|
||
# 從環境變數讀取 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') # 新模板路徑(模組化)
|
||
TEMPLATE_DIR_WEB = os.path.join(BASE_DIR, 'web/templates') # web 子目錄模板
|
||
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}")
|
||
|
||
# Flask 應用程式
|
||
# 使用 Jinja2 的多路徑載入器支援新舊兩種模板路徑
|
||
from jinja2 import FileSystemLoader, ChoiceLoader
|
||
|
||
app = Flask(__name__,
|
||
template_folder=TEMPLATE_DIR,
|
||
static_folder=STATIC_DIR)
|
||
|
||
# 設定多路徑模板載入器:優先載入 templates/ 目錄,再載入根目錄與 web/templates
|
||
app.jinja_loader = ChoiceLoader([
|
||
FileSystemLoader(TEMPLATE_DIR_NEW), # 新模板路徑優先 (templates/)
|
||
FileSystemLoader(TEMPLATE_DIR), # 舊模板路徑備用 (根目錄)
|
||
FileSystemLoader(TEMPLATE_DIR_WEB), # web 子目錄模板 (web/templates/)
|
||
])
|
||
|
||
# ==========================================
|
||
# 🔒 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 註冊 - 廠商缺貨系統
|
||
# ==========================================
|
||
# ==========================================
|
||
# 🔒 認證路由初始化
|
||
# ==========================================
|
||
init_auth_routes(app)
|
||
sys_log.info("[Auth] ✅ 認證路由已註冊 (/login, /logout)")
|
||
|
||
from vendor_routes import vendor_bp
|
||
app.register_blueprint(vendor_bp)
|
||
sys_log.info("[Blueprint] ✅ 廠商缺貨系統 Blueprint 已註冊")
|
||
|
||
# ==========================================
|
||
# 🔧 Blueprint 註冊 - Google Drive 自動匯入
|
||
# ==========================================
|
||
from 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 crawler_management_routes import crawler_bp
|
||
app.register_blueprint(crawler_bp)
|
||
sys_log.info("[Blueprint] ✅ 爬蟲管理系統 Blueprint 已註冊")
|
||
|
||
# ==========================================
|
||
# 🔧 Blueprint 註冊 - 重構後的路由模組
|
||
# ==========================================
|
||
from routes import register_blueprints
|
||
register_blueprints(app)
|
||
sys_log.info("[Blueprint] ✅ 重構路由模組已註冊")
|
||
|
||
# ==========================================
|
||
# 🔧 Blueprint 註冊 - 用戶管理系統
|
||
# ==========================================
|
||
from routes.user_routes import user_bp
|
||
app.register_blueprint(user_bp)
|
||
sys_log.info("[Blueprint] ✅ 用戶管理系統 Blueprint 已註冊")
|
||
|
||
# V-Fix: 註冊 slugify 函數供模板使用,解決 'slugify is undefined' 錯誤
|
||
def slugify(text):
|
||
if not text: return ""
|
||
return str(text).replace(' ', '_').replace(':', '').replace('!', '').replace('?', '').replace('/', '').replace('&', '').replace('(', '').replace(')', '').replace('+', '_').replace('.', '_').replace('%', '').replace("'", "")
|
||
|
||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||
public_url = "服務啟動中..."
|
||
|
||
# 🚩 時區設定:台北時間 (UTC+8)
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
# ================= 🛠️ V9.72: 分類設定管理核心 =================
|
||
CATEGORIES_JSON_PATH = os.path.join(BASE_DIR, 'data', 'categories.json')
|
||
|
||
def load_categories():
|
||
"""從 JSON 檔案載入分類列表"""
|
||
try:
|
||
with open(CATEGORIES_JSON_PATH, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
except (FileNotFoundError, json.JSONDecodeError):
|
||
return []
|
||
|
||
def save_categories(categories):
|
||
"""將分類列表儲存到 JSON 檔案"""
|
||
with open(CATEGORIES_JSON_PATH, 'w', encoding='utf-8') as f:
|
||
json.dump(categories, f, ensure_ascii=False, indent=4)
|
||
|
||
def load_scheduler_stats():
|
||
"""讀取排程統計資料"""
|
||
stats_path = os.path.join(BASE_DIR, 'data', 'scheduler_stats.json')
|
||
if os.path.exists(stats_path):
|
||
try:
|
||
with open(stats_path, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
except (IOError, json.JSONDecodeError):
|
||
return {}
|
||
return {}
|
||
|
||
# ================= 🛠️ 數據處理核心 (封裝) =================
|
||
|
||
def get_color_for_string(s):
|
||
"""為字串生成一個穩定且美觀的 HSL 顏色"""
|
||
if not s: return "hsl(0, 0%, 85%)" # 預設灰色
|
||
# 使用 md5 hash 確保顏色穩定,並映射到 HSL 色彩空間以獲得柔和色彩
|
||
hash_val = int(hashlib.md5(s.encode('utf-8')).hexdigest(), 16)
|
||
hue = hash_val % 360
|
||
return f"hsl({hue}, 60%, 88%)"
|
||
|
||
def extract_snapshot_date_from_filename(filename):
|
||
"""從檔名提取日期:即時業績_當日_20260111.xlsx → 2026-01-11"""
|
||
match = re.search(r'(\d{8})', filename)
|
||
if match:
|
||
date_str = match.group(1) # '20260111'
|
||
try:
|
||
# 轉換為 YYYY-MM-DD 格式
|
||
year = date_str[:4]
|
||
month = date_str[4:6]
|
||
day = date_str[6:8]
|
||
return f"{year}-{month}-{day}"
|
||
except:
|
||
return None
|
||
return None
|
||
|
||
@app.template_filter('number_format')
|
||
def number_format_filter(value):
|
||
"""V9.61: 將數字格式化,加上千分位符號。"""
|
||
if isinstance(value, (int, float)):
|
||
return "{:,.0f}".format(value)
|
||
return value
|
||
|
||
# V-Refactor: 將 find_col 移至全域,方便多個函式共用
|
||
def find_col(df_cols, keywords):
|
||
"""從欄位列表中,根據關鍵字列表找出最匹配的欄位名稱"""
|
||
# V-Opt: 改為優先遍歷關鍵字,確保優先匹配更精確的名稱 (例如 '廠商名稱' 優於 '廠商')
|
||
for k in keywords:
|
||
for col in df_cols:
|
||
if k in str(col):
|
||
return col
|
||
return None
|
||
|
||
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] 🔄 快取過期或不存在,重新查詢資料庫")
|
||
|
||
# 🚩 V-New: 追蹤查詢時間
|
||
query_start_time = time.time()
|
||
|
||
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
|
||
|
||
# 🚩 V-New: 追蹤查詢時間並記錄
|
||
query_duration_ms = (time.time() - query_start_time) * 1000
|
||
track_query_time('get_consolidated_data', query_duration_ms)
|
||
sys_log.debug(f"[Dashboard] [Cache] 💾 快取已更新 | 商品數: {len(unique_items)} | 耗時: {query_duration_ms:.0f}ms")
|
||
|
||
return unique_items, today_start
|
||
finally:
|
||
session.close()
|
||
|
||
def get_full_dashboard_data():
|
||
"""🚩 獲取完整的看板資料,包含快取清單與全部 KPIs (V-Opt: 深度快取)"""
|
||
global _DASHBOARD_DATA_CACHE
|
||
now = datetime.now(TAIPEI_TZ)
|
||
|
||
# 1. 檢查完整快取是否有效 (300秒)
|
||
if _DASHBOARD_DATA_CACHE.get('full_data') and _DASHBOARD_DATA_CACHE.get('full_timestamp'):
|
||
age = now.timestamp() - _DASHBOARD_DATA_CACHE['full_timestamp']
|
||
if age < _DASHBOARD_CACHE_TTL:
|
||
sys_log.debug(f"[Dashboard] [Cache] ✅ 使用完整看板快取 | 快取年齡: {age:.0f}秒")
|
||
return _DASHBOARD_DATA_CACHE['full_data']
|
||
|
||
sys_log.info("[Dashboard] [Cache] 🔄 完整快取過期,重新計算所有 KPIs 與統計數據...")
|
||
|
||
# 🚩 V-New: 追蹤查詢時間
|
||
query_start_time = time.time()
|
||
|
||
# 2. 獲取基本彙總資料
|
||
unique_items, today_start = get_consolidated_data()
|
||
today_start_db = today_start.replace(tzinfo=None)
|
||
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
|
||
try:
|
||
# A. 基礎清單統計
|
||
increase_items = [item for item in unique_items if item['yesterday_diff'] > 0]
|
||
decrease_items = [item for item in unique_items if item['yesterday_diff'] < 0]
|
||
|
||
# B. 分類筆數統計
|
||
cat_counts = {}
|
||
for item in unique_items:
|
||
c = item['record'].product.category
|
||
if c: cat_counts[c] = cat_counts.get(c, 0) + 1
|
||
all_categories = [f"{cat} ({count}筆)" for cat, count in sorted(cat_counts.items())]
|
||
|
||
# C. 核心 KPI 統計 (資料庫查詢)
|
||
total_products_history = session.query(Product).count()
|
||
total_price_records = session.query(PriceRecord).count()
|
||
today_updates = session.query(PriceRecord).filter(PriceRecord.timestamp >= today_start_db).count()
|
||
|
||
# 今日新增商品 ID 集合與數量 (優化查詢)
|
||
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()}
|
||
today_new_products = len(new_product_ids)
|
||
|
||
# D. 今日下架商品處理
|
||
raw_delisted_items = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start_db
|
||
).all()
|
||
|
||
today_delisted_items = []
|
||
if raw_delisted_items:
|
||
delisted_ids = [p.id for p in raw_delisted_items]
|
||
# 一次性查詢最後價格
|
||
last_prices_subq = session.query(
|
||
PriceRecord.product_id,
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).filter(PriceRecord.product_id.in_(delisted_ids)).group_by(PriceRecord.product_id).subquery()
|
||
|
||
last_prices_q = session.query(PriceRecord.product_id, PriceRecord.price).join(
|
||
last_prices_subq, PriceRecord.id == last_prices_subq.c.max_id).all()
|
||
price_map = {pid: price for pid, price in last_prices_q}
|
||
|
||
for p in raw_delisted_items:
|
||
today_delisted_items.append({'product': p, 'last_price': price_map.get(p.id, 0)})
|
||
|
||
# E. 週增長 (過去 7 天新增的商品數)
|
||
week_ago_db = (now.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=7)).replace(tzinfo=None)
|
||
week_new_products = session.query(func.count(Product.id)).filter(
|
||
Product.id.in_(
|
||
session.query(PriceRecord.product_id)
|
||
.group_by(PriceRecord.product_id)
|
||
.having(func.min(PriceRecord.timestamp) >= week_ago_db)
|
||
)
|
||
).scalar() or 0
|
||
|
||
# F. 價格穩定商品數(7 天內無變價)
|
||
try:
|
||
stable_count = session.query(PriceRecord.product_id).filter(
|
||
PriceRecord.timestamp >= week_ago_db
|
||
).group_by(PriceRecord.product_id).having(
|
||
func.count(func.distinct(PriceRecord.price)) == 1
|
||
).count()
|
||
except Exception:
|
||
stable_count = 0
|
||
|
||
# G. 最大變動計算
|
||
max_change_item = None
|
||
max_change_value = 0
|
||
for item in unique_items:
|
||
if abs(item['yesterday_diff']) > abs(max_change_value):
|
||
max_change_value = item['yesterday_diff']
|
||
max_change_item = item
|
||
|
||
# H. 最活躍分類
|
||
category_activity = {}
|
||
for item in increase_items + decrease_items:
|
||
cat = item['record'].product.category
|
||
if cat: category_activity[cat] = category_activity.get(cat, 0) + 1
|
||
most_active_category_item = max(category_activity.items(), key=lambda x: x[1]) if category_activity else (None, 0)
|
||
|
||
# I. 組合結果
|
||
full_data = {
|
||
'unique_items': unique_items,
|
||
'today_start': today_start,
|
||
'today_start_db': today_start_db,
|
||
'increase_items_all': increase_items,
|
||
'decrease_items_all': decrease_items,
|
||
'all_categories': all_categories,
|
||
'new_product_ids': new_product_ids,
|
||
'total_products_history': total_products_history,
|
||
'total_price_records': total_price_records,
|
||
'today_updates': today_updates,
|
||
'today_new_products': today_new_products,
|
||
'today_delisted_count': len(raw_delisted_items),
|
||
'today_delisted_items': today_delisted_items,
|
||
'max_change_item': max_change_item,
|
||
'max_change_value': max_change_value,
|
||
'avg_increase': sum(item['yesterday_diff'] for item in increase_items) / len(increase_items) if increase_items else 0,
|
||
'avg_decrease': sum(item['yesterday_diff'] for item in decrease_items) / len(decrease_items) if decrease_items else 0,
|
||
'activity_rate': (len(increase_items) + len(decrease_items)) / total_products_history * 100 if total_products_history > 0 else 0,
|
||
'active_count': len(increase_items) + len(decrease_items),
|
||
'week_new_products': week_new_products,
|
||
'stable_count': stable_count,
|
||
'most_active_category': most_active_category_item[0],
|
||
'most_active_count': most_active_category_item[1]
|
||
}
|
||
|
||
# 更新快取
|
||
_DASHBOARD_DATA_CACHE['full_data'] = full_data
|
||
_DASHBOARD_DATA_CACHE['full_timestamp'] = now.timestamp()
|
||
|
||
# 🚩 V-New: 追蹤查詢時間
|
||
query_duration_ms = (time.time() - query_start_time) * 1000
|
||
track_query_time('get_full_dashboard_data', query_duration_ms)
|
||
sys_log.info(f"[Dashboard] [Cache] 💾 完整看板快取已更新 | 耗時: {query_duration_ms:.0f}ms")
|
||
|
||
return full_data
|
||
except Exception as e:
|
||
sys_log.error(f"[Dashboard] ❌ KPI 計算失敗: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return None
|
||
finally:
|
||
session.close()
|
||
|
||
def get_dashboard_stats():
|
||
"""計算看板統計數據 (供通知使用) - 改成使用快取版本"""
|
||
data = get_full_dashboard_data()
|
||
if data:
|
||
return {
|
||
'new': data['today_new_products'],
|
||
'up': len(data['increase_items_all']),
|
||
'down': len(data['decrease_items_all']),
|
||
'delisted': data['today_delisted_count']
|
||
}
|
||
return {'new': 0, 'up': 0, 'down': 0, 'delisted': 0}
|
||
|
||
# ================= 🛣️ 4. Flask 路由 =================
|
||
|
||
# Session 自動續期機制
|
||
@app.before_request
|
||
def refresh_session():
|
||
"""
|
||
在每次請求時自動刷新 Session,避免長時間閒置後突然斷線
|
||
只要用戶有任何操作,Session 就會自動延長
|
||
"""
|
||
if session.get('logged_in'):
|
||
session.modified = True # 標記 Session 已修改,觸發 Cookie 更新
|
||
|
||
|
||
# =============================================================================
|
||
# 全域模板變數 (Context Processor)
|
||
# =============================================================================
|
||
@app.context_processor
|
||
def inject_global_vars():
|
||
"""
|
||
注入全域模板變數,供所有模板使用
|
||
"""
|
||
import config as app_config # 區域導入避免循環引用
|
||
from auth import get_current_user
|
||
|
||
# 建立模擬的 current_user 對象供導航列使用
|
||
class CurrentUser:
|
||
def __init__(self, logged_in, user_info=None):
|
||
self._logged_in = logged_in
|
||
self._user_info = user_info or {}
|
||
|
||
@property
|
||
def is_authenticated(self):
|
||
return self._logged_in
|
||
|
||
@property
|
||
def role(self):
|
||
return self._user_info.get('role', 'user') if self._logged_in else None
|
||
|
||
@property
|
||
def username(self):
|
||
return self._user_info.get('username', 'User') if self._logged_in else None
|
||
|
||
@property
|
||
def display_name(self):
|
||
return self._user_info.get('display_name', self.username) if self._logged_in else None
|
||
|
||
@property
|
||
def user_id(self):
|
||
return self._user_info.get('user_id') if self._logged_in else None
|
||
|
||
def is_admin(self):
|
||
return self.role == 'admin'
|
||
|
||
def is_manager_or_above(self):
|
||
return self.role in ['admin', 'manager']
|
||
|
||
is_logged_in = session.get('logged_in', False)
|
||
user_info = get_current_user() if is_logged_in else None
|
||
|
||
return {
|
||
'metabase_url': app_config.METABASE_URL, # Metabase BI 連結 (空值時不顯示)
|
||
'grist_url': getattr(app_config, 'GRIST_URL', ''), # Grist 連結 (空值時不顯示)
|
||
'system_version': app_config.SYSTEM_VERSION,
|
||
'current_user': CurrentUser(is_logged_in, user_info), # 支援多用戶角色
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 模板繼承測試端點 (開發用)
|
||
# =============================================================================
|
||
@app.route('/test_template')
|
||
def test_template():
|
||
"""測試 base.html 模板繼承是否正常運作"""
|
||
return render_template('test_base.html',
|
||
datetime_now=datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'),
|
||
active_page='test')
|
||
|
||
|
||
# =============================================================================
|
||
# 健康檢查端點 (供 Docker / Load Balancer 使用)
|
||
# =============================================================================
|
||
@app.route('/health')
|
||
def health_check():
|
||
"""
|
||
健康檢查端點,返回系統狀態
|
||
- 用於 Docker HEALTHCHECK
|
||
- 用於 Nginx upstream 健康檢查
|
||
- 用於負載均衡器健康檢查
|
||
"""
|
||
try:
|
||
# 檢查資料庫連線
|
||
db = DatabaseManager()
|
||
with db.get_session() as session:
|
||
session.execute(text("SELECT 1"))
|
||
|
||
return jsonify({
|
||
'status': 'healthy',
|
||
'timestamp': datetime.now(TAIPEI_TZ).isoformat(),
|
||
'version': SYSTEM_VERSION
|
||
}), 200
|
||
except Exception as e:
|
||
return jsonify({
|
||
'status': 'unhealthy',
|
||
'error': str(e),
|
||
'timestamp': datetime.now(TAIPEI_TZ).isoformat()
|
||
}), 503
|
||
|
||
# =============================================================================
|
||
# Prometheus Metrics 端點 (供監控系統使用)
|
||
# =============================================================================
|
||
@app.route('/metrics')
|
||
def prometheus_metrics():
|
||
"""
|
||
Prometheus metrics endpoint - 暴露系統指標供 Prometheus 抓取
|
||
包含:資料庫大小、表記錄數、連線狀態等
|
||
"""
|
||
try:
|
||
db = DatabaseManager()
|
||
metrics = []
|
||
|
||
# 1. 資料庫檔案大小
|
||
db_path = os.path.join(BASE_DIR, 'data', 'momo_database.db')
|
||
if os.path.exists(db_path):
|
||
db_size = os.path.getsize(db_path)
|
||
metrics.append(f'momo_database_size_bytes{{db="main"}} {db_size}')
|
||
|
||
# 2. WAL 檔案大小
|
||
wal_path = db_path + '-wal'
|
||
if os.path.exists(wal_path):
|
||
wal_size = os.path.getsize(wal_path)
|
||
metrics.append(f'momo_database_wal_size_bytes{{db="main"}} {wal_size}')
|
||
else:
|
||
metrics.append(f'momo_database_wal_size_bytes{{db="main"}} 0')
|
||
|
||
# 3. 資料表記錄數
|
||
with db.get_session() as session:
|
||
# Products 表
|
||
product_count = session.execute(text("SELECT COUNT(*) FROM products")).scalar() or 0
|
||
metrics.append(f'momo_table_rows{{table="products"}} {product_count}')
|
||
|
||
# PriceRecords 表
|
||
price_count = session.execute(text("SELECT COUNT(*) FROM price_records")).scalar() or 0
|
||
metrics.append(f'momo_table_rows{{table="price_records"}} {price_count}')
|
||
|
||
# MonthlySummaryAnalysis 表
|
||
try:
|
||
monthly_count = session.execute(text("SELECT COUNT(*) FROM monthly_summary_analysis")).scalar() or 0
|
||
metrics.append(f'momo_table_rows{{table="monthly_summary_analysis"}} {monthly_count}')
|
||
except:
|
||
metrics.append(f'momo_table_rows{{table="monthly_summary_analysis"}} 0')
|
||
|
||
# PromoProducts 表 (EDM)
|
||
try:
|
||
promo_count = session.execute(text("SELECT COUNT(*) FROM promo_products")).scalar() or 0
|
||
metrics.append(f'momo_table_rows{{table="promo_products"}} {promo_count}')
|
||
except:
|
||
metrics.append(f'momo_table_rows{{table="promo_products"}} 0')
|
||
|
||
# 4. 資料庫連線狀態
|
||
metrics.append('momo_database_up 1')
|
||
|
||
# 5. 今日新增商品數
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
today_products = session.execute(
|
||
text("SELECT COUNT(*) FROM products WHERE created_at >= :today"),
|
||
{'today': today_start}
|
||
).scalar() or 0
|
||
metrics.append(f'momo_products_today_total {today_products}')
|
||
|
||
# 6. 今日價格變動記錄數
|
||
today_price_records = session.execute(
|
||
text("SELECT COUNT(*) FROM price_records WHERE timestamp >= :today"),
|
||
{'today': today_start}
|
||
).scalar() or 0
|
||
metrics.append(f'momo_price_records_today_total {today_price_records}')
|
||
|
||
# 7. 磁碟使用率
|
||
total, used, free = shutil.disk_usage(BASE_DIR)
|
||
metrics.append(f'momo_disk_total_bytes {total}')
|
||
metrics.append(f'momo_disk_used_bytes {used}')
|
||
metrics.append(f'momo_disk_free_bytes {free}')
|
||
|
||
# 8. 應用程式資訊
|
||
metrics.append(f'momo_app_info{{version="{SYSTEM_VERSION}"}} 1')
|
||
|
||
# 9. 慢查詢統計
|
||
metrics.append(f'momo_query_total {_SLOW_QUERY_STATS["total_queries"]}')
|
||
metrics.append(f'momo_query_slow_total {_SLOW_QUERY_STATS["slow_queries"]}')
|
||
metrics.append(f'momo_query_very_slow_total {_SLOW_QUERY_STATS["very_slow_queries"]}')
|
||
metrics.append(f'momo_query_time_total_ms {_SLOW_QUERY_STATS["total_query_time_ms"]}')
|
||
|
||
# 10. 計算平均查詢時間
|
||
if _SLOW_QUERY_STATS["total_queries"] > 0:
|
||
avg_query_time = _SLOW_QUERY_STATS["total_query_time_ms"] / _SLOW_QUERY_STATS["total_queries"]
|
||
metrics.append(f'momo_query_avg_time_ms {avg_query_time:.2f}')
|
||
else:
|
||
metrics.append('momo_query_avg_time_ms 0')
|
||
|
||
# 11. 慢查詢率 (百分比)
|
||
if _SLOW_QUERY_STATS["total_queries"] > 0:
|
||
slow_rate = (_SLOW_QUERY_STATS["slow_queries"] / _SLOW_QUERY_STATS["total_queries"]) * 100
|
||
metrics.append(f'momo_query_slow_rate_percent {slow_rate:.2f}')
|
||
else:
|
||
metrics.append('momo_query_slow_rate_percent 0')
|
||
|
||
# 12. SQLite 連線池狀態 (使用 PRAGMA 查詢)
|
||
with db.get_session() as session:
|
||
try:
|
||
# 查詢 SQLite 頁面統計
|
||
page_count = session.execute(text("PRAGMA page_count")).scalar() or 0
|
||
page_size = session.execute(text("PRAGMA page_size")).scalar() or 4096
|
||
freelist_count = session.execute(text("PRAGMA freelist_count")).scalar() or 0
|
||
|
||
metrics.append(f'momo_sqlite_page_count {page_count}')
|
||
metrics.append(f'momo_sqlite_page_size {page_size}')
|
||
metrics.append(f'momo_sqlite_freelist_count {freelist_count}')
|
||
|
||
# 碎片率 (freelist / page_count * 100)
|
||
if page_count > 0:
|
||
fragmentation = (freelist_count / page_count) * 100
|
||
metrics.append(f'momo_sqlite_fragmentation_percent {fragmentation:.2f}')
|
||
else:
|
||
metrics.append('momo_sqlite_fragmentation_percent 0')
|
||
|
||
# 緩存命中率 (如果啟用)
|
||
try:
|
||
cache_stats = session.execute(text("PRAGMA cache_stats")).fetchone()
|
||
if cache_stats:
|
||
metrics.append(f'momo_sqlite_cache_hit {cache_stats[0] if cache_stats[0] else 0}')
|
||
metrics.append(f'momo_sqlite_cache_miss {cache_stats[1] if len(cache_stats) > 1 else 0}')
|
||
except:
|
||
pass
|
||
|
||
except Exception as pragma_error:
|
||
pass # PRAGMA 查詢失敗不影響其他指標
|
||
|
||
# 返回 Prometheus 格式
|
||
return '\n'.join(metrics) + '\n', 200, {'Content-Type': 'text/plain; charset=utf-8'}
|
||
|
||
except Exception as e:
|
||
# 資料庫連線失敗
|
||
return f'momo_database_up 0\nmomo_database_error{{error="{str(e)}"}} 1\n', 200, {'Content-Type': 'text/plain; charset=utf-8'}
|
||
|
||
@app.route('/')
|
||
def index():
|
||
db = DatabaseManager()
|
||
|
||
session = db.get_session()
|
||
page = request.args.get('page', 1, type=int)
|
||
category_filter = request.args.get('category', 'all')
|
||
sort_by = request.args.get('sort_by', 'timestamp') # 預設按時間排序
|
||
filter_type = request.args.get('filter', 'all') # 🚩 新增:狀態篩選 (increase, decrease, delisted)
|
||
order = request.args.get('order', 'desc')
|
||
search_query = request.args.get('q', '').strip() # 🚩 新增:搜尋關鍵字
|
||
per_page = 50
|
||
|
||
# 🚩 取得台北時間的今日起始點 (用於資料庫查詢比較)
|
||
# 注意:若資料庫內存的是 naive time (無時區),則需轉為 naive 進行比較
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
today_start_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
try:
|
||
# 🚩 1. 使用「深度快取」獲取所有數據 (優化:不再於路由中重複計算 KPIs)
|
||
data = get_full_dashboard_data()
|
||
if not data:
|
||
return render_template('index.html', error="無法載入數據,請檢查資料庫。")
|
||
|
||
unique_items = data['unique_items']
|
||
today_start = data['today_start']
|
||
today_start_db = data['today_start_db']
|
||
increase_items = data['increase_items_all']
|
||
decrease_items = data['decrease_items_all']
|
||
all_categories = data['all_categories']
|
||
new_product_ids = data['new_product_ids']
|
||
total_products_history = data['total_products_history']
|
||
today_new_products = data['today_new_products']
|
||
total_price_records = data['total_price_records']
|
||
today_updates = data['today_updates']
|
||
today_delisted_count = data['today_delisted_count']
|
||
today_delisted_items = data['today_delisted_items']
|
||
max_change_item = data['max_change_item']
|
||
max_change_value = data['max_change_value']
|
||
avg_increase = data['avg_increase']
|
||
avg_decrease = data['avg_decrease']
|
||
activity_rate = data['activity_rate']
|
||
week_new_products = data['week_new_products']
|
||
stable_count = data['stable_count']
|
||
most_active_category = data['most_active_category']
|
||
most_active_count = data['most_active_count']
|
||
active_count = data.get('active_count', 0)
|
||
|
||
# 🚩 讀取系統狀態 (用於紅綠燈顯示)
|
||
system_status = {"status": "UNKNOWN", "message": "尚無執行紀錄", "timestamp": "-"}
|
||
status_path = os.path.join(BASE_DIR, 'data/system_status.json')
|
||
if os.path.exists(status_path):
|
||
try:
|
||
with open(status_path, 'r', encoding='utf-8') as f:
|
||
system_status = json.load(f)
|
||
except: pass
|
||
|
||
# --- 取得所有分類用於篩選器 ---
|
||
# (已在上方取得)
|
||
|
||
# 🚩 2. 後端篩選 (Server-side Filtering)
|
||
scheduler_stats = load_scheduler_stats()
|
||
|
||
# V-Fix: Handle old scheduler stats format (dict) by converting to list to prevent template errors
|
||
if scheduler_stats.get('momo_task') and isinstance(scheduler_stats.get('momo_task'), dict):
|
||
scheduler_stats['momo_task'] = [scheduler_stats['momo_task']]
|
||
if scheduler_stats.get('edm_task') and isinstance(scheduler_stats.get('edm_task'), dict):
|
||
scheduler_stats['edm_task'] = [scheduler_stats['edm_task']]
|
||
|
||
filtered_items = []
|
||
|
||
# 0. 先處理搜尋 (若有)
|
||
if search_query:
|
||
search_lower = search_query.lower()
|
||
# V9.81: 搜尋功能修復,支援搜尋商品名稱與 i_code
|
||
base_items = [
|
||
item for item in unique_items
|
||
if (item['record'].product.name and search_lower in item['record'].product.name.lower()) or
|
||
(item['record'].product.i_code and search_lower in str(item['record'].product.i_code))
|
||
]
|
||
else:
|
||
base_items = unique_items
|
||
|
||
# A. 先處理狀態篩選 (漲/跌/下架)
|
||
if filter_type == 'increase':
|
||
filtered_items = [i for i in base_items if i in increase_items]
|
||
elif filter_type == 'decrease':
|
||
filtered_items = [i for i in base_items if i in decrease_items]
|
||
elif filter_type == 'new':
|
||
# V-New: 新上架篩選 (今日新增的商品)
|
||
filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids]
|
||
elif filter_type == 'delisted':
|
||
# 特殊處理:將下架商品轉換為列表格式以便顯示
|
||
for item in today_delisted_items:
|
||
# 模擬 record 物件結構
|
||
class MockRecord:
|
||
def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at
|
||
|
||
if not search_query or search_query.lower() in item['product'].name.lower():
|
||
filtered_items.append({
|
||
'record': MockRecord(item['product'], item['last_price']),
|
||
'stats': {'1d_diff': 0, '7d_diff': 0, '30d_diff': 0}, # 模擬 stats 結構
|
||
'yesterday_diff': 0,
|
||
'today_changes': [], # 確保結構一致
|
||
'status': 'DELISTED' # 新增狀態
|
||
})
|
||
else:
|
||
# B. 若無狀態篩選,則處理分類篩選
|
||
if category_filter != 'all':
|
||
# V-New: 處理帶有筆數的分類名稱,例如 "化妝水 (50筆)" -> "化妝水"
|
||
real_category = category_filter
|
||
if "(" in category_filter and "筆)" in category_filter:
|
||
real_category = category_filter.rsplit(" (", 1)[0]
|
||
filtered_items = [item for item in base_items if item['record'].product.category == real_category]
|
||
else:
|
||
filtered_items = base_items
|
||
|
||
# 🚩 3. 後端排序 (Server-side Sorting)
|
||
reverse = (order == 'desc')
|
||
def get_sort_key(item):
|
||
# 處理 None 值,確保排序時不會出錯
|
||
def safe_get(value, default=0):
|
||
return default if value is None else value
|
||
|
||
if sort_by == 'i_code': return int(safe_get(item['record'].product.i_code, 0))
|
||
if sort_by == 'category': return safe_get(item['record'].product.category, '')
|
||
if sort_by == 'name': return safe_get(item['record'].product.name, '')
|
||
if sort_by == 'price': return safe_get(item['record'].price, 0)
|
||
if sort_by == 'today_change': return safe_get(item['stats']['1d_diff'], 0) # 今日內波動
|
||
if sort_by == 'yesterday_change': return safe_get(item['yesterday_diff'], 0)
|
||
if sort_by == 'week_change': return safe_get(item['stats']['7d_diff'], 0)
|
||
return item['record'].timestamp # 預設
|
||
|
||
sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse)
|
||
|
||
# 🚩 4. 分頁 (Pagination) - 在篩選和排序之後執行
|
||
total_items = len(sorted_items)
|
||
total_pages = math.ceil(total_items / per_page)
|
||
|
||
start_idx = (page - 1) * per_page
|
||
paged_items = sorted_items[start_idx : start_idx + per_page]
|
||
|
||
# V-Fix: 為前端準備安全的 created_at 屬性
|
||
for item in paged_items:
|
||
item['safe_created_at'] = getattr(item['record'].product, 'created_at', None)
|
||
|
||
# 🚩 5. 為當前頁面項目添加顏色
|
||
for item in paged_items:
|
||
category_name = item['record'].product.category
|
||
item['category_color'] = get_color_for_string(category_name)
|
||
|
||
return render_template('dashboard.html',
|
||
total_products=total_products_history,
|
||
today_new_products=today_new_products,
|
||
total_price_records=total_price_records,
|
||
cnt_increase=len(increase_items),
|
||
cnt_decrease=len(decrease_items), # 傳遞跌價數
|
||
today_delisted_count=today_delisted_count,
|
||
today_delisted_items=today_delisted_items,
|
||
system_status=system_status,
|
||
items=paged_items,
|
||
categories=all_categories,
|
||
current_page=page,
|
||
total_pages=total_pages, # V-New: 傳遞總項目數
|
||
total_items=total_items,
|
||
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), # 顯示台北時間
|
||
today_date=now_taipei.strftime('%Y-%m-%d'), # 傳遞今日日期
|
||
public_url=public_url,
|
||
current_category=category_filter,
|
||
current_filter=filter_type, # 傳遞當前篩選狀態
|
||
search_query=search_query, # 傳遞搜尋關鍵字
|
||
current_sort=sort_by,
|
||
current_order=order,
|
||
scheduler_stats=scheduler_stats,
|
||
# V9.2: 新增 KPI 數據
|
||
avg_increase=avg_increase,
|
||
avg_decrease=avg_decrease,
|
||
activity_rate=activity_rate,
|
||
active_count=active_count,
|
||
max_change_item=max_change_item,
|
||
max_change_value=max_change_value,
|
||
week_new_products=week_new_products,
|
||
stable_count=stable_count,
|
||
most_active_category=most_active_category,
|
||
most_active_count=most_active_count)
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Dashboard] 🚨 渲染錯誤 | Error: {e}")
|
||
return f"系統維護中,錯誤詳情:{e}"
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/settings')
|
||
def settings():
|
||
"""分類設定頁面"""
|
||
categories = load_categories()
|
||
return render_template('settings.html',
|
||
categories=categories,
|
||
public_url=public_url,
|
||
system_version=SYSTEM_VERSION)
|
||
|
||
@app.route('/system_settings')
|
||
def system_settings_page():
|
||
"""系統設定與匯入頁面"""
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
return render_template('system_settings.html', system_version=SYSTEM_VERSION, datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'))
|
||
|
||
@app.route('/api/categories', methods=['POST'])
|
||
def add_category():
|
||
"""API: 新增分類"""
|
||
name = request.form.get('name')
|
||
url = request.form.get('url')
|
||
if not name or not url:
|
||
return jsonify({"status": "error", "message": "名稱和 URL 皆不可為空"}), 400
|
||
|
||
categories = load_categories()
|
||
new_id = int(time.time() * 1000) # 使用時間戳作為簡易唯一 ID
|
||
categories.append({'id': new_id, 'name': name, 'url': url})
|
||
save_categories(categories)
|
||
|
||
return jsonify({"status": "success", "message": "分類新增成功"})
|
||
|
||
@app.route('/api/categories/<int:category_id>', methods=['PUT'])
|
||
def update_category(category_id):
|
||
"""API: 更新分類"""
|
||
name = request.form.get('name')
|
||
url = request.form.get('url')
|
||
if not name or not url:
|
||
return jsonify({"status": "error", "message": "名稱和 URL 皆不可為空"}), 400
|
||
|
||
categories = load_categories()
|
||
category_found = False
|
||
for cat in categories:
|
||
if cat.get('id') == category_id:
|
||
cat['name'] = name
|
||
cat['url'] = url
|
||
category_found = True
|
||
break
|
||
|
||
if not category_found:
|
||
return jsonify({"status": "error", "message": "找不到指定的分類 ID"}), 404
|
||
|
||
save_categories(categories)
|
||
return jsonify({"status": "success", "message": "分類更新成功"})
|
||
|
||
@app.route('/api/categories/<int:category_id>', methods=['DELETE'])
|
||
def delete_category(category_id):
|
||
"""API: 刪除分類"""
|
||
categories = [cat for cat in load_categories() if cat.get('id') != category_id]
|
||
save_categories(categories)
|
||
return jsonify({"status": "success", "message": "分類刪除成功"})
|
||
|
||
@app.route('/api/test_url', methods=['POST'])
|
||
def test_url():
|
||
"""API: 測試網址是否有效"""
|
||
try:
|
||
data = request.get_json()
|
||
url = data.get('url')
|
||
if not url:
|
||
return jsonify({"status": "error", "message": "網址不能為空"}), 400
|
||
|
||
import requests
|
||
headers = {
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||
}
|
||
# 設定 10 秒超時,避免卡住
|
||
response = requests.get(url, headers=headers, timeout=10)
|
||
|
||
if response.status_code == 200:
|
||
return jsonify({"status": "success", "message": f"✅ 連結有效 (Status: 200)"})
|
||
else:
|
||
return jsonify({"status": "warning", "message": f"⚠️ 連結回應異常 (Status: {response.status_code})"})
|
||
|
||
except Exception as e:
|
||
return jsonify({"status": "error", "message": f"❌ 連線失敗: {str(e)}"}), 500
|
||
|
||
|
||
@app.route('/brand_assets')
|
||
def brand_assets():
|
||
"""顯示品牌資產庫"""
|
||
return render_template('brand_assets.html')
|
||
|
||
@app.route('/edm')
|
||
def edm_dashboard():
|
||
"""🚩 新增:MOMO 限時搶購 (EDM) 專屬儀表板"""
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
|
||
# V-New: 排序參數
|
||
sort_by = request.args.get('sort_by', 'default')
|
||
order = request.args.get('order', 'desc')
|
||
|
||
try:
|
||
# 1. 基礎統計
|
||
# 取得最後更新時間
|
||
last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
|
||
last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料"
|
||
|
||
# 🚩 V9.29 新增:取得最新的活動時間文字
|
||
latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
|
||
activity_time = getattr(latest_entry, 'activity_time_text', '限時搶購') if latest_entry else "限時搶購"
|
||
|
||
# 2. 查詢資料 (V9.44: 只顯示最新批次的資料)
|
||
# 找出最新的 batch_id
|
||
latest_batch = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
|
||
current_batch_id = latest_batch[0] if latest_batch else None
|
||
|
||
# 🚩 V9.55 修正:改為查詢「全商品的最新狀態快照」,而非僅查詢最新批次
|
||
# 因為 Scheduler 現在只記錄異動,若只查最新批次,未變動的商品會消失
|
||
subq = session.query(
|
||
func.max(PromoProduct.id).label('max_id')
|
||
).filter(PromoProduct.page_type == 'edm').group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery()
|
||
|
||
latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all()
|
||
|
||
# 過濾顯示列表:顯示「上架中」、「本批次剛下架」或「今日結束時段」的商品
|
||
items_in_batch = []
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
for item in latest_records:
|
||
# V9.60: 隱藏自然結束的時段商品
|
||
# V-New: 如果商品是今天才結束的,則依然顯示在儀表板上,方便回查
|
||
if item.status_change == 'SLOT_END' and item.crawled_at < today_start:
|
||
continue
|
||
|
||
# V-New: 如果是下架狀態,只有當它是「今天」下架的才顯示
|
||
if item.status_change == 'DELISTED' and item.crawled_at < today_start:
|
||
continue
|
||
items_in_batch.append(item)
|
||
|
||
# V9.45: 按時段分組
|
||
grouped_items = {}
|
||
for item in items_in_batch:
|
||
if item.time_slot not in grouped_items:
|
||
grouped_items[item.time_slot] = []
|
||
grouped_items[item.time_slot].append(item)
|
||
|
||
# 按時段鍵值排序 (e.g., 00:00, 07:00, ...)
|
||
sorted_grouped_items = dict(sorted(grouped_items.items()))
|
||
|
||
# V9.45: 決定預設顯示的頁籤
|
||
def get_current_time_slot():
|
||
hour = datetime.now(TAIPEI_TZ).hour
|
||
available_slots = sorted([int(s.split(':')[0]) for s in sorted_grouped_items.keys() if s and ':' in s]) if sorted_grouped_items else [0, 7, 11, 14, 18, 22]
|
||
current_slot_hour = 0
|
||
for s in available_slots:
|
||
if hour >= s:
|
||
current_slot_hour = s
|
||
return f"{current_slot_hour:02d}:00"
|
||
|
||
active_tab = get_current_time_slot()
|
||
if active_tab not in sorted_grouped_items and sorted_grouped_items:
|
||
active_tab = next(iter(sorted_grouped_items))
|
||
|
||
# V-New: 計算在架天數與總銷量
|
||
all_icodes_in_batch = [item.i_code for item in items_in_batch]
|
||
product_categories = {}
|
||
days_on_shelf_map = {}
|
||
total_sold_map = {}
|
||
|
||
if all_icodes_in_batch:
|
||
# 從主商品表 (products) 查詢這些 i_code 對應的分類
|
||
main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all()
|
||
product_categories = {p.i_code: p.category for p in main_products}
|
||
|
||
# 計算上架天數 (days_on_shelf)
|
||
days_on_shelf_q = session.query(
|
||
PromoProduct.i_code,
|
||
func.count(func.distinct(func.strftime('%Y-%m-%d', PromoProduct.crawled_at)))
|
||
).filter( # V-New: 增加 page_type 過濾
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.page_type == 'edm'
|
||
).group_by(PromoProduct.i_code).all()
|
||
days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q}
|
||
|
||
# 計算總銷量
|
||
# 1. 找出每個商品第一次有庫存紀錄的 ID
|
||
first_qty_subq = session.query(
|
||
PromoProduct.i_code,
|
||
func.min(PromoProduct.id).label('min_id')
|
||
).filter(
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.remain_qty.isnot(None),
|
||
PromoProduct.page_type == 'edm'
|
||
).group_by(PromoProduct.i_code).subquery()
|
||
|
||
# 2. 根據 ID 取得當時的庫存
|
||
first_qty_records = session.query(
|
||
PromoProduct.i_code, PromoProduct.remain_qty
|
||
).join(first_qty_subq, PromoProduct.id == first_qty_subq.c.min_id).all()
|
||
first_qty_map = {r[0]: r[1] for r in first_qty_records}
|
||
|
||
# 3. 計算總銷量 (初始庫存 - 當前庫存)
|
||
for item in items_in_batch:
|
||
# 確保該商品有初始庫存紀錄,且當前庫存也存在
|
||
if item.i_code in first_qty_map and item.remain_qty is not None:
|
||
initial_qty = first_qty_map[item.i_code]
|
||
current_qty = item.remain_qty
|
||
# 只有在初始庫存大於當前庫存時才計算,避免負數
|
||
if initial_qty > current_qty:
|
||
total_sold_map[item.i_code] = initial_qty - current_qty
|
||
|
||
# V-Fix: 修正 NameError: name 'history_map' is not defined
|
||
# 準備銷售歷程資料
|
||
history_map = {}
|
||
if all_icodes_in_batch:
|
||
all_history_records = session.query(
|
||
PromoProduct.i_code,
|
||
PromoProduct.time_slot,
|
||
PromoProduct.remain_qty,
|
||
PromoProduct.crawled_at
|
||
).filter(
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.crawled_at >= today_start
|
||
).order_by(PromoProduct.crawled_at).all()
|
||
|
||
for rec in all_history_records:
|
||
key = (rec.i_code, rec.time_slot)
|
||
if key not in history_map:
|
||
history_map[key] = []
|
||
|
||
if rec.remain_qty is not None:
|
||
if not history_map[key] or (history_map[key] and history_map[key][-1]['qty'] != rec.remain_qty):
|
||
history_map[key].append({'time': rec.crawled_at.strftime('%H:%M'), 'qty': rec.remain_qty})
|
||
|
||
# 將查到的分類資訊附加到每個 item 物件上
|
||
for item in items_in_batch:
|
||
item.main_category = product_categories.get(item.i_code)
|
||
if item.main_category:
|
||
item.category_color = get_color_for_string(item.main_category)
|
||
# V-New: 附加在架天數與總銷量
|
||
item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1)
|
||
item.total_sold = total_sold_map.get(item.i_code, 0)
|
||
# V-New: Attach quantity history
|
||
item.qty_history = history_map.get((item.i_code, item.time_slot), [])
|
||
|
||
# V9.46: 排序邏輯優化 (中文註解)
|
||
# 排序規則:
|
||
# 1. 有貼標 (main_category 存在) 的商品優先
|
||
# 2. 有狀態變更 (NEW, 漲價, 降價) 的商品次之
|
||
# 3. 已下架的商品再次之
|
||
# 4. 最後按價格由高到低排序
|
||
reverse = (order == 'desc')
|
||
for time_slot in sorted_grouped_items:
|
||
if sort_by == 'name':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse)
|
||
elif sort_by == 'remain_qty':
|
||
# 將 None 視為 -1,確保排序時在最下方
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.remain_qty if x.remain_qty is not None else -1, reverse=reverse)
|
||
elif sort_by == 'price':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse)
|
||
else: # 預設排序
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: (
|
||
1 if x.main_category else 0,
|
||
2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0),
|
||
x.price if x.price is not None else -1
|
||
), reverse=True)
|
||
|
||
# 🚩 V-Fix: 修正時段統計,使其能區分「當前狀態」與「上次異動」
|
||
# V-New: 重構時段統計邏輯,確保統計所有今日異動
|
||
slot_stats = {}
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
# 1. 取得今日所有異動紀錄
|
||
today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == 'edm').all()
|
||
|
||
# 2. 取得所有相關時段的鍵 (今日異動的 + 當前在架的)
|
||
slots_from_changes = {rec.time_slot for rec in today_change_records}
|
||
slots_from_display = set(sorted_grouped_items.keys())
|
||
all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display)))
|
||
|
||
# 3. 初始化所有相關時段的統計數據
|
||
for slot in all_relevant_slots:
|
||
slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0}
|
||
|
||
# 4. 累加新品、漲價、降價、下架的數量 (從今日歷史紀錄)
|
||
for rec in today_change_records:
|
||
if rec.time_slot in slot_stats:
|
||
if rec.status_change == 'NEW':
|
||
slot_stats[rec.time_slot]['new'] += 1
|
||
elif rec.status_change == 'PRICE_UP':
|
||
slot_stats[rec.time_slot]['up'] += 1
|
||
elif rec.status_change == 'PRICE_DOWN':
|
||
slot_stats[rec.time_slot]['down'] += 1
|
||
elif rec.status_change in ['DELISTED', 'SLOT_END']:
|
||
slot_stats[rec.time_slot]['delisted_last_run'] += 1
|
||
|
||
# 5. 計算在架與下架總數 (從當前顯示的商品快照)
|
||
for slot, items in sorted_grouped_items.items():
|
||
if slot in slot_stats:
|
||
on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END'])
|
||
delisted_total_count = len(items) - on_shelf_count
|
||
slot_stats[slot]['on_shelf'] = on_shelf_count
|
||
slot_stats[slot]['delisted_total'] = delisted_total_count
|
||
|
||
# V-New: 建立儀表板頁籤
|
||
promo_pages = [
|
||
{'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
|
||
{'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}
|
||
]
|
||
|
||
scheduler_stats = load_scheduler_stats()
|
||
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
return render_template('edm_dashboard.html',
|
||
promo_pages=promo_pages,
|
||
current_promo_page='edm',
|
||
page_title='MOMO 限時搶購',
|
||
grouped_items=sorted_grouped_items,
|
||
slot_stats=slot_stats,
|
||
total_edm_products=len(items_in_batch),
|
||
last_update=last_update_str,
|
||
activity_time=activity_time,
|
||
active_tab=active_tab,
|
||
current_batch_id=current_batch_id,
|
||
public_url=public_url,
|
||
scheduler_stats=scheduler_stats,
|
||
current_sort=sort_by,
|
||
current_order=order,
|
||
slugify=slugify,
|
||
datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'))
|
||
except Exception as e:
|
||
sys_log.error(f"🚨 EDM Dashboard 渲染錯誤: {e}")
|
||
return f"系統錯誤: {e}"
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/festival')
|
||
def festival_dashboard():
|
||
"""🚩 新增:1.1 狂歡購物節專屬儀表板"""
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
|
||
PAGE_TYPE = "festival"
|
||
PAGE_NAME = "1.1狂歡購物節"
|
||
|
||
sort_by = request.args.get('sort_by', 'default')
|
||
order = request.args.get('order', 'desc')
|
||
|
||
try:
|
||
# 1. 基礎統計
|
||
last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first()
|
||
last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料"
|
||
|
||
latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first()
|
||
activity_time = getattr(latest_entry, 'activity_time_text', PAGE_NAME) if latest_entry else PAGE_NAME
|
||
|
||
# 2. 查詢資料
|
||
subq = session.query(
|
||
func.max(PromoProduct.id).label('max_id')
|
||
).filter(PromoProduct.page_type == PAGE_TYPE).group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery()
|
||
|
||
latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all()
|
||
|
||
items_in_batch = []
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
for item in latest_records:
|
||
if item.status_change == 'SLOT_END' and item.crawled_at < today_start:
|
||
continue
|
||
if item.status_change == 'DELISTED' and item.crawled_at < today_start:
|
||
continue
|
||
items_in_batch.append(item)
|
||
|
||
# 此頁面使用區塊標題作為分組依據
|
||
grouped_items = {}
|
||
for item in items_in_batch:
|
||
if item.time_slot not in grouped_items:
|
||
grouped_items[item.time_slot] = []
|
||
grouped_items[item.time_slot].append(item)
|
||
|
||
sorted_grouped_items = dict(sorted(grouped_items.items()))
|
||
|
||
# 預設顯示第一個頁籤
|
||
active_tab = next(iter(sorted_grouped_items)) if sorted_grouped_items else ""
|
||
|
||
all_icodes_in_batch = [item.i_code for item in items_in_batch]
|
||
product_categories = {}
|
||
days_on_shelf_map = {}
|
||
total_sold_map = {}
|
||
|
||
if all_icodes_in_batch:
|
||
main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all()
|
||
product_categories = {p.i_code: p.category for p in main_products}
|
||
|
||
days_on_shelf_q = session.query(
|
||
PromoProduct.i_code,
|
||
func.count(func.distinct(func.strftime('%Y-%m-%d', PromoProduct.crawled_at)))
|
||
).filter(
|
||
PromoProduct.i_code.in_(all_icodes_in_batch),
|
||
PromoProduct.page_type == PAGE_TYPE
|
||
).group_by(PromoProduct.i_code).all()
|
||
days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q}
|
||
|
||
# 將查到的分類資訊附加到每個 item 物件上
|
||
for item in items_in_batch:
|
||
item.main_category = product_categories.get(item.i_code)
|
||
if item.main_category:
|
||
item.category_color = get_color_for_string(item.main_category)
|
||
item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1)
|
||
# V-Fix: 為 festival 頁面提供預設值,避免共用模板在渲染 total_sold 和 qty_history 時出錯
|
||
item.total_sold = 0
|
||
item.qty_history = []
|
||
|
||
# 排序邏輯
|
||
reverse = (order == 'desc')
|
||
for time_slot in sorted_grouped_items:
|
||
if sort_by == 'name':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse)
|
||
elif sort_by == 'price':
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse)
|
||
else: # 預設排序
|
||
sorted_grouped_items[time_slot].sort(key=lambda x: (
|
||
1 if x.main_category else 0,
|
||
2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0),
|
||
x.price if x.price is not None else -1
|
||
), reverse=True)
|
||
|
||
# 時段統計
|
||
slot_stats = {}
|
||
today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||
|
||
today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == PAGE_TYPE).all()
|
||
|
||
slots_from_changes = {rec.time_slot for rec in today_change_records}
|
||
slots_from_display = set(sorted_grouped_items.keys())
|
||
all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display)))
|
||
|
||
for slot in all_relevant_slots:
|
||
slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0}
|
||
|
||
for rec in today_change_records:
|
||
if rec.time_slot in slot_stats:
|
||
if rec.status_change == 'NEW': slot_stats[rec.time_slot]['new'] += 1
|
||
elif rec.status_change == 'PRICE_UP': slot_stats[rec.time_slot]['up'] += 1
|
||
elif rec.status_change == 'PRICE_DOWN': slot_stats[rec.time_slot]['down'] += 1
|
||
elif rec.status_change in ['DELISTED', 'SLOT_END']: slot_stats[rec.time_slot]['delisted_last_run'] += 1
|
||
|
||
for slot, items in sorted_grouped_items.items():
|
||
if slot in slot_stats:
|
||
on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END'])
|
||
delisted_total_count = len(items) - on_shelf_count
|
||
slot_stats[slot]['on_shelf'] = on_shelf_count
|
||
slot_stats[slot]['delisted_total'] = delisted_total_count
|
||
|
||
scheduler_stats = load_scheduler_stats()
|
||
|
||
# 建立儀表板頁籤
|
||
promo_pages = [
|
||
{'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
|
||
{'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'}
|
||
]
|
||
|
||
# 注意:這裡我們重複使用 edm_dashboard.html 範本
|
||
# 您需要建立一個它的複本,命名為 festival.html
|
||
return render_template('edm_dashboard.html',
|
||
promo_pages=promo_pages,
|
||
current_promo_page='festival',
|
||
page_title=PAGE_NAME,
|
||
grouped_items=sorted_grouped_items,
|
||
slot_stats=slot_stats,
|
||
total_edm_products=len(items_in_batch),
|
||
last_update=last_update_str,
|
||
activity_time=activity_time,
|
||
active_tab=active_tab,
|
||
public_url=public_url,
|
||
scheduler_stats=scheduler_stats,
|
||
current_sort=sort_by,
|
||
current_order=order,
|
||
slugify=slugify)
|
||
except Exception as e:
|
||
sys_log.error(f"🚨 {PAGE_NAME} Dashboard 渲染錯誤: {e}")
|
||
return f"系統錯誤: {e}"
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/api/export/all_categories')
|
||
def export_all_categories():
|
||
"""🚩 需求 A:處理全分類報表匯出請求"""
|
||
try:
|
||
sys_log.info("📊 執行全分類 CSV 數據導出...")
|
||
|
||
# 1. 獲取與看板一致的整合數據
|
||
items, _ = get_consolidated_data()
|
||
|
||
# 2. 呼叫匯出服務
|
||
exporter = Exporter()
|
||
file_path = exporter.generate_all_categories_report() # 此函式內部已處理按分類分 Sheet
|
||
|
||
if file_path:
|
||
# 🚩 強制轉為絕對路徑,解決 CWD 與 Flask Root Path 不一致導致的 404 問題
|
||
abs_file_path = os.path.abspath(file_path)
|
||
|
||
if os.path.exists(abs_file_path):
|
||
sys_log.info(f"✅ 報表匯出成功,準備下載: {abs_file_path}")
|
||
return send_file(abs_file_path, as_attachment=True)
|
||
|
||
return "匯出失敗:資料庫內尚無足夠數據", 404
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ 全分類報表匯出異常 | Error: {e}")
|
||
return f"匯出失敗,錯誤詳情:{e}", 500
|
||
|
||
# 🚩 V9.90: 新增 Excel 匯出路由
|
||
@app.route('/api/export/excel/all')
|
||
def export_excel_all():
|
||
try:
|
||
items, _ = get_consolidated_data()
|
||
exporter = Exporter()
|
||
file_path = exporter.generate_all_products_excel(items)
|
||
if file_path and os.path.exists(file_path):
|
||
return send_file(file_path, as_attachment=True)
|
||
return "匯出失敗", 500
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (All) | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/excel/changes')
|
||
def export_excel_changes():
|
||
try:
|
||
items, _ = get_consolidated_data()
|
||
increase = [i for i in items if i['yesterday_diff'] > 0]
|
||
decrease = [i for i in items if i['yesterday_diff'] < 0]
|
||
|
||
exporter = Exporter()
|
||
file_path = exporter.generate_changes_excel(increase, decrease)
|
||
if file_path and os.path.exists(file_path):
|
||
return send_file(file_path, as_attachment=True)
|
||
return "匯出失敗", 500
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Changes) | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/excel/delisted')
|
||
def export_excel_delisted():
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
_, today_start = get_consolidated_data()
|
||
today_delisted_query = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start.replace(tzinfo=None)
|
||
)
|
||
raw_items = today_delisted_query.all()
|
||
delisted_items = [{'product': p, 'last_price': (session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first().price if session.query(PriceRecord).filter_by(product_id=p.id).first() else 0)} for p in raw_items]
|
||
|
||
exporter = Exporter()
|
||
file_path = exporter.generate_delisted_excel(delisted_items)
|
||
return send_file(file_path, as_attachment=True)
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Delisted) | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/api/export/price_changes')
|
||
def export_price_changes():
|
||
"""V9.4 更新:匯出今日價格異動明細 (支援篩選) - 修正:改用與儀表板相同的邏輯"""
|
||
import openpyxl
|
||
from openpyxl.styles import Font, Alignment, PatternFill
|
||
|
||
filter_type = request.args.get('type', '')
|
||
filter_category = request.args.get('category', '')
|
||
|
||
try:
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
|
||
# 使用與 /api/price_change_details 相同的邏輯
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
|
||
|
||
# 基礎查詢:取得所有商品的最新記錄
|
||
latest_records_subq = session.query(
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
query = session.query(PriceRecord, Product).join(
|
||
latest_records_subq,
|
||
PriceRecord.id == latest_records_subq.c.max_id
|
||
).join(Product, PriceRecord.product_id == Product.id)
|
||
|
||
# 一次性查詢所有商品的「今日之前最後價格」
|
||
product_ids = [r[0] for r in session.query(PriceRecord.product_id).join(
|
||
latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id
|
||
).all()]
|
||
|
||
yesterday_prices_subq = session.query(
|
||
PriceRecord.product_id,
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).filter(
|
||
PriceRecord.product_id.in_(product_ids),
|
||
PriceRecord.timestamp < today_start
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
yesterday_prices_q = session.query(
|
||
PriceRecord.product_id, PriceRecord.price
|
||
).join(
|
||
yesterday_prices_subq,
|
||
PriceRecord.id == yesterday_prices_subq.c.max_id
|
||
)
|
||
yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q}
|
||
|
||
products = []
|
||
|
||
# 根據 filter_type 篩選
|
||
if filter_type == 'increase':
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price > old_price:
|
||
products.append((product, record, old_price))
|
||
|
||
elif filter_type == 'decrease':
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price < old_price:
|
||
products.append((product, record, old_price))
|
||
|
||
elif filter_type == 'delisted':
|
||
today_delisted = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start
|
||
).all()
|
||
for product in today_delisted:
|
||
last_record = session.query(PriceRecord).filter(
|
||
PriceRecord.product_id == product.id
|
||
).order_by(PriceRecord.timestamp.desc()).first()
|
||
if last_record:
|
||
products.append((product, last_record, last_record.price))
|
||
|
||
elif filter_type == 'active':
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append((product, record, old_price))
|
||
|
||
elif filter_type == 'category' and filter_category:
|
||
for record, product in query.filter(Product.category == filter_category).all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append((product, record, old_price))
|
||
|
||
else:
|
||
# 預設:所有變動商品
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append((product, record, old_price))
|
||
|
||
session.close()
|
||
|
||
if not products:
|
||
return "無符合條件的商品資料", 404
|
||
|
||
# 建立 Excel
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "價格變動明細"
|
||
|
||
# 標題列
|
||
headers = ['商品ID', '商品名稱', '分類', '原價格', '現價格', '變動金額', '變動百分比', '更新時間', '商品網址']
|
||
ws.append(headers)
|
||
|
||
# 設定標題列樣式
|
||
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
|
||
header_font = Font(bold=True, color='FFFFFF')
|
||
for cell in ws[1]:
|
||
cell.fill = header_fill
|
||
cell.font = header_font
|
||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||
|
||
# 填充資料
|
||
for product, record, old_price in products:
|
||
change = record.price - old_price
|
||
change_pct = (change / old_price * 100) if old_price > 0 else 0
|
||
ws.append([
|
||
product.i_code,
|
||
product.name,
|
||
product.category or '未分類',
|
||
old_price,
|
||
record.price,
|
||
change,
|
||
f"{change_pct:.2f}%",
|
||
record.timestamp.strftime('%Y-%m-%d %H:%M'),
|
||
product.url
|
||
])
|
||
|
||
# 調整欄寬
|
||
ws.column_dimensions['A'].width = 12
|
||
ws.column_dimensions['B'].width = 40
|
||
ws.column_dimensions['C'].width = 15
|
||
ws.column_dimensions['D'].width = 12
|
||
ws.column_dimensions['E'].width = 12
|
||
ws.column_dimensions['F'].width = 12
|
||
ws.column_dimensions['G'].width = 12
|
||
ws.column_dimensions['H'].width = 18
|
||
ws.column_dimensions['I'].width = 50
|
||
|
||
# 儲存檔案
|
||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
filename = f"價格變動明細_{filter_type or 'all'}_{timestamp}.xlsx"
|
||
filepath = os.path.join(EXCEL_EXPORT_DIR, filename)
|
||
|
||
os.makedirs(EXCEL_EXPORT_DIR, exist_ok=True)
|
||
wb.save(filepath)
|
||
|
||
return send_file(filepath, as_attachment=True, download_name=filename)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ 異動報表匯出失敗 | Type: {filter_type} | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/low_prices')
|
||
def export_low_prices():
|
||
"""🚩 新增:匯出歷史低價商品"""
|
||
try:
|
||
exporter = Exporter()
|
||
file_path = exporter.generate_low_price_report()
|
||
|
||
if file_path and os.path.exists(file_path):
|
||
return send_file(file_path, as_attachment=True)
|
||
return "目前無歷史低價商品", 404
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ 低價報表匯出失敗 | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/changes')
|
||
def export_changes():
|
||
"""🚩 需求:匯出篩選後的資料 (漲/跌/下架)"""
|
||
filter_type = request.args.get('type')
|
||
exporter = Exporter()
|
||
file_path = None
|
||
|
||
try:
|
||
unique_items, today_start = get_consolidated_data()
|
||
|
||
if filter_type == 'increase':
|
||
target_items = [i for i in unique_items if i['yesterday_diff'] > 0]
|
||
file_path = exporter.generate_custom_report(target_items, "今日漲價商品")
|
||
elif filter_type == 'decrease':
|
||
target_items = [i for i in unique_items if i['yesterday_diff'] < 0]
|
||
file_path = exporter.generate_custom_report(target_items, "今日跌價商品")
|
||
elif filter_type == 'delisted':
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
today_start_naive = today_start.replace(tzinfo=None)
|
||
today_delisted_query = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start_naive
|
||
)
|
||
raw_delisted_items = today_delisted_query.all()
|
||
|
||
delisted_items_with_price = []
|
||
|
||
# 定義模擬物件 (移至迴圈外以提升效率)
|
||
class MockRecord:
|
||
def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at
|
||
|
||
for p in raw_delisted_items:
|
||
last_rec = session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first()
|
||
price = last_rec.price if last_rec else 0
|
||
delisted_items_with_price.append({'product': p, 'last_price': price})
|
||
|
||
file_path = exporter.generate_delisted_report(delisted_items_with_price, "今日下架商品")
|
||
finally:
|
||
session.close()
|
||
|
||
if file_path and os.path.exists(file_path):
|
||
return send_file(file_path, as_attachment=True)
|
||
return "無資料可匯出", 404
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Export] ❌ 篩選匯出失敗 | Type: {filter_type} | Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/excel/abc')
|
||
def export_abc_analysis():
|
||
"""API: 匯出 ABC 分析報表 (Excel)"""
|
||
try:
|
||
db = DatabaseManager()
|
||
table_name = 'realtime_sales_monthly'
|
||
|
||
# 1. 嘗試從快取讀取資料 (與 sales_analysis 共用快取)
|
||
df = None
|
||
cols_map = {}
|
||
|
||
if table_name in _SALES_PROCESSED_CACHE:
|
||
cache_data = _SALES_PROCESSED_CACHE[table_name]
|
||
df = cache_data['df']
|
||
cols_map = cache_data['cols']
|
||
else:
|
||
return "請先瀏覽「業績分析」頁面以載入資料與快取。", 400
|
||
|
||
# 恢復欄位變數
|
||
col_name = cols_map.get('name')
|
||
col_amount = cols_map.get('amount')
|
||
col_qty = cols_map.get('qty')
|
||
col_category = cols_map.get('category')
|
||
col_brand = cols_map.get('brand')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_activity = cols_map.get('activity')
|
||
col_payment = cols_map.get('payment')
|
||
col_price = cols_map.get('price')
|
||
col_cost = cols_map.get('cost')
|
||
col_profit = cols_map.get('profit')
|
||
col_date = cols_map.get('date')
|
||
col_pid = cols_map.get('pid') # V-New: 取得商品ID欄位
|
||
|
||
# 2. 篩選資料 (複製 sales_analysis 的篩選邏輯以確保結果一致)
|
||
selected_category = request.args.get('category', 'all')
|
||
selected_brand = request.args.get('brand', 'all')
|
||
selected_vendor = request.args.get('vendor', 'all')
|
||
selected_activity = request.args.get('activity', 'all')
|
||
selected_payment = request.args.get('payment', 'all')
|
||
selected_dow = request.args.get('dow', 'all')
|
||
selected_hour = request.args.get('hour', 'all')
|
||
selected_month = request.args.get('month', 'all')
|
||
keyword = request.args.get('keyword', '').strip()
|
||
min_price = request.args.get('min_price', '')
|
||
max_price = request.args.get('max_price', '')
|
||
min_margin = request.args.get('min_margin', '')
|
||
max_margin = request.args.get('max_margin', '')
|
||
|
||
target_df = df.copy() # 複製一份以免修改到快取
|
||
|
||
# 重新計算 Top N 分類 (用於 '其他' 篩選)
|
||
TOP_N_CATS = 12
|
||
top_cats_names = []
|
||
if col_category:
|
||
cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False)
|
||
if len(cat_group_all) > TOP_N_CATS:
|
||
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
|
||
|
||
if selected_category != 'all' and col_category:
|
||
if selected_category == '其他' and top_cats_names:
|
||
target_df = target_df[~target_df[col_category].isin(top_cats_names)]
|
||
else:
|
||
target_df = target_df[target_df[col_category] == selected_category]
|
||
|
||
if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand]
|
||
if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor]
|
||
if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity]
|
||
if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment]
|
||
if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)]
|
||
if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)]
|
||
if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month]
|
||
if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)]
|
||
if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)]
|
||
if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)]
|
||
if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)]
|
||
if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)]
|
||
|
||
# 3. 執行 ABC 分析與匯出
|
||
if col_amount and not target_df.empty:
|
||
# V-Fix: 同步 abc_analysis_detail 的聚合邏輯,確保匯出數據與網頁一致
|
||
agg_rules = {col_amount: 'sum'}
|
||
if col_qty: agg_rules[col_qty] = 'sum'
|
||
if col_cost: agg_rules[col_cost] = 'sum'
|
||
if col_profit: agg_rules[col_profit] = 'sum'
|
||
if col_category: agg_rules[col_category] = 'first'
|
||
if col_vendor: agg_rules[col_vendor] = 'first'
|
||
if col_brand: agg_rules[col_brand] = 'first'
|
||
if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID
|
||
|
||
# 執行聚合
|
||
df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index()
|
||
|
||
# 重新計算聚合後的毛利率
|
||
if col_profit:
|
||
df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
|
||
elif col_cost:
|
||
df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
|
||
else:
|
||
df_agg['calculated_margin_rate'] = 0.0
|
||
df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
|
||
|
||
# 排序與 ABC 分類
|
||
target_df = df_agg.sort_values(by=col_amount, ascending=False)
|
||
target_df['cumulative_revenue'] = target_df[col_amount].cumsum()
|
||
total_revenue = target_df[col_amount].sum()
|
||
target_df['cumulative_pct'] = (target_df['cumulative_revenue'] / total_revenue) * 100
|
||
|
||
conditions = [(target_df['cumulative_pct'] <= 80), (target_df['cumulative_pct'] <= 95)]
|
||
choices = ['A', 'B']
|
||
target_df['ABC_Class'] = np.select(conditions, choices, default='C')
|
||
|
||
# V-New: 支援依類別篩選匯出 (例如只匯出 A 類)
|
||
filter_class = request.args.get('class')
|
||
if filter_class:
|
||
target_df = target_df[target_df['ABC_Class'] == filter_class]
|
||
|
||
# V-New: 計算平均單價 (Avg Unit Price)
|
||
if col_qty:
|
||
target_df['avg_unit_price'] = (target_df[col_amount] / target_df[col_qty]).fillna(0)
|
||
|
||
# V-New: 計算建議補貨量 (支援自訂係數)
|
||
if col_qty:
|
||
custom_factor = request.args.get('factor')
|
||
if custom_factor:
|
||
try:
|
||
factor = float(custom_factor)
|
||
# 若有指定係數,則全體套用 (通常用於單一類別匯出)
|
||
target_df['suggested_restock'] = (target_df[col_qty] * factor).astype(int)
|
||
except:
|
||
# 格式錯誤則回退至預設邏輯
|
||
conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')]
|
||
choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2]
|
||
target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int)
|
||
else:
|
||
# 預設邏輯 (A=1.5, B=1.2, C=0)
|
||
conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')]
|
||
choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2]
|
||
target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int)
|
||
|
||
# 整理匯出欄位
|
||
export_cols = []
|
||
header_map = {}
|
||
if col_pid: export_cols.append(col_pid); header_map[col_pid] = '商品ID' # V-New: 匯出商品ID
|
||
if col_name: export_cols.append(col_name); header_map[col_name] = '商品名稱'
|
||
if col_category: export_cols.append(col_category); header_map[col_category] = '分類'
|
||
if col_brand: export_cols.append(col_brand); header_map[col_brand] = '品牌'
|
||
if col_vendor: export_cols.append(col_vendor); header_map[col_vendor] = '廠商'
|
||
export_cols.append('ABC_Class'); header_map['ABC_Class'] = 'ABC分類'
|
||
if col_amount: export_cols.append(col_amount); header_map[col_amount] = '銷售金額'
|
||
if col_qty: export_cols.append(col_qty); header_map[col_qty] = '銷售數量'
|
||
# V-Fix: 移除 col_price 匯出,因為聚合後的資料表不包含原始單價欄位 (已由 avg_unit_price 取代)
|
||
if 'avg_unit_price' in target_df.columns:
|
||
export_cols.append('avg_unit_price'); header_map['avg_unit_price'] = '平均單價'
|
||
if col_cost: export_cols.append(col_cost); header_map[col_cost] = '成本'
|
||
if col_profit: export_cols.append(col_profit); header_map[col_profit] = '毛利'
|
||
if 'calculated_margin_rate' in target_df.columns:
|
||
export_cols.append('calculated_margin_rate'); header_map['calculated_margin_rate'] = '毛利率(%)'
|
||
if 'suggested_restock' in target_df.columns:
|
||
export_cols.append('suggested_restock')
|
||
header_map['suggested_restock'] = '建議補貨量'
|
||
|
||
export_df = target_df[export_cols].rename(columns=header_map)
|
||
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
export_df.to_excel(writer, index=False, sheet_name='ABC分析')
|
||
output.seek(0)
|
||
|
||
filename_prefix = f"ABC_Analysis_{filter_class}_" if filter_class else "ABC_Analysis_"
|
||
return send_file(output, as_attachment=True, download_name=f"{filename_prefix}{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||
|
||
return "無資料可匯出", 404
|
||
except Exception as e:
|
||
sys_log.error(f"ABC Export Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/api/export/excel/vendor')
|
||
def export_vendor_analysis():
|
||
"""API: 匯出廠商獲利能力排行 (Excel)"""
|
||
try:
|
||
db = DatabaseManager()
|
||
table_name = 'realtime_sales_monthly'
|
||
|
||
# 1. 嘗試從快取讀取資料
|
||
df = None
|
||
cols_map = {}
|
||
|
||
if table_name in _SALES_PROCESSED_CACHE:
|
||
cache_data = _SALES_PROCESSED_CACHE[table_name]
|
||
df = cache_data['df']
|
||
cols_map = cache_data['cols']
|
||
else:
|
||
# V-Fix: 快取失效時,重定向到 sales_analysis 以重新載入資料
|
||
params = {k: v for k, v in request.args.items()}
|
||
flash('資料快取已失效,請稍候重新載入資料後再匯出。', 'warning')
|
||
return redirect(url_for('sales_analysis', **params))
|
||
|
||
# 恢復欄位變數
|
||
col_name = cols_map.get('name')
|
||
col_amount = cols_map.get('amount')
|
||
col_qty = cols_map.get('qty')
|
||
col_category = cols_map.get('category')
|
||
col_brand = cols_map.get('brand')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_activity = cols_map.get('activity')
|
||
col_payment = cols_map.get('payment')
|
||
col_price = cols_map.get('price')
|
||
col_cost = cols_map.get('cost')
|
||
col_profit = cols_map.get('profit')
|
||
col_date = cols_map.get('date')
|
||
|
||
if not col_vendor:
|
||
return "無法識別廠商欄位,無法匯出。", 400
|
||
|
||
# 2. 篩選資料 (複製 sales_analysis 的篩選邏輯)
|
||
selected_category = request.args.get('category', 'all')
|
||
selected_brand = request.args.get('brand', 'all')
|
||
selected_vendor = request.args.get('vendor', 'all')
|
||
selected_activity = request.args.get('activity', 'all')
|
||
selected_payment = request.args.get('payment', 'all')
|
||
selected_dow = request.args.get('dow', 'all')
|
||
selected_hour = request.args.get('hour', 'all')
|
||
selected_month = request.args.get('month', 'all')
|
||
keyword = request.args.get('keyword', '').strip()
|
||
min_price = request.args.get('min_price', '')
|
||
max_price = request.args.get('max_price', '')
|
||
min_margin = request.args.get('min_margin', '')
|
||
max_margin = request.args.get('max_margin', '')
|
||
|
||
target_df = df.copy()
|
||
|
||
# Top N 分類處理
|
||
TOP_N_CATS = 12
|
||
top_cats_names = []
|
||
if col_category:
|
||
cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False)
|
||
if len(cat_group_all) > TOP_N_CATS:
|
||
top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist()
|
||
|
||
if selected_category != 'all' and col_category:
|
||
if selected_category == '其他' and top_cats_names:
|
||
target_df = target_df[~target_df[col_category].isin(top_cats_names)]
|
||
else:
|
||
target_df = target_df[target_df[col_category] == selected_category]
|
||
|
||
if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand]
|
||
if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor]
|
||
if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity]
|
||
if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment]
|
||
if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)]
|
||
if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)]
|
||
if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month]
|
||
if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)]
|
||
if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)]
|
||
if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)]
|
||
if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)]
|
||
if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)]
|
||
|
||
# 3. 執行廠商聚合
|
||
if col_amount and not target_df.empty:
|
||
agg_dict = {col_amount: 'sum', col_name: 'nunique'}
|
||
if col_qty: agg_dict[col_qty] = 'sum' # V-Fix: 加入銷量聚合,否則無法計算 ASP
|
||
if col_profit:
|
||
agg_dict[col_profit] = 'sum'
|
||
elif col_cost:
|
||
agg_dict[col_cost] = 'sum'
|
||
|
||
vendor_group = target_df.groupby(col_vendor).agg(agg_dict).reset_index()
|
||
|
||
if col_profit:
|
||
vendor_group['total_profit'] = vendor_group[col_profit]
|
||
elif col_cost:
|
||
vendor_group['total_profit'] = vendor_group[col_amount] - vendor_group[col_cost]
|
||
else:
|
||
vendor_group['total_profit'] = 0
|
||
|
||
# V-Fix: 計算營收佔比 (Share %)
|
||
total_vendor_revenue = vendor_group[col_amount].sum()
|
||
vendor_group['revenue_share'] = (vendor_group[col_amount] / total_vendor_revenue * 100)
|
||
|
||
vendor_group['margin_rate'] = np.where(vendor_group[col_amount] > 0, (vendor_group['total_profit'] / vendor_group[col_amount] * 100), 0)
|
||
|
||
# V-Fix: 計算平均客單價 (ASP)
|
||
if col_qty:
|
||
vendor_group['asp'] = np.where(vendor_group[col_qty] > 0, vendor_group[col_amount] / vendor_group[col_qty], 0)
|
||
|
||
vendor_group['avg_sku_revenue'] = np.where(vendor_group[col_name] > 0, vendor_group[col_amount] / vendor_group[col_name], 0)
|
||
vendor_group = vendor_group.sort_values(by=col_amount, ascending=False)
|
||
|
||
# V-Fix: 更新匯出欄位以匹配儀表板
|
||
export_cols = [col_vendor, col_amount, 'revenue_share']
|
||
header_map = {col_vendor: '廠商名稱', col_amount: '總業績', 'revenue_share': '佔比(%)'}
|
||
|
||
if col_qty:
|
||
export_cols.extend([col_qty, 'asp'])
|
||
header_map.update({col_qty: '總銷量', 'asp': '平均客單(ASP)'})
|
||
|
||
export_cols.extend(['total_profit', 'margin_rate', col_name, 'avg_sku_revenue'])
|
||
header_map.update({'total_profit': '毛利額', 'margin_rate': '毛利率(%)', col_name: '商品數(SKU)', 'avg_sku_revenue': '平均單品產值'})
|
||
|
||
export_df = vendor_group[export_cols].rename(columns=header_map)
|
||
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
export_df.to_excel(writer, index=False, sheet_name='廠商排行')
|
||
output.seek(0)
|
||
return send_file(output, as_attachment=True, download_name=f"Vendor_Ranking_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||
return "無資料可匯出", 404
|
||
except Exception as e:
|
||
sys_log.error(f"Vendor Export Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
@app.route('/abc_analysis/detail')
|
||
def abc_analysis_detail():
|
||
"""ABC 分析詳細報表頁面"""
|
||
try:
|
||
target_class = request.args.get('class', 'A') # 預設 A 類
|
||
table_name = 'realtime_sales_monthly'
|
||
|
||
# 1. 生成與主頁面一致的 cache_key
|
||
data_range_months = int(request.args.get('data_range', '0') or '0')
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
|
||
# 2. 使用共用篩選函式取得資料
|
||
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
|
||
|
||
# V-Fix: 如果 cache_key 不存在,嘗試後補使用 table_name 固定鍵值
|
||
if err and table_name in _SALES_PROCESSED_CACHE:
|
||
target_df, cols_map, err = _get_filtered_sales_data(table_name)
|
||
|
||
if err:
|
||
# V-Fix: 如果自動重載也失敗,則顯示稍後再試,並引導回主頁面
|
||
return f'''
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>數據加載中 - WOOO TECH</title>
|
||
<style>
|
||
body {{ font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f8f9fa; }}
|
||
.card {{ background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); text-align: center; }}
|
||
.spinner {{ border: 3px solid #f3f3f3; border-top: 3px solid #1e3c72; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 0 auto 1rem; }}
|
||
@keyframes spin {{ 0% {{ transform: rotate(0deg); }} 100% {{ transform: rotate(360deg); }} }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="card">
|
||
<div class="spinner"></div>
|
||
<h3>數據準備中</h3>
|
||
<p>正在自動重新加載數據,請稍後...</p>
|
||
<script>
|
||
// 1.5 秒後嘗試重載當前頁面
|
||
setTimeout(function() {{
|
||
window.location.reload();
|
||
}}, 1500);
|
||
|
||
// 若重試 3 次仍失敗,引導回主頁
|
||
let retryCount = parseInt(sessionStorage.getItem('abc_retry') || '0');
|
||
if (retryCount > 3) {{
|
||
sessionStorage.removeItem('abc_retry');
|
||
alert('數據載入過久,請先在業績分析主頁重新整理。');
|
||
window.location.href = '/sales_analysis';
|
||
}} else {{
|
||
sessionStorage.setItem('abc_retry', retryCount + 1);
|
||
}}
|
||
</script>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
''', 200
|
||
|
||
# 恢復欄位變數
|
||
col_name = cols_map.get('name')
|
||
col_amount = cols_map.get('amount')
|
||
col_qty = cols_map.get('qty')
|
||
col_category = cols_map.get('category')
|
||
col_brand = cols_map.get('brand')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_price = cols_map.get('price')
|
||
col_cost = cols_map.get('cost')
|
||
col_profit = cols_map.get('profit')
|
||
col_date = cols_map.get('date')
|
||
col_pid = cols_map.get('pid')
|
||
|
||
|
||
# 3. 執行 ABC 分類
|
||
items = []
|
||
total_revenue = 0
|
||
if col_amount and not target_df.empty:
|
||
# V-Fix: 先針對商品進行聚合,確保 ABC 分析是基於「商品總銷量」而非「單筆訂單」
|
||
agg_rules = {col_amount: 'sum'}
|
||
if col_qty: agg_rules[col_qty] = 'sum'
|
||
if col_cost: agg_rules[col_cost] = 'sum'
|
||
if col_profit: agg_rules[col_profit] = 'sum'
|
||
if col_category: agg_rules[col_category] = 'first'
|
||
if col_vendor: agg_rules[col_vendor] = 'first'
|
||
if col_brand: agg_rules[col_brand] = 'first' # V-New: 加入品牌
|
||
if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID
|
||
if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique()))
|
||
|
||
df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index()
|
||
|
||
# 重新計算聚合後的毛利率
|
||
if col_profit:
|
||
df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
|
||
elif col_cost:
|
||
df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
|
||
else:
|
||
df_agg['calculated_margin_rate'] = 0.0
|
||
df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
|
||
|
||
# 執行 ABC 排序與計算
|
||
df_agg = df_agg.sort_values(by=col_amount, ascending=False)
|
||
df_agg['cumulative_revenue'] = df_agg[col_amount].cumsum()
|
||
total_revenue = df_agg[col_amount].sum()
|
||
df_agg['cumulative_pct'] = (df_agg['cumulative_revenue'] / total_revenue) * 100
|
||
|
||
conditions = [(df_agg['cumulative_pct'] <= 80), (df_agg['cumulative_pct'] <= 95)]
|
||
choices = ['A', 'B']
|
||
df_agg['ABC_Class'] = np.select(conditions, choices, default='C')
|
||
|
||
# 4. 篩選特定類別
|
||
class_df = df_agg[df_agg['ABC_Class'] == target_class].copy()
|
||
|
||
# V-New: 計算平均單價與庫存建議
|
||
if col_qty:
|
||
class_df['avg_unit_price'] = (class_df[col_amount] / class_df[col_qty]).fillna(0)
|
||
|
||
# V-New: 處理動態補貨係數
|
||
custom_factor = request.args.get('factor')
|
||
current_factor = 0.0
|
||
|
||
if custom_factor:
|
||
try:
|
||
current_factor = float(custom_factor)
|
||
except:
|
||
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
|
||
else:
|
||
current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0)
|
||
|
||
class_df['suggested_restock'] = (class_df[col_qty] * current_factor).astype(int)
|
||
|
||
items = class_df.to_dict('records')
|
||
|
||
# 準備標題與描述
|
||
class_info = {
|
||
'A': {'title': 'A 類 - 核心商品', 'desc': '營收佔比前 80% 的主力商品,建議重點備貨與監控。', 'color': 'danger'},
|
||
'B': {'title': 'B 類 - 次要商品', 'desc': '營收佔比 80%~95% 的輔助商品,維持正常庫存。', 'color': 'warning'},
|
||
'C': {'title': 'C 類 - 長尾商品', 'desc': '營收佔比最後 5% 的長尾商品,建議評估清倉或縮減 SKU。', 'color': 'success'}
|
||
}
|
||
info = class_info.get(target_class, {'title': f'{target_class} 類', 'desc': '', 'color': 'secondary'})
|
||
|
||
# 計算 DataTables 預設排序欄位 (銷售金額) 的索引
|
||
# 欄位順序: Rank(0), [PID], Name, [Brand], [Vendor], [Cat], [Margin], [AvgPrice, Qty, Restock], Amount
|
||
sort_col_index = 1 # Rank
|
||
if col_pid: sort_col_index += 1
|
||
sort_col_index += 1 # Name
|
||
if col_brand: sort_col_index += 1
|
||
if col_vendor: sort_col_index += 1
|
||
if col_category: sort_col_index += 1
|
||
if col_cost or col_profit: sort_col_index += 1
|
||
if col_qty: sort_col_index += 3
|
||
# 此時 sort_col_index 即為 Amount 欄位的索引
|
||
|
||
return render_template('abc_analysis_detail.html',
|
||
items=items,
|
||
info=info,
|
||
target_class=target_class,
|
||
current_factor=current_factor, # V-New: 傳遞當前係數
|
||
total_revenue=total_revenue,
|
||
sort_col_index=sort_col_index, # V-New: 傳遞排序欄位索引
|
||
cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category,
|
||
'vendor': col_vendor, 'brand': col_brand, 'cost': col_cost, 'profit': col_profit, 'date': col_date, 'pid': col_pid},
|
||
# 傳遞當前查詢參數以供匯出連結使用
|
||
query_string=request.query_string.decode())
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"ABC Detail Error: {e}")
|
||
return f"系統錯誤: {e}"
|
||
|
||
@app.route('/logs')
|
||
def show_logs():
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
return render_template('logs.html', datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'))
|
||
|
||
@app.route('/api/run_task', methods=['POST'])
|
||
def trigger_task():
|
||
try:
|
||
client_ip = request.remote_addr
|
||
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動執行請求 | IP: {client_ip}")
|
||
scheduled_job_wrapper()
|
||
return jsonify({"status": "success", "message": "爬蟲任務已在背景啟動"})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Task] ❌ 手動觸發任務失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/run_edm_task', methods=['POST'])
|
||
def trigger_edm_task():
|
||
"""🚩 新增:手動觸發 EDM 爬蟲任務"""
|
||
try:
|
||
target_lpn = "O1K5FBOqsvN" # 預設活動代碼
|
||
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 EDM 執行請求 | LPN: {target_lpn}")
|
||
|
||
# V-Fix: 強制重載 scheduler 模組,確保讀取到最新的截圖與通知邏輯
|
||
import importlib
|
||
import scheduler
|
||
importlib.reload(scheduler)
|
||
|
||
# 使用執行緒啟動,避免卡住 Web Server
|
||
task_thread = threading.Thread(target=scheduler.run_edm_task, args=(target_lpn,))
|
||
task_thread.daemon = True
|
||
task_thread.start()
|
||
|
||
return jsonify({"status": "success", "message": f"EDM 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Task] ❌ 手動觸發 EDM 任務失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/run_festival_task', methods=['POST'])
|
||
def trigger_festival_task():
|
||
"""🚩 新增:手動觸發 1.1 狂歡購物節爬蟲任務"""
|
||
try:
|
||
target_lpn = "O7ylWfihYUM"
|
||
sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 Festival 執行請求 | LPN: {target_lpn}")
|
||
|
||
# 使用執行緒啟動,避免卡住 Web Server
|
||
task_thread = threading.Thread(target=run_festival_task, args=(target_lpn,))
|
||
task_thread.daemon = True
|
||
task_thread.start()
|
||
|
||
return jsonify({"status": "success", "message": f"Festival 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Task] ❌ 手動觸發 Festival 任務失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/trigger_momo_notification', methods=['POST'])
|
||
def trigger_momo_notification():
|
||
"""🚩 新增:手動觸發商品看板通知"""
|
||
try:
|
||
# 強制重載通知模組
|
||
import importlib
|
||
import scheduler
|
||
import services.notification_manager
|
||
importlib.reload(scheduler)
|
||
importlib.reload(services.notification_manager)
|
||
from services.notification_manager import NotificationManager
|
||
|
||
# 1. 取得統計數據
|
||
stats = get_dashboard_stats()
|
||
|
||
# 2. 截取儀表板畫面
|
||
dashboard_url = "http://127.0.0.1/"
|
||
screenshot_path = scheduler.capture_page_screenshot(dashboard_url, "momo_dashboard")
|
||
|
||
# 3. 發送通知
|
||
notifier = NotificationManager()
|
||
sys_log.info(f"[Web] [Notification] 📢 手動觸發 MOMO 通知")
|
||
notifier.send_momo_report(stats, screenshot_path)
|
||
|
||
return jsonify({"status": "success", "message": "已發送商品看板通知"})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/trigger_edm_notification', methods=['POST'])
|
||
def trigger_edm_notification():
|
||
"""🚩 新增:手動觸發 EDM 比價通知 (不重爬,僅重發)"""
|
||
try:
|
||
# V-Fix: 強制重新載入設定與通知模組,確保讀取到最新的 LINE ID (避免快取舊資料)
|
||
import importlib
|
||
import config
|
||
import services.notification_manager
|
||
import services.edm_notifier # V-New: 導入新的通知模組
|
||
importlib.reload(config)
|
||
importlib.reload(services.notification_manager)
|
||
importlib.reload(services.edm_notifier)
|
||
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
# V-Fix: 改為只抓取最新一批次的異動資料,避免訊息過長
|
||
# 1. 找出最新的 batch_id
|
||
latest_batch_tuple = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first()
|
||
|
||
if not latest_batch_tuple:
|
||
return jsonify({"status": "warning", "message": "目前無 EDM 商品資料,請先執行爬蟲"}), 400
|
||
|
||
latest_batch_id = latest_batch_tuple[0]
|
||
|
||
# 2. 取得最新批次的所有異動商品
|
||
products = session.query(PromoProduct).filter(PromoProduct.batch_id == latest_batch_id).all()
|
||
|
||
if not products:
|
||
return jsonify({"status": "info", "message": "最新一輪掃描中無任何商品異動"}), 200
|
||
|
||
# V-Fix: 手動觸發時,嘗試尋找對應的截圖檔案
|
||
screenshot_path = None
|
||
try:
|
||
filename = f"edm_{latest_batch_id}.png"
|
||
potential_path = os.path.join(BASE_DIR, 'web/static/screenshots', filename)
|
||
if os.path.exists(potential_path):
|
||
screenshot_path = potential_path
|
||
except Exception: pass
|
||
|
||
from services.edm_notifier import EdmNotifier
|
||
notifier = EdmNotifier()
|
||
sys_log.info(f"[Web] [Notification] 📢 手動觸發 EDM 通知 | Count: {len(products)} | BatchID: {latest_batch_id}")
|
||
notifier.send_edm_report(products, screenshot_path)
|
||
|
||
return jsonify({"status": "success", "message": f"已針對最新批次的 {len(products)} 筆商品異動發送通知"})
|
||
finally:
|
||
session.close()
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/test_notification', methods=['POST'])
|
||
def test_notification():
|
||
"""🚩 新增:測試訊息通知功能"""
|
||
try:
|
||
from services.notification_manager import NotificationManager
|
||
import config
|
||
import requests
|
||
notifier = NotificationManager()
|
||
|
||
# --- 🕵️♂️ V9.13 更新:Messaging API 診斷邏輯 ---
|
||
sys_log.info("[Web] [Notification] 🕵️♂️ 執行手動通知發送測試 (Line/Telegram/Email)...")
|
||
|
||
token = getattr(config, 'LINE_CHANNEL_ACCESS_TOKEN', None)
|
||
target_id = getattr(config, 'LINE_GROUP_ID', None)
|
||
|
||
if token and target_id:
|
||
sys_log.info(f"[Web] [Notification] 🔑 偵測到 Channel Token: {token[:4]}...{token[-4:]}")
|
||
sys_log.info(f"[Web] [Notification] 🎯 目標 ID: {target_id}")
|
||
|
||
# 2. 嘗試直接發送請求
|
||
try:
|
||
headers = {
|
||
"Authorization": f"Bearer {token}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
payload = {
|
||
"to": target_id,
|
||
"messages": [
|
||
{
|
||
"type": "text",
|
||
"text": "🧪 這是系統診斷測試訊息 (Messaging API)\n\n✅ 連線測試成功!"
|
||
}
|
||
]
|
||
}
|
||
|
||
sys_log.info("[Web] [Notification] 📡 正在嘗試連線至 Line Messaging API (push)...")
|
||
resp = requests.post("https://api.line.me/v2/bot/message/push", headers=headers, json=payload, timeout=10)
|
||
|
||
sys_log.info(f"[Web] [Notification] 📩 Line API 回應 | Code: {resp.status_code}")
|
||
sys_log.info(f"[Web] [Notification] 📄 Line API 內容 | Body: {resp.text}")
|
||
|
||
if resp.status_code != 200:
|
||
return jsonify({"status": "error", "message": f"❌ Line API 拒絕連線: {resp.status_code} - {resp.text}"}), 400
|
||
except Exception as req_err:
|
||
sys_log.error(f"[Web] [Notification] ❌ 直接連線測試發生異常 | Error: {req_err}")
|
||
return jsonify({"status": "error", "message": f"連線異常: {req_err}"}), 500
|
||
else:
|
||
sys_log.warning("[Web] [Notification] ⚠️ 無法偵測到 Messaging API 設定 (Token 或 Group ID 缺失)")
|
||
return jsonify({"status": "error", "message": "設定檔缺少 LINE_CHANNEL_ACCESS_TOKEN 或 LINE_GROUP_ID"}), 400
|
||
|
||
# 🚩 V9.14 修改:呼叫真實的日報發送邏輯
|
||
notifier.send_daily_report()
|
||
|
||
return jsonify({"status": "success", "message": "✅ 當日異動通知已發送 (Line/Telegram/Email)"})
|
||
except ImportError:
|
||
return jsonify({"status": "error", "message": "❌ 找不到 NotificationManager 模組"}), 500
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Notification] ❌ 測試通知失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": f"發送失敗: {str(e)}"}), 500
|
||
|
||
@app.route('/api/logs')
|
||
def get_logs_api():
|
||
if os.path.exists(LOG_FILE_PATH):
|
||
try:
|
||
with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f:
|
||
return jsonify({"logs": "".join(f.readlines()[-60:])})
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Logs] ❌ 日誌 API 讀取異常 | Error: {e}")
|
||
return jsonify({"logs": "讀取日誌異常"})
|
||
return jsonify({"logs": "等待系統啟動中..."})
|
||
|
||
# 🚩 V9.82: 新增歷史價格 API
|
||
@app.route('/api/history/<int:product_id>')
|
||
def get_price_history(product_id):
|
||
"""API: 取得商品過去 180 天的價格歷史"""
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
# 計算 180 天前的日期
|
||
start_date = datetime.now(TAIPEI_TZ).replace(tzinfo=None) - timedelta(days=180)
|
||
|
||
records = session.query(PriceRecord).filter(
|
||
PriceRecord.product_id == product_id,
|
||
PriceRecord.timestamp >= start_date
|
||
).order_by(PriceRecord.timestamp).all()
|
||
|
||
data = [{
|
||
't': r.timestamp.strftime('%Y-%m-%d %H:%M'),
|
||
'p': r.price
|
||
} for r in records]
|
||
|
||
return jsonify(data)
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [History] ❌ 獲取歷史價格失敗 | ProductID: {product_id} | Error: {e}")
|
||
return jsonify([]), 500
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/api/price_change_details')
|
||
def get_price_change_details():
|
||
"""API: V9.4 取得價格變動商品明細 (供彈窗使用) - 修正:改用與儀表板相同的邏輯"""
|
||
filter_type = request.args.get('type', '')
|
||
filter_category = request.args.get('category', '')
|
||
filter_product_id = request.args.get('product_id', '')
|
||
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
# 取得今日起始時間
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
|
||
|
||
# 基礎查詢:取得所有商品的最新記錄 (與儀表板相同邏輯)
|
||
latest_records_subq = session.query(
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
query = session.query(PriceRecord, Product).join(
|
||
latest_records_subq,
|
||
PriceRecord.id == latest_records_subq.c.max_id
|
||
).join(Product, PriceRecord.product_id == Product.id)
|
||
|
||
# 一次性查詢所有商品的「今日之前最後價格」(yesterday_prices_map)
|
||
product_ids = [r[0] for r in session.query(PriceRecord.product_id).join(
|
||
latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id
|
||
).all()]
|
||
|
||
yesterday_prices_subq = session.query(
|
||
PriceRecord.product_id,
|
||
func.max(PriceRecord.id).label('max_id')
|
||
).filter(
|
||
PriceRecord.product_id.in_(product_ids),
|
||
PriceRecord.timestamp < today_start
|
||
).group_by(PriceRecord.product_id).subquery()
|
||
|
||
yesterday_prices_q = session.query(
|
||
PriceRecord.product_id, PriceRecord.price
|
||
).join(
|
||
yesterday_prices_subq,
|
||
PriceRecord.id == yesterday_prices_subq.c.max_id
|
||
)
|
||
yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q}
|
||
|
||
# 根據 filter_type 進行篩選
|
||
products = []
|
||
|
||
if filter_type == 'increase':
|
||
# 漲價商品 - 比對今日最新價格與今日之前的最後價格
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price > old_price:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': old_price,
|
||
'current_price': record.price,
|
||
'change': record.price - old_price,
|
||
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
|
||
elif filter_type == 'decrease':
|
||
# 降價商品
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price < old_price:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': old_price,
|
||
'current_price': record.price,
|
||
'change': record.price - old_price,
|
||
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
|
||
elif filter_type == 'delisted':
|
||
# 下架商品 (今日狀態為 INACTIVE 且今天更新的)
|
||
today_delisted = session.query(Product).filter(
|
||
Product.status == 'INACTIVE',
|
||
Product.updated_at >= today_start
|
||
).all()
|
||
|
||
for product in today_delisted:
|
||
last_record = session.query(PriceRecord).filter(
|
||
PriceRecord.product_id == product.id
|
||
).order_by(PriceRecord.timestamp.desc()).first()
|
||
|
||
if last_record:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': last_record.price,
|
||
'current_price': 0,
|
||
'change': 0,
|
||
'update_time': last_record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
|
||
elif filter_type == 'active':
|
||
# 活躍商品 (今日有價格變動的)
|
||
for record, product in query.all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': old_price,
|
||
'current_price': record.price,
|
||
'change': record.price - old_price,
|
||
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
|
||
elif filter_type == 'category' and filter_category:
|
||
# 特定分類的變動商品
|
||
for record, product in query.filter(Product.category == filter_category).all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': old_price,
|
||
'current_price': record.price,
|
||
'change': record.price - old_price,
|
||
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
|
||
elif filter_type == 'max_change' and filter_product_id:
|
||
# 最大變動商品 - 只顯示指定的單一商品
|
||
for record, product in query.filter(Product.i_code == filter_product_id).all():
|
||
old_price = yesterday_prices_map.get(product.id)
|
||
if old_price is not None and record.price != old_price:
|
||
products.append({
|
||
'product_id': product.i_code,
|
||
'name': product.name,
|
||
'category': product.category,
|
||
'url': product.url,
|
||
'image_url': product.image_url or '/static/placeholder.png',
|
||
'old_price': old_price,
|
||
'current_price': record.price,
|
||
'change': record.price - old_price,
|
||
'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M')
|
||
})
|
||
break # 只需要一件商品
|
||
|
||
return jsonify({'products': products})
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [PriceChangeDetails] ❌ 獲取價格變動明細失敗 | Type: {filter_type} | Error: {e}")
|
||
return jsonify({'products': []}), 500
|
||
finally:
|
||
session.close()
|
||
|
||
@app.route('/api/backup', methods=['POST'])
|
||
def trigger_backup():
|
||
"""API: 觸發系統完整備份"""
|
||
# Note: [功能] 尚未實作「系統還原」功能 (Restore),需評估安全性後加入
|
||
try:
|
||
sys_log.info("[System] [Backup] 💾 開始執行系統完整備份...")
|
||
backup_dir = os.path.join(BASE_DIR, 'backups')
|
||
if not os.path.exists(backup_dir):
|
||
os.makedirs(backup_dir)
|
||
|
||
timestamp = datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')
|
||
zip_filename = f"momo_system_backup_{SYSTEM_VERSION}_{timestamp}.zip"
|
||
zip_filepath = os.path.join(backup_dir, zip_filename)
|
||
|
||
with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||
for root, dirs, files in os.walk(BASE_DIR):
|
||
# 排除不必要的目錄
|
||
dirs[:] = [d for d in dirs if d not in ['backups', '__pycache__', 'venv', '.git', '.idea', '.vscode', 'node_modules']]
|
||
|
||
for file in files:
|
||
if file == zip_filename: continue # 跳過正在寫入的檔案
|
||
if file.endswith('.pyc') or file.endswith('.DS_Store'): continue
|
||
|
||
file_path = os.path.join(root, file)
|
||
arcname = os.path.relpath(file_path, BASE_DIR)
|
||
zipf.write(file_path, arcname)
|
||
|
||
sys_log.info(f"[System] [Backup] ✅ 系統備份完成 | File: {zip_filename}")
|
||
|
||
# V-New: 回傳下載連結
|
||
download_url = url_for('download_backup', filename=zip_filename)
|
||
|
||
return jsonify({
|
||
"status": "success",
|
||
"message": f"備份成功!\n檔案已儲存為: {zip_filename}\n即將開始下載...",
|
||
"download_url": download_url
|
||
})
|
||
except Exception as e:
|
||
sys_log.error(f"[System] [Backup] ❌ 備份失敗 | Error: {e}")
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/api/backup/download/<path:filename>')
|
||
def download_backup(filename):
|
||
"""
|
||
API: 下載備份檔案(已加入路徑遍歷防護)
|
||
"""
|
||
try:
|
||
backup_dir = os.path.join(BASE_DIR, 'backups')
|
||
# 使用 safe_join 驗證路徑,防止路徑遍歷攻擊
|
||
safe_path = safe_join(backup_dir, filename)
|
||
|
||
# 確保檔案存在
|
||
if not safe_path.exists():
|
||
sys_log.warning(f"[Security] 備份檔案不存在 | File: {filename}")
|
||
return jsonify({'error': '檔案不存在'}), 404
|
||
|
||
# 確保是檔案而非目錄
|
||
if not safe_path.is_file():
|
||
sys_log.warning(f"[Security] 嘗試下載非檔案路徑 | Path: {filename}")
|
||
return jsonify({'error': '非法路徑'}), 400
|
||
|
||
return send_from_directory(backup_dir, safe_path.name, as_attachment=True)
|
||
|
||
except ValueError as e:
|
||
# safe_join 偵測到路徑遍歷嘗試
|
||
sys_log.error(f"[Security] 路徑遍歷攻擊嘗試被阻擋 | Filename: {filename} | Error: {e}")
|
||
return jsonify({'error': '非法路徑'}), 400
|
||
except Exception as e:
|
||
sys_log.error(f"[System] 下載備份失敗 | Error: {e}")
|
||
return jsonify({'error': '下載失敗'}), 500
|
||
|
||
@app.route('/api/import_excel', methods=['POST'])
|
||
def import_excel():
|
||
"""
|
||
API: 匯入 Excel/CSV 並自動建表
|
||
已加入檔案上傳安全驗證 (副檔名白名單、檔案名稱清理)
|
||
"""
|
||
try:
|
||
# 1. 檢查是否有上傳檔案
|
||
if 'file' not in request.files:
|
||
return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400
|
||
|
||
file = request.files['file']
|
||
|
||
# 2. 使用安全驗證函數
|
||
is_valid, error_msg, safe_name = validate_upload_file(file)
|
||
if not is_valid:
|
||
sys_log.warning(f"[Security] 檔案上傳驗證失敗 | Filename: {file.filename} | Error: {error_msg}")
|
||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||
|
||
sys_log.info(f"[Web] [Import] 檔案上傳驗證通過 | Original: {file.filename} | Safe: {safe_name}")
|
||
|
||
# 3. 根據副檔名讀取檔案
|
||
df = None
|
||
filename_lower = safe_name.lower()
|
||
|
||
if filename_lower.endswith(('.xlsx', '.xls')):
|
||
try:
|
||
df = pd.read_excel(file, engine='openpyxl', dtype=str)
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500
|
||
elif filename_lower.endswith('.csv'):
|
||
try:
|
||
# V-New: 嘗試用多種編碼讀取 CSV
|
||
try:
|
||
df = pd.read_csv(file, dtype=str)
|
||
except UnicodeDecodeError:
|
||
file.seek(0) # 重置文件指針
|
||
df = pd.read_csv(file, encoding='big5', dtype=str)
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': f'CSV 讀取失敗: {str(e)}'}), 500
|
||
else:
|
||
# 理論上不會到這裡,因為 validate_upload_file 已經檢查過
|
||
return jsonify({'status': 'error', 'message': '不支援的檔案格式'}), 400
|
||
|
||
if df is None:
|
||
return jsonify({'status': 'error', 'message': '無法讀取檔案內容'}), 500
|
||
|
||
# V-New: 增加日誌以確認目前為原始匯入模式 (提醒使用者已略過清理)
|
||
sys_log.info("[Web] [Import] ⚠️ 偵測到原始匯入模式 (Raw Import Mode) - 已略過智慧清理")
|
||
|
||
# V-Fix: 1. 先標準化欄位名稱,確保後續關鍵字比對準確
|
||
# df.columns = [str(c).strip().replace(' ', '_').replace('-', '_').replace('(', '').replace(')', '').replace('/', '_') for c in df.columns]
|
||
|
||
# V-Fix: 2. 執行智慧資料清理 (v3 保守模式 - 解決 'F' 被強制轉 0 的問題)
|
||
# sys_log.info("[Web] [Import] 執行智慧資料清理程序 (v3 保守模式)...")
|
||
|
||
# 定義必須是數值的欄位關鍵字 (這些欄位必須是數字,髒資料轉 0 以免影響計算)
|
||
# numeric_keywords = ['序號', '數量', '單價', '金額', '成本', '毛利', '售價', '應收', '營收',
|
||
# 'Quantity', 'Qty', 'Price', 'Amount', 'Cost', 'Profit', 'Sales', 'Revenue']
|
||
|
||
# for col in df.columns:
|
||
# # 判斷是否為強制數值欄位
|
||
# is_force_numeric = any(k in col for k in numeric_keywords)
|
||
|
||
# if df[col].dtype == 'object':
|
||
# if is_force_numeric:
|
||
# # 策略 A: 強制數值欄位 -> 激進清理 (保留數字,其餘轉 0)
|
||
# # 先移除千分位逗號等非數值字符
|
||
# cleaned_series = df[col].astype(str).str.replace(r'[^\d.-]', '', regex=True)
|
||
# converted_series = pd.to_numeric(cleaned_series, errors='coerce')
|
||
# df[col] = converted_series.fillna(0)
|
||
# sys_log.info(f"[Web] [Import] 強制清理數值欄位 '{col}' (髒資料已轉為 0)")
|
||
# else:
|
||
# # 策略 B: 一般欄位 -> 保守檢查 (保留 'F' 等文字)
|
||
# # 直接嘗試轉換,不移除文字
|
||
# converted_series = pd.to_numeric(df[col], errors='coerce')
|
||
|
||
# # 檢查有多少值變成了 NaN (原本不是 NaN/空字串,但轉換後變成 NaN 的)
|
||
# original_valid_mask = df[col].notna() & (df[col].astype(str).str.strip() != '')
|
||
# converted_valid_mask = converted_series.notna()
|
||
# loss_count = (original_valid_mask & ~converted_valid_mask).sum()
|
||
|
||
# if loss_count == 0:
|
||
# # 如果沒有資料損失 (代表全是數字或空值),才轉換
|
||
# df[col] = converted_series
|
||
# else:
|
||
# # 有資料損失 (例如包含 'F'),保留為文字
|
||
# sys_log.info(f"[Web] [Import] 欄位 '{col}' 保留為文字 (含 {loss_count} 筆非數值資料,如 'F')")
|
||
|
||
# 識別檔案類型
|
||
is_daily_sales = '即時業績' in file.filename and '當日' in file.filename
|
||
is_sales_report = '即時業績' in file.filename and '全月' in file.filename
|
||
|
||
if is_daily_sales:
|
||
table_name = 'daily_sales_snapshot'
|
||
|
||
# V-New: 智慧匯入 - 根據 Excel 內的日期欄位自動拆分 snapshot_date
|
||
date_col = None
|
||
for possible_col in ['日期', '訂單日期', '交易日期', 'Date']:
|
||
if possible_col in df.columns:
|
||
date_col = possible_col
|
||
break
|
||
|
||
if date_col:
|
||
# 使用 Excel 內的日期欄位作為 snapshot_date
|
||
sys_log.info(f"[Web] [Import] 使用 Excel 內的「{date_col}」欄位作為快照日期")
|
||
|
||
# 將日期欄位轉換為標準格式 YYYY-MM-DD
|
||
df['snapshot_date'] = pd.to_datetime(df[date_col], errors='coerce').dt.strftime('%Y-%m-%d')
|
||
|
||
# 移除無效日期的資料
|
||
invalid_count = df['snapshot_date'].isna().sum()
|
||
if invalid_count > 0:
|
||
sys_log.warning(f"[Web] [Import] 發現 {invalid_count} 筆無效日期資料,已移除")
|
||
df = df.dropna(subset=['snapshot_date'])
|
||
|
||
unique_dates = df['snapshot_date'].nunique()
|
||
sys_log.info(f"[Web] [Import] 識別為當日業績報表,包含 {unique_dates} 個不同日期")
|
||
else:
|
||
# 備用方案:從檔名提取日期
|
||
snapshot_date = extract_snapshot_date_from_filename(file.filename)
|
||
if not snapshot_date:
|
||
return jsonify({'status': 'error', 'message': '無法從檔名提取日期,且 Excel 中無日期欄位'}), 400
|
||
df['snapshot_date'] = snapshot_date
|
||
sys_log.info(f"[Web] [Import] Excel 無日期欄位,使用檔名日期: {snapshot_date}")
|
||
elif is_sales_report:
|
||
table_name = 'realtime_sales_monthly'
|
||
else:
|
||
filename_no_ext = os.path.splitext(file.filename)[0]
|
||
table_name = re.sub(r'[^\w\u4e00-\u9fff]+', '_', filename_no_ext).strip('_')
|
||
|
||
if not table_name: table_name = f"import_{int(time.time())}"
|
||
|
||
db = DatabaseManager()
|
||
engine = db.engine
|
||
|
||
# V-Debug: 顯示實際寫入的資料庫路徑
|
||
sys_log.info(f"[Web] [Import] 正在寫入資料庫: {engine.url}")
|
||
|
||
if table_name in ['realtime_sales_monthly', 'daily_sales_snapshot']:
|
||
try:
|
||
# V-Fix: 實作自動去重邏輯 (Deduplication)
|
||
# 1. 檢查資料表是否存在
|
||
inspector = inspect(engine)
|
||
if not inspector.has_table(table_name):
|
||
sys_log.info(f"[Web] [Import] 資料表不存在,建立新表: {table_name}")
|
||
df.to_sql(table_name, con=engine, if_exists='replace', index=False)
|
||
rows_imported = len(df)
|
||
message = f'匯入成功!已建立新資料表並寫入 {rows_imported} 筆資料。'
|
||
else:
|
||
sys_log.info(f"[Web] [Import] 資料表已存在,執行自動去重 (Deduplication)...")
|
||
|
||
# 2. 讀取現有資料(優化:僅讀取相關日期的資料以進行去重)
|
||
try:
|
||
# 嘗試根據 incoming df 的日期範圍來過濾現有資料
|
||
filter_clause = ""
|
||
if '日期' in df.columns:
|
||
# V-Fix: 確保日期格式與資料庫一致 (YYYY/MM/DD) 以便 SQL IN 查詢能正確比對
|
||
# 有時 Pandas 會將其轉換為 datetime 或 2024-01-01 格式
|
||
temp_dates = pd.to_datetime(df['日期'], errors='coerce')
|
||
unique_dates = temp_dates.dropna().dt.strftime('%Y/%m/%d').unique()
|
||
|
||
if len(unique_dates) > 0:
|
||
date_list = "', '".join([str(d) for d in unique_dates])
|
||
filter_clause = f" WHERE 日期 IN ('{date_list}')"
|
||
sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(unique_dates)} 個日期相關的現有資料 (範例: {unique_dates[0]})")
|
||
elif 'snapshot_date' in df.columns:
|
||
unique_dates = df['snapshot_date'].dropna().unique()
|
||
if len(unique_dates) > 0:
|
||
date_list = "', '".join([str(d) for d in unique_dates])
|
||
filter_clause = f" WHERE snapshot_date IN ('{date_list}')"
|
||
sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(unique_dates)} 個快照日期相關的現有資料")
|
||
|
||
if filter_clause:
|
||
# V-Security: 使用參數化查詢,防止 SQL Injection
|
||
# 注意:SQLAlchemy 的 text() 配合綁定參數
|
||
# 針對 'IN' 查詢,SQLite 支援綁定多個參數
|
||
try:
|
||
from sqlalchemy import text
|
||
|
||
# 準備參數字典,例如 {'d0': '2024/01/01', 'd1': '2024/01/02', ...}
|
||
params = {f"d{i}": str(d) for i, d in enumerate(unique_dates)}
|
||
param_names = ", ".join([f":d{i}" for i in range(len(unique_dates))])
|
||
|
||
date_col = "日期" if '日期' in df.columns else "snapshot_date"
|
||
sql = text(f"SELECT * FROM {table_name} WHERE {date_col} IN ({param_names})")
|
||
|
||
# V-Debug
|
||
# sys_log.debug(f"[Web] [Import] SQL Filter: {sql} with {len(params)} params")
|
||
|
||
# Pandas read_sql 支援與參數一起執行
|
||
df_existing = pd.read_sql(sql, con=engine, params=params)
|
||
except Exception as sql_err:
|
||
sys_log.error(f"[Web] [Import] 建立參數化 SQL 失敗: {sql_err}")
|
||
# 備用方案 (Sanitized)
|
||
df_existing = safe_read_sql(table_name, engine=engine)
|
||
else:
|
||
# 備用方案:若無日期欄位,仍讀取全表
|
||
sys_log.warning(f"[Web] [Import] ⚠️ 無法根據日期過濾,讀取全表進行去重 (可能效能較差)")
|
||
df_existing = safe_read_sql(table_name, engine=engine)
|
||
|
||
except Exception as e:
|
||
sys_log.warning(f"[Web] [Import] ⚠️ 讀取舊資料失敗 ({e}),略過去重直接累加。")
|
||
df_existing = pd.DataFrame()
|
||
|
||
rows_to_write = df
|
||
|
||
if not df_existing.empty:
|
||
# 3. 執行比對 (找出共有欄位)
|
||
common_cols = list(set(df.columns) & set(df_existing.columns))
|
||
|
||
# 針對 daily_sales_snapshot 使用特定去重鍵
|
||
if table_name == 'daily_sales_snapshot':
|
||
# 優先使用 snapshot_date + 訂單編號
|
||
if 'snapshot_date' in common_cols and '訂單編號' in common_cols:
|
||
common_cols = ['snapshot_date', '訂單編號']
|
||
sys_log.info(f"[Web] [Import] 使用去重鍵: snapshot_date + 訂單編號")
|
||
elif 'snapshot_date' in common_cols:
|
||
# 備用方案:使用所有共有欄位
|
||
sys_log.info(f"[Web] [Import] 使用全欄位去重 (共 {len(common_cols)} 個欄位)")
|
||
|
||
if common_cols:
|
||
# 轉換為字串以確保比對準確 (處理 NaN 與型別差異)
|
||
# V-Fix: 加強去重邏輯,處理 '100.0' vs '100' 的問題
|
||
def normalize_series(s):
|
||
return s.astype(str).str.strip().str.replace(r'\.0$', '', regex=True)
|
||
|
||
df_str = df[common_cols].apply(normalize_series).fillna('')
|
||
existing_str = df_existing[common_cols].apply(normalize_series).fillna('')
|
||
|
||
# 移除 df_existing 中的重複項 (優化 merge 效能)
|
||
existing_str = existing_str.drop_duplicates()
|
||
|
||
# 使用 merge 找出 df 中已存在的資料
|
||
merged = df_str.merge(existing_str, on=common_cols, how='left', indicator=True)
|
||
|
||
# 只保留 'left_only' 的資料 (即新資料)
|
||
rows_to_write = df[merged['_merge'] == 'left_only']
|
||
|
||
duplicates_count = len(df) - len(rows_to_write)
|
||
sys_log.info(f"[Web] [Import] 🔍 自動去重: 發現 {duplicates_count} 筆重複資料,已忽略。")
|
||
|
||
# 4. 寫入新資料
|
||
if not rows_to_write.empty:
|
||
rows_to_write.to_sql(table_name, con=engine, if_exists='append', index=False)
|
||
rows_imported = len(rows_to_write)
|
||
message = f'匯入成功!已去重並新增 {rows_imported} 筆資料。'
|
||
else:
|
||
rows_imported = 0
|
||
message = '匯入完成,但所有資料皆已存在 (重複),無新增數據。'
|
||
|
||
# V-Fix: 無條件清除快取,確保行事曆能夠顯示最新資料
|
||
# 原問題:只有 rows_imported > 0 時才清除快取,導致匯入後行事曆不更新
|
||
if table_name in _SALES_DF_CACHE:
|
||
del _SALES_DF_CACHE[table_name]
|
||
sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}")
|
||
|
||
# V-Opt: 清除所有相關的處理後快取(包含不同 data_range 的快取)
|
||
cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)]
|
||
for cache_key in cache_keys_to_delete:
|
||
del _SALES_PROCESSED_CACHE[cache_key]
|
||
sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}")
|
||
|
||
return jsonify({'status': 'success', 'message': message, 'rows': rows_imported, 'table': table_name})
|
||
|
||
except Exception as de:
|
||
sys_log.error(f"[Web] [Import] 業績報表匯入去重或寫入時發生錯誤: {de}")
|
||
return jsonify({'status': 'error', 'message': f'業績報表匯入失敗: {de}'}), 500
|
||
else:
|
||
# 對於非業績報表,維持覆蓋邏輯
|
||
sys_log.info(f"[Web] [Import] 使用覆蓋模式 (replace)寫入資料表: {table_name}")
|
||
df.to_sql(table_name, con=engine, if_exists='replace', index=False)
|
||
|
||
if table_name in _SALES_DF_CACHE:
|
||
del _SALES_DF_CACHE[table_name]
|
||
sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}")
|
||
|
||
# V-Opt: 清除所有相關的處理後快取
|
||
cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)]
|
||
for cache_key in cache_keys_to_delete:
|
||
del _SALES_PROCESSED_CACHE[cache_key]
|
||
sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}")
|
||
|
||
return jsonify({'status': 'success', 'message': f'通用匯入成功!資料已覆蓋至 {table_name}。', 'rows': len(df), 'table': table_name})
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Import] ❌ 檔案匯入發生嚴重錯誤 | Error: {str(e)}")
|
||
return jsonify({'status': 'error', 'message': f'檔案匯入失敗: {str(e)}'}), 500
|
||
|
||
@app.route('/api/import/monthly_summary', methods=['POST'])
|
||
def import_monthly_summary():
|
||
"""API: 匯入月份總表數據分析"""
|
||
try:
|
||
if 'file' not in request.files:
|
||
return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400
|
||
|
||
file = request.files['file']
|
||
is_valid, error_msg, safe_name = validate_upload_file(file)
|
||
if not is_valid:
|
||
sys_log.warning(f"[Security] 月份總表上傳驗證失敗: {error_msg}")
|
||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||
|
||
# 讀取 Excel
|
||
try:
|
||
df = pd.read_excel(file, engine='openpyxl')
|
||
except Exception as e:
|
||
return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500
|
||
|
||
if df.empty:
|
||
return jsonify({'status': 'error', 'message': '檔案內容為空'}), 400
|
||
|
||
# 欄位對照表 (對應 Excel 繁體中文標題與資料庫英文欄位)
|
||
mapping = {
|
||
'年': 'year', '月': 'month', '商品部': 'department', '3C百貨': 'category_3c',
|
||
'處別': 'division', '科別': 'section', '區ID': 'area_id', '區名稱': 'area_name',
|
||
'商品_PM': 'pm_name', '品牌名稱_合併': 'brand_name', '廠商編號': 'vendor_id',
|
||
'廠商名稱': 'vendor_name', '借採轉': 'trade_type', '件單價': 'unit_price',
|
||
'銷售額_本月': 'sales_amt_curr', '銷售額_上月': 'sales_amt_prev', '銷售額_去年同期': 'sales_amt_yoa',
|
||
'毛1額_本月': 'profit_amt_curr', '毛1額_上月': 'profit_amt_prev', '毛1額_去年同期': 'profit_amt_yoa',
|
||
'折扣金額_本月': 'discount_amt_curr', '折扣金額_上月': 'discount_amt_prev', '折扣金額_去年同期': 'discount_amt_yoa',
|
||
'折價券_本月': 'coupon_amt_curr', '折價券_上月': 'coupon_amt_prev', '折價券_去年同期': 'coupon_amt_yoa',
|
||
'其他行銷活動_本月': 'other_mkt_curr', '其他行銷活動_上月': 'other_mkt_prev', '其他行銷活動_去年同期': 'other_mkt_yoa',
|
||
'點我折_本月': 'spot_disc_curr', '點我折_上月': 'spot_disc_prev', '點我折_去年同期': 'spot_disc_yoa',
|
||
'點數折抵_本月': 'point_disc_curr', '點數折抵_上月': 'point_disc_prev', '點數折抵_去年同期': 'point_disc_yoa',
|
||
'銷售量_本月': 'sales_vol_curr', '銷售量_上月': 'sales_vol_prev', '銷售量_去年同期': 'sales_vol_yoa',
|
||
'轉換率': 'conv_rate', '瀏覽數_本月': 'views_curr', '瀏覽數_上月': 'views_prev', '瀏覽數_去年同期': 'views_yoa'
|
||
}
|
||
|
||
# 檢查必備欄位 (寬鬆檢查:只要有 mapping 中的欄位就匯入)
|
||
current_cols = df.columns.tolist()
|
||
import_mapping = {k: v for k, v in mapping.items() if k in current_cols}
|
||
|
||
if len(import_mapping) < 5: # 至少要有幾個維度
|
||
return jsonify({'status': 'error', 'message': '檔案欄位不符,請確認是否為正確的月份業績總表'}), 400
|
||
|
||
# 重新命名與清理資料
|
||
target_df = df[list(import_mapping.keys())].rename(columns=import_mapping)
|
||
|
||
# 轉換數值欄位,填補 NaN
|
||
numeric_cols = [v for k, v in import_mapping.items() if v not in [
|
||
'department', 'category_3c', 'division', 'section', 'area_id', 'area_name',
|
||
'pm_name', 'brand_name', 'vendor_name', 'trade_type'
|
||
]]
|
||
for col in numeric_cols:
|
||
target_df[col] = pd.to_numeric(target_df[col], errors='coerce').fillna(0)
|
||
|
||
# 寫入資料庫 - 優化效能版本 (Phase 9 Optimization)
|
||
db = DatabaseManager()
|
||
engine = db.engine
|
||
|
||
try:
|
||
# 取得要匯入的年月份,用於先行刪除重複資料
|
||
years_months = target_df[['year', 'month']].drop_duplicates()
|
||
|
||
with engine.begin() as conn:
|
||
# 1. 刪除該月份舊資料 (Transaction 開始)
|
||
for _, row in years_months.iterrows():
|
||
conn.execute(text("DELETE FROM monthly_summary_analysis WHERE year = :y AND month = :m"),
|
||
{'y': int(row['year']), 'm': int(row['month'])})
|
||
|
||
# 2. 批量寫入 (使用 multi 方法加速,SQLite chunksize 建議 2000 避免參數過多)
|
||
# 比照 realtime_sales_monthly 的優化方式
|
||
target_df.to_sql('monthly_summary_analysis',
|
||
con=conn,
|
||
if_exists='append',
|
||
index=False,
|
||
chunksize=2000,
|
||
method='multi')
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Import] 匯入資料庫失敗: {e}")
|
||
raise e
|
||
|
||
|
||
sys_log.info(f"[Web] [Import] 🚀 月份總表資料匯入成功 | 筆數: {len(target_df)}")
|
||
return jsonify({
|
||
'status': 'success',
|
||
'message': f'成功匯入 {len(target_df)} 筆分析數據。',
|
||
'rows': len(target_df)
|
||
})
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Import] ❌ 月份總表匯入嚴重失敗: {str(e)}")
|
||
return jsonify({'status': 'error', 'message': f'匯入失敗: {str(e)}'}), 500
|
||
|
||
@app.route('/monthly_summary_analysis')
|
||
def monthly_summary_analysis_page():
|
||
"""月份總表數據分析展示頁 (Phase 9)"""
|
||
return render_template('monthly_summary_analysis.html',
|
||
datetime_now=datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'),
|
||
system_version=SYSTEM_VERSION)
|
||
|
||
@app.route('/api/monthly_summary_data')
|
||
def get_monthly_summary_data():
|
||
"""API: 取得月份總表數據與分析指標 (Phase 9)"""
|
||
year = request.args.get('year', type=int)
|
||
month = request.args.get('month', type=int)
|
||
division = request.args.get('division')
|
||
pm_name = request.args.get('pm_name')
|
||
brand_name = request.args.get('brand_name')
|
||
vendor_name = request.args.get('vendor')
|
||
area_name = request.args.get('area_name')
|
||
trade_type = request.args.get('trade_type')
|
||
limit = request.args.get('limit', default=1000, type=int)
|
||
|
||
# DEBUG LOGGING
|
||
import logging
|
||
debug_logger = logging.getLogger('app')
|
||
debug_logger.info(f"🔍 [API Debug] Request Args: {request.args}")
|
||
|
||
db = DatabaseManager()
|
||
session = db.get_session()
|
||
try:
|
||
# 基礎查詢
|
||
query = session.query(MonthlySummaryAnalysis)
|
||
|
||
# 套用過濾
|
||
if year: query = query.filter(MonthlySummaryAnalysis.year == year)
|
||
if month: query = query.filter(MonthlySummaryAnalysis.month == month)
|
||
if division: query = query.filter(MonthlySummaryAnalysis.division == division)
|
||
if pm_name: query = query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
|
||
if brand_name: query = query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
|
||
if vendor_name: query = query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
|
||
if area_name:
|
||
if ',' in area_name:
|
||
query = query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
|
||
else:
|
||
query = query.filter(MonthlySummaryAnalysis.area_name == area_name)
|
||
if trade_type: query = query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
|
||
|
||
# 取得統計數據 (KPIs)
|
||
kpi_query = session.query(
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('total_sales'),
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_prev).label('total_sales_prev'),
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('total_sales_yoa'),
|
||
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('total_profit'),
|
||
func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('total_vol'),
|
||
func.sum(MonthlySummaryAnalysis.views_curr).label('total_views')
|
||
)
|
||
|
||
# 同樣套用過濾到 KPI
|
||
if year: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.year == year)
|
||
if month: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.month == month)
|
||
if division: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.division == division)
|
||
if pm_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
|
||
if brand_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
|
||
if vendor_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
|
||
if area_name:
|
||
if ',' in area_name:
|
||
kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
|
||
else:
|
||
kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name == area_name)
|
||
if trade_type: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
|
||
|
||
kpi_res = kpi_query.one()
|
||
|
||
# 取得總筆數與月數
|
||
total_rows = session.query(func.count(MonthlySummaryAnalysis.id))
|
||
total_months_query = session.query(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).distinct()
|
||
|
||
if year:
|
||
total_rows = total_rows.filter(MonthlySummaryAnalysis.year == year)
|
||
total_months_query = total_months_query.filter(MonthlySummaryAnalysis.year == year)
|
||
if month:
|
||
total_rows = total_rows.filter(MonthlySummaryAnalysis.month == month)
|
||
|
||
total_rows = total_rows.scalar()
|
||
total_months = total_months_query.count()
|
||
|
||
# 取得趨勢數據 (按月加總)
|
||
trend_query = session.query(
|
||
MonthlySummaryAnalysis.year,
|
||
MonthlySummaryAnalysis.month,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
|
||
).group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
|
||
|
||
if division: trend_query = trend_query.filter(MonthlySummaryAnalysis.division == division)
|
||
if pm_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
|
||
if brand_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
|
||
if vendor_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
|
||
if area_name:
|
||
if ',' in area_name:
|
||
trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
|
||
else:
|
||
trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name == area_name)
|
||
if trade_type: trend_query = trend_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
|
||
|
||
# 取得排行榜 (Top 10 Brands)
|
||
rank_query = session.query(
|
||
MonthlySummaryAnalysis.brand_name,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
|
||
).group_by(MonthlySummaryAnalysis.brand_name)
|
||
|
||
if year: rank_query = rank_query.filter(MonthlySummaryAnalysis.year == year)
|
||
if month: rank_query = rank_query.filter(MonthlySummaryAnalysis.month == month)
|
||
if division: rank_query = rank_query.filter(MonthlySummaryAnalysis.division == division)
|
||
if pm_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.pm_name == pm_name)
|
||
if brand_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.brand_name == brand_name)
|
||
if vendor_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
|
||
if area_name:
|
||
if ',' in area_name:
|
||
rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
|
||
else:
|
||
rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name == area_name)
|
||
if trade_type: rank_query = rank_query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
|
||
|
||
rank_query = rank_query.order_by(desc('sales')).limit(10)
|
||
|
||
# 取得明細資料
|
||
rows_query = query.order_by(
|
||
MonthlySummaryAnalysis.year.desc(),
|
||
MonthlySummaryAnalysis.month.desc(),
|
||
MonthlySummaryAnalysis.sales_amt_curr.desc()
|
||
).limit(limit)
|
||
|
||
# --- 📊 V-New: 進階分析子查詢 (Phase 17) ---
|
||
def apply_filters(q, ignore_year=False):
|
||
if year and not ignore_year: q = q.filter(MonthlySummaryAnalysis.year == year)
|
||
if month: q = q.filter(MonthlySummaryAnalysis.month == month)
|
||
if division: q = q.filter(MonthlySummaryAnalysis.division == division)
|
||
if pm_name: q = q.filter(MonthlySummaryAnalysis.pm_name == pm_name)
|
||
if brand_name: q = q.filter(MonthlySummaryAnalysis.brand_name == brand_name)
|
||
if vendor_name: q = q.filter(MonthlySummaryAnalysis.vendor_name == vendor_name)
|
||
if area_name:
|
||
if ',' in area_name:
|
||
q = q.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(',')))
|
||
else:
|
||
q = q.filter(MonthlySummaryAnalysis.area_name == area_name)
|
||
if trade_type: q = q.filter(MonthlySummaryAnalysis.trade_type == trade_type)
|
||
return q
|
||
|
||
# 廠商排行
|
||
# 廠商排行 (Top 20, 分年度)
|
||
# 廠商排行 (Top 20, 分年度)
|
||
vendor_rank_q = session.query(
|
||
MonthlySummaryAnalysis.vendor_name,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025'),
|
||
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2024'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2025'),
|
||
).group_by(MonthlySummaryAnalysis.vendor_name)
|
||
|
||
vendor_rank_q = apply_filters(vendor_rank_q, ignore_year=True)
|
||
vendor_rank_q = vendor_rank_q.order_by(desc('sales')).limit(20)
|
||
|
||
# 區域分佈 (按 area_name, Top 12, 分年度)
|
||
div_dist_q = session.query(
|
||
MonthlySummaryAnalysis.area_name,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
|
||
).group_by(MonthlySummaryAnalysis.area_name)
|
||
|
||
div_dist_q = apply_filters(div_dist_q, ignore_year=True)
|
||
div_dist_q = div_dist_q.order_by(desc('sales')).limit(12)
|
||
|
||
# 價格帶貢獻 (分年度)
|
||
price_cont_q = session.query(
|
||
case(
|
||
(MonthlySummaryAnalysis.unit_price < 500, '0-499'),
|
||
(MonthlySummaryAnalysis.unit_price < 1000, '500-999'),
|
||
(MonthlySummaryAnalysis.unit_price < 2000, '1,000-1,999'),
|
||
(MonthlySummaryAnalysis.unit_price < 5000, '2,000-4,999'),
|
||
(MonthlySummaryAnalysis.unit_price < 10000, '5,000-9,999'),
|
||
else_='10,000+'
|
||
).label('price_range'),
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
|
||
).group_by('price_range')
|
||
price_cont_q = apply_filters(price_cont_q, ignore_year=True)
|
||
|
||
# BCG 矩陣 (品牌 x 區域)
|
||
bcg_q = session.query(
|
||
MonthlySummaryAnalysis.brand_name,
|
||
MonthlySummaryAnalysis.area_name,
|
||
func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('vol'),
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
|
||
func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit')
|
||
).group_by(MonthlySummaryAnalysis.brand_name, MonthlySummaryAnalysis.area_name)\
|
||
.having(func.sum(MonthlySummaryAnalysis.sales_amt_curr) > 0)
|
||
|
||
bcg_q = apply_filters(bcg_q)
|
||
bcg_q = bcg_q.order_by(desc('sales')).limit(100)
|
||
|
||
# 熱力圖 (月份 x 分類)
|
||
# V-Opt: 預先執行一次 div_dist_q,避免重複查詢
|
||
div_dist_results = div_dist_q.all()
|
||
top_12_areas = [r.area_name for r in div_dist_results]
|
||
|
||
heatmap_q = session.query(
|
||
MonthlySummaryAnalysis.year,
|
||
MonthlySummaryAnalysis.month,
|
||
MonthlySummaryAnalysis.area_name,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales')
|
||
).filter(MonthlySummaryAnalysis.area_name.in_(top_12_areas))\
|
||
.group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month, MonthlySummaryAnalysis.area_name)\
|
||
.order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
|
||
heatmap_q = apply_filters(heatmap_q, ignore_year=True)
|
||
|
||
# Highlights (Top 3)
|
||
def get_highlights_q(metric_col):
|
||
q = session.query(MonthlySummaryAnalysis.brand_name, func.sum(metric_col).label('val'))
|
||
q = apply_filters(q)
|
||
q = q.group_by(MonthlySummaryAnalysis.brand_name).order_by(desc('val')).limit(3)
|
||
return q
|
||
|
||
rev_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_amt_curr)
|
||
profit_top_q = get_highlights_q(MonthlySummaryAnalysis.profit_amt_curr)
|
||
vol_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_vol_curr)
|
||
|
||
# 區域排行
|
||
area_rank_q = session.query(
|
||
MonthlySummaryAnalysis.area_name,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'),
|
||
func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025')
|
||
).group_by(MonthlySummaryAnalysis.area_name)
|
||
|
||
area_rank_q = apply_filters(area_rank_q, ignore_year=True)
|
||
area_rank_q = area_rank_q.order_by(desc('sales'))
|
||
|
||
# 年度對比趨勢 (需要包含本期與去年同期)
|
||
# 年度對比趨勢 (需要包含本期與去年同期)
|
||
yoy_trend_q = session.query(
|
||
MonthlySummaryAnalysis.year,
|
||
MonthlySummaryAnalysis.month,
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales_curr'),
|
||
func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('sales_yoa')
|
||
)
|
||
yoy_trend_q = apply_filters(yoy_trend_q)
|
||
yoy_trend_q = yoy_trend_q.group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month)
|
||
|
||
rows = []
|
||
for r in rows_query.all():
|
||
rows.append({
|
||
'year': r.year,
|
||
'month': r.month,
|
||
'division': r.division,
|
||
'pm_name': r.pm_name,
|
||
'area_name': r.area_name,
|
||
'brand_name': r.brand_name,
|
||
'vendor_name': r.vendor_name,
|
||
'trade_type': r.trade_type,
|
||
'sales_amt_curr': r.sales_amt_curr,
|
||
'sales_amt_yoa': r.sales_amt_yoa,
|
||
'sales_vol_curr': r.sales_vol_curr,
|
||
'profit_amt_curr': r.profit_amt_curr,
|
||
'views_curr': r.views_curr
|
||
})
|
||
|
||
# V-Opt: 使用單一 SQL 查詢取得所有不重複的維度列表
|
||
with db.engine.connect() as conn:
|
||
filters_result = conn.execute(text("""
|
||
SELECT
|
||
GROUP_CONCAT(DISTINCT year) as years,
|
||
GROUP_CONCAT(DISTINCT month) as months,
|
||
GROUP_CONCAT(DISTINCT division) as divisions,
|
||
GROUP_CONCAT(DISTINCT pm_name) as pms,
|
||
GROUP_CONCAT(DISTINCT area_name) as areas,
|
||
GROUP_CONCAT(DISTINCT vendor_name) as vendors,
|
||
GROUP_CONCAT(DISTINCT trade_type) as trades
|
||
FROM monthly_summary_analysis
|
||
""")).fetchone()
|
||
|
||
years_list = [int(x) for x in (filters_result[0] or '').split(',') if x]
|
||
months_list = [int(x) for x in (filters_result[1] or '').split(',') if x]
|
||
divisions_list = [x for x in (filters_result[2] or '').split(',') if x]
|
||
pms_list = [x for x in (filters_result[3] or '').split(',') if x]
|
||
areas_list = [x for x in (filters_result[4] or '').split(',') if x]
|
||
vendors_list = [x for x in (filters_result[5] or '').split(',') if x]
|
||
trades_list = [x for x in (filters_result[6] or '').split(',') if x]
|
||
# V-Opt: 預先執行所有查詢,避免在 jsonify 中重複執行
|
||
area_rank_results = area_rank_q.all()
|
||
vendor_rank_results = vendor_rank_q.all()
|
||
price_cont_results = price_cont_q.all()
|
||
bcg_results = bcg_q.all()
|
||
heatmap_results = heatmap_q.all()
|
||
trend_results = trend_query.all()
|
||
yoy_trend_results = yoy_trend_q.all()
|
||
rank_results = rank_query.all()
|
||
rev_top_results = rev_top_q.all()
|
||
profit_top_results = profit_top_q.all()
|
||
vol_top_results = vol_top_q.all()
|
||
|
||
return jsonify({
|
||
'status': 'success',
|
||
'total_rows': total_rows,
|
||
'total_months': total_months,
|
||
'kpis': {
|
||
'sales': int(kpi_res.total_sales or 0),
|
||
'sales_prev': int(kpi_res.total_sales_prev or 0),
|
||
'sales_yoa': int(kpi_res.total_sales_yoa or 0),
|
||
'profit': int(kpi_res.total_profit or 0),
|
||
'vol': int(kpi_res.total_vol or 0),
|
||
'views': int(kpi_res.total_views or 0),
|
||
'margin': round((kpi_res.total_profit / kpi_res.total_sales * 100), 2) if kpi_res.total_sales and kpi_res.total_profit else 0
|
||
},
|
||
'trend': [{'date': f"{r.year}/{r.month}", 'sales': int(r.sales or 0)} for r in trend_results],
|
||
'yoy_trend': [{'date': f"{r.year}/{r.month}", 'curr': int(r.sales_curr or 0), 'yoa': int(r.sales_yoa or 0)} for r in yoy_trend_results],
|
||
'rankings': [{'brand': r.brand_name, 'sales': int(r.sales or 0)} for r in rank_results],
|
||
'area_ranking': [
|
||
{
|
||
'name': r.area_name,
|
||
'sales': int(r.sales or 0),
|
||
'sales_2024': int(r.sales_2024 or 0),
|
||
'sales_2025': int(r.sales_2025 or 0)
|
||
}
|
||
for r in area_rank_results
|
||
],
|
||
'vendor_ranking': [
|
||
{
|
||
'name': r.vendor_name,
|
||
'sales': int(r.sales or 0),
|
||
'sales_2024': int(r.sales_2024 or 0),
|
||
'sales_2025': int(r.sales_2025 or 0),
|
||
'profit': int(r.profit or 0),
|
||
'profit_2024': int(r.profit_2024 or 0),
|
||
'profit_2025': int(r.profit_2025 or 0),
|
||
'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0
|
||
}
|
||
for r in vendor_rank_results
|
||
],
|
||
'division_dist': [
|
||
{
|
||
'name': r.area_name,
|
||
'value': int(r.sales or 0),
|
||
'sales_2024': int(r.sales_2024 or 0),
|
||
'sales_2025': int(r.sales_2025 or 0)
|
||
}
|
||
for r in div_dist_results
|
||
],
|
||
'price_contribution': [
|
||
{
|
||
'range': r.price_range,
|
||
'sales': int(r.sales or 0),
|
||
'sales_2024': int(r.sales_2024 or 0),
|
||
'sales_2025': int(r.sales_2025 or 0)
|
||
}
|
||
for r in price_cont_results
|
||
],
|
||
'bcg_data': [
|
||
{'name': f"{r.brand_name}-{r.area_name}", 'qty': int(r.vol or 0), 'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0, 'sales': int(r.sales or 0)}
|
||
for r in bcg_results
|
||
],
|
||
'heatmap_data': [
|
||
{'year': r.year, 'month': r.month, 'category': r.area_name, 'sales': int(r.sales or 0)}
|
||
for r in heatmap_results
|
||
],
|
||
'highlights': {
|
||
'rev_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in rev_top_results],
|
||
'profit_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in profit_top_results],
|
||
'vol_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in vol_top_results]
|
||
},
|
||
'filters': {
|
||
'years': sorted(years_list, reverse=True),
|
||
'months': sorted(months_list),
|
||
'divisions': sorted(divisions_list),
|
||
'pms': sorted(pms_list),
|
||
'areas': sorted(areas_list),
|
||
'vendors': sorted(vendors_list),
|
||
'trades': sorted(trades_list)
|
||
},
|
||
'rows': rows
|
||
})
|
||
|
||
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"取得月份總表數據失敗: {e}")
|
||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||
finally:
|
||
session.close()
|
||
|
||
|
||
|
||
# ================= 📊 V-New: 業績分析報表 =================
|
||
|
||
|
||
def _get_filtered_sales_data(cache_key):
|
||
"""
|
||
🚩 共用函式:從快取讀取資料並根據 request.args 進行篩選
|
||
回傳: (target_df, cols_map, error_message)
|
||
參數: cache_key - 快取鍵值 (例如: "realtime_sales_monthly_3m")
|
||
"""
|
||
db = DatabaseManager()
|
||
|
||
# 1. 檢查資料表與快取
|
||
df = None
|
||
cols_map = {}
|
||
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
cache_data = _SALES_PROCESSED_CACHE[cache_key]
|
||
df = cache_data['df']
|
||
cols_map = cache_data['cols']
|
||
else:
|
||
# V-Fix: 增加自動重載邏輯,如果快取不存在,試圖從資料庫載入
|
||
sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),試圖重新從資料庫載入...")
|
||
try:
|
||
# 判斷是自訂區間還是標配區間
|
||
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 = int(cache_key.split('_')[-1].replace('m', '') or '1')
|
||
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:
|
||
# 補回月份標籤供後續篩選
|
||
if '日期' in result_df.columns:
|
||
result_df['_month_str'] = pd.to_datetime(result_df['日期']).dt.strftime('%Y-%m')
|
||
|
||
# 自動存入快取
|
||
_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:
|
||
# Top N 分類處理 (用於 '其他' 篩選)
|
||
TOP_N_CATS = 12
|
||
# V-Opt: 從快取中讀取 Top N 分類,避免重算
|
||
cache_data = _SALES_PROCESSED_CACHE.get(cache_key, {})
|
||
top_cats_names = cache_data.get('top_cats')
|
||
|
||
if top_cats_names is None:
|
||
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()
|
||
else:
|
||
top_cats_names = []
|
||
# 回填快取以供下次使用
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
_SALES_PROCESSED_CACHE[cache_key]['top_cats'] = top_cats_names
|
||
|
||
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():
|
||
"""V9.98: 業績分析儀表板 (效能優化版 + 穩定性增強)"""
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
datetime_now_str = now_taipei.strftime('%Y-%m-%d %H:%M:%S')
|
||
try:
|
||
# V-Fix: 定義全域默認變數以防止 Undefined 錯誤
|
||
DEFAULT_COLS = {
|
||
'name': None, 'date': None, 'amount': None, 'qty': None,
|
||
'cat': None, 'category': None, 'brand': None, 'vendor': None,
|
||
'activity': None, 'payment': None, 'price': None, 'cost': None,
|
||
'profit': None, 'return_qty': None, 'pid': None
|
||
}
|
||
DEFAULT_KPI = {'revenue': 0, 'qty': 0, 'count': 0, 'sku_count': 0, 'cost': 0, 'gross_margin': 0, 'gross_margin_rate': 0, 'avg_price': 0}
|
||
DEFAULT_BAR_DATA = {'labels': [], 'chart_values': [], 'metric_label': ''}
|
||
DEFAULT_CHART_DATA = {'labels': [], 'chart_values': []}
|
||
|
||
db = DatabaseManager()
|
||
|
||
# 1. 處理參數
|
||
table_name = request.args.get('table', '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='',
|
||
cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(),
|
||
items=[], bar_data=DEFAULT_BAR_DATA.copy(),
|
||
cat_data=DEFAULT_CHART_DATA.copy(),
|
||
price_dist_data=DEFAULT_CHART_DATA.copy(),
|
||
datetime_now=datetime_now_str)
|
||
|
||
# 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):
|
||
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筆)以獲得更完整的月份資訊
|
||
# V-Security: 使用安全的 SQL 讀取函式
|
||
preview_df = safe_read_sql(table_name, engine=db.engine, limit=1000)
|
||
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,
|
||
cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(),
|
||
items=[], bar_data=DEFAULT_BAR_DATA.copy(),
|
||
cat_data=DEFAULT_CHART_DATA.copy(),
|
||
price_dist_data=DEFAULT_CHART_DATA.copy(),
|
||
scatter_data=None,
|
||
bcg_data=None,
|
||
seasonality_data=None,
|
||
dow_data=DEFAULT_CHART_DATA.copy(),
|
||
monthly_data=DEFAULT_CHART_DATA.copy(),
|
||
weekly_data=DEFAULT_CHART_DATA.copy(),
|
||
hourly_data=DEFAULT_CHART_DATA.copy(),
|
||
heatmap_data=None,
|
||
treemap_data=None,
|
||
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,
|
||
datetime_now=datetime_now_str)
|
||
|
||
# 解析 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. 優先檢查是否已有處理好的快取 (最快) + TTL 驗證
|
||
cache_hit = False
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
cache_data = _SALES_PROCESSED_CACHE[cache_key]
|
||
cache_time = cache_data.get('time', 0)
|
||
# V-Opt: 加入 TTL 檢查(10 分鐘有效期)
|
||
if time.time() - cache_time < _SALES_CACHE_TTL:
|
||
df = cache_data['df']
|
||
cols_map = cache_data['cols']
|
||
cache_hit = True
|
||
sys_log.debug(f"[Sales Analysis] ✅ 快取命中: {cache_key} (age: {time.time() - cache_time:.0f}s)")
|
||
else:
|
||
sys_log.debug(f"[Sales Analysis] ⏰ 快取過期: {cache_key} (age: {time.time() - cache_time:.0f}s > {_SALES_CACHE_TTL}s)")
|
||
del _SALES_PROCESSED_CACHE[cache_key]
|
||
|
||
if cache_hit:
|
||
|
||
# 恢復欄位變數
|
||
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 已在上方定義)
|
||
|
||
# 先讀取小樣本以識別日期欄位 (Security: Sanitized)
|
||
sample_df = safe_read_sql(table_name, engine=db.engine, limit=100)
|
||
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,
|
||
cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(),
|
||
items=[], bar_data=DEFAULT_BAR_DATA.copy(),
|
||
cat_data=DEFAULT_CHART_DATA.copy(),
|
||
price_dist_data=DEFAULT_CHART_DATA.copy(),
|
||
datetime_now=datetime_now_str)
|
||
|
||
# 自動識別日期欄位(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
|
||
|
||
# 根據是否有日期欄位決定查詢方式
|
||
where_clause = None
|
||
params = {}
|
||
|
||
if date_col_name:
|
||
|
||
# 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:
|
||
where_clause = f'"{date_col_name}" BETWEEN :start AND :end'
|
||
params = {'start': start_date_slash, 'end': end_date_slash}
|
||
sys_log.info(f"[Sales Analysis] 📅 使用自訂日期範圍: {start_date} ~ {end_date}")
|
||
elif start_date:
|
||
where_clause = f'"{date_col_name}" >= :start'
|
||
params = {'start': start_date_slash}
|
||
sys_log.info(f"[Sales Analysis] 📅 使用自訂開始日期: >= {start_date}")
|
||
else: # only end_date
|
||
where_clause = f'"{date_col_name}" <= :end'
|
||
params = {'end': end_date_slash}
|
||
sys_log.info(f"[Sales Analysis] 📅 使用自訂結束日期: <= {end_date}")
|
||
elif data_range_months > 0:
|
||
# 使用相對日期範圍(最近N個月)
|
||
cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d')
|
||
where_clause = f'"{date_col_name}" >= :cutoff'
|
||
params = {'cutoff': cutoff_date}
|
||
sys_log.info(f"[Sales Analysis] 📊 使用日期範圍篩選: 最近 {data_range_months} 個月")
|
||
else:
|
||
sys_log.info(f"[Sales Analysis] 📊 載入全部資料(用戶選擇)")
|
||
else:
|
||
sys_log.info(f"[Sales Analysis] ⚠️ 未找到日期欄位,載入全部資料")
|
||
|
||
if table_name in _SALES_DF_CACHE:
|
||
df = _SALES_DF_CACHE[table_name].copy()
|
||
else:
|
||
# V-Security: 使用安全的 SQL 讀取函式 (參數化查詢)
|
||
df = safe_read_sql(table_name, engine=db.engine, where_clause=where_clause, params=params)
|
||
sys_log.info(f"[Sales Analysis] 📊 載入資料: {len(df):,} 筆記錄 (Security: Parameterized)")
|
||
if not df.empty:
|
||
_SALES_DF_CACHE[table_name] = df.copy()
|
||
|
||
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,
|
||
cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(),
|
||
items=[], bar_data=DEFAULT_BAR_DATA.copy(),
|
||
cat_data=DEFAULT_CHART_DATA.copy(),
|
||
price_dist_data=DEFAULT_CHART_DATA.copy(),
|
||
datetime_now=datetime_now_str)
|
||
|
||
# 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,
|
||
cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(),
|
||
items=[], bar_data=DEFAULT_BAR_DATA.copy(),
|
||
cat_data=DEFAULT_CHART_DATA.copy(),
|
||
price_dist_data=DEFAULT_CHART_DATA.copy(),
|
||
datetime_now=datetime_now_str)
|
||
|
||
# 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 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:
|
||
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, 'cat': col_category, # V-Fix: 加上 cat 對映 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欄位
|
||
'time': time.time() # V-Opt: 加入時間戳記用於 TTL 檢查
|
||
}
|
||
_SALES_PROCESSED_CACHE[cache_key] = cache_entry
|
||
# V-Fix: 同時使用固定的 table_name 作為 key 儲存副本,供其他路由使用
|
||
_SALES_PROCESSED_CACHE[table_name] = cache_entry
|
||
|
||
# 🚩 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: 從數據庫直接查詢所有可用選項,而不是只從篩選後的快取中讀取
|
||
# 這樣可以確保下拉選單顯示完整的可選項,即使當前篩選範圍很小
|
||
# V-Fix: 預先初始化所有下拉選單變數,避免 referenced before assignment 錯誤
|
||
all_categories = []
|
||
all_brands = []
|
||
all_vendors = []
|
||
all_activities = []
|
||
all_payments = []
|
||
all_months = []
|
||
|
||
# V-Opt: 優先從快取中讀取下拉選單選項
|
||
global _SALES_OPTIONS_CACHE
|
||
if table_name in _SALES_OPTIONS_CACHE and (time.time() - _SALES_OPTIONS_CACHE[table_name]['time'] < _SALES_OPTIONS_TTL):
|
||
opts = _SALES_OPTIONS_CACHE[table_name]['options']
|
||
all_categories = opts.get('categories', [])
|
||
all_brands = opts.get('brands', [])
|
||
all_vendors = opts.get('vendors', [])
|
||
all_activities = opts.get('activities', [])
|
||
all_payments = opts.get('payments', [])
|
||
all_months = opts.get('months', [])
|
||
sys_log.debug(f"[Sales Analysis] [Cache] ✅ 使用下拉選單快取: {table_name}")
|
||
else:
|
||
try:
|
||
from sqlalchemy import text
|
||
# V-Fix: SQLAlchemy 2.0 需要使用 connection 對象
|
||
with db.engine.connect() as conn:
|
||
# 讀取完整資料表的所有可用選項(使用 DISTINCT 以提升效能)
|
||
if col_category:
|
||
result = conn.execute(text(f'SELECT DISTINCT "{col_category}" FROM {table_name} WHERE "{col_category}" IS NOT NULL AND "{col_category}" != ""')).fetchall()
|
||
all_categories = sorted([str(row[0]) for row in result if row[0]])
|
||
|
||
if col_brand:
|
||
result = conn.execute(text(f'SELECT DISTINCT "{col_brand}" FROM {table_name} WHERE "{col_brand}" IS NOT NULL AND "{col_brand}" != ""')).fetchall()
|
||
all_brands = sorted([str(row[0]) for row in result if row[0]])
|
||
|
||
if col_vendor:
|
||
result = conn.execute(text(f'SELECT DISTINCT "{col_vendor}" FROM {table_name} WHERE "{col_vendor}" IS NOT NULL AND "{col_vendor}" != ""')).fetchall()
|
||
all_vendors = sorted([str(row[0]) for row in result if row[0]])
|
||
|
||
if col_activity:
|
||
result = conn.execute(text(f'SELECT DISTINCT "{col_activity}" FROM {table_name} WHERE "{col_activity}" IS NOT NULL AND "{col_activity}" != ""')).fetchall()
|
||
all_activities = sorted([str(row[0]) for row in result if row[0]])
|
||
|
||
if col_payment:
|
||
result = conn.execute(text(f'SELECT DISTINCT "{col_payment}" FROM {table_name} WHERE "{col_payment}" IS NOT NULL AND "{col_payment}" != ""')).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:
|
||
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:
|
||
all_months = [row[0] for row in result if row[0] and '-' in str(row[0])]
|
||
if all_months: break
|
||
except: continue
|
||
|
||
# 存入快取
|
||
_SALES_OPTIONS_CACHE[table_name] = {
|
||
'options': {
|
||
'categories': all_categories, 'brands': all_brands, 'vendors': all_vendors,
|
||
'activities': all_activities, 'payments': all_payments, 'months': all_months
|
||
},
|
||
'time': time.time()
|
||
}
|
||
sys_log.info(f"[Sales Analysis] [Cache] 💾 已更新下拉選單快取: {table_name}")
|
||
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', '')
|
||
|
||
# 🚩 核心優化:檢查分析結果快取 (Result Cache)
|
||
# 快取鍵值應包含原始資料快取鍵 + 所有目前套用的篩選參數
|
||
import hashlib
|
||
filter_str = f"{request.args.get('category')}_{request.args.get('brand')}_{request.args.get('vendor')}_{request.args.get('activity')}_{request.args.get('payment')}_{request.args.get('dow')}_{request.args.get('hour')}_{request.args.get('month')}_{request.args.get('keyword')}_{request.args.get('metric')}_{request.args.get('min_price')}_{request.args.get('max_price')}_{request.args.get('min_margin')}_{request.args.get('max_margin')}"
|
||
result_cache_key = hashlib.md5(f"{cache_key}_{filter_str}".encode()).hexdigest()
|
||
|
||
if result_cache_key in _SALES_ANALYSIS_RESULT_CACHE and (time.time() - _SALES_ANALYSIS_RESULT_CACHE[result_cache_key]['time'] < _SALES_RESULT_TTL):
|
||
sys_log.info(f"[Sales Analysis] [Result Cache] 🚀 命中分析結果快取 | Key: {result_cache_key}")
|
||
cached_res = _SALES_ANALYSIS_RESULT_CACHE[result_cache_key]['data']
|
||
return render_template('sales_analysis.html', **{**cached_res, 'public_url': public_url, 'datetime_now': datetime_now_str})
|
||
|
||
# 若未命中快取,執行下方昂貴的運算
|
||
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)
|
||
|
||
# 🚩 全面初始化分析變數,避免無數據時報錯
|
||
treemap_data = []
|
||
bcg_data = {'datasets': [], 'thresholds': {'x': 0, 'y': 0}}
|
||
heatmap_data = []
|
||
scatter_data = []
|
||
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': []}
|
||
monthly_data = {'labels': [], 'chart_values': []}
|
||
vendor_stats = []
|
||
seasonality_data = None
|
||
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}}
|
||
insights = {'rev_cats': [], 'rev_prods': [], 'margin_cats': [], 'margin_prods': [], 'qty_cats': [], 'qty_prods': []}
|
||
cat_data = {'labels': [], 'chart_values': []}
|
||
bar_data = {'labels': [], 'chart_values': [], 'metric_label': ''}
|
||
price_dist_data = {'labels': [], 'chart_values': []}
|
||
table_items = []
|
||
total_revenue = total_qty = total_count = sku_count = total_cost = gross_margin = gross_margin_rate = avg_price = 0
|
||
|
||
|
||
# 🚩 運算優化:合併 GroupBy 減少對大規模目標 DataFrame 的重複掃描
|
||
# 建立一次性聚合字典
|
||
agg_map = {col_amount: 'sum'}
|
||
if col_qty: agg_map[col_qty] = 'sum'
|
||
if 'calculated_profit' in target_df.columns: agg_map['calculated_profit'] = 'sum'
|
||
|
||
# 1. 類別聚合 (包含 Top 3, 圓餅圖, 其他)
|
||
cat_agg = target_df.groupby(col_category).agg(agg_map) if col_category else pd.DataFrame()
|
||
|
||
# 2. 商品聚合 (PID + Name)
|
||
product_groupby = [col_pid, col_name] if col_pid else col_name
|
||
prod_agg = target_df.groupby(product_groupby).agg(agg_map) if col_name else pd.DataFrame()
|
||
|
||
# 📊 KPI 計算 (直接從 sum 取得,速度極快)
|
||
total_revenue = float(target_df[col_amount].sum())
|
||
total_qty = float(target_df[col_qty].sum()) if col_qty else 0
|
||
total_count = len(target_df)
|
||
sku_count = int(target_df[col_name].nunique()) if col_name else total_count
|
||
total_cost = float(target_df[col_cost].sum()) if col_cost else 0
|
||
gross_margin = float(target_df['calculated_profit'].sum()) if 'calculated_profit' in target_df.columns else (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
|
||
|
||
# 📊 洞察分析 (從已聚合的 cat_agg/prod_agg 提取,不再掃描原表)
|
||
insights = {'rev_cats': [], 'rev_prods': [], 'margin_cats': [], 'margin_prods': [], 'qty_cats': [], 'qty_prods': []}
|
||
|
||
def format_top3(agg_df, metric, is_prod=False):
|
||
if agg_df.empty: return []
|
||
top_df = agg_df.sort_values(metric, ascending=False).head(3)
|
||
return [{'name': (str(k[-1]) if isinstance(k, tuple) else str(k)), 'value': float(v)} for k, v in top_df[metric].items() if v > 0]
|
||
|
||
insights['rev_cats'] = format_top3(cat_agg, col_amount)
|
||
insights['rev_prods'] = format_top3(prod_agg, col_amount, True)
|
||
insights['qty_cats'] = format_top3(cat_agg, col_qty) if col_qty else []
|
||
insights['qty_prods'] = format_top3(prod_agg, col_qty, True) if col_qty else []
|
||
if 'calculated_profit' in target_df.columns:
|
||
insights['margin_cats'] = format_top3(cat_agg, 'calculated_profit')
|
||
insights['margin_prods'] = format_top3(prod_agg, 'calculated_profit', True)
|
||
|
||
# 📊 1. 核心長條圖數據 (Top 20 商品) - 從 prod_agg 提取
|
||
bar_data = {'labels': [], 'chart_values': [], 'metric_label': ('銷售金額' if selected_metric == 'amount' else '銷售數量')}
|
||
if not prod_agg.empty:
|
||
top20_df = prod_agg.sort_values(sort_col, ascending=False).head(20)
|
||
bar_data['labels'] = [(str(k[-1]) if isinstance(k, tuple) else str(k)) for k in top20_df.index]
|
||
bar_data['chart_values'] = [float(x) for x in top20_df[sort_col].tolist()]
|
||
|
||
# 📊 2. 圓餅圖數據 (從 cat_agg 提取)
|
||
cat_data = {'labels': [], 'chart_values': []}
|
||
if not cat_agg.empty:
|
||
sorted_cat = cat_agg[col_amount].sort_values(ascending=False)
|
||
if len(sorted_cat) > 12:
|
||
top_cats = sorted_cat.head(12)
|
||
other_val = sorted_cat.iloc[12:].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 sorted_cat.index.tolist()]
|
||
cat_data['chart_values'] = [float(x) for x in sorted_cat.tolist()]
|
||
|
||
# 📊 價格帶分析 (維持 PD.CUT)
|
||
price_dist_data = {'labels': [], 'chart_values': []}
|
||
if col_price and not target_df.empty:
|
||
bins = [0, 500, 1000, 2000, 5000, 10000, float('inf')]
|
||
lbls = ['0-499', '500-999', '1,000-1,999', '2,000-4,999', '5,000-9,999', '10,000+']
|
||
price_bins = pd.cut(target_df[col_price], bins=bins, labels=lbls, right=False)
|
||
range_group = target_df.groupby(price_bins, observed=False)[col_amount].sum()
|
||
price_dist_data['labels'] = lbls
|
||
price_dist_data['chart_values'] = [float(range_group.get(l, 0)) for l in lbls]
|
||
|
||
# 📊 散佈圖數據 (取樣 300 點以保證前端流暢)
|
||
scatter_data = []
|
||
if col_price and col_qty and not target_df.empty:
|
||
scatter_source = target_df.head(300)
|
||
for _, row in scatter_source.iterrows():
|
||
scatter_data.append({
|
||
'x': float(row[col_price]), 'y': float(row[col_qty]),
|
||
'name': str(row[col_name]), 'amt': float(row[col_amount])
|
||
})
|
||
|
||
# 📊 BCG 矩陣分析 (BCG Matrix)
|
||
if col_qty and 'calculated_margin_rate' in target_df.columns and not target_df.empty:
|
||
med_qty = target_df[col_qty].median() if not target_df.empty else 1
|
||
med_margin = target_df['calculated_margin_rate'].median() if not target_df.empty else 0
|
||
bcg_data['thresholds'] = {'x': float(med_qty or 1), 'y': float(med_margin)}
|
||
# 分類邏輯 (Stars, Cows, Questions, Dogs)
|
||
stars = target_df[(target_df[col_qty] >= med_qty) & (target_df['calculated_margin_rate'] >= med_margin)].head(50)
|
||
cows = target_df[(target_df[col_qty] >= med_qty) & (target_df['calculated_margin_rate'] < med_margin)].head(50)
|
||
questions = target_df[(target_df[col_qty] < med_qty) & (target_df['calculated_margin_rate'] >= med_margin)].head(50)
|
||
dogs = target_df[(target_df[col_qty] < med_qty) & (target_df['calculated_margin_rate'] < med_margin)].head(50)
|
||
|
||
def format_p(df_seg):
|
||
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_seg.iterrows()]
|
||
|
||
bcg_data['datasets'] = [
|
||
{'label': '明星商品 (Stars)', 'data': format_p(stars), 'backgroundColor': 'rgba(255, 206, 86, 0.8)', 'borderColor': 'rgba(255, 206, 86, 1)'},
|
||
{'label': '金牛商品 (Cows)', 'data': format_p(cows), 'backgroundColor': 'rgba(75, 192, 192, 0.8)', 'borderColor': 'rgba(75, 192, 192, 1)'},
|
||
{'label': '問題商品 (Questions)', 'data': format_p(questions), 'backgroundColor': 'rgba(54, 162, 235, 0.8)', 'borderColor': 'rgba(54, 162, 235, 1)'},
|
||
{'label': '瘦狗商品 (Dogs)', 'data': format_p(dogs), 'backgroundColor': 'rgba(201, 203, 207, 0.8)', 'borderColor': 'rgba(201, 203, 207, 1)'}
|
||
]
|
||
|
||
# 📊 多維度熱力圖 (Day x Hour)
|
||
if col_date and not target_df.empty:
|
||
dh_group = target_df.groupby(['_dow', '_hour'])[col_amount].sum()
|
||
max_val = dh_group.max() if not dh_group.empty else 1
|
||
for (day, hour), val in dh_group.items():
|
||
radius = 3 + (math.sqrt(val) / math.sqrt(max_val)) * 22 if val > 0 else 0
|
||
heatmap_data.append({
|
||
'x': int(hour), 'y': int(day), 'r': radius, 'v': float(val)
|
||
})
|
||
|
||
# 📊 V-Fix: 計算星期業績趨勢 (dow_data)
|
||
dow_group = target_df.groupby('_dow')[col_amount].sum()
|
||
for i in range(7):
|
||
if i in dow_group.index:
|
||
dow_data['chart_values'][i] = float(dow_group[i])
|
||
|
||
# 📊 V-Fix: 計算小時業績趨勢 (hourly_data)
|
||
hour_group = target_df.groupby('_hour')[col_amount].sum()
|
||
for i in range(24):
|
||
if i in hour_group.index:
|
||
hourly_data['chart_values'][i] = float(hour_group[i])
|
||
|
||
# 📊 V-Fix: 計算週業績趨勢 (weekly_data)
|
||
if '_week' in target_df.columns:
|
||
week_group = target_df.groupby('_week')[col_amount].sum().sort_index()
|
||
weekly_data['labels'] = week_group.index.tolist()
|
||
weekly_data['chart_values'] = [float(v) for v in week_group.values]
|
||
|
||
# 📊 V-Fix: 計算月業績趨勢 (monthly_data)
|
||
if '_month_str' in target_df.columns:
|
||
month_group = target_df.groupby('_month_str')[col_amount].sum().sort_index()
|
||
monthly_data['labels'] = month_group.index.tolist()
|
||
monthly_data['chart_values'] = [float(v) for v in month_group.values]
|
||
|
||
# 📊 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():
|
||
treemap_data.append({
|
||
'category': str(cat),
|
||
'product': str(row[col_name]),
|
||
'value': float(row[col_amount]),
|
||
'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])
|
||
})
|
||
|
||
# 📊 格式化 Top 1000 列表商品 (DataTables 使用)
|
||
table_items = []
|
||
if not target_df.empty:
|
||
# 取得 Top 1000
|
||
display_df = target_df.head(1000)
|
||
for i, (_, row) in enumerate(display_df.iterrows()):
|
||
item = {
|
||
'rank': i + 1,
|
||
'pid': str(row[col_pid]) if col_pid else 'N/A',
|
||
'name': str(row[col_name]) if col_name else 'N/A',
|
||
'amount': float(row[col_amount]),
|
||
}
|
||
if col_brand: item['brand'] = str(row[col_brand])
|
||
if col_vendor: item['vendor'] = str(row[col_vendor])
|
||
if col_category: item['cat'] = str(row[col_category])
|
||
if col_qty:
|
||
item['qty'] = float(row[col_qty])
|
||
item['avg_price'] = float(row[col_amount] / row[col_qty]) if row[col_qty] > 0 else 0
|
||
if 'calculated_margin_rate' in df.columns: # 使用原 df 的 columns 判斷是否有此欄位
|
||
item['margin_rate'] = float(row['calculated_margin_rate'])
|
||
if col_return_qty:
|
||
item['return_rate'] = float(row[col_return_qty] / row[col_qty] * 100) if row.get(col_qty, 0) > 0 else 0
|
||
if col_date and '_month_str' in row:
|
||
item['date'] = str(row['_month_str'])
|
||
table_items.append(item)
|
||
|
||
# 📊 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)
|
||
|
||
# 📊 封裝回傳內容並存入快取
|
||
res_data = {
|
||
'marketing_data': marketing_data,
|
||
'items': table_items,
|
||
'kpi': {
|
||
'revenue': total_revenue, 'qty': total_qty, 'count': total_count,
|
||
'sku_count': sku_count, 'cost': total_cost, 'gross_margin': gross_margin,
|
||
'gross_margin_rate': gross_margin_rate, 'avg_price': avg_price
|
||
},
|
||
'insights': insights, 'abc_stats': abc_stats, 'vendor_stats': vendor_stats,
|
||
'seasonality_data': seasonality_data, 'bar_data': bar_data, 'cat_data': cat_data,
|
||
'price_dist_data': price_dist_data, 'scatter_data': scatter_data, 'bcg_data': bcg_data,
|
||
'dow_data': dow_data, 'hourly_data': hourly_data, 'monthly_data': monthly_data,
|
||
'weekly_data': weekly_data, 'heatmap_data': heatmap_data, 'treemap_data': treemap_data,
|
||
'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,
|
||
'all_categories': all_categories, 'all_brands': all_brands, 'all_vendors': all_vendors,
|
||
'all_activities': all_activities, 'all_payments': all_payments, 'all_months': all_months,
|
||
'table_name': table_name, 'total_records': total_count, 'data_range_months': data_range_months,
|
||
'start_date': start_date, 'end_date': end_date, 'db_data_range': db_data_range,
|
||
'cols': cols_map, 'no_filter': False
|
||
}
|
||
|
||
_SALES_ANALYSIS_RESULT_CACHE[result_cache_key] = {'data': res_data, 'time': time.time()}
|
||
|
||
return render_template('sales_analysis.html', **{**res_data, 'public_url': public_url, 'datetime_now': datetime_now_str})
|
||
|
||
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)}",
|
||
total_records=0,
|
||
public_url=public_url if 'public_url' in locals() else '',
|
||
db_data_range=db_data_range if 'db_data_range' in locals() else '',
|
||
start_date=request.args.get('start_date', ''),
|
||
end_date=request.args.get('end_date', ''),
|
||
data_range_months=int(request.args.get('data_range', '1') or '1'),
|
||
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=request.args.get('keyword', ''),
|
||
min_price='', max_price='', min_margin='', max_margin='',
|
||
items=[],
|
||
kpi=DEFAULT_KPI.copy(),
|
||
cols=DEFAULT_COLS.copy(),
|
||
no_filter=False,
|
||
bar_data=DEFAULT_BAR_DATA.copy(),
|
||
cat_data=DEFAULT_CHART_DATA.copy(),
|
||
price_dist_data=DEFAULT_CHART_DATA.copy(),
|
||
datetime_now=datetime_now_str)
|
||
|
||
@app.route('/api/sales_analysis/table_data')
|
||
def get_sales_table_data():
|
||
"""API: 取得業績分析的詳細列表資料 (Server-side AJAX) - 使用 SQL 聚合優化"""
|
||
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: 自訂結束日期
|
||
|
||
# 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', {})
|
||
|
||
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
|
||
col_name = cols_map.get('name', '商品名稱')
|
||
col_pid = cols_map.get('pid', '商品ID')
|
||
col_brand = cols_map.get('brand', '品牌')
|
||
col_vendor = cols_map.get('vendor', '廠商名稱')
|
||
col_category = cols_map.get('category', '商品館')
|
||
col_amount = cols_map.get('amount', '總業績')
|
||
col_qty = cols_map.get('qty', '數量')
|
||
col_cost = cols_map.get('cost', '總成本')
|
||
col_return_qty = cols_map.get('return_qty', '退貨數量')
|
||
|
||
# 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 = []
|
||
query_params = {} # V-Fix: 初始化查詢參數字典
|
||
|
||
# 分類篩選
|
||
if category_filter and category_filter != 'all' and col_category:
|
||
additional_filters.append(f"\"{col_category}\" = :cat")
|
||
query_params['cat'] = category_filter
|
||
|
||
if brand_filter and brand_filter != 'all' and col_brand:
|
||
additional_filters.append(f"\"{col_brand}\" = :brand")
|
||
query_params['brand'] = brand_filter
|
||
|
||
if vendor_filter and vendor_filter != 'all' and col_vendor:
|
||
additional_filters.append(f"\"{col_vendor}\" = :vendor")
|
||
query_params['vendor'] = vendor_filter
|
||
|
||
col_activity = cols_map.get('activity')
|
||
if activity_filter and activity_filter != 'all' and col_activity:
|
||
additional_filters.append(f"\"{col_activity}\" = :act")
|
||
query_params['act'] = activity_filter
|
||
|
||
col_payment = cols_map.get('payment')
|
||
if payment_filter and payment_filter != 'all' and col_payment:
|
||
additional_filters.append(f"\"{col_payment}\" = :pay")
|
||
query_params['pay'] = payment_filter
|
||
|
||
# 月份篩選
|
||
if month_filter and month_filter != 'all':
|
||
month_filter_slash = month_filter.replace('-', '/')
|
||
additional_filters.append('("日期" LIKE :m1 OR "日期" LIKE :m2)')
|
||
query_params['m1'] = f"{month_filter}%"
|
||
query_params['m2'] = f"{month_filter_slash}%"
|
||
|
||
# 星期篩選 (需要從日期計算)
|
||
if dow_filter and dow_filter != 'all':
|
||
sqlite_dow = str((int(dow_filter) + 1) % 7)
|
||
additional_filters.append("strftime('%w', \"日期\") = :dow")
|
||
query_params['dow'] = sqlite_dow
|
||
|
||
# 小時篩選 (需要從時間欄位提取)
|
||
if hour_filter and hour_filter != 'all':
|
||
hour_conditions = []
|
||
for field in ["時間"]: # 通常只有「時間」欄位包含小時資訊
|
||
hour_conditions.append(f"""CAST(strftime('%H', "{field}") AS INTEGER) = {hour_filter}""")
|
||
if hour_conditions:
|
||
additional_filters.append(f"({' OR '.join(hour_conditions)})")
|
||
|
||
# 關鍵字篩選
|
||
if keyword:
|
||
keyword_conditions = []
|
||
if col_name: keyword_conditions.append(f"\"{col_name}\" LIKE :kw")
|
||
if col_pid: keyword_conditions.append(f"\"{col_pid}\" LIKE :kw")
|
||
if col_brand: keyword_conditions.append(f"\"{col_brand}\" LIKE :kw")
|
||
if col_vendor: keyword_conditions.append(f"\"{col_vendor}\" LIKE :kw")
|
||
if keyword_conditions:
|
||
additional_filters.append(f"({' OR '.join(keyword_conditions)})")
|
||
query_params['kw'] = f"%{keyword}%"
|
||
|
||
# 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 1000
|
||
"""
|
||
|
||
df_agg = pd.read_sql(text(sql_query), db.engine, params=query_params)
|
||
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
|
||
|
||
# 重新排序並限制到 1000 筆
|
||
df_agg = df_agg.sort_values('amount', ascending=False).head(1000)
|
||
|
||
# 轉換為 DataTables 格式
|
||
data = []
|
||
for i, row in enumerate(df_agg.to_dict('records')):
|
||
data.append({
|
||
'rank': i + 1,
|
||
'product_id': row.get('product_id', ''),
|
||
'name': row.get('name', ''),
|
||
'brand': row.get('brand', ''),
|
||
'vendor': row.get('vendor', ''),
|
||
'category': row.get('category', ''),
|
||
'margin_rate': row.get('margin_rate', 0),
|
||
'month_str': '', # SQL聚合模式不需要月份字串
|
||
'avg_price': row.get('avg_price', 0),
|
||
'return_rate': row.get('return_rate', 0),
|
||
'qty': row.get('qty', 0),
|
||
'amount': row.get('amount', 0)
|
||
})
|
||
|
||
return jsonify({'data': data})
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[API] Table Data Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return jsonify({'error': str(e)}), 500
|
||
|
||
|
||
# V-Old: 保留舊版本以防需要回滾
|
||
@app.route('/api/sales_analysis/table_data_pandas')
|
||
def get_sales_table_data_pandas():
|
||
"""API: 取得業績分析的詳細列表資料 (使用 pandas 聚合 - 舊版本)"""
|
||
try:
|
||
table_name = 'realtime_sales_monthly'
|
||
data_range_months = int(request.args.get('data_range', '1'))
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
|
||
|
||
if err or target_df is None:
|
||
sys_log.warning(f"[API] Table Data: 快取不存在 ({cache_key}),返回空資料")
|
||
return jsonify({'data': []})
|
||
|
||
if target_df.empty:
|
||
return jsonify({'data': []})
|
||
|
||
col_name = cols_map.get('name')
|
||
col_amount = cols_map.get('amount')
|
||
col_qty = cols_map.get('qty')
|
||
col_cost = cols_map.get('cost')
|
||
col_profit = cols_map.get('profit')
|
||
col_category = cols_map.get('category')
|
||
col_vendor = cols_map.get('vendor')
|
||
col_date = cols_map.get('date')
|
||
col_brand = cols_map.get('brand')
|
||
col_return_qty = cols_map.get('return_qty')
|
||
|
||
selected_metric = request.args.get('metric', 'amount')
|
||
|
||
# 執行聚合 (V-Opt: 多維度聚合,增加精確度)
|
||
agg_rules = {col_amount: 'sum'}
|
||
if col_qty: agg_rules[col_qty] = 'sum'
|
||
if col_cost: agg_rules[col_cost] = 'sum'
|
||
if col_profit: agg_rules[col_profit] = 'sum'
|
||
if col_return_qty: agg_rules[col_return_qty] = 'sum'
|
||
if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique()))
|
||
|
||
# Group By 鍵值:商品名稱 + 品牌 + 廠商 + 分類 (確保唯一性)
|
||
group_cols = [col_name]
|
||
if col_brand: group_cols.append(col_brand)
|
||
if col_vendor: group_cols.append(col_vendor)
|
||
if col_category: group_cols.append(col_category)
|
||
|
||
df_agg = target_df.groupby(group_cols).agg(agg_rules).reset_index()
|
||
|
||
# 計算毛利率
|
||
if col_profit:
|
||
df_agg['agg_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100
|
||
elif col_cost:
|
||
df_agg['agg_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100
|
||
else:
|
||
df_agg['agg_margin_rate'] = 0.0
|
||
df_agg['agg_margin_rate'] = df_agg['agg_margin_rate'].replace([np.inf, -np.inf, np.nan], 0)
|
||
|
||
# V-New: 計算平均單價與退貨率
|
||
if col_qty:
|
||
df_agg['avg_price'] = (df_agg[col_amount] / df_agg[col_qty]).fillna(0)
|
||
if col_return_qty:
|
||
df_agg['return_rate'] = (df_agg[col_return_qty] / df_agg[col_qty] * 100).fillna(0)
|
||
|
||
# 排序
|
||
sort_col_agg = col_amount
|
||
if selected_metric == 'qty' and col_qty:
|
||
sort_col_agg = col_qty
|
||
|
||
df_agg = df_agg.sort_values(by=sort_col_agg, ascending=False).head(1000) # 限制前 1000 筆
|
||
|
||
# 轉換為 DataTables 需要的格式
|
||
data = []
|
||
for i, row in enumerate(df_agg.to_dict('records')):
|
||
data.append({
|
||
'rank': i + 1,
|
||
'name': row.get(col_name, ''),
|
||
'brand': row.get(col_brand, ''),
|
||
'vendor': row.get(col_vendor, ''),
|
||
'category': row.get(col_category, ''),
|
||
'margin_rate': row.get('agg_margin_rate', 0),
|
||
'month_str': row.get('_month_str', ''),
|
||
'avg_price': row.get('avg_price', 0),
|
||
'return_rate': row.get('return_rate', 0),
|
||
'qty': row.get(col_qty, 0),
|
||
'amount': row.get(col_amount, 0)
|
||
})
|
||
|
||
return jsonify({'data': data})
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"Table Data API Error: {e}")
|
||
return jsonify({'error': str(e)}), 500
|
||
|
||
@app.route('/api/export/excel/seasonality_detail')
|
||
def export_seasonality_detail():
|
||
"""API: 匯出淡旺季熱力圖的詳細資料 (點擊氣泡觸發)"""
|
||
try:
|
||
table_name = 'realtime_sales_monthly'
|
||
data_range_months = int(request.args.get('data_range', '1') or '1')
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
|
||
|
||
# V-Fix: 如果 cache_key 不存在,嘗試使用固定的 table_name
|
||
if err and table_name in _SALES_PROCESSED_CACHE:
|
||
target_df, cols_map, err = _get_filtered_sales_data(table_name)
|
||
|
||
if err: return f"匯出失敗: {err}", 400
|
||
|
||
# 取得額外參數
|
||
target_month = request.args.get('target_month')
|
||
target_category = request.args.get('target_category')
|
||
|
||
if not target_month or not target_category:
|
||
return "缺少必要參數 (month, category)", 400
|
||
|
||
col_category = cols_map.get('category')
|
||
|
||
# 進一步篩選
|
||
export_df = target_df[
|
||
(target_df['_month_str'] == target_month) &
|
||
(target_df[col_category] == target_category)
|
||
]
|
||
|
||
if export_df.empty:
|
||
return "該月份與分類無資料", 404
|
||
|
||
# 使用 BytesIO 直接在記憶體中產生 Excel (避免 Exporter 的類型不相容)
|
||
import io
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
export_df.to_excel(writer, index=False, sheet_name='明細')
|
||
output.seek(0)
|
||
|
||
filename = f"Seasonality_{target_category}_{target_month}.xlsx"
|
||
return send_file(
|
||
output,
|
||
as_attachment=True,
|
||
download_name=filename,
|
||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"Seasonality Export Error: {e}")
|
||
return f"匯出失敗: {e}", 500
|
||
|
||
# ================= 💎 V-New: Top 3 Highlights 詳細列表 API =================
|
||
@app.route('/api/sales_analysis/top_detail')
|
||
def get_top_detail():
|
||
"""API: 取得 Top N 詳細列表(業績貢獻王/獲利金雞母/人氣引流款)"""
|
||
try:
|
||
from datetime import datetime, timedelta, timezone
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
table_name = 'realtime_sales_monthly'
|
||
data_range_months = int(request.args.get('data_range', '1') or '1')
|
||
start_date = request.args.get('start_date', '') # V-New: 自訂開始日期
|
||
end_date = request.args.get('end_date', '') # V-New: 自訂結束日期
|
||
top_type = request.args.get('type', 'revenue') # revenue/margin/quantity
|
||
metric = request.args.get('metric', 'amount') # amount/profit/qty
|
||
view_type = request.args.get('view', 'product') # product/category
|
||
|
||
db = DatabaseManager()
|
||
|
||
# V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱
|
||
if start_date or end_date:
|
||
cache_key = f"{table_name}_custom_{start_date}_{end_date}"
|
||
else:
|
||
cache_key = f"{table_name}_{data_range_months}m"
|
||
|
||
# 嘗試從快取讀取欄位名稱
|
||
cols_map = {}
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {})
|
||
elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key
|
||
cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {})
|
||
|
||
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
|
||
col_name = cols_map.get('name', '商品名稱')
|
||
col_brand = cols_map.get('brand', '品牌')
|
||
col_vendor = cols_map.get('vendor', '廠商名稱')
|
||
col_category = cols_map.get('category', '商品館')
|
||
col_amount = cols_map.get('amount', '總業績')
|
||
col_qty = cols_map.get('qty', '數量')
|
||
col_cost = cols_map.get('cost', '總成本')
|
||
col_profit = cols_map.get('profit') # V-New: 取得利潤欄位
|
||
col_activity = cols_map.get('activity', '活動名稱')
|
||
col_payment = cols_map.get('payment', '付款方式')
|
||
|
||
|
||
# 建立日期篩選條件
|
||
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}\" = :cat")
|
||
query_params['cat'] = category_filter
|
||
if brand_filter and brand_filter != 'all':
|
||
additional_filters.append(f"\"{col_brand}\" = :brand")
|
||
query_params['brand'] = brand_filter
|
||
if vendor_filter and vendor_filter != 'all':
|
||
additional_filters.append(f"\"{col_vendor}\" = :vendor")
|
||
query_params['vendor'] = vendor_filter
|
||
if activity_filter and activity_filter != 'all':
|
||
additional_filters.append(f"\"{col_activity}\" = :act")
|
||
query_params['act'] = activity_filter
|
||
if payment_filter and payment_filter != 'all':
|
||
additional_filters.append(f"\"{col_payment}\" = :pay")
|
||
query_params['pay'] = payment_filter
|
||
|
||
# 時間維度
|
||
if month_filter and month_filter != 'all':
|
||
month_filter_slash = month_filter.replace('-', '/')
|
||
additional_filters.append('("日期" LIKE :m1 OR "日期" LIKE :m2)')
|
||
query_params['m1'] = f"{month_filter}%"
|
||
query_params['m2'] = f"{month_filter_slash}%"
|
||
|
||
if dow_filter and dow_filter != 'all':
|
||
sqlite_dow = str((int(dow_filter) + 1) % 7)
|
||
additional_filters.append("strftime('%w', \"日期\") = :dow")
|
||
query_params['dow'] = sqlite_dow
|
||
|
||
if hour_filter and hour_filter != 'all':
|
||
additional_filters.append(f"""CAST(strftime('%H', "時間") AS INTEGER) = {hour_filter}""")
|
||
|
||
# 關鍵字
|
||
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(text(sql_query), db.engine, params=query_params)
|
||
sys_log.info(f"[API] Top Detail: {top_type}/{view_type} 返回 {len(df)} 筆資料")
|
||
|
||
if df.empty:
|
||
return jsonify({'items': []})
|
||
|
||
# 轉換為 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', {})
|
||
|
||
# 取得實際欄位名稱(如果快取中沒有,使用預設名稱)
|
||
col_name = cols_map.get('name', '商品名稱')
|
||
col_brand = cols_map.get('brand', '品牌')
|
||
col_vendor = cols_map.get('vendor', '廠商名稱')
|
||
col_category = cols_map.get('category', '商品館')
|
||
col_amount = cols_map.get('amount', '總業績')
|
||
col_qty = cols_map.get('qty', '數量')
|
||
col_cost = cols_map.get('cost', '總成本')
|
||
col_profit = cols_map.get('profit') # V-New: 取得利潤欄位
|
||
col_activity = cols_map.get('activity', '活動名稱')
|
||
col_payment = cols_map.get('payment', '付款方式')
|
||
|
||
|
||
# 建立日期篩選條件
|
||
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':
|
||
pandas_dow = int(dow_filter)
|
||
sqlite_dow = str((pandas_dow + 1) % 7)
|
||
additional_filters.append(f"""strftime('%w', "日期") = '{sqlite_dow}'""")
|
||
|
||
if hour_filter and hour_filter != 'all':
|
||
additional_filters.append(f"""CAST(strftime('%H', "時間") AS INTEGER) = {hour_filter}""")
|
||
|
||
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(text(sql_query), db.engine, params=query_params)
|
||
|
||
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) =================
|
||
# YoY 快取
|
||
_YOY_CACHE = {}
|
||
_YOY_CACHE_TTL = 1800 # 30 分鐘
|
||
|
||
@app.route('/api/sales_analysis/yoy_comparison')
|
||
def yoy_comparison():
|
||
"""
|
||
API: 年度對比分析 (YoY Comparison) - 已優化效能
|
||
|
||
參數:
|
||
year1: 基準年 (例如 2025)
|
||
year2: 對比年 (例如 2026)
|
||
month: 月份 (可選,1-12,不帶則為全年)
|
||
metric: 指標 (revenue/qty/profit)
|
||
|
||
回傳:
|
||
JSON with year1 total, year2 total, growth rate, and monthly breakdown
|
||
|
||
優化:
|
||
- 使用單一 SQL 查詢取代 24 次查詢
|
||
- 加入 30 分鐘快取機制
|
||
"""
|
||
import time
|
||
start_time = time.time()
|
||
|
||
try:
|
||
from datetime import datetime, timedelta, timezone
|
||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||
|
||
table_name = 'realtime_sales_monthly'
|
||
year1 = request.args.get('year1', '2025')
|
||
year2 = request.args.get('year2', '2026')
|
||
month = request.args.get('month', '') # 可選,1-12
|
||
metric = request.args.get('metric', 'revenue') # revenue/qty/profit
|
||
|
||
# 快取檢查
|
||
cache_key = f"{year1}_{year2}_{month}_{metric}"
|
||
now = datetime.now(TAIPEI_TZ)
|
||
if cache_key in _YOY_CACHE:
|
||
cached = _YOY_CACHE[cache_key]
|
||
cache_age = (now - cached['timestamp']).total_seconds()
|
||
if cache_age < _YOY_CACHE_TTL:
|
||
sys_log.debug(f"[YoY] [Cache] 使用快取 | 快取年齡: {int(cache_age)}秒")
|
||
return jsonify(cached['data'])
|
||
|
||
db = DatabaseManager()
|
||
|
||
# 欄位名稱
|
||
col_amount = '總業績'
|
||
col_qty = '數量'
|
||
col_cost = '總成本'
|
||
col_date = '日期'
|
||
|
||
# 根據指標決定標籤
|
||
if metric == 'qty':
|
||
metric_label = '銷售數量'
|
||
elif metric == 'profit':
|
||
metric_label = '毛利金額'
|
||
else: # revenue
|
||
metric_label = '銷售金額'
|
||
|
||
# 優化:使用 Pandas 一次讀取兩年資料,再分組聚合
|
||
# 比起 24 次 SQL 查詢,效能大幅提升
|
||
years_filter = f'("{col_date}" LIKE :y1a OR "{col_date}" LIKE :y1b OR "{col_date}" LIKE :y2a OR "{col_date}" LIKE :y2b)'
|
||
params = {
|
||
'y1a': f'{year1}/%', 'y1b': f'{year1}-%',
|
||
'y2a': f'{year2}/%', 'y2b': f'{year2}-%'
|
||
}
|
||
|
||
cols_to_fetch = [col_date, col_amount]
|
||
if metric == 'qty':
|
||
cols_to_fetch.append(col_qty)
|
||
elif metric == 'profit':
|
||
cols_to_fetch.extend([col_amount, col_cost])
|
||
|
||
cols_sql = ', '.join([f'"{c}"' for c in set(cols_to_fetch)])
|
||
sql = f'SELECT {cols_sql} FROM "{table_name}" WHERE {years_filter}'
|
||
|
||
df = pd.read_sql(text(sql), db.engine, params=params)
|
||
|
||
if df.empty:
|
||
response = {
|
||
'year1': {'label': f'{year1}年', 'total': 0},
|
||
'year2': {'label': f'{year2}年', 'total': 0},
|
||
'growth_rate': 0,
|
||
'metric': metric,
|
||
'metric_label': metric_label,
|
||
'monthly_breakdown': []
|
||
}
|
||
return jsonify(response)
|
||
|
||
# 解析日期,提取年份和月份
|
||
df['date_parsed'] = pd.to_datetime(df[col_date], errors='coerce')
|
||
df = df.dropna(subset=['date_parsed'])
|
||
df['year'] = df['date_parsed'].dt.year.astype(str)
|
||
df['month'] = df['date_parsed'].dt.month
|
||
|
||
# 計算指標值
|
||
if metric == 'qty':
|
||
df['value'] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0)
|
||
elif metric == 'profit':
|
||
df['value'] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0) - pd.to_numeric(df[col_cost], errors='coerce').fillna(0)
|
||
else:
|
||
df['value'] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0)
|
||
|
||
# 按年份+月份聚合
|
||
grouped = df.groupby(['year', 'month'])['value'].sum().reset_index()
|
||
|
||
# 計算年度總計
|
||
total_year1 = float(grouped[grouped['year'] == year1]['value'].sum())
|
||
total_year2 = float(grouped[grouped['year'] == year2]['value'].sum())
|
||
|
||
# 計算成長率
|
||
if total_year1 > 0:
|
||
growth_rate = ((total_year2 - total_year1) / total_year1) * 100
|
||
else:
|
||
growth_rate = 0 if total_year2 == 0 else 100
|
||
|
||
# 月度明細
|
||
monthly_breakdown = []
|
||
if not month:
|
||
# 建立年度-月份的 pivot 表
|
||
pivot = grouped.pivot(index='month', columns='year', values='value').fillna(0)
|
||
|
||
for m in range(1, 13):
|
||
v1 = float(pivot.loc[m, year1]) if m in pivot.index and year1 in pivot.columns else 0
|
||
v2 = float(pivot.loc[m, year2]) if m in pivot.index and year2 in pivot.columns else 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
|
||
}
|
||
|
||
# 儲存快取
|
||
_YOY_CACHE[cache_key] = {
|
||
'data': response,
|
||
'timestamp': now
|
||
}
|
||
|
||
elapsed = time.time() - start_time
|
||
sys_log.info(f"[YoY] {year1} vs {year2}: {total_year1:,.0f} -> {total_year2:,.0f} ({growth_rate:+.1f}%) | 耗時: {elapsed:.3f}s")
|
||
|
||
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:
|
||
db = DatabaseManager()
|
||
table_name = 'realtime_sales_monthly'
|
||
|
||
# 1. 檢查資料表
|
||
inspector = inspect(db.engine)
|
||
if table_name not in inspector.get_table_names():
|
||
# V-Fix: 使用正確的模板或回傳錯誤訊息
|
||
return f"尚未匯入業績資料 ({table_name})", 404
|
||
|
||
# 2. 讀取資料 (只讀取必要欄位以提升效能,使用安全函數防止 SQL Injection)
|
||
# 根據 inspect_columns.py 結果,使用正確的中文欄位名稱
|
||
req_cols = ['日期', '總業績', '訂單編號', '總成本']
|
||
df = safe_read_sql(table_name, columns=req_cols, engine=db.engine)
|
||
|
||
if df.empty:
|
||
# V-Fix: 使用正確的模板或回傳錯誤訊息
|
||
return f"資料表 {table_name} 為空", 404
|
||
|
||
# 3. 資料前處理
|
||
df['dt'] = pd.to_datetime(df['日期'], errors='coerce')
|
||
df = df.dropna(subset=['dt']) # 移除日期無效的資料
|
||
df['amount'] = pd.to_numeric(df['總業績'], errors='coerce').fillna(0)
|
||
df['cost'] = pd.to_numeric(df['總成本'], errors='coerce').fillna(0)
|
||
df['profit'] = df['amount'] - df['cost']
|
||
|
||
# 4. 按月聚合統計
|
||
# resample('MS') 會將日期對齊到月初 (Month Start)
|
||
monthly_stats = df.set_index('dt').resample('MS').agg({
|
||
'amount': 'sum',
|
||
'profit': 'sum',
|
||
'訂單編號': 'nunique' # 計算不重複訂單數
|
||
}).rename(columns={'訂單編號': 'orders'})
|
||
|
||
# 5. 計算衍生指標 (AOV, MoM, YoY)
|
||
monthly_stats['aov'] = monthly_stats['amount'] / monthly_stats['orders']
|
||
monthly_stats['margin_rate'] = (monthly_stats['profit'] / monthly_stats['amount']) * 100
|
||
|
||
# MoM (月增率)
|
||
monthly_stats['mom'] = monthly_stats['amount'].pct_change() * 100
|
||
|
||
# YoY (年增率) - shift(12)
|
||
monthly_stats['yoy'] = monthly_stats['amount'].pct_change(periods=12) * 100
|
||
|
||
# 填補 NaN (第一個月或無上期資料)
|
||
monthly_stats = monthly_stats.fillna(0)
|
||
|
||
# 6. 準備圖表數據
|
||
# 轉換索引為字串 'YYYY-MM'
|
||
labels = monthly_stats.index.strftime('%Y-%m').tolist()
|
||
|
||
chart_data = {
|
||
'labels': labels,
|
||
'revenue': monthly_stats['amount'].tolist(),
|
||
'profit': monthly_stats['profit'].tolist(),
|
||
'orders': monthly_stats['orders'].tolist(),
|
||
'aov': monthly_stats['aov'].round(0).tolist(),
|
||
'mom': monthly_stats['mom'].round(2).tolist(),
|
||
'yoy': monthly_stats['yoy'].round(2).tolist(),
|
||
'margin_rate': monthly_stats['margin_rate'].round(1).tolist()
|
||
}
|
||
|
||
# 7. 計算 KPI (YTD - Year to Date)
|
||
current_year = df['dt'].max().year
|
||
last_year = current_year - 1
|
||
|
||
ytd_mask = df['dt'].dt.year == current_year
|
||
last_ytd_mask = (df['dt'].dt.year == last_year) & (df['dt'].dt.dayofyear <= df['dt'].max().dayofyear)
|
||
|
||
ytd_revenue = df.loc[ytd_mask, 'amount'].sum()
|
||
last_ytd_revenue = df.loc[last_ytd_mask, 'amount'].sum()
|
||
|
||
ytd_growth = 0
|
||
if last_ytd_revenue > 0:
|
||
ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100
|
||
|
||
# 近30天客單價
|
||
last_month_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30))
|
||
recent_revenue = df.loc[last_month_mask, 'amount'].sum()
|
||
recent_orders = df.loc[last_month_mask, '訂單編號'].nunique()
|
||
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': monthly_stats['orders'].sum()
|
||
}
|
||
|
||
# V-Fix: 將模板移至根目錄,與 sales_analysis.html 一致,解決 TemplateNotFound 問題
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
return render_template('growth_analysis.html', chart_data=chart_data, kpi=kpi, datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'))
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"Growth Analysis Error: {e}")
|
||
return f"系統錯誤: {e}"
|
||
|
||
# ================= 📅 V-New: 當日業績看板 =================
|
||
|
||
@app.route('/daily_sales')
|
||
def daily_sales():
|
||
"""當日業績看板 (Day-over-Day 與 Week-over-Week 分析)"""
|
||
now_taipei = datetime.now(TAIPEI_TZ)
|
||
datetime_now_str = now_taipei.strftime('%Y-%m-%d %H:%M:%S')
|
||
try:
|
||
db = DatabaseManager()
|
||
engine = db.engine
|
||
table_name = 'daily_sales_snapshot'
|
||
|
||
# 1. 檢查資料表是否存在
|
||
inspector = inspect(engine)
|
||
if table_name not in inspector.get_table_names():
|
||
return render_template('daily_sales.html',
|
||
error="尚未匯入當日業績資料,請先至系統設定頁面匯入 Excel。",
|
||
selected_date=None, available_dates=[], current=None, dod=None, wow=None,
|
||
chart_data=None, categories=None, calendar_data=None, selected_month=None,
|
||
datetime_now=datetime_now_str)
|
||
|
||
# 2. 讀取資料(使用快取)
|
||
cache_key = f'{table_name}_daily'
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
df = _SALES_PROCESSED_CACHE[cache_key]['df']
|
||
else:
|
||
# V-Security: 使用安全的 SQL 讀取函式
|
||
df = safe_read_sql(table_name, engine=engine)
|
||
if df.empty:
|
||
return render_template('daily_sales.html',
|
||
error="資料表為空,請先匯入當日業績資料。",
|
||
selected_date=None, available_dates=[], current=None, dod=None, wow=None,
|
||
chart_data=None, categories=None, calendar_data=None, selected_month=None,
|
||
datetime_now=datetime_now_str)
|
||
|
||
# 3. 資料前處理(欄位識別、型別轉換)
|
||
df = preprocess_daily_sales_data(df)
|
||
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
|
||
|
||
# 4. 取得可用日期列表
|
||
available_dates = sorted(df['snapshot_date'].unique(), reverse=True)
|
||
available_dates_str = [d.strftime('%Y-%m-%d') if isinstance(d, pd.Timestamp) else str(d) for d in available_dates]
|
||
|
||
# 5. 取得選擇的日期(從 URL 參數或使用最新日期)
|
||
selected_date_param = request.args.get('date')
|
||
if selected_date_param:
|
||
selected_date = pd.to_datetime(selected_date_param)
|
||
else:
|
||
selected_date = df['snapshot_date'].max()
|
||
|
||
# 6. 取得選擇的月份(用於行事曆顯示)
|
||
selected_month_param = request.args.get('month')
|
||
if selected_month_param:
|
||
selected_month = pd.to_datetime(selected_month_param)
|
||
else:
|
||
selected_month = selected_date
|
||
|
||
# V-New 2026-01-15: 判斷是否為月概覽模式(沒有選擇特定日期)
|
||
is_month_view = not selected_date_param and not request.args.get('month')
|
||
# 如果只有 month 參數沒有 date 參數,也是月概覽模式
|
||
if selected_month_param and not selected_date_param:
|
||
is_month_view = True
|
||
|
||
# 7. 計算 KPI
|
||
current_kpi = calculate_daily_kpis(df, selected_date)
|
||
dod_kpi = calculate_dod(df, selected_date)
|
||
wow_kpi = calculate_wow(df, selected_date)
|
||
|
||
# V-New 2026-01-15: 計算月度總計 KPI
|
||
month_start = selected_month.replace(day=1)
|
||
month_end = (month_start + pd.DateOffset(months=1)) - pd.Timedelta(days=1)
|
||
month_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)]
|
||
|
||
# V-Fix 2026-01-15: 使用 find_col 動態獲取正確欄位名稱
|
||
cols = month_df.columns.tolist()
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名', 'Name'])
|
||
|
||
month_kpi = {
|
||
'total_revenue': float(month_df[col_amount].sum()) if col_amount else 0,
|
||
'total_cost': float(month_df[col_cost].sum()) if col_cost else 0,
|
||
'gross_margin': float(month_df[col_profit].sum()) if col_profit else 0,
|
||
'total_qty': float(month_df[col_qty].sum()) if col_qty else 0,
|
||
'sku_count': int(month_df[col_name].nunique()) if col_name else 0,
|
||
'days_with_data': int(month_df['snapshot_date'].nunique())
|
||
}
|
||
# 若無毛利欄位,用業績減成本計算
|
||
if not col_profit and col_amount and col_cost:
|
||
month_kpi['gross_margin'] = month_kpi['total_revenue'] - month_kpi['total_cost']
|
||
# 計算月度毛利率
|
||
if month_kpi['total_revenue'] > 0:
|
||
month_kpi['margin_rate'] = month_kpi['gross_margin'] / month_kpi['total_revenue'] * 100
|
||
else:
|
||
month_kpi['margin_rate'] = 0
|
||
# 計算月度客單價
|
||
if month_kpi['total_qty'] > 0:
|
||
month_kpi['avg_price'] = month_kpi['total_revenue'] / month_kpi['total_qty']
|
||
else:
|
||
month_kpi['avg_price'] = 0
|
||
|
||
# 8. 準備圖表數據(根據選擇的日期)
|
||
chart_data = prepare_daily_charts(df, selected_date, days=30)
|
||
|
||
# 9. 準備分類聚合列表
|
||
# V-Fix 2026-01-15: 根據檢視模式(單日/月度)決定聚合範圍
|
||
category_list = prepare_category_summary(
|
||
df,
|
||
date_str=selected_date,
|
||
is_month_view=is_month_view,
|
||
month_start=month_start if is_month_view else None,
|
||
month_end=month_end if is_month_view else None
|
||
)
|
||
|
||
# 10. 準備行事曆數據
|
||
calendar_data = prepare_calendar_data(df, selected_month)
|
||
|
||
# 11. V-New: 準備行銷活動業績數據
|
||
marketing_data = prepare_marketing_summary(
|
||
df,
|
||
selected_date=selected_date if not is_month_view else None,
|
||
is_month_view=is_month_view,
|
||
month_start=month_start if is_month_view else None,
|
||
month_end=month_end if is_month_view else None
|
||
)
|
||
|
||
# 12. 回傳模板
|
||
return render_template('daily_sales.html',
|
||
selected_date=selected_date.strftime('%Y-%m-%d') if isinstance(selected_date, pd.Timestamp) else selected_date,
|
||
available_dates=available_dates_str,
|
||
current=current_kpi,
|
||
dod=dod_kpi,
|
||
wow=wow_kpi,
|
||
month_kpi=month_kpi, # V-New: 月度總計
|
||
is_month_view=is_month_view, # V-New: 月概覽模式標誌
|
||
chart_data=chart_data,
|
||
categories=category_list,
|
||
calendar_data=calendar_data,
|
||
marketing_data=marketing_data, # V-New: 行銷活動數據
|
||
selected_month=selected_month.strftime('%Y-%m') if isinstance(selected_month, pd.Timestamp) else selected_month,
|
||
datetime_now=datetime_now_str)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [DailySales] Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return render_template('daily_sales.html',
|
||
error=f"系統錯誤: {str(e)}",
|
||
selected_date=None,
|
||
available_dates=[],
|
||
current=None,
|
||
dod=None,
|
||
wow=None,
|
||
month_kpi=None,
|
||
is_month_view=False,
|
||
chart_data=None,
|
||
categories=None,
|
||
calendar_data=None,
|
||
marketing_data=None,
|
||
selected_month=None,
|
||
datetime_now=datetime_now_str)
|
||
|
||
@app.route('/daily_sales/export')
|
||
def export_daily_sales_category():
|
||
"""匯出當日業績分類明細為 Excel"""
|
||
try:
|
||
from datetime import datetime
|
||
import io
|
||
from flask import send_file
|
||
|
||
db = DatabaseManager()
|
||
engine = db.engine
|
||
table_name = 'daily_sales_snapshot'
|
||
|
||
# 檢查資料表是否存在
|
||
inspector = inspect(engine)
|
||
if table_name not in inspector.get_table_names():
|
||
return "資料表不存在", 404
|
||
|
||
# 讀取資料
|
||
cache_key = f'{table_name}_daily'
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
df = _SALES_PROCESSED_CACHE[cache_key]['df']
|
||
else:
|
||
# V-Security: 使用安全的 SQL 讀取函式
|
||
df = safe_read_sql(table_name, engine=engine)
|
||
df = preprocess_daily_sales_data(df)
|
||
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
|
||
|
||
# 取得選擇的日期
|
||
selected_date = request.args.get('date')
|
||
if not selected_date:
|
||
available_dates = sorted(df['snapshot_date'].unique(), reverse=True)
|
||
if available_dates:
|
||
selected_date = str(available_dates[0])
|
||
else:
|
||
return "無可用日期", 404
|
||
|
||
# 準備分類資料
|
||
categories = prepare_category_summary(df, selected_date)
|
||
|
||
if not categories:
|
||
return "無資料可匯出", 404
|
||
|
||
# 轉為 DataFrame
|
||
export_df = pd.DataFrame(categories)
|
||
|
||
# 重新排列欄位順序並重新命名為中文
|
||
column_mapping = {
|
||
'category': '分類',
|
||
'vendor': '廠商',
|
||
'revenue': '總業績',
|
||
'cost': '總成本',
|
||
'profit': '毛利',
|
||
'margin_rate': '毛利率(%)',
|
||
'qty': '總銷量',
|
||
'sku_count': 'SKU數',
|
||
'avg_price': '平均單價'
|
||
}
|
||
|
||
# 只保留存在的欄位
|
||
export_columns = [col for col in column_mapping.keys() if col in export_df.columns]
|
||
export_df = export_df[export_columns]
|
||
export_df = export_df.rename(columns=column_mapping)
|
||
|
||
# 格式化數值欄位
|
||
for col in export_df.columns:
|
||
if col in ['總業績', '總成本', '毛利', '總銷量', 'SKU數', '平均單價']:
|
||
export_df[col] = export_df[col].apply(lambda x: f"{x:,.0f}" if pd.notna(x) else "0")
|
||
elif col == '毛利率(%)':
|
||
export_df[col] = export_df[col].apply(lambda x: f"{x:.1f}" if pd.notna(x) else "0.0")
|
||
|
||
# 產生檔案名稱
|
||
filename = f"當日業績_分類明細_{selected_date}.xlsx"
|
||
|
||
# 寫入 Excel
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
export_df.to_excel(writer, index=False, sheet_name='分類業績明細')
|
||
|
||
# 調整欄寬
|
||
worksheet = writer.sheets['分類業績明細']
|
||
for idx, col in enumerate(export_df.columns, 1):
|
||
max_length = max(
|
||
export_df[col].astype(str).apply(len).max(),
|
||
len(col)
|
||
) + 2
|
||
worksheet.column_dimensions[chr(64 + idx)].width = min(max_length, 50)
|
||
|
||
output.seek(0)
|
||
|
||
sys_log.info(f"[Web] [DailySales] Excel 匯出成功: {filename}")
|
||
|
||
return send_file(
|
||
output,
|
||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
as_attachment=True,
|
||
download_name=filename
|
||
)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [DailySales] Excel 匯出失敗: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return f"匯出失敗: {str(e)}", 500
|
||
|
||
# V-New 2026-01-15: 行銷活動業績匯出 API
|
||
@app.route('/daily_sales/export_marketing')
|
||
def export_marketing_summary_excel():
|
||
"""匯出行銷活動業績明細為 Excel"""
|
||
try:
|
||
import io
|
||
from flask import send_file
|
||
|
||
db = DatabaseManager()
|
||
engine = db.engine
|
||
table_name = 'daily_sales_snapshot'
|
||
|
||
# 讀取資料
|
||
cache_key = f'{table_name}_daily'
|
||
if cache_key in _SALES_PROCESSED_CACHE:
|
||
df = _SALES_PROCESSED_CACHE[cache_key]['df']
|
||
else:
|
||
# V-Security: 使用安全的 SQL 讀取函式
|
||
df = safe_read_sql(table_name, engine=engine)
|
||
df = preprocess_daily_sales_data(df)
|
||
_SALES_PROCESSED_CACHE[cache_key] = {'df': df}
|
||
|
||
# 取得參數
|
||
activity_type = request.args.get('type', 'all') # coupon, discount, bonus, click, all
|
||
start_date = request.args.get('start_date')
|
||
end_date = request.args.get('end_date')
|
||
selected_date = request.args.get('date')
|
||
|
||
# 額外篩選參數 (與 sales_analysis 同步)
|
||
selected_category = request.args.get('category', 'all')
|
||
selected_brand = request.args.get('brand', 'all')
|
||
selected_vendor = request.args.get('vendor', 'all')
|
||
keyword = request.args.get('keyword', '')
|
||
|
||
# 決定日期範圍
|
||
if start_date and end_date:
|
||
df = df[(df['snapshot_date'] >= pd.to_datetime(start_date)) &
|
||
(df['snapshot_date'] <= pd.to_datetime(end_date))]
|
||
date_label = f"{start_date}_{end_date}"
|
||
elif selected_date:
|
||
df = df[df['snapshot_date'] == pd.to_datetime(selected_date)]
|
||
date_label = selected_date
|
||
else:
|
||
date_label = "全部"
|
||
|
||
# 應用額外篩選
|
||
cols = df.columns.tolist()
|
||
col_category = find_col(cols, ['館別', '商品館', '分類', 'Category'])
|
||
col_brand = find_col(cols, ['品牌', 'Brand'])
|
||
col_vendor = find_col(cols, ['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier'])
|
||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
|
||
if selected_category != 'all' and col_category:
|
||
df = df[df[col_category] == selected_category]
|
||
if selected_brand != 'all' and col_brand:
|
||
df = df[df[col_brand] == selected_brand]
|
||
if selected_vendor != 'all' and col_vendor:
|
||
df = df[df[col_vendor] == selected_vendor]
|
||
if keyword and col_name:
|
||
df = df[df[col_name].str.contains(keyword, case=False, na=False)]
|
||
|
||
# 定義行銷活動欄位
|
||
marketing_cols = {
|
||
'coupon': ('折價券活動名稱', '折價券活動'),
|
||
'discount': ('折扣活動名稱', '折扣活動'),
|
||
'bonus': ('滿額再折扣活動名稱', '滿額再折扣'),
|
||
'click': ('點我再折扣', '點我再折扣')
|
||
}
|
||
|
||
# 準備 Excel 輸出
|
||
output = io.BytesIO()
|
||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
# 如果是 all,循環所有類型
|
||
types_to_export = [activity_type] if activity_type != 'all' else ['coupon', 'discount', 'bonus', 'click']
|
||
|
||
summary_rows = []
|
||
|
||
for t in types_to_export:
|
||
if t not in marketing_cols: continue
|
||
col_internal, sheet_label = marketing_cols[t]
|
||
if col_internal not in df.columns:
|
||
continue
|
||
|
||
# 聚合數據
|
||
# V-Fix: 排除空值和 0
|
||
m_df = df[df[col_internal].notna() & (df[col_internal] != '') & (df[col_internal] != '0') & (df[col_internal] != 0)]
|
||
|
||
if m_df.empty:
|
||
continue
|
||
|
||
grouped = m_df.groupby(col_internal).agg({
|
||
col_amount: 'sum',
|
||
col_qty: 'sum',
|
||
col_name: 'count' # 訂單筆數/商品筆數
|
||
}).reset_index()
|
||
|
||
# 重命名
|
||
grouped.columns = ['活動名稱', '總業績', '總銷量', '項目筆數']
|
||
grouped = grouped.sort_values(by='總業績', ascending=False)
|
||
|
||
# 寫入工作表
|
||
grouped.to_excel(writer, sheet_name=sheet_label[:31], index=False)
|
||
|
||
# 加入到總表數據
|
||
grouped['活動類型'] = sheet_label
|
||
summary_rows.append(grouped)
|
||
|
||
# 建立總表工作表 (如果有多個類型)
|
||
if len(summary_rows) > 1:
|
||
all_m_df = pd.concat(summary_rows).sort_values(by='總業績', ascending=False)
|
||
all_m_df = all_m_df[['活動類型', '活動名稱', '總業績', '總銷量', '項目筆數']]
|
||
all_m_df.to_excel(writer, sheet_name='合併總表', index=False)
|
||
|
||
output.seek(0)
|
||
output.seek(0)
|
||
|
||
filename = f"行銷活動分析_{date_label}.xlsx"
|
||
# 處理中文檔名編碼
|
||
from urllib.parse import quote
|
||
encoded_filename = quote(filename)
|
||
|
||
return send_file(
|
||
output,
|
||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
as_attachment=True,
|
||
download_name=filename,
|
||
conditional=True
|
||
)
|
||
|
||
except Exception as e:
|
||
sys_log.error(f"[Web] [Marketing] Excel 匯出失敗: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return f"匯出失敗: {str(e)}", 500
|
||
|
||
def preprocess_daily_sales_data(df):
|
||
"""前處理當日業績資料:欄位識別、型別轉換"""
|
||
cols = df.columns.tolist()
|
||
|
||
# 欄位自動識別(使用現有的 find_col 函式)
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', 'Amount', '總業績'])
|
||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量'])
|
||
|
||
# 型別轉換
|
||
if col_amount:
|
||
df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0)
|
||
if col_cost:
|
||
df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0)
|
||
if col_profit:
|
||
df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0)
|
||
if col_qty:
|
||
df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0)
|
||
|
||
# 日期轉換
|
||
df['snapshot_date'] = pd.to_datetime(df['snapshot_date'], errors='coerce')
|
||
|
||
return df
|
||
|
||
def calculate_daily_kpis(df, date_str):
|
||
"""計算單日 6 個 KPI"""
|
||
day_df = df[df['snapshot_date'] == date_str]
|
||
cols = day_df.columns.tolist()
|
||
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名', 'Name'])
|
||
|
||
total_revenue = float(day_df[col_amount].sum()) if col_amount else 0
|
||
total_cost = float(day_df[col_cost].sum()) if col_cost else 0
|
||
gross_margin = float(day_df[col_profit].sum()) if col_profit else (total_revenue - total_cost)
|
||
total_qty = float(day_df[col_qty].sum()) if col_qty else 0
|
||
sku_count = int(day_df[col_name].nunique()) if col_name else 0
|
||
avg_price = total_revenue / total_qty if total_qty > 0 else 0
|
||
|
||
return {
|
||
'total_revenue': total_revenue,
|
||
'total_cost': total_cost,
|
||
'gross_margin': gross_margin,
|
||
'total_qty': total_qty,
|
||
'sku_count': sku_count,
|
||
'avg_price': avg_price
|
||
}
|
||
|
||
def calculate_dod(df, current_date):
|
||
"""計算 Day-over-Day 變化率"""
|
||
current = calculate_daily_kpis(df, current_date)
|
||
prev_date = current_date - timedelta(days=1)
|
||
|
||
if prev_date not in df['snapshot_date'].values:
|
||
return {k: 0.0 for k in current.keys()}
|
||
|
||
previous = calculate_daily_kpis(df, prev_date)
|
||
|
||
dod = {}
|
||
for key in current:
|
||
if previous[key] > 0:
|
||
dod[key] = ((current[key] - previous[key]) / previous[key]) * 100
|
||
else:
|
||
dod[key] = 0.0
|
||
return dod
|
||
|
||
def calculate_wow(df, current_date):
|
||
"""計算 Week-over-Week 變化率"""
|
||
current = calculate_daily_kpis(df, current_date)
|
||
prev_week_date = current_date - timedelta(days=7)
|
||
|
||
if prev_week_date not in df['snapshot_date'].values:
|
||
return {k: 0.0 for k in current.keys()}
|
||
|
||
previous = calculate_daily_kpis(df, prev_week_date)
|
||
|
||
wow = {}
|
||
for key in current:
|
||
if previous[key] > 0:
|
||
wow[key] = ((current[key] - previous[key]) / previous[key]) * 100
|
||
else:
|
||
wow[key] = 0.0
|
||
return wow
|
||
|
||
def prepare_daily_charts(df, selected_date, days=30):
|
||
"""準備 4 個圖表的數據(根據選擇的日期)"""
|
||
# 取選擇日期前 N 天的數據
|
||
start_date = selected_date - timedelta(days=days)
|
||
df_range = df[(df['snapshot_date'] >= start_date) & (df['snapshot_date'] <= selected_date)]
|
||
|
||
# 按日期聚合
|
||
cols = df_range.columns.tolist()
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_cost = find_col(cols, ['成本', '總成本'])
|
||
col_profit = find_col(cols, ['毛利'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||
|
||
# 日期聚合
|
||
agg_dict = {}
|
||
if col_amount:
|
||
agg_dict[col_amount] = 'sum'
|
||
if col_cost:
|
||
agg_dict[col_cost] = 'sum'
|
||
if col_profit:
|
||
agg_dict[col_profit] = 'sum'
|
||
if col_qty:
|
||
agg_dict[col_qty] = 'sum'
|
||
|
||
daily_agg = df_range.groupby('snapshot_date').agg(agg_dict).reset_index()
|
||
|
||
# 計算或取得毛利(如果沒有毛利欄位,用業績-成本計算)
|
||
if col_profit and col_profit in daily_agg.columns:
|
||
daily_agg['profit'] = daily_agg[col_profit]
|
||
elif col_amount and col_cost and col_amount in daily_agg.columns and col_cost in daily_agg.columns:
|
||
daily_agg['profit'] = daily_agg[col_amount] - daily_agg[col_cost]
|
||
else:
|
||
daily_agg['profit'] = 0
|
||
|
||
# 計算客單價
|
||
if col_amount and col_qty and col_amount in daily_agg.columns and col_qty in daily_agg.columns:
|
||
daily_agg['avg_price'] = (daily_agg[col_amount] / daily_agg[col_qty]).fillna(0)
|
||
else:
|
||
daily_agg['avg_price'] = 0
|
||
|
||
# 計算 DoD (Day-over-Day) 變化率 - 多個維度
|
||
if col_amount and col_amount in daily_agg.columns:
|
||
daily_agg['dod_revenue'] = daily_agg[col_amount].pct_change() * 100
|
||
if 'profit' in daily_agg.columns:
|
||
daily_agg['dod_profit'] = daily_agg['profit'].pct_change() * 100
|
||
if 'avg_price' in daily_agg.columns:
|
||
daily_agg['dod_avg_price'] = daily_agg['avg_price'].pct_change() * 100
|
||
if col_qty and col_qty in daily_agg.columns:
|
||
daily_agg['dod_qty'] = daily_agg[col_qty].pct_change() * 100
|
||
|
||
# 計算 WoW (Week-over-Week) 變化率 - 多個維度
|
||
if col_amount and col_amount in daily_agg.columns:
|
||
daily_agg['wow_revenue'] = daily_agg[col_amount].pct_change(periods=7) * 100
|
||
if 'profit' in daily_agg.columns:
|
||
daily_agg['wow_profit'] = daily_agg['profit'].pct_change(periods=7) * 100
|
||
if 'avg_price' in daily_agg.columns:
|
||
daily_agg['wow_avg_price'] = daily_agg['avg_price'].pct_change(periods=7) * 100
|
||
if col_qty and col_qty in daily_agg.columns:
|
||
daily_agg['wow_qty'] = daily_agg[col_qty].pct_change(periods=7) * 100
|
||
|
||
# Top 10 商品(選擇的日期,包含廠商)
|
||
selected_df = df[df['snapshot_date'] == selected_date]
|
||
top10_labels = []
|
||
top10_values = []
|
||
|
||
if col_name and col_amount:
|
||
col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier'])
|
||
|
||
if col_vendor:
|
||
# 如果有廠商欄位,按商品+廠商聚合
|
||
top10_df = selected_df.groupby([col_name, col_vendor])[col_amount].sum().nlargest(10).reset_index()
|
||
top10_labels = [f"{row[col_name]} ({row[col_vendor]})" for _, row in top10_df.iterrows()]
|
||
top10_values = top10_df[col_amount].tolist()
|
||
else:
|
||
# 沒有廠商欄位,只按商品聚合
|
||
top10 = selected_df.groupby(col_name)[col_amount].sum().nlargest(10)
|
||
top10_labels = top10.index.tolist()
|
||
top10_values = top10.values.tolist()
|
||
|
||
return {
|
||
'labels': daily_agg['snapshot_date'].dt.strftime('%m/%d').tolist() if not daily_agg.empty else [],
|
||
'revenue': daily_agg[col_amount].tolist() if col_amount and col_amount in daily_agg.columns and not daily_agg.empty else [],
|
||
'cost': daily_agg[col_cost].tolist() if col_cost and col_cost in daily_agg.columns and not daily_agg.empty else [],
|
||
'profit': daily_agg['profit'].tolist() if 'profit' in daily_agg.columns and not daily_agg.empty else [],
|
||
'qty': daily_agg[col_qty].tolist() if col_qty and col_qty in daily_agg.columns and not daily_agg.empty else [],
|
||
'avg_price': daily_agg['avg_price'].tolist() if 'avg_price' in daily_agg.columns and not daily_agg.empty else [],
|
||
|
||
# DoD 多維度
|
||
'dod_revenue': daily_agg['dod_revenue'].fillna(0).tolist() if 'dod_revenue' in daily_agg.columns and not daily_agg.empty else [],
|
||
'dod_profit': daily_agg['dod_profit'].fillna(0).tolist() if 'dod_profit' in daily_agg.columns and not daily_agg.empty else [],
|
||
'dod_avg_price': daily_agg['dod_avg_price'].fillna(0).tolist() if 'dod_avg_price' in daily_agg.columns and not daily_agg.empty else [],
|
||
'dod_qty': daily_agg['dod_qty'].fillna(0).tolist() if 'dod_qty' in daily_agg.columns and not daily_agg.empty else [],
|
||
|
||
# WoW 多維度
|
||
'wow_revenue': daily_agg['wow_revenue'].fillna(0).tolist() if 'wow_revenue' in daily_agg.columns and not daily_agg.empty else [],
|
||
'wow_profit': daily_agg['wow_profit'].fillna(0).tolist() if 'wow_profit' in daily_agg.columns and not daily_agg.empty else [],
|
||
'wow_avg_price': daily_agg['wow_avg_price'].fillna(0).tolist() if 'wow_avg_price' in daily_agg.columns and not daily_agg.empty else [],
|
||
'wow_qty': daily_agg['wow_qty'].fillna(0).tolist() if 'wow_qty' in daily_agg.columns and not daily_agg.empty else [],
|
||
|
||
'top10_labels': top10_labels,
|
||
'top10_values': top10_values
|
||
}
|
||
|
||
def prepare_category_summary(df, date_str=None, is_month_view=False, month_start=None, month_end=None):
|
||
"""準備分類聚合列表 (支援單日或月度範圍)"""
|
||
if is_month_view and month_start is not None and month_end is not None:
|
||
day_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)]
|
||
else:
|
||
day_df = df[df['snapshot_date'] == date_str]
|
||
cols = day_df.columns.tolist()
|
||
|
||
col_category = find_col(cols, ['館別', '分類', 'Category'])
|
||
col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier'])
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '總業績'])
|
||
col_cost = find_col(cols, ['成本', '總成本'])
|
||
col_profit = find_col(cols, ['毛利'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||
|
||
if not col_category or not col_amount:
|
||
return []
|
||
|
||
# 分類 + 廠商聚合
|
||
agg_dict = {col_amount: 'sum'}
|
||
if col_cost:
|
||
agg_dict[col_cost] = 'sum'
|
||
if col_profit:
|
||
agg_dict[col_profit] = 'sum'
|
||
if col_qty:
|
||
agg_dict[col_qty] = 'sum'
|
||
if col_name:
|
||
agg_dict[col_name] = 'nunique'
|
||
|
||
# 如果有廠商欄位,按分類+廠商聚合;否則只按分類聚合
|
||
if col_vendor:
|
||
category_df = day_df.groupby([col_category, col_vendor]).agg(agg_dict).reset_index()
|
||
else:
|
||
category_df = day_df.groupby(col_category).agg(agg_dict).reset_index()
|
||
|
||
# 計算毛利(如果資料中沒有毛利欄位,自動計算)
|
||
if col_profit and col_profit in category_df.columns:
|
||
# 資料中有毛利欄位,直接使用
|
||
pass
|
||
elif col_amount and col_cost and col_amount in category_df.columns and col_cost in category_df.columns:
|
||
# 資料中沒有毛利欄位,用 業績 - 成本 計算
|
||
category_df['profit_calculated'] = category_df[col_amount] - category_df[col_cost]
|
||
col_profit = 'profit_calculated'
|
||
else:
|
||
col_profit = None
|
||
|
||
# 計算毛利率
|
||
if col_profit and col_profit in category_df.columns and col_amount and col_amount in category_df.columns:
|
||
category_df['margin_rate'] = (category_df[col_profit] / category_df[col_amount] * 100).fillna(0)
|
||
else:
|
||
category_df['margin_rate'] = 0
|
||
|
||
# 計算均價
|
||
if col_qty and col_amount:
|
||
category_df['avg_price'] = (category_df[col_amount] / category_df[col_qty]).fillna(0)
|
||
else:
|
||
category_df['avg_price'] = 0
|
||
|
||
# 重新命名欄位以便模板使用
|
||
rename_dict = {col_category: 'category', col_amount: 'revenue'}
|
||
if col_vendor:
|
||
rename_dict[col_vendor] = 'vendor'
|
||
if col_cost:
|
||
rename_dict[col_cost] = 'cost'
|
||
if col_profit and col_profit in category_df.columns:
|
||
rename_dict[col_profit] = 'profit'
|
||
if col_qty:
|
||
rename_dict[col_qty] = 'qty'
|
||
if col_name:
|
||
rename_dict[col_name] = 'sku_count'
|
||
|
||
category_df = category_df.rename(columns=rename_dict)
|
||
|
||
# 確保 profit 欄位存在,如果不存在則設為 0
|
||
if 'profit' not in category_df.columns:
|
||
category_df['profit'] = 0
|
||
|
||
# 轉為字典列表
|
||
return category_df.to_dict('records')
|
||
|
||
# V-New 2026-01-15: 行銷活動業績聚合函數
|
||
def prepare_marketing_summary(df, selected_date=None, is_month_view=False, month_start=None, month_end=None, sort_by='revenue'):
|
||
"""
|
||
準備行銷活動業績貢獻數據
|
||
支援單日模式和月度模式,並可指定排序維度 (revenue, qty, profit)
|
||
"""
|
||
# 決定使用的數據範圍
|
||
if is_month_view and month_start is not None and month_end is not None:
|
||
target_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)]
|
||
elif selected_date is not None:
|
||
target_df = df[df['snapshot_date'] == selected_date]
|
||
else:
|
||
target_df = df
|
||
|
||
if target_df.empty:
|
||
return {'coupon': [], 'discount': [], 'bonus': [], 'click': []}
|
||
|
||
cols = target_df.columns.tolist()
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', '數量', 'Qty'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit', '利潤'])
|
||
col_cost = find_col(cols, ['成本', 'Cost', '總成本'])
|
||
|
||
if not col_amount:
|
||
return {'coupon': [], 'discount': [], 'bonus': [], 'click': []}
|
||
|
||
# 定義四種行銷活動欄位
|
||
marketing_cols = {
|
||
'coupon': '折價券活動名稱', # 折價券活動
|
||
'discount': '折扣活動名稱', # 折扣活動
|
||
'bonus': '滿額再折扣活動名稱', # 滿額再折扣
|
||
'click': '點我再折扣' # 點我再折扣
|
||
}
|
||
|
||
result = {}
|
||
|
||
# 確保 sort_by 欄位存在,否則退回 revenue
|
||
actual_sort_key = sort_by if sort_by in ['revenue', 'qty', 'profit'] else 'revenue'
|
||
|
||
for key, col_name in marketing_cols.items():
|
||
if col_name not in cols:
|
||
result[key] = []
|
||
continue
|
||
|
||
# 篩選有該行銷活動的記錄
|
||
activity_df = target_df[
|
||
(target_df[col_name].notna()) &
|
||
(target_df[col_name] != '') &
|
||
(target_df[col_name] != '0') &
|
||
(target_df[col_name] != 0)
|
||
]
|
||
|
||
if activity_df.empty:
|
||
result[key] = []
|
||
continue
|
||
|
||
# 聚合計算
|
||
agg_args = {
|
||
'revenue': (col_amount, 'sum'),
|
||
'order_count': (col_amount, 'count')
|
||
}
|
||
if col_qty: agg_args['qty'] = (col_qty, 'sum')
|
||
if col_profit: agg_args['profit'] = (col_profit, 'sum')
|
||
|
||
grouped = activity_df.groupby(col_name).agg(**agg_args).reset_index()
|
||
|
||
# 若需要手動計算毛利 (金額 - 成本)
|
||
if 'profit' not in agg_args and col_cost:
|
||
cost_agg = activity_df.groupby(col_name)[col_cost].sum().reset_index()
|
||
grouped = grouped.merge(cost_agg, on=col_name)
|
||
grouped['profit'] = grouped['revenue'] - grouped[col_cost]
|
||
|
||
grouped = grouped.rename(columns={col_name: 'name'})
|
||
|
||
# 動態排序
|
||
sort_col = actual_sort_key if actual_sort_key in grouped.columns else 'revenue'
|
||
grouped = grouped.sort_values(sort_col, ascending=False).head(15)
|
||
|
||
# 轉為字典列表
|
||
records = []
|
||
for _, row in grouped.iterrows():
|
||
record = {
|
||
'name': str(row['name'])[:50],
|
||
'revenue': float(row['revenue']),
|
||
'order_count': int(row['order_count'])
|
||
}
|
||
if 'qty' in row: record['qty'] = float(row['qty'])
|
||
if 'profit' in row: record['profit'] = float(row['profit'])
|
||
records.append(record)
|
||
|
||
result[key] = records
|
||
|
||
return result
|
||
|
||
|
||
def get_taiwan_holiday(date):
|
||
"""判斷是否為台灣國定假日,回傳 (is_holiday, holiday_name)"""
|
||
year = date.year
|
||
month = date.month
|
||
day = date.day
|
||
|
||
# 2026年台灣國定假日(根據人事行政總處公佈)
|
||
holidays_2026 = {
|
||
(1, 1): '元旦',
|
||
# 春節連假 (2/14-2/22,共9天)
|
||
(2, 14): '春節連假',
|
||
(2, 15): '小年夜',
|
||
(2, 16): '除夕',
|
||
(2, 17): '春節 (初一)',
|
||
(2, 18): '春節 (初二)',
|
||
(2, 19): '春節 (初三)',
|
||
(2, 20): '春節連假',
|
||
(2, 21): '春節連假',
|
||
(2, 22): '春節連假',
|
||
# 和平紀念日 (2/28-3/2,共3天)
|
||
(2, 28): '和平紀念日',
|
||
(3, 2): '和平紀念日補假',
|
||
# 兒童節+清明節 (4/3-4/6,共4天)
|
||
(4, 3): '兒童節補假',
|
||
(4, 4): '清明節',
|
||
(4, 5): '清明節連假',
|
||
(4, 6): '清明節補假',
|
||
# 勞動節 (5/1-5/3,共3天)
|
||
(5, 1): '勞動節',
|
||
# 端午節 (6/19-6/21,共3天)
|
||
(6, 19): '端午節',
|
||
# 中秋節+教師節 (9/25-9/28,共4天)
|
||
(9, 25): '中秋節',
|
||
(9, 28): '教師節',
|
||
# 國慶日 (10/9-10/11,共3天)
|
||
(10, 9): '國慶日補假',
|
||
(10, 10): '國慶日',
|
||
# 光復節 (10/25-10/26,共2天)
|
||
(10, 25): '臺灣光復節',
|
||
(10, 26): '光復節補假',
|
||
# 行憲紀念日 (12/25-12/27,共3天)
|
||
(12, 25): '行憲紀念日',
|
||
}
|
||
|
||
# 2027年台灣國定假日(預先計算部分)
|
||
holidays_2027 = {
|
||
(1, 1): '元旦',
|
||
(2, 11): '春節 (除夕)',
|
||
(2, 12): '春節 (初一)',
|
||
(2, 13): '春節 (初二)',
|
||
(2, 14): '春節 (初三)',
|
||
(2, 15): '春節 (初四)',
|
||
(2, 16): '春節 (初五)',
|
||
(2, 17): '春節 (初六)',
|
||
(2, 28): '和平紀念日',
|
||
(4, 4): '清明節',
|
||
(4, 5): '清明節連假',
|
||
(6, 14): '端午節',
|
||
(9, 21): '中秋節',
|
||
(10, 10): '國慶日',
|
||
(10, 11): '國慶日連假',
|
||
}
|
||
|
||
holidays = holidays_2026 if year == 2026 else (holidays_2027 if year == 2027 else {})
|
||
|
||
holiday_name = holidays.get((month, day))
|
||
return (True, holiday_name) if holiday_name else (False, None)
|
||
|
||
def prepare_calendar_data(df, selected_month):
|
||
"""準備行事曆數據(豐富版:顯示總業績、毛利、SKU數 + DoD%)"""
|
||
import calendar
|
||
|
||
# 取得該月份的年月
|
||
year = selected_month.year
|
||
month = selected_month.month
|
||
|
||
# 計算該月第一天和最後一天
|
||
first_day = pd.Timestamp(year=year, month=month, day=1)
|
||
last_day = pd.Timestamp(year=year, month=month, day=calendar.monthrange(year, month)[1])
|
||
|
||
# 計算行事曆顯示範圍(包含前後月份的日期以填滿週)
|
||
# 取得該月第一天是星期幾 (0=Monday, 6=Sunday)
|
||
first_weekday = first_day.weekday()
|
||
|
||
# 計算行事曆起始日(從週一開始)
|
||
calendar_start = first_day - timedelta(days=first_weekday)
|
||
|
||
# 計算該月最後一天是星期幾
|
||
last_weekday = last_day.weekday()
|
||
|
||
# 計算行事曆結束日(到週日結束)
|
||
calendar_end = last_day + timedelta(days=(6 - last_weekday))
|
||
|
||
# 取得該月份及前後各一天的所有資料(用於計算 DoD)
|
||
data_start = first_day - timedelta(days=1)
|
||
data_end = last_day
|
||
month_df = df[(df['snapshot_date'] >= data_start) & (df['snapshot_date'] <= data_end)]
|
||
|
||
# 取得欄位
|
||
cols = df.columns.tolist()
|
||
col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績'])
|
||
col_cost = find_col(cols, ['成本', 'Cost'])
|
||
col_profit = find_col(cols, ['毛利', 'Profit'])
|
||
col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量'])
|
||
col_name = find_col(cols, ['商品名稱', '品名'])
|
||
|
||
# 為每一天計算 KPI
|
||
calendar_days = []
|
||
current_date = calendar_start
|
||
|
||
while current_date <= calendar_end:
|
||
# 取得星期(0=週一, 6=週日)
|
||
weekday = current_date.weekday()
|
||
weekday_names = ['週一', '週二', '週三', '週四', '週五', '週六', '週日']
|
||
|
||
# 判斷是否為國定假日
|
||
is_holiday, holiday_name = get_taiwan_holiday(current_date)
|
||
|
||
day_data = {
|
||
'date': current_date.strftime('%Y-%m-%d'),
|
||
'day': current_date.day,
|
||
'weekday': weekday_names[weekday],
|
||
'is_weekend': weekday >= 5, # 週六或週日
|
||
'is_holiday': is_holiday,
|
||
'holiday_name': holiday_name,
|
||
'is_current_month': current_date.month == month,
|
||
'has_data': False,
|
||
'revenue': 0,
|
||
'profit': 0,
|
||
'margin_rate': 0,
|
||
'sku_count': 0,
|
||
'qty': 0,
|
||
'avg_price': 0,
|
||
'dod_percent': 0,
|
||
'dod_direction': 'neutral' # 'up', 'down', 'neutral'
|
||
}
|
||
|
||
# 如果該日期在當前月份範圍內,計算 KPI
|
||
if first_day <= current_date <= last_day:
|
||
day_df = month_df[month_df['snapshot_date'] == current_date]
|
||
|
||
if not day_df.empty:
|
||
day_data['has_data'] = True
|
||
|
||
# 計算總業績
|
||
if col_amount:
|
||
day_data['revenue'] = float(day_df[col_amount].sum())
|
||
|
||
# 計算毛利(優先使用毛利欄位,否則用業績-成本計算)
|
||
if col_profit:
|
||
day_data['profit'] = float(day_df[col_profit].sum())
|
||
elif col_cost and col_amount:
|
||
total_cost = float(day_df[col_cost].sum())
|
||
day_data['profit'] = day_data['revenue'] - total_cost
|
||
|
||
# 計算毛利率
|
||
if day_data['revenue'] > 0:
|
||
day_data['margin_rate'] = (day_data['profit'] / day_data['revenue']) * 100
|
||
|
||
# 計算銷量
|
||
if col_qty:
|
||
day_data['qty'] = float(day_df[col_qty].sum())
|
||
|
||
# 計算客單價(總業績 / 總銷量)
|
||
if day_data['qty'] > 0:
|
||
day_data['avg_price'] = day_data['revenue'] / day_data['qty']
|
||
|
||
# 計算 SKU 數
|
||
if col_name:
|
||
day_data['sku_count'] = int(day_df[col_name].nunique())
|
||
|
||
# 計算 DoD%
|
||
prev_date = current_date - timedelta(days=1)
|
||
prev_df = month_df[month_df['snapshot_date'] == prev_date]
|
||
|
||
if not prev_df.empty and col_amount:
|
||
prev_revenue = float(prev_df[col_amount].sum())
|
||
if prev_revenue > 0:
|
||
dod = ((day_data['revenue'] - prev_revenue) / prev_revenue) * 100
|
||
day_data['dod_percent'] = round(dod, 1)
|
||
day_data['dod_direction'] = 'up' if dod >= 0 else 'down'
|
||
|
||
calendar_days.append(day_data)
|
||
current_date += timedelta(days=1)
|
||
|
||
# 組織成週結構(每週 7 天)
|
||
weeks = []
|
||
for i in range(0, len(calendar_days), 7):
|
||
weeks.append(calendar_days[i:i+7])
|
||
|
||
# 計算上個月和下個月的年月
|
||
prev_month = selected_month - pd.DateOffset(months=1)
|
||
next_month = selected_month + pd.DateOffset(months=1)
|
||
|
||
return {
|
||
'year': year,
|
||
'month': month,
|
||
'month_name': selected_month.strftime('%Y年%m月'),
|
||
'weeks': weeks,
|
||
'prev_month': prev_month.strftime('%Y-%m'),
|
||
'next_month': next_month.strftime('%Y-%m')
|
||
}
|
||
|
||
# ================= ⚙️ 5. 服務啟動邏輯 =================
|
||
|
||
def run_schedule():
|
||
"""在背景執行緒中運行排程"""
|
||
sys_log.info("🚀 排程服務已啟動,等待任務...")
|
||
while True:
|
||
schedule.run_pending()
|
||
time.sleep(1)
|
||
|
||
def init_scheduler():
|
||
"""初始化排程任務(Gunicorn 模式下也會執行)"""
|
||
schedule.every(1).hours.do(run_momo_task)
|
||
schedule.every(1).hours.do(run_edm_task)
|
||
schedule.every(1).hours.do(run_festival_task)
|
||
sys_log.info(f"📅 已設定每小時執行主站、EDM與購物節爬蟲任務")
|
||
|
||
schedule.every(30).minutes.do(run_auto_import_task)
|
||
sys_log.info(f"📅 已設定每 30 分鐘執行 Google Drive 自動匯入任務")
|
||
|
||
schedule.every(30).minutes.do(run_whitepage_check)
|
||
sys_log.info(f"📅 已設定每 30 分鐘執行網頁白頁監控任務")
|
||
|
||
# 啟動排程執行緒
|
||
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()
|
||
|
||
|
||
# ==========================================
|
||
# 🔧 路由清理:移除被模組化路由覆蓋的重複端點
|
||
# ==========================================
|
||
def cleanup_duplicate_routes():
|
||
"""
|
||
當模組化路由啟用時,移除 app.py 中定義的重複路由
|
||
這確保 Blueprint 中的路由優先被使用
|
||
"""
|
||
from routes import MODULAR_ENDPOINTS
|
||
|
||
if not MODULAR_ENDPOINTS:
|
||
return # 沒有啟用任何模組化路由
|
||
|
||
# 找出需要移除的端點(app.py 中定義的,沒有藍圖前綴)
|
||
endpoints_to_remove = set()
|
||
rules_to_remove = []
|
||
|
||
for rule in list(app.url_map.iter_rules()):
|
||
# 只移除 app.py 中定義的路由(沒有藍圖前綴)
|
||
if '.' not in rule.endpoint and rule.endpoint in MODULAR_ENDPOINTS:
|
||
endpoints_to_remove.add(rule.endpoint)
|
||
rules_to_remove.append(rule)
|
||
|
||
if not endpoints_to_remove:
|
||
return
|
||
|
||
# 從 view_functions 中移除重複的端點
|
||
for endpoint in endpoints_to_remove:
|
||
if endpoint in app.view_functions:
|
||
del app.view_functions[endpoint]
|
||
|
||
# 從 url_map._rules 中移除對應的規則
|
||
# 注意:這是直接操作內部結構,需要同時更新索引
|
||
for rule in rules_to_remove:
|
||
try:
|
||
app.url_map._rules.remove(rule)
|
||
# 同時從 _rules_by_endpoint 中移除
|
||
if rule.endpoint in app.url_map._rules_by_endpoint:
|
||
endpoint_rules = app.url_map._rules_by_endpoint[rule.endpoint]
|
||
if rule in endpoint_rules:
|
||
endpoint_rules.remove(rule)
|
||
if not endpoint_rules:
|
||
del app.url_map._rules_by_endpoint[rule.endpoint]
|
||
except (ValueError, KeyError):
|
||
pass # 規則可能已被移除
|
||
|
||
sys_log.info(f"[Routes] 🧹 已清理 {len(endpoints_to_remove)} 條重複路由 (模組化路由優先)")
|
||
|
||
|
||
# 執行路由清理
|
||
cleanup_duplicate_routes()
|
||
|
||
|
||
# ==========================================
|
||
# 🔧 端點別名:確保模組化路由啟用後 url_for 仍可使用舊名稱
|
||
# ==========================================
|
||
def register_endpoint_aliases():
|
||
"""
|
||
當模組化路由啟用時,為藍圖端點建立別名
|
||
這樣模板中的 url_for('index') 仍可正常運作
|
||
"""
|
||
from routes import MODULAR_ENDPOINTS
|
||
from werkzeug.routing import Rule
|
||
|
||
if not MODULAR_ENDPOINTS:
|
||
return
|
||
|
||
# 端點別名對應表:舊名稱 -> (藍圖端點, URL路徑, 方法)
|
||
aliases = {
|
||
# dashboard_routes
|
||
'index': ('dashboard.index', '/', ['GET']),
|
||
'brand_assets': ('dashboard.brand_assets', '/brand_assets', ['GET']),
|
||
# system_routes
|
||
'health_check': ('system.health_check', '/health', ['GET']),
|
||
'prometheus_metrics': ('system.prometheus_metrics', '/metrics', ['GET']),
|
||
'settings': ('system.settings', '/settings', ['GET']),
|
||
'system_settings_page': ('system.system_settings_page', '/system_settings', ['GET']),
|
||
'show_logs': ('system.show_logs', '/logs', ['GET']),
|
||
'get_logs_api': ('system.get_logs_api', '/api/logs', ['GET']),
|
||
'add_category': ('system.add_category', '/api/categories', ['POST']),
|
||
'update_category': ('system.update_category', '/api/categories/<int:category_id>', ['PUT']),
|
||
'delete_category': ('system.delete_category', '/api/categories/<int:category_id>', ['DELETE']),
|
||
'test_url': ('system.test_url', '/api/test_url', ['POST']),
|
||
'trigger_backup': ('system.trigger_backup', '/api/backup', ['POST']),
|
||
'download_backup': ('system.download_backup', '/api/backup/download/<path:filename>', ['GET']),
|
||
# edm_routes
|
||
'edm_dashboard': ('edm.edm_dashboard', '/edm', ['GET']),
|
||
'festival_dashboard': ('edm.festival_dashboard', '/festival', ['GET']),
|
||
# monthly_routes
|
||
'monthly_summary_analysis_page': ('monthly.monthly_summary_analysis_page', '/monthly_summary_analysis', ['GET']),
|
||
'get_monthly_summary_data': ('monthly.get_monthly_summary_data', '/api/monthly_summary_data', ['GET']),
|
||
# daily_sales_routes
|
||
'daily_sales': ('daily_sales.daily_sales', '/daily_sales', ['GET']),
|
||
'export_daily_sales_category': ('daily_sales.export_daily_sales_category', '/daily_sales/export', ['GET']),
|
||
'export_marketing_summary_excel': ('daily_sales.export_marketing_summary_excel', '/daily_sales/export_marketing', ['GET']),
|
||
}
|
||
|
||
registered_count = 0
|
||
for old_name, (new_name, url_path, methods) in aliases.items():
|
||
# 只有當舊端點不存在且新端點存在時才建立別名
|
||
if old_name not in app.view_functions and new_name in app.view_functions:
|
||
# 1. 複製 view function
|
||
app.view_functions[old_name] = app.view_functions[new_name]
|
||
# 2. 建立 URL 規則(讓 url_for 可以找到)
|
||
rule = Rule(url_path, endpoint=old_name, methods=methods)
|
||
app.url_map.add(rule)
|
||
registered_count += 1
|
||
|
||
if registered_count > 0:
|
||
sys_log.info(f"[Routes] 🔗 已建立 {registered_count} 個端點別名 (相容舊版 url_for)")
|
||
|
||
|
||
# 執行端點別名註冊
|
||
register_endpoint_aliases()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
banner = f" MOMO 專業數據管理系統 {SYSTEM_VERSION} "
|
||
sys_log.info(f"{ '='*20} {banner} {'='*20}")
|
||
|
||
# 啟動前先檢查資料庫結構
|
||
repair_database_schema()
|
||
|
||
# 使用生產環境域名
|
||
public_url = "https://momo.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}")
|