fix(analytics): link sales filters to chart contracts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user