Files
ewoooc/routes/monthly_routes.py
OoO 026d0e7539
All checks were successful
CD Pipeline / deploy (push) Successful in 2m14s
feat(reports): move monthly analysis to v2 shell
2026-05-01 21:13:18 +08:00

482 lines
22 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
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
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__)
# ==========================================
# 頁面路由
# ==========================================
@monthly_bp.route('/monthly_summary_analysis')
@login_required
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,
active_page='monthly')
# ==========================================
# API 路由
# ==========================================
@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)
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)
# --- 進階分析子查詢 (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, 分年度)
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 分類)
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
})
# 使用單一 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',
'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()