# 外部 records 同步到 PrintingOrder / PrintingJob 设计

日期：2026-03-20

## 目标

将外部服务 `GET http://43.139.183.222:18080/api/v1/records` 返回的增量 records，
按每 5 分钟一次的频率同步到本系统：

- 一组相同 `BianHaoID` 的 records -> 1 条 `printing.PrintingOrder`
- 每条 record -> 1 条 `printing.PrintingJob`

本设计文档用于固化本次实现的字段映射和兜底规则，后续若业务修正，以此为追溯基线。

## 外部 records 样例判断

外部 `records` 不是“订单主表”，而是“印花明细表”。

判断依据：

- 同一个 `BianHaoID` 下会出现多条 record
- 每条 record 含数量、尺寸、单位、产品名等明细信息
- 因此单条 record 更接近本系统的 `PrintingJob`

## 同步主规则

1. 按 `BianHaoID` 对本次拉到的 records 分组
2. 每个分组 upsert 一条 `PrintingOrder`
3. 分组内每条 record 按 `BianHaoID + YanSe` 覆盖 upsert 一条 `PrintingJob`
4. 仅当本地全部写入成功后，才推进外部 cursor

## 字段映射

### 一、外部分组 -> PrintingOrder

| 外部字段 | 含义 | 本系统字段 | 规则 |
| --- | --- | --- | --- |
| `BianHaoID` | 订单编号 | `PrintingOrder.external_order_id` | 分组键；原样保存 |
| `customer.KhName` | 客户名 | `PrintingOrder.customer` | 按本商户 `Customer.name` 精确匹配，取第一条 |
| `KhID` | 外部客户 ID | `PrintingOrder.external_customer_id` | 原样保存 |
| `customer.KhName` | 外部客户名 | `PrintingOrder.external_customer_name` | 原样保存 |
| `HpName` | 布料名 | `PrintingOrder.fabric` | 原样保存到订单面料字段 |
| `area` | 地区/区域 | `PrintingOrder.area` | 原样保存；取不到时回退为空字符串 `""` |
| `CaoZY` | 外部操作员/业务员 | `PrintingOrder.created_by` / `PrintingOrder.external_employee_name` | 若能匹配到内部员工且员工绑定了系统用户，则 `created_by` 取该用户，`external_employee_name` 置空；否则 `created_by` 取固定同步用户，`external_employee_name` 保留原文 |
| `SHDZ` | 布料来源 + 工艺 | `PrintingOrder.fabric_source` / `PrintingOrder.craft` | 以空白字符 split；第 1 段为 `fabric_source`，剩余重新拼接为 `craft` |
| `SeHao` | 幅宽 | `PrintingOrder.width` | 原样保存 |
| `MeoA` | 曲线 | `PrintingOrder.curve` | 原样保存 |
| `FidJ` | 电脑位置 | `PrintingOrder.position` | 取分组首条 record 的原始值 |
| `BeiZhu` | 滚筒注意事项 | `PrintingOrder.rolling_warn` | 原样保存 |
| `KdRiQi` | 下单日期 | `PrintingOrder.outgoing_date` | 按 ISO 时间解析并保存 |
| 分组首条原始数据 | 外部来源快照 | `PrintingOrder.external_raw` | 保存 order 级别的原始数据，便于追溯 |

补充说明：

- 外部 `HpName` 已作为布料名使用，直接写入 `PrintingOrder.fabric`
- 外部 `area` 直接写入 `PrintingOrder.area`；若外部未返回或为空，则统一写入空字符串
- `BianHaoKD`、`RiQi` 等当前未单独属性化的字段，保留在 `external_raw` 中
- `BianHaoKD` 当前样例值类似 `"1.27"`，不按日期强解析

### 二、单条 record -> PrintingJob

| 外部字段 | 含义 | 本系统字段 | 规则 |
| --- | --- | --- | --- |
| `ID` | 明细顺序 ID | `PrintingJob.original_id` | 保存最近一次覆盖该任务的外部明细 ID，便于追溯 |
| `YanSe` | 外部产品名 | `PrintingJob.product` / `PrintingJob.external_product_name` | 若能按本商户 `Product.name` 精确匹配，直接绑定该产品且不再拉图；若本地不存在，则先创建产品、拉图并上传到 storage，成功后绑定该产品 |
| `ShuLiang` | 下单数量 | `PrintingJob.quantity` | 按整数解析；当前预期外部值为整数字符串或 `10.00` 这类可安全转整数的值 |
| `JiJiaDW` | 单位 | `PrintingJob.unit` | 去掉前导 `/` 后保存，例如 `/段` -> `段` |
| `ShuLiangZ` | 一段尺寸 | `PrintingJob.size` | 原样保存 |
| `BeiZhuC` | 件数 | `PrintingJob.pieces` | 提取其中的整数，例如 `10件` -> `10` |
| `YanSe` | 外部产品名 | `PrintingJob.description` | 本次不单独映射到 description；原始值由 `product.name` 或 `external_product_name` / `external_raw` 表达 |
| 单条原始数据 | 外部来源快照 | `PrintingJob.external_raw` | 保存 job 级别完整原始 record |

