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

203 lines
9.1 KiB
Markdown
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.
# 成本模块 (cost) 设计文档
## 1. 概述
成本模块是 ERP 系统中用于记录和管理各项支出的独立模块。第一版实现手工开支记账功能(支出类目 + 支出明细),后续版本将纳入从其它模块(如印刷 `printing`、库存 `stock`、物流 `shipment` 等)自动采集的成本数据。
### 设计原则
- **六边形架构**:通过 `CostProviderPort` 协议定义成本数据输入端口,其它模块只需实现该接口即可被成本模块统一采集。
- **双向可依赖**Provider 既可以被动被 cost 模块调用,也可以主动 `import` cost 模块的基础能力(如 `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
```python
@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)
```python
@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 双向依赖机制
**方向 1Cost 模块调用 Provider**
```python
# 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, ...)
```
**方向 2Provider 调用 Cost 模块基础能力**
```python
# 第三方模块中
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 层作为推荐业务入口 |