# Business 单据红冲服务设计备查

日期：2026-06-12

## 目标

在 `business` 模块为已审核正式单据增加整单红冲能力，并通过独立 API 对外开放。

## 已确认口径

- 红冲后原单据 `status` 保持 `APPROVED`，不回退、不作废。
- 原单据增加“已红冲”标记，用于列表查询提速。
- 因为 `status` 保持 `APPROVED`，**对账单 builder 必须额外按 `is_red_flushed=False` 过滤**，否则红冲单会被原值计入 `positive_amount`/`negative_amount`，summary 多算一倍。
- 每次红冲由 service 自动生成一个 UUID 类型的 `red_flush_id`。
- `red_flush_id` 用于跨表、跨记录追踪同一次红冲涉及的所有数据，是审计关联批次 ID。
- 不新增 `red_flush_no`。该编号只适合人工展示，目前无需求。
- 第一版只支持整单红冲，不支持部分红冲。
- API 已开放独立 `/red-flush/` action，API 层红冲原因必填。
- API 权限复用现有审批/作废权限，不新增独立红冲权限。
- 多商户隔离必须下沉到 service。红冲 service 调用方必须传入当前 `merchant`，service 在解析单据后校验单据所属商户。

## 字段方案

6 类正式业务单据增加：

- `is_red_flushed`: 是否已红冲，布尔值，建索引。
- `red_flush_id`: 红冲批次 UUID，可空，建索引。
- `red_flushed_at`: 红冲时间，可空。

覆盖单据：

- `PurchaseOrder`
- `SalesOrder`
- `PurchaseReturnOrder`
- `SalesReturnOrder`
- `PaymentOrder`
- `ReceiptOrder`

`BalanceChangeRecord` 增加：

- `red_flush_id`: 红冲批次 UUID，可空，建索引。

`StockChangeRecord` 增加：

- `red_flush_id`: 红冲批次 UUID，可空，建索引。

暂不在 `StockSnapshot` 增加 `red_flush_id`，因为快照已有 `offset_to/offset_id/cancelled` 精确匹配关系，且可以通过 `StockChangeRecord.red_flush_id` 查到相关快照。

## 关系标记规则

一次红冲生成一个 `red_flush_id`，并在同一个事务中写入相关数据。

原业务单据：

- `is_red_flushed=True`
- `red_flush_id=<本次 UUID>`
- `red_flushed_at=<当前时间>`

原余额变动记录：

- `cancelled=True`
- `cancelled_at=<当前时间>`
- `offset_id=<反向余额记录 ID>`
- `red_flush_id=<本次 UUID>`

反向余额变动记录：

- `offset_to=<原余额记录 ID>`
- `offset_at=<当前时间>`
- `red_flush_id=<本次 UUID>`

原库存记录：

- `red_flush_id=<本次 UUID>`

反向库存记录：

- `source_type=OFFSET`
- `source_id=<原库存记录 ID>`
- `red_flush_id=<本次 UUID>`

库存快照继续复用现有关系：

- 原快照：`cancelled=True`、`cancelled_at`、`offset_id=<反向快照 ID>`
- 反向快照：`offset_to=<原快照 ID>`、`offset_at`

## Service 方案

新增业务红冲入口：

- `red_flush_purchase_order(...)`
- `red_flush_sales_order(...)`
- `red_flush_purchase_return_order(...)`
- `red_flush_sales_return_order(...)`
- `red_flush_payment_order(...)`
- `red_flush_receipt_order(...)`

内部复用统一实现，避免 6 类单据逻辑分叉。

校验规则：

- 必须提供当前 `merchant`。
- 单据必须属于当前 `merchant`。
- 只允许 `APPROVED` 单据红冲。
- `is_red_flushed=True` 的单据不能重复红冲。
- 原始余额变动记录必须存在且未被冲抵。
- 有库存影响且要求库存闭环的单据必须找到对应库存记录，库存记录必须已完成且未被红冲；销售单允许不存在库存记录，此时只红冲余额。
- 资金和库存任一红冲失败，整体事务回滚。

