diff --git a/config.py b/config.py index a0bbb39..ff5e4f6 100644 --- a/config.py +++ b/config.py @@ -414,7 +414,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.805" +SYSTEM_VERSION = "V10.806" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/sales_routes.py b/routes/sales_routes.py index ecb52e5..7ad8947 100644 --- a/routes/sales_routes.py +++ b/routes/sales_routes.py @@ -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() diff --git a/templates/sales_analysis.html b/templates/sales_analysis.html index 395af2e..5c7a035 100644 --- a/templates/sales_analysis.html +++ b/templates/sales_analysis.html @@ -821,18 +821,18 @@
| 排名 | -商品編號 | -商品名稱 | - {% if cols.brand %}品牌 | {% endif %} - {% if cols.vendor %}廠商名稱 | {% endif %} - {% if cols.cat %}分類 | {% endif %} - {% if cols.qty %}平均單價 | {% endif %} - {% if cols.cost or cols.profit %}毛利率 | {% endif %} - {% if cols.return_qty %}退貨率 | {% endif %} - {% if cols.date %}銷售月份 | {% endif %} - {% if cols.qty %}銷售數量 | {% endif %} -銷售金額 | +排名 | +商品編號 | +商品名稱 | + {% if cols.brand %}品牌 | {% endif %} + {% if cols.vendor %}廠商名稱 | {% endif %} + {% if cols.cat %}分類 | {% endif %} + {% if cols.qty %}平均單價 | {% endif %} + {% if cols.cost or cols.profit %}毛利率 | {% endif %} + {% if cols.return_qty %}退貨率 | {% endif %} + {% if cols.date %}銷售月份 | {% endif %} + {% if cols.qty %}銷售數量 | {% endif %} +銷售金額 |
|---|