1
0
forked from erp-dev/erp
Files
erpnew/cost/models.py
2026-07-01 11:51:13 +08:00

212 lines
6.8 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.
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
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
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):
"""支出明细"""
AMOUNT_QUANT = Decimal('0.01')
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, 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',
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})'
@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)
# ==================== 六边形端口协议 ====================
@dataclass(slots=True)
class CostEntryInput:
"""Provider 返回的结构化成本条目"""
category_key: str
category_name: str
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 = ''
@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]:
...