fix(analytics): link sales filters to chart contracts

This commit is contained in:
ogt
2026-07-22 16:41:00 +08:00
parent 95682ee3bc
commit 9b677ae616
8 changed files with 960 additions and 310 deletions

View File

@@ -296,12 +296,22 @@ def test_sales_frontend_uses_live_period_linked_api_routes():
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 "/api/sales_analysis/top_detail" in script
assert "topDetailModal" in script
assert "window.open(url.toString()" not in script
assert "form.requestSubmit()" in script
assert "if (start) start.value = '';" in script
assert "if (end) end.value = '';" 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 "每週業績趨勢({{ analysis_period.label }}" in template
assert "data-analysis-period" in template
assert 'id="topDetailModal"' in template
assert template.count("period_badge(analysis_scope)") >= 10
assert "yoy_years" in template
assert '<option value="2026" selected>' not in template
assert "全年度" not in template
assert "fetch(`/api/yoy?" not in script
assert "'/api/sales_table?'" not in script
@@ -315,6 +325,24 @@ def test_yoy_period_projects_selected_dates_to_each_comparison_year():
assert _period_month_keys(start, end) == ["2025-04"]
def test_yoy_period_projects_rolling_range_instead_of_falling_back_to_full_year():
start, end = _project_yoy_period(
2025,
data_range_value="6",
anchor_date="2026-07-22",
)
assert start == date(2025, 1, 23)
assert end == date(2025, 7, 22)
def test_yoy_period_accepts_the_year_month_value_sent_by_the_page_filter():
start, end = _project_yoy_period(2025, month_value="2026-04")
assert start == date(2025, 4, 1)
assert end == date(2025, 4, 30)
def test_yoy_api_uses_only_the_selected_period_for_totals_and_chart(monkeypatch):
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
@@ -361,3 +389,91 @@ def test_yoy_api_uses_only_the_selected_period_for_totals_and_chart(monkeypatch)
"year2_value": 150,
"growth_rate": 50,
}]
def test_sales_page_builds_every_chart_from_the_same_custom_period(monkeypatch):
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.exec_driver_sql(
'''
CREATE TABLE realtime_sales_monthly (
"日期" TEXT,
"時間" TEXT,
"商品ID" TEXT,
"商品名稱" TEXT,
"商品館" TEXT,
"品牌" TEXT,
"廠商名稱" TEXT,
"總業績" REAL,
"數量" REAL,
"總成本" REAL,
"折扣活動名稱" TEXT,
"折價券活動名稱" TEXT
)
'''
)
conn.exec_driver_sql(
'''
INSERT INTO realtime_sales_monthly
("日期", "時間", "商品ID", "商品名稱", "商品館", "品牌", "廠商名稱",
"總業績", "數量", "總成本", "折扣活動名稱", "折價券活動名稱")
VALUES
('2026/04/10', '10:00:00', 'P1', '商品 A', '美妝', '品牌 A', '廠商 A', 100, 2, 60, '母親節', ''),
('2026/04/11', '21:00:00', 'P2', '商品 B', '3C', '品牌 B', '廠商 B', 300, 3, 210, '', '折價券'),
('2026/07/01', '12:00:00', 'P3', '區間外商品', '家電', '品牌 C', '廠商 C', 9999, 1, 1, '暑期', '')
'''
)
class FakeDatabaseManager:
def __init__(self):
self.engine = engine
monkeypatch.setattr(sales_routes, "DatabaseManager", FakeDatabaseManager)
monkeypatch.setattr(sales_routes, "DATABASE_TYPE", "sqlite")
monkeypatch.setattr(sales_routes, "_get_sales_shared_page_context_cache", lambda _key: None)
monkeypatch.setattr(sales_routes, "_set_sales_shared_page_context_cache", lambda _key, _context: None)
monkeypatch.setattr(sales_routes, "render_template", lambda _name, **context: context)
sales_routes._SALES_PROCESSED_CACHE.clear()
sales_routes._SALES_OPTIONS_CACHE.clear()
sales_routes._SALES_ANALYSIS_RESULT_CACHE.clear()
app = Flask(__name__)
with app.test_request_context(
"/sales_analysis?metric=profit&start_date=2026-04-01&end_date=2026-04-30"
):
context = sales_routes.sales_analysis.__wrapped__()
assert context["analysis_period"]["label"] == "2026 年 04 月"
assert context["analysis_scope"]["label"] == "2026 年 04 月"
assert context["total_records"] == 2
assert context["kpi"]["revenue"] == 400
assert context["bar_data"]["metric_label"] == "毛利金額 ($)"
assert context["bar_data"]["chart_values"] == [90.0, 40.0]
assert {item["name"] for item in context["treemap_data"]} == {"商品 A", "商品 B"}
assert len(context["bcg_data"]["points"]) == 2
assert context["heatmap_data"]["matrix"][4][10] == 100
assert context["heatmap_data"]["matrix"][5][21] == 300
assert context["seasonality_data"]["months"] == ["2026-04"]
assert context["all_months"] == ["2026-04"]
assert context["marketing_data"]["discount"] == {
"labels": ["母親節"],
"values": [40.0],
}
assert context["marketing_data"]["coupon"] == {
"labels": ["折價券"],
"values": [90.0],
}
assert context["yoy_year1"] == 2025
assert context["yoy_year2"] == 2026
sales_routes._TABLE_DATA_CACHE.clear()
with app.test_request_context(
"/api/sales_analysis/table_data"
"?metric=profit&start_date=2026-04-01&end_date=2026-04-30&category=美妝"
):
table_response = sales_routes.api_sales_table_data.__wrapped__()
table_payload = table_response.get_json()
assert len(table_payload["data"]) == 1
assert table_payload["data"][0]["product_id"] == "P1"
assert table_payload["data"][0]["amount"] == 100

View File

@@ -1,6 +1,16 @@
import pandas as pd
from routes.sales_routes import _build_top_product_chart_data
from services.sales_analysis_chart_service import (
build_analysis_scope,
build_bcg_chart_data,
build_heatmap_chart_data,
build_marketing_chart_data,
build_sales_filter_options,
build_seasonality_chart_data,
build_treemap_chart_data,
resolve_yoy_year_options,
)
def test_top_product_chart_aggregates_sku_rows_before_ranking():
@@ -34,4 +44,111 @@ def test_top_product_chart_returns_explicit_empty_contract():
'labels': [],
'chart_values': [],
'metric_label': '銷售數量',
'is_money': False,
}
def test_sales_chart_builders_emit_the_contract_consumed_by_frontend():
frame = pd.DataFrame([
{
'pid': 'P1', 'name': '商品 A', 'category': '美妝', 'amount': 100,
'qty': 2, 'calculated_profit': 30, '_month_str': '2026-04',
'_dow': 2, '_hour': 10,
},
{
'pid': 'P1', 'name': '商品 A', 'category': '美妝', 'amount': 50,
'qty': 1, 'calculated_profit': 10, '_month_str': '2026-04',
'_dow': 2, '_hour': 10,
},
{
'pid': 'P2', 'name': '商品 B', 'category': '3C', 'amount': 300,
'qty': 3, 'calculated_profit': 60, '_month_str': '2026-05',
'_dow': 4, '_hour': 21,
},
])
treemap = build_treemap_chart_data(frame, 'category', 'name', 'amount')
bcg = build_bcg_chart_data(frame, 'pid', 'name', 'qty', 'amount')
heatmap = build_heatmap_chart_data(frame, 'amount')
seasonality = build_seasonality_chart_data(frame, 'category', 'amount')
assert treemap[0].keys() == {'category', 'name', 'value'}
assert {item['name'] for item in treemap} == {'商品 A', '商品 B'}
assert bcg['points'][0]['name'] == '商品 B'
assert bcg['points'][1]['x'] == 3
assert bcg['x_median'] == 3
assert len(heatmap['matrix']) == 7
assert all(len(row) == 24 for row in heatmap['matrix'])
assert heatmap['matrix'][2][10] == 150
assert heatmap['matrix'][4][21] == 300
assert seasonality == {
'categories': ['3C', '美妝'],
'months': ['2026-04', '2026-05'],
'matrix': [[0.0, 300.0], [150.0, 0.0]],
}
def test_sales_chart_builders_return_explicit_empty_contracts():
empty = pd.DataFrame()
assert build_treemap_chart_data(empty, 'category', 'name', 'amount') == []
assert build_bcg_chart_data(empty, 'pid', 'name', 'qty', 'amount') == {
'points': [], 'x_median': 0.0, 'y_median': 0.0,
}
assert build_heatmap_chart_data(empty, 'amount')['matrix'] == []
assert build_seasonality_chart_data(empty, 'category', 'amount')['matrix'] == []
def test_marketing_and_yoy_controls_follow_active_metric_and_period():
marketing = build_marketing_chart_data({
'discount': [{'name': '母親節', 'revenue': 500, 'qty': 5, 'profit': 120}],
'coupon': [{'name': '折價券', 'revenue': 300, 'qty': 3, 'profit': 80}],
}, 'profit')
years = resolve_yoy_year_options(
['2024-12', '2026-04', float('nan')],
{'end_date': '2026-04-30'},
)
assert marketing['metric'] == 'profit'
assert marketing['discount'] == {'labels': ['母親節'], 'values': [120.0]}
assert marketing['coupon'] == {'labels': ['折價券'], 'values': [80.0]}
assert years == {'years': [2026, 2025, 2024], 'year1': 2025, 'year2': 2026}
def test_filter_options_are_scoped_to_the_loaded_period_frame():
frame = pd.DataFrame([
{'category': '美妝', 'brand': 'A', '_month_str': '2026-04'},
{'category': '3C', 'brand': 'B', '_month_str': '2026-04'},
{'category': '美妝', 'brand': 'A', '_month_str': None},
])
options = build_sales_filter_options(frame, {
'category': 'category',
'brand': 'brand',
'vendor': None,
'activity': None,
'payment': None,
})
assert options['categories'] == ['3C', '美妝']
assert options['brands'] == ['A', 'B']
assert options['months'] == ['2026-04']
def test_analysis_scope_names_every_active_time_filter():
scope = build_analysis_scope(
{
'label': '最近 6 個月',
'start_date': '2026-01-23',
'end_date': '2026-07-22',
},
month='2026-04',
dow='2',
hour='9',
)
assert scope == {
'label': '最近 6 個月 · 月份 2026-04 · 週三 · 09:00',
'start_date': '2026-01-23',
'end_date': '2026-07-22',
}