# OMS 面单标签模板设计与 PDF 生成方案 > **版本**:v0.4(开工核对版) > **日期**:2026-07-18 > **基线文档**:`docs/oms系统设计-050801.md`(§5.1 标签设计 / §5.2.20 `t_label_template` / §6 操作打印 / §9~10 订单 `labelUrl`) ### 已确认决策 | 项 | 结论 | | --- | --- | | 出 PDF 时机 | **异步**(Pending → Ready / Failed) | | 产品「客户面单」 | **下拉选具体模板**(`LabelTemplateId`),去掉写死「系统面单/客户面单」枚举作为出单依据 | | 渲染引擎 | **纯 .NET**:`HiprintJson → QuestPDF`;vg-print **只做设计** | | 默认模板 | 产品未选时回落 **全局默认模板 Id**(配置项) | | 模板归属(一期) | **仅运营/管理员**设计与维护(客户自助 → 二期) | | 默认纸张(一期) | **100×150 mm**(模板可改;未填时用此默认) | ### 落地进度(相对本文) | 模块 | 状态 | 说明 | | --- | --- | --- | | 标签设计列表 + 新建弹窗 + 懒加载设计器 | **已做** | `labelDesign`:list/get/save/delete;新建弹窗填名称/类型再进设计 | | 模板 CRUD API | **已做** | `GET list` / `GET {id}` / `POST save` / `POST delete` | | Hiprint→QuestPDF 渲染器 | **已做** | Parser/IR/QuestPDF + 样本单测 | | 异步出 PDF + 订单状态字段 | **已做** | Pending/Ready/Failed;创单投递 `generate-label-pdf` | | 替换 HTML stub / 批量&仓内打 PDF | **已做** | print / batch / 仓内返回 PDF `printUrl`;无模板时回落 HTML | | `IsEnabled` / 纸张字段 / enable API | **一期可砍** | 用「有无模板」即可;纸张从 JSON panel 读 | | 独立 `labelTemplate` 列表页 | **取消** | 已并入 `labelDesign`,勿再建 | | 设计器字段字典注入 | **已做** | `field-dictionary` + 设计器左侧字段 | | 销售产品绑 `LabelTemplateId` | **已做** | 下拉接模板列表 | --- ## 0. 一期开工范围(核对无遗漏后按此做) ### 0.1 必须做(闭环) 1. **导出真实 TemplateJson 样本** + 单位校准单测(pt/mm)。 2. **`ILabelPdfRenderer`**:Parser → IR → QuestPDF;一期元素:text / barcode / qrcode / longText / hline / vline / rect / image。 3. **`LabelPrintDataBuilder`**:订单 → PrintData(附录 B 字段)。 4. **订单字段**:`LabelUrl`(复用/确认)、`LabelTemplateId`、`LabelGenerateStatus`、`LabelGenerateError`;SQL 迁移。 5. **异步任务** `generateLabelPdf`(复用现有批量队列机制)。 6. **创单投递任务**;`POST /order/print`、`batch/print` 返回 PDF `printUrl`;Pending/Failed 前端提示。 7. **销售产品**:`LabelTemplateId` + 下拉接 `label-template/list`;废弃枚举出单逻辑。 8. **全局默认模板配置**(配置表或 appsettings 键均可,先简单)。 9. **设计器字段字典**:`GET field-dictionary` + vg-print 左侧可拖字段(至少单号/收件/重量常用组)。 10. **仓内 print-label**:返回 `printUrl`;模板下拉改真实列表(可选覆盖)。 ### 0.2 一期明确不做(避免范围膨胀) - Playwright / 浏览器渲染 - table / html 元素、页眉页脚高级规则 - 模板启停 API、预览图、客户自助设计 - PDF 合并下载、地址变更自动 stale(二期) - 平台店铺 ShippingLabelUrl ### 0.3 建议实施顺序 ```text ① JSON 样本 + Renderer 单测 ② PrintDataBuilder + preview-pdf(管理端验收版式) ③ 订单字段迁移 + 异步 generateLabelPdf + 创单挂钩 ④ 销售产品 LabelTemplateId 下拉 ⑤ 设计器字段字典 ⑥ order/print + batch/print + 仓内 printUrl ``` --- ## 1. 背景与目标 ### 1.1 要解决的问题 1. **标签样式不可配置**:订单创建后的「面单」目前是固定 HTML stub,无法按客户/产品定制版式。 2. **设计器与打印链路断开**:`vg-print` 已能设计并保存 `TemplateJson`,但创建订单、单票打印、批量打印均未消费该模板。 3. **批量打印需要稳定文件**:仓内/客服批量出单希望拿到 **PDF**(可下载、可静默打印、可归档),而不是临时拼 HTML。 4. **产品「客户面单」未绑模板**:字段名虽叫客户面单,下拉却是写死的「系统面单/客户面单」,与 `t_label_template` 无关。 ### 1.2 目标(一期) | 目标 | 说明 | | --- | --- | | 可设计 | vg-print 设计,拖拽绑定订单字段,保存 `TemplateJson` | | 可保存 | 模板 CRUD(名称、类型;列表浏览/删除)— **已有** | | 可绑定 | 销售产品「客户面单」改为 **模板下拉**(`LabelTemplateId`) | | 可出 PDF | 纯 .NET 解析 `TemplateJson` + PrintData → 异步生成 PDF | | 可批量 | 按 `labelUrl` 批量静默打 / 下载 | ### 1.3 非目标(一期不做) - 平台店铺面单(Shopee 等)仍走平台 URL。 - 不在服务端跑 Playwright/Chromium。 - 不保证 hiprint 全部高级特性 100% 还原(见 §3.3 支持矩阵);一期覆盖面单常用元素。 - 不做模板审批流 / 模板市场 / 客户自助设计。 --- ## 2. 现状结论(Gap) ```mermaid flowchart LR A[vg-print 设计器] -->|保存 TemplateJson| B[(t_label_template)] C[销售产品 CustomerWaybill] -.->|仅枚举字符串| X[未关联模板] D[创建订单 / 打单] -->|CreateOrderPrintFileAsync| E[极简 HTML] F[仓内 print-label] -->|只写记录| G[(t_label_print_record)] H[批量 print] --> E B -.->|未消费| D ``` | 能力 | 现状 | 缺口 | | --- | --- | --- | | 设计器 | 列表+懒加载设计器 **已有**;新建弹窗 **已有** | 字段字典未注入 | | 模板存储 | `t_label_template` CRUD **已有** | 纸张可读 panel;启停可砍 | | 产品绑定 | `CustomerWaybill` 写死两枚举 | → `LabelTemplateId` | | 渲染 | HTML stub | HiprintJson→PDF | | 仓内打印 | 记日志 | 返回/打开 PDF | | 批量 | 有框架 | 依赖真实 `labelUrl`(PDF) | --- ## 3. 总体方案 ### 3.1 核心思路 把链路拆成四段,职责清晰: | 阶段 | 做什么 | 产物 | | --- | --- | --- | | **① 设计** | vg-print 配置版式,左侧拖订单字段占位符 | `TemplateJson` | | **② 绑定** | 销售产品「客户面单」下拉选模板 | `SalesProduct.LabelTemplateId` | | **③ 灌数出 PDF** | 异步:.NET 解析 TemplateJson + PrintData → PDF | `Order.LabelUrl` | | **④ 打印** | 单票 / 批量 / 仓内,统一打 `labelUrl` | 打印机 / 下载 | ### 3.2 架构(纯 .NET 渲染 + 客户端静默打) ```mermaid sequenceDiagram participant Designer as vg-print 设计器 participant API as OMS API (.NET) participant DB as DB participant Worker as 异步 Worker participant FE as 订单/仓内前端 participant Hiprint as 本地 hiprint 客户端 Designer->>API: 保存 TemplateJson API->>DB: t_label_template Note over API,DB: 产品「客户面单」下拉存 LabelTemplateId FE->>API: 创建订单 API->>DB: 落单 + Pending + 投递 generateLabelPdf Worker->>DB: 读模板 JSON + 组 PrintData Worker->>Worker: HiprintJsonRenderer → QuestPDF Worker->>DB: 写 PDF + Ready/labelUrl FE->>API: 打印(Ready) API-->>FE: printUrl (PDF) FE->>Hiprint: 静默打印 PDF ``` - **设计**:继续 vg-print(前端)。 - **出文件**:纯 .NET,**不**起浏览器。 - **送打印机**:前端 hiprint 客户端打已生成的 PDF(与现网批量打印一致)。 ### 3.3 纯 .NET 如何「转化使用」vg-print 保存的 JSON(已确认) #### 3.3.1 思路 vg-print / hiprint 的 `TemplateJson` 本质是 **声明式版式**:纸张尺寸 + 绝对定位元素列表(文本/条码/线/图/表…)。 我们不在服务端执行 JS,而是: 1. **反序列化** JSON → C# 模型(`HiprintTemplate` / `HiprintPanel` / `HiprintElement`) 2. **灌数**:按 `options.field` 从 `PrintData` 取值(无 field 则用 `title` 静态文案) 3. **绘制**:用 **QuestPDF**(或 PdfSharp)按 left/top/width/height 绝对定位画到页面 4. **条码/二维码**:`options.textType = barcode | qrcode` 时用 **ZXing.Net** 生成图片再嵌入 ```text TemplateJson (hiprint) │ ▼ HiprintTemplateParser ← 只认我们支持的元素子集 │ ▼ Intermediate IR ← 统一:PageSize + List │ ▼ QuestPdfEmitter ← 输出 PDF bytes ``` 中间 IR 的好处:以后若换 PdfSharp,只换 Emitter;Parser 不动。 #### 3.3.2 JSON 结构(需解析的关键部分) 典型形态(示意): ```json { "panels": [ { "index": 0, "width": 100, "height": 150, "printElements": [ { "printElementType": { "type": "text" }, "options": { "left": 20, "top": 30, "width": 120, "height": 20, "field": "serviceNo", "title": "运单号", "fontSize": 12, "fontWeight": "bold", "textAlign": "left", "textType": "text" } }, { "printElementType": { "type": "text" }, "options": { "left": 20, "top": 60, "width": 160, "height": 40, "field": "serviceNo", "textType": "barcode" } } ] } ] } ``` 坐标约定(实现时用真实样本校准一次): | 项 | hiprint 常见单位 | .NET 处理 | | --- | --- | --- | | 纸张 width/height | **mm** | QuestPDF `PageSizes` / 自定义 mm | | 元素 left/top/width/height | **pt**(1 inch = 72 pt) | 换算:`pt → mm = pt * 25.4 / 72` | | fontSize | pt | 直接用 | > 落地第一步:从设计器导出 1~2 份真实 JSON 做单元测试,校准单位(不同 vg-print 版本偶有差异)。 #### 3.3.3 元素支持矩阵(一期 / 二期) | 元素 type / textType | 一期 | 实现要点 | | --- | --- | --- | | text(普通文本) | ✅ | 灌 field;字体/对齐/颜色/粗体 | | text + barcode | ✅ | ZXing Code128(可配置) | | text + qrcode | ✅ | ZXing QR | | longText | ✅ | 多行换行、超出裁剪或缩小 | | hline / vline | ✅ | 线宽、颜色 | | rect | ✅ | 边框/填充(简化) | | image | ✅ | 静态图 URL/相对路径;或 field 指向图片地址 | | table | 二期 | 按 columns + items[] 画表;一期可用「多行 text」凑合 | | html | ❌ 一期跳过 | 设计规范禁止用自定义 HTML 做面单关键信息 | | 页眉页脚/页码高级规则 | 二期 | 面单多为单页,一期可忽略 | **设计规范(避免踩坑)**:面单模板只用支持矩阵内的控件;保存模板时可做服务端校验,遇到不支持类型给出警告。 #### 3.3.4 关键代码落点(建议) | 组件 | 位置(建议) | | --- | --- | | `HiprintTemplateDocument` 等模型 | `Oms.Application/Labeling/Hiprint/` | | `HiprintTemplateParser` | 同上 | | `ILabelPdfRenderer` / `QuestPdfLabelRenderer` | 同上 | | `LabelPrintDataBuilder` | 订单 → PrintData 字典 | | `GenerateLabelPdfJobHandler` | 异步队列 Worker | | NuGet | `QuestPDF`、`ZXing.Net`(及 ImageSharp 如需) | 接口形态: ```csharp public interface ILabelPdfRenderer { Task RenderAsync(string templateJson, IReadOnlyDictionary printData, CancellationToken ct = default); } ``` #### 3.3.5 与「设计预览」的对齐策略 | 手段 | 说明 | | --- | --- | | 设计器预览 | 仍用 vg-print 前端预览(设计时) | | 服务端预览 API | `POST .../preview-pdf` 走同一套 `ILabelPdfRenderer`,给产品配置/验收用 | | 回归用例 | 固定 TemplateJson + PrintData → 对比 PDF 哈希或关键区域截图 | 预期:**常用面单元素**视觉接近;像素级与浏览器 hiprint 不完全一致(字体 hinting 等),以「可扫码、信息齐全、布局不错位」为验收标准。 #### 3.3.6 为何仍划算(相对 Playwright) - 无 Chromium 运维,部署与现有 API 一致。 - 异步高峰更可控(线程池而非浏览器池)。 - 设计器继续复用,**不是**推倒重做;成本在「解析器 + 支持矩阵」,可控且可测。 ### 3.4 异步出 PDF(已确认) 创单:落单 → 解析产品 `LabelTemplateId`(空则全局默认)→ `LabelGenerateStatus=Pending` → 投递 `generateLabelPdf` → 立即返回。 Worker:`PrintData` → `ILabelPdfRenderer` → 落盘 → `Ready` / `Failed`。 打印:Ready 用 `labelUrl`;Pending 提示生成中或短等补生成;Failed 可「重新生成」。 ### 3.5 产品「客户面单」字段改造(已确认) **现状**:`customerWaybillOptions` 写死: ```ts { label: '系统面单', value: '系统面单' }, { label: '客户面单', value: '客户面单' }, ``` 与模板库无关,不能表达「用哪张版式」。 **改造**: | 项 | 做法 | | --- | --- | | UI | 「客户面单」下拉改为拉 `GET /channel/label-template/list`(启用中的模板),展示 `Name` | | 存储 | 新增/使用 `LabelTemplateId`(long,可空) | | 旧字段 `CustomerWaybill` | 迁移期可废弃写入,或仅作展示兼容;**出 PDF 只认 LabelTemplateId** | | 未选模板 | 使用系统配置的 **全局默认模板 Id**;若默认也没有 → 异步任务 Failed,并提示去配置 | 仓内打印页的模板下拉同理:不要写死 `lastMile/customer/...` 类型枚举当「模板」,应拉真实模板列表(类型可作筛选)。 平台店铺面单 URL 仍独立,不进该下拉。 ## 4. 领域模型 ### 4.1 模板 `t_label_template` | 字段 | 一期 | 说明 | | --- | --- | --- | | Id / Name / TemplateType / TemplateJson | ✅ 已有 | CRUD 已通 | | PaperWidthMm / PaperHeightMm | 可选 | **优先从 TemplateJson.panels[0] 读**;缺省 100×150 | | IsEnabled / PreviewImageUrl / Remark | 二期 | 一期不做启停 | ### 4.2 销售产品绑定 | 字段 | 说明 | | --- | --- | | LabelTemplateId | **主字段**:客户面单下拉选中的模板 Id | | CustomerWaybill | 旧枚举;迁移后停止作为出单依据(可留列不清) | 解析:`产品.LabelTemplateId` → 否则 → `全局默认模板Id` → 否则 Failed。 ### 4.3 订单侧产物 | 字段 | 说明 | | --- | --- | | `LabelUrl` | PDF 路径(替换 HTML stub) | | `LabelTemplateId` | 实际模板 Id | | `LabelGenerateStatus` | `Pending` / `Ready` / `Failed` | | `LabelGenerateError` | 失败原因 | | PrintData 快照 | 建议一期就落 json 列或扩展表,便于重打 | ### 4.4 仓内打印记录 - `print-label/print` 返回 `printUrl`;可选写 `PrintFileUrl` 到记录表。 ### 设计页交互(已落地) - 默认列表;新建弹窗(名称+类型)→ 懒加载设计器;返回卸载。 - API:list / get / save / delete。 - **待补**:字段字典注入。 --- ## 5. 字段字典(设计器左侧可拖字段) 设计器不应让业务人员手写字段名。一期提供 **标准 PrintData Schema**,前端注入 vg-print 的元素/字段面板。 ### 5.1 命名约定 - 全部 **camelCase**,与前端/hiprint `print(data)` 一致。 - 地址类同时提供分行字段与 `recipientAddressFull`。 ### 5.2 字段分组(初稿) #### A. 单号 | 字段 | 说明 | | --- | --- | | serviceNo | 运单号 / 服务商单号 | | clientNo | 客户单号 | | trackNo | 跟踪号 | | otherNo | 其他单号(若有) | | requestId | 请求号 | #### B. 产品 / 渠道 / 仓库 | 字段 | 说明 | | --- | --- | | productCode | 销售产品编码 | | salesProductName | 销售产品名称 | | channelCode | 渠道编码 | | channelName | 渠道名称 | | warehouseName | 仓库名称 | | customerCode / customerName | 客户(管理端场景) | #### C. 收件人 | 字段 | 说明 | | --- | --- | | recipientName | 姓名 | | recipientCompany | 公司 | | recipientPhone | 电话 | | recipientEmail | 邮箱 | | recipientZip | 邮编 | | recipientCountry / recipientCountryName | 国家 | | recipientProvince / City / Street1/2/3 | 省市区街道 | | recipientAddressFull | 拼接地址 | | recipientTaxNo / vatNo / eori | 税号类 | #### D. 发件人 对称字段:`senderName`、`senderCompany`、`senderPhone`、`senderAddressFull` … #### E. 重量尺寸 | 字段 | 说明 | | --- | --- | | actualWeight | 实重 | | volumetricWeight | 体积重 | | chargeableWeight | 计费重 | | length / width / height | 长宽高 | | weightUnit | 默认 KG | | dimUnit | 默认 CM | #### F. 货品明细(表格 — 二期) ```json "items": [ { "skuCode": "", "skuName": "", "qty": 1, "weight": 0.1, "declareNameEn": "", "declareValue": 0 } ] ``` 一期可用多个 text 字段代替。 #### G. 系统 | 字段 | 说明 | | --- | --- | | printTime | 出 PDF 时间 | | operatorName | 操作人(仓内打印时) | ### 5.3 设计器待补 1. `GET /channel/label-template/field-dictionary` 2. 注入 vg-print provider / 可拖字段分组 3. 预览可用 Mock PrintData (列表/新建弹窗/保存/删除已完成,勿重复建设独立列表页。) --- ## 6. 关键业务流程 ### 6.1 设计与发布 1. 运营「标签设计」→ 新建弹窗 / 设计 → 拖字段 → 预览 → 保存。 2. 销售产品「客户面单」下拉选模板(`LabelTemplateId`)。 3. 配置全局默认模板(产品未选时回落)。 ### 6.2 创建订单 → 生成 PDF | 触发 | 建议 | | --- | --- | | 订单创建成功 | **主路径**:异步 `generateLabelPdf` | | 预报/生成包裹 | 地址重量补全后可 **重生** | | 地址变更 | 二期:`labelStale` | ``` resolveTemplate(product) → buildPrintData(order) → renderPdf → /uploads/prints/orders/xxx.pdf → labelUrl + Ready ``` ### 6.3 单票 / 批量打印 - 单票 / 批量:返回 PDF `printUrl`;Pending 提示生成中;Failed 可重生。 - 合并下载 → 二期。 ### 6.4 仓内「打印标签」 1. lookup(已有)→ 确认打印。 2. 优先订单 `labelUrl`;无则现场生成。 3. 返回 `printUrl`;模板下拉接真实列表。 --- ## 7. API 设计(增量) ### 7.1 模板 | 方法 | 路径 | 一期 | | --- | --- | --- | | POST save | `/channel/label-template/save` | ✅ | | GET list | `/channel/label-template/list` | ✅ | | GET detail | `/channel/label-template/{id}` | ✅ | | POST delete | `/channel/label-template/delete` | ✅ | | GET field-dictionary | `/channel/label-template/field-dictionary` | 待做 | | POST preview-pdf | `/channel/label-template/preview-pdf` | 待做 | | POST enable | `/channel/label-template/enable` | 砍掉(二期) | ### 7.2 销售产品 - Save/Detail 增加 `labelTemplateId`;下拉接 list。 ### 7.3 订单 / 打印 | 方法 | 说明 | | --- | --- | | 创单内部 | 投递 `generateLabelPdf`(非同步堵接口) | | `POST /order/print` | PDF `printUrl` | | `POST /order/batch/print` | 每单 PDF url | | `POST /order/label/regenerate` | 强制重生 | ### 7.4 仓内 | 方法 | 说明 | | --- | --- | | `POST /operation/print-label/print` | 返回 `printUrl` | ### 7.5 渲染(进程内) ```csharp Task RenderAsync(string templateJson, IReadOnlyDictionary printData, CancellationToken ct = default); ``` --- ## 8. 前端改造清单 | 页面/模块 | 状态 | | --- | --- | | `channel/labelDesign` | ✅ 列表+弹窗+懒加载;⏳ 字段字典 | | `channel/labelTemplate` 独立页 | ❌ 取消 | | `channel/salesProducts` | ⏳ 模板下拉 | | `OrderPrintModal` / 我的订单批量 | ⏳ PDF + Pending 提示 | | `operation/printLabel` | ⏳ printUrl + 真实模板下拉 | | 权限文案 | 随菜单既有资源即可 | --- ## 9. 分阶段落地 ### 一期(按 §0.3 顺序) 见文首 **§0**。 ### 二期 1. table 元素、启停、预览图。 2. 地址变更 stale / 重生;PDF 合并。 3. 客户自助设计;保存时不支持元素强校验。 ### 三期(可选) - 仅当 QuestPDF 还原成本过高时再评估 Playwright 兜底。 --- ## 10. 风险与对策 | 风险 | 对策 | | --- | --- | | JSON 与 PDF 视觉偏差 | 支持矩阵;preview-pdf;pt/mm 单测 | | 中文字体 | 服务端嵌入 Noto Sans SC / 雅黑 | | 创单后立刻打印 | Pending 短等或提示稍后 | | 旧枚举数据 | 出单只认 LabelTemplateId | | 批量高峰 | 异步队列限流 | --- ## 11. 验收标准(一期) 1. 设计器模板(文本+条码+线框)经 .NET 出可扫码 PDF。 2. 销售产品下拉为真实模板,存 `LabelTemplateId`。 3. 创单异步后 Ready + `labelUrl` 可打开。 4. 单票/批量/仓内能打该 PDF;Pending 有提示。 5. 无产品模板且无全局默认 → Failed + 明确提示。 --- ## 12. 决策状态 | # | 项 | 状态 | | --- | --- | --- | | 1 | 渲染:纯 .NET + Hiprint JSON | **已确认** | | 2 | 出 PDF:异步 | **已确认** | | 3 | 产品:模板下拉 | **已确认** | | 4 | 模板归属 | **一期:仅运营**(已拍板默认) | | 5 | 默认纸张 | **一期:100×150 mm**(已拍板默认) | **结论:方案可开工。** 无阻塞性遗漏;下一刀从 §0.3 ①「真实 JSON 样本 + Renderer」开始。 --- ## 附录 A. 与现有文档章节映射 | 本方案 | 设计总册 | | --- | --- | | 设计器 / TemplateJson | §5.1、§5.2.20、§5.3.12 | | 仓内打印记录 | §6.1、§6.2.3、§6.3.3 | | 订单 labelUrl / batch print | §4.4.1.8、§9.6.3、§10 | | 批量异步 | `订单批量处理与异步队列方案0707.md` | ## 附录 B. PrintData 示例(节选) ```json { "serviceNo": "S202607180001", "clientNo": "C-10086", "trackNo": "YT1234567890", "salesProductName": "专线-普货", "warehouseName": "SZ-01", "recipientName": "John Lee", "recipientPhone": "+1-xxx", "recipientCountryName": "United States", "recipientAddressFull": "xxx Street, City, CA 90001", "actualWeight": 1.25, "length": 30, "width": 20, "height": 10, "items": [ { "skuCode": "SKU001", "skuName": "T-Shirt", "qty": 2 } ], "printTime": "2026-07-18 14:00:00" } ```