This commit is contained in:
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.107"
|
||||
SYSTEM_VERSION = "V10.108"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ from services.logger_manager import SystemLogger
|
||||
from services.daily_sales_service import prepare_marketing_summary
|
||||
from services.cache_manager import (
|
||||
_SALES_PROCESSED_CACHE,
|
||||
_SALES_OPTIONS_CACHE,
|
||||
_SALES_ANALYSIS_RESULT_CACHE,
|
||||
set_sales_processed_cache,
|
||||
)
|
||||
from utils.text_helpers import get_color_for_string
|
||||
@@ -41,6 +43,10 @@ sales_bp = Blueprint('sales', __name__)
|
||||
|
||||
_TABLE_DATA_CACHE = {}
|
||||
_TABLE_DATA_CACHE_TTL = 60
|
||||
_SALES_PREVIEW_CACHE_TTL = 600
|
||||
_SALES_OPTIONS_CACHE_TTL = 1800
|
||||
_SALES_PAGE_CONTEXT_CACHE_TTL = 180
|
||||
_SALES_PAGE_CONTEXT_CACHE_MAX = 24
|
||||
|
||||
|
||||
# ==========================================
|
||||
@@ -52,6 +58,214 @@ from utils.df_helpers import find_col # noqa: E402, F401
|
||||
from utils.security import validate_table_name, safe_read_sql # noqa: E402, F401
|
||||
|
||||
|
||||
def _get_timed_cache(store, key, ttl_seconds):
|
||||
entry = store.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
if time.time() - entry.get('time', 0) >= ttl_seconds:
|
||||
store.pop(key, None)
|
||||
return None
|
||||
return entry.get('data')
|
||||
|
||||
|
||||
def _set_timed_cache(store, key, data, max_entries=64):
|
||||
if len(store) >= max_entries:
|
||||
oldest_key = min(store, key=lambda k: store[k].get('time', 0))
|
||||
store.pop(oldest_key, None)
|
||||
store[key] = {'data': data, 'time': time.time()}
|
||||
|
||||
|
||||
def _sales_analysis_args_fingerprint(table_name, cache_key, processed_time=None):
|
||||
args = tuple(
|
||||
(key, tuple(values))
|
||||
for key, values in sorted(request.args.lists())
|
||||
)
|
||||
raw = repr((table_name, cache_key, processed_time or 0, args)).encode('utf-8')
|
||||
return hashlib.md5(raw).hexdigest()
|
||||
|
||||
|
||||
def _get_sales_page_context_cache(cache_key):
|
||||
return _get_timed_cache(
|
||||
_SALES_ANALYSIS_RESULT_CACHE,
|
||||
cache_key,
|
||||
_SALES_PAGE_CONTEXT_CACHE_TTL,
|
||||
)
|
||||
|
||||
|
||||
def _set_sales_page_context_cache(cache_key, context):
|
||||
_set_timed_cache(
|
||||
_SALES_ANALYSIS_RESULT_CACHE,
|
||||
cache_key,
|
||||
context,
|
||||
max_entries=_SALES_PAGE_CONTEXT_CACHE_MAX,
|
||||
)
|
||||
|
||||
|
||||
def _format_sales_data_range(min_date, max_date):
|
||||
if not min_date or not max_date:
|
||||
return ''
|
||||
try:
|
||||
if isinstance(min_date, str):
|
||||
min_date_obj = datetime.strptime(min_date.split()[0], '%Y-%m-%d')
|
||||
max_date_obj = datetime.strptime(max_date.split()[0], '%Y-%m-%d')
|
||||
else:
|
||||
min_date_obj = min_date
|
||||
max_date_obj = max_date
|
||||
return f"{min_date_obj.year}年{min_date_obj.month}月 ~ {max_date_obj.year}年{max_date_obj.month}月"
|
||||
except Exception:
|
||||
return f"{min_date} ~ {max_date}"
|
||||
|
||||
|
||||
def _fetch_sales_data_range(engine, table_name):
|
||||
validate_table_name(table_name)
|
||||
cache_key = f"sales_analysis:data_range:{table_name}"
|
||||
cached = _get_timed_cache(_SALES_OPTIONS_CACHE, cache_key, _SALES_PREVIEW_CACHE_TTL)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
date_query = text(f'SELECT MIN("日期") as min_date, MAX("日期") as max_date FROM "{table_name}"')
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(date_query).fetchone()
|
||||
db_data_range = _format_sales_data_range(result[0], result[1]) if result else ''
|
||||
except Exception as e:
|
||||
sys_log.warning(f"[Sales Analysis] 無法取得資料期間範圍: {e}")
|
||||
db_data_range = ''
|
||||
|
||||
_set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, db_data_range)
|
||||
return db_data_range
|
||||
|
||||
|
||||
def _preview_sales_filter_options(engine, table_name):
|
||||
"""首次進入業績分析時的輕量下拉資料,避免每次載入都掃預覽資料。"""
|
||||
validate_table_name(table_name)
|
||||
cache_key = f"sales_analysis:preview_options:{table_name}"
|
||||
cached = _get_timed_cache(_SALES_OPTIONS_CACHE, cache_key, _SALES_PREVIEW_CACHE_TTL)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
options = {
|
||||
'categories': [],
|
||||
'brands': [],
|
||||
'vendors': [],
|
||||
'activities': [],
|
||||
'payments': [],
|
||||
'months': [],
|
||||
}
|
||||
preview_df = pd.read_sql(f'SELECT * FROM "{table_name}" LIMIT 1000', engine)
|
||||
if preview_df.empty:
|
||||
_set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, options)
|
||||
return options
|
||||
|
||||
cols = preview_df.columns.tolist()
|
||||
|
||||
def find_preview_col(keywords):
|
||||
for k in keywords:
|
||||
for col in cols:
|
||||
if k in str(col):
|
||||
return col
|
||||
return None
|
||||
|
||||
col_category = find_preview_col(['館別', '商品館', '分類', 'Category'])
|
||||
col_brand = find_preview_col(['品牌', 'Brand'])
|
||||
col_vendor = find_preview_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier'])
|
||||
col_activity = find_preview_col(['折扣活動名稱', '折價券活動名稱', '滿額再折扣活動名稱', '活動', 'Activity', 'Campaign'])
|
||||
col_payment = find_preview_col(['付款', 'Payment', 'Pay'])
|
||||
col_date_part = find_preview_col(['日期', '交易日期', 'Date', 'Day'])
|
||||
|
||||
def clean_values(col):
|
||||
if not col:
|
||||
return []
|
||||
return sorted([
|
||||
x for x in preview_df[col].dropna().astype(str).unique().tolist()
|
||||
if x and x.strip()
|
||||
])
|
||||
|
||||
options['categories'] = clean_values(col_category)
|
||||
options['brands'] = clean_values(col_brand)
|
||||
options['vendors'] = clean_values(col_vendor)
|
||||
options['activities'] = clean_values(col_activity)
|
||||
options['payments'] = clean_values(col_payment)
|
||||
|
||||
if col_date_part:
|
||||
try:
|
||||
with 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()
|
||||
options['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(options['months'])} 個月份")
|
||||
except Exception as e:
|
||||
sys_log.warning(f"[Sales Analysis] 無法從「{col_date_part}」欄位提取月份: {e}")
|
||||
|
||||
_set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, options)
|
||||
return options
|
||||
|
||||
|
||||
def _get_sales_filter_options(engine, table_name, cols_map):
|
||||
"""完整篩選選項共用快取;資料匯入後 clear_sales_cache() 會一起清掉。"""
|
||||
validate_table_name(table_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_date = cols_map.get('date')
|
||||
option_cols = (col_category, col_brand, col_vendor, col_activity, col_payment, col_date)
|
||||
digest = hashlib.md5(repr(option_cols).encode('utf-8')).hexdigest()
|
||||
cache_key = f"sales_analysis:filter_options:{table_name}:{digest}"
|
||||
cached = _get_timed_cache(_SALES_OPTIONS_CACHE, cache_key, _SALES_OPTIONS_CACHE_TTL)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
options = {
|
||||
'categories': [],
|
||||
'brands': [],
|
||||
'vendors': [],
|
||||
'activities': [],
|
||||
'payments': [],
|
||||
'months': [],
|
||||
}
|
||||
|
||||
def read_distinct(conn, col):
|
||||
if not col:
|
||||
return []
|
||||
sql = f'SELECT DISTINCT "{col}" FROM "{table_name}" WHERE "{col}" IS NOT NULL AND "{col}" <> \'\' ORDER BY "{col}"'
|
||||
result = conn.execute(text(sql)).fetchall()
|
||||
return [str(row[0]) for row in result if row[0]]
|
||||
|
||||
with engine.connect() as conn:
|
||||
options['categories'] = read_distinct(conn, col_category)
|
||||
options['brands'] = read_distinct(conn, col_brand)
|
||||
options['vendors'] = read_distinct(conn, col_vendor)
|
||||
options['activities'] = read_distinct(conn, col_activity)
|
||||
options['payments'] = read_distinct(conn, col_payment)
|
||||
|
||||
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()
|
||||
months = [row[0] for row in result if row[0] and '-' in str(row[0])]
|
||||
if months:
|
||||
options['months'] = months
|
||||
sys_log.info(f"[Sales Analysis] 從欄位 {field} 提取到 {len(months)} 個月份: {months}")
|
||||
break
|
||||
except Exception as ex:
|
||||
sys_log.warning(f"[Sales Analysis] 從欄位 {field} 提取月份失敗: {ex}")
|
||||
|
||||
_set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, options)
|
||||
return options
|
||||
|
||||
|
||||
def _growth_empty_payload(now_taipei=None):
|
||||
now_taipei = now_taipei or datetime.now(TAIPEI_TZ)
|
||||
return (
|
||||
@@ -558,7 +772,7 @@ def sales_analysis():
|
||||
|
||||
# 1. 檢查資料表是否存在
|
||||
inspector = inspect(db.engine)
|
||||
if table_name not in inspector.get_table_names():
|
||||
if not inspector.has_table(table_name):
|
||||
return render_template('sales_analysis.html',
|
||||
error="尚未匯入「即時業績(全月)」資料,請先至設定頁面匯入 Excel。",
|
||||
table_name=table_name,
|
||||
@@ -571,31 +785,8 @@ def sales_analysis():
|
||||
active_page='sales',
|
||||
db_data_range='')
|
||||
|
||||
# V-New: 查詢資料庫的資料期間範圍
|
||||
db_data_range = ''
|
||||
try:
|
||||
# 取得日期欄位的最小值和最大值
|
||||
from sqlalchemy import text
|
||||
date_query = text(f"SELECT MIN(日期) as min_date, MAX(日期) as max_date FROM {table_name}")
|
||||
# V-Fix: SQLAlchemy 2.0 需要使用 connection 對象
|
||||
with db.engine.connect() as conn:
|
||||
result = conn.execute(date_query).fetchone()
|
||||
if result and result[0] and result[1]:
|
||||
min_date = result[0]
|
||||
max_date = result[1]
|
||||
# 格式化為 YYYY年MM月 格式
|
||||
if isinstance(min_date, str):
|
||||
from datetime import datetime
|
||||
try:
|
||||
min_date_obj = datetime.strptime(min_date.split()[0], '%Y-%m-%d')
|
||||
max_date_obj = datetime.strptime(max_date.split()[0], '%Y-%m-%d')
|
||||
db_data_range = f"{min_date_obj.year}年{min_date_obj.month}月 ~ {max_date_obj.year}年{max_date_obj.month}月"
|
||||
except:
|
||||
db_data_range = f"{min_date} ~ {max_date}"
|
||||
else:
|
||||
db_data_range = f"{min_date.year}年{min_date.month}月 ~ {max_date.year}年{max_date.month}月"
|
||||
except Exception as e:
|
||||
sys_log.warning(f"[Sales Analysis] 無法取得資料期間範圍: {e}")
|
||||
# V-Opt: 資料期間在同一批匯入後穩定,避免每次首屏都查 MIN/MAX。
|
||||
db_data_range = _fetch_sales_data_range(db.engine, table_name)
|
||||
|
||||
# V-New: 取得篩選參數
|
||||
data_range_param = request.args.get('data_range', '') # 不再設預設值
|
||||
@@ -605,63 +796,13 @@ def sales_analysis():
|
||||
# V-New: 按需載入 - 如果沒有任何篩選條件,顯示引導頁面
|
||||
if not data_range_param and not start_date and not end_date:
|
||||
sys_log.info("[Sales Analysis] 👋 首次進入頁面,等待用戶選擇篩選條件")
|
||||
|
||||
# V-Fix: 即使在引導頁面,也要提供下拉選單選項
|
||||
# V-Opt: 讀取較多筆數(1000筆)以獲得更完整的月份資訊
|
||||
preview_df = pd.read_sql(f"SELECT * FROM {table_name} LIMIT 1000", db.engine)
|
||||
preview_categories = []
|
||||
preview_brands = []
|
||||
preview_vendors = []
|
||||
preview_activities = []
|
||||
preview_payments = []
|
||||
preview_months = [] # V-New: 新增月份列表
|
||||
|
||||
if not preview_df.empty:
|
||||
cols = preview_df.columns.tolist()
|
||||
def find_col(keywords):
|
||||
for k in keywords:
|
||||
for col in cols:
|
||||
if k in str(col): return col
|
||||
return None
|
||||
|
||||
col_category = find_col(['館別', '商品館', '分類', 'Category'])
|
||||
col_brand = find_col(['品牌', 'Brand'])
|
||||
col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier'])
|
||||
# V-Fix: 優先匹配具體的活動欄位名稱
|
||||
col_activity = find_col(['折扣活動名稱', '折價券活動名稱', '滿額再折扣活動名稱', '活動', 'Activity', 'Campaign'])
|
||||
col_payment = find_col(['付款', 'Payment', 'Pay'])
|
||||
# V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期)
|
||||
col_date_part = find_col(['日期', '交易日期', 'Date', 'Day'])
|
||||
col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created'])
|
||||
|
||||
# V-Fix: 篩選掉空字串,只保留有效數據
|
||||
if col_category:
|
||||
preview_categories = sorted([x for x in preview_df[col_category].dropna().astype(str).unique().tolist() if x and x.strip()])
|
||||
if col_brand:
|
||||
preview_brands = sorted([x for x in preview_df[col_brand].dropna().astype(str).unique().tolist() if x and x.strip()])
|
||||
if col_vendor:
|
||||
preview_vendors = sorted([x for x in preview_df[col_vendor].dropna().astype(str).unique().tolist() if x and x.strip()])
|
||||
if col_activity:
|
||||
preview_activities = sorted([x for x in preview_df[col_activity].dropna().astype(str).unique().tolist() if x and x.strip()])
|
||||
if col_payment:
|
||||
preview_payments = sorted([x for x in preview_df[col_payment].dropna().astype(str).unique().tolist() if x and x.strip()])
|
||||
|
||||
# V-Fix: 從數據庫直接查詢所有月份(而不是從預覽數據提取,避免只獲得部分月份)
|
||||
if col_date_part:
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
with db.engine.connect() as conn:
|
||||
result = conn.execute(text(f"""
|
||||
SELECT DISTINCT replace(substr("{col_date_part}", 1, 7), '/', '-') as month
|
||||
FROM {table_name}
|
||||
WHERE "{col_date_part}" IS NOT NULL AND "{col_date_part}" != ''
|
||||
ORDER BY month
|
||||
""")).fetchall()
|
||||
preview_months = [row[0] for row in result if row[0] and '-' in str(row[0])]
|
||||
sys_log.info(f"[Sales Analysis] 預覽模式從「{col_date_part}」欄位提取到 {len(preview_months)} 個月份")
|
||||
except Exception as e:
|
||||
sys_log.warning(f"[Sales Analysis] 無法從「{col_date_part}」欄位提取月份: {e}")
|
||||
pass
|
||||
preview_options = _preview_sales_filter_options(db.engine, table_name)
|
||||
preview_categories = preview_options['categories']
|
||||
preview_brands = preview_options['brands']
|
||||
preview_vendors = preview_options['vendors']
|
||||
preview_activities = preview_options['activities']
|
||||
preview_payments = preview_options['payments']
|
||||
preview_months = preview_options['months']
|
||||
|
||||
# 傳遞必要的變數以避免模板錯誤
|
||||
selected_metric = request.args.get('metric', 'amount')
|
||||
@@ -1020,6 +1161,16 @@ def sales_analysis():
|
||||
}
|
||||
set_sales_processed_cache(cache_key, cache_entry, aliases=(table_name,))
|
||||
|
||||
processed_entry = _SALES_PROCESSED_CACHE.get(cache_key, {})
|
||||
page_cache_key = "sales_analysis:page_context:" + _sales_analysis_args_fingerprint(
|
||||
table_name,
|
||||
cache_key,
|
||||
processed_entry.get('time'),
|
||||
)
|
||||
cached_context = _get_sales_page_context_cache(page_cache_key)
|
||||
if cached_context:
|
||||
return render_template('sales_analysis.html', **cached_context)
|
||||
|
||||
# 🚩 V-Opt: 使用共用篩選函式
|
||||
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
|
||||
if err:
|
||||
@@ -1043,9 +1194,6 @@ def sales_analysis():
|
||||
col_date = cols_map.get('date')
|
||||
col_pid = cols_map.get('pid')
|
||||
|
||||
# V-Fix: 準備前端需要的下拉選單資料
|
||||
# V-Opt: 從數據庫直接查詢所有可用選項,而不是只從篩選後的快取中讀取
|
||||
# 這樣可以確保下拉選單顯示完整的可選項,即使當前篩選範圍很小
|
||||
all_categories = []
|
||||
all_brands = []
|
||||
all_vendors = []
|
||||
@@ -1054,58 +1202,13 @@ def sales_analysis():
|
||||
all_months = []
|
||||
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
# V-Fix: SQLAlchemy 2.0 需要使用 connection 對象
|
||||
with db.engine.connect() as conn:
|
||||
# 讀取完整資料表的所有可用選項(使用 DISTINCT 以提升效能)
|
||||
# V-Fix: 使用單引號空字串,兼容 PostgreSQL
|
||||
if col_category:
|
||||
sql = f"SELECT DISTINCT \"{col_category}\" FROM {table_name} WHERE \"{col_category}\" IS NOT NULL AND \"{col_category}\" <> ''"
|
||||
result = conn.execute(text(sql)).fetchall()
|
||||
all_categories = sorted([str(row[0]) for row in result if row[0]])
|
||||
|
||||
if col_brand:
|
||||
sql = f"SELECT DISTINCT \"{col_brand}\" FROM {table_name} WHERE \"{col_brand}\" IS NOT NULL AND \"{col_brand}\" <> ''"
|
||||
result = conn.execute(text(sql)).fetchall()
|
||||
all_brands = sorted([str(row[0]) for row in result if row[0]])
|
||||
|
||||
if col_vendor:
|
||||
sql = f"SELECT DISTINCT \"{col_vendor}\" FROM {table_name} WHERE \"{col_vendor}\" IS NOT NULL AND \"{col_vendor}\" <> ''"
|
||||
result = conn.execute(text(sql)).fetchall()
|
||||
all_vendors = sorted([str(row[0]) for row in result if row[0]])
|
||||
|
||||
if col_activity:
|
||||
sql = f"SELECT DISTINCT \"{col_activity}\" FROM {table_name} WHERE \"{col_activity}\" IS NOT NULL AND \"{col_activity}\" <> ''"
|
||||
result = conn.execute(text(sql)).fetchall()
|
||||
all_activities = sorted([str(row[0]) for row in result if row[0]])
|
||||
|
||||
if col_payment:
|
||||
sql = f"SELECT DISTINCT \"{col_payment}\" FROM {table_name} WHERE \"{col_payment}\" IS NOT NULL AND \"{col_payment}\" <> ''"
|
||||
result = conn.execute(text(sql)).fetchall()
|
||||
all_payments = sorted([str(row[0]) for row in result if row[0]])
|
||||
|
||||
# V-Fix: 從數據庫提取所有月份(格式:YYYY-MM)
|
||||
if col_date:
|
||||
# 從日期欄位提取月份(支援多種日期欄位名稱)
|
||||
date_fields = ['日期', '訂單日期', '時間']
|
||||
for field in date_fields:
|
||||
try:
|
||||
# V-Fix: 使用 substr 提取年月部分,並將斜線替換為橫線
|
||||
# 數據庫格式: "2025/07/01" -> 提取前7個字符 "2025/07" -> 替換斜線 "2025-07"
|
||||
result = conn.execute(text(f"""
|
||||
SELECT DISTINCT replace(substr(\"{field}\", 1, 7), '/', '-') as month
|
||||
FROM {table_name}
|
||||
WHERE \"{field}\" IS NOT NULL AND \"{field}\" != ''
|
||||
ORDER BY month
|
||||
""")).fetchall()
|
||||
if result and len(result) > 0:
|
||||
all_months = [row[0] for row in result if row[0] and '-' in str(row[0])]
|
||||
if all_months: # 如果成功提取到月份,就使用這個欄位
|
||||
sys_log.info(f"[Sales Analysis] 從欄位 {field} 提取到 {len(all_months)} 個月份: {all_months}")
|
||||
break
|
||||
except Exception as ex:
|
||||
sys_log.warning(f"[Sales Analysis] 從欄位 {field} 提取月份失敗: {ex}")
|
||||
continue
|
||||
filter_options = _get_sales_filter_options(db.engine, table_name, cols_map)
|
||||
all_categories = filter_options['categories']
|
||||
all_brands = filter_options['brands']
|
||||
all_vendors = filter_options['vendors']
|
||||
all_activities = filter_options['activities']
|
||||
all_payments = filter_options['payments']
|
||||
all_months = filter_options['months']
|
||||
except Exception as e:
|
||||
sys_log.warning(f"[Sales Analysis] 從數據庫查詢下拉選項失敗: {e}")
|
||||
# 如果查詢失敗,回退到從快取讀取
|
||||
@@ -1531,54 +1634,78 @@ def sales_analysis():
|
||||
if not target_df.empty:
|
||||
marketing_data = prepare_marketing_summary(target_df, sort_by=selected_metric)
|
||||
|
||||
return render_template('sales_analysis.html',
|
||||
marketing_data=marketing_data, # V-New: 傳遞行銷活動數據
|
||||
items=table_items,
|
||||
kpi={
|
||||
'revenue': total_revenue,
|
||||
'qty': total_qty,
|
||||
'count': total_count,
|
||||
'sku_count': sku_count, # V-Fix 2026-01-15: 唯一商品數
|
||||
'cost': total_cost,
|
||||
'gross_margin': gross_margin,
|
||||
'gross_margin_rate': gross_margin_rate,
|
||||
'avg_price': avg_price
|
||||
},
|
||||
insights=insights,
|
||||
abc_stats=abc_stats, # V-New: 傳遞 ABC 分析數據
|
||||
vendor_stats=vendor_stats, # V-New: 傳遞廠商排行數據
|
||||
seasonality_data=seasonality_data, # V-New: 傳遞淡旺季數據
|
||||
bar_data=bar_data,
|
||||
cat_data=cat_data,
|
||||
category_data=cat_data,
|
||||
price_dist_data=price_dist_data,
|
||||
scatter_data=scatter_data,
|
||||
bcg_data=bcg_data, # V-New: 傳遞 BCG 數據
|
||||
dow_data=dow_data,
|
||||
hourly_data=hourly_data,
|
||||
monthly_data=monthly_data,
|
||||
weekly_data=weekly_data,
|
||||
heatmap_data=heatmap_data,
|
||||
treemap_data=treemap_data,
|
||||
all_categories=all_categories,
|
||||
all_brands=all_brands, all_vendors=all_vendors, all_activities=all_activities, all_payments=all_payments,
|
||||
all_months=all_months, # V-New: 傳遞月份列表
|
||||
selected_category=selected_category,
|
||||
selected_brand=selected_brand, selected_vendor=selected_vendor,
|
||||
selected_activity=selected_activity, selected_payment=selected_payment,
|
||||
selected_dow=selected_dow, selected_hour=selected_hour,
|
||||
selected_month=selected_month,
|
||||
selected_metric=selected_metric,
|
||||
keyword=keyword, min_price=min_price, max_price=max_price,
|
||||
min_margin=min_margin, max_margin=max_margin,
|
||||
cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category, 'date': col_date, 'cost': col_cost, 'profit': col_profit, 'vendor': col_vendor, 'brand': col_brand, 'return_qty': col_return_qty, 'pid': col_pid},
|
||||
table_name=table_name,
|
||||
data_range_months=data_range_months,
|
||||
start_date=start_date, # V-New: 傳遞自訂開始日期
|
||||
end_date=end_date, # V-New: 傳遞自訂結束日期
|
||||
total_records=len(df),
|
||||
active_page='sales',
|
||||
db_data_range=db_data_range) # V-New: 傳遞資料庫資料期間
|
||||
context = {
|
||||
'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,
|
||||
'category_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,
|
||||
'all_categories': all_categories,
|
||||
'all_brands': all_brands,
|
||||
'all_vendors': all_vendors,
|
||||
'all_activities': all_activities,
|
||||
'all_payments': all_payments,
|
||||
'all_months': all_months,
|
||||
'selected_category': selected_category,
|
||||
'selected_brand': selected_brand,
|
||||
'selected_vendor': selected_vendor,
|
||||
'selected_activity': selected_activity,
|
||||
'selected_payment': selected_payment,
|
||||
'selected_dow': selected_dow,
|
||||
'selected_hour': selected_hour,
|
||||
'selected_month': selected_month,
|
||||
'selected_metric': selected_metric,
|
||||
'keyword': keyword,
|
||||
'min_price': min_price,
|
||||
'max_price': max_price,
|
||||
'min_margin': min_margin,
|
||||
'max_margin': max_margin,
|
||||
'cols': {
|
||||
'name': col_name,
|
||||
'amount': col_amount,
|
||||
'qty': col_qty,
|
||||
'cat': col_category,
|
||||
'date': col_date,
|
||||
'cost': col_cost,
|
||||
'profit': col_profit,
|
||||
'vendor': col_vendor,
|
||||
'brand': col_brand,
|
||||
'return_qty': col_return_qty,
|
||||
'pid': col_pid,
|
||||
},
|
||||
'table_name': table_name,
|
||||
'data_range_months': data_range_months,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'total_records': len(df),
|
||||
'active_page': 'sales',
|
||||
'db_data_range': db_data_range,
|
||||
}
|
||||
_set_sales_page_context_cache(page_cache_key, context)
|
||||
return render_template('sales_analysis.html', **context)
|
||||
|
||||
except Exception as e:
|
||||
sys_log.error(f"Sales Analysis Error: {e}")
|
||||
|
||||
@@ -847,11 +847,13 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if not no_filter %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-treemap@2.0.2/dist/chartjs-chart-treemap.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.11.5/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.11.5/js/dataTables.bootstrap5.min.js"></script>
|
||||
{% endif %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/zh-tw.js"></script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user