# 采购单审批流程与红冲规划

## 1. 背景

- 采购单在创建时默认进入 `PENDING`（审批中）状态，不再立即创建出入库记录。
- `business.services.review_purchase_order` 统一处理审批通过 (`APPROVED`) 与作废 (`CANCELLED`) 的业务规则。
- `api_v1/views/business/purchase/views.py` 暴露 `POST /api/v1/purchase-orders/<id>/review/` 接口作为唯一入口，便于前端、自动化流程和第三方系统统一调用。

## 2. 审批流程总览

| 步骤 | 说明 |
|------|------|
| 1 | 前端在采购单详情页或审批面板触发 `POST /api/v1/purchase-orders/<id>/review/`，请求体只包含 `{"action": "approve"}` 或 `{"action": "cancel"}` |
| 2 | API 层验证：当前用户必须具备员工身份、采购单必须属于当前商户；`action` 字段必须是 `approve/cancel` 之一 |
| 3 | API 调用 `business.services.review_purchase_order`，根据 `action` 映射到 `PurchaseOrderStatusEnum.APPROVED` 或 `CANCELLED` |
| 4 | Service 内部基于状态决定调用 `_approve_purchase_order` 或 `_cancel_purchase_order`，并在必要时抛出业务错误（例如重复审批、作废时已有出入库记录等） |
| 5 | API 返回最新的 `PurchaseOrderSerializer` 数据（包含总金额、总数量、明细与状态等）供前端刷新页面 |

## 3. 审批通过触发的事务

1. `_approve_purchase_order` 先构建 `stock_flow_items`：
   - 宽进仓：`items=[{'product_id', 'value', 'num_of_rolls'}]`
   - 严进/严进严出：`items=[{'product_id', 'quantities': [...] }]`
2. 在数据库事务中，将 `purchase_order.status` 置为 `APPROVED` 并保存，保证状态更新原子性。
3. 读取商户设置 `auto_create_stock_change_tasks`：
   - 若未配置或为 `False`，审批仅改变状态，不会触发后续任务。
   - 若为 `True`，投递 `business.tasks.create_purchase_order_stock_entries` Celery 任务。
4. Celery 任务执行 `StockFlowService.stock_in`：
   - 依据仓库模式创建入库 `StockChangeRecord` 及 `StockChangeDetail`。
   - 后续在 `stock.services.make_stock_change_completed` 中写入 `Inventory` 并生成 `StockSnapshot`。
5. 任务返回 `stock_change_record_id`、明细数量等信息，并写日志用于追踪审计。

## 4. 作废流程约束

1. 同样通过 `POST /api/v1/purchase-orders/<id>/review/`，但 `{"action": "cancel"}`。
2. Service 端调用 `_cancel_purchase_order`，首先查询是否存在 `source_type = PURCHASE` 且 `source_id = 采购单ID` 的 `StockChangeRecord`。
3. 若已存在入库记录，直接抛出错误“采购单已生成出入库记录，无法作废”；API 返回 `400` 并提示原因。
4. 若未生成记录，则在事务内将状态更新为 `CANCELLED`。不会触发 Celery 任务。

## 5. 常见异常与返回

- 缺少 `action` 字段：`400` + DRF 序列化错误。
- 审批已是目标状态：直接返回现状（幂等）。
- 已作废的单据请求审批：`ValueError('作废状态的采购单无法再次审批')` -> API 返回 `400`。
- 作废时已有出入库记录：`ValueError('采购单已生成出入库记录，无法作废')` -> API 返回 `400`。
- ID 不存在或不属于当前商户：API 返回 `404`。

## 6. 采购退货（采退）流程补充

- API：`POST /api/v1/purchase-return-orders/` 创建，`POST /api/v1/purchase-return-orders/<id>/review/` 审批。
- 创建阶段与采购单一致，只是字段改为 `return_date`，并允许传入 `purchase_order` 以关联原单。
- 审批通过：
  1. 在事务内锁单、生成 `stock_flow_items`。
  2. 状态置为 `APPROVED`。
  3. 调用 `BalanceService.adjust_supplier_balance(delta=-总金额，source=PURCHASE_RETURN_ORDER)`，写入 `BalanceChangeRecord`。
  4. 触发 `create_purchase_return_order_stock_entries`，底层调用 `StockFlowService.stock_out`（source=`PURCHASE_RETURN`），受仓库 mode 约束：
     - 宽进仓：传递 `value + num_of_rolls`
     - 严进仓：传递 `numbers`
     - 严进严出：必须提供 `consume_detail_ids`
- 作废逻辑沿用采购单：只有 `PENDING` 且未产生 `StockChangeRecord` 时才允许取消，一旦审批成功便禁止。

---

## 7. 红冲（对冲）落地状态

### 6.1 目标与原则

- **当前状态**：采购单已支持整单红冲。已审批采购单可通过 `POST /api/v1/purchase-orders/<id>/red-flush/` 触发，生成反向余额记录和反向库存记录，同时保持库存快照与主库存的可追溯性。
- **原则**：
  - 采用“新增反向记录”方式对冲，而非直接修改原记录；保留每一次实际发生的库存变更。
  - 红冲记录需要指向原 `StockChangeRecord` / `StockSnapshot`（如 `offset_to`、`offset_id` 字段），便于审计。
  - 红冲动作必须与业务流程挂钩（例如采购单红冲），避免孤立的库存操作。

### 6.2 行动步骤

1. **业务入口确定**  
   - 当前支持采购、销售、采购退货、销售退货、付款、收款 6 类正式单据整单红冲。  
   - 触发条件：单据必须已审批，且尚未红冲。

2. **库存服务层改造**  
   - `stock.services.StockFlowService.offset_stock_change()` 已负责根据原 `StockChangeRecord` 生成反向 `StockChangeRecord` 和明细。  
   - 更新 `StockSnapshot` 的 `offset_to` / `offset_id` / `cancelled` 字段，确保链路闭环。  
   - `Inventory` 调整遵循相同的原子逻辑（事务 + 快照）。

3. **业务服务与模型扩展**  
   - `business.services.red_flush_purchase_order(...)` 负责校验原单是否允许红冲、阻止重复红冲，并调用库存红冲服务记录关联。
   - `PurchaseOrder` 记录 `is_red_flushed`、`red_flush_id`、`red_flushed_at`。

4. **API 与文档更新**  
   - 红冲 API 为 `POST /api/v1/purchase-orders/<id>/red-flush/`，请求体必须包含 `reason`。  
   - 权限复用现有业务单据员工权限，多商户数据先由 API 查询隔离，再由 service 二次校验。

5. **测试与审计**  
   - 单元测试：业务 Service、库存 Service、API 都要覆盖正常流程与错误场景。  
   - 集成测试：已覆盖红冲后库存数量恢复、快照/库存记录关系、余额反向记录和 API 错误映射。
   - 如有需要，增加日志或审计表，记录红冲操作人、时间、关联记录。

审批流程与红冲机制已衔接：审批负责“正向入库”与任务分发，红冲负责在业务需要时生成配对的反向库存记录，保持库存账实一致，同时具备可回溯、可审计的业务闭环。详细 API 见 `docs/2026-06-12_business_red_flush_api.md`。 

---

## 8. 库存记录红冲标识字段

库存变动记录的 API 响应新增以下字段，用于前端识别红冲关系：

- `cancelled`：是否已被红冲（等价于 `is_reversed`）
- `is_reversed`：当前记录是否已被红冲
- `is_offset`：当前记录是否为红冲记录（用于冲抵其他记录）

说明：红冲关系来源于库存快照的 `offset_to/offset_id/cancelled` 字段，记录本身不落库状态字段。

