forked from erp-dev/erp
9.1 KiB
9.1 KiB
成本模块 (cost) 设计文档
1. 概述
成本模块是 ERP 系统中用于记录和管理各项支出的独立模块。第一版实现手工开支记账功能(支出类目 + 支出明细),后续版本将纳入从其它模块(如印刷 printing、库存 stock、物流 shipment 等)自动采集的成本数据。
设计原则
- 六边形架构:通过
CostProviderPort协议定义成本数据输入端口,其它模块只需实现该接口即可被成本模块统一采集。 - 双向可依赖:Provider 既可以被动被 cost 模块调用,也可以主动
importcost 模块的基础能力(如ensure_category)来预创建类目。 - 独立部署单元:cost 是独立的 Django app,不修改现有模块。
2. 模块结构
cost/ # 新 Django app
├── __init__.py
├── apps.py # CostConfig
├── models.py # CostCategory, CostEntry, CostProviderPort, CostEntryInput
├── services.py # ensure_category, create_cost_entry, aggregate_by_category, collect_from_provider
├── admin.py # CostCategoryAdmin, CostEntryAdmin
├── tasks.py # Celery 任务(预留)
├── tests/
│ ├── __init__.py
│ ├── test_models.py
│ └── test_services.py
└── migrations/
└── __init__.py
api_v2/views/cost.py # API View(不放在 cost 内部)
3. 模型设计
3.1 CostCategory — 支出类目
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BigAutoField (PK) | |
merchant |
FK → Merchant | 所属商户 |
unique_key |
CharField (max_length=100) | 全局唯一标识键,用于 Port 协议匹配 |
name |
CharField (max_length=100) | 类目显示名 |
parent |
FK → self (null) | 父类目,支持层级 |
description |
TextField (null) | 备注 |
created_at |
DateTimeField | ModelBase |
updated_at |
DateTimeField | ModelBase |
约束:unique_together = ('merchant', 'unique_key')
3.2 CostEntry — 支出明细
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BigAutoField (PK) | |
merchant |
FK → Merchant | 所属商户 |
category |
FK → CostCategory | 支出类目 |
amount |
DecimalField(15, 2) | 最终支出金额 / 统计金额。普通支出手工填写;倍数型支出由 unit_amount * quantity 计算写入 |
unit_amount |
DecimalField(15, 4, null) | 单价 / 基数金额,如临时工日薪 |
quantity |
DecimalField(12, 4, null) | 数量 / 倍数,如人天、小时、件数 |
unit_name |
CharField(max_length=20, null) | 单位名称,如 人天、小时、件 |
occurred_at |
DateField | 发生日期 |
operator |
FK → Employee | 经办人 |
image1 |
ImageField (null) | 凭证图片 |
image2 |
ImageField (null) | 备用凭证图片 |
source_module |
CharField (null, max_length=50) | 来源模块名,如 'printing' |
source_id |
CharField (null, max_length=100) | 来源记录 ID |
remarks |
TextField (null) | 备注 |
created_at |
DateTimeField | ModelBase |
updated_at |
DateTimeField | ModelBase |
4. 六边形端口协议
4.1 CostEntryInput
@dataclass
class CostEntryInput:
category_key: str # 类目标识键,用于匹配 CostCategory.unique_key
category_name: str # 类目显示名(匹配不到时用此名自动创建)
amount: Decimal | None # 最终金额;倍数型支出可传 None,由 unit_amount * quantity 计算
occurred_at: date
source_module: str
source_id: str
unit_amount: Decimal | None = None
quantity: Decimal | None = None
unit_name: str = ''
remarks: str = ''
4.2 CostProviderPort (Protocol)
@runtime_checkable
class CostProviderPort(Protocol):
category_key: str # 类级别:该 Provider 默认使用的类目标识键
def get_cost_entries(
self, *, merchant, start_date: date, end_date: date
) -> list[CostEntryInput]:
...
4.3 双向依赖机制
方向 1:Cost 模块调用 Provider
# cost/services.py
def collect_from_provider(provider: CostProviderPort, *, merchant, start_date, end_date):
"""从 Provider 采集成本数据"""
for entry in provider.get_cost_entries(merchant=merchant, start_date=start_date, end_date=end_date):
cat = ensure_category(merchant=merchant, category_key=entry.category_key, category_name=entry.category_name)
create_cost_entry(merchant=merchant, category=cat, ...)
方向 2:Provider 调用 Cost 模块基础能力
# 第三方模块中
from cost.services import ensure_category
class PrintingCostProvider:
category_key = 'printing_consumables'
def get_cost_entries(self, *, merchant, start_date, end_date):
# Provider 主动确保类目存在
ensure_category(merchant=merchant, category_key=self.category_key, category_name='印刷耗材')
# ... 计算成本条目 ...
4.4 类目匹配逻辑
ensure_category() 优先按 unique_key 匹配现有类目,匹配不到时自动创建:
1. 查 CostCategory.objects.filter(merchant=merchant, unique_key=category_key)
2. 命中 → 返回已有类目
3. 未命中 → 创建:CostCategory(merchant=merchant, unique_key=category_key, name=category_name)
5. Services 层
| 函数 | 说明 |
|---|---|
ensure_category(*, merchant, category_key, category_name) |
按 key 查找或创建支出类目 |
create_cost_entry(*, merchant, category, occurred_at, amount, unit_amount, quantity, unit_name, operator, image1, image2, source_module, source_id, remarks) |
创建支出记录;普通支出使用 amount,倍数型支出使用 unit_amount + quantity 自动计算最终 amount |
collect_from_provider(provider, *, merchant, start_date, end_date) |
从 Provider 采集成本数据 |
aggregate_by_category(*, merchant, start_date, end_date) |
按类别汇总(group by category) |
5.1 金额公式与写入约束
CostEntry.amount 永远表示最终支出金额,也是所有统计、排序、报表的唯一金额口径。倍数型支出使用 unit_amount * quantity 推导最终金额,保存时写回 amount;普通支出不填写公式字段,直接保存手工 amount。
规则:
unit_amount和quantity必须同时填写或同时为空。- 当
unit_amount和quantity同时存在时,amount以公式计算结果为准,手工传入的amount会被覆盖。 - 当公式字段为空时,
amount必须填写。 unit_name只用于展示单位,不参与金额计算。
WARNING: 禁止使用
QuerySet.update()、bulk_update()或 SQL 直接更新CostEntry.amount、unit_amount、quantity。这些写法不会触发CostEntry.save(),会绕过金额公式重算,可能造成统计金额错误。更新支出明细必须使用 service 入口或实例save();如果确实需要批量修正,必须编写专门的数据迁移/管理命令,并在命令内逐条调用save()。
6. API 设计 (api_v2)
| Method | Path | 说明 |
|---|---|---|
| GET | /api/v2/cost-categories/ |
类目列表(支持 ?merchant_id= 筛选) |
| POST | /api/v2/cost-categories/ |
创建类目 |
| GET | /api/v2/cost-categories/<id>/ |
类目详情 |
| PUT | /api/v2/cost-categories/<id>/ |
修改类目 |
| DELETE | /api/v2/cost-categories/<id>/ |
删除类目 |
| GET | /api/v2/cost-entries/ |
支出明细列表(支持 ?start=&end=&category_id=&merchant_id=) |
| POST | /api/v2/cost-entries/ |
创建支出记录(multipart/form-data 支持图片上传) |
| GET | /api/v2/cost-entries/<id>/ |
支出明细详情 |
| PUT | /api/v2/cost-entries/<id>/ |
修改支出记录 |
| DELETE | /api/v2/cost-entries/<id>/ |
删除支出记录 |
| GET | /api/v2/cost-summary/by-category/?start=&end=&merchant_id= |
按支出类目汇总 |
7. 决策记录
| # | 决策 | 原因 |
|---|---|---|
| 1 | 成本模块独立为 cost app |
遵循项目惯例 business/printing/stock 各有独立 app;成本后续会关联多模块,独立 app 避免循环依赖 |
| 2 | API 放在 api_v2 而非 cost 内部 |
项目约定 API 层与业务模型分离 |
| 3 | 用 typing.Protocol 而非 ABC 定义端口 |
不需要显式注册/继承,符合 Python 鸭子类型习惯 |
| 4 | CostCategory.unique_key 用于 Port 匹配 |
比按 name 匹配更稳定,避免重名/改名问题 |
| 5 | CostEntry 带两个 ImageField |
用户要求:一个用于凭证图片,一个预留备用 |
| 6 | 汇总 API 按 start/end 时间段 + group by category |
用户指定的统计方式 |
| 7 | 第一版不做 Provider 注册表 | Provider 暂时只有一个调用入口 collect_from_provider(),后续可扩展为注册表模式 |
| 8 | amount 固定为最终统计金额 |
兼容普通金额支出与倍数型支出,避免统计层判断 amount 的双重语义 |
| 9 | 金额公式在 CostEntry.save() 兜底计算 |
保证 create/update 经实例保存时都能重算 amount,service 层作为推荐业务入口 |