From ec40d161af040d9ab7bb76540b4acfc6e39cd556 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 15 Jul 2026 17:07:59 +0800 Subject: [PATCH] fix(analytics): align yoy and detail table contracts --- config.py | 2 +- routes/sales_routes.py | 265 +++++++++++++++----------- templates/sales_analysis.html | 24 +-- tests/test_analysis_period_linkage.py | 70 ++++++- web/static/js/page-sales-analysis.js | 38 +++- 5 files changed, 265 insertions(+), 134 deletions(-) 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 %} + diff --git a/tests/test_analysis_period_linkage.py b/tests/test_analysis_period_linkage.py index 777ba24..3188299 100644 --- a/tests/test_analysis_period_linkage.py +++ b/tests/test_analysis_period_linkage.py @@ -14,7 +14,13 @@ from routes.monthly_routes import ( _resolve_monthly_period, get_monthly_summary_data, ) -from routes.sales_routes import _filter_growth_payload, _sorted_valid_month_labels +import routes.sales_routes as sales_routes +from routes.sales_routes import ( + _filter_growth_payload, + _period_month_keys, + _project_yoy_period, + _sorted_valid_month_labels, +) from services.analysis_period_service import make_analysis_period, make_month_period @@ -284,10 +290,72 @@ def test_monthly_frontend_has_replayable_period_and_no_fixed_years(): def test_sales_frontend_uses_live_period_linked_api_routes(): script = (ROOT / "web/static/js/page-sales-analysis.js").read_text(encoding="utf-8") + template = (ROOT / "templates/sales_analysis.html").read_text(encoding="utf-8") assert "buildPeriodApiUrl" in script assert "new URLSearchParams(window.location.search)" in script assert "/api/sales_analysis/yoy_comparison" in script assert "/api/sales_analysis/table_data" in script + assert "d.growth_rate" in script + assert "d.monthly_breakdown" in script + assert "columns," in script + assert 'data-field="product_id"' in template + assert 'data-field="amount"' in template assert "fetch(`/api/yoy?" not in script assert "'/api/sales_table?'" not in script + + +def test_yoy_period_projects_selected_dates_to_each_comparison_year(): + start, end = _project_yoy_period(2025, "2026-04-01", "2026-04-30") + + assert start == date(2025, 4, 1) + assert end == date(2025, 4, 30) + assert _period_month_keys(start, end) == ["2025-04"] + + +def test_yoy_api_uses_only_the_selected_period_for_totals_and_chart(monkeypatch): + engine = create_engine("sqlite:///:memory:") + with engine.begin() as conn: + conn.exec_driver_sql( + 'CREATE TABLE realtime_sales_monthly ("日期" TEXT, "總業績" REAL, "數量" REAL, "總成本" REAL)' + ) + conn.exec_driver_sql( + """ + INSERT INTO realtime_sales_monthly ("日期", "總業績", "數量", "總成本") + VALUES + ('2025/04/10', 100, 2, 60), + ('2025/05/10', 900, 9, 100), + ('2026/04/10', 150, 3, 80), + ('2026/05/10', 1200, 12, 200) + """ + ) + + class FakeDatabaseManager: + def __init__(self): + self.engine = engine + + monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager) + app = Flask(__name__) + with app.test_request_context( + "/api/sales_analysis/yoy_comparison" + "?year1=2025&year2=2026&metric=revenue" + "&start_date=2026-04-01&end_date=2026-04-30" + ): + response = sales_routes.api_yoy_comparison.__wrapped__() + + payload = response.get_json() + assert payload["period"]["linked"] is True + assert payload["period"]["year1"] == { + "start_date": "2025-04-01", + "end_date": "2025-04-30", + } + assert payload["year1"]["total"] == 100 + assert payload["year2"]["total"] == 150 + assert payload["growth_rate"] == 50 + assert payload["monthly_breakdown"] == [{ + "month": 4, + "month_label": "4月", + "year1_value": 100, + "year2_value": 150, + "growth_rate": 50, + }] diff --git a/web/static/js/page-sales-analysis.js b/web/static/js/page-sales-analysis.js index 72054ff..d40ecd3 100644 --- a/web/static/js/page-sales-analysis.js +++ b/web/static/js/page-sales-analysis.js @@ -488,11 +488,14 @@ return response.json(); }) .then(d => { - document.getElementById('yoy-year1-label').textContent = `${y1}年`; - document.getElementById('yoy-year2-label').textContent = `${y2}年`; - document.getElementById('yoy-year1-value').textContent = fmtMoney(d.total1 || 0); - document.getElementById('yoy-year2-value').textContent = fmtMoney(d.total2 || 0); - const growth = d.growth || 0; + const breakdown = Array.isArray(d.monthly_breakdown) ? d.monthly_breakdown : []; + const total1 = Number(d.year1 && d.year1.total || 0); + const total2 = Number(d.year2 && d.year2.total || 0); + document.getElementById('yoy-year1-label').textContent = d.year1 && d.year1.label || `${y1}年`; + document.getElementById('yoy-year2-label').textContent = d.year2 && d.year2.label || `${y2}年`; + document.getElementById('yoy-year1-value').textContent = fmtMoney(total1); + document.getElementById('yoy-year2-value').textContent = fmtMoney(total2); + const growth = Number(d.growth_rate || 0); const card = document.getElementById('yoy-growth-card'); card.classList.toggle('is-decline', growth < 0); const arrow = growth >= 0 ? 'up' : 'down'; @@ -504,11 +507,11 @@ yoyChart = new Chart(ctx, { type: 'line', data: { - labels: d.labels || [], + labels: breakdown.map(row => row.month_label), datasets: [ - { label: `${y1}年`, data: d.values1 || [], borderColor: MUTED, + { label: `${y1}年`, data: breakdown.map(row => Number(row.year1_value || 0)), borderColor: MUTED, backgroundColor: mix(MUTED, 8), tension: 0.3, fill: false, borderWidth: 2 }, - { label: `${y2}年`, data: d.values2 || [], borderColor: ACCENT, + { label: `${y2}年`, data: breakdown.map(row => Number(row.year2_value || 0)), borderColor: ACCENT, backgroundColor: mix(ACCENT, 12), tension: 0.3, fill: true, borderWidth: 2.5 } ] }, @@ -530,8 +533,27 @@ const $tbl = window.jQuery && jQuery('#dataTable'); if (!$tbl || !$tbl.length || !jQuery.fn.DataTable) return; const url = buildPeriodApiUrl('/api/sales_analysis/table_data'); + const numericFields = new Set(['rank', 'avg_price', 'margin_rate', 'return_rate', 'qty', 'amount']); + const columns = Array.from($tbl[0].querySelectorAll('thead th')).map((header) => { + const field = header.dataset.field; + return { + data: field, + defaultContent: '—', + render(value, type) { + if (type !== 'display') return numericFields.has(field) ? Number(value || 0) : value; + if (value === null || value === undefined || value === '') return '—'; + if (field === 'amount' || field === 'avg_price') return fmtMoney(value); + if (field === 'margin_rate' || field === 'return_rate') return `${Number(value).toFixed(1)}%`; + if (field === 'qty' || field === 'rank') return fmtNum(value); + const node = document.createElement('span'); + node.textContent = String(value); + return node.innerHTML; + } + }; + }); $tbl.DataTable({ ajax: { url, dataSrc: 'data' }, + columns, processing: true, serverSide: false, pageLength: 25, lengthMenu: [10, 25, 50, 100], order: [[$tbl.find('thead th').length - 1, 'desc']],
排名商品編號商品名稱品牌廠商名稱分類平均單價毛利率退貨率銷售月份銷售數量銷售金額排名商品編號商品名稱品牌廠商名稱分類平均單價毛利率退貨率銷售月份銷售數量銷售金額