1
0
forked from erp-dev/erp

feat: BalanceChangeRecord

This commit is contained in:
2025-12-01 15:22:59 +08:00
parent 9acc4e14fc
commit a3b4af2602
8 changed files with 359 additions and 14 deletions

View File

@@ -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/服务层抽象复用绝大多数公共逻辑**。如需变更此架构,请在评估后更新本文件,说明原因与迁移方案。

View File

@@ -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

View File

@@ -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')],
},
),
]

View File

@@ -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}'

View File

@@ -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(
BalanceService._adjust_balance(
merchant=merchant,
supplier=supplier,
defaults={'balance': Decimal('0')},
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,
)
balance.balance += delta
balance.save(update_fields=['balance', 'updated_at'])
@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,
):
with transaction.atomic():
balance, _ = models.CustomerBalance.objects.select_for_update().get_or_create(
BalanceService._adjust_balance(
merchant=merchant,
customer=customer,
defaults={'balance': Decimal('0')},
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, _ = balance_model.objects.select_for_update().get_or_create(
merchant=merchant,
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)

View File

@@ -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()

View File

@@ -15,6 +15,7 @@
| 金额字段 | 字符串形式的十进制数(例如 `"123.45"`),与后端 `Decimal` 精度一致。 |
| items 结构 | 受仓库模式影响:<br>• 严进/严进严出:`numbers: [int,…]` 表示条数明细<br>• 宽进宽出:`quantity` + `num_of_rolls`<br>• 严出:`consume_detail_ids: [detail_id,…]` |
| 审批副作用 | 采购/销售审批通过后根据商户设置触发 Celery 入/出库任务;付款/收款审批通过将同步写入余额表。 |
| 余额审计 | 审批成功后会同步写入 `BalanceChangeRecord`,记录来源单据、方向、前后余额与冲抵占位字段,供审计/红冲使用。 |
错误响应统一为 `{"error": "代码", "message": "描述"}`,字段可能因场景扩展(如 `record_id``fields` 等)。
@@ -189,6 +190,17 @@
目前仅内部使用(审批写入),如需对外查询可在此基础上新增 `/suppliers/<id>/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. 错误码与常见响应

View File

@@ -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/<payment|receipt>-orders/<id>/review/`
- 请求体:`{"action": "approve"}``{"action": "cancel"}`
- 审批通过:状态变为 `APPROVED`后续可用于应付/应收对账
- 作废:状态变为 `CANCELLED`若已是目标状态则返回原状态(幂等)。
- 审批通过:状态变为 `APPROVED`同步写入余额表与 `BalanceChangeRecord`(记录来源单据、方向、前后余额);审批成功后禁止再作废
- 作废:仅允许 `PENDING` 状态作废,状态变为 `CANCELLED`若已审批或已作废会抛出业务错误(幂等)。
## 4. 常见异常
| 场景 | 响应 |