补充说明：

- `PrintingJob` 的覆盖唯一性按 `PrintingOrder.external_order_id + YanSe` 处理
- 若同一外部订单下再次出现相同 `YanSe`，则更新已有 `PrintingJob`，不再新增
- 更新时 `original_id` 会覆盖为最新外部 record 的 `ID`

## 员工绑定规则

1. 读取外部 `CaoZY`
2. 在同步用户所属商户下按 `Employee.name` 精确匹配
3. 若匹配到的 `Employee.sys_user` 存在：
   - `PrintingOrder.created_by = employee.sys_user`
   - `PrintingOrder.external_employee_name = ""`
4. 若未匹配到，或员工未绑定系统用户：
   - `PrintingOrder.created_by = settings.PRINTING_EXTERNAL_SYNC_USER_ID`
   - `PrintingOrder.external_employee_name = 外部原文`

## 客户绑定规则

1. 读取 `customer.KhName`
2. 在同步用户所属商户下按 `Customer.name` 精确匹配
3. 取第一条结果作为 `PrintingOrder.customer`
4. 同时保留：
   - `external_customer_id`
   - `external_customer_name`

保护规则：

- 若本地找不到客户，则本次同步失败，不推进外部 cursor
- 原因：`PrintingOrder.customer` 在业务上不可空，且当前未授权把客户兜底到固定客户

## 产品绑定与图片规则

1. 读取 `YanSe`
2. 在同步用户所属商户下按 `Product.name` 精确匹配
3. 匹配成功：
   - `PrintingJob.product = 匹配到的产品`
   - 不请求外部图片 API
   - `PrintingJob.external_product_name = ""`
4. 匹配失败：
   - 先创建本地 `Product`
   - 再调用外部图片 API：
     - `GET /api/v1/image?name_b64=<base64url(YanSe)>`
   - 将返回的 base64 图片内容写入 `Product.image`
   - 由 Django storage 自动上传到七牛
   - 成功后 `PrintingJob.product = 新建产品`
   - `PrintingJob.external_product_name = ""`
5. 若产品创建或图片拉取/上传失败：
   - 该条外部 `record.ID` 记为失败
   - 该 record 不创建 `PrintingJob`
   - 本批次其它成功 records 继续处理
   - cursor 仍然推进

## 外部接口与游标策略

外部配置写入 `settings.py`，不经 `.env`：

- `PRINTING_EXTERNAL_RECORDS_BASE_URL`
- `PRINTING_EXTERNAL_RECORDS_AUTHORIZATION`
- `PRINTING_EXTERNAL_SYNC_USER_ID`
- `PRINTING_EXTERNAL_SYNC_PRODUCT_CATEGORY_ID`

游标策略：

1. 拉取 records 时显式使用 `update_cursor=false`
2. 本地成功 records / 失败 records 都处理完成后，调用 `/api/v1/cursor/set` 推进到 `last_record_id`
3. 失败 records 需单独落库，便于后续按外部 `record.ID` 补偿

## 调度策略

Celery Beat 每 5 分钟触发一次。

单次请求固定：

- `limit=100`
- 不传范围参数

即依赖外部 cursor 做增量拉取。

## 本次新增字段计划

### PrintingOrder

- `external_order_id`
- `external_customer_id`
- `external_customer_name`
- `external_employee_name`
- `external_raw`

### PrintingJob

- `external_product_name`
- `external_raw`

### API v1 失败记录

- `PrintingExternalSyncFailure`
  - `external_record_id`
  - `external_order_id`
  - `product_name`
  - `error`
  - `raw`

## 已接受的业务风险

1. `BianHaoID` 是否跨外部系统唯一，不由本系统负责纠偏
2. 客户按名称取第一条，存在误绑风险，但这是本期接受的业务策略
3. 产品若本地不存在，则依赖“创建产品 + 拉图上传”流程；该流程失败时只影响对应 `record.ID`
