列表导出不够用-SaaS-ERP-单据详情导出的-Provider-模板与文档型-Excel-设计

列表导出不够用:SaaS ERP 单据详情导出的 Provider、模板与文档型 Excel 设计

很多后台系统一开始都会有一个"公共导出"能力:前端把列表查询条件传给后端,后端查出数据,按字段配置生成 Excel。

这个能力很有价值。它解决的是最常见的一类问题:把当前列表筛选结果批量带走。

但在 SaaS ERP 里,导出很快会撞到另一个问题:用户要导出的不一定是"列表",而是一张完整单据。

比如销售订单详情页,它不是一张二维表。它通常包含订单头、客户信息、商品明细、批次分摊、支付记录、还款历史、金额汇总等多个区块。继续复用列表导出,看似省事,实际会把数据口径、展示结构和模板能力全部搅在一起。

这篇文章记录一次单据详情导出的设计取舍。重点不是怎么调 Apache POI,也不是怎么把 Excel 画得更漂亮,而是当公共导出能力已经存在时,如何判断它的边界,并抽出一条更适合"文档型单据"的导出链路。

1. 先区分三类导出

我后来发现,导出需求如果一上来就讨论"字段怎么传""表头怎么配""Excel 怎么画",很容易陷入细节。

更好的入口是先问一个问题:用户到底要带走什么形态的数据?

1.1 列表型导出

列表型导出解决的是"筛选结果"。

典型场景包括客户列表、商品列表、库存流水、价格变更记录、资金流水等。这类数据天然是二维表结构:一行一条记录,一列一个字段。

它的核心设计点是:

  • 导出口径要和列表查询口径一致。
  • 查询条件要复用列表筛选条件。
  • 展示字段要避免 N+1,关联名称、枚举文案、金额格式要批量回填。
  • 字段配置可以围绕列选择、列顺序、表头文案展开。

这类导出适合做成公共能力。

1.2 明细布局型导出

第二类是"一主多行"的布局型导出。

例如订单商品明细导出。用户仍然是从订单列表出发,但希望一行代表一个商品明细;同一张订单下面可能有多行商品,所以订单号、客户、订单时间、订单状态、订单金额等订单级字段,需要按订单分组后合并单元格。

它不是单纯列表,也不是完整单据。

它的核心设计点是:

  • 行粒度从"订单"变成"订单商品明细"。
  • 订单级字段可以合并展示。
  • 商品级字段不能合并。
  • 支持分组字段、合并字段、排序规则、多级表头。
  • 如果按商品筛选,订单总金额和商品行金额的语义要说清楚,避免用户误读。

这类导出可以复用公共导出的下载、字段配置、文件响应能力,但渲染层需要支持分组、合并和多级表头。

1.3 单据文档型导出

第三类就是本文重点:单据详情导出。

它要导出的不是一张二维清单,而是一份业务文档。

以销售订单详情为例,一份单据里可能包括:

  • 单据标题
  • 订单基础信息
  • 客户信息
  • 商品明细表
  • 批次分摊表
  • 收款或还款记录
  • 金额汇总

这种结构继续塞进列表导出会很别扭。因为列表导出关心"行和列",单据详情导出关心"区块和语义"。

2. 为什么不能直接复用列表导出

很多导出架构变复杂,不是因为一开始设计太少,而是因为把不同形态的需求塞进了同一条链路。

列表导出和单据详情导出的差异,至少有四个。

2.1 数据源不同

列表导出的数据源通常来自分页查询。

分页查询为了列表展示,往往只返回摘要字段。例如订单号、客户、状态、金额、时间。它不会把批次、还款历史、商品行扩展字段、客户更多资料全部查出来。

单据详情导出的数据源应该来自详情聚合模型,而不是列表分页模型。

这不是实现洁癖,而是业务口径问题。用户点"详情导出",期望导出的内容和详情页一致。如果后端为了省事拿列表数据凑,迟早会遇到"页面有,Excel 没有""页面口径和导出口径不一致"的问题。

2.2 结构不同

列表导出天然是一张表。

单据详情更像一份文档。订单头是键值区块,商品明细是表格,批次分摊是表格,还款历史是表格,金额汇总又是键值或多列区块。

如果硬压成一张二维表,要么字段非常宽,要么一张订单被拆成很多行,要么重复大量订单头信息。最后 Excel 既不适合阅读,也不适合归档。

2.3 模板能力不同

列表模板通常配置"列"。

单据详情模板配置的是"文档里的字段和区块"。它不仅要控制字段顺序,还要知道字段属于订单头、客户信息、商品明细、批次、还款还是汇总。

所以单据详情模板不能只是一个自由 JSON。它需要有字段白名单,需要知道字段分类,需要避免前端传一个后端根本不支持渲染的字段。

2.4 失败策略不同

列表导出通常可以接受"空列表导出空文件"或者"没有数据直接提示"。

单据详情导出更适合 fail-fast:只要某张单据不存在、跨租户、详情聚合失败、模板过滤后没有可渲染字段,就应该整批失败。

因为它更接近业务单据归档。如果导出十张订单,其中一张失败但文件仍然生成,用户很容易误以为这批单据已经完整导出。

3. 单据详情导出的目标边界

我对第一版目标做了收敛。

支持:

  • 单个单据详情导出。
  • 多个单据批量导出。
  • 默认一个 Sheet 内纵向拼接多个单据。
  • 预留每单一个 Sheet 的模式。
  • 没有自定义模板时,全字段兜底导出。
  • 支持模板字段选择、字段顺序和标签覆盖。
  • 支持后端标签国际化和值展示兜底。
  • 同步导出,但要有单据数量、明细行数量和 Sheet 数量护栏。

不做:

  • 不改造现有列表导出。
  • 不做 PDF。
  • 不做异步导出任务中心。
  • 不做可视化拖拽套打平台。
  • 不承诺 100% 复刻前端详情页布局。

这几个"不做"很重要。

单据详情导出第一版如果直接做成套打平台,很容易失控。字段拖拽、区块布局、样式配置、PDF、异步任务、文件存储、权限审计全部进来,短期会变成一个大工程。

第一版更合理的目标,是先把业务模型、模板边界、渲染链路跑通。

4. 推荐的整体架构

单据详情导出可以分成五层。

text 复制代码
接入层
  ExportController
  ExportTemplateController

公共导出层
  DocumentExportService
  DocumentExportProviderFactory
  DocumentExcelRenderer
  LabelTranslationHelper

业务适配层
  SalesOrderDocumentProvider
  PurchaseOrderDocumentProvider
  StockInDocumentProvider

模板管理层
  ExportTemplateService
  ExportTemplateOptionService

数据层
  export_template
  export_template_option

这套分层的核心,不是类名,而是职责。

4.1 Controller 只做接入

接入层只处理参数、文件响应、下载头、异常返回。

它不应该知道销售订单里面有哪些字段,也不应该关心 Excel 哪个单元格要合并。

4.2 公共导出层负责编排

公共导出层做几件事:

  • 校验导出请求。
  • 根据单据类型找到 Provider。
  • 解析最终导出的单据编号并去重。
  • 检查数量上限。
  • 调用 Provider 加载单据文档模型。
  • 应用模板字段过滤和标签优先级。
  • 调用 Renderer 渲染 Excel。
  • 返回文件流。

这层是"通用流程",不绑定某一种业务单据。

4.3 Provider 负责业务语义

Provider 是这套设计里最关键的一层。

它负责把业务详情模型转换成导出文档模型。

示意接口可以长这样:

java 复制代码
public interface DocumentExportProvider {

    DocumentType supportType();

    List<String> resolveDocumentNoList(DocumentExportQuery query);

    List<ExportDocument> loadDocuments(DocumentExportQuery query);

    List<ExportFieldOption> listSupportedFields();
}

这里有一个容易忽略的点:Provider 不只是加载数据,还要提供字段目录。

