1
0
forked from erp-dev/erp
Files
erpnew/docs/cost/design.md
2026-06-09 11:15:02 +08:00

180 lines
7.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) | 金额 |
| `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
occurred_at: date
source_module: str
source_id: 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, amount, occurred_at, operator, image1, image2, source_module, source_id, remarks)` | 创建支出记录 |
| `collect_from_provider(provider, *, merchant, start_date, end_date)` | 从 Provider 采集成本数据 |
| `aggregate_by_category(*, merchant, start_date, end_date)` | 按类别汇总group by category |
---
## 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()`,后续可扩展为注册表模式 |