# 客户欠款统计口径

本文档定义客户欠款的计算方式，已通过 4 个客户的 ERP 导出数据交叉验证，全部精确对上。

---

## 1. 核心公式

```text
欠款 = AR - 收款 - 现场收款 - 折扣
     = (sales.JinE + sale_returns.JinE)
       - receipt.FkJinE
       - sales.SfJinE（按 BianHaoID 去重）
       - (sales.ZkJinE + sale_returns.ZkJinE + receipt.ZkJinE)
```

各项说明：

| 项目 | 数据来源 | 筛选条件 | 取值字段 | 去重规则 |
|------|----------|----------|----------|----------|
| 销售额 | I_Sale | DanType='成品销售单' AND BianHaoID LIKE 'XS%' | SUM(JinE) | 不去重，每行明细独立 |
| 退货抵扣 | I_Sale | DanType='客户退货单' AND BianHaoID LIKE 'XT%' | SUM(JinE) | 不去重，JinE 本身为负数 |
| 收款 | F_Skd | BianHaoID LIKE 'SK%' | SUM(FkJinE) | 不去重，每行独立 |
| 现场收款 | I_Sale | DanType='成品销售单' AND SfJinE != 0 | SUM(SfJinE) | **必须按 BianHaoID 去重** |
| 折扣 | I_Sale + F_Skd | 所有相关行 | SUM(ZkJinE) | 不去重 |

---

## 2. SfJinE 去重规则（关键）

`SfJinE` 是**单据头级别字段**，被冗余写到同一 BianHaoID 的每一行明细上。

示例（张晓鹏 XS20205788，5 行明细）：

```text
SubID=1  JinE=380      SfJinE=22,287  ← 同一个值
SubID=2  JinE=11,668   SfJinE=22,287  ← 重复
SubID=3  JinE=3,864    SfJinE=22,287  ← 重复
SubID=4  JinE=3,206    SfJinE=22,287  ← 重复
SubID=5  JinE=3,169    SfJinE=22,287  ← 重复
```

正确做法：每个 BianHaoID 只取一次 SfJinE = 22,287。
错误做法：直接 SUM 所有行 = 22,287 × 5 = 111,435（多算 4 倍）。

---

## 3. 不参与计算的字段

| 字段 | 所在表 | 原因 |
|------|--------|------|
| I_Sale.YfJinE | I_Sale | 全部为 0 |
| I_Sale.DingJin | I_Sale | 订金标记，实际入账已通过 SK/SfJinE 体现 |
| I_Sale.JinET | I_Sale | 信息字段，不参与欠款 |
| I_Sale.LjQK | I_Sale | 滚动累欠快照，不是增量 |
| I_Sale.DanJiaCB / JinECB / JinE_SL | I_Sale | 全部为 0 |
| F_Skd.YfJinE | F_Skd | XS 行与 I_Sale.JinE 重复；XT 行与退货重复 |
| F_Skd.LjJinE | F_Skd | 累计金额，不参与求和 |
| F_Skd.DjJinE / JyJinE | F_Skd | 订金/结余，不参与 |
| refund 桶 | F_Skd | 与 sale_returns.JinE 等值，不重复计入 |

---

## 4. 对应 API 接口

| 桶 | 接口 | 参数 |
|----|------|------|
| 销售单 | GET /api/v1/i-sale/by-customer | customer_id=KHxxxxx&category=sale |
| 销售退货 | GET /api/v1/i-sale/by-customer | customer_id=KHxxxxx&category=sale_return |
| 收款单 | GET /api/v1/finance/by-customer | customer_name_b64=xxx&record_types=receipt |

所有接口无分页限制，一次返回全集。

Base URL: `http://43.139.183.222:18080`
Auth: `Authorization: your-fixed-authorization-secret`

---

## 5. ERP 对账单滚动累计逻辑

ERP 对账单按时间排序，每行的"结欠金额"是滚动累计：

```text
结欠[n] = 结欠[n-1] + 本行应收金额 - 本行已收金额
```

- XS 销售单：应收 = JinE，已收 = SfJinE（现场收款，多数为 0）
- SK 收款单：应收 = 0（或负数折扣），已收 = FkJinE
- XT 退货单：应收 = JinE（负数），已收 = 0

---

## 6. 折扣与 ERP "应收金额"的关系

ERP 展示的"应收金额"是净应收（JinE - ZkJinE），我们从 I_Sale.JinE 拿到的是毛额。

```text
ERP 视角：  欠款 = Σ(应收_净) - Σ(已收)
API 视角：  欠款 = Σ(JinE) - Σ(FkJinE) - Σ(SfJinE去重) - Σ(ZkJinE)
```

