1
0
forked from erp-dev/erp

feat: correct first

This commit is contained in:
2026-05-19 23:41:34 +08:00
parent f409f2e6ee
commit b97e86257e
25 changed files with 3885 additions and 25 deletions

View File

@@ -0,0 +1,221 @@
# 客户欠款统计口径
本文档定义客户欠款的计算方式,已通过 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 的每一行明细上。
示例(张晓鹏 XS202057885 行明细):
```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 1SfJinE 未去重
当前同步代码对 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 |

View File

@@ -0,0 +1,257 @@
# Customer Finance API Handoff For AI Agent
本文档面向会中协作的 AI agent用于通过当前 API 配合财务人员核实指定客户的销售、销退、收款、退款数据。
## Scope
当前只使用这一个接口:
- `GET /api/v1/finance/by-customer`
当前业务口径已经固定:
- 销售:只认 `dbo.I_Sale``DanType = 成品销售单`
- 销退:只认 `dbo.I_Sale``DanType = 客户退货单`
- 收款:只认 `dbo.F_Skd``BianHaoID LIKE 'XS%'`
- 退款:只认 `dbo.F_Skd``BianHaoID LIKE 'XT%'`
## Access
Base URL:
```text
http://43.139.183.222:18080
```
Authorization header format:
```http
Authorization: <deployment auth secret>
```
说明:
- 本仓库不写入真实鉴权密钥。
- 实际值请从目标环境部署配置中的 `auth.secret` 获取。
- 当前系统仍然使用固定值鉴权,不是动态 token。
当前线上固定鉴权值:
- `Authorization: your-fixed-authorization-secret`
## Purpose
该接口用于:
1. 按客户名称精确抽取该客户的指定财务记录。
2. 在会议中快速切换不同记录类型,逐项与财务人员核对。
3. 根据需要区分“真实资金变动”和“挂账/欠款调整”。
## Request Shape
```http
GET /api/v1/finance/by-customer?customer_name_b64=...&record_types=...&include_cash_movement=...&include_adjustments=...
```
参数:
- `customer_name_b64`: 必填,客户名称的 Base64URL 编码。
- `record_types`: 可选,逗号分隔,支持 `sale``sale_return``receipt``refund`
- `include_cash_movement`: 可选,布尔值,默认 `true`
- `include_adjustments`: 可选,布尔值,默认 `false`
## Current Semantics
### 0. 用户可以查询哪些财务数据
当前支持查询的记录类型只有 4 类:
- `sale`:销售记录
- `sale_return`:销退 / 客户退货记录
- `receipt`:收款 / 回款记录
- `refund`:退款记录
说明:
- 可以单选一种类型,例如只查 `sale_return`
- 也可以多选多种类型,例如 `sale,receipt`
- 如果用户问的是“这个客户有哪些财务记录”这类宽泛问题,可以查询四类全部数据
- 但会中更建议显式指定类型,避免一次返回过多记录
### 1. `record_types`
可选值:
- `sale`
- `sale_return`
- `receipt`
- `refund`
示例:
- 只查销售:`record_types=sale`
- 只查销退:`record_types=sale_return`
- 只查收款:`record_types=receipt`
- 只查退款:`record_types=refund`
- 同时查销售和收款:`record_types=sale,receipt`
注意:
- 如果不传 `record_types`,当前实现会默认查询四类全部数据。
- 因为返回的是“当前条件命中的全部记录”,会中使用时建议始终显式传 `record_types`,避免一次取太多数据。
### 2. `include_cash_movement`
控制是否包含真实资金变动记录。
`receipt`
- `true` 时取 `FkJinE > 0`
`refund`
- `true` 时取 `FkJinE < 0`
### 3. `include_adjustments`
控制是否包含挂账/欠款调整记录。
`receipt``refund`
- `true` 时额外包含 `FkJinE = 0`
典型含义:
- `FkJinE > 0`:客户真实收款
- `FkJinE < 0`:客户真实退款
- `FkJinE = 0`:挂账、欠款、非现金调整
## Recommended Meeting Flow
建议会中按以下顺序核实:
1. 先查销售:确认该客户近期销售单是否齐全。
2. 再查销退:确认是否存在退货单以及备注原因。
3. 再查收款:先只看真实资金流入。
4. 再查退款:先只看真实资金流出。
5. 如果财务提到“这笔不是付款,是挂账冲减”,再打开 `include_adjustments=true` 复核。
## Ready-To-Use Examples
### Only Sales
```http
GET /api/v1/finance/by-customer?customer_name_b64=<BASE64URL_NAME>&record_types=sale
Authorization: <deployment auth secret>
```
### Only Sale Returns
```http
GET /api/v1/finance/by-customer?customer_name_b64=<BASE64URL_NAME>&record_types=sale_return
Authorization: <deployment auth secret>
```
### Only Receipts With Real Cash Movement
```http
GET /api/v1/finance/by-customer?customer_name_b64=<BASE64URL_NAME>&record_types=receipt&include_cash_movement=true&include_adjustments=false
Authorization: <deployment auth secret>
```
### Only Refunds With Real Cash Movement
```http
GET /api/v1/finance/by-customer?customer_name_b64=<BASE64URL_NAME>&record_types=refund&include_cash_movement=true&include_adjustments=false
Authorization: <deployment auth secret>
```
### Only Refund Adjustments
```http
GET /api/v1/finance/by-customer?customer_name_b64=<BASE64URL_NAME>&record_types=refund&include_cash_movement=false&include_adjustments=true
Authorization: <deployment auth secret>
```
### Sales And Receipts Together
```http
GET /api/v1/finance/by-customer?customer_name_b64=<BASE64URL_NAME>&record_types=sale,receipt&include_cash_movement=true&include_adjustments=false
Authorization: <deployment auth secret>
```
## Response Reading Guide
关键字段:
- `customer_name`
- `customer_ids`
- `record_types`
- `include_cash_movement`
- `include_adjustments`
- `total_count`
- `sales_count`
- `sale_returns_count`
- `receipts_count`
- `refunds_count`
- `sales`
- `sale_returns`
- `receipts`
- `refunds`
注意:
- 返回结构按类别拆分,不是一个混合数组。
- 只请求某一类时,其它类别通常为 `null` 或计数为 `0`
- 当前不会做按单聚合,返回的是原始命中记录。
## Known Limitations
当前接口只支持以下过滤维度:
1. 客户名称
2. 财务记录类型
3. 是否包含真实资金变动
4. 是否包含挂账/欠款调整
当前不支持:
1. 时间范围过滤
2. 分页
3. 排序参数
4. 数量限制
5. 写入、修改、删除、审批、纠正财务数据
6. 非简单累计之外的统计分析
7. 按季度、按月、按年等时间维度汇总
8. 环比、同比、趋势分析、占比分析、分组统计
9. 筛选后的分组累计、小计、分类汇总
因此,当前返回的是“指定客户 + 指定类型 + 指定资金口径”下的全部命中数据。
这里的“允许的简单累计”仅指:
- 对当前 API 已返回的同类记录做直接求和或直接计数
不允许的情况包括:
- 先按额外条件切片后再做累计
- 先按季度/月度分桶后再汇总
- 任何需要派生统计口径的复杂计算
## What To Tell Finance In The Meeting
可以直接这样解释:
1. 现在可以按客户名只查销售,或只查销退,或只查收款,或只查退款。
2. 收款和退款还可以区分成“真实资金变动”和“挂账调整”。
3. 但当前还不能按时间截取,也不能分页,所以结果是当前条件下的全量命中集。
## Practical Advice For The Agent
1. 会中尽量显式传 `record_types`,不要依赖默认全量。
2. 查收款/退款时,先用 `include_cash_movement=true&include_adjustments=false`,先看真实收付款。
3. 只有在财务明确提到“挂账”“冲减”“欠款”时,再补查 `include_adjustments=true`
4. 如果返回 `not_found`,先不要直接下结论,优先确认客户名称是否与主数据 `B_Khzl.KhName` 完全一致。
5. 如果用户问“可以查哪些财务数据”,应明确回答当前支持:销售、销退、收款、退款,并说明可以单选也可以多选。
6. 如果用户要求写入财务数据,必须拒绝,并说明当前 API 只支持查询。
7. 如果用户要求复杂统计,必须拒绝,并说明当前只支持原始命中记录查询,以及必要时基于返回结果做简单累计或计数。

View File

@@ -0,0 +1,117 @@
# 销售折扣数据ZkJinE接口更新说明
## 问题回顾
你们反馈 `/api/v1/i-sale/by-customer` 接口的 `ZkJinE` 字段始终返回 0导致销售折扣金额583 元部分)无法正确获取。
## 根因确认
经查证,问题属实但需要澄清:
- `/api/v1/i-sale/by-customer` 接口**确实返回了 `ZkJinE` 字段**,但该字段的数据来源是 `I_Sale` 表,而 `I_Sale` 表中该字段的值本身就是 0。这不是接口遗漏字段而是数据源的问题。
- **真实的销售折扣数据**存在于 `F_Skd` 表中 `BianHaoID``XS` 开头的记录里。
## 解决方案
我们已在 `/api/v1/finance/by-customer` 接口新增了 `sale_discount` 类型,用于从 `F_Skd` 表查询 XS% 行的财务数据(包含 `ZkJinE`)。
**此改动为纯增量更新,不影响任何现有接口和字段。**
---
## 对接方式
### 请求
```
GET /api/v1/finance/by-customer?customer_name_b64={base64url编码的客户名}&record_types=sale_discount
```
也可以和其它类型组合使用:
```
GET /api/v1/finance/by-customer?customer_name_b64={base64url编码的客户名}&record_types=sale,sale_discount,receipt
```
### record_types 可选值
| 值 | 说明 | 数据来源 |
|---|---|---|
| `sale` | 成品销售单 | I_Sale 表 |
| `sale_return` | 客户退货单 | I_Sale 表 |
| `receipt` | 收款记录SK% | F_Skd 表 |
| `refund` | 退款记录XT% | F_Skd 表 |
| **`sale_discount`** | **销售折扣记录XS%** | **F_Skd 表** ← 新增 |
### 响应结构
新增字段(在原有响应基础上):
```json
{
"mode": "customer_finance",
"customer_name": "客户名称",
"customer_ids": ["KH001"],
"record_types": ["sale_discount"],
"status": "active",
"snapshot_at": "2026-05-19T10:00:00Z",
"total_count": 5,
"sale_discounts_count": 5,
"sale_discounts": [
{
"BianHaoID": "XS20260101-001",
"KhID": "KH001",
"RiQi": "2026-01-01T00:00:00Z",
"KdRiQi": "2026-01-01T00:00:00Z",
"YfJinE": 1000.00,
"FkJinE": 800.00,
"ZkJinE": 200.00,
"JieSunFS": "...",
"YhID": "...",
"ZhaiYao": "...",
"BeiZhu": "..."
}
]
}
```
### sale_discount 每条记录包含的字段
| 字段 | 说明 |
|---|---|
| `BianHaoID` | 单据编号XS 开头) |
| `KhID` | 客户 ID |
| `RiQi` | 日期 |
| `KdRiQi` | 开单日期 |
| `YfJinE` | 应付金额 |
| `FkJinE` | 付款金额 |
| **`ZkJinE`** | **折扣金额(你们需要的字段)** |
| `JieSunFS` | 结算方式 |
| `YhID` | 银行 ID |
| `ZhaiYao` | 摘要 |
| `BeiZhu` | 备注 |
---
## 关于差额 816 的对账建议
根据你们的分析:
- receipt.ZkJinE = 233 → 通过 `record_types=receipt` 获取 ✅
- sales.ZkJinE = 583 → 现在通过 `record_types=sale_discount` 获取 ✅
建议对账时同时请求:
```
record_types=receipt,sale_discount
```
然后分别对 `receipts``sale_discounts` 数组中的 `ZkJinE` 字段求和,即可得到完整的折扣金额。
---
## 注意事项
1. `sale_discount` 不会包含在默认查询中。如果不传 `record_types` 参数,默认只返回 `sale, sale_return, receipt, refund` 四种类型(保持向后兼容)。
2. 请求 `sale_discount` 时不需要传 `include_cash_movement``include_adjustments` 参数,这两个参数只对 `receipt``refund` 类型生效。
3. `customer_name_b64` 使用 base64url 编码(无 padding与之前的用法一致。
如有疑问请随时联系。

View File

@@ -0,0 +1,166 @@
# 客户对账单 Bug 修复计划
本文档列出 antd-demo 后端对账单同步与计算中已确认的所有问题,按优先级排序。
---
## 问题总览
| # | 问题 | 影响金额(腾飞案例) | 影响金额(张晓鹏案例) | 优先级 |
|---|------|---:|---:|:---:|
| 1 | 退货单金额放错列 | 7,378 | 17,224 | P0 |
| 2 | 退货单被冗余写入收款桶 | 7,378 | 17,224 | P0 |
| 3 | SfJinE 未按 BianHaoID 去重 | 0 | 197,280 | P0 |
| 4 | 销售折扣F_Skd XS% 行 ZkJinE无法获取 | 2 | 583 | P1 |
---
## Bug 1退货单金额放错列
### 现象
"外部销售退货单"行的金额被放到了"已收金额"列,应该放到"本单应收"列(负数)。
| | antd-demo 当前 | ERP 正确值 |
|---|---|---|
| 本单应收 | 0 | -7,378负数 |
| 已收金额 | 7,378 | 0 |
### 影响
退货对欠款的减少效果被抵消,导致累欠偏高。
### 修复
同步 `sale_return` 类型的 `ExternalCustomerStatementOrder` 时:
- `positive_amount` = 0
- `negative_amount` = |JinE|(取绝对值,如 7,378
对账单视图中"本单应收"显示为负数(= positive_amount - negative_amount = -7,378
---
## Bug 2退货单被冗余写入收款桶
### 现象
同一笔 XT 退货单同时出现在"外部销售退货单"和"收款单"两种类型里。
腾飞纺织:收款单 101 行(应为 92多出的 9 行 = 退货单。
### 影响
收款行数虚增,但金额被退货冲减,导致退货效果完全抵消。
### 修复
同步逻辑中XT 退货单只写入退货桶(`external_sales_return_order`),不写入收款桶(`receipt_order`)。
检查点:`finance/by-customer?record_types=refund` 返回的 XT 记录不应该再创建 `ReceiptOrder`
---
## Bug 3SfJinE 未按 BianHaoID 去重
### 现象
`I_Sale.SfJinE` 是单据头级别字段,被冗余写到同一 BianHaoID 的每一行明细上。直接对所有明细行求和会重复计入。
张晓鹏案例:
- 7 笔现场收款订单,共 22 行明细
- 去重后 SfJinE = 77,132正确
- 直接 SUM = 274,412错误多算 197,280
### 影响
欠款被严重低估(张晓鹏少算了 197,280 元)。
### 修复
同步时按 BianHaoID 分组,每组只取一次 SfJinE
```python
seen = set()
total_sf = 0.0
for record in sales_records:
if record.BianHaoID not in seen:
seen.add(record.BianHaoID)
total_sf += record.SfJinE
```
---
## Bug 4销售折扣数据缺失
### 现象
`/api/v1/i-sale/by-customer` 返回的 `ZkJinE` 全部为 0。真实的销售折扣存在于 `F_Skd` 表的 XS% 行中,但当前只从 F_Skd 拉 SK%(收款)记录。
张晓鹏案例:
- receipt.ZkJinE = 233已正确获取
- sales.ZkJinE = 583存在于 F_Skd XS% 行,无法获取)❌
- 总差额 = 816 元
### 影响
欠款偏高 583 元(销售折扣未扣减)。影响较小,大部分客户折扣为 0 或极小。
### 解决方案(需 haobuye API 配合)
**方案 A推荐**:让 haobuye API 的 `finance/by-customer` 接口支持 `record_types=sale`
```http
GET /api/v1/finance/by-customer?customer_name_b64=xxx&record_types=sale
```
返回 F_Skd 中 `sType=1 AND BianHaoID LIKE 'XS%'` 的记录,包含 ZkJinE 字段。
**方案 B**:用 `db/select` 补查非零折扣
```http
GET /api/v1/db/select?table=dbo.F_Skd
&columns=BianHaoID,ZkJinE
&where=KhID='KH00308' AND BianHaoID LIKE 'XS%' AND ZkJinE<>0
&limit=1000
```
非零折扣行通常很少,不会触及 1000 行上限。
**方案 C**:让 haobuye API 修改 `i-sale/by-customer` 接口,按 BianHaoID join F_Skd 把真实 ZkJinE 填入返回值。
---
## 修复优先级建议
```text
第一步:修 Bug 1 + Bug 2退货相关影响最大纯后端改动
第二步:修 Bug 3SfJinE 去重,纯后端改动)
第三步:修 Bug 4需要 haobuye API 配合,影响最小)
```
---
## 修复后预期结果
以腾飞纺织为例:
| 项目 | 修复前 | 修复后 | ERP 正确值 |
|------|---:|---:|---:|
| 累欠金额 | 60,899 | 53,521 | 53,521 |
以张晓鹏为例(截至 5-18
| 项目 | 修复前 | 修复后(不含 Bug4 | 修复后(含 Bug4 | ERP |
|------|---:|---:|---:|---:|
| 累欠金额 | 748,563 | 748,330 | 747,747 | 747,747 |
---
## 验证方法
修复后用以下客户验证:
1. **木棉KH01078**— SfJinE=0无折扣预期 10,718
2. **腾飞纺织KH00311**— SfJinE=0折扣=2预期 53,521
3. **金庸KH00290**— SfJinE=23,3941笔1行不涉及去重折扣=13,009预期 82,670
4. **张晓鹏KH00308**— SfJinE=77,1327笔22行必须去重折扣=816预期与 ERP 实时值一致

View File

@@ -177,6 +177,7 @@
- `bank_account`:可选,引用 `basic_info.BankAccount`,用于记录具体的付款账户。
- `markup`:可选字符串,用于记录票据附言;与 `remarks`(内部备注)区分。
- `discount_amount`:可选,默认 0允许大于 `amount`(表示折扣大于实付);必须 ≥ 0。
- 列表/详情响应额外包含 `is_external_source``external_source_id`,用于标识是否来自外部系统同步以及对应外部记录 ID。
- `settlement_amount = amount + discount_amount`,所有余额、对账及汇总均基于结算金额。
- `amount` 必须大于 0。返回 201 + 创建的记录,响应中会包含 `discount_amount``settlement_amount`
@@ -214,6 +215,7 @@
- `bank_account`:可选,引用到账银行账户。
- `discount_amount`:可选,默认 0可大于 `amount`,但必须 ≥ 0。
- `markup`:可选,记录回单附言,默认留空。
- 列表/详情响应额外包含 `is_external_source``external_source_id`,用于外部收款/退款同步关联与审计。
- `settlement_amount` 为响应只读字段(`amount + discount_amount`),对账及余额只会计算结算金额。
### 5.2 审批
@@ -290,7 +292,8 @@
- 固定按 `occurred_at -> recorded_at -> source_id` 倒序输出,不提供排序参数。
- `positive_amount` / `negative_amount` 统一表示余额增减;`cumulative_amount``current_balance``arrears_amount` 均冗余在每条记录中,前端可直接使用。
- 余额快照来自 `BalanceService`,每次请求只查询一次,保证与审批事务一致
- 客户对账单在存在外部 statement-only 业务依据时,可能出现 `external_sales_order` / `external_sales_return_order` 两种新的 `source_type`
- 客户对账单在存在外部 statement-only 业务依据时,`current_balance` / `arrears_amount` 会基于“本地余额 + 外部业务来源净额”做临时展示口径修正;余额接口本身仍返回持久化 `CustomerBalance.balance`
- 单条查询接口需提供 `counterparty_type``counterparty_id``order_type``order_id` 四个 Query 参数,返回 schema 与列表一致,仅 `records` 中包含匹配记录。
---

View File

@@ -0,0 +1,276 @@
# 外部财务同步设计
本文档记录 `ReceiptOrder` 对接 HaoBuYe 外部财务 API 的当前实现口径,以及后续批量同步的预备方案。
## 1. 当前已实现能力
- 单客户同步命令:`python manage.py sync_external_customer_finance <客户名> --operator-id <Employee.id>`
- 仅同步到 `business.ReceiptOrder`
- `PaymentOrder` 当前不参与外部财务同步
- 命令完成后,会继续同步该客户的外部 `sale` / `sale_return` 业务记录,用于对账单计算
### 1.0 使用前前置条件
在首次使用前,需要确认以下条件:
- 已完成数据库迁移,使 `ReceiptOrder` / `PaymentOrder` 拥有 `is_external_source``external_source_id` 字段
- 已配置外部 API 访问参数:`HAOBUYE_API_BASE_URL``HAOBUYE_API_AUTHORIZATION`
- 已确定一个本地经办人 `Employee.id`,用于创建并审批同步产生的 `ReceiptOrder`
推荐执行:
```bash
python manage.py migrate
```
如果只想确认本次相关迁移,也可以先查看:
```bash
python manage.py showmigrations business
```
本次同步依赖的迁移文件为:
- `business.0028_payment_receipt_external_source_fields`
- `business.0029_externalcustomerstatementorder`
### 1.0.1 外部业务记录的落库策略
外部 `sale` / `sale_return` 当前不会落到核心 `SalesOrder` / `SalesReturnOrder`,而是落到专用模型:
- `business.ExternalCustomerStatementOrder`
设计目的:
- 只服务 `build_customer_statement(...)` 的对账计算
- 避免把外部历史业务单塞进当前 ERP 的库存/审批流程
- 不触发库存出入库
- 不修改持久化的 `CustomerBalance`
说明:
- 对账单计算时,会把这部分外部业务记录纳入 statement records
- 同时会在 statement 内部临时调整当前余额口径,使“本地收款已同步,但销售历史来自外部”的客户也能得到兼容结果
### 1.0.2 i-sale 数据实际存放位置
外部 `i-sale/by-customer` 拉到的 `sale` / `sale_return` 数据,当前不会展开成核心 `SalesOrder` / `SalesReturnOrder`,而是存到:
- 模型:`business.ExternalCustomerStatementOrder`
其中关键字段为:
- `category``sale``sale_return`
- `external_customer_id`:外部客户 ID例如 `KH00308`
- `external_source_id`:外部业务单号,对应 `BianHaoID`
- `occurred_at`:业务日期,对应 `RiQi`
- `recorded_at`:录单时间,对应 `KdRiQi`
- `total_amount`:按同一 `BianHaoID` 聚合后的金额
- `remarks`:由外部备注字段整理后的文本备注
明细与扩展信息使用 JSONField 保存:
- `items_payload`:明细快照,按外部单据的多行 `I_Sale` 记录聚合后保存
- `extra_payload`:扩展数据,如 `dan_type``raw_count`
当前 `items_payload` 中保留的信息包括:
- `product_id`:若能通过 `HpID -> Product.human_id` 匹配到本地产品,则写入本地产品 ID否则为 `null`
- `product_name`:优先本地产品名,否则回退为外部 `HpID`
- `quantity`
- `price`
- `unit`
- `spec`
- `num_of_rolls`
- `external_sub_id`
- `external_product_id`
### 1.1 字段映射
#### 收款(`SK%`
- `BianHaoID` -> `ReceiptOrder.external_source_id`
- `FkJinE` -> `ReceiptOrder.amount`
- `ZkJinE` -> `ReceiptOrder.discount_amount`
- `JieSunFS` -> `ReceiptOrder.markup`
- `RiQi`(回退 `KdRiQi`-> `ReceiptOrder.receipt_date`
#### 退款(`XT%`
- `BianHaoID` -> `ReceiptOrder.external_source_id`
- `YfJinE` -> `ReceiptOrder.amount`
- `discount_amount` 固定为 `0`
- `JieSunFS` -> `ReceiptOrder.markup`
- `RiQi`(回退 `KdRiQi`-> `ReceiptOrder.receipt_date`
说明:
- `XT%` 当前按负金额 `ReceiptOrder` 落库,表示客户退款。
- 外部 `refund` 若结算金额为 `0`,当前会跳过并记入 summary不落库。
- 为兼容外部“纯折扣收款”(如 `FkJinE=0, ZkJinE>0`),同步层直接创建 `ReceiptOrder` 模型并复用审批逻辑,不走普通创建 API 的 `amount != 0` 限制。
### 1.2 幂等规则
- 幂等键:`merchant + external_source_id + is_external_source=True`
- 若本地已存在同一 `external_source_id` 且核心字段一致,则跳过
- 若同一 `external_source_id` 已存在但金额/日期/客户不一致,则报冲突错误,避免静默脏写
外部业务记录statement-only使用单独幂等键
- `merchant + category + external_source_id`
其中:
- `category = sale | sale_return`
- `external_source_id = BianHaoID`
### 1.3 外部业务单来源字段
命令在同步收款/退款后,会继续调用:
- `GET /api/v1/i-sale/by-customer?customer_id=...&category=sale`
- `GET /api/v1/i-sale/by-customer?customer_id=...&category=sale_return`
当前聚合策略:
-`BianHaoID` 聚合同一张外部业务单
- `RiQi` -> statement `occurred_at`
- `KdRiQi` -> statement `recorded_at`
- `JinE` 聚合为外部业务单金额
- `HpID` 优先匹配本地 `Product.human_id`
- 若未匹配到本地产品,仍保留 `HpID` 作为对账单 item 展示标识,不阻塞同步
## 1.4 已同步客户如何补齐 i-sale 业务依据
对于“已经同步过收款/退款,但当时还没有 i-sale 接口”的客户,不需要删除任何旧数据,也不需要回滚收款单。
正确做法是:
1. 先执行数据库迁移
2. 重新执行同一个单客户同步命令
例如:
```bash
python manage.py migrate
python manage.py sync_external_customer_finance "张晓鹏" --operator-id 12 --allow-create-customer
```
原因:
- 已存在的 `ReceiptOrder` 会按既有幂等键跳过,不会重复插入
- 缺失的 `ExternalCustomerStatementOrder` 会被补齐
- 已存在的外部业务来源单,也会按 `merchant + category + external_source_id` 跳过
因此,补齐老客户时不需要先删除收款/退款数据;“先删再重灌”既不科学,也会增加误删风险。
建议:
- 如果只是想先确认将会补哪些业务依据,可先用 `--dry-run`
- 如果外部同一 `BianHaoID` 的核心字段与本地已存快照不一致,命令会报冲突错误,而不是静默覆盖
- 真正需要人工处理的场景,应优先核对外部数据是否修订过,而不是直接删除本地记录
## 2. 当前命令参数
```bash
python manage.py sync_external_customer_finance "张晓鹏" --operator-id 12 --allow-create-customer
```
参数说明:
- `customer_name`:外部客户名称,精确匹配
- `--operator-id`:本地经办人 `Employee.id`。若不传,则回退使用 `HAOBUYE_FINANCE_SYNC_OPERATOR_ID`
- `--allow-create-customer`:本地没有该客户时自动创建;默认关闭
- `--dry-run`:只校验,不写入;默认关闭
命令行为说明:
- 如果既没有传 `--operator-id`,也没有配置 `HAOBUYE_FINANCE_SYNC_OPERATOR_ID`,命令会直接报错退出
- 如果客户不存在且未传 `--allow-create-customer`,命令会直接报错退出
- 如果外部返回 `status=not_found`,命令不会写入任何数据,而是返回一份空结果 summary
- 如果本地已存在同一个 `external_source_id` 且字段一致,会跳过,不会重复插入
- 如果本地已存在同一个 `external_source_id` 但核心字段不一致,会报冲突错误,避免静默覆盖
- 收款/退款同步完成后,命令会继续同步外部 `sale` / `sale_return``ExternalCustomerStatementOrder`
常用示例:
```bash
# 正式同步
python manage.py sync_external_customer_finance "张晓鹏" --operator-id 12 --allow-create-customer
# 只做拉取和校验,不写入
python manage.py sync_external_customer_finance "张晓鹏" --operator-id 12 --allow-create-customer --dry-run
# 使用环境变量里的默认 operator
python manage.py sync_external_customer_finance "张晓鹏" --allow-create-customer
```
命令成功后会输出 summary典型字段包括
- `customer_name`
- `external_customer_id`
- `receipts_seen`
- `refunds_seen`
- `sales_seen`
- `sale_returns_seen`
- `created_count`
- `skipped_existing_count`
- `skipped_zero_settlement_count`
- `external_business_created_count`
- `external_business_skipped_existing_count`
- `dry_run_count`
- `created_receipt_ids`
- `created_external_business_ids`
- `skipped_external_ids`
对于已同步过财务数据、再次补齐 i-sale 的客户,常见 summary 形态是:
- `created_count = 0`
- `skipped_existing_count > 0`
- `external_business_created_count > 0`
这表示:
- 收款/退款因为已存在而被跳过
- 但外部业务依据statement-only被成功补录
也可通过环境变量预配置:
- `HAOBUYE_API_BASE_URL`
- `HAOBUYE_API_AUTHORIZATION`
- `HAOBUYE_API_TIMEOUT_SECONDS`
- `HAOBUYE_FINANCE_SYNC_OPERATOR_ID`
推荐最小配置示例:
```env
HAOBUYE_API_BASE_URL=http://43.139.183.222:18080
HAOBUYE_API_AUTHORIZATION=your-fixed-authorization-secret
HAOBUYE_API_TIMEOUT_SECONDS=30
HAOBUYE_FINANCE_SYNC_OPERATOR_ID=12
```
## 3. 已预留的批量能力
代码中已实现批量入口:
- `sync_customer_finance_batch(...)`
设计原则:
- 批量模式只负责“枚举客户”
- 单客户同步逻辑完全复用 `sync_customer_finance(...)`
- 当前尚未暴露为 command / celery / cron
### 3.1 批量模式预期流程
1. 调用外部 `GET /api/v1/customers?cursor_id=...`
2. 拿到最多 `10` 个客户
3. 对每个客户执行 `sync_customer_finance(...)`
4. 整页处理完毕后再推进 `next_cursor_id`
### 3.2 后续建议
- 若进入定时任务阶段,建议将外部 `cursor_id` 持久化到 `api_v1.DataSync`
- 若需要失败补偿,建议单独增加 finance failure model记录 `customer_name / external_source_id / run_date / error`
- 若前端后续需要区分“收款/退款”,建议在 `ReceiptOrder` 列表 API 上增加只读过滤参数(例如 `amount_sign=positive|negative`),而不是新增第二套退款模型

1120
docs/haobuye-api.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,31 @@
## 1. 功能概览
- **客户对账单**:聚合指定客户的销售单、销售退货单、收款单,统一展示正负金额、当前余额以及累欠金额。
- **客户对账单**:聚合指定客户的销售单、销售退货单、收款单,以及外部来源的 statement-only 业务依据,统一展示正负金额、当前余额以及累欠金额。
- **供应商对账单**:聚合指定供应商的采购单、采购退货单、付款单,展示待付金额的增减与余额。
- **排序规则**:固定按 `occurred_at -> recorded_at -> source_id` 倒序排列,不支持外部修改。
- **金额方向**
- 客户:销售单记入 `positive_amount`(增加应收),销售退货/收款记入 `negative_amount`(减少应收)。
- 供应商:采购单记入 `positive_amount`(增加待付),采购退货/付款记入 `negative_amount`(减少待付)。
- **余额来源**调用 `BalanceService`,每次请求仅查询一次,并冗余在每条记录中,便于前端表格或统计组件使用
- **余额来源**供应商对账单直接使用余额表;客户对账单在存在外部 statement-only 业务依据时,会基于余额表做一次“仅用于 statement 展示”的临时口径修正
### 1.1 外部 statement-only 业务依据
当客户的历史 `sale` / `sale_return` 来自外部系统、且不希望落入核心 `SalesOrder` / `SalesReturnOrder` 时,系统会把它们存入:
- `business.ExternalCustomerStatementOrder`
这些记录:
- 只参与客户对账单计算
- 不参与库存流转
- 不参与核心销售单审批链
- 不修改持久化 `CustomerBalance`
在对账单响应中,它们会以以下 `source_type` 出现:
- `external_sales_order`
- `external_sales_return_order`
---
@@ -41,8 +59,8 @@
```json
{
"customer": 6,
"customer_name": "杭州零售商",
"counterparty": 6,
"counterparty_name": "杭州零售商",
"records": [
{
"source_type": "sales_order",
@@ -104,18 +122,58 @@
| `cumulative_amount` | 当前记录输出前累计的对账金额(正负抵消)。 |
| `current_balance` | 余额表的最新应收/待付款快照;每条记录重复提供,便于表格展示。 |
| `arrears_amount` | 累欠金额,等于 `current_balance - cumulative_amount`,表示该记录时刻的实时欠款/待付款。 |
| `remarks` | 统一备注字段。所有 record 都会返回;无备注时为空字符串。 |
| `extra` | 预留字段,后续可扩展批次、仓库等信息。 |
| `summary` | 记录集中正负金额的求和,供前端快速展示。 |
对于外部 statement-only 业务依据,`extra` 当前会额外包含:
- `external_source_id`
- `external_customer_id`
- `settlement_method`
对客户对账单来说,当前还可能出现两种新的 `source_type`
- `external_sales_order`
- `external_sales_return_order`
`remarks` 的来源统一如下:
- 本地采购/销售/退货/收付款单:直接取对应单据的 `remarks`
- 外部 statement-only 业务依据:取 `ExternalCustomerStatementOrder.remarks`,其中会包含同步时整理过的 `BeiZhu` / `BeiZhuC` / `BeiZhuD` / `MeoD` 文本
---
## 4. 业务规则
1. **审批依赖**:只有审批通过的单据才会出现在对账单中;审批完成后若需冲销则需走红冲流程。
2. **数据一致性**:在审批事务内同步写入 `BalanceService``BalanceChangeRecord`,对账单直接基于这些模型计算,因此账面数据与业务状态保持一致。
2. **数据一致性**:在纯本地业务场景下,对账单直接基于审批后的业务单据和余额表计算,因此账面数据与业务状态保持一致。
3. **幂等保障**`BalanceService` 在审批中使用行级锁和 `select_for_update`,避免重复写入;对账单查询为只读操作,不影响事务。
4. **扩展字段**:如需在对账单中加入汇总、备注、仓库信息,可在视图构建 `extra` 字段或通过 serializer context 注入新的统计字段。
### 4.1 客户余额口径边界
这是本次修正最重要的边界说明。
当客户存在 `ExternalCustomerStatementOrder` 时:
- `GET /customers/<id>/balance/` 仍返回数据库中持久化的 `CustomerBalance.balance`
- `GET /customers/<id>/statements/` 中每条记录的 `current_balance` / `arrears_amount`,会基于“本地余额 + 外部业务来源净额”做一次临时口径修正
因此,在存在外部 statement-only 业务依据的客户上:
- 余额接口和对账单接口的余额展示,可能暂时不完全一致
这是当前设计的有意结果,因为:
- 我们需要让对账单拥有完整的“应收业务依据”
- 但又不希望把外部历史业务单写入核心销售链,进而污染库存与审批语义
换句话说:
- `CustomerBalance` 代表 ERP 内部正式余额账
- 客户对账单在此场景下代表“兼容外部历史业务依据后的展示口径”
---
## 5. 场景示例