# 外部 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` / `PrintingOrder.kd_riqi` | 按中国时间解析并保存;即使外部字符串带 `Z`,也按本地业务时间解释;`kd_riqi` 用于 API 展示、筛选和排序 | | 分组首条原始数据 | 外部来源快照 | `PrintingOrder.external_raw` | 保存 order 级别的原始数据,便于追溯 | 补充说明: - 外部 `HpName` 已作为布料名使用,直接写入 `PrintingOrder.fabric` - 外部 `area` 直接写入 `PrintingOrder.area`;若外部未返回或为空,则统一写入空字符串 - `BianHaoKD`、`RiQi` 等当前未单独属性化的字段,保留在 `external_raw` 中 - `BianHaoKD` 当前样例值类似 `"1.27"`,不按日期强解析 - 外部 `KdRiQi` 样例可能形如 `2026-07-08T17:22:22Z`,但业务语义为中国时间 `2026-07-08 17:22:22`,不能按 UTC 解释,否则 API 展示会偏移 8 小时 ## 历史 `kd_riqi` 回填命令 新增字段 `PrintingOrder.kd_riqi` 后,新同步数据会自动写入该字段。历史数据的 `KdRiQi` 已保存在 `external_raw.first_record.KdRiQi`,可用以下 management command 批量回填。 命令文件: - `printing/management/commands/backfill_printing_order_kd_riqi.py` 默认处理范围: - 只处理 `PrintingOrder.kd_riqi IS NULL` 的订单 - 从 `external_raw.first_record.KdRiQi` 提取并解析时间 - 解析成功则批量写入 `kd_riqi` - 缺失或解析失败的数据跳过并计数 - 不覆盖已有 `kd_riqi` - 使用 `bulk_update(['kd_riqi'])`,不会更新 `updated_at` 历史错误值修正模式: - 传 `--overwrite` 后,不再限定 `kd_riqi IS NULL` - 所有存在 `external_raw` 且能读取 `external_raw.first_record.KdRiQi` 的订单都会重新解析并覆盖 `kd_riqi` - 传 `--update-outgoing-date` 后,会同时覆盖 `outgoing_date` - 该模式用于修正早期把外部 `KdRiQi` 误按 UTC 解析导致的 8 小时偏移 推荐先 dry-run: ```bash docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web uv run python manage.py backfill_printing_order_kd_riqi --dry-run ``` 确认统计后批量执行: ```bash docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web uv run python manage.py backfill_printing_order_kd_riqi ``` 常用参数: - `--dry-run`:只统计,不写入 - `--batch-size 1000`:批量读取和批量写入大小,默认 `1000` - `--limit 5000`:最多处理多少条候选记录 - `--merchant-id 1`:限定商户 - `--external-order-id KD20453713`:限定外部订单编号,适合单条验证 - `--overwrite`:覆盖已有 `kd_riqi`,用于修正历史按 UTC 解析导致的偏移数据 - `--update-outgoing-date`:同时用 `KdRiQi` 修正 `outgoing_date` 输出为 JSON,典型字段: ```json { "dry_run": true, "matched": 18000, "updated": 0, "would_update": 17800, "skipped_missing": 150, "skipped_invalid": 50, "overwrite": false, "update_outgoing_date": false } ``` 历史偏移数据修正建议先 dry-run: ```bash docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web uv run python manage.py backfill_printing_order_kd_riqi --overwrite --update-outgoing-date --dry-run ``` 确认后执行: ```bash docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web uv run python manage.py backfill_printing_order_kd_riqi --overwrite --update-outgoing-date ``` ### 二、单条 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=` - 将返回的 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`