fix(analytics): align yoy and detail table contracts
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
@@ -15,7 +15,8 @@ import os
|
||||
import pickle
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from calendar import monthrange
|
||||
from datetime import date, datetime, timezone, timedelta
|
||||
from flask import Blueprint, request, render_template, jsonify, send_file, redirect, url_for
|
||||
from auth import login_required
|
||||
from sqlalchemy import inspect, text
|
||||
@@ -30,6 +31,7 @@ from services.analysis_period_service import (
|
||||
make_analysis_period,
|
||||
make_month_period,
|
||||
month_end,
|
||||
parse_iso_date,
|
||||
parse_month,
|
||||
)
|
||||
from services.cache_manager import (
|
||||
@@ -59,6 +61,89 @@ _SALES_PAGE_CONTEXT_CACHE_MAX = 24
|
||||
_SALES_SHARED_PAGE_CONTEXT_CACHE_TTL = 1800
|
||||
|
||||
|
||||
def _parse_yoy_year(value, default):
|
||||
raw = str(value or default).strip()
|
||||
if not raw.isdigit() or len(raw) != 4:
|
||||
raise ValueError('year1/year2 必須是四位數年份')
|
||||
year = int(raw)
|
||||
if year < 2000 or year > 2100:
|
||||
raise ValueError('year1/year2 超出允許範圍')
|
||||
return year
|
||||
|
||||
|
||||
def _safe_period_date(year, month, day):
|
||||
return date(year, month, min(day, monthrange(year, month)[1]))
|
||||
|
||||
|
||||
def _project_yoy_period(target_year, start_value=None, end_value=None, month_value=None):
|
||||
start = parse_iso_date(start_value)
|
||||
end = parse_iso_date(end_value)
|
||||
month_raw = str(month_value or '').strip().lower()
|
||||
|
||||
if start_value and not start:
|
||||
raise ValueError('start_date 必須是 YYYY-MM-DD')
|
||||
if end_value and not end:
|
||||
raise ValueError('end_date 必須是 YYYY-MM-DD')
|
||||
|
||||
if start or end:
|
||||
start = start or end
|
||||
end = end or start
|
||||
if end < start:
|
||||
start, end = end, start
|
||||
wraps_year = end.year > start.year or (end.month, end.day) < (start.month, start.day)
|
||||
projected_start = _safe_period_date(target_year, start.month, start.day)
|
||||
projected_end = _safe_period_date(target_year + int(wraps_year), end.month, end.day)
|
||||
return projected_start, projected_end
|
||||
|
||||
if month_raw and month_raw != 'all':
|
||||
if not month_raw.isdigit() or not 1 <= int(month_raw) <= 12:
|
||||
raise ValueError('month 必須介於 1 到 12')
|
||||
month = int(month_raw)
|
||||
return date(target_year, month, 1), date(target_year, month, monthrange(target_year, month)[1])
|
||||
|
||||
return date(target_year, 1, 1), date(target_year, 12, 31)
|
||||
|
||||
|
||||
def _period_month_keys(start, end):
|
||||
current = date(start.year, start.month, 1)
|
||||
terminal = date(end.year, end.month, 1)
|
||||
keys = []
|
||||
while current <= terminal:
|
||||
keys.append(current.strftime('%Y-%m'))
|
||||
if current.month == 12:
|
||||
current = date(current.year + 1, 1, 1)
|
||||
else:
|
||||
current = date(current.year, current.month + 1, 1)
|
||||
return keys
|
||||
|
||||
|
||||
def _query_yoy_month_totals(engine, aggregate_sql, start, end):
|
||||
if engine.dialect.name == 'postgresql':
|
||||
date_expr = 'LEFT(REPLACE(CAST("日期" AS TEXT), \'/\', \'-\'), 10)'
|
||||
month_expr = f'LEFT({date_expr}, 7)'
|
||||
else:
|
||||
date_expr = 'substr(replace(CAST("日期" AS TEXT), \'/\', \'-\'), 1, 10)'
|
||||
month_expr = f'substr({date_expr}, 1, 7)'
|
||||
|
||||
query = text(f"""
|
||||
SELECT {month_expr} AS period_month, {aggregate_sql} AS total
|
||||
FROM realtime_sales_monthly
|
||||
WHERE {date_expr} >= :start_date AND {date_expr} <= :end_date
|
||||
GROUP BY {month_expr}
|
||||
ORDER BY {month_expr}
|
||||
""")
|
||||
frame = pd.read_sql(
|
||||
query,
|
||||
engine,
|
||||
params={'start_date': start.isoformat(), 'end_date': end.isoformat()},
|
||||
)
|
||||
return {
|
||||
str(row.period_month): float(row.total or 0)
|
||||
for row in frame.itertuples(index=False)
|
||||
if row.period_month
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 輔助函數
|
||||
# ==========================================
|
||||
@@ -3285,132 +3370,88 @@ def api_export_top_detail():
|
||||
@sales_bp.route('/api/sales_analysis/yoy_comparison')
|
||||
@login_required
|
||||
def api_yoy_comparison():
|
||||
"""
|
||||
API: 年度對比分析 (YoY Comparison)
|
||||
|
||||
參數:
|
||||
year1: 基準年 (例如 2024)
|
||||
year2: 對比年 (例如 2025)
|
||||
month: 月份 (可選,1-12,不帶則為全年)
|
||||
metric: 指標 (revenue/qty/profit)
|
||||
|
||||
回傳:
|
||||
JSON with year1 total, year2 total, growth rate, and monthly breakdown
|
||||
"""
|
||||
"""Return a period-linked year-over-year comparison."""
|
||||
try:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
table_name = 'realtime_sales_monthly'
|
||||
year1 = request.args.get('year1', '2024')
|
||||
year2 = request.args.get('year2', '2025')
|
||||
month = request.args.get('month', '') # 可選,1-12
|
||||
metric = request.args.get('metric', 'revenue') # revenue/qty/profit
|
||||
|
||||
db = DatabaseManager()
|
||||
|
||||
# 欄位名稱
|
||||
col_amount = '總業績'
|
||||
col_qty = '數量'
|
||||
col_cost = '總成本'
|
||||
col_date = '日期'
|
||||
|
||||
# 根據指標決定聚合欄位
|
||||
if metric == 'qty':
|
||||
agg_sql = f'SUM(CAST("{col_qty}" AS REAL))'
|
||||
metric_label = '銷售數量'
|
||||
elif metric == 'profit':
|
||||
agg_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))'
|
||||
metric_label = '毛利金額'
|
||||
else: # revenue
|
||||
agg_sql = f'SUM(CAST("{col_amount}" AS REAL))'
|
||||
metric_label = '銷售金額'
|
||||
|
||||
# 建立年度篩選條件
|
||||
# 日期格式為 2025/01/01 或 2025-01-01
|
||||
def build_year_filter(year, month_filter=''):
|
||||
if month_filter:
|
||||
month_str = month_filter.zfill(2)
|
||||
return f"""("{col_date}" LIKE '{year}/{month_str}%' OR "{col_date}" LIKE '{year}-{month_str}%')"""
|
||||
else:
|
||||
return f"""("{col_date}" LIKE '{year}/%' OR "{col_date}" LIKE '{year}-%')"""
|
||||
|
||||
year1_filter = build_year_filter(year1, month)
|
||||
year2_filter = build_year_filter(year2, month)
|
||||
|
||||
# 查詢年度總計
|
||||
sql_year1 = f"""
|
||||
SELECT {agg_sql} as total
|
||||
FROM {table_name}
|
||||
WHERE {year1_filter}
|
||||
"""
|
||||
sql_year2 = f"""
|
||||
SELECT {agg_sql} as total
|
||||
FROM {table_name}
|
||||
WHERE {year2_filter}
|
||||
"""
|
||||
year1 = _parse_yoy_year(request.args.get('year1'), 2024)
|
||||
year2 = _parse_yoy_year(request.args.get('year2'), 2025)
|
||||
metric = str(request.args.get('metric', 'revenue')).strip().lower()
|
||||
metric_options = {
|
||||
'revenue': ('COALESCE(SUM(CAST("總業績" AS REAL)), 0)', '銷售金額'),
|
||||
'qty': ('COALESCE(SUM(CAST("數量" AS REAL)), 0)', '銷售數量'),
|
||||
'profit': (
|
||||
'COALESCE(SUM(CAST("總業績" AS REAL)), 0) '
|
||||
'- COALESCE(SUM(CAST("總成本" AS REAL)), 0)',
|
||||
'毛利金額',
|
||||
),
|
||||
}
|
||||
if metric not in metric_options:
|
||||
raise ValueError('metric 僅允許 revenue、qty、profit')
|
||||
|
||||
start_value = request.args.get('start_date')
|
||||
end_value = request.args.get('end_date')
|
||||
month_value = request.args.get('month')
|
||||
year1_start, year1_end = _project_yoy_period(year1, start_value, end_value, month_value)
|
||||
year2_start, year2_end = _project_yoy_period(year2, start_value, end_value, month_value)
|
||||
|
||||
aggregate_sql, metric_label = metric_options[metric]
|
||||
engine = DatabaseManager().engine
|
||||
year1_values = _query_yoy_month_totals(engine, aggregate_sql, year1_start, year1_end)
|
||||
year2_values = _query_yoy_month_totals(engine, aggregate_sql, year2_start, year2_end)
|
||||
year1_keys = _period_month_keys(year1_start, year1_end)
|
||||
year2_keys = _period_month_keys(year2_start, year2_end)
|
||||
|
||||
# V-Fix: SQLAlchemy 2.0 需要使用 text() 包裹 SQL 字串
|
||||
from sqlalchemy import text
|
||||
result_year1 = pd.read_sql(text(sql_year1), db.engine)
|
||||
result_year2 = pd.read_sql(text(sql_year2), db.engine)
|
||||
|
||||
total_year1 = float(result_year1['total'].iloc[0] or 0)
|
||||
total_year2 = float(result_year2['total'].iloc[0] or 0)
|
||||
|
||||
# 計算成長率
|
||||
if total_year1 > 0:
|
||||
growth_rate = ((total_year2 - total_year1) / total_year1) * 100
|
||||
else:
|
||||
growth_rate = 0 if total_year2 == 0 else 100
|
||||
|
||||
# 月度明細 (如果沒有指定月份,則查詢 12 個月的明細)
|
||||
monthly_breakdown = []
|
||||
if not month:
|
||||
for m in range(1, 13):
|
||||
m_str = str(m).zfill(2)
|
||||
y1_filter = build_year_filter(year1, m_str)
|
||||
y2_filter = build_year_filter(year2, m_str)
|
||||
|
||||
sql_m1 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y1_filter}"
|
||||
sql_m2 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y2_filter}"
|
||||
for index in range(max(len(year1_keys), len(year2_keys))):
|
||||
year1_key = year1_keys[index] if index < len(year1_keys) else None
|
||||
year2_key = year2_keys[index] if index < len(year2_keys) else None
|
||||
value1 = year1_values.get(year1_key, 0) if year1_key else 0
|
||||
value2 = year2_values.get(year2_key, 0) if year2_key else 0
|
||||
growth = ((value2 - value1) / value1 * 100) if value1 else (100 if value2 else 0)
|
||||
label_key = year2_key or year1_key
|
||||
monthly_breakdown.append({
|
||||
'month': int(label_key[-2:]),
|
||||
'month_label': f'{int(label_key[-2:])}月',
|
||||
'year1_value': value1,
|
||||
'year2_value': value2,
|
||||
'growth_rate': round(growth, 2),
|
||||
})
|
||||
|
||||
# V-Fix: SQLAlchemy 2.0 需要使用 text()
|
||||
r1 = pd.read_sql(text(sql_m1), db.engine)
|
||||
r2 = pd.read_sql(text(sql_m2), db.engine)
|
||||
|
||||
v1 = float(r1['total'].iloc[0] or 0)
|
||||
v2 = float(r2['total'].iloc[0] or 0)
|
||||
|
||||
m_growth = ((v2 - v1) / v1 * 100) if v1 > 0 else (0 if v2 == 0 else 100)
|
||||
|
||||
monthly_breakdown.append({
|
||||
'month': m,
|
||||
'month_label': f'{m}月',
|
||||
'year1_value': v1,
|
||||
'year2_value': v2,
|
||||
'growth_rate': round(m_growth, 2)
|
||||
})
|
||||
|
||||
total_year1 = sum(year1_values.values())
|
||||
total_year2 = sum(year2_values.values())
|
||||
growth_rate = (
|
||||
((total_year2 - total_year1) / total_year1) * 100
|
||||
if total_year1
|
||||
else (100 if total_year2 else 0)
|
||||
)
|
||||
response = {
|
||||
'year1': {
|
||||
'label': f'{year1}年' + (f'{month}月' if month else ''),
|
||||
'total': total_year1
|
||||
'label': f'{year1}年',
|
||||
'total': total_year1,
|
||||
},
|
||||
'year2': {
|
||||
'label': f'{year2}年' + (f'{month}月' if month else ''),
|
||||
'total': total_year2
|
||||
'label': f'{year2}年',
|
||||
'total': total_year2,
|
||||
},
|
||||
'growth_rate': round(growth_rate, 2),
|
||||
'metric': metric,
|
||||
'metric_label': metric_label,
|
||||
'monthly_breakdown': monthly_breakdown
|
||||
'monthly_breakdown': monthly_breakdown,
|
||||
'period': {
|
||||
'linked': bool(start_value or end_value or (month_value and month_value != 'all')),
|
||||
'year1': {
|
||||
'start_date': year1_start.isoformat(),
|
||||
'end_date': year1_end.isoformat(),
|
||||
},
|
||||
'year2': {
|
||||
'start_date': year2_start.isoformat(),
|
||||
'end_date': year2_end.isoformat(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sys_log.info(f"[YoY] {year1} vs {year2}: {total_year1:,.0f} -> {total_year2:,.0f} ({growth_rate:+.1f}%)")
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as e:
|
||||
sys_log.error(f"[YoY] Error: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
Reference in New Issue
Block a user