# 客户对账单 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 查询：~200ms（4 次查询 + 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"
  }
}
```
