forked from erp-dev/erp
424 lines
17 KiB
Markdown
424 lines
17 KiB
Markdown
# 四类主要业务单据 `balance_before_snapshot` 实现交接
|
||
|
||
日期:2026-06-22
|
||
状态:已完成。核心实现、入口扫描、定向测试、API 测试、迁移检查及相关回归测试均已通过。
|
||
|
||
## 0. 最终完成结果
|
||
|
||
本任务已于 2026-06-22 完成,当前没有余额快照相关的待实现项。
|
||
|
||
最终落地内容:
|
||
|
||
- 四类主单模型均已新增可空 `balance_before_snapshot`。
|
||
- 新增 `0032_order_balance_before_snapshot` 迁移,不回填历史数据。
|
||
- 四个正式创建 service 均必写当前本地往来余额;不存在余额行时写 `0`。
|
||
- 更新、往来单位变更、余额后续变化、审批、作废及红冲均不重算快照。
|
||
- 四类 API serializer 输出该字段并设为只读。
|
||
- 四个 POST 创建响应均显式返回该字段。
|
||
- 生产代码扫描确认没有绕过正式 service 直接创建四类主单的入口。
|
||
- 预销售转正式销售调用 `business_services.create_sales_order()`,已自然覆盖快照逻辑。
|
||
|
||
新增测试文件:`business/tests/test_balance_before_snapshot.py`,共 4 个测试,覆盖:
|
||
|
||
- 四类创建读取已有正数/负数余额。
|
||
- 无余额行时四类创建均写 `0` 而非 `NULL`。
|
||
- 四类更新、更换往来单位以及余额后续变化均不重算快照。
|
||
- 四类 API serializer 输出且拒绝客户端写入快照。
|
||
|
||
另在 `api_v1/tests.py` 四个既有成功创建测试中加入 POST 响应和数据库落值断言。
|
||
|
||
已在 `web` 容器内使用 `DB_HOST=postgres DB_PORT=5432` 直连 PostgreSQL、绕过 PgBouncer 完成验证:
|
||
|
||
- `makemigrations --check --dry-run`:通过,`No changes detected`。
|
||
- `manage.py check`:通过,0 issues。
|
||
- 新增余额快照定向测试:4/4 通过。
|
||
- 四类单据 service/API 回归:74/74 通过。
|
||
- 红冲、对账单及红冲 API 共享服务回归:36/36 通过。
|
||
- 本任务相关已跟踪文件 `git diff --check`:通过。
|
||
- 新增迁移与新增测试文件的补丁格式检查:通过。
|
||
|
||
下文保留了实现细节和原始验收清单,方便以后维护和排查回归;其中“尚未完成”内容已经全部执行完毕。
|
||
|
||
## 1. 需求目标
|
||
|
||
为以下四类主要业务单据增加 `balance_before_snapshot` 字段:
|
||
|
||
1. `PurchaseOrder`(采购单)
|
||
2. `SalesOrder`(销售单)
|
||
3. `PurchaseReturnOrder`(采购退货单)
|
||
4. `SalesReturnOrder`(销售退货单)
|
||
|
||
字段语义必须保持一致:
|
||
|
||
- 字段记录单据正常创建时,对应往来单位在本地余额表中的当前余额。
|
||
- 采购单、采购退货单记录供应商余额,即 `SupplierBalance.balance`。
|
||
- 销售单、销售退货单记录客户余额,即 `CustomerBalance.balance`。
|
||
- 对应余额记录不存在时,快照写入 `Decimal('0')`,不能因为数据库字段可空而让正常创建的新单据写入 `NULL`。
|
||
- 数据库字段必须允许 `NULL`,用于兼容迁移前的历史单据。
|
||
- 不对历史数据执行回填或推算;历史行保持 `NULL`。
|
||
- 快照只在创建时写一次。之后即使往来余额发生变化,或者单据被修改、审批、作废、红冲,都不能重新计算或覆盖该字段。
|
||
- 客户快照采用本地 `CustomerBalance` 口径,不叠加 `ExternalCustomerStatementOrder`。当前实现调用 `BalanceService.get_customer_balance()`,没有调用 `get_customer_statement_balance()`。
|
||
|
||
## 2. 接手前工作区情况
|
||
|
||
本次任务开始时工作区已有大量未提交修改,主要涉及:
|
||
|
||
- 业务单据红冲逻辑和测试。
|
||
- 对账单排除已红冲单据。
|
||
- 销售单列表查询过滤。
|
||
- 开版看板序列化与查询优化。
|
||
- 环境、域名、文档及 Celery schedule 文件。
|
||
|
||
这些改动属于上一轮或用户已有工作,不能清理、回退或覆盖。后续只应增量修改余额快照相关文件。特别不要使用 `git reset --hard`、`git checkout --` 等命令。
|
||
|
||
在 `business/services.py` 和 `api_v1/views/business/sales/views.py` 中,本次余额快照修改与已有未提交修改位于同一文件。审阅 diff 时应按具体代码块区分,不要把整份文件都视为本任务新增。
|
||
|
||
## 3. 已写入的实现
|
||
|
||
### 3.1 模型字段
|
||
|
||
文件:`business/models.py`
|
||
|
||
已在四个模型中加入 `balance_before_snapshot`:
|
||
|
||
```python
|
||
balance_before_snapshot = models.DecimalField(
|
||
max_digits=15,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name='创建前供应商余额快照', # 客户侧为“创建前客户余额快照”
|
||
)
|
||
```
|
||
|
||
具体口径:
|
||
|
||
| 模型 | 快照对象 | verbose_name |
|
||
|---|---|---|
|
||
| `PurchaseOrder` | 供应商余额 | `创建前供应商余额快照` |
|
||
| `SalesOrder` | 客户余额 | `创建前客户余额快照` |
|
||
| `PurchaseReturnOrder` | 供应商余额 | `创建前供应商余额快照` |
|
||
| `SalesReturnOrder` | 客户余额 | `创建前客户余额快照` |
|
||
|
||
字段没有默认值,数据库允许为空。这样迁移不会给历史单据制造一个看似真实但无法验证的余额。
|
||
|
||
### 3.2 数据库迁移
|
||
|
||
文件:`business/migrations/0032_order_balance_before_snapshot.py`
|
||
|
||
已新增迁移,依赖:
|
||
|
||
```python
|
||
dependencies = [
|
||
('business', '0031_red_flush_fields'),
|
||
]
|
||
```
|
||
|
||
迁移包含四个 `AddField`,全部是:
|
||
|
||
- `DecimalField(max_digits=15, decimal_places=2)`
|
||
- `null=True`
|
||
- `blank=True`
|
||
- 无 `default`
|
||
- 无数据迁移和历史回填
|
||
|
||
需要在后续验证迁移依赖仍然是当前分支最新叶子;当前检查时 `0031_red_flush_fields.py` 是最新已存在迁移,`0032` 是本任务新文件。
|
||
|
||
### 3.3 正常创建服务写入快照
|
||
|
||
文件:`business/services.py`
|
||
|
||
以下四个服务函数的 `objects.create(...)` 已加入快照赋值:
|
||
|
||
#### `create_purchase_order(...)`
|
||
|
||
```python
|
||
balance_before_snapshot=BalanceService.get_supplier_balance(
|
||
merchant=merchant,
|
||
supplier=supplier,
|
||
),
|
||
```
|
||
|
||
#### `create_sales_order(...)`
|
||
|
||
```python
|
||
balance_before_snapshot=BalanceService.get_customer_balance(
|
||
merchant=merchant,
|
||
customer=customer,
|
||
),
|
||
```
|
||
|
||
#### `create_purchase_return_order(...)`
|
||
|
||
```python
|
||
balance_before_snapshot=BalanceService.get_supplier_balance(
|
||
merchant=merchant,
|
||
supplier=supplier,
|
||
),
|
||
```
|
||
|
||
#### `create_sales_return_order(...)`
|
||
|
||
```python
|
||
balance_before_snapshot=BalanceService.get_customer_balance(
|
||
merchant=merchant,
|
||
customer=customer,
|
||
),
|
||
```
|
||
|
||
这些调用位于各自已有的 `transaction.atomic()` 中,并在创建主单据时直接写字段。
|
||
|
||
`BalanceService.get_supplier_balance()` 和 `get_customer_balance()` 的既有行为是:余额行存在则返回其 `balance`;不存在则返回 `Decimal('0')`。因此正常服务创建入口理论上不会写入 `NULL`。
|
||
|
||
### 3.4 “之后不重算”的当前实现状态
|
||
|
||
四个更新服务当前均未把 `balance_before_snapshot` 放进赋值或 `update_fields`:
|
||
|
||
- `update_purchase_order(...)`
|
||
- `update_sales_order(...)`
|
||
- `update_purchase_return_order(...)`
|
||
- `update_sales_return_order(...)`
|
||
|
||
审批、作废、红冲逻辑也没有写该字段。模型没有为该字段增加 `save()` 自动计算或 signal。因此按照当前代码结构,创建完成后不会自动重算。
|
||
|
||
这一点尚缺定向测试,必须补测试防止未来回归。
|
||
|
||
### 3.5 API 输出
|
||
|
||
以下四个 ModelSerializer 的 `fields` 已加入 `balance_before_snapshot`,同时加入 `read_only_fields`:
|
||
|
||
- `api_v1/views/business/purchase/views.py`
|
||
- `PurchaseOrderSerializer`
|
||
- `api_v1/views/business/sales/views.py`
|
||
- `SalesOrderSerializer`
|
||
- `api_v1/views/business/purchase_return/views.py`
|
||
- `PurchaseReturnOrderSerializer`
|
||
- `api_v1/views/business/sales_return/views.py`
|
||
- `SalesReturnOrderSerializer`
|
||
|
||
因此列表、详情、更新结果、审批结果和红冲结果只要使用上述 serializer,都会输出该字段,且客户端不能通过 serializer 修改它。
|
||
|
||
四个创建 API 当前返回的是手工构造的精简字典,不走 ModelSerializer,所以创建成功响应中也已显式加入:
|
||
|
||
```python
|
||
'balance_before_snapshot': order.balance_before_snapshot,
|
||
```
|
||
|
||
具体变量名分别为:
|
||
|
||
- `purchase_order.balance_before_snapshot`
|
||
- `sales_order.balance_before_snapshot`
|
||
- `purchase_return.balance_before_snapshot`
|
||
- `sales_return.balance_before_snapshot`
|
||
|
||
## 4. 已完成的收尾工作(原验收计划)
|
||
|
||
### 4.1 扫描所有生产创建入口
|
||
|
||
已确认四个主要 API 创建入口均调用 `business.services` 中对应的 `create_*` 函数,因此已覆盖:
|
||
|
||
- `POST` 采购单
|
||
- `POST` 销售单
|
||
- `POST` 采购退货单
|
||
- `POST` 销售退货单
|
||
|
||
但最后一次完整生产代码扫描尚未完成。接手后应再次执行:
|
||
|
||
```bash
|
||
rg -n "create_(purchase_order|sales_order|purchase_return_order|sales_return_order)\(" \
|
||
api_v1 business \
|
||
--glob '!business/services.py' \
|
||
--glob '!**/tests/**' \
|
||
--glob '!**/test*.py'
|
||
```
|
||
|
||
还应扫描是否有生产代码绕过 service 直接创建四类模型:
|
||
|
||
```bash
|
||
rg -n "(PurchaseOrder|SalesOrder|PurchaseReturnOrder|SalesReturnOrder)\.objects\.(create|get_or_create|update_or_create|bulk_create)" \
|
||
api_v1 business \
|
||
--glob '!**/tests/**' \
|
||
--glob '!**/test*.py'
|
||
```
|
||
|
||
如果发现正常业务入口绕过 service,应让其改用 service,或在同一创建事务中显式写快照。数据导入、测试 fixture、历史修复脚本不一定属于“正常创建入口”,需要按用途判断,不能盲目强制。
|
||
|
||
预销售单/预采购单转正式单通常会调用正式单据 service;需要通过上述扫描确认,避免漏掉转换入口。
|
||
|
||
### 4.2 补定向服务测试
|
||
|
||
建议新增:
|
||
|
||
`business/tests/test_balance_before_snapshot.py`
|
||
|
||
至少覆盖以下场景。
|
||
|
||
#### 场景 A:四类创建均写当前余额
|
||
|
||
1. 使用 `create_basic_fixtures()` 创建供应商侧数据。
|
||
2. 建立或更新 `SupplierBalance(balance=Decimal('123.45'))`。
|
||
3. 通过 `create_purchase_order()` 创建采购单,断言快照为 `123.45`。
|
||
4. 通过 `create_purchase_return_order()` 创建采购退货单,断言快照为 `123.45`。
|
||
5. 使用 `create_sales_fixtures()` 创建客户侧数据。
|
||
6. 建立或更新 `CustomerBalance(balance=Decimal('-67.89'))`。
|
||
7. 通过 `create_sales_order()` 创建销售单,断言快照为 `-67.89`。
|
||
8. 通过 `create_sales_return_order()` 创建销售退货单,断言快照为 `-67.89`。
|
||
|
||
测试应调用正式 service,而不是直接 `objects.create()`,以验证正常入口。
|
||
|
||
#### 场景 B:余额行不存在时,新单快照写零而不是 NULL
|
||
|
||
对供应商侧和客户侧至少各测一个:
|
||
|
||
```python
|
||
self.assertEqual(order.balance_before_snapshot, Decimal('0'))
|
||
self.assertIsNotNone(order.balance_before_snapshot)
|
||
```
|
||
|
||
这是“数据库允许空”和“正常创建必写”之间最容易回归的边界。
|
||
|
||
#### 场景 C:余额变化后不重算
|
||
|
||
1. 余额为 `100.00` 时创建单据。
|
||
2. 创建后把对应 `SupplierBalance`/`CustomerBalance` 改成其他值,或审批另一张会改变余额的单据。
|
||
3. `refresh_from_db()` 原单据。
|
||
4. 断言快照仍为 `100.00`。
|
||
|
||
#### 场景 D:更新单据后不重算
|
||
|
||
更新服务允许审批中的单据更换供应商/客户。需要明确验证快照仍是最初创建时的值,而不是新往来单位当前余额:
|
||
|
||
1. 往来单位 A 余额为 `100.00`,创建单据。
|
||
2. 往来单位 B 余额为 `999.00`。
|
||
3. 调用对应 `update_*` 将单据往来单位改为 B,并提交合法 items。
|
||
4. 断言 `balance_before_snapshot` 仍为 `100.00`。
|
||
|
||
供应商侧与客户侧至少各覆盖一次。若产品语义认为更换往来单位应另有约束,也不能在更新时重算快照;需求已经明确“之后不重算”。
|
||
|
||
#### 场景 E:序列化字段只读
|
||
|
||
可以在 API 测试中验证:
|
||
|
||
- 创建响应包含正确快照。
|
||
- 列表或详情响应包含正确快照。
|
||
- PATCH/PUT 请求即使携带伪造的 `balance_before_snapshot`,数据库值仍不变。
|
||
|
||
由于当前 API 更新路径手工提取允许字段,本身不会读取该请求字段;serializer 中也已标记只读。
|
||
|
||
### 4.3 迁移与系统检查
|
||
|
||
需要运行:
|
||
|
||
```bash
|
||
python manage.py makemigrations --check --dry-run
|
||
python manage.py check
|
||
```
|
||
|
||
预期:
|
||
|
||
- `makemigrations --check --dry-run` 不再生成额外迁移。
|
||
- `manage.py check` 无本任务引入的问题。
|
||
|
||
若项目约定在 Docker 内运行,应使用现有项目测试容器,并按当前开发环境配置绕过 PgBouncer。不要擅自修改 `.env`、`docker-compose.yml` 或数据库配置来迁就测试;这些文件已有其他未提交改动。
|
||
|
||
### 4.4 定向测试命令
|
||
|
||
新增测试文件后建议先运行:
|
||
|
||
```bash
|
||
python manage.py test business.tests.test_balance_before_snapshot
|
||
```
|
||
|
||
然后运行四类既有 service/API 测试,具体模块可根据项目现有测试命名选择:
|
||
|
||
```bash
|
||
python manage.py test \
|
||
business.tests.test_purchase_order \
|
||
business.tests.test_sales_order \
|
||
business.tests.test_purchase_return \
|
||
business.tests.test_sales_return
|
||
```
|
||
|
||
API 测试集中在 `api_v1/tests.py` 时,可至少运行相关 TestCase:
|
||
|
||
```bash
|
||
python manage.py test \
|
||
api_v1.tests.PurchaseOrderAPITestCase \
|
||
api_v1.tests.SalesOrderAPITestCase \
|
||
api_v1.tests.PurchaseReturnOrderAPITestCase \
|
||
api_v1.tests.SalesReturnOrderAPITestCase
|
||
```
|
||
|
||
最后根据时间运行更宽范围回归。由于工作区已有红冲与 statement 修改,如果宽范围测试失败,需先判断失败属于余额快照还是已有未提交工作。
|
||
|
||
## 5. 需要重点复核的设计点
|
||
|
||
### 5.1 快照时点
|
||
|
||
当前实现是在四个创建 service 的 `transaction.atomic()` 内、执行主单 `objects.create()` 参数求值时读取余额。这符合“创建时快照”。
|
||
|
||
当前读取函数没有使用 `select_for_update()`。在绝大多数正常流程中会读取当时已提交的余额;但如果产品要求与并发余额调整建立严格串行顺序,需要额外评估是否应锁定余额行。
|
||
|
||
不要未经评估直接把读取改成 `get_or_create(...).select_for_update()`:这会使每次创建单据都创建一行零余额记录,改变现有数据行为。若要增强并发语义,应先查看项目现有并发策略和数据库隔离级别,并补并发测试。
|
||
|
||
本需求当前未明确要求强锁,建议先保持现有简单读取,完成基础测试后再决定是否扩展。
|
||
|
||
### 5.2 更新时更换往来单位
|
||
|
||
当前更新服务允许把审批中的采购类单据改为另一供应商,或把销售类单据改为另一客户。快照仍保留创建时旧往来单位余额。
|
||
|
||
这看起来可能与更新后的往来单位不一致,但符合“创建时快照,之后不重算”的明确要求。不要在更新 service 中加入重算,除非用户重新定义业务语义。
|
||
|
||
### 5.3 历史数据
|
||
|
||
迁移不能回填 `0`,也不能依据当前余额倒推历史余额。历史单据的正确创建前余额通常无法可靠恢复,因此 `NULL` 本身就是“未知”的有效表达。
|
||
|
||
### 5.4 API 可写性
|
||
|
||
字段必须是输出字段而不是客户端输入字段。当前 serializer 已标记只读,创建 API 也没有读取客户端传入的同名字段。后续若引入新的 create serializer,要继续把字段设为 read-only,并由 service 计算。
|
||
|
||
## 6. 建议的完成顺序
|
||
|
||
1. 用 `git diff` 和 `git status --short` 确认上述八个代码/迁移文件的实际状态。
|
||
2. 扫描所有生产创建入口和直接 ORM 创建点。
|
||
3. 补 `business/tests/test_balance_before_snapshot.py`。
|
||
4. 补必要的 API 字段测试。
|
||
5. 运行 `makemigrations --check --dry-run` 和 `manage.py check`。
|
||
6. 运行定向测试及四类既有回归测试。
|
||
7. 执行 `git diff --check`;注意工作区已有文件可能存在与本任务无关的尾部空行问题,应区分来源。
|
||
8. 最终审阅只聚焦本任务的文件/代码块,保留所有既有未提交修改。
|
||
|
||
## 7. 本任务相关文件清单
|
||
|
||
已修改或新增:
|
||
|
||
- `business/models.py`
|
||
- `business/services.py`
|
||
- `business/migrations/0032_order_balance_before_snapshot.py`(新增、未提交)
|
||
- `api_v1/views/business/purchase/views.py`
|
||
- `api_v1/views/business/sales/views.py`
|
||
- `api_v1/views/business/purchase_return/views.py`
|
||
- `api_v1/views/business/sales_return/views.py`
|
||
|
||
计划新增:
|
||
|
||
- `business/tests/test_balance_before_snapshot.py`
|
||
|
||
可能按测试结果增量修改:
|
||
|
||
- `api_v1/tests.py`,或拆分后的对应 API 测试文件
|
||
- 业务 API 文档(只有在项目要求公开列出响应字段时再补,不是核心实现的阻塞项)
|
||
|
||
## 8. 完成判定
|
||
|
||
只有同时满足以下条件,才能认为本任务完成:
|
||
|
||
- 四个数据库字段存在且允许 `NULL`。
|
||
- 迁移不回填历史数据。
|
||
- 四个正式创建 service 对余额存在和不存在两种情况均写入非空快照。
|
||
- 所有正常生产创建入口都经过上述逻辑。
|
||
- 更新、审批、作废、红冲以及余额后续变化不会改写快照。
|
||
- 四类 API 能返回快照,且客户端不能写入或篡改。
|
||
- 模型与迁移一致。
|
||
- 定向测试通过,既有四类业务单据测试无本任务引入的回归。
|