forked from erp-dev/erp
feat: completed
This commit is contained in:
0
cost/__init__.py
Normal file
0
cost/__init__.py
Normal file
22
cost/admin.py
Normal file
22
cost/admin.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.CostCategory)
|
||||
class CostCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'merchant', 'unique_key', 'name', 'parent', 'created_at')
|
||||
search_fields = ('name', 'unique_key')
|
||||
list_filter = ('merchant', 'created_at')
|
||||
ordering = ('merchant', 'name')
|
||||
|
||||
|
||||
@admin.register(models.CostEntry)
|
||||
class CostEntryAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'id', 'merchant', 'category', 'amount', 'occurred_at',
|
||||
'operator', 'source_module', 'source_id', 'created_at',
|
||||
)
|
||||
search_fields = ('category__name', 'source_module', 'source_id')
|
||||
list_filter = ('merchant', 'category', 'occurred_at', 'source_module', 'created_at')
|
||||
ordering = ('-occurred_at', '-created_at')
|
||||
7
cost/apps.py
Normal file
7
cost/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CostConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'cost'
|
||||
verbose_name = '成本模块'
|
||||
58
cost/migrations/0001_initial.py
Normal file
58
cost/migrations/0001_initial.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 5.2.8 on 2026-06-06 08:01
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0027_employee_wecom_user_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CostCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('unique_key', models.CharField(max_length=100, verbose_name='唯一标识键')),
|
||||
('name', models.CharField(max_length=100, verbose_name='类目名称')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='描述')),
|
||||
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cost_categories', to='basic_info.merchant', verbose_name='所属商户')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='cost.costcategory', verbose_name='父类目')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '支出类目',
|
||||
'verbose_name_plural': '支出类目',
|
||||
'ordering': ('merchant', 'name'),
|
||||
'unique_together': {('merchant', 'unique_key')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CostEntry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='金额')),
|
||||
('occurred_at', models.DateField(verbose_name='发生日期')),
|
||||
('image1', models.ImageField(blank=True, null=True, upload_to='cost/entries/', verbose_name='凭证图片')),
|
||||
('image2', models.ImageField(blank=True, null=True, upload_to='cost/entries/', verbose_name='备用凭证图片')),
|
||||
('source_module', models.CharField(blank=True, max_length=50, null=True, verbose_name='来源模块')),
|
||||
('source_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='来源记录ID')),
|
||||
('remarks', models.TextField(blank=True, null=True, verbose_name='备注')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='entries', to='cost.costcategory', verbose_name='支出类目')),
|
||||
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cost_entries', to='basic_info.merchant', verbose_name='所属商户')),
|
||||
('operator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cost_entries', to='basic_info.employee', verbose_name='经办人')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '支出明细',
|
||||
'verbose_name_plural': '支出明细',
|
||||
'ordering': ('-occurred_at', '-created_at'),
|
||||
},
|
||||
),
|
||||
]
|
||||
0
cost/migrations/__init__.py
Normal file
0
cost/migrations/__init__.py
Normal file
151
cost/models.py
Normal file
151
cost/models.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from django.db import models
|
||||
|
||||
from flower.common import ModelBase
|
||||
|
||||
|
||||
class CostCategory(ModelBase):
|
||||
"""支出类目"""
|
||||
|
||||
merchant = models.ForeignKey(
|
||||
'basic_info.Merchant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='cost_categories',
|
||||
verbose_name='所属商户',
|
||||
)
|
||||
unique_key = models.CharField(max_length=100, verbose_name='唯一标识键')
|
||||
name = models.CharField(max_length=100, verbose_name='类目名称')
|
||||
parent = models.ForeignKey(
|
||||
'self',
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='children',
|
||||
verbose_name='父类目',
|
||||
)
|
||||
description = models.TextField(null=True, blank=True, verbose_name='描述')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '支出类目'
|
||||
verbose_name_plural = '支出类目'
|
||||
unique_together = ('merchant', 'unique_key')
|
||||
ordering = ('merchant', 'name')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.unique_key})'
|
||||
|
||||
|
||||
class CostEntry(ModelBase):
|
||||
"""支出明细"""
|
||||
|
||||
merchant = models.ForeignKey(
|
||||
'basic_info.Merchant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='cost_entries',
|
||||
verbose_name='所属商户',
|
||||
)
|
||||
category = models.ForeignKey(
|
||||
CostCategory,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='entries',
|
||||
verbose_name='支出类目',
|
||||
)
|
||||
amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额')
|
||||
occurred_at = models.DateField(verbose_name='发生日期')
|
||||
operator = models.ForeignKey(
|
||||
'basic_info.Employee',
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='cost_entries',
|
||||
verbose_name='经办人',
|
||||
)
|
||||
image1 = models.ImageField(null=True, blank=True, upload_to='cost/entries/', verbose_name='凭证图片')
|
||||
image2 = models.ImageField(null=True, blank=True, upload_to='cost/entries/', verbose_name='备用凭证图片')
|
||||
source_module = models.CharField(
|
||||
max_length=50, null=True, blank=True, verbose_name='来源模块',
|
||||
)
|
||||
source_id = models.CharField(
|
||||
max_length=100, null=True, blank=True, verbose_name='来源记录ID',
|
||||
)
|
||||
remarks = models.TextField(null=True, blank=True, verbose_name='备注')
|
||||
|
||||
class Meta:
|
||||
verbose_name = '支出明细'
|
||||
verbose_name_plural = '支出明细'
|
||||
ordering = ('-occurred_at', '-created_at')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.category.name} {self.amount} ({self.occurred_at})'
|
||||
|
||||
|
||||
# ==================== 六边形端口协议 ====================
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CostEntryInput:
|
||||
"""Provider 返回的结构化成本条目"""
|
||||
|
||||
category_key: str
|
||||
category_name: str
|
||||
amount: Decimal
|
||||
occurred_at: date
|
||||
source_module: str
|
||||
source_id: str
|
||||
remarks: str = ''
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CostProviderPort(Protocol):
|
||||
"""
|
||||
成本数据提供者接口。
|
||||
|
||||
其它模块实现此接口即可被 cost.services.collect_from_provider() 统一采集。
|
||||
|
||||
Provider 可以通过 category_key 声明自己使用的类目:
|
||||
- 如果 cost 模块已有匹配的 category_key,直接使用
|
||||
- 如果没有,cost 模块会调用 ensure_category() 自动创建
|
||||
|
||||
Provider 也可以反向依赖 cost 模块的基础能力:
|
||||
- from cost.services import ensure_category
|
||||
- 在实现 get_cost_entries 前主动调用 ensure_category 预建类目
|
||||
|
||||
使用示例::
|
||||
|
||||
from cost.models import CostProviderPort, CostEntryInput
|
||||
from cost.services import ensure_category
|
||||
|
||||
class PrintingCostProvider:
|
||||
|
||||
category_key = 'printing_consumables'
|
||||
|
||||
def get_cost_entries(self, *, merchant, start_date, end_date):
|
||||
ensure_category(
|
||||
merchant=merchant,
|
||||
category_key=self.category_key,
|
||||
category_name='印刷耗材',
|
||||
)
|
||||
return [
|
||||
CostEntryInput(
|
||||
category_key=self.category_key,
|
||||
category_name='印刷耗材',
|
||||
amount=Decimal('100.00'),
|
||||
occurred_at=date.today(),
|
||||
source_module='printing',
|
||||
source_id='PJ-001',
|
||||
),
|
||||
]
|
||||
"""
|
||||
|
||||
category_key: str
|
||||
|
||||
def get_cost_entries(
|
||||
self, *, merchant, start_date: date, end_date: date
|
||||
) -> list[CostEntryInput]:
|
||||
...
|
||||
178
cost/services.py
Normal file
178
cost/services.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Sum
|
||||
|
||||
from basic_info.models import Employee, Merchant
|
||||
from . import models as cost_models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_category(
|
||||
*,
|
||||
merchant: Merchant,
|
||||
category_key: str,
|
||||
category_name: str,
|
||||
description: str = '',
|
||||
) -> cost_models.CostCategory:
|
||||
"""按 unique_key 查找或创建支出类目。
|
||||
|
||||
这是 Provider 可以反向调用的基础能力。
|
||||
"""
|
||||
normalized_key = str(category_key or '').strip()
|
||||
normalized_name = str(category_name or '').strip()
|
||||
if not normalized_key:
|
||||
raise ValueError('category_key 不能为空')
|
||||
if not normalized_name:
|
||||
raise ValueError('category_name 不能为空')
|
||||
|
||||
category = cost_models.CostCategory.objects.filter(
|
||||
merchant=merchant,
|
||||
unique_key=normalized_key,
|
||||
).first()
|
||||
|
||||
if category:
|
||||
return category
|
||||
|
||||
return cost_models.CostCategory.objects.create(
|
||||
merchant=merchant,
|
||||
unique_key=normalized_key,
|
||||
name=normalized_name,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
def create_cost_entry(
|
||||
*,
|
||||
merchant: Merchant,
|
||||
category: cost_models.CostCategory,
|
||||
amount: Decimal,
|
||||
occurred_at: date,
|
||||
operator: Employee | None = None,
|
||||
image1=None,
|
||||
image2=None,
|
||||
source_module: str = '',
|
||||
source_id: str = '',
|
||||
remarks: str = '',
|
||||
) -> cost_models.CostEntry:
|
||||
"""创建一条支出记录。"""
|
||||
if category.merchant_id != merchant.id:
|
||||
raise ValueError('category 与 merchant 不属于同一商户')
|
||||
if operator is not None and operator.merchant_id != merchant.id:
|
||||
raise ValueError('operator 与 merchant 不属于同一商户')
|
||||
|
||||
return cost_models.CostEntry.objects.create(
|
||||
merchant=merchant,
|
||||
category=category,
|
||||
amount=amount,
|
||||
occurred_at=occurred_at,
|
||||
operator=operator,
|
||||
image1=image1,
|
||||
image2=image2,
|
||||
source_module=source_module or '',
|
||||
source_id=source_id or '',
|
||||
remarks=remarks or '',
|
||||
)
|
||||
|
||||
|
||||
def collect_from_provider(
|
||||
provider: cost_models.CostProviderPort,
|
||||
*,
|
||||
merchant: Merchant,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
) -> dict[str, Any]:
|
||||
"""从 Provider 采集成本数据,返回写入统计。"""
|
||||
entries = provider.get_cost_entries(merchant=merchant, start_date=start_date, end_date=end_date)
|
||||
if not isinstance(entries, list):
|
||||
raise TypeError(f'Provider.get_cost_entries 必须返回 list,实际返回 {type(entries).__name__}')
|
||||
|
||||
created_count = 0
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
for index, entry in enumerate(entries, start=1):
|
||||
if not isinstance(entry, cost_models.CostEntryInput):
|
||||
errors.append({
|
||||
'index': index,
|
||||
'error': f'条目不是 CostEntryInput 实例,实际类型: {type(entry).__name__}',
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
category = ensure_category(
|
||||
merchant=merchant,
|
||||
category_key=entry.category_key,
|
||||
category_name=entry.category_name,
|
||||
)
|
||||
except ValueError as exc:
|
||||
errors.append({
|
||||
'index': index,
|
||||
'source_module': entry.source_module,
|
||||
'source_id': entry.source_id,
|
||||
'error': str(exc),
|
||||
})
|
||||
continue
|
||||
|
||||
create_cost_entry(
|
||||
merchant=merchant,
|
||||
category=category,
|
||||
amount=entry.amount,
|
||||
occurred_at=entry.occurred_at,
|
||||
operator=None, # provider 采集的记录没有经办人
|
||||
source_module=entry.source_module,
|
||||
source_id=entry.source_id,
|
||||
remarks=entry.remarks,
|
||||
)
|
||||
created_count += 1
|
||||
|
||||
return {
|
||||
'provider_category_key': getattr(provider, 'category_key', ''),
|
||||
'total_seen': len(entries),
|
||||
'created_count': created_count,
|
||||
'error_count': len(errors),
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
|
||||
def aggregate_by_category(
|
||||
*,
|
||||
merchant: Merchant,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""按支出类目汇总。
|
||||
|
||||
返回按 total_amount 降序排列的结果列表。
|
||||
"""
|
||||
queryset = cost_models.CostEntry.objects.filter(merchant=merchant)
|
||||
|
||||
if start_date is not None:
|
||||
queryset = queryset.filter(occurred_at__gte=start_date)
|
||||
if end_date is not None:
|
||||
queryset = queryset.filter(occurred_at__lte=end_date)
|
||||
|
||||
aggregated = (
|
||||
queryset.values(
|
||||
'category_id',
|
||||
'category__name',
|
||||
'category__unique_key',
|
||||
)
|
||||
.annotate(total_amount=Sum('amount'), entry_count=Sum(1))
|
||||
.order_by('-total_amount')
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'category_id': row['category_id'],
|
||||
'category_name': row['category__name'],
|
||||
'category_key': row['category__unique_key'],
|
||||
'total_amount': str(row['total_amount']),
|
||||
'entry_count': row['entry_count'],
|
||||
}
|
||||
for row in aggregated
|
||||
]
|
||||
1
cost/tasks.py
Normal file
1
cost/tasks.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Celery 任务(当前版本预留,暂无定时任务)。"""
|
||||
0
cost/tests/__init__.py
Normal file
0
cost/tests/__init__.py
Normal file
145
cost/tests/test_models.py
Normal file
145
cost/tests/test_models.py
Normal 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
445
cost/tests/test_services.py
Normal 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')
|
||||
Reference in New Issue
Block a user