diff --git a/api_v1/views/business/statements/serializers.py b/api_v1/views/business/statements/serializers.py index 7b976dc..1bacc99 100644 --- a/api_v1/views/business/statements/serializers.py +++ b/api_v1/views/business/statements/serializers.py @@ -13,6 +13,9 @@ class StatementRecordSerializer(serializers.Serializer): counterparty_name = serializers.CharField() positive_amount = serializers.DecimalField(max_digits=15, decimal_places=2) negative_amount = serializers.DecimalField(max_digits=15, decimal_places=2) + cumulative_amount = serializers.CharField() + current_balance = serializers.CharField() + arrears_amount = serializers.CharField() extra = serializers.DictField(required=False) diff --git a/api_v1/views/business/statements/views.py b/api_v1/views/business/statements/views.py index a8f8a93..da125ef 100644 --- a/api_v1/views/business/statements/views.py +++ b/api_v1/views/business/statements/views.py @@ -10,6 +10,7 @@ from rest_framework.response import Response from basic_info import models as basic_models from business import models as business_models from api_v1.views.stock_change_views.mixins import StockChangeViewMixin +from business import services as business_services from . import serializers as statement_serializers TWO_PLACES = Decimal('0.01') @@ -24,6 +25,11 @@ class StatementViewBase(StockChangeViewMixin, views.APIView): permission_classes = [IsAuthenticated] serializer_class = statement_serializers.StatementResponseSerializer + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._current_balance_display: str | None = None + self._current_balance_value: Decimal | None = None + def get_serializer_context(self): return { 'request': self.request, @@ -32,11 +38,12 @@ class StatementViewBase(StockChangeViewMixin, views.APIView): } def _build_response(self, counterparty, records: List[dict]): + processed_records = self._attach_running_totals(records) serializer = self.serializer_class( instance={ 'counterparty': counterparty.id, 'counterparty_name': counterparty.name, - 'records': records, + 'records': processed_records, }, context=self.get_serializer_context(), ) @@ -102,6 +109,22 @@ class StatementViewBase(StockChangeViewMixin, views.APIView): 'negative_total': self._decimal_to_string(total_negative), } + def _attach_running_totals(self, records: List[dict]) -> List[dict]: + running_total = ZERO + current_balance_value = self._current_balance_value or ZERO + current_balance_display = self._current_balance_display or self._decimal_to_string(current_balance_value) + processed = [] + for record in records: + record_copy = dict(record) + record_copy['cumulative_amount'] = self._decimal_to_string(running_total) + record_copy['current_balance'] = current_balance_display + arrears_amount = current_balance_value - running_total + record_copy['arrears_amount'] = self._decimal_to_string(arrears_amount) + delta = record_copy['positive_amount'] - record_copy['negative_amount'] + running_total += delta + processed.append(record_copy) + return processed + class CustomerStatementView(StatementViewBase): """ @@ -118,6 +141,7 @@ class CustomerStatementView(StatementViewBase): except basic_models.Customer.DoesNotExist: return self.not_found_response('客户不存在') + self._get_customer_balance(merchant, customer) records = self._collect_customer_records(merchant, customer) return self._build_response(customer, records) @@ -212,6 +236,15 @@ class CustomerStatementView(StatementViewBase): ) return records + def _get_customer_balance(self, merchant, customer): + balance = business_services.BalanceService.get_customer_balance( + merchant=merchant, + customer=customer, + ) + self._current_balance_value = balance + self._current_balance_display = self._decimal_to_string(balance) + return self._current_balance_display + class SupplierStatementView(StatementViewBase): """ @@ -228,6 +261,7 @@ class SupplierStatementView(StatementViewBase): except basic_models.Supplier.DoesNotExist: return self.not_found_response('供应商不存在') + self._get_supplier_balance(merchant, supplier) records = self._collect_supplier_records(merchant, supplier) return self._build_response(supplier, records) @@ -322,4 +356,13 @@ class SupplierStatementView(StatementViewBase): ) return records + def _get_supplier_balance(self, merchant, supplier): + balance = business_services.BalanceService.get_supplier_balance( + merchant=merchant, + supplier=supplier, + ) + self._current_balance_value = balance + self._current_balance_display = self._decimal_to_string(balance) + return self._current_balance_display + diff --git a/business/admin.py b/business/admin.py index fdb73ee..3d9e55b 100644 --- a/business/admin.py +++ b/business/admin.py @@ -203,11 +203,23 @@ class SupplierBalanceAdmin(admin.ModelAdmin): @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') + list_display = ( + 'id', 'merchant', 'target_type', 'target', + '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 target(self, obj: models.BalanceChangeRecord): + if obj.target_type == models.BalanceChangeTargetEnum.SUPPLIER: + return obj.supplier.name + elif obj.target_type == models.BalanceChangeTargetEnum.CUSTOMER: + return obj.customer.name + return '-' @admin.display(description='商户') def merchant(self, obj: models.BalanceChangeRecord): return obj.merchant.name diff --git a/docs/business_api_reference.md b/docs/business_api_reference.md index 2049971..c50be7b 100644 --- a/docs/business_api_reference.md +++ b/docs/business_api_reference.md @@ -255,77 +255,18 @@ ## 7. 对账单(Statements) -对账单 API 汇总客户/供应商所有 **已审批通过** 的相关业务单据,并提供统一的金额正负视图,字段后续可通过 serializer context 继续扩展统计信息。 - -### 7.1 客户对账单 +客户/供应商对账单提供统一的金额视图与余额快照,用于销售/采购结算场景。完整说明(含响应示例、字段定义与业务规则)请参阅 `docs/statements.md`。 | API | 方法 | 描述 | |-----|------|------| -| `/customers//statements/` | GET | 返回该客户的销售单、销售退货单、收款单对账记录。 | +| `/customers//statements/` | GET | 指定客户的销售 / 销退 / 收款对账单 | +| `/suppliers//statements/` | GET | 指定供应商的采购 / 采退 / 付款对账单 | -```json -{ - "customer": 6, - "customer_name": "杭州零售商", - "records": [ - { - "source_type": "sales_order", - "source_label": "销售单", - "source_id": 1024, - "occurred_at": "2025-11-30", - "recorded_at": "2025-12-01T03:26:18.815992Z", - "status": 2, - "status_label": "审批通过", - "counterparty": 6, - "counterparty_name": "杭州零售商", - "positive_amount": "3200.00", - "negative_amount": "0.00" - }, - { - "source_type": "receipt_order", - "source_label": "收款单", - "source_id": 2001, - "occurred_at": "2025-12-05", - "recorded_at": "2025-12-05T02:11:07.441982Z", - "status": 2, - "status_label": "审批通过", - "counterparty": 6, - "counterparty_name": "杭州零售商", - "positive_amount": "0.00", - "negative_amount": "1500.00" - } - ], - "summary": { - "positive_total": "3200.00", - "negative_total": "1500.00" - } -} -``` +关键特性: -- `records` 依照 `occurred_at -> recorded_at -> source_id` 倒序排列。 -- `positive_amount` 始终代表应收增加:销售单为正,其余(销售退货、收款)为负。 -- `summary` 通过 serializer context 生成,如需扩展其他统计字段可在视图中向 context 注入。 - -### 7.2 供应商对账单 - -| API | 方法 | 描述 | -|-----|------|------| -| `/suppliers//statements/` | GET | 返回该供应商的采购单、采购退货单、付款单对账记录。 | - -- 采购单为正向金额,采购退货与付款单为负向金额。 -- 其余字段与客户对账单完全一致。 - -### 7.3 记录字段 - -| 字段 | 说明 | -|------|------| -| `source_type` / `source_label` | 业务来源与可读名称(`sales_order`、`payment_order` 等)。 | -| `source_id` | 原始单据 ID。 | -| `occurred_at` / `recorded_at` | 业务日期(如 `sales_date`)与系统写入时间。 | -| `status` / `status_label` | 当前单据状态。 | -| `counterparty` / `counterparty_name` | 客户或供应商。 | -| `positive_amount` / `negative_amount` | 金额正负值,字符串形式的 `Decimal`。 | -| `extra` | 预留字典字段,后续可承载额外统计信息。 | +- 固定按 `occurred_at -> recorded_at -> source_id` 倒序输出,不提供排序参数。 +- `positive_amount` / `negative_amount` 统一表示余额增减;`cumulative_amount`、`current_balance`、`arrears_amount` 均冗余在每条记录中,前端可直接使用。 +- 余额快照来自 `BalanceService`,每次请求只查询一次,保证与审批事务一致。 --- diff --git a/docs/statements.md b/docs/statements.md new file mode 100644 index 0000000..98df417 --- /dev/null +++ b/docs/statements.md @@ -0,0 +1,119 @@ +# Statements 对账单 API + +本文件专门描述客户与供应商对账单接口的请求与响应结构。所有接口均位于 `/api/v1/`,默认需要登录并具备员工身份,且只返回当前员工所属商户的数据。 + +--- + +## 1. 功能概览 + +- **客户对账单**:聚合指定客户的销售单、销售退货单、收款单,统一展示正负金额、当前余额以及累欠金额。 +- **供应商对账单**:聚合指定供应商的采购单、采购退货单、付款单,展示待付金额的增减与余额。 +- **排序规则**:固定按 `occurred_at -> recorded_at -> source_id` 倒序排列,不支持外部修改。 +- **金额方向**: + - 客户:销售单记入 `positive_amount`(增加应收),销售退货/收款记入 `negative_amount`(减少应收)。 + - 供应商:采购单记入 `positive_amount`(增加待付),采购退货/付款记入 `negative_amount`(减少待付)。 +- **余额来源**:调用 `BalanceService`,每次请求仅查询一次,并冗余在每条记录中,便于前端表格或统计组件使用。 + +--- + +## 2. 接口列表 + +| API | 方法 | 描述 | +|-----|------|------| +| `/customers//statements/` | GET | 指定客户的销售、销退、收款对账单 | +| `/suppliers//statements/` | GET | 指定供应商的采购、采退、付款对账单 | + +请求无需额外参数;分页暂不开放(按时间倒序返回全部记录)。 + +--- + +## 3. 响应结构 + +```json +{ + "customer": 6, + "customer_name": "杭州零售商", + "records": [ + { + "source_type": "sales_order", + "source_label": "销售单", + "source_id": 1024, + "occurred_at": "2025-11-30", + "recorded_at": "2025-12-01T03:26:18.815992Z", + "status": 2, + "status_label": "审批通过", + "counterparty": 6, + "counterparty_name": "杭州零售商", + "positive_amount": "3200.00", + "negative_amount": "0.00", + "cumulative_amount": "0.00", + "current_balance": "1850.00", + "arrears_amount": "1850.00" + }, + { + "source_type": "receipt_order", + "source_label": "收款单", + "source_id": 2001, + "occurred_at": "2025-12-05", + "recorded_at": "2025-12-05T02:11:07.441982Z", + "status": 2, + "status_label": "审批通过", + "counterparty": 6, + "counterparty_name": "杭州零售商", + "positive_amount": "0.00", + "negative_amount": "1500.00", + "cumulative_amount": "3200.00", + "current_balance": "1850.00", + "arrears_amount": "-335.00" + } + ], + "summary": { + "positive_total": "3200.00", + "negative_total": "1500.00" + } +} +``` + +### 字段说明 + +| 字段 | 说明 | +|------|------| +| `source_type` / `source_label` | 业务来源及可读名称(`sales_order`、`payment_order` 等)。 | +| `source_id` | 业务单据 ID。 | +| `occurred_at` | 业务日期(如 `sales_date`、`return_date`、`receipt_date` 等)。 | +| `recorded_at` | 系统记录时间(`created_at`)。 | +| `status` / `status_label` | 业务单据当前状态(仅返回 `APPROVED` 的记录)。 | +| `counterparty` / `counterparty_name` | 客户或供应商信息。 | +| `positive_amount` / `negative_amount` | 金额正负值(字符串形式的 Decimal)。 | +| `cumulative_amount` | 当前记录输出前累计的对账金额(正负抵消)。 | +| `current_balance` | 余额表的最新应收/待付款快照;每条记录重复提供,便于表格展示。 | +| `arrears_amount` | 累欠金额,等于 `current_balance - cumulative_amount`,表示该记录时刻的实时欠款/待付款。 | +| `extra` | 预留字段,后续可扩展批次、仓库等信息。 | +| `summary` | 记录集中正负金额的求和,供前端快速展示。 | + +--- + +## 4. 业务规则 + +1. **审批依赖**:只有审批通过的单据才会出现在对账单中;审批完成后若需冲销则需走红冲流程。 +2. **数据一致性**:在审批事务内同步写入 `BalanceService` 及 `BalanceChangeRecord`,对账单直接基于这些模型计算,因此账面数据与业务状态保持一致。 +3. **幂等保障**:`BalanceService` 在审批中使用行级锁和 `select_for_update`,避免重复写入;对账单查询为只读操作,不影响事务。 +4. **扩展字段**:如需在对账单中加入汇总、备注、仓库信息,可在视图构建 `extra` 字段或通过 serializer context 注入新的统计字段。 + +--- + +## 5. 场景示例 + +### 5.1 客户账龄视图 + +前端可使用 `cumulative_amount`、`arrears_amount` 直接绘制折线或柱状图;由于排序固定为最新在前,可反向遍历展示账龄。 + +### 5.2 供应商对账 + +供应商 API 返回结构与客户完全一致,只是正负金额对应采购维度,方便在采购结算或付款审批前快速核对余款。 + +--- + +如需拓展分页、过滤、导出等能力,请在提交需求时同步更新本文件,保持文档的单一来源。 + +