# 外部财务同步设计

本文档记录 `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`），而不是新增第二套退款模型