资金侧实现：

- 基于原 `BalanceChangeRecord.delta` 创建反向余额变动，`delta=-original.delta`。
- 使用现有 `BalanceService.adjust_supplier_balance()` / `adjust_customer_balance()` 写余额与记录。
- 调整 `BalanceService` 让它返回新创建的 `BalanceChangeRecord`，方便写 `offset_to/offset_id/red_flush_id`。

库存侧实现：

- 复用 `StockFlowService.offset_stock_change()`。
- 扩展其参数 `red_flush_id=None`。
- 该方法负责把 `red_flush_id` 写入原库存记录和反向库存记录。

## 测试要求

先跑现有 `business.tests` baseline。

新增 service 测试覆盖：

- 采购单整单红冲：余额反向、库存反向、单据标记、批次 ID 写入。
- 销售单整单红冲：余额反向、单据标记、批次 ID 写入；若存在销售出库记录则同步库存反向，若无库存记录则跳过库存红冲。
- 采购退货单整单红冲。
- 销售退货单整单红冲。
- 付款单整单红冲。
- 收款单整单红冲。
- 非已审核单据不能红冲。
- 已红冲单据不能重复红冲。
- 缺少原始余额记录时报错。
- 库存记录未完成或已红冲时报错。
- 事务回滚场景。

API 测试覆盖：

- 6 类单据 `/red-flush/` 成功路径。
- `reason` 必填。
- 无员工身份权限拒绝。
- 跨商户查询隔离。
- 外部付款/收款单拒绝。
- 未审核、重复红冲、采购等要求库存闭环的单据缺库存记录等 service 校验错误映射为 400。
- 成功响应包含 `is_red_flushed`、`red_flush_id`、`red_flushed_at`，并抽样断言余额/库存副作用。

目标：red flush 关键 service/API 路径需要有定向测试覆盖。

## 对账单 builder 联动修复（2026-06-22）

`business.services._CustomerStatementBuilder._build_sales_records` / `_build_sales_return_records` / `_build_receipt_records` 以及 `_SupplierStatementBuilder._build_purchase_records` / `_build_purchase_return_records` / `_build_payment_records` 之前只过滤 `status=APPROVED`，未过滤 `is_red_flushed`。

红冲后单据 `status` 仍为 `APPROVED`，所以会继续以原金额出现在 `build_customer_statement` / `build_supplier_statement` 的 `records` 列表中，被 `build_statement_summary` 双倍计入 `positive_total`/`negative_total`。

修复办法：6 个 builder queryset 全部追加 `is_red_flushed=False`。

注意：
- `BalanceService.get_customer_balance` / `get_supplier_balance` 读取 `CustomerBalance/SupplierBalance` 表，红冲服务通过反向 `BalanceChangeRecord` 已经把余额抵消，因此 `current_balance` 字段一直是正确的；只有 `records` 与 `summary` 受影响。
- `ExternalCustomerStatementOrder` 目前不在 6 类红冲入口里，相关 builder/adjustment 暂不需要过滤，将来若开放外部单红冲必须同步更新。

定向测试：见 `business.tests.test_statement_services.BusinessStatementRedFlushExclusionTestCase`，覆盖 6 类正式单据红冲后 statement 中 `records` 不再包含、`summary` 不再多算。

## 测试命令

开发环境在容器内运行测试，并绕过 PgBouncer：

```bash
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web \
  uv run python manage.py test business.tests.test_red_flush_services api_v1.tests.BusinessRedFlushAPITestCase --keepdb --noinput
```

覆盖率：

```bash
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web \
  uv run coverage run --source=business,api_v1 manage.py test business.tests.test_red_flush_services api_v1.tests.BusinessRedFlushAPITestCase --keepdb --noinput
```

```bash
docker compose exec -T web uv run coverage report -m
```
