forked from erp-dev/erp
276 lines
9.6 KiB
Markdown
276 lines
9.6 KiB
Markdown
# 外部财务同步设计
|
||
|
||
本文档记录 `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`),而不是新增第二套退款模型 |