1
0
forked from erp-dev/erp

feat: big

This commit is contained in:
2026-07-01 11:51:13 +08:00
parent 7c9b77afb0
commit 5170700234
28 changed files with 933 additions and 37 deletions

View File

@@ -14,8 +14,9 @@ class CostCategoryAdmin(admin.ModelAdmin):
@admin.register(models.CostEntry)
class CostEntryAdmin(admin.ModelAdmin):
list_display = (
'id', 'merchant', 'category', 'amount', 'occurred_at',
'operator', 'source_module', 'source_id', 'created_at',
'id', 'merchant', 'category', 'amount', 'unit_amount', 'quantity',
'unit_name', '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')

View File

@@ -0,0 +1,33 @@
# Generated by Codex on 2026-06-30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cost', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='costentry',
name='amount',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, verbose_name='金额'),
),
migrations.AddField(
model_name='costentry',
name='unit_amount',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=15, null=True, verbose_name='单价/基数金额'),
),
migrations.AddField(
model_name='costentry',
name='quantity',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=12, null=True, verbose_name='数量/倍数'),
),
migrations.AddField(
model_name='costentry',
name='unit_name',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='单位名称'),
),
]

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from decimal import Decimal, ROUND_HALF_UP
from typing import Protocol, runtime_checkable
from django.core.exceptions import ValidationError
from django.db import models
from flower.common import ModelBase
@@ -44,6 +45,8 @@ class CostCategory(ModelBase):
class CostEntry(ModelBase):
"""支出明细"""
AMOUNT_QUANT = Decimal('0.01')
merchant = models.ForeignKey(
'basic_info.Merchant',
on_delete=models.PROTECT,
@@ -56,7 +59,27 @@ class CostEntry(ModelBase):
related_name='entries',
verbose_name='支出类目',
)
amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额')
amount = models.DecimalField(max_digits=15, decimal_places=2, blank=True, verbose_name='金额')
unit_amount = models.DecimalField(
max_digits=15,
decimal_places=4,
null=True,
blank=True,
verbose_name='单价/基数金额',
)
quantity = models.DecimalField(
max_digits=12,
decimal_places=4,
null=True,
blank=True,
verbose_name='数量/倍数',
)
unit_name = models.CharField(
max_length=20,
null=True,
blank=True,
verbose_name='单位名称',
)
occurred_at = models.DateField(verbose_name='发生日期')
operator = models.ForeignKey(
'basic_info.Employee',
@@ -84,6 +107,41 @@ class CostEntry(ModelBase):
def __str__(self):
return f'{self.category.name} {self.amount} ({self.occurred_at})'
@property
def has_amount_formula(self) -> bool:
"""是否使用 unit_amount * quantity 推导最终金额。"""
return self.unit_amount is not None or self.quantity is not None
def calculate_amount(self) -> Decimal | None:
"""根据单价和数量计算最终金额。"""
if self.unit_amount is None and self.quantity is None:
return None
if self.unit_amount is None or self.quantity is None:
raise ValidationError({
'unit_amount': 'unit_amount 和 quantity 必须同时填写或同时为空',
'quantity': 'unit_amount 和 quantity 必须同时填写或同时为空',
})
return (self.unit_amount * self.quantity).quantize(self.AMOUNT_QUANT, rounding=ROUND_HALF_UP)
def apply_amount_formula(self) -> None:
"""如果存在公式字段,则用公式结果覆盖 amount。"""
calculated_amount = self.calculate_amount()
if calculated_amount is not None:
self.amount = calculated_amount
def clean(self):
super().clean()
self.apply_amount_formula()
if self.amount is None:
raise ValidationError({'amount': '普通支出必须填写 amount'})
def save(self, *args, **kwargs):
self.clean()
update_fields = kwargs.get('update_fields')
if update_fields is not None and self.has_amount_formula:
kwargs['update_fields'] = set(update_fields) | {'amount'}
return super().save(*args, **kwargs)
# ==================== 六边形端口协议 ====================
@@ -94,10 +152,13 @@ class CostEntryInput:
category_key: str
category_name: str
amount: Decimal
amount: Decimal | None
occurred_at: date
source_module: str
source_id: str
unit_amount: Decimal | None = None
quantity: Decimal | None = None
unit_name: str = ''
remarks: str = ''

View File

@@ -51,11 +51,14 @@ def create_cost_entry(
*,
merchant: Merchant,
category: cost_models.CostCategory,
amount: Decimal,
occurred_at: date,
amount: Decimal | None = None,
operator: Employee | None = None,
image1=None,
image2=None,
unit_amount: Decimal | None = None,
quantity: Decimal | None = None,
unit_name: str = '',
source_module: str = '',
source_id: str = '',
remarks: str = '',
@@ -70,6 +73,9 @@ def create_cost_entry(
merchant=merchant,
category=category,
amount=amount,
unit_amount=unit_amount,
quantity=quantity,
unit_name=unit_name or '',
occurred_at=occurred_at,
operator=operator,
image1=image1,
@@ -122,6 +128,9 @@ def collect_from_provider(
merchant=merchant,
category=category,
amount=entry.amount,
unit_amount=entry.unit_amount,
quantity=entry.quantity,
unit_name=entry.unit_name,
occurred_at=entry.occurred_at,
operator=None, # provider 采集的记录没有经办人
source_module=entry.source_module,

View File

@@ -142,4 +142,54 @@ class CostEntryModelTests(TestCase):
occurred_at=date(2026, 6, 1),
)
self.assertFalse(bool(entry.image1))
self.assertFalse(bool(entry.image2))
self.assertFalse(bool(entry.image2))
def test_formula_amount_calculated_on_create(self):
entry = cost_models.CostEntry.objects.create(
merchant=self.merchant,
category=self.category,
amount=None,
unit_amount=Decimal('120.00'),
quantity=Decimal('2.5'),
unit_name='人天',
occurred_at=date(2026, 6, 1),
)
self.assertEqual(entry.amount, Decimal('300.00'))
self.assertEqual(entry.unit_name, '人天')
def test_formula_amount_recalculated_on_update(self):
entry = cost_models.CostEntry.objects.create(
merchant=self.merchant,
category=self.category,
amount=Decimal('300.00'),
unit_amount=Decimal('100.00'),
quantity=Decimal('3'),
occurred_at=date(2026, 6, 1),
)
entry.quantity = Decimal('4')
entry.amount = Decimal('999.00')
entry.save()
entry.refresh_from_db()
self.assertEqual(entry.amount, Decimal('400.00'))
def test_formula_partial_fields_raise_validation_error(self):
with self.assertRaises(ValidationError) as ctx:
cost_models.CostEntry.objects.create(
merchant=self.merchant,
category=self.category,
amount=None,
unit_amount=Decimal('100.00'),
quantity=None,
occurred_at=date(2026, 6, 1),
)
self.assertIn('quantity', ctx.exception.message_dict)
def test_manual_amount_required_without_formula(self):
with self.assertRaises(ValidationError) as ctx:
cost_models.CostEntry.objects.create(
merchant=self.merchant,
category=self.category,
amount=None,
occurred_at=date(2026, 6, 1),
)
self.assertIn('amount', ctx.exception.message_dict)

View File

@@ -171,6 +171,33 @@ class CreateCostEntryTests(TestCase):
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"""
@@ -222,6 +249,33 @@ class CollectFromProviderTests(TestCase):
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: