1
0
forked from erp-dev/erp
Files
erpnew/docs/statements_pagination_performance_evaluation.md
2026-06-06 14:14:50 +08:00

202 lines
6.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 客户对账单 API 分页与性能评估
本文档评估当前 `/api/v1/customers/<id>/statements/` 接口的分页能力与性能瓶颈,供前后端共同讨论优化方案。
---
## 1. 当前现状
### 接口行为
- 每次请求返回**该客户的全部对账记录**(销售单 + 退货单 + 收款单 + 外部业务依据)
- 无分页参数,无日期过滤
- 排序固定:`occurred_at → recorded_at → source_id` 倒序
- 每条记录包含 `cumulative_amount`(滚动累计)和 `arrears_amount`(实时欠款)
### 数据规模参考
| 客户 | 销售单 | 退货单 | 收款单 | 总记录数 | 响应体积(估算) |
|------|--------|--------|--------|----------|-----------------|
| 木棉 | ~200 | 1 | ~50 | ~250 | ~100KB |
| 腾飞纺织 | ~1,500 | 9 | ~92 | ~1,600 | ~800KB |
| 张晓鹏 | ~3,100 | 6 | ~219 | ~3,350 | ~2MB |
---
## 2. 为什么当前无法直接分页
核心原因:**滚动累计字段(`cumulative_amount` / `arrears_amount`)依赖前序所有记录的计算结果。**
```
结欠[n] = 结欠[n-1] + 本行应收 - 本行已收
```
要展示第 N 页的 `arrears_amount`,必须先计算前 N-1 页所有记录的累加值。这意味着:
- 不能简单用 DB 的 `OFFSET/LIMIT`
- 不能跳页
- 后端必须从第一条开始逐条计算
---
## 3. 性能瓶颈分析
每次 API 调用的执行路径:
```
1. 4 次 DB 查询sales / returns / receipts / external_statements
└── 每次都 prefetch_related('items__product') 加载全部明细
2. Python 内存中合并 + 排序全部记录
3. 逐条遍历计算 running totals
4. 全量序列化为 JSON含 items 明细数组)
5. HTTP 响应传输
```
对于张晓鹏3350 条记录):
- DB 查询:~200ms4 次查询 + prefetch
- Python 排序 + 计算:~50ms
- JSON 序列化:~300ms
- 网络传输2MB取决于带宽
**`StatementRecordView`(单条查询)更严重**:为了查 1 条记录,构建了完整对账单再过滤。
---
## 4. 优化方案对比
### 方案 A游标分页推荐短期方案
**原理**:前端传 `page_size` + 上一页最后一条的 `cumulative_amount` 作为 cursor后端从 cursor 继续累加。
```
GET /customers/6/statements/?page_size=50
GET /customers/6/statements/?page_size=50&cursor=eyJjdW11bGF0aXZlIjoiMzE4OTEuMDAiLCJsYXN0X2lkIjoxODE0MX0=
```
| 优点 | 缺点 |
|------|------|
| 减少响应体积 | 后端仍需全量查询(但只序列化一页) |
| 前端按需加载 | 不能跳页,只能顺序翻页 |
| 向后兼容(不传参数 = 全量) | 需要前端配合改造 |
**后端改动**:中等。排序 + running total 计算后,只返回 cursor 之后的 N 条。
---
### 方案 B日期范围过滤
**原理**:前端传 `date_from` / `date_to`,后端只查询范围内的记录。
```
GET /customers/6/statements/?date_from=2026-01-01&date_to=2026-05-18
```
| 优点 | 缺点 |
|------|------|
| 实现简单 | running total 仍需从历史第一条开始算 |
| 减少返回数据量 | 或者放弃 running total 的准确性 |
| 前端可做"按年/按月"切换 | |
**后端改动**:低。在 DB 查询加 `occurred_at` 过滤即可。但 `cumulative_amount` 需要决定:
- 选项 1从第一条算到 date_to准确但慢
- 选项 2只在返回范围内累计快但不连续
---
### 方案 C延迟加载 items 明细
**原理**:默认不返回 `items` 数组,前端需要时单独请求。
```
GET /customers/6/statements/ → 不含 items
GET /customers/6/statements/?include_items=true → 含 items当前行为
GET /statements/record/?...&include_items=true → 单条含 items
```
| 优点 | 缺点 |
|------|------|
| 响应体积减少 50%+ | 前端需要额外请求获取明细 |
| 后端改动极小 | 如果前端表格需要展开明细,交互变复杂 |
| 完全向后兼容 | |
**后端改动**:极低。序列化时根据参数决定是否包含 items。
---
### 方案 D预计算快照长期方案
**原理**:同步时预计算每条记录的 `cumulative_amount` / `arrears_amount` 存入 DB查询时直接分页。
| 优点 | 缺点 |
|------|------|
| 真正的 DB 级分页 | 需要新表或新字段 |
| 查询性能最优 | 数据一致性维护复杂(任何单据变动需重算) |
| 支持跳页 | 开发成本高 |
**后端改动**:高。需要设计快照表 + 触发重算机制。
---
### 方案 E响应缓存
**原理**:对账单结果缓存 N 秒(或按数据版本号缓存),重复请求直接返回。
| 优点 | 缺点 |
|------|------|
| 零前端改动 | 数据有延迟(缓存过期前看不到最新) |
| 后端改动极小 | 大客户首次请求仍然慢 |
| 对高频刷新场景效果显著 | |
---
## 5. 推荐实施路径
```
第一步(立即可做):方案 C — 默认不返回 items减少 50%+ 响应体积
第二步(短期):方案 B — 加日期范围过滤,前端做"按月/按年"切换
第三步(中期):方案 A — 游标分页,前端改为滚动加载
第四步(按需):方案 E — 缓存,应对高频刷新
```
---
## 6. 需要前端确认的问题
1. **对账单表格是否需要一次展示全部记录?** 还是可以接受分页/滚动加载?
2. **items 明细是否默认展示?** 还是用户点击展开时才加载?
3. **是否需要"按月/按年"切换?** 如果需要running total 是否可以只在当前范围内累计?
4. **`cumulative_amount` / `arrears_amount` 是否是必须字段?** 如果前端不使用这两个字段,分页就变得简单很多。
5. **导出 Excel 功能是否需要全量数据?** 如果需要,导出可以走单独的异步接口。
---
## 7. 当前接口响应结构(供参考)
```json
{
"counterparty": 6,
"counterparty_name": "张晓鹏",
"records": [
{
"source_type": "external_sales_order",
"source_label": "外部销售单",
"source_id": 18141,
"occurred_at": "2026-05-18",
"recorded_at": "2026-05-18T21:45:03Z",
"status": 2,
"status_label": "已同步",
"positive_amount": "2436.00",
"negative_amount": "0.00",
"cumulative_amount": "31891.00",
"current_balance": "780454.00",
"arrears_amount": "748563.00",
"remarks": "...",
"items": [ ... ]
}
],
"summary": {
"positive_total": "26981509.80",
"negative_total": "26201055.80"
}
}
```