diff --git a/business/ARCHITECTURE.md b/business/ARCHITECTURE.md index 9fa136c..c3647dd 100644 --- a/business/ARCHITECTURE.md +++ b/business/ARCHITECTURE.md @@ -40,6 +40,7 @@ - **PaymentOrder**(付款单):`direction=-1`,`counterparty=supplier`,仅金额字段,不触发库存。 - **ReceiptOrder**(收款单):`direction=-1`,`counterparty=customer`,仅金额字段,不触发库存。 - **SupplierBalance / CustomerBalance**:实时维护供应商应付、客户应收余额,所有审批通过的带金额单据都会写入,供查询接口和报表使用。 +- **BalanceChangeRecord**(余额变动记录):仿照 `stock.StockSnapshot` 的“流水 + 快照”模式,记录每一次余额写入的来源、方向、前后余额与可能的冲抵关系,为财务审计与未来的红冲能力提供依据。 通过 mixin,所有单据都具备: - 统一的金额聚合与方向计算(资金/库存可共用 `get_signed_total_amount()`) @@ -57,7 +58,8 @@ 1. **新增单据**:若未来出现调拨单、退货单或更多资金类单据,优先复用 `OrderDirectionMixin + OrderCounterpartyMixin`(如有明细再叠加 `OrderItemsAggregationMixin`),仅通过 `get_direction()` / `get_counterparty_field_name()` 区别方向与主体,减少重复实现。 2. **审批/状态机**:采购与销售如需共享状态流转,可提炼状态机或 service 层 mixin,而无需在模型层合并。 3. **统计与报表**:财务/库存统计应依赖 `get_signed_total_amount()` / `get_direction()`,确保采购/销售、退货/正向都能通过统一接口处理。 -4. **文档同步**:新增单据或服务时必须更新本文件,描述新增模型如何复用 mixin、如何影响下游模块,保持设计透明。 +4. **余额审计**:任何会写入 Supplier/CustomerBalance 的流程必须通过 `BalanceService`,以便自动生成 `BalanceChangeRecord`。审批通过后禁止作废,若未来需要冲销,必须新建红冲记录并维护 `offset_to/offset_id` 链路。 +5. **文档同步**:新增单据或服务时必须更新本文件,描述新增模型如何复用 mixin、如何影响下游模块,保持设计透明。 以上约定的目标是:**保持各类业务单据的独立性,同时通过 mixin/服务层抽象复用绝大多数公共逻辑**。如需变更此架构,请在评估后更新本文件,说明原因与迁移方案。 diff --git a/business/admin.py b/business/admin.py index 2f4e0fa..49be32c 100644 --- a/business/admin.py +++ b/business/admin.py @@ -47,3 +47,30 @@ class PurchaseOrderItemAdmin(admin.ModelAdmin): @admin.display(description='总金额') def _total_amount(self, obj: models.PurchaseOrderItem): return obj.total_amount() + + +@admin.register(models.CustomerBalance) +class CustomerBalancemAdmin(admin.ModelAdmin): + list_display = ('id', 'customer', 'balance', 'created_at') + search_fields = ('customer_balance__id', 'product__name') + ordering = ('-created_at',) + + +@admin.register(models.BalanceChangeRecord) +class BalanceChangeRecordAdmin(admin.ModelAdmin): + list_display = ('id', 'merchant', 'target_type', 'source_type', 'source_id', 'delta', 'balance_before', 'balance_after', 'direction', 'created_at') + search_fields = ('merchant__name', 'target_type', 'source_type', 'source_id') + list_filter = ('merchant', 'target_type', 'source_type', 'direction', 'created_at') + ordering = ('-created_at',) + + @admin.display(description='商户') + def merchant(self, obj: models.BalanceChangeRecord): + return obj.merchant.name + + @admin.display(description='目标类型') + def target_type(self, obj: models.BalanceChangeRecord): + return obj.target_type.label + + @admin.display(description='来源类型') + def source_type(self, obj: models.BalanceChangeRecord): + return obj.source_type.label \ No newline at end of file diff --git a/business/migrations/0013_balancechangerecord.py b/business/migrations/0013_balancechangerecord.py new file mode 100644 index 0000000..cfd3919 --- /dev/null +++ b/business/migrations/0013_balancechangerecord.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.7 on 2025-12-01 06:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0016_merchantsetting_type'), + ('business', '0012_customerbalance_supplierbalance'), + ] + + operations = [ + migrations.CreateModel( + name='BalanceChangeRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('target_type', models.IntegerField(choices=[(1, '供应商'), (2, '客户')], verbose_name='业务主体类型')), + ('source_type', models.IntegerField(choices=[(1, '采购单'), (2, '销售单'), (3, '付款单'), (4, '收款单')], verbose_name='来源业务类型')), + ('source_id', models.BigIntegerField(verbose_name='来源业务ID')), + ('delta', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='变动金额')), + ('direction', models.IntegerField(choices=[(1, '增加'), (2, '减少')], verbose_name='方向')), + ('balance_before', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='变动前余额')), + ('balance_after', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='变动后余额')), + ('request_id', models.CharField(blank=True, max_length=64, null=True, verbose_name='幂等请求ID')), + ('remarks', models.TextField(blank=True, null=True, verbose_name='备注')), + ('extra_meta', models.JSONField(blank=True, default=dict, verbose_name='扩展信息')), + ('offset_to', models.BigIntegerField(blank=True, null=True, verbose_name='冲抵目标ID')), + ('offset_at', models.DateTimeField(blank=True, null=True, verbose_name='冲抵时间')), + ('offset_id', models.BigIntegerField(blank=True, null=True, verbose_name='冲抵来源ID')), + ('cancelled', models.BooleanField(default=False, verbose_name='已被冲抵')), + ('cancelled_at', models.DateTimeField(blank=True, null=True, verbose_name='被冲抵时间')), + ('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='balance_change_records', to='basic_info.customer', verbose_name='客户')), + ('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='balance_change_records', to='basic_info.merchant', verbose_name='所属商户')), + ('supplier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='balance_change_records', to='basic_info.supplier', verbose_name='供应商')), + ], + options={ + 'verbose_name': '余额变动记录', + 'verbose_name_plural': '余额变动记录', + 'indexes': [models.Index(fields=['merchant', 'source_type', 'source_id'], name='balance_change_source_idx')], + 'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('customer__isnull', True), ('supplier__isnull', False)), models.Q(('customer__isnull', False), ('supplier__isnull', True)), _connector='OR'), name='balance_change_single_counterparty')], + }, + ), + ] diff --git a/business/models.py b/business/models.py index 929a5cf..b63cef4 100644 --- a/business/models.py +++ b/business/models.py @@ -462,3 +462,89 @@ class CustomerBalance(ModelBase): verbose_name = '客户余额' verbose_name_plural = '客户余额' unique_together = ('merchant', 'customer') + + +class BalanceChangeTargetEnum(models.IntegerChoices): + SUPPLIER = 1, '供应商' + CUSTOMER = 2, '客户' + + +class BalanceChangeSourceEnum(models.IntegerChoices): + PURCHASE_ORDER = 1, '采购单' + SALES_ORDER = 2, '销售单' + PAYMENT_ORDER = 3, '付款单' + RECEIPT_ORDER = 4, '收款单' + + +class BalanceChangeDirectionEnum(models.IntegerChoices): + INCREASE = 1, '增加' + DECREASE = 2, '减少' + + +class BalanceChangeRecord(ModelBase): + merchant = models.ForeignKey( + basic_info_models.Merchant, + on_delete=models.PROTECT, + related_name='balance_change_records', + verbose_name='所属商户', + ) + supplier = models.ForeignKey( + basic_info_models.Supplier, + on_delete=models.PROTECT, + related_name='balance_change_records', + verbose_name='供应商', + null=True, + blank=True, + ) + customer = models.ForeignKey( + basic_info_models.Customer, + on_delete=models.PROTECT, + related_name='balance_change_records', + verbose_name='客户', + null=True, + blank=True, + ) + target_type = models.IntegerField( + choices=BalanceChangeTargetEnum.choices, + verbose_name='业务主体类型', + ) + source_type = models.IntegerField( + choices=BalanceChangeSourceEnum.choices, + verbose_name='来源业务类型', + ) + source_id = models.BigIntegerField(verbose_name='来源业务ID') + delta = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='变动金额') + direction = models.IntegerField( + choices=BalanceChangeDirectionEnum.choices, + verbose_name='方向', + ) + balance_before = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='变动前余额') + balance_after = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='变动后余额') + request_id = models.CharField(max_length=64, null=True, blank=True, verbose_name='幂等请求ID') + remarks = models.TextField(blank=True, null=True, verbose_name='备注') + extra_meta = models.JSONField(default=dict, blank=True, verbose_name='扩展信息') + offset_to = models.BigIntegerField(null=True, blank=True, verbose_name='冲抵目标ID') + offset_at = models.DateTimeField(null=True, blank=True, verbose_name='冲抵时间') + offset_id = models.BigIntegerField(null=True, blank=True, verbose_name='冲抵来源ID') + cancelled = models.BooleanField(default=False, verbose_name='已被冲抵') + cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name='被冲抵时间') + + class Meta: + verbose_name = '余额变动记录' + verbose_name_plural = '余额变动记录' + indexes = [ + models.Index(fields=['merchant', 'source_type', 'source_id'], name='balance_change_source_idx'), + ] + constraints = [ + models.CheckConstraint( + check=( + (models.Q(supplier__isnull=False, customer__isnull=True)) + | (models.Q(supplier__isnull=True, customer__isnull=False)) + ), + name='balance_change_single_counterparty', + ), + ] + + def __str__(self): + counterparty = self.supplier or self.customer + return f'余额变动 {self.id} - {counterparty}' diff --git a/business/services.py b/business/services.py index a67b8fe..bc6118e 100644 --- a/business/services.py +++ b/business/services.py @@ -25,15 +25,25 @@ class BalanceService: merchant: basic_info_models.Merchant, supplier: basic_info_models.Supplier, delta: Decimal, + source_type: models.BalanceChangeSourceEnum, + source_id: int, + request_id: str | None = None, + remarks: str | None = '', + extra_meta: Dict[str, Any] | None = None, ): - with transaction.atomic(): - balance, _ = models.SupplierBalance.objects.select_for_update().get_or_create( - merchant=merchant, - supplier=supplier, - defaults={'balance': Decimal('0')}, - ) - balance.balance += delta - balance.save(update_fields=['balance', 'updated_at']) + BalanceService._adjust_balance( + merchant=merchant, + counterparty=supplier, + balance_model=models.SupplierBalance, + balance_field='supplier', + delta=delta, + target_type=models.BalanceChangeTargetEnum.SUPPLIER, + source_type=source_type, + source_id=source_id, + request_id=request_id, + remarks=remarks, + extra_meta=extra_meta, + ) @staticmethod def adjust_customer_balance( @@ -41,16 +51,77 @@ class BalanceService: merchant: basic_info_models.Merchant, customer: basic_info_models.Customer, delta: Decimal, + source_type: models.BalanceChangeSourceEnum, + source_id: int, + request_id: str | None = None, + remarks: str | None = '', + extra_meta: Dict[str, Any] | None = None, ): + BalanceService._adjust_balance( + merchant=merchant, + counterparty=customer, + balance_model=models.CustomerBalance, + balance_field='customer', + delta=delta, + target_type=models.BalanceChangeTargetEnum.CUSTOMER, + source_type=source_type, + source_id=source_id, + request_id=request_id, + remarks=remarks, + extra_meta=extra_meta, + ) + + @staticmethod + def _adjust_balance( + *, + merchant: basic_info_models.Merchant, + counterparty, + balance_model, + balance_field: str, + delta: Decimal, + target_type: models.BalanceChangeTargetEnum, + source_type: models.BalanceChangeSourceEnum, + source_id: int, + request_id: str | None, + remarks: str | None, + extra_meta: Dict[str, Any] | None, + ): + meta_payload = extra_meta or {} + remarks_value = remarks or '' with transaction.atomic(): - balance, _ = models.CustomerBalance.objects.select_for_update().get_or_create( + balance, _ = balance_model.objects.select_for_update().get_or_create( merchant=merchant, - customer=customer, defaults={'balance': Decimal('0')}, + **{balance_field: counterparty}, ) + before = balance.balance balance.balance += delta balance.save(update_fields=['balance', 'updated_at']) + record_kwargs = { + 'merchant': merchant, + 'target_type': target_type, + 'source_type': source_type, + 'source_id': source_id, + 'delta': delta, + 'direction': ( + models.BalanceChangeDirectionEnum.INCREASE + if delta >= 0 + else models.BalanceChangeDirectionEnum.DECREASE + ), + 'balance_before': before, + 'balance_after': balance.balance, + 'request_id': request_id, + 'remarks': remarks_value, + 'extra_meta': meta_payload, + } + if target_type == models.BalanceChangeTargetEnum.SUPPLIER: + record_kwargs['supplier'] = counterparty + else: + record_kwargs['customer'] = counterparty + + models.BalanceChangeRecord.objects.create(**record_kwargs) + @staticmethod def get_customer_balance( *, @@ -373,6 +444,8 @@ def review_payment_order( merchant=locked.merchant, supplier=locked.supplier, delta=-locked.amount, + source_type=models.BalanceChangeSourceEnum.PAYMENT_ORDER, + source_id=locked.id, ) locked.refresh_from_db(fields=['status', 'updated_at']) return locked @@ -424,6 +497,8 @@ def review_receipt_order( merchant=locked.merchant, customer=locked.customer, delta=-locked.amount, + source_type=models.BalanceChangeSourceEnum.RECEIPT_ORDER, + source_id=locked.id, ) locked.refresh_from_db(fields=['status', 'updated_at']) return locked @@ -577,6 +652,8 @@ def _approve_purchase_order( merchant=locked_order.merchant, supplier=locked_order.supplier, delta=locked_order.get_total_amount(), + source_type=models.BalanceChangeSourceEnum.PURCHASE_ORDER, + source_id=locked_order.id, ) created_by_id = getattr(reviewed_by, 'id', None) @@ -632,6 +709,8 @@ def _approve_sales_order( merchant=locked_order.merchant, customer=locked_order.customer, delta=locked_order.get_total_amount(), + source_type=models.BalanceChangeSourceEnum.SALES_ORDER, + source_id=locked_order.id, ) created_by_id = getattr(reviewed_by, 'id', None) diff --git a/business/tests.py b/business/tests.py index d1893ba..4774ed4 100644 --- a/business/tests.py +++ b/business/tests.py @@ -179,6 +179,15 @@ class PurchaseOrderServiceTestCase(TestCase): supplier=self.supplier, ) self.assertEqual(balance.balance, purchase_order.get_total_amount()) + record = business_models.BalanceChangeRecord.objects.get( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER, + source_id=purchase_order.id, + ) + self.assertEqual(record.target_type, business_models.BalanceChangeTargetEnum.SUPPLIER) + self.assertEqual(record.delta, purchase_order.get_total_amount()) + self.assertEqual(record.balance_after, balance.balance) + self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.INCREASE) def test_create_purchase_order_without_items_raises(self): with self.assertRaises(ValueError): @@ -209,6 +218,13 @@ class PurchaseOrderServiceTestCase(TestCase): reviewed_by=self.user, ) self.assertEqual(cancelled.status, business_models.PurchaseOrderStatusEnum.CANCELLED) + self.assertFalse( + business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER, + source_id=purchase_order.id, + ).exists() + ) def test_review_purchase_order_cancel_blocked_after_stock_created(self): purchase_order = services.create_purchase_order( @@ -337,6 +353,15 @@ class SalesOrderServiceTestCase(TestCase): customer=self.customer, ) self.assertEqual(balance.balance, sales_order.get_total_amount()) + record = business_models.BalanceChangeRecord.objects.get( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER, + source_id=sales_order.id, + ) + self.assertEqual(record.target_type, business_models.BalanceChangeTargetEnum.CUSTOMER) + self.assertEqual(record.delta, sales_order.get_total_amount()) + self.assertEqual(record.balance_after, balance.balance) + self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.INCREASE) def test_sales_order_cancel_blocked_after_stock_created(self): sales_order = services.create_sales_order( @@ -361,6 +386,13 @@ class SalesOrderServiceTestCase(TestCase): target_status=business_models.SalesOrderStatusEnum.CANCELLED, reviewed_by=self.user, ) + self.assertFalse( + business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER, + source_id=sales_order.id, + ).exists() + ) def test_sales_order_requires_consume_ids_for_strict_out(self): with self.assertRaises(ValueError): @@ -443,6 +475,14 @@ class PaymentReceiptServiceTestCase(TestCase): supplier=self.supplier, ) self.assertEqual(balance.balance, Decimal('-120.50')) + record = business_models.BalanceChangeRecord.objects.get( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER, + source_id=order.id, + ) + self.assertEqual(record.direction, business_models.BalanceChangeDirectionEnum.DECREASE) + self.assertEqual(record.delta, Decimal('-120.50')) + self.assertEqual(record.balance_after, balance.balance) with self.assertRaises(ValueError): services.review_payment_order( payment_order=order, @@ -465,6 +505,13 @@ class PaymentReceiptServiceTestCase(TestCase): reviewed_by=self.operator, ) self.assertEqual(cancelled.status, business_models.ReceiptOrderStatusEnum.CANCELLED) + self.assertFalse( + business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.RECEIPT_ORDER, + source_id=order.id, + ).exists() + ) def test_payment_amount_must_be_positive(self): with self.assertRaises(ValueError): @@ -500,6 +547,44 @@ class PaymentReceiptServiceTestCase(TestCase): customer=self.customer, ) self.assertEqual(balance.balance, Decimal('-10')) + records = business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.RECEIPT_ORDER, + source_id=order.id, + ) + self.assertEqual(records.count(), 1) + self.assertEqual(records.first().balance_after, balance.balance) + + def test_payment_approval_is_idempotent(self): + order = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='75.00', + operator=self.operator, + ) + services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + reviewed_by=self.operator, + ) + services.review_payment_order( + payment_order=order, + target_status=business_models.PaymentOrderStatusEnum.APPROVED, + reviewed_by=self.operator, + ) + balance = business_models.SupplierBalance.objects.get( + merchant=self.merchant, + supplier=self.supplier, + ) + self.assertEqual(balance.balance, Decimal('-75.00')) + records = business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER, + source_id=order.id, + ) + self.assertEqual(records.count(), 1) + self.assertEqual(records.first().balance_after, balance.balance) class PurchaseOrderStockServiceTestCase(TestCase): @@ -668,6 +753,13 @@ class SalesOrderConcurrencyTestCase(TransactionTestCase): customer=self.customer, ) self.assertEqual(balance.balance, self.sales_order.get_total_amount()) + records = business_models.BalanceChangeRecord.objects.filter( + merchant=self.merchant, + source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER, + source_id=self.sales_order.id, + ) + self.assertEqual(records.count(), 1) + self.assertEqual(records.first().balance_after, balance.balance) def tearDown(self): connections.close_all() diff --git a/docs/business_api_reference.md b/docs/business_api_reference.md index 7779b6a..5bd0b48 100644 --- a/docs/business_api_reference.md +++ b/docs/business_api_reference.md @@ -15,6 +15,7 @@ | 金额字段 | 字符串形式的十进制数(例如 `"123.45"`),与后端 `Decimal` 精度一致。 | | items 结构 | 受仓库模式影响:
• 严进/严进严出:`numbers: [int,…]` 表示条数明细
• 宽进宽出:`quantity` + `num_of_rolls`
• 严出:`consume_detail_ids: [detail_id,…]` | | 审批副作用 | 采购/销售审批通过后根据商户设置触发 Celery 入/出库任务;付款/收款审批通过将同步写入余额表。 | +| 余额审计 | 审批成功后会同步写入 `BalanceChangeRecord`,记录来源单据、方向、前后余额与冲抵占位字段,供审计/红冲使用。 | 错误响应统一为 `{"error": "代码", "message": "描述"}`,字段可能因场景扩展(如 `record_id`、`fields` 等)。 @@ -189,6 +190,17 @@ 目前仅内部使用(审批写入),如需对外查询可在此基础上新增 `/suppliers//balance/`,逻辑与客户一致:采购单审批增加余额、付款单审批减少余额。 +### 6.3 余额变动记录(BalanceChangeRecord) + +- **写入时机**:仅在审批通过瞬间写入;审批成功后禁止作废,若需冲销必须通过红冲/对冲流程生成反向记录。 +- **字段概要**: + - `target_type`:供应商 / 客户 + - `source_type` + `source_id`:关联具体业务对象(采购/销售/付款/收款) + - `delta / balance_before / balance_after / direction`:记录本次增减与余额快照 + - `offset_to / offset_id`:预留冲抵链路,与库存 `StockSnapshot` 设计一致 + - `request_id / extra_meta`:用于幂等和记录审批上下文(操作者、触发渠道等) +- **用途**:对账、审计、未来的余额红冲。目前未开放对外查询 API,可在内部管理端或报表服务中直接访问;若后续开放,请提供分页、时间范围与 `source_type` 过滤能力。 + --- ## 7. 错误码与常见响应 diff --git a/docs/payment_receipt_workflow.md b/docs/payment_receipt_workflow.md index 67339de..1d6ce0a 100644 --- a/docs/payment_receipt_workflow.md +++ b/docs/payment_receipt_workflow.md @@ -5,7 +5,7 @@ - `ReceiptOrder`:针对客户的资金收入单据,审批通过后代表“确认收款”。 - 两者均位于 `business` 模块,API 路径分别为 `/api/v1/payment-orders/`、`/api/v1/receipt-orders/`。 - 与采购/销售相比,不涉及产品与库存,仅维护资金方向、对方主体、金额与状态。 -- 审批通过会同步更新供应商/客户余额表(`SupplierBalance` / `CustomerBalance`),提供 O(1) 的欠款查询。 +- 审批通过会同步更新供应商/客户余额表(`SupplierBalance` / `CustomerBalance`),并写入 `BalanceChangeRecord`,提供 O(1) 的欠款查询与可追溯的余额流水。 ## 2. 创建流程 | 字段 | 付款单 | 收款单 | @@ -20,8 +20,8 @@ ## 3. 审批 / 作废 - 接口:`POST /api/v1/-orders//review/` - 请求体:`{"action": "approve"}` 或 `{"action": "cancel"}` -- 审批通过:状态变为 `APPROVED`,后续可用于应付/应收对账。 -- 作废:状态变为 `CANCELLED`;若已是目标状态则返回原状态(幂等)。 +- 审批通过:状态变为 `APPROVED`,同步写入余额表与 `BalanceChangeRecord`(记录来源单据、方向、前后余额);审批成功后禁止再作废。 +- 作废:仅允许 `PENDING` 状态作废,状态变为 `CANCELLED`。若已审批或已作废会抛出业务错误(幂等)。 ## 4. 常见异常 | 场景 | 响应 |