# 外部财务同步设计 本文档记录 `ReceiptOrder` 对接 HaoBuYe 外部财务 API 的当前实现口径,以及后续批量同步的预备方案。 ## 1. 当前已实现能力 - 单客户同步命令:`python manage.py sync_external_customer_finance <客户名> --operator-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`),而不是新增第二套退款模型