155 lines
5.3 KiB
Python
155 lines
5.3 KiB
Python
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():
|
|
rows = pd.DataFrame(
|
|
[
|
|
{'pid': 'P1', 'name': '同名商品', 'amount': 100},
|
|
{'pid': 'P1', 'name': '同名商品', 'amount': 50},
|
|
{'pid': 'P2', 'name': '同名商品', 'amount': 90},
|
|
{'pid': 'P3', 'name': '獨立商品', 'amount': 200},
|
|
{'pid': 'P4', 'name': '零業績商品', 'amount': 0},
|
|
]
|
|
)
|
|
|
|
result = _build_top_product_chart_data(rows, 'pid', 'name', 'amount', 'amount')
|
|
|
|
assert result['chart_values'] == [200.0, 150.0, 90.0]
|
|
assert result['labels'] == ['獨立商品', '同名商品 [P1]', '同名商品 [P2]']
|
|
assert result['metric_label'] == '銷售金額 ($)'
|
|
|
|
|
|
def test_top_product_chart_returns_explicit_empty_contract():
|
|
result = _build_top_product_chart_data(
|
|
pd.DataFrame(columns=['name', 'amount']),
|
|
None,
|
|
'name',
|
|
'amount',
|
|
'qty',
|
|
)
|
|
|
|
assert result == {
|
|
'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',
|
|
}
|