"""Cost 模块服务测试""" from datetime import date from decimal import Decimal from django.contrib.auth import get_user_model from django.test import TestCase from basic_info import models as basic_models from cost import models as cost_models from cost import services as cost_services from cost.models import CostEntryInput, CostProviderPort class EnsureCategoryTests(TestCase): def setUp(self): self.merchant = basic_models.Merchant.objects.create( name='测试商户', type=basic_models.MerchantTypeEnum.STORE, ) self.other_merchant = basic_models.Merchant.objects.create( name='其他商户', type=basic_models.MerchantTypeEnum.STORE, ) def test_ensure_category_creates_new(self): """category_key 不存在时创建新类目""" cat = cost_services.ensure_category( merchant=self.merchant, category_key='electricity', category_name='电费', description='每月电费支出', ) self.assertEqual(cat.unique_key, 'electricity') self.assertEqual(cat.name, '电费') self.assertEqual(cat.description, '每月电费支出') self.assertEqual(cat.merchant_id, self.merchant.id) def test_ensure_category_returns_existing(self): """category_key 已存在时返回已有类目""" existing = cost_models.CostCategory.objects.create( merchant=self.merchant, unique_key='electricity', name='电费', ) cat = cost_services.ensure_category( merchant=self.merchant, category_key='electricity', category_name='电力', ) self.assertEqual(cat.id, existing.id) self.assertEqual(cat.name, '电费') # 不覆盖已有 name def test_ensure_category_empty_key_raises(self): """空 category_key 抛 ValueError""" with self.assertRaises(ValueError) as ctx: cost_services.ensure_category( merchant=self.merchant, category_key='', category_name='电费', ) self.assertIn('category_key', str(ctx.exception)) def test_ensure_category_whitespace_key_raises(self): """纯空格 category_key 抛 ValueError""" with self.assertRaises(ValueError) as ctx: cost_services.ensure_category( merchant=self.merchant, category_key=' ', category_name='电费', ) self.assertIn('category_key', str(ctx.exception)) def test_ensure_category_empty_name_raises(self): """空 category_name 抛 ValueError""" with self.assertRaises(ValueError) as ctx: cost_services.ensure_category( merchant=self.merchant, category_key='key', category_name='', ) self.assertIn('category_name', str(ctx.exception)) def test_ensure_category_different_merchants_same_key(self): """不同商户相同 key 不冲突""" cat1 = cost_services.ensure_category( merchant=self.merchant, category_key='same_key', category_name='类目A', ) cat2 = cost_services.ensure_category( merchant=self.other_merchant, category_key='same_key', category_name='类目B', ) self.assertNotEqual(cat1.id, cat2.id) self.assertEqual(cat1.name, '类目A') self.assertEqual(cat2.name, '类目B') class CreateCostEntryTests(TestCase): def setUp(self): self.merchant = basic_models.Merchant.objects.create( name='测试商户', type=basic_models.MerchantTypeEnum.STORE, ) self.other_merchant = basic_models.Merchant.objects.create( name='其他商户', type=basic_models.MerchantTypeEnum.STORE, ) self.user = get_user_model().objects.create_user(username='testuser', password='pass12345') self.employee = basic_models.Employee.objects.create( merchant=self.merchant, sys_user=self.user, name='测试员工', ) self.other_user = get_user_model().objects.create_user(username='otheruser', password='pass12345') self.other_employee = basic_models.Employee.objects.create( merchant=self.other_merchant, sys_user=self.other_user, name='其他员工', ) self.category = cost_models.CostCategory.objects.create( merchant=self.merchant, unique_key='transport', name='运输费', ) self.other_category = cost_models.CostCategory.objects.create( merchant=self.other_merchant, unique_key='transport', name='运输费', ) def test_create_cost_entry_normal(self): """正常创建支出记录""" entry = cost_services.create_cost_entry( merchant=self.merchant, category=self.category, amount=Decimal('150.00'), occurred_at=date(2026, 6, 1), operator=self.employee, source_module='manual', source_id='', remarks='测试备注', ) self.assertEqual(entry.amount, Decimal('150.00')) self.assertEqual(entry.occurred_at, date(2026, 6, 1)) self.assertEqual(entry.operator_id, self.employee.id) self.assertEqual(entry.merchant_id, self.merchant.id) self.assertEqual(entry.source_module, 'manual') self.assertEqual(entry.remarks, '测试备注') def test_create_cost_entry_operator_none(self): """operator=None 正常创建(provider 场景)""" entry = cost_services.create_cost_entry( merchant=self.merchant, category=self.category, amount=Decimal('200.00'), occurred_at=date(2026, 6, 2), operator=None, ) self.assertIsNone(entry.operator_id) self.assertEqual(entry.amount, Decimal('200.00')) def test_create_cost_entry_category_mismatch_merchant_raises(self): """category 所属商户与 merchant 不一致抛 ValueError""" with self.assertRaises(ValueError) as ctx: cost_services.create_cost_entry( merchant=self.merchant, category=self.other_category, amount=Decimal('100.00'), occurred_at=date(2026, 6, 1), operator=self.employee, ) self.assertIn('category', str(ctx.exception)) def test_create_cost_entry_operator_mismatch_merchant_raises(self): """operator 所属商户与 merchant 不一致抛 ValueError""" with self.assertRaises(ValueError) as ctx: cost_services.create_cost_entry( merchant=self.merchant, category=self.category, amount=Decimal('100.00'), occurred_at=date(2026, 6, 1), operator=self.other_employee, ) self.assertIn('operator', str(ctx.exception)) def test_create_cost_entry_default_values(self): """不传 source_module/source_id/remarks 时默认为空字符串""" entry = cost_services.create_cost_entry( merchant=self.merchant, category=self.category, amount=Decimal('50.00'), occurred_at=date(2026, 6, 3), ) self.assertEqual(entry.source_module, '') self.assertEqual(entry.source_id, '') self.assertEqual(entry.remarks, '') def test_create_cost_entry_with_formula_fields(self): """传入 unit_amount + quantity 时自动计算最终 amount""" entry = cost_services.create_cost_entry( merchant=self.merchant, category=self.category, unit_amount=Decimal('180.00'), quantity=Decimal('2'), unit_name='人天', occurred_at=date(2026, 6, 4), ) self.assertEqual(entry.amount, Decimal('360.00')) self.assertEqual(entry.unit_amount, Decimal('180.0000')) self.assertEqual(entry.quantity, Decimal('2.0000')) self.assertEqual(entry.unit_name, '人天') def test_create_cost_entry_formula_overrides_amount(self): """倍数型支出以公式结果作为最终统计金额""" entry = cost_services.create_cost_entry( merchant=self.merchant, category=self.category, amount=Decimal('999.00'), unit_amount=Decimal('120.00'), quantity=Decimal('3'), occurred_at=date(2026, 6, 4), ) self.assertEqual(entry.amount, Decimal('360.00')) class MockCostProvider: """测试用 Provider""" category_key = 'test_provider' def __init__(self, entries): self._entries = entries def get_cost_entries(self, *, merchant, start_date, end_date): return self._entries class CollectFromProviderTests(TestCase): def setUp(self): self.merchant = basic_models.Merchant.objects.create( name='测试商户', type=basic_models.MerchantTypeEnum.STORE, ) def test_collect_creates_entries(self): """正常采集并创建 CostEntry""" entries = [ CostEntryInput( category_key='electricity', category_name='电费', amount=Decimal('300.00'), occurred_at=date(2026, 6, 1), source_module='test', source_id='E001', ), CostEntryInput( category_key='water', category_name='水费', amount=Decimal('80.00'), occurred_at=date(2026, 6, 2), source_module='test', source_id='W001', ), ] provider = MockCostProvider(entries) result = cost_services.collect_from_provider( provider=provider, merchant=self.merchant, start_date=date(2026, 6, 1), end_date=date(2026, 6, 30), ) self.assertEqual(result['total_seen'], 2) self.assertEqual(result['created_count'], 2) self.assertEqual(result['error_count'], 0) self.assertEqual(len(result['errors']), 0) self.assertEqual(result['provider_category_key'], 'test_provider') # 验证数据库 self.assertEqual(cost_models.CostEntry.objects.count(), 2) self.assertEqual(cost_models.CostCategory.objects.count(), 2) def test_collect_creates_formula_entries(self): """Provider 可以提供 unit_amount + quantity,由 cost 模块计算 amount""" entries = [ CostEntryInput( category_key='temp_worker', category_name='临时工工资', amount=None, unit_amount=Decimal('200.00'), quantity=Decimal('3'), unit_name='人天', occurred_at=date(2026, 6, 3), source_module='test', source_id='T001', ), ] provider = MockCostProvider(entries) result = cost_services.collect_from_provider( provider=provider, merchant=self.merchant, start_date=date(2026, 6, 1), end_date=date(2026, 6, 30), ) self.assertEqual(result['created_count'], 1) entry = cost_models.CostEntry.objects.get() self.assertEqual(entry.amount, Decimal('600.00')) self.assertEqual(entry.unit_amount, Decimal('200.0000')) self.assertEqual(entry.quantity, Decimal('3.0000')) self.assertEqual(entry.unit_name, '人天') def test_collect_returns_not_list_raises(self): """Provider 返回非 list 抛 TypeError""" class BadProvider: category_key = 'bad' def get_cost_entries(self, *, merchant, start_date, end_date): return 'not_a_list' with self.assertRaises(TypeError): cost_services.collect_from_provider( provider=BadProvider(), merchant=self.merchant, start_date=date(2026, 6, 1), end_date=date(2026, 6, 30), ) def test_collect_non_entry_input_records_error(self): """非 CostEntryInput 的条目记入 errors""" provider = MockCostProvider(['not_a_dataclass', 123]) result = cost_services.collect_from_provider( provider=provider, merchant=self.merchant, start_date=date(2026, 6, 1), end_date=date(2026, 6, 30), ) self.assertEqual(result['total_seen'], 2) self.assertEqual(result['created_count'], 0) self.assertEqual(result['error_count'], 2) self.assertEqual(cost_models.CostEntry.objects.count(), 0) def test_collect_empty_category_key_records_error(self): """category_key 为空导致 ValueError 记入 errors""" entries = [ CostEntryInput( category_key='', category_name='无key类目', amount=Decimal('100.00'), occurred_at=date(2026, 6, 1), source_module='test', source_id='X001', ), ] provider = MockCostProvider(entries) result = cost_services.collect_from_provider( provider=provider, merchant=self.merchant, start_date=date(2026, 6, 1), end_date=date(2026, 6, 30), ) self.assertEqual(result['total_seen'], 1) self.assertEqual(result['created_count'], 0) self.assertEqual(result['error_count'], 1) self.assertIn('category_key', result['errors'][0]['error']) def test_collect_empty_list(self): """空列表返回 created_count=0""" provider = MockCostProvider([]) result = cost_services.collect_from_provider( provider=provider, merchant=self.merchant, start_date=date(2026, 6, 1), end_date=date(2026, 6, 30), ) self.assertEqual(result['total_seen'], 0) self.assertEqual(result['created_count'], 0) self.assertEqual(result['error_count'], 0) def test_collect_mixed_valid_invalid_partial_success(self): """混合合法+非法条目,合法条目正常创建""" entries = [ CostEntryInput( category_key='electricity', category_name='电费', amount=Decimal('300.00'), occurred_at=date(2026, 6, 1), source_module='test', source_id='E001', ), 'invalid_item', CostEntryInput( category_key='', category_name='', amount=Decimal('50.00'), occurred_at=date(2026, 6, 2), source_module='test', source_id='X002', ), ] provider = MockCostProvider(entries) result = cost_services.collect_from_provider( provider=provider, merchant=self.merchant, start_date=date(2026, 6, 1), end_date=date(2026, 6, 30), ) self.assertEqual(result['total_seen'], 3) self.assertEqual(result['created_count'], 1) self.assertEqual(result['error_count'], 2) self.assertEqual(cost_models.CostEntry.objects.count(), 1) def test_collect_reuses_existing_category(self): """已有类目时不重复创建""" cost_models.CostCategory.objects.create( merchant=self.merchant, unique_key='electricity', name='电费旧名', ) entries = [ CostEntryInput( category_key='electricity', category_name='电费新名', amount=Decimal('100.00'), occurred_at=date(2026, 6, 1), source_module='test', source_id='E001', ), ] provider = MockCostProvider(entries) cost_services.collect_from_provider( provider=provider, merchant=self.merchant, start_date=date(2026, 6, 1), end_date=date(2026, 6, 30), ) self.assertEqual(cost_models.CostCategory.objects.count(), 1) cat = cost_models.CostCategory.objects.first() self.assertEqual(cat.name, '电费旧名') # 不覆盖已有 name class AggregateByCategoryTests(TestCase): def setUp(self): self.merchant = basic_models.Merchant.objects.create( name='测试商户', type=basic_models.MerchantTypeEnum.STORE, ) self.other_merchant = basic_models.Merchant.objects.create( name='其他商户', type=basic_models.MerchantTypeEnum.STORE, ) self.cat_a = cost_models.CostCategory.objects.create( merchant=self.merchant, unique_key='electricity', name='电费', ) self.cat_b = cost_models.CostCategory.objects.create( merchant=self.merchant, unique_key='water', name='水费', ) def _create_entries(self): """创建测试数据:电费 300+200=500,水费 80,其他商户 1000""" cost_models.CostEntry.objects.create( merchant=self.merchant, category=self.cat_a, amount=Decimal('300.00'), occurred_at=date(2026, 6, 1), ) cost_models.CostEntry.objects.create( merchant=self.merchant, category=self.cat_a, amount=Decimal('200.00'), occurred_at=date(2026, 6, 10), ) cost_models.CostEntry.objects.create( merchant=self.merchant, category=self.cat_b, amount=Decimal('80.00'), occurred_at=date(2026, 6, 5), ) other_cat = cost_models.CostCategory.objects.create( merchant=self.other_merchant, unique_key='other', name='其他', ) cost_models.CostEntry.objects.create( merchant=self.other_merchant, category=other_cat, amount=Decimal('1000.00'), occurred_at=date(2026, 6, 15), ) def test_aggregate_by_category_desc_order(self): """按 total_amount 降序,只统计本商户""" self._create_entries() result = cost_services.aggregate_by_category(merchant=self.merchant) self.assertEqual(len(result), 2) self.assertEqual(result[0]['category_name'], '电费') self.assertEqual(result[0]['total_amount'], '500.00') self.assertEqual(result[0]['entry_count'], 2) self.assertEqual(result[1]['category_name'], '水费') self.assertEqual(result[1]['total_amount'], '80.00') self.assertEqual(result[1]['entry_count'], 1) def test_aggregate_by_category_with_start_date(self): """start_date 过滤""" self._create_entries() result = cost_services.aggregate_by_category( merchant=self.merchant, start_date=date(2026, 6, 5), ) self.assertEqual(len(result), 2) totals = {r['category_name']: r['total_amount'] for r in result} self.assertEqual(totals['电费'], '200.00') # 只有 6.10 那条 self.assertEqual(totals['水费'], '80.00') def test_aggregate_by_category_with_end_date(self): """end_date 过滤""" self._create_entries() result = cost_services.aggregate_by_category( merchant=self.merchant, end_date=date(2026, 6, 5), ) self.assertEqual(len(result), 2) totals = {r['category_name']: r['total_amount'] for r in result} self.assertEqual(totals['电费'], '300.00') # 只有 6.1 那条 self.assertEqual(totals['水费'], '80.00') def test_aggregate_by_category_with_both_dates(self): """start_date + end_date 同时过滤""" self._create_entries() result = cost_services.aggregate_by_category( merchant=self.merchant, start_date=date(2026, 6, 5), end_date=date(2026, 6, 8), ) self.assertEqual(len(result), 1) self.assertEqual(result[0]['category_name'], '水费') self.assertEqual(result[0]['total_amount'], '80.00') def test_aggregate_no_data_returns_empty(self): """无数据返回空列表""" result = cost_services.aggregate_by_category(merchant=self.merchant) self.assertEqual(result, []) def test_aggregate_includes_category_key(self): """返回结果包含 category_key""" self._create_entries() result = cost_services.aggregate_by_category(merchant=self.merchant) self.assertEqual(result[0]['category_key'], 'electricity') self.assertEqual(result[1]['category_key'], 'water')