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

@@ -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 # 用於模板顯示

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

View File

@@ -821,18 +821,18 @@
<table id="dataTable" class="table table-hover align-middle mb-0" style="width:100%">
<thead>
<tr>
<th class="text-center" style="width: 60px;">排名</th>
<th style="width: 100px;">商品編號</th>
<th style="width: 25%;">商品名稱</th>
{% if cols.brand %}<th>品牌</th>{% endif %}
{% if cols.vendor %}<th>廠商名稱</th>{% endif %}
{% if cols.cat %}<th>分類</th>{% endif %}
{% if cols.qty %}<th>平均單價</th>{% endif %}
{% if cols.cost or cols.profit %}<th>毛利率</th>{% endif %}
{% if cols.return_qty %}<th>退貨率</th>{% endif %}
{% if cols.date %}<th>銷售月份</th>{% endif %}
{% if cols.qty %}<th class="text-end">銷售數量</th>{% endif %}
<th class="text-end">銷售金額</th>
<th data-field="rank" class="text-center" style="width: 60px;">排名</th>
<th data-field="product_id" style="width: 100px;">商品編號</th>
<th data-field="name" style="width: 25%;">商品名稱</th>
{% if cols.brand %}<th data-field="brand">品牌</th>{% endif %}
{% if cols.vendor %}<th data-field="vendor">廠商名稱</th>{% endif %}
{% if cols.cat %}<th data-field="category">分類</th>{% endif %}
{% if cols.qty %}<th data-field="avg_price">平均單價</th>{% endif %}
{% if cols.cost or cols.profit %}<th data-field="margin_rate">毛利率</th>{% endif %}
{% if cols.return_qty %}<th data-field="return_rate">退貨率</th>{% endif %}
{% if cols.date %}<th data-field="month_str">銷售月份</th>{% endif %}
{% if cols.qty %}<th data-field="qty" class="text-end">銷售數量</th>{% endif %}
<th data-field="amount" class="text-end">銷售金額</th>
</tr>
</thead>
<tbody></tbody>

View File

@@ -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,
}]

View File

@@ -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']],