fix(analytics): align yoy and detail table contracts
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-15 17:07:59 +08:00
parent 9cffe9f8e4
commit ec40d161af
5 changed files with 265 additions and 134 deletions

View File

@@ -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()