1
0
forked from erp-dev/erp
Files
erpnew/docs/2026-06-22_balance_before_snapshot_handoff.md
2026-06-22 22:27:28 +08:00

424 lines
17 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.
# 四类主要业务单据 `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 能返回快照,且客户端不能写入或篡改。
- 模型与迁移一致。
- 定向测试通过,既有四类业务单据测试无本任务引入的回归。