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', }