forked from erp-dev/erp
feat: completed
This commit is contained in:
180
docs/cost/design.md
Normal file
180
docs/cost/design.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 成本模块 (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 双向依赖机制
|
||||
|
||||
**方向 1:Cost 模块调用 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, ...)
|
||||
```
|
||||
|
||||
**方向 2:Provider 调用 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()`,后续可扩展为注册表模式 |
|
||||
Reference in New Issue
Block a user