1
0
forked from erp-dev/erp
Files
erpnew/cost/tests/test_services.py
2026-06-09 11:15:02 +08:00

445 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, '')
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_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')