746 lines
31 KiB
Python
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()
|