# 成本模块 (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 双向依赖机制

**方向 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, 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 层作为推荐业务入口 |
