diff --git a/pom.xml b/pom.xml index dc304e2d..a9c55777 100644 --- a/pom.xml +++ b/pom.xml @@ -59,7 +59,7 @@ 1.5.4 - 1.12.1 + 1.12.2 3.14.0 diff --git a/ruoyi-common/ruoyi-common-word/src/main/java/org/dromara/common/word/util/WordTemplateUtil.java b/ruoyi-common/ruoyi-common-word/src/main/java/org/dromara/common/word/util/WordTemplateUtil.java index 81e5413b..c04658de 100644 --- a/ruoyi-common/ruoyi-common-word/src/main/java/org/dromara/common/word/util/WordTemplateUtil.java +++ b/ruoyi-common/ruoyi-common-word/src/main/java/org/dromara/common/word/util/WordTemplateUtil.java @@ -1,6 +1,7 @@ package org.dromara.common.word.util; import com.deepoove.poi.XWPFTemplate; +import com.deepoove.poi.config.Configure; import jakarta.servlet.http.HttpServletResponse; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -105,6 +106,20 @@ public final class WordTemplateUtil { } } + /** + * 构建并渲染模板(支持自定义 Configure 配置) + * + * @param templatePath 模板路径(classpath) + * @param data 模板数据 Map + * @param config poi-tl 配置对象,可包含自定义 RenderPolicy + * @return 已完成占位符渲染的 XWPFTemplate 对象 + */ + private static XWPFTemplate buildTemplate(String templatePath, Map data, Configure config) throws IOException { + try (InputStream templateStream = getTemplateStream(templatePath)) { + return XWPFTemplate.compile(templateStream, config).render(data); + } + } + /** * 重置响应头,返回输出流 * @@ -122,6 +137,42 @@ public final class WordTemplateUtil { return response.getOutputStream(); } + /** + * 渲染模板并输出到 HttpServletResponse(支持自定义 Configure 配置) + *

+ * 适用于需要自定义渲染策略的场景,例如使用 DynamicTableRenderPolicy 动态生成表格。 + * + * @param templatePath classpath 下的模板路径(含文件名),如 "templates/wms_shipping_bill.docx" + * @param fileName 下载文件名(不含后缀),如 "发货单_X123",方法内部自动补全 .docx 后缀 + * @param data 模板数据 Map,业务自行约定占位符字段 + * @param config poi-tl 配置对象,可包含自定义 RenderPolicy(如 DynamicTableRenderPolicy) + * @param response HttpServletResponse,由 Spring MVC 自动注入 + */ + public static void renderToResponse(String templatePath, String fileName, Map data, + Configure config, HttpServletResponse response) { + Objects.requireNonNull(response, "HttpServletResponse must not be null"); + Objects.requireNonNull(config, "Configure must not be null"); + if (StringUtils.isBlank(templatePath)) { + throw new ServiceException("Word 模板路径不能为空"); + } + + String safeFileName = StringUtils.isBlank(fileName) ? "export" : fileName.trim(); + Map safeData = (data == null ? Collections.emptyMap() : data); + + log.info("Word模板导出开始(自定义Configure), templatePath={}, fileName={}, dataKeys={}", + templatePath, safeFileName, safeData.keySet()); + + try (XWPFTemplate template = buildTemplate(templatePath, safeData, config); + OutputStream os = resetResponse(safeFileName, response)) { + template.write(os); + os.flush(); + log.info("Word模板导出成功, fileName={}", safeFileName); + } catch (IOException e) { + log.error("Word 模板导出失败, templatePath={}, fileName={}", templatePath, safeFileName, e); + throw new ServiceException("Word 模板导出失败,请联系管理员").setDetailMessage(e.getMessage()); + } + } + /** * 渲染模板并输出到指定 OutputStream(不负责关闭 out) * 适用于非 HTTP 场景,例如: diff --git a/ruoyi-modules/ruoyi-wms/src/main/java/org/dromara/wms/controller/WmsShippingBillController.java b/ruoyi-modules/ruoyi-wms/src/main/java/org/dromara/wms/controller/WmsShippingBillController.java index 2667ae5d..ebc08281 100644 --- a/ruoyi-modules/ruoyi-wms/src/main/java/org/dromara/wms/controller/WmsShippingBillController.java +++ b/ruoyi-modules/ruoyi-wms/src/main/java/org/dromara/wms/controller/WmsShippingBillController.java @@ -1,6 +1,7 @@ package org.dromara.wms.controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import com.deepoove.poi.config.Configure; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -19,6 +20,7 @@ import org.dromara.common.word.util.WordTemplateUtil; import org.dromara.wms.domain.bo.WmsShippingBillBo; import org.dromara.wms.domain.vo.WmsShippingBillVo; import org.dromara.wms.service.IWmsShippingBillService; +import org.dromara.wms.word.WmsShippingDetailTablePolicy; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -140,13 +142,17 @@ public class WmsShippingBillController extends BaseController { public void exportWord(@NotNull(message = "发货单ID不能为空") @PathVariable("shippingBillId") Long shippingBillId, HttpServletResponse response) { - // 组装模板数据 + // 组装模板数据(包含主表字段和 List 明细列表) Map data = wmsShippingBillService.buildWordExportData(shippingBillId); // 生成文件名(发货单号) WmsShippingBillVo vo = wmsShippingBillService.queryById(shippingBillId); String fileName = "发货单_" + (vo != null && vo.getShippingCode() != null ? vo.getShippingCode() : shippingBillId); - // 渲染并输出Word文档(模板路径为classpath根目录) - WordTemplateUtil.renderToResponse("发货单模板.docx", fileName, data, response); + // 配置自定义表格渲染策略,绑定 detailsTable 占位符到 WmsShippingDetailTablePolicy + Configure config = Configure.builder() + .bind("detailsTable", new WmsShippingDetailTablePolicy()) + .build(); + // 渲染并输出Word文档(使用自定义 Configure) + WordTemplateUtil.renderToResponse("发货单模板.docx", fileName, data, config, response); } } diff --git a/ruoyi-modules/ruoyi-wms/src/main/java/org/dromara/wms/service/impl/WmsShippingBillServiceImpl.java b/ruoyi-modules/ruoyi-wms/src/main/java/org/dromara/wms/service/impl/WmsShippingBillServiceImpl.java index 7b7a3dc1..ba57d569 100644 --- a/ruoyi-modules/ruoyi-wms/src/main/java/org/dromara/wms/service/impl/WmsShippingBillServiceImpl.java +++ b/ruoyi-modules/ruoyi-wms/src/main/java/org/dromara/wms/service/impl/WmsShippingBillServiceImpl.java @@ -6,6 +6,8 @@ import cn.hutool.core.map.MapUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.deepoove.poi.data.RowRenderData; +import com.deepoove.poi.data.Rows; import com.github.yulichang.toolkit.JoinWrappers; import com.github.yulichang.wrapper.MPJLambdaWrapper; import lombok.RequiredArgsConstructor; @@ -382,30 +384,27 @@ public class WmsShippingBillServiceImpl implements IWmsShippingBillService { // 发货方联系电话 data.put("contactNumber", StringUtils.blankToDefault(vo.getContactNumber(), "")); - // === 明细列表 === - // 对应 Word 模板中的 {{#details}}...{{/details}} 循环表格: - // - seq → {{seq}} (行号) - // - materialName → {{materialName}}(物料名称) - // - quantity → {{quantity}} (发货数量) - // - unit → {{unit}} (计量单位) - // - remark → {{remark}} (备注) - // 如需在模板中添加单价、金额、合计行等字段,可在此处扩展 row 中的字段,并在 data 中追加 totalQuantity、totalAmount 等汇总字段。 - List> details = new ArrayList<>(); + // === 明细列表(使用 DynamicTableRenderPolicy 动态表格渲染) === + // 对应 Word 模板中的 {{detailsTable}} 占位符,由 WmsShippingDetailTablePolicy 策略处理 + // 表格列顺序:序号、名称、数量、单位、备注 + List detailRows = new ArrayList<>(); List itemsVo = vo.getItemsVo(); if (CollUtil.isNotEmpty(itemsVo)) { int seq = 1; for (WmsShippingDetailsVo item : itemsVo) { - Map row = new HashMap<>(); - row.put("seq", String.valueOf(seq++)); - row.put("materialName", StringUtils.blankToDefault(item.getMaterialName(), "")); - row.put("quantity", item.getShippingStockAmount() != null ? item.getShippingStockAmount().toPlainString() : ""); - row.put("unit", StringUtils.blankToDefault(item.getUnitName(), "")); - row.put("remark", StringUtils.blankToDefault(item.getRemark(), "")); - details.add(row); + // 构建行数据:序号、名称、数量、单位、备注(poi-tl 1.12.x 使用 Rows.of().create()) + RowRenderData row = Rows.of( + String.valueOf(seq++), + StringUtils.blankToDefault(item.getMaterialName(), ""), + item.getShippingStockAmount() != null ? item.getShippingStockAmount().toPlainString() : "", + StringUtils.blankToDefault(item.getUnitName(), ""), + StringUtils.blankToDefault(item.getRemark(), "") + ).create(); + detailRows.add(row); } } - // poi-tl 列表占位符,对应 Word 模板中的 {{#details}}...{{/details}} - data.put("details", details); + // DynamicTableRenderPolicy 表格占位符,由 WmsShippingDetailTablePolicy 渲染 + data.put("detailsTable", detailRows); return data; } diff --git a/ruoyi-modules/ruoyi-wms/src/main/java/org/dromara/wms/word/WmsShippingDetailTablePolicy.java b/ruoyi-modules/ruoyi-wms/src/main/java/org/dromara/wms/word/WmsShippingDetailTablePolicy.java new file mode 100644 index 00000000..bd100b77 --- /dev/null +++ b/ruoyi-modules/ruoyi-wms/src/main/java/org/dromara/wms/word/WmsShippingDetailTablePolicy.java @@ -0,0 +1,109 @@ +package org.dromara.wms.word; + +import cn.hutool.core.collection.CollUtil; +import com.deepoove.poi.data.RowRenderData; +import com.deepoove.poi.policy.DynamicTableRenderPolicy; +import com.deepoove.poi.policy.TableRenderPolicy; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.apache.poi.xwpf.usermodel.XWPFTableRow; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcBorders; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder; + +import java.util.List; + +/** + * 发货单明细表格渲染策略 + *

+ * 基于 poi-tl 的 DynamicTableRenderPolicy,用于在 Word 模板中动态渲染发货单明细表格。 + * 模板中只需放置一个占位符 {{detailsTable}},本策略会自动删除模板行并插入实际明细数据。 + *

+ * 使用方式: + * 1) Word 模板中画一个 5 列表格(序号/名称/数量/单位/备注),表头行 + 一行模板行(含 {{detailsTable}}) + * 2) Java 代码中将明细数据转换为 List,放入 data.put("detailsTable", rows) + * 3) 使用 Configure.builder().customPolicy("detailsTable", new WmsShippingDetailTablePolicy()).build() 绑定策略 + * + * @author Yinq + * @date 2025-12-11 + */ +@Slf4j +public class WmsShippingDetailTablePolicy extends DynamicTableRenderPolicy { + + /** + * 明细数据起始行索引(0=表头行,1=模板行/数据行) + */ + private static final int DETAIL_START_ROW = 1; + + /** + * 表格列数(序号、名称、数量、单位、备注) + */ + private static final int COLUMN_COUNT = 5; + + @Override + public void render(XWPFTable table, Object data) throws Exception { + if (data == null) { + log.warn("发货单明细数据为空,跳过表格渲染"); + // 删除模板行,保留表头 + if (table.getNumberOfRows() > DETAIL_START_ROW) { + table.removeRow(DETAIL_START_ROW); + } + return; + } + + @SuppressWarnings("unchecked") + List rows = (List) data; + if (CollUtil.isEmpty(rows)) { + log.warn("发货单明细列表为空,跳过表格渲染"); + if (table.getNumberOfRows() > DETAIL_START_ROW) { + table.removeRow(DETAIL_START_ROW); + } + return; + } + + log.info("开始渲染发货单明细表格,共 {} 行", rows.size()); + + // 1. 删除模板行(包含 {{detailsTable}} 占位符的那一行) + table.removeRow(DETAIL_START_ROW); + + // 2. 逐行插入明细数据 + for (int i = 0; i < rows.size(); i++) { + RowRenderData rowData = rows.get(i); + int rowIndex = DETAIL_START_ROW + i; + + // 插入新行 + XWPFTableRow newRow = table.insertNewTableRow(rowIndex); + + // 创建单元格并设置边框 + for (int j = 0; j < COLUMN_COUNT; j++) { + XWPFTableCell cell = newRow.createCell(); + // 设置单元格垂直居中 + cell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER); + // 设置单元格边框(单线边框) + setCellBorder(cell); + } + + // 渲染行数据到单元格(使用 poi-tl 官方 API) + TableRenderPolicy.Helper.renderRow(table.getRow(rowIndex), rowData); + } + + log.info("发货单明细表格渲染完成"); + } + + /** + * 设置单元格四边边框为单线 + * + * @param cell 单元格 + */ + private void setCellBorder(XWPFTableCell cell) { + // 确保 TcPr 存在 + if (cell.getCTTc().getTcPr() == null) { + cell.getCTTc().addNewTcPr(); + } + CTTcBorders borders = cell.getCTTc().getTcPr().addNewTcBorders(); + borders.addNewTop().setVal(STBorder.SINGLE); + borders.addNewBottom().setVal(STBorder.SINGLE); + borders.addNewLeft().setVal(STBorder.SINGLE); + borders.addNewRight().setVal(STBorder.SINGLE); + } +} diff --git a/ruoyi-modules/ruoyi-wms/src/main/resources/发货单模板.docx b/ruoyi-modules/ruoyi-wms/src/main/resources/发货单模板.docx index b2cbd22f..5c20c8ce 100644 Binary files a/ruoyi-modules/ruoyi-wms/src/main/resources/发货单模板.docx and b/ruoyi-modules/ruoyi-wms/src/main/resources/发货单模板.docx differ