两者数学等价。

---

## 7. 验证结果（全部精确对上）

| 客户 | KhID | API 欠款 | ERP 欠款 | 差额 |
|------|------|---:|---:|---:|
| 木棉 | KH01078 | 10,718 | 10,718 | 0 |
| 腾飞纺织 | KH00311 | 53,521 | 53,521 | 0 |
| 金庸 | KH00290 | 82,670 | 82,670 | 0 |
| 张晓鹏 | KH00308 | 753,897 | 753,897 | 0 |

各客户 SfJinE 情况：

| 客户 | SfJinE（去重后） | 笔数 | 说明 |
|------|---:|---:|------|
| 木棉 | 0 | 0 | 无现场收款 |
| 腾飞纺织 | 0 | 0 | 无现场收款 |
| 金庸 | 23,394 | 1 笔 | XS20214770 微信收款 |
| 张晓鹏 | 77,132 | 7 笔 | 2020-09 期间支付宝/微信收款 |

---

## 8. 伪代码

```python
def calculate_customer_debt(customer_id: str, customer_name_b64: str) -> float:
    # 1. 拉销售单
    sales = api.get("/api/v1/i-sale/by-customer",
                    customer_id=customer_id, category="sale")

    # 2. 拉销售退货
    sale_returns = api.get("/api/v1/i-sale/by-customer",
                           customer_id=customer_id, category="sale_return")

    # 3. 拉收款单
    receipts = api.get("/api/v1/finance/by-customer",
                       customer_name_b64=customer_name_b64, record_types="receipt")

    # 4. 计算 AR（每行明细的 JinE 独立求和）
    ar = sum(r.JinE for r in sales.records) + sum(r.JinE for r in sale_returns.records)

    # 5. 收款
    received_sk = sum(r.FkJinE for r in receipts.receipts)

    # 6. 现场收款（按 BianHaoID 去重，每单只取一次）
    seen = set()
    received_inline = 0.0
    for r in sales.records:
        if r.BianHaoID not in seen:
            seen.add(r.BianHaoID)
            received_inline += r.SfJinE

    # 7. 折扣
    discount = (sum(r.ZkJinE for r in sales.records)
                + sum(r.ZkJinE for r in sale_returns.records)
                + sum(r.ZkJinE for r in receipts.receipts))

    # 8. 欠款
    return ar - received_sk - received_inline - discount
```

---

## 9. 按时间窗口计算

```python
def calculate_debt_as_of(customer_id, customer_name_b64, cutoff_date):
    # 拉取同上，然后按 RiQi < cutoff_date 过滤
    sales_f = [r for r in sales if r.RiQi < cutoff_date]
    sr_f = [r for r in sale_returns if r.RiQi < cutoff_date]
    rcp_f = [r for r in receipts if r.RiQi < cutoff_date]

    ar = sum(r.JinE for r in sales_f) + sum(r.JinE for r in sr_f)
    received_sk = sum(r.FkJinE for r in rcp_f)

    seen = set()
    received_inline = 0.0
    for r in sales_f:
        if r.BianHaoID not in seen:
            seen.add(r.BianHaoID)
            received_inline += r.SfJinE

    discount = (sum(r.ZkJinE for r in sales_f)
                + sum(r.ZkJinE for r in sr_f)
                + sum(r.ZkJinE for r in rcp_f))

    return ar - received_sk - received_inline - discount
```

---

## 10. antd-demo 后端待修复的 Bug

### Bug 1：SfJinE 未去重

当前同步代码对 I_Sale 明细行直接求和 SfJinE，导致多行明细的订单被重复计入。

修复：按 BianHaoID 分组，每组只取一次 SfJinE。

### Bug 2：退货单金额放错列

退货金额被放到"已收金额"（negative_amount），应该放到"本单应收"（positive_amount=0, negative_amount=|JinE|，使得应收为负数）。

### Bug 3：退货单被冗余写入收款桶

9 条 XT 退货单同时出现在"外部销售退货单"和"收款单"两种类型里，导致收款行数多了 9 条。

修复：同步时 XT 退货只写入退货桶，不写入收款桶。

---

## 变更记录

| 日期 | 内容 |
|------|------|
| 2026-05-18 | 初版，木棉/腾飞纺织验证通过 |
| 2026-05-18 | 金庸验证发现 SfJinE 必须参与扣减 |
| 2026-05-19 | **最终版**：张晓鹏验证发现 SfJinE 必须按 BianHaoID 去重。四个客户全部精确对上 ERP |
