forked from erp-dev/erp
feat: completed
This commit is contained in:
329
docs/cost/api.md
Normal file
329
docs/cost/api.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# API v2 成本模块接口文档
|
||||
|
||||
本文档面向前端,描述 `cost` 成本模块的 API。
|
||||
|
||||
## 基本约定
|
||||
|
||||
- Base URL: `/api/v2`
|
||||
- 认证:所有接口都需要登录(JWT)。
|
||||
- 人员身份:后端使用 `request.user.employee` 作为当前员工身份。
|
||||
- 多商户隔离:所有类目、支出明细只允许访问当前员工所属商户的数据;可通过 `?merchant_id=` 参数跨商户查询(如果权限允许)。
|
||||
- 时间格式:请求参数中的日期使用 `YYYY-MM-DD`(如 `2026-06-01`),响应中的日期时间使用 ISO 8601。
|
||||
|
||||
---
|
||||
|
||||
## 1. 支出类目
|
||||
|
||||
### 1.1 类目列表
|
||||
|
||||
```
|
||||
GET /api/v2/cost-categories/
|
||||
```
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `merchant_id` | int | 否 | 按商户筛选,不传则使用当前员工的商户 |
|
||||
|
||||
成功响应 `200`:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"merchant_id": 1,
|
||||
"unique_key": "electricity",
|
||||
"name": "电费",
|
||||
"parent_id": null,
|
||||
"description": "每月电费支出",
|
||||
"created_at": "2026-06-06T10:00:00Z",
|
||||
"updated_at": "2026-06-06T10:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 1.2 创建类目
|
||||
|
||||
```
|
||||
POST /api/v2/cost-categories/
|
||||
```
|
||||
|
||||
请求体(JSON):
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `unique_key` | string | **是** | 唯一标识键,同商户不可重复,如 `electricity` |
|
||||
| `name` | string | **是** | 类目名称 |
|
||||
| `parent_id` | int | 否 | 父类目 ID,用于层级 |
|
||||
| `description` | string | 否 | 描述 |
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"unique_key": "electricity",
|
||||
"name": "电费",
|
||||
"description": "每月电费支出"
|
||||
}
|
||||
```
|
||||
|
||||
成功响应 `201`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"merchant_id": 1,
|
||||
"unique_key": "electricity",
|
||||
"name": "电费",
|
||||
"parent_id": null,
|
||||
"description": "每月电费支出",
|
||||
"created_at": "2026-06-06T10:00:00Z",
|
||||
"updated_at": "2026-06-06T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
错误响应 `400`:缺少必填字段时返回校验错误。
|
||||
|
||||
### 1.3 类目详情
|
||||
|
||||
```
|
||||
GET /api/v2/cost-categories/{category_id}/
|
||||
```
|
||||
|
||||
成功响应 `200`:返回单个类目对象,结构同上。
|
||||
|
||||
错误响应 `404`:类目不存在或不属于当前商户。
|
||||
|
||||
### 1.4 修改类目
|
||||
|
||||
```
|
||||
PUT /api/v2/cost-categories/{category_id}/
|
||||
```
|
||||
|
||||
请求体(JSON,所有字段可选):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `unique_key` | string | 唯一标识键 |
|
||||
| `name` | string | 类目名称 |
|
||||
| `parent_id` | int | 父类目 ID,传 `null` 清除 |
|
||||
| `description` | string | 描述 |
|
||||
|
||||
成功响应 `200`:返回更新后的类目对象。
|
||||
|
||||
### 1.5 删除类目
|
||||
|
||||
```
|
||||
DELETE /api/v2/cost-categories/{category_id}/
|
||||
```
|
||||
|
||||
成功响应 `204`(无响应体)。
|
||||
|
||||
错误响应 `404`:类目不存在或不属于当前商户。
|
||||
|
||||
---
|
||||
|
||||
## 2. 支出明细
|
||||
|
||||
### 2.1 支出明细列表
|
||||
|
||||
```
|
||||
GET /api/v2/cost-entries/
|
||||
```
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `merchant_id` | int | 否 | 按商户筛选 |
|
||||
| `category_id` | int | 否 | 按支出类目筛选 |
|
||||
| `start` | date | 否 | 发生日期起始,格式 `YYYY-MM-DD` |
|
||||
| `end` | date | 否 | 发生日期截止,格式 `YYYY-MM-DD` |
|
||||
|
||||
默认按 `occurred_at` 降序排列。
|
||||
|
||||
成功响应 `200`:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"merchant_id": 1,
|
||||
"category_id": 1,
|
||||
"category_name": "电费",
|
||||
"amount": "350.00",
|
||||
"occurred_at": "2026-06-01",
|
||||
"operator_id": 12,
|
||||
"image1": "",
|
||||
"image2": "",
|
||||
"source_module": "",
|
||||
"source_id": "",
|
||||
"remarks": "六月份电费",
|
||||
"created_at": "2026-06-06T10:00:00Z",
|
||||
"updated_at": "2026-06-06T10:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `amount` | string | 金额(Decimal 转字符串,前端展示时注意格式化) |
|
||||
| `occurred_at` | string | 发生日期,`YYYY-MM-DD` |
|
||||
| `operator_id` | int | 经办人 ID,自动从当前登录用户获取 |
|
||||
| `image1` | string | 凭证图片 URL(七牛云 CDN),无则为空字符串 |
|
||||
| `image2` | string | 备用凭证图片 URL,无则为空字符串 |
|
||||
| `source_module` | string | 来源模块名,手工录入时为空 |
|
||||
| `source_id` | string | 来源记录 ID,手工录入时为空 |
|
||||
|
||||
### 2.2 创建支出明细
|
||||
|
||||
```
|
||||
POST /api/v2/cost-entries/
|
||||
```
|
||||
|
||||
Content-Type: `multipart/form-data`(支持图片上传)或 `application/json`
|
||||
|
||||
请求体:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `category_id` | int | **是** | 支出类目 ID |
|
||||
| `amount` | decimal | **是** | 金额,如 `"350.00"` |
|
||||
| `occurred_at` | date | **是** | 发生日期,`YYYY-MM-DD` |
|
||||
| `image1` | file | 否 | 凭证图片 |
|
||||
| `image2` | file | 否 | 备用凭证图片 |
|
||||
| `source_module` | string | 否 | 来源模块名 |
|
||||
| `source_id` | string | 否 | 来源记录 ID |
|
||||
| `remarks` | string | 否 | 备注 |
|
||||
|
||||
请求示例(JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"category_id": 1,
|
||||
"amount": "350.00",
|
||||
"occurred_at": "2026-06-01",
|
||||
"remarks": "六月份电费"
|
||||
}
|
||||
```
|
||||
|
||||
成功响应 `201`:返回创建的支出明细对象。
|
||||
|
||||
错误响应 `400`:校验失败。
|
||||
错误响应 `404`:`category_id` 不存在或不属于当前商户。
|
||||
|
||||
### 2.3 支出明细详情
|
||||
|
||||
```
|
||||
GET /api/v2/cost-entries/{entry_id}/
|
||||
```
|
||||
|
||||
成功响应 `200`:返回单个支出明细对象,含 `category_name`。
|
||||
|
||||
错误响应 `404`:明细不存在或不属于当前商户。
|
||||
|
||||
### 2.4 修改支出明细
|
||||
|
||||
```
|
||||
PUT /api/v2/cost-entries/{entry_id}/
|
||||
```
|
||||
|
||||
Content-Type: `multipart/form-data` 或 `application/json`
|
||||
|
||||
请求体(所有字段可选):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `category_id` | int | 切换类目 |
|
||||
| `amount` | decimal | 金额 |
|
||||
| `occurred_at` | date | 发生日期 |
|
||||
| `image1` | file | 凭证图片 |
|
||||
| `image2` | file | 备用凭证图片 |
|
||||
| `source_module` | string | 来源模块名 |
|
||||
| `source_id` | string | 来源记录 ID |
|
||||
| `remarks` | string | 备注 |
|
||||
|
||||
成功响应 `200`:返回更新后的支出明细对象。
|
||||
|
||||
### 2.5 删除支出明细
|
||||
|
||||
```
|
||||
DELETE /api/v2/cost-entries/{entry_id}/
|
||||
```
|
||||
|
||||
成功响应 `204`。
|
||||
|
||||
错误响应 `404`:明细不存在或不属于当前商户。
|
||||
|
||||
---
|
||||
|
||||
## 3. 按类目汇总
|
||||
|
||||
```
|
||||
GET /api/v2/cost-summary/by-category/
|
||||
```
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `merchant_id` | int | 否 | 按商户筛选 |
|
||||
| `start` | date | 否 | 发生日期起始 |
|
||||
| `end` | date | 否 | 发生日期截止 |
|
||||
|
||||
按 `total_amount` 降序排列。
|
||||
|
||||
成功响应 `200`:
|
||||
|
||||
```json
|
||||
{
|
||||
"merchant_id": 1,
|
||||
"start_date": "2026-06-01",
|
||||
"end_date": "2026-06-30",
|
||||
"results": [
|
||||
{
|
||||
"category_id": 1,
|
||||
"category_name": "电费",
|
||||
"category_key": "electricity",
|
||||
"total_amount": "500.00",
|
||||
"entry_count": 2
|
||||
},
|
||||
{
|
||||
"category_id": 2,
|
||||
"category_name": "水费",
|
||||
"category_key": "water",
|
||||
"total_amount": "80.00",
|
||||
"entry_count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `total_amount` | string | 该类别在查询时间段内的总金额 |
|
||||
| `entry_count` | integer | 该类别在查询时间段内的记录数 |
|
||||
| `category_key` | string | 类目唯一键,可用于前端做图表的 key 映射 |
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| Method | URL | 说明 |
|
||||
|--------|-----|------|
|
||||
| GET | `/api/v2/cost-categories/` | 类目列表 |
|
||||
| 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/` | 支出明细列表 |
|
||||
| POST | `/api/v2/cost-entries/` | 创建支出明细 |
|
||||
| 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/` | 按类目汇总 |
|
||||
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()`,后续可扩展为注册表模式 |
|
||||
172
docs/cost/inventory-cost-feasibility.md
Normal file
172
docs/cost/inventory-cost-feasibility.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 库存成本结算可行性分析
|
||||
|
||||
## 背景
|
||||
|
||||
纺织/印花行业 ERP 中,库存成本核算通常使用两种方法:
|
||||
|
||||
- **先进先出(FIFO)**:先入库的批次先出库,出库成本按对应入库批次的实际采购价计算。
|
||||
- **加权平均**:每次入库后重新计算库存均价(总成本 ÷ 总数量),出库统一按当前均价计算。
|
||||
|
||||
本文档对当前系统的库存模块进行研判,评估是否具备实现这两种成本结算方法的要素。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前库存模块数据结构
|
||||
|
||||
### 1.1 核心模型
|
||||
|
||||
```
|
||||
StockChangeRecord(库存变动记录)
|
||||
├── type: 入库/出库
|
||||
├── source_type: 来源类型(采购/销售/调拨/盘盈盘亏/红冲等)
|
||||
├── warehouse: 仓库
|
||||
├── is_finished: 是否已完成
|
||||
└── details ────── StockChangeDetail(库存变动明细)
|
||||
├── product: 产品
|
||||
├── quantity: 数量
|
||||
├── consume_with: 消耗关联(出库指向入库明细,自引用 FK)
|
||||
└── is_consumed: 是否已被消耗
|
||||
|
||||
Inventory(库存汇总)
|
||||
├── product: 产品
|
||||
├── warehouse: 仓库
|
||||
├── quantity: 当前库存数量
|
||||
└── num_of_rolls: 匹数
|
||||
|
||||
StockSnapshot(库存变动快照)
|
||||
├── delta: 变动量
|
||||
├── quantity_before: 变动前库存
|
||||
├── quantity_after: 变动后库存
|
||||
└── (offset/cancelled 红冲链路)
|
||||
```
|
||||
|
||||
### 1.2 价格数据所在位置
|
||||
|
||||
价格数据**不在库存模块**,而在业务模块的明细行中:
|
||||
|
||||
| 模型 | 价格字段 | 说明 |
|
||||
|------|---------|------|
|
||||
| `business.PurchaseOrderItem.price` | 采购单价 | 入库成本价的唯一来源 |
|
||||
| `business.SalesOrderItem.price` | 销售单价 | 出库售价,非成本价 |
|
||||
|
||||
### 1.3 关键发现:库存模块不做任何成本核算
|
||||
|
||||
所有 `StockChangeDetail`、`Inventory`、`StockSnapshot` 只记录**数量(quantity)**,完全没有 `cost_price`、`unit_cost`、`total_cost` 等成本维度字段。
|
||||
|
||||
---
|
||||
|
||||
## 2. 按结算方法逐一分析
|
||||
|
||||
### 2.1 先进先出(FIFO)
|
||||
|
||||
#### 原理
|
||||
|
||||
每次出库时,从最早未消耗的入库批次开始扣除,出库成本等于对应入库批次的实际采购单价。
|
||||
|
||||
#### 已有基础设施 ✅
|
||||
|
||||
`StockChangeDetail.consume_with` 是一个自引用外键,在严进严出模式下,出库明细通过它指向被消耗的入库明细:
|
||||
|
||||
```python
|
||||
# stock/models.py
|
||||
consume_with = models.OneToOneField(
|
||||
'self',
|
||||
on_delete=models.PROTECT,
|
||||
null=True, blank=True,
|
||||
related_name='consumed_by_detail',
|
||||
verbose_name='所消耗的入库明细',
|
||||
)
|
||||
```
|
||||
|
||||
这是**天然的 FIFO 追踪链**。如果入库明细携带了成本价,出库成本可以直接通过 `consume_with.unit_cost` 确定。
|
||||
|
||||
#### 缺失要素
|
||||
|
||||
| 缺失项 | 说明 |
|
||||
|--------|------|
|
||||
| `StockChangeDetail.unit_cost` | 入库明细需要记录该批次的采购成本单价 |
|
||||
| 宽进宽出模式的批次追踪 | 当前 `consume_with` 仅在严进严出模式下使用,宽进宽出需要补充批次追踪或按 FIFO 规则自动匹配 |
|
||||
|
||||
#### 落地难度:低
|
||||
|
||||
改动范围极小——给 `StockChangeDetail` 加一个 `unit_cost` 字段,在入库时从采购单同步价格,出库成本沿 `consume_with` 链读取即可。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 加权平均
|
||||
|
||||
#### 原理
|
||||
|
||||
每次入库后,重新计算加权平均单价:
|
||||
```
|
||||
加权均价 = (库存总成本 + 本次入库成本) ÷ (库存数量 + 本次入库数量)
|
||||
```
|
||||
出库时统一按当前加权均价计算成本。
|
||||
|
||||
#### 已有基础设施 ⚠️
|
||||
|
||||
`Inventory` 表是天然的加权平均计算锚点——它汇总了每个产品在每个仓库的当前库存数量。只需增加一个总成本字段即可完成计算。
|
||||
|
||||
#### 缺失要素
|
||||
|
||||
| 缺失项 | 说明 |
|
||||
|--------|------|
|
||||
| `Inventory.total_cost` | 库存表需要增加总成本字段,每次入库累加 |
|
||||
| `StockChangeDetail.unit_cost` | 同上,入库明细需要知道入库单价 |
|
||||
| 红冲/退货的成本回冲逻辑 | 红冲或退货时需反向调整 `total_cost` 和重新计算均价 |
|
||||
|
||||
#### 落地难度:低
|
||||
|
||||
给 `Inventory` 加一个 `total_cost` 字段,在 `make_stock_change_completed()` 中补充成本累加逻辑即可。
|
||||
|
||||
---
|
||||
|
||||
## 3. 两种方法对比
|
||||
|
||||
| 维度 | 先进先出 (FIFO) | 加权平均 |
|
||||
|------|:---:|:---:|
|
||||
| 追踪粒度 | 批次级别 | 仓库+产品级别 |
|
||||
| 已有基础设施 | `consume_with` 链 ✅ | `Inventory` 汇总表 ⚠️ |
|
||||
| 需新增字段 | `StockChangeDetail.unit_cost` | `StockChangeDetail.unit_cost` + `Inventory.total_cost` |
|
||||
| 计算复杂度 | 需维护批次消耗顺序 | 每次入库后重新算均价 |
|
||||
| 红冲处理 | 恢复原批次 | 重新计算均价 |
|
||||
| 适用场景 | 价格波动大、需精确追踪每批成本 | 价格稳定、简化核算 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 实施建议
|
||||
|
||||
### 最小改动方案
|
||||
|
||||
两个方法都需要以下共同改动:
|
||||
|
||||
1. **`StockChangeDetail` 增加 `unit_cost` 字段**
|
||||
```python
|
||||
unit_cost = models.DecimalField(max_digits=15, decimal_places=6, null=True, verbose_name='成本单价')
|
||||
```
|
||||
|
||||
2. **入库时填充 `unit_cost`**
|
||||
- 采购入库:从 `PurchaseOrderItem.price` 同步
|
||||
- 销退入库:从原销售出库的成本回冲
|
||||
- 调拨入库:从调出仓的当前成本同步
|
||||
- 盘盈:可设为 0 或要求手动录入
|
||||
|
||||
3. **出库时计算成本**
|
||||
- FIFO:沿 `consume_with` 链读取对应入库批次的 `unit_cost`
|
||||
- 加权平均:需额外在 `Inventory` 表增加 `total_cost` 字段,在 `make_stock_change_completed` 中维护
|
||||
|
||||
### 建议优先实现 FIFO
|
||||
|
||||
对于纺织行业(布料批次间价格差异大),**FIFO 更合适**。且当前 `consume_with` 链已就绪,实现成本最低。
|
||||
|
||||
若后续需要加权平均,在 FIFO 的基础上给 `Inventory` 增加 `total_cost` 即可,两者不冲突。
|
||||
|
||||
---
|
||||
|
||||
## 5. 结论
|
||||
|
||||
**当前系统不具备直接进行先进先出或加权平均成本结算的能力**,库存模块完全是数量管理。
|
||||
|
||||
但 **FIFO 所需的基础设施已经存在**(`consume_with` 追踪链),改动范围极小——仅需给 `StockChangeDetail` 增加 `unit_cost` 字段,并在入库时同步采购价格。加权平均也只需在 `Inventory` 表增加 `total_cost` 字段即可。
|
||||
|
||||
两个方法的落地成本都很低,不存在结构性障碍。
|
||||
Reference in New Issue
Block a user