1
0
forked from erp-dev/erp
Files
erpnew/docs/external_finance_sync.md

300 lines
11 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.
# 外部财务同步设计
本文档记录 `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`:外部 statement-only 明细不绑定本地产品,固定为 `null`
- `product_name`:优先使用外部 `HpName`,否则回退为外部 `HpID`
- `quantity`
- `price`
- `unit`
- `color`:来源于外部 i-sale payload 的 `YanSe` 字段;若源端未下发则为空字符串
- `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`,避免外部历史数据误入库存/审批产品体系
### 1.3.1 i-sale → 对账单 item 完整字段映射
下表列出 `i-sale/by-customer` 返回的外部字段到对账单 API `records[].items[]` 内各字段的映射关系。
| 外部字段 (I_Sale) | item 字段 | 前端取值路径 | 类型 | 说明 |
|---|---|---|---|---|
| `DanJia` | `price` | `records[].items[].price` | 字符串 | 不含税单价,两位小数 |
| `ShuLiang` | `quantity` | `records[].items[].quantity` | 字符串 | 总数量,两位小数 |
| `JianShu` | `num_of_rolls` | `records[].items[].num_of_rolls` | 整数 | 条数(件数) |
| `HpName` | `product_name` | `records[].items[].product_name` | 字符串 | 品名;空时回退为 `HpID` |
| `YanSe` | `color` | `records[].items[].color` | 字符串 | 颜色;源端未下发则空串 |
| `SeHao` | `spec` | `records[].items[].spec` | 字符串 | 幅宽/规格;源端未下发则空串 |
| `HpID` | `external_product_id` | `records[].items[].external_product_id` | 字符串 | 外部货品 ID |
| `JiJiaDW` | `unit` | `records[].items[].unit` | 字符串 | 计价单位 |
| `SubID` | `external_sub_id` | `records[].items[].external_sub_id` | 整数/null | 外部明细行 ID |
注意事项:
- `price``quantity` 均为**字符串类型**,不是 number前端使用时需注意类型转换。
- 所有 item 字段位于 `records[].items[]` **数组内**,不在 record 顶层。
- `product_id` 固定为 `null`(外部业务单不绑定本地产品)。
- `quantity_of_rolls` 固定为空数组 `[]`(外部数据无逐条明细)。
- 同一 `BianHaoID` 下可能有多条明细行(不同产品/单价),`items` 数组会有多个元素。
## 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`),而不是新增第二套退款模型