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,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`),而不是新增第二套退款模型