forked from erp-dev/erp
fix: docs
This commit is contained in:
@@ -1437,6 +1437,20 @@ class BusinessRedFlushAPITestCase(TestCase):
|
|||||||
order.refresh_from_db()
|
order.refresh_from_db()
|
||||||
self.assertEqual(order.status, business_models.PurchaseOrderStatusEnum.APPROVED)
|
self.assertEqual(order.status, business_models.PurchaseOrderStatusEnum.APPROVED)
|
||||||
self.assertTrue(order.is_red_flushed)
|
self.assertTrue(order.is_red_flushed)
|
||||||
|
self.assertEqual(
|
||||||
|
business_models.BalanceChangeRecord.objects.filter(
|
||||||
|
source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER,
|
||||||
|
source_id=order.id,
|
||||||
|
red_flush_id=order.red_flush_id,
|
||||||
|
).count(),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
stock_models.StockChangeRecord.objects.filter(
|
||||||
|
source_type=stock_models.StockChangeSourceEnum.OFFSET,
|
||||||
|
red_flush_id=order.red_flush_id,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
def test_sales_order_red_flush_success(self):
|
def test_sales_order_red_flush_success(self):
|
||||||
order = self._create_approved_sales_order()
|
order = self._create_approved_sales_order()
|
||||||
@@ -1495,6 +1509,54 @@ class BusinessRedFlushAPITestCase(TestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertTrue(response.data['is_red_flushed'])
|
self.assertTrue(response.data['is_red_flushed'])
|
||||||
|
|
||||||
|
def test_red_flush_requires_employee_permission(self):
|
||||||
|
order = self._create_approved_payment_order()
|
||||||
|
user_without_employee = User.objects.create_user(username='red_flush_no_employee', password='pass123')
|
||||||
|
self.client.force_authenticate(user=user_without_employee)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f'/api/v1/payment-orders/{order.id}/red-flush/',
|
||||||
|
{'reason': '无员工身份红冲'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertEqual(response.data['error'], '无权限访问')
|
||||||
|
order.refresh_from_db()
|
||||||
|
self.assertFalse(order.is_red_flushed)
|
||||||
|
|
||||||
|
def test_purchase_order_red_flush_stock_failure_returns_400_and_rolls_back(self):
|
||||||
|
order = services.create_purchase_order(
|
||||||
|
merchant=self.merchant,
|
||||||
|
supplier=self.supplier,
|
||||||
|
order_date=datetime.date(2025, 11, 26),
|
||||||
|
warehouse=self.warehouse,
|
||||||
|
operator=self.employee,
|
||||||
|
items=self._items(),
|
||||||
|
)
|
||||||
|
services.review_purchase_order(
|
||||||
|
purchase_order=order,
|
||||||
|
target_status=business_models.PurchaseOrderStatusEnum.APPROVED,
|
||||||
|
reviewed_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f'/api/v1/purchase-orders/{order.id}/red-flush/',
|
||||||
|
{'reason': '缺库存记录红冲'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn('采购单缺少可红冲的库存记录', response.data['error'])
|
||||||
|
order.refresh_from_db()
|
||||||
|
self.assertFalse(order.is_red_flushed)
|
||||||
|
records = business_models.BalanceChangeRecord.objects.filter(
|
||||||
|
source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER,
|
||||||
|
source_id=order.id,
|
||||||
|
)
|
||||||
|
self.assertEqual(records.count(), 1)
|
||||||
|
self.assertFalse(records.first().cancelled)
|
||||||
|
|
||||||
def test_red_flush_rejects_external_payment_order(self):
|
def test_red_flush_rejects_external_payment_order(self):
|
||||||
order = self._create_approved_payment_order()
|
order = self._create_approved_payment_order()
|
||||||
order.is_external_source = True
|
order.is_external_source = True
|
||||||
|
|||||||
@@ -167,7 +167,9 @@
|
|||||||
- **响应**:`{"customer": 12, "customer_name": "张三", "balance": "1234.50"}`
|
- **响应**:`{"customer": 12, "customer_name": "张三", "balance": "1234.50"}`
|
||||||
- `balance` 为字符串格式的十进制数,正数表示客户欠款,应收;负数表示已收超额。
|
- `balance` 为字符串格式的十进制数,正数表示客户欠款,应收;负数表示已收超额。
|
||||||
|
|
||||||
如需对 `items` 结构、仓库模式或审批流程做深入了解,请参阅:
|
如需对 `items` 结构、仓库模式、审批流程或红冲 API 做深入了解,请参阅:
|
||||||
|
- `docs/2026-06-12_business_red_flush_api.md`
|
||||||
|
- `docs/2026-06-12_business_red_flush_design.md`
|
||||||
- `docs/purchase_order_approval_and_red_flush.md`
|
- `docs/purchase_order_approval_and_red_flush.md`
|
||||||
- `docs/sales_order_approval_and_red_flush.md`
|
- `docs/sales_order_approval_and_red_flush.md`
|
||||||
|
|
||||||
|
|||||||
@@ -364,6 +364,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
is_sales_order_bound = serializers.SerializerMethodField()
|
is_sales_order_bound = serializers.SerializerMethodField()
|
||||||
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
|
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
|
||||||
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
|
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
|
||||||
|
fabric = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
@@ -377,6 +378,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
'is_sales_order_bound',
|
'is_sales_order_bound',
|
||||||
'batch_advance_records',
|
'batch_advance_records',
|
||||||
'saleitems',
|
'saleitems',
|
||||||
|
'fabric',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@@ -449,6 +451,9 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
def get_is_sales_order_bound(self, obj):
|
def get_is_sales_order_bound(self, obj):
|
||||||
return _get_is_sales_order_bound(obj)
|
return _get_is_sales_order_bound(obj)
|
||||||
|
|
||||||
|
def get_fabric(self, obj):
|
||||||
|
return obj.printing_order.fabric
|
||||||
|
|
||||||
|
|
||||||
class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
||||||
"""印染款式明细详情序列化器"""
|
"""印染款式明细详情序列化器"""
|
||||||
@@ -470,6 +475,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
is_sales_order_bound = serializers.SerializerMethodField()
|
is_sales_order_bound = serializers.SerializerMethodField()
|
||||||
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
|
billed_quantity = serializers.DecimalField(max_digits=18, decimal_places=2, read_only=True)
|
||||||
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
|
merchant_id = serializers.IntegerField(source='merchant.id', read_only=True, allow_null=True)
|
||||||
|
fabric = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
@@ -483,6 +489,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
'is_sales_order_bound',
|
'is_sales_order_bound',
|
||||||
'batch_advance_records',
|
'batch_advance_records',
|
||||||
'saleitems',
|
'saleitems',
|
||||||
|
'fabric',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@@ -524,6 +531,9 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
def get_is_sales_order_bound(self, obj):
|
def get_is_sales_order_bound(self, obj):
|
||||||
return _get_is_sales_order_bound(obj)
|
return _get_is_sales_order_bound(obj)
|
||||||
|
|
||||||
|
def get_fabric(self, obj):
|
||||||
|
return obj.printing_order.fabric
|
||||||
|
|
||||||
|
|
||||||
class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
||||||
"""印染款式明细创建/更新序列化器"""
|
"""印染款式明细创建/更新序列化器"""
|
||||||
|
|||||||
@@ -73,3 +73,16 @@ The API first scopes order lookup by `request.user.employee.merchant`. The servi
|
|||||||
## Audit Notes
|
## Audit Notes
|
||||||
|
|
||||||
`red_flush_id` is written to the source order, related `BalanceChangeRecord` rows, and related `StockChangeRecord` rows when inventory is involved. Existing `offset_to`/`offset_id` relationships remain the precise reverse-link mechanism for balance and stock records.
|
`red_flush_id` is written to the source order, related `BalanceChangeRecord` rows, and related `StockChangeRecord` rows when inventory is involved. Existing `offset_to`/`offset_id` relationships remain the precise reverse-link mechanism for balance and stock records.
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
The dedicated regression suite is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py test business.tests.test_red_flush_services api_v1.tests.BusinessRedFlushAPITestCase --settings=flower.settings_test
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage expectations:
|
||||||
|
|
||||||
|
- Service tests cover all six order types, balance reversal, inventory reversal, duplicate blocking, merchant isolation, missing balance/stock records, transaction rollback, and external payment/receipt rejection.
|
||||||
|
- API tests cover all six endpoints, required `reason`, employee permission rejection, merchant-scoped 404, non-approved orders, duplicate red flush, external payment/receipt rejection, stock-validation error mapping, and sampled balance/stock side effects.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
在 `business` 模块为已审核正式单据增加整单红冲能力。第一阶段只实现 service 和测试,不新增 API。
|
在 `business` 模块为已审核正式单据增加整单红冲能力,并通过独立 API 对外开放。
|
||||||
|
|
||||||
## 已确认口径
|
## 已确认口径
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
- `red_flush_id` 用于跨表、跨记录追踪同一次红冲涉及的所有数据,是审计关联批次 ID。
|
- `red_flush_id` 用于跨表、跨记录追踪同一次红冲涉及的所有数据,是审计关联批次 ID。
|
||||||
- 不新增 `red_flush_no`。该编号只适合人工展示,目前无需求。
|
- 不新增 `red_flush_no`。该编号只适合人工展示,目前无需求。
|
||||||
- 第一版只支持整单红冲,不支持部分红冲。
|
- 第一版只支持整单红冲,不支持部分红冲。
|
||||||
- API 在 service 与测试完成、覆盖率达标后再增加;API 层红冲原因必填。
|
- API 已开放独立 `/red-flush/` action,API 层红冲原因必填。
|
||||||
- API 权限复用现有审批/作废权限,不新增独立红冲权限。
|
- API 权限复用现有审批/作废权限,不新增独立红冲权限。
|
||||||
- 多商户隔离必须下沉到 service。红冲 service 调用方必须传入当前 `merchant`,service 在解析单据后校验单据所属商户。
|
- 多商户隔离必须下沉到 service。红冲 service 调用方必须传入当前 `merchant`,service 在解析单据后校验单据所属商户。
|
||||||
|
|
||||||
@@ -136,7 +136,17 @@
|
|||||||
- 库存记录未完成或已红冲时报错。
|
- 库存记录未完成或已红冲时报错。
|
||||||
- 事务回滚场景。
|
- 事务回滚场景。
|
||||||
|
|
||||||
目标:`business` 模块覆盖率 90%+。
|
API 测试覆盖:
|
||||||
|
|
||||||
|
- 6 类单据 `/red-flush/` 成功路径。
|
||||||
|
- `reason` 必填。
|
||||||
|
- 无员工身份权限拒绝。
|
||||||
|
- 跨商户查询隔离。
|
||||||
|
- 外部付款/收款单拒绝。
|
||||||
|
- 未审核、重复红冲、缺库存记录等 service 校验错误映射为 400。
|
||||||
|
- 成功响应包含 `is_red_flushed`、`red_flush_id`、`red_flushed_at`,并抽样断言余额/库存副作用。
|
||||||
|
|
||||||
|
目标:red flush 关键 service/API 路径需要有定向测试覆盖。
|
||||||
|
|
||||||
## 测试命令
|
## 测试命令
|
||||||
|
|
||||||
@@ -144,14 +154,14 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web \
|
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web \
|
||||||
uv run python manage.py test business.tests --keepdb --noinput
|
uv run python manage.py test business.tests.test_red_flush_services api_v1.tests.BusinessRedFlushAPITestCase --keepdb --noinput
|
||||||
```
|
```
|
||||||
|
|
||||||
覆盖率:
|
覆盖率:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web \
|
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web \
|
||||||
uv run coverage run --source=business manage.py test business.tests --keepdb --noinput
|
uv run coverage run --source=business,api_v1 manage.py test business.tests.test_red_flush_services api_v1.tests.BusinessRedFlushAPITestCase --keepdb --noinput
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -284,7 +284,30 @@
|
|||||||
- `delta / balance_before / balance_after / direction`:记录本次增减与余额快照
|
- `delta / balance_before / balance_after / direction`:记录本次增减与余额快照
|
||||||
- `offset_to / offset_id`:预留冲抵链路,与库存 `StockSnapshot` 设计一致
|
- `offset_to / offset_id`:预留冲抵链路,与库存 `StockSnapshot` 设计一致
|
||||||
- `request_id / extra_meta`:用于幂等和记录审批上下文(操作者、触发渠道等)
|
- `request_id / extra_meta`:用于幂等和记录审批上下文(操作者、触发渠道等)
|
||||||
- **用途**:对账、审计、未来的余额红冲。目前未开放对外查询 API,可在内部管理端或报表服务中直接访问;若后续开放,请提供分页、时间范围与 `source_type` 过滤能力。
|
- **用途**:对账、审计和红冲追踪。业务单据红冲会生成反向 `BalanceChangeRecord`,并通过 `offset_to / offset_id / red_flush_id` 与原记录关联。目前余额变动记录仍未开放对外查询 API,可在内部管理端或报表服务中直接访问;若后续开放,请提供分页、时间范围与 `source_type` 过滤能力。
|
||||||
|
|
||||||
|
### 6.4 业务单据红冲(Red Flush)
|
||||||
|
|
||||||
|
正式业务单据已开放整单红冲入口,详细说明请参阅独立文档 `docs/2026-06-12_business_red_flush_api.md`。
|
||||||
|
|
||||||
|
| API | 方法 | 描述 |
|
||||||
|
|-----|------|------|
|
||||||
|
| `/purchase-orders/<id>/red-flush/` | POST | 红冲已审批采购单,反向余额和库存影响 |
|
||||||
|
| `/sales-orders/<id>/red-flush/` | POST | 红冲已审批销售单,反向余额和库存影响 |
|
||||||
|
| `/purchase-return-orders/<id>/red-flush/` | POST | 红冲已审批采购退货单,反向余额和库存影响 |
|
||||||
|
| `/sales-return-orders/<id>/red-flush/` | POST | 红冲已审批销售退货单,反向余额和库存影响 |
|
||||||
|
| `/payment-orders/<id>/red-flush/` | POST | 红冲已审批付款单,反向供应商余额 |
|
||||||
|
| `/receipt-orders/<id>/red-flush/` | POST | 红冲已审批收款单,反向客户余额 |
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reason": "录入错误,需要红冲"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`reason` 必填且不能为空。红冲成功后原单据状态保持已审批,并返回 `is_red_flushed=true`、`red_flush_id`、`red_flushed_at`。外部来源付款/收款单不允许红冲。
|
||||||
|
|
||||||
## 7. 对账单(Statements)
|
## 7. 对账单(Statements)
|
||||||
|
|
||||||
@@ -314,13 +337,15 @@
|
|||||||
| 非本商户数据 | 403 | `{"error": "forbidden", "message": "无权限访问"}` |
|
| 非本商户数据 | 403 | `{"error": "forbidden", "message": "无权限访问"}` |
|
||||||
| 单据不存在 | 404 | `{"error": "purchase_order_not_found"}` 等 |
|
| 单据不存在 | 404 | `{"error": "purchase_order_not_found"}` 等 |
|
||||||
| 审批非法状态 | 400 | 例如 `{"error": "purchase_order_has_stock_records"}`、`{"error": "receipt_order_already_approved"}` |
|
| 审批非法状态 | 400 | 例如 `{"error": "purchase_order_has_stock_records"}`、`{"error": "receipt_order_already_approved"}` |
|
||||||
| 余额功能未实现 | 501 | 仅限未来拓展,例如库存红冲尚未开放 |
|
| 红冲非法状态 | 400 | 例如未审批、重复红冲、缺少可红冲库存/余额记录、外部来源单据不允许红冲 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 参考文档
|
## 9. 参考文档
|
||||||
|
|
||||||
- `docs/purchase_order_approval_and_red_flush.md`:采购单审批及未来红冲方案。
|
- `docs/2026-06-12_business_red_flush_api.md`:业务单据红冲独立 API 文档。
|
||||||
|
- `docs/2026-06-12_business_red_flush_design.md`:业务单据红冲 service/API 设计备查。
|
||||||
|
- `docs/purchase_order_approval_and_red_flush.md`:采购单审批与红冲背景。
|
||||||
- `docs/sales_order_approval_and_red_flush.md`:销售单审批与严出模式说明。
|
- `docs/sales_order_approval_and_red_flush.md`:销售单审批与严出模式说明。
|
||||||
- `docs/payment_receipt_workflow.md`:资金类单据与余额表写入逻辑。
|
- `docs/payment_receipt_workflow.md`:资金类单据与余额表写入逻辑。
|
||||||
|
|
||||||
|
|||||||
@@ -61,44 +61,41 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 下一阶段:库存红冲(对冲)方案规划
|
## 7. 红冲(对冲)落地状态
|
||||||
|
|
||||||
### 6.1 目标与原则
|
### 6.1 目标与原则
|
||||||
|
|
||||||
- **目标**:允许在采购单及其衍生的出入库记录完成后,通过“红冲”方式撤销或抵消错误的库存变动,同时保持库存快照与主库存的可追溯性。
|
- **当前状态**:采购单已支持整单红冲。已审批采购单可通过 `POST /api/v1/purchase-orders/<id>/red-flush/` 触发,生成反向余额记录和反向库存记录,同时保持库存快照与主库存的可追溯性。
|
||||||
- **原则**:
|
- **原则**:
|
||||||
- 采用“新增反向记录”方式对冲,而非直接修改原记录;保留每一次实际发生的库存变更。
|
- 采用“新增反向记录”方式对冲,而非直接修改原记录;保留每一次实际发生的库存变更。
|
||||||
- 红冲记录需要指向原 `StockChangeRecord` / `StockSnapshot`(如 `offset_to`、`offset_id` 字段),便于审计。
|
- 红冲记录需要指向原 `StockChangeRecord` / `StockSnapshot`(如 `offset_to`、`offset_id` 字段),便于审计。
|
||||||
- 红冲动作必须与业务流程挂钩(例如采购单作废或红冲),避免孤立的库存操作。
|
- 红冲动作必须与业务流程挂钩(例如采购单红冲),避免孤立的库存操作。
|
||||||
|
|
||||||
### 6.2 行动步骤
|
### 6.2 行动步骤
|
||||||
|
|
||||||
1. **业务入口确定**
|
1. **业务入口确定**
|
||||||
- 定义哪些业务对象可以触发红冲(采购单、销售单等)。
|
- 当前支持采购、销售、采购退货、销售退货、付款、收款 6 类正式单据整单红冲。
|
||||||
- 明确触发条件:如采购单审批通过后,允许“创建红冲请求”并指向原采购单。
|
- 触发条件:单据必须已审批,且尚未红冲。
|
||||||
|
|
||||||
2. **库存服务层改造**
|
2. **库存服务层改造**
|
||||||
- 在 `stock.services.StockFlowService` 或相关函数中增加创建红冲记录的能力:
|
- `stock.services.StockFlowService.offset_stock_change()` 已负责根据原 `StockChangeRecord` 生成反向 `StockChangeRecord` 和明细。
|
||||||
- 根据原 `StockChangeRecord` 生成反向 `StockChangeRecord` 和明细。
|
- 更新 `StockSnapshot` 的 `offset_to` / `offset_id` / `cancelled` 字段,确保链路闭环。
|
||||||
- 更新 `StockSnapshot` 的 `offset_to` / `offset_id` / `cancelled` 字段,确保链路闭环。
|
- `Inventory` 调整遵循相同的原子逻辑(事务 + 快照)。
|
||||||
- 确保 `Inventory` 调整遵循相同的原子逻辑(事务 + 快照)。
|
|
||||||
|
|
||||||
3. **业务服务与模型扩展**
|
3. **业务服务与模型扩展**
|
||||||
- 在 `business.services` 中引入“红冲采购单”或“撤销审批”的新方法:
|
- `business.services.red_flush_purchase_order(...)` 负责校验原单是否允许红冲、阻止重复红冲,并调用库存红冲服务记录关联。
|
||||||
- 校验:原单是否允许红冲、是否存在未处理的红冲记录。
|
- `PurchaseOrder` 记录 `is_red_flushed`、`red_flush_id`、`red_flushed_at`。
|
||||||
- 调用库存红冲服务并记录关联。
|
|
||||||
- 必要时在 `PurchaseOrder` 或新模型中记录红冲状态/引用。
|
|
||||||
|
|
||||||
4. **API 与文档更新**
|
4. **API 与文档更新**
|
||||||
- 设计新的红冲 API(例如 `/api/v1/purchase-orders/<id>/offset/`),请求体注明原因、操作者。
|
- 红冲 API 为 `POST /api/v1/purchase-orders/<id>/red-flush/`,请求体必须包含 `reason`。
|
||||||
- 文档中要说明红冲流程与限制(只能对已审批单据、需管理员权限等)。
|
- 权限复用现有业务单据员工权限,多商户数据先由 API 查询隔离,再由 service 二次校验。
|
||||||
|
|
||||||
5. **测试与审计**
|
5. **测试与审计**
|
||||||
- 单元测试:业务 Service、库存 Service、API 都要覆盖正常流程与错误场景。
|
- 单元测试:业务 Service、库存 Service、API 都要覆盖正常流程与错误场景。
|
||||||
- 集成测试:验证红冲后库存数量恢复、快照关系正确、消息/通知链路是否需要补充。
|
- 集成测试:已覆盖红冲后库存数量恢复、快照/库存记录关系、余额反向记录和 API 错误映射。
|
||||||
- 如有需要,增加日志或审计表,记录红冲操作人、时间、关联记录。
|
- 如有需要,增加日志或审计表,记录红冲操作人、时间、关联记录。
|
||||||
|
|
||||||
通过以上步骤,审批流程与红冲机制可以衔接:审批负责“正向入库”与任务分发,红冲负责在业务需要时生成配对的反向库存记录,保持库存账实一致,同时具备可回溯、可审计的业务闭环。
|
审批流程与红冲机制已衔接:审批负责“正向入库”与任务分发,红冲负责在业务需要时生成配对的反向库存记录,保持库存账实一致,同时具备可回溯、可审计的业务闭环。详细 API 见 `docs/2026-06-12_business_red_flush_api.md`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -68,13 +68,13 @@
|
|||||||
|
|
||||||
> 说明:在严出模式下,`consume_detail_ids` 列表中的 ID 必须为同仓库、同商户且未被消耗的入库明细 ID。系统会在审批通过时逐条扣减对应库存。
|
> 说明:在严出模式下,`consume_detail_ids` 列表中的 ID 必须为同仓库、同商户且未被消耗的入库明细 ID。系统会在审批通过时逐条扣减对应库存。
|
||||||
|
|
||||||
## 8. 红冲(对冲)规划
|
## 8. 红冲(对冲)落地状态
|
||||||
|
|
||||||
与采购单一致,后续将通过库存红冲入口(`StockFlowService.offset_stock_change` 占位方法)实现销售单出库的反向抵销。关键原则:
|
与采购单一致,销售单已支持整单红冲。已审批销售单可通过 `POST /api/v1/sales-orders/<id>/red-flush/` 触发,生成反向余额记录和反向库存记录。关键原则:
|
||||||
|
|
||||||
- 采用新增反向记录的方式,保留所有历史变动。
|
- 采用新增反向记录的方式,保留所有历史变动。
|
||||||
- 红冲记录需要指向原 `StockChangeRecord` / `StockSnapshot`,保证审计可追溯。
|
- 红冲记录需要指向原 `StockChangeRecord` / `StockSnapshot`,保证审计可追溯。
|
||||||
- 红冲需与业务对象绑定,避免孤立库存操作。
|
- 红冲需与业务对象绑定,避免孤立库存操作。
|
||||||
|
|
||||||
文档与测试应在红冲能力落地时同步更新,确保审批、出库、红冲形成闭环。
|
红冲能力已补充独立 API 文档与定向测试,确保审批、出库、红冲形成闭环。详细 API 见 `docs/2026-06-12_business_red_flush_api.md`。
|
||||||
|
|
||||||
|
|||||||
@@ -596,35 +596,18 @@ Warehouses can operate in different modes:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8. Stock Change Offset (Red Flush Placeholder)
|
### 8. Stock Change Offset 与业务红冲
|
||||||
|
|
||||||
- **URL**: `POST /api/v1/stock-change/<id>/offset/`
|
`POST /api/v1/stock-change/<id>/offset/` 已在上文“库存红冲(Stock Change Offset)”说明。业务单据红冲会通过 `business` service 调用同一库存对冲能力,并把同一个 `red_flush_id` 写入原库存记录和反向库存记录。
|
||||||
- **Description**: 面向未来的库存红冲入口。当前仅提供占位实现,用于锁定最终接口形态;实际红冲逻辑尚未上线,因此调用会返回 `501 Not Implemented`,方便前端或上游业务在流程编排中预留节点。
|
|
||||||
- **Request**:
|
面向前端或业务系统的正式业务红冲入口请优先使用 `business` 单据 API,例如:
|
||||||
```json
|
|
||||||
{
|
- `POST /api/v1/purchase-orders/<id>/red-flush/`
|
||||||
"reason": "审批错误,需要冲销",
|
- `POST /api/v1/sales-orders/<id>/red-flush/`
|
||||||
"items": [
|
- `POST /api/v1/purchase-return-orders/<id>/red-flush/`
|
||||||
{
|
- `POST /api/v1/sales-return-orders/<id>/red-flush/`
|
||||||
"product_id": 1,
|
|
||||||
"quantities": ["50.00"]
|
完整业务 API 见 `docs/2026-06-12_business_red_flush_api.md`。
|
||||||
}
|
|
||||||
],
|
|
||||||
"request_id": "po-123-offset",
|
|
||||||
"extra_meta": {
|
|
||||||
"source": "purchase_order",
|
|
||||||
"operator": "warehouse_admin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Response(当前占位行为)**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "stock_offset_not_ready",
|
|
||||||
"message": "库存红冲功能尚未实现",
|
|
||||||
"record_id": 25
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-06-12 07:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('shipment', '0024_shipmentdeliveryphoto'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='shipmentdelivery',
|
||||||
|
options={'ordering': ['-created_at', '-id'], 'verbose_name': '送货单', 'verbose_name_plural': '送货单'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='shipmentdeliveryphoto',
|
||||||
|
options={'ordering': ['-created_at', '-id'], 'permissions': [('cancel_shipmentdelivery', 'Can cancel shipment delivery')], 'verbose_name': '送达照片', 'verbose_name_plural': '送达照片'},
|
||||||
|
),
|
||||||
|
migrations.RenameIndex(
|
||||||
|
model_name='shipmentdeliveryphoto',
|
||||||
|
new_name='shipment_de_shipmen_31e6d5_idx',
|
||||||
|
old_name='shipment_de_shipment_355c1f_idx',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='shipmentdeliveryphoto',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='shipmentdeliveryphoto',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
|
||||||
|
),
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user