1
0
forked from erp-dev/erp

feat: completed

This commit is contained in:
2026-06-09 11:15:02 +08:00
parent 3b2aacaa5c
commit 65564f772a
20 changed files with 2444 additions and 5 deletions

0
cost/tests/__init__.py Normal file
View File

145
cost/tests/test_models.py Normal file
View File

@@ -0,0 +1,145 @@
"""Cost 模块模型测试"""
from datetime import date
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.test import TestCase
from basic_info import models as basic_models
from cost import models as cost_models
class CostCategoryModelTests(TestCase):
def setUp(self):
self.merchant = basic_models.Merchant.objects.create(
name='测试商户', type=basic_models.MerchantTypeEnum.STORE,
)
def test_str_representation(self):
cat = cost_models.CostCategory.objects.create(
merchant=self.merchant, unique_key='electricity', name='电费',
)
self.assertEqual(str(cat), '电费 (electricity)')
def test_unique_together_merchant_key(self):
"""同一商户 same unique_key 不可重复"""
cost_models.CostCategory.objects.create(
merchant=self.merchant, unique_key='electricity', name='电费',
)
with self.assertRaises(IntegrityError):
cost_models.CostCategory.objects.create(
merchant=self.merchant, unique_key='electricity', name='电费2',
)
def test_different_merchants_same_key_allowed(self):
"""不同商户 same unique_key 允许"""
other = basic_models.Merchant.objects.create(
name='其他商户', type=basic_models.MerchantTypeEnum.STORE,
)
cat1 = cost_models.CostCategory.objects.create(
merchant=self.merchant, unique_key='electricity', name='电费',
)
cat2 = cost_models.CostCategory.objects.create(
merchant=other, unique_key='electricity', name='电费',
)
self.assertNotEqual(cat1.id, cat2.id)
def test_parent_self_reference(self):
"""父类目 self-FK"""
parent = cost_models.CostCategory.objects.create(
merchant=self.merchant, unique_key='utilities', name='公共事业',
)
child = cost_models.CostCategory.objects.create(
merchant=self.merchant, unique_key='electricity', name='电费',
parent=parent,
)
self.assertEqual(child.parent_id, parent.id)
self.assertEqual(list(parent.children.all()), [child])
def test_description_nullable(self):
cat = cost_models.CostCategory.objects.create(
merchant=self.merchant, unique_key='key', name='name',
)
self.assertIsNone(cat.description)
def test_ordering_by_merchant_then_name(self):
other = basic_models.Merchant.objects.create(
name='其他商户', type=basic_models.MerchantTypeEnum.STORE,
)
cost_models.CostCategory.objects.create(
merchant=self.merchant, unique_key='z', name='Z类目',
)
cost_models.CostCategory.objects.create(
merchant=self.merchant, unique_key='a', name='A类目',
)
cost_models.CostCategory.objects.create(
merchant=other, unique_key='m', name='M类目',
)
# ordering = ('merchant', 'name') — 同一个 merchant 内按 name 排序
own = cost_models.CostCategory.objects.filter(merchant=self.merchant)
own_names = [c.name for c in own]
self.assertEqual(own_names, ['A类目', 'Z类目']) # name 升序
class CostEntryModelTests(TestCase):
def setUp(self):
self.merchant = basic_models.Merchant.objects.create(
name='测试商户', type=basic_models.MerchantTypeEnum.STORE,
)
self.category = cost_models.CostCategory.objects.create(
merchant=self.merchant, unique_key='transport', name='运输费',
)
def test_str_representation(self):
entry = cost_models.CostEntry.objects.create(
merchant=self.merchant,
category=self.category,
amount=Decimal('150.00'),
occurred_at=date(2026, 6, 1),
)
self.assertIn('运输费', str(entry))
self.assertIn('150.00', str(entry))
def test_default_ordering_by_occurred_at_desc(self):
e1 = cost_models.CostEntry.objects.create(
merchant=self.merchant, category=self.category,
amount=Decimal('100.00'), occurred_at=date(2026, 6, 1),
)
e2 = cost_models.CostEntry.objects.create(
merchant=self.merchant, category=self.category,
amount=Decimal('200.00'), occurred_at=date(2026, 6, 10),
)
entries = list(cost_models.CostEntry.objects.all())
self.assertEqual(entries[0].id, e2.id)
self.assertEqual(entries[1].id, e1.id)
def test_operator_nullable(self):
entry = cost_models.CostEntry.objects.create(
merchant=self.merchant,
category=self.category,
amount=Decimal('100.00'),
occurred_at=date(2026, 6, 1),
operator=None,
)
self.assertIsNone(entry.operator_id)
def test_source_fields_nullable(self):
entry = cost_models.CostEntry.objects.create(
merchant=self.merchant,
category=self.category,
amount=Decimal('100.00'),
occurred_at=date(2026, 6, 1),
)
self.assertIsNone(entry.source_module)
self.assertIsNone(entry.source_id)
def test_image_fields_nullable(self):
entry = cost_models.CostEntry.objects.create(
merchant=self.merchant,
category=self.category,
amount=Decimal('100.00'),
occurred_at=date(2026, 6, 1),
)
self.assertFalse(bool(entry.image1))
self.assertFalse(bool(entry.image2))

445
cost/tests/test_services.py Normal file
View File

@@ -0,0 +1,445 @@
"""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')