字段目录的价值在于:

  • 模板配置页可以知道当前单据支持哪些字段。
  • 后端可以校验模板 JSON 里的字段是否合法。
  • 字段可以按区块分组,例如 HEADER、ITEM、BATCH、PAYMENT、TOTAL。
  • 字段可以标记类型,例如文本、数字、金额、日期、枚举。

如果没有字段目录,模板配置就会变成"前端随便传、后端尽量猜"。短期看灵活,长期看不可维护。

4.4 Renderer 只负责渲染

Renderer 不应该查询业务数据,也不应该理解订单状态是什么意思。

它只接收一个渲染上下文:

text 复制代码
documents
layoutMode
labelMap
styleConfig

然后把文档模型画成 Excel。

这样做有两个好处。

第一,渲染逻辑可以复用。销售订单、采购订单、入库单、付款单都可以共用同一个 Renderer。

第二,业务字段变更不会污染 Excel 底层代码。新增一个区块时,优先扩展 Provider 输出的文档模型,而不是在 Renderer 里写一堆订单专属判断。

4.5 Template Service 管住配置边界

模板服务的职责不是"保存一段 JSON"这么简单。

它至少要做三件事:

  • 模板属于哪个租户、哪个单据类型。
  • 模板是否启用、是否默认。
  • 模板里的字段编码是否全部来自当前单据类型的字段白名单。

这一步能挡住很多后续问题。

比如前端把 A 单据的字段传给 B 单据,或者模板里还残留已经废弃的字段。如果没有白名单校验,问题会在导出时才暴露,而且错误更难定位。

5. 文档模型应该长什么样

我更倾向于让 Provider 输出"语义文档模型",而不是直接输出二维行列。

一个简化后的模型可以是:

text 复制代码
ExportDocument
  title
  headerFields
  sections
    - sectionName
    - type: KEY_VALUE / TABLE / SUMMARY
    - fields
    - rows

销售订单详情可以映射成:

text 复制代码
销售订单详情
  订单头
    订单号、创建时间、交易时间、状态、门店、销售员
  客户信息
    客户名称、手机号、联系人、地址、等级
  商品明细
    商品、条码、规格、单位、数量、单价、金额、退货数量
  批次分摊
    商品、批次号、生产日期、过期日期、分摊数量
  还款历史
    还款时间、还款方式、还款金额
  金额汇总
    小计、优惠、税额、应付、已付、待还

这个模型比二维表更接近用户理解,也更适合后续扩展。

6. 模板不是自由 DSL,而是字段白名单

模板配置很容易被设计过度。

一上来就支持拖拽布局、合并规则、字体、边框、颜色、公式、条件样式,听起来很完整,但对第一版来说太重。

我的建议是:第一版只做字段级模板。

也就是模板只表达三件事:

  • 要哪些字段。
  • 字段顺序是什么。
  • 字段标签是否覆盖。

示意 JSON:

json 复制代码
{
  "fields": [
    { "fieldCode": "order.orderNo", "label": "订单号" },
    { "fieldCode": "order.customerName", "label": "客户" },
    { "fieldCode": "order.item.productName", "label": "商品名称" },
    { "fieldCode": "order.item.quantity", "label": "数量" },
    { "fieldCode": "order.totalPayableAmount", "label": "应付金额" }
  ]
}

后端解析时,不建议只认一种 key。

真实前后端联调里,字段编码可能叫 fieldCode,也可能叫 field_code,还有历史配置可能叫 columnOptionCode。如果后端只认一种格式,前端模板改造成本会被放大。

但兼容 key 不等于不校验。

更合理的规则是:

  • 后端可以递归识别多种字段编码 key。
  • 后端可以递归识别 label、name、title、displayName 等标签 key。
  • 识别出来的字段必须在 Provider 字段白名单里。
  • 非空模板必须至少包含一个合法字段。
  • 过滤后如果没有任何可渲染内容,直接报错。

这样既保留了前端配置灵活性,也不放弃后端边界。

7. 标签优先级要提前定

导出最容易被低估的细节之一,是标签和国际化。

如果系统支持多语言,Excel 不能直接输出枚举名、字段名或后端内部 code。否则用户下载后看到的是 PAIDREFUNDEDtotalPayableAmount,体验会很差。

标签优先级可以设计成:

text 复制代码
请求级 labelOverrides
> 模板 JSON 中的 label/name/title/displayName
> 后端国际化标签
> Provider 兜底中文标签

为什么请求级覆盖优先?

因为第一版前端可能还没有完整模板配置页,但详情页已经有当前语言的展示文案。前端点击导出时,把当前页面的标签带给后端,可以快速保证导出文案和页面一致。

后端国际化兜底也要保留。因为批量导出、任务导出、后台触发导出等场景,不一定都有前端传 labelOverrides。

8. 值渲染也要统一

字段标签之外,值的渲染也要统一。

常见规则包括:

  • 枚举值优先翻译,缺失时回退业务备注,再回退枚举名。
  • 布尔值按当前语言展示"是/否"或 "Yes/No"。
  • 日期和日期时间统一格式。
  • 金额、数量保留数值类型,让 Excel 可以继续计算。
  • 空值不要输出 null,统一输出空白或业务约定值。

这些逻辑不要散落在各个 Provider 里。

Provider 应该提供字段和值,Renderer 或 ValueFormatter 负责把值变成 Excel 里应该看到的展示形态。

9. 默认一个 Sheet 纵向拼接

批量单据导出有两种常见版式。

第一种是一个 Sheet 内纵向拼接多张单据。

text 复制代码
销售订单详情 - A
  订单头
  商品明细
  金额汇总

销售订单详情 - B
  订单头
  商品明细
  金额汇总

第二种是每张单据一个 Sheet。

text 复制代码
Sheet1: 订单 A
Sheet2: 订单 B
Sheet3: 订单 C

我更建议第一版默认使用纵向拼接。

原因有三个:

  • 用户批量导出后更容易连续阅读。
  • Sheet 数不会随着单据数量快速膨胀。
  • Excel 对 Sheet 数和名称有额外限制,细节更多。

每单一个 Sheet 可以预留,但不一定要作为默认能力。

10. 同步导出必须加护栏

第一版不做异步导出,不代表可以无限制同步导出。

同步导出至少要加三类上限:

  • 单次最多导出的单据数。
  • 所有单据的明细行总数。
  • 如果支持每单一个 Sheet,要限制 Sheet 数。

例如:

text 复制代码
maxDocumentCount = 50
maxDetailRowCount = 5000
maxSheetCount = 255

具体数值要按系统性能、单据复杂度和业务使用习惯调整。

这里的重点不是 50 或 5000,而是要把"同步导出的资源边界"显式写出来。

否则它迟早会变成一个慢接口:用户一次选几百张大单,后端查详情、拼区块、渲染 Excel,最后请求超时,前端也不知道到底成功还是失败。

11. 错误要偏业务化,而不是技术化

单据详情导出失败时,错误提示应该尽量贴近用户动作。

常见错误可以包括:

场景 用户能理解的提示方向
导出数量超限 缩小选择范围后重试
单据不存在 刷新列表或详情页后重试
模板不存在或停用 刷新模板配置后重试
模板字段非法 调整模板字段后重试
过滤后无可渲染字段 至少选择一个有效字段
当前筛选无数据 当前条件下没有可导出数据

这比直接抛 NullPointerExceptionJSON parse errorfield not found 有意义得多。

12. 为什么要 fail-fast

批量导出时,有一种看似温和的做法:能导出几张就导出几张,失败的跳过。

我不建议单据详情导出第一版这么做。

原因是单据详情导出往往用于对账、归档、沟通或业务留痕。用户选择了十张单据,下载了一个 Excel,天然会认为这十张都在里面。

如果其中一张因为数据异常被跳过,而文件仍然成功生成,后续反而更危险。

所以第一版更适合 fail-fast:任一单据非法,整批失败,并给出明确提示。

等后续引入异步任务中心,再考虑"部分成功 + 失败清单 + 重试"的模式。

13. 公共导出底座还能复用什么

单据详情导出不等于完全推翻公共导出。

可以复用的部分包括:

  • 文件下载响应。
  • Excel 基础样式。
  • 枚举和国际化翻译。
  • 字段备选项管理思路。
  • 导出数量限制。
  • 错误码和前端提示规范。
  • 日志与审计。

不应该强行复用的部分包括:

  • 列表分页查询数据源。
  • 二维行列模型。
  • 只面向列的模板结构。
  • 简单按字段反射取值的渲染方式。

换句话说,公共导出应该沉淀"通用基础设施",而不是垄断所有导出形态。

14. 一个实用的选择树

遇到新导出需求时,可以先按下面这个选择树判断。

如果用户要的是筛选结果,走列表型导出。

如果用户要的是"一张主表下面展开多行明细",走明细布局型导出。

如果用户要的是完整业务单据,尤其包含多个区块、多个子表和汇总信息,就应该走单据文档型导出。

15. 后续演进路线

单据详情导出第一版跑通后,后续可以沿四个方向演进。

15.1 更多单据类型

销售订单只是一个开始。

后续采购订单、入库单、退货单、付款单、调拨单,都可以通过新增 Provider 接入。

公共层不需要关心这些单据的业务细节,只要 Provider 能输出统一文档模型即可。

15.2 异步任务中心

当导出数据量变大,同步导出一定会到边界。

异步任务中心可以解决:

  • 大文件导出。
  • 失败重试。
  • 导出进度。
  • 历史文件下载。
  • 部分成功清单。
  • 文件存储和过期清理。

但它应该在单据详情导出模型稳定之后再做。

15.3 模板配置增强

字段级模板稳定后,可以逐步增加:

  • 区块显隐。
  • 区块排序。
  • Sheet 模式配置。
  • 标题配置。
  • 汇总字段配置。
  • 部分样式配置。

不建议一开始就做全量拖拽套打。

15.4 布局型导出独立沉淀

订单商品明细这种"一主多行"的导出,可以单独沉淀成布局型导出能力。

它关注的是:

  • group keys
  • merge fields
  • sort rules
  • summary rules
  • multi headers

这和单据详情导出是相邻能力,但不是同一个抽象。

16. 这次设计真正想解决的问题

这个设计真正有价值的地方,不是多写了一个导出接口,也不是多画了一种 Excel 样式。

它解决的是一个更基础的问题:导出能力要尊重业务对象的形态。

列表就是列表,明细布局就是明细布局,单据详情就是单据详情。

如果一开始不区分,后面所有需求都会往"公共导出"里塞。公共导出会越来越复杂,业务适配会越来越脏,模板配置会越来越难懂,最后谁也不敢改。

更稳的方式是:

  • 公共导出层沉淀通用流程。
  • Provider 承接业务语义。
  • 模板只开放受控字段。
  • Renderer 专注渲染。
  • 同步导出显式设置资源边界。
  • 失败策略按业务归档场景设计。

这样做,第一版不会过重,后续也有清晰的演进空间。

本文为作者原创,首发于掘金,CSDN 为同步发布版本。

相关推荐
SimonKing42 分钟前
写文档的最佳搭档:Typora+PicList+SM.MS
java·后端·程序员
侧耳倾听1111 小时前
disruptor 从入门到使用
java
龙智DevSecOps解决方案1 小时前
WildFly vs JBoss vs GlassFish:2026年Java应用服务器选型对比与Perforce JRebel加速实践
java·应用服务器·jrebel·wildfly
luiyarch1 小时前
汽车电子ISO 26262功能安全系列(第16期):系统架构设计中的安全考量——让安全机制“长”在架构里
架构·系统架构·汽车
孫治AllenSun1 小时前
【LangChain4J-04】Tool 工具的使用
java·人工智能
choumou_M1 小时前
MySQL_1:数据库悲观锁
后端·spring·spring cloud
南城以南溫暖如初1471 小时前
树洞交友系统架构设计与匿名聊天实战指南
java·spring boot·mysql·系统架构·vue·mybatis·交友
城数派1 小时前
1901-2025年中国省市县三级逐月平均气温数据(Shp/Excel格式)
excel