chore(wms): 优化发货单Word模板导出功能,支持动态明细表格渲染

- 升级poi-tl版本至1.12.2,增强模板渲染能力
- 引入自定义DynamicTableRenderPolicy策略WmsShippingDetailTablePolicy
- 在导出发货单Word功能中绑定自定义表格渲染策略
- 将发货单明细数据封装为RowRenderData列表供动态表格渲染使用
- 在WordTemplateUtil中新增支持自定义Configure配置的渲染方法
- 通过动态表格策略,自动删除模板示例行并插入实际明细行
dev
zangch@mesnac.com 5 days ago
parent 9c83573137
commit 2309ba3197

@ -59,7 +59,7 @@
<!-- MyBatis-Plus-Join -->
<mybatis-pj.version>1.5.4</mybatis-pj.version>
<!-- Word 模板导出 -->
<poi-tl.version>1.12.1</poi-tl.version>
<poi-tl.version>1.12.2</poi-tl.version>
<!-- 插件版本 -->
<maven-compiler-plugin.version>3.14.0</maven-compiler-plugin.version>

@ -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<String, Object> 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
* <p>
* 使 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<String, Object> 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<String, Object> 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

@ -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<RowRenderData> 明细列表)
Map<String, Object> 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);
}
}

@ -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<Map<String, Object>> details = new ArrayList<>();
// === 明细列表(使用 DynamicTableRenderPolicy 动态表格渲染) ===
// 对应 Word 模板中的 {{detailsTable}} 占位符,由 WmsShippingDetailTablePolicy 策略处理
// 表格列顺序:序号、名称、数量、单位、备注
List<RowRenderData> detailRows = new ArrayList<>();
List<WmsShippingDetailsVo> itemsVo = vo.getItemsVo();
if (CollUtil.isNotEmpty(itemsVo)) {
int seq = 1;
for (WmsShippingDetailsVo item : itemsVo) {
Map<String, Object> 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;
}

@ -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;
/**
*
* <p>
* poi-tl DynamicTableRenderPolicy Word
* {{detailsTable}}
* <p>
* 使
* 1) Word 5 //// + {{detailsTable}}
* 2) Java List<RowRenderData> 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<RowRenderData> rows = (List<RowRenderData>) 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);
}
}
Loading…
Cancel
Save