Files
ewoooc/routes/monthly_routes.py
ogt 5f3e88c9ca
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
fix(analytics): link report data to selected period
2026-07-15 15:54:35 +08:00

746 lines
31 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
月結分析路由模組
包含:月份總表數據分析展示頁面與 API
"""
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, jsonify, render_template
from auth import login_required
from sqlalchemy import func, desc, text, case, and_, or_
from config import BASE_DIR, SYSTEM_VERSION, DATABASE_TYPE
from database.manager import DatabaseManager
from database.models import MonthlySummaryAnalysis
from services.logger_manager import SystemLogger
from services.analysis_period_service import make_analysis_period, month_end, parse_month
def get_group_concat_sql():
"""根據資料庫類型回傳適當的聚合函數 SQL"""
if DATABASE_TYPE == 'postgresql':
return """
SELECT
STRING_AGG(DISTINCT year::TEXT, ',') as years,
STRING_AGG(DISTINCT month::TEXT, ',') as months,
STRING_AGG(DISTINCT division, ',') as divisions,
STRING_AGG(DISTINCT pm_name, ',') as pms,
STRING_AGG(DISTINCT area_name, ',') as areas,
STRING_AGG(DISTINCT vendor_name, ',') as vendors,
STRING_AGG(DISTINCT trade_type, ',') as trades
FROM monthly_summary_analysis
"""
else:
return """
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
"""
# 時區設定
TAIPEI_TZ = timezone(timedelta(hours=8))
# Logger
sys_log = SystemLogger("MonthlyRoutes").get_logger()
# Blueprint 定義
monthly_bp = Blueprint('monthly', __name__)
def _split_csv_param(value):
if not value:
return []
return [item.strip() for item in value.split(',') if item.strip()]
def _apply_monthly_filters(query, *, year=None, month=None, division=None, pm_name=None,
brand_name=None, vendor_name=None, area_name=None,
trade_type=None, ignore_year=False):
if year and not ignore_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)
area_values = _split_csv_param(area_name)
if len(area_values) > 1:
query = query.filter(MonthlySummaryAnalysis.area_name.in_(area_values))
elif len(area_values) == 1:
query = query.filter(MonthlySummaryAnalysis.area_name == area_values[0])
if trade_type:
query = query.filter(MonthlySummaryAnalysis.trade_type == trade_type)
return query
def _monthly_key_expr():
return MonthlySummaryAnalysis.year * 100 + MonthlySummaryAnalysis.month
def _resolve_monthly_period(session, *, year=None, month=None, start_month=None, end_month=None):
latest = session.query(
MonthlySummaryAnalysis.year,
MonthlySummaryAnalysis.month,
).order_by(
MonthlySummaryAnalysis.year.desc(),
MonthlySummaryAnalysis.month.desc(),
).first()
latest_month = (
datetime(int(latest.year), int(latest.month), 1).date()
if latest else datetime.now(TAIPEI_TZ).date().replace(day=1)
)
parsed_start = parse_month(start_month)
parsed_end = parse_month(end_month)
if parsed_start or parsed_end:
period_start = parsed_start or parsed_end
period_end = parsed_end or parsed_start
elif year or month:
selected_year = int(year) if year else latest_month.year
selected_month = int(month) if month else 1
period_start = datetime(selected_year, selected_month, 1).date()
period_end = period_start if month else datetime(selected_year, 12, 1).date()
else:
period_start = latest_month.replace(month=1)
period_end = latest_month
if period_start > period_end:
period_start, period_end = period_end, period_start
previous_start = period_start.replace(year=period_start.year - 1)
previous_end = period_end.replace(year=period_end.year - 1)
analysis_period = make_analysis_period(
period_start,
month_end(period_end),
mode='month' if period_start == period_end else 'month_range',
)
previous_period = make_analysis_period(
previous_start,
month_end(previous_end),
mode='comparison',
)
return {
'start_key': period_start.year * 100 + period_start.month,
'end_key': period_end.year * 100 + period_end.month,
'previous_start_key': previous_start.year * 100 + previous_start.month,
'previous_end_key': previous_end.year * 100 + previous_end.month,
'current_year': period_end.year,
'previous_year': period_end.year - 1,
'analysis_period': analysis_period,
'previous_period': previous_period,
'label': analysis_period['label'],
'previous_label': previous_period['label'],
}
def _apply_monthly_period(query, period, *, include_previous=False):
key_expr = _monthly_key_expr()
current_clause = and_(
key_expr >= period['start_key'],
key_expr <= period['end_key'],
)
if not include_previous:
return query.filter(current_clause)
previous_clause = and_(
key_expr >= period['previous_start_key'],
key_expr <= period['previous_end_key'],
)
return query.filter(or_(current_clause, previous_clause))
def _apply_monthly_dimensions(query, *, division=None, pm_name=None, brand_name=None,
vendor_name=None, area_name=None, trade_type=None):
return _apply_monthly_filters(
query,
division=division,
pm_name=pm_name,
brand_name=brand_name,
vendor_name=vendor_name,
area_name=area_name,
trade_type=trade_type,
ignore_year=True,
)
def _month_key_to_date(month_key):
return datetime(int(month_key) // 100, int(month_key) % 100, 1).date()
def _month_distance(start_key, target_key):
start = _month_key_to_date(start_key)
target = _month_key_to_date(target_key)
return (target.year - start.year) * 12 + target.month - start.month
def _serialize_comparison_trend(rows, period):
serialized = []
for row in rows:
key = int(row.year) * 100 + int(row.month)
if period['start_key'] <= key <= period['end_key']:
role = 'current'
slot = _month_distance(period['start_key'], key)
slot_date = _month_key_to_date(key)
elif period['previous_start_key'] <= key <= period['previous_end_key']:
role = 'previous'
slot = _month_distance(period['previous_start_key'], key)
slot_date = _month_key_to_date(key).replace(year=int(row.year) + 1)
else:
continue
serialized.append({
'date': f"{row.year}/{row.month}",
'sales': int(row.sales or 0),
'period_role': role,
'slot': slot,
'slot_label': slot_date.strftime('%Y/%m'),
})
return serialized
# ==========================================
# 頁面路由
# ==========================================
@monthly_bp.route('/monthly_summary_analysis')
@login_required
def monthly_summary_analysis_page():
"""月份總表數據分析展示頁 (Phase 9)"""
start_month = request.args.get('start_month')
end_month = request.args.get('end_month')
year = request.args.get('year', type=int)
month = request.args.get('month', type=int)
if not start_month and year:
start_month = f"{year:04d}-{month:02d}" if month else f"{year:04d}-01"
end_month = f"{year:04d}-{month:02d}" if month else f"{year:04d}-12"
period_start = parse_month(start_month)
period_end = parse_month(end_month) or period_start
analysis_period = (
make_analysis_period(period_start, month_end(period_end), mode='month_range')
if period_start else make_analysis_period(None)
)
return render_template('monthly_summary_analysis.html',
datetime_now=datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'),
system_version=SYSTEM_VERSION,
analysis_period=analysis_period,
active_page='monthly')
# ==========================================
# API 路由
# ==========================================
@monthly_bp.route('/api/monthly_summary_trend')
@login_required
def get_monthly_summary_trend():
"""API: 取得月份總表輕量趨勢資料。"""
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')
start_month = request.args.get('start_month')
end_month = request.args.get('end_month')
db = DatabaseManager()
session = db.get_session()
try:
period = _resolve_monthly_period(
session,
year=year,
month=month,
start_month=start_month,
end_month=end_month,
)
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)
trend_query = _apply_monthly_dimensions(
trend_query,
division=division,
pm_name=pm_name,
brand_name=brand_name,
vendor_name=vendor_name,
area_name=area_name,
trade_type=trade_type,
)
trend_query = _apply_monthly_period(trend_query, period, include_previous=True)
trend_results = trend_query.all()
return jsonify({
'status': 'success',
'period': {
'label': period['label'],
'previous_label': period['previous_label'],
'current_year': period['current_year'],
'previous_year': period['previous_year'],
**period['analysis_period'],
},
'trend': _serialize_comparison_trend(trend_results, period),
})
except Exception as e:
sys_log.error(f"取得月份總表趨勢資料失敗: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
finally:
session.close()
@monthly_bp.route('/api/monthly_summary_data')
@login_required
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)
start_month = request.args.get('start_month')
end_month = request.args.get('end_month')
db = DatabaseManager()
session = db.get_session()
try:
period = _resolve_monthly_period(
session,
year=year,
month=month,
start_month=start_month,
end_month=end_month,
)
if start_month or end_month:
year = None
month = None
# 基礎查詢
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)
query = _apply_monthly_period(query, period)
# 取得統計數據 (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_query = _apply_monthly_period(kpi_query, period)
kpi_res = kpi_query.one()
# 取得總筆數與月數
total_rows = session.query(func.count(MonthlySummaryAnalysis.id))
total_months_query = session.query(
MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month
).distinct()
total_rows = _apply_monthly_dimensions(
total_rows,
division=division,
pm_name=pm_name,
brand_name=brand_name,
vendor_name=vendor_name,
area_name=area_name,
trade_type=trade_type,
)
total_months_query = _apply_monthly_dimensions(
total_months_query,
division=division,
pm_name=pm_name,
brand_name=brand_name,
vendor_name=vendor_name,
area_name=area_name,
trade_type=trade_type,
)
total_rows = _apply_monthly_period(total_rows, period)
total_months_query = _apply_monthly_period(total_months_query, period)
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)
trend_query = _apply_monthly_period(trend_query, period, include_previous=True)
# 取得排行榜 (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 = _apply_monthly_period(rank_query, period)
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)
# --- 進階分析子查詢 (Phase 17) ---
def apply_filters(q, include_previous=False):
q = _apply_monthly_dimensions(
q,
division=division,
pm_name=pm_name,
brand_name=brand_name,
vendor_name=vendor_name,
area_name=area_name,
trade_type=trade_type,
)
return _apply_monthly_period(q, period, include_previous=include_previous)
month_key = _monthly_key_expr()
current_period_condition = and_(
month_key >= period['start_key'],
month_key <= period['end_key'],
)
previous_period_condition = and_(
month_key >= period['previous_start_key'],
month_key <= period['previous_end_key'],
)
# 廠商排行 (Top 20, 分年度)
vendor_rank_q = session.query(
MonthlySummaryAnalysis.vendor_name,
func.sum(case((current_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales'),
func.sum(case((previous_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_previous'),
func.sum(case((current_period_condition, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit'),
func.sum(case((previous_period_condition, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_previous'),
).group_by(MonthlySummaryAnalysis.vendor_name)
vendor_rank_q = apply_filters(vendor_rank_q, include_previous=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(case((current_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales'),
func.sum(case((previous_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_previous')
).group_by(MonthlySummaryAnalysis.area_name)
div_dist_q = apply_filters(div_dist_q, include_previous=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(case((current_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales'),
func.sum(case((previous_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_previous')
).group_by('price_range')
price_cont_q = apply_filters(price_cont_q, include_previous=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 分類)
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, include_previous=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(case((current_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales'),
func.sum(case((previous_period_condition, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_previous')
).group_by(MonthlySummaryAnalysis.area_name)
area_rank_q = apply_filters(area_rank_q, include_previous=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
})
# 使用單一 SQL 查詢取得所有不重複的維度列表 (支援 PostgreSQL/SQLite)
with db.engine.connect() as conn:
filters_result = conn.execute(text(get_group_concat_sql())).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]
# 預先執行所有查詢
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',
'period': {
'label': period['label'],
'previous_label': period['previous_label'],
'current_year': period['current_year'],
'previous_year': period['previous_year'],
**period['analysis_period'],
},
'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': _serialize_comparison_trend(trend_results, period),
'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_current': int(r.sales or 0),
'sales_previous': int(r.sales_previous or 0)
}
for r in area_rank_results
],
'vendor_ranking': [
{
'name': r.vendor_name,
'sales': int(r.sales or 0),
'sales_current': int(r.sales or 0),
'sales_previous': int(r.sales_previous or 0),
'profit': int(r.profit or 0),
'profit_current': int(r.profit or 0),
'profit_previous': int(r.profit_previous 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_current': int(r.sales or 0),
'sales_previous': int(r.sales_previous or 0)
}
for r in div_dist_results
],
'price_contribution': [
{
'range': r.price_range,
'sales': int(r.sales or 0),
'sales_current': int(r.sales or 0),
'sales_previous': int(r.sales_previous 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()