|
|
|
|
@ -0,0 +1,242 @@
|
|
|
|
|
package org.dromara.common.word.util;
|
|
|
|
|
|
|
|
|
|
import com.deepoove.poi.XWPFTemplate;
|
|
|
|
|
import jakarta.servlet.http.HttpServletResponse;
|
|
|
|
|
import lombok.AccessLevel;
|
|
|
|
|
import lombok.NoArgsConstructor;
|
|
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
import org.dromara.common.core.exception.ServiceException;
|
|
|
|
|
import org.dromara.common.core.utils.StringUtils;
|
|
|
|
|
import org.dromara.common.core.utils.file.FileUtils;
|
|
|
|
|
|
|
|
|
|
import java.io.ByteArrayOutputStream;
|
|
|
|
|
import java.io.IOException;
|
|
|
|
|
import java.io.InputStream;
|
|
|
|
|
import java.io.OutputStream;
|
|
|
|
|
import java.util.Collections;
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
import java.util.Objects;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Word 模板导出工具(基于 poi-tl)
|
|
|
|
|
*
|
|
|
|
|
* 设计要点:
|
|
|
|
|
* 1) 仅提供模板加载、渲染、响应输出的通用能力,不侵入具体业务字段。
|
|
|
|
|
* 2) 模板文件放在各业务模块的 resources 下(建议放 templates 目录),通过 classpath 路径传入,例如
|
|
|
|
|
* {@code templates/wms_shipping_bill.docx}。
|
|
|
|
|
* 3) 业务方负责组装模板所需的数据 Map(包含普通字段与列表),本工具不约束字段名称。
|
|
|
|
|
* - 普通字段示例:data.put("shippingCode", "X123"); → 模板里使用 {{shippingCode}}
|
|
|
|
|
* - 列表字段示例:data.put("details", detailList); → 模板里使用 {{#details}}...{{/details}}
|
|
|
|
|
* 4) 推荐由 Service 负责组装 data,Controller / 定时任务 / 消息消费端只负责调用本工具进行导出或上传,
|
|
|
|
|
* 降低不同模块之间的耦合度。
|
|
|
|
|
*
|
|
|
|
|
* 简单使用示例(Web 导出):
|
|
|
|
|
* <pre>
|
|
|
|
|
* Map<String, Object> data = new HashMap<>();
|
|
|
|
|
* data.put("shippingCode", "X123");
|
|
|
|
|
* data.put("details", detailList); // 列表占位符示例
|
|
|
|
|
* WordTemplateUtil.renderToResponse("templates/wms_shipping_bill.docx", "发货单_X123", data, response);
|
|
|
|
|
* </pre>
|
|
|
|
|
*
|
|
|
|
|
* 更多背景说明、模板字段约定及发货单完整示例可参考:
|
|
|
|
|
* hwbm-cloud/ruoyi-common/ruoyi-common-word/word.md
|
|
|
|
|
*/
|
|
|
|
|
@Slf4j
|
|
|
|
|
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
|
|
|
|
public final class WordTemplateUtil {
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 渲染模板并输出到 HttpServletResponse(典型用于 Controller 直接导出下载)
|
|
|
|
|
*
|
|
|
|
|
* @param templatePath classpath 下的模板路径(含文件名),如 "templates/wms_shipping_bill.docx"
|
|
|
|
|
* @param fileName 下载文件名(不含后缀),如 "发货单_X123",方法内部自动补全 .docx 后缀
|
|
|
|
|
* @param data 模板数据 Map,业务自行约定占位符字段(支持普通字段和列表字段)
|
|
|
|
|
* @param response HttpServletResponse,由 Spring MVC 自动注入
|
|
|
|
|
*
|
|
|
|
|
* 注意:
|
|
|
|
|
* 1) 本方法内部会重置响应头并向 response 写入二进制流,调用前不要再向 response 写任何内容。
|
|
|
|
|
* 2) templatePath 使用的是 classpath 相对路径,打包后模板应放在各模块的 resources 目录中。
|
|
|
|
|
*/
|
|
|
|
|
public static void renderToResponse(String templatePath, String fileName, Map<String, Object> data, HttpServletResponse response) {
|
|
|
|
|
Objects.requireNonNull(response, "HttpServletResponse must not be null");
|
|
|
|
|
if (StringUtils.isBlank(templatePath)) {
|
|
|
|
|
throw new ServiceException("Word 模板路径不能为空");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 兜底处理:下载文件名为空时使用默认值;模板数据为空时使用空 Map,避免 NPE
|
|
|
|
|
String safeFileName = StringUtils.isBlank(fileName) ? "export" : fileName.trim();
|
|
|
|
|
Map<String, Object> safeData = (data == null ? Collections.emptyMap() : data);
|
|
|
|
|
|
|
|
|
|
try (XWPFTemplate template = buildTemplate(templatePath, safeData);
|
|
|
|
|
OutputStream os = resetResponse(safeFileName, response)) {
|
|
|
|
|
template.write(os);
|
|
|
|
|
os.flush();
|
|
|
|
|
} catch (IOException e) {
|
|
|
|
|
log.error("Word 模板导出失败, templatePath={}, fileName={}", templatePath, safeFileName, e);
|
|
|
|
|
throw new ServiceException("Word 模板导出失败,请联系管理员").setDetailMessage(e.getMessage());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 从 classpath 加载模板文件
|
|
|
|
|
*
|
|
|
|
|
* @param templatePath 模板在 classpath 下的相对路径
|
|
|
|
|
* @return 模板输入流(由调用方负责关闭)
|
|
|
|
|
*/
|
|
|
|
|
private static InputStream getTemplateStream(String templatePath) throws IOException {
|
|
|
|
|
// 使用当前线程的 ClassLoader 查找资源,兼容不同部署环境(应用服务器 / Spring Boot 可执行 Jar 等)
|
|
|
|
|
InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(templatePath);
|
|
|
|
|
if (is == null) {
|
|
|
|
|
throw new IOException("未找到模板文件: " + templatePath);
|
|
|
|
|
}
|
|
|
|
|
return is;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 构建并渲染模板
|
|
|
|
|
*
|
|
|
|
|
* @param templatePath 模板路径(classpath)
|
|
|
|
|
* @param data 模板数据 Map
|
|
|
|
|
* @return 已完成占位符渲染的 XWPFTemplate 对象
|
|
|
|
|
*/
|
|
|
|
|
private static XWPFTemplate buildTemplate(String templatePath, Map<String, Object> data) throws IOException {
|
|
|
|
|
try (InputStream templateStream = getTemplateStream(templatePath)) {
|
|
|
|
|
return XWPFTemplate.compile(templateStream).render(data);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 重置响应头,返回输出流
|
|
|
|
|
*
|
|
|
|
|
* @param fileName 下载文件名(不含后缀或含 .docx 均可)
|
|
|
|
|
* @param response HttpServletResponse
|
|
|
|
|
* @return 已设置好 Content-Type 和 Content-Disposition 的输出流
|
|
|
|
|
*/
|
|
|
|
|
private static OutputStream resetResponse(String fileName, HttpServletResponse response) throws IOException {
|
|
|
|
|
String realName = fileName;
|
|
|
|
|
if (!realName.endsWith(".docx")) {
|
|
|
|
|
realName = realName + ".docx";
|
|
|
|
|
}
|
|
|
|
|
FileUtils.setAttachmentResponseHeader(response, realName);
|
|
|
|
|
response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document;charset=UTF-8");
|
|
|
|
|
return response.getOutputStream();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 渲染模板并输出到指定 OutputStream(不负责关闭 out)
|
|
|
|
|
* 适用于非 HTTP 场景,例如:
|
|
|
|
|
* <ul>
|
|
|
|
|
* <li>生成后上传到 OSS / MinIO 等对象存储;</li>
|
|
|
|
|
* <li>作为 MQ 附件发送;</li>
|
|
|
|
|
* <li>写入本地文件(例如本地调试或定时任务导出)。</li>
|
|
|
|
|
* </ul>
|
|
|
|
|
*
|
|
|
|
|
* @param templatePath classpath 下的模板路径(含文件名)
|
|
|
|
|
* @param data 模板数据 Map
|
|
|
|
|
* @param out 目标输出流,由调用方负责关闭(例如 HttpServletResponse#getOutputStream、
|
|
|
|
|
* FileOutputStream、ByteArrayOutputStream 等)
|
|
|
|
|
*/
|
|
|
|
|
public static void renderToOutputStream(String templatePath, Map<String, Object> data, OutputStream out) {
|
|
|
|
|
Objects.requireNonNull(out, "OutputStream must not be null");
|
|
|
|
|
if (StringUtils.isBlank(templatePath)) {
|
|
|
|
|
throw new ServiceException("Word 模板路径不能为空");
|
|
|
|
|
}
|
|
|
|
|
Map<String, Object> safeData = (data == null ? Collections.emptyMap() : data);
|
|
|
|
|
try (XWPFTemplate template = buildTemplate(templatePath, safeData)) {
|
|
|
|
|
template.write(out);
|
|
|
|
|
out.flush();
|
|
|
|
|
} catch (IOException e) {
|
|
|
|
|
log.error("Word 模板渲染失败, templatePath={}", templatePath, e);
|
|
|
|
|
throw new ServiceException("Word 模板渲染失败,请联系管理员").setDetailMessage(e.getMessage());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 渲染模板并返回字节数组,便于在非 Web 场景中复用
|
|
|
|
|
* <p>
|
|
|
|
|
* 典型使用场景:
|
|
|
|
|
* <ul>
|
|
|
|
|
* <li>先生成字节数组,再统一由 OSS 客户端上传;</li>
|
|
|
|
|
* <li>生成后作为 API 的二进制返回值,由前端自行触发下载;</li>
|
|
|
|
|
* <li>在集成测试中验证模板渲染结果是否符合预期。</li>
|
|
|
|
|
* </ul>
|
|
|
|
|
*
|
|
|
|
|
* @param templatePath classpath 下的模板路径(含文件名)
|
|
|
|
|
* @param data 模板数据 Map
|
|
|
|
|
* @return 渲染后的 Word 文档字节数组
|
|
|
|
|
*/
|
|
|
|
|
public static byte[] renderToBytes(String templatePath, Map<String, Object> data) {
|
|
|
|
|
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
|
|
|
|
renderToOutputStream(templatePath, data, bos);
|
|
|
|
|
return bos.toByteArray();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =====================================================================
|
|
|
|
|
// 典型调用示例(仅作为文档说明,不参与编译):
|
|
|
|
|
// =====================================================================
|
|
|
|
|
/*
|
|
|
|
|
* 1. Controller 中直接导出发货单 Word(最常见场景,推荐其他模块模仿此写法)
|
|
|
|
|
*
|
|
|
|
|
* @GetMapping("/exportShippingBill/{id}")
|
|
|
|
|
* public void exportShippingBill(@PathVariable Long id, HttpServletResponse response) {
|
|
|
|
|
* // 由业务 Service 组装模板所需数据 Map(包含主表字段 + 明细列表等)
|
|
|
|
|
* Map<String, Object> data = wmsShippingBillService.buildWordExportData(id);
|
|
|
|
|
* String templatePath = "templates/wms_shipping_bill.docx"; // 视具体模块而定
|
|
|
|
|
* String fileName = "发货单_" + id; // 或使用业务单号
|
|
|
|
|
* WordTemplateUtil.renderToResponse(templatePath, fileName, data, response);
|
|
|
|
|
* }
|
|
|
|
|
*
|
|
|
|
|
* 2. Service 中生成字节数组并上传 OSS / MinIO(非 Web 场景)
|
|
|
|
|
*
|
|
|
|
|
* public String generateAndUploadShippingBill(Long id) {
|
|
|
|
|
* Map<String, Object> data = wmsShippingBillService.buildWordExportData(id);
|
|
|
|
|
* byte[] bytes = WordTemplateUtil.renderToBytes("templates/wms_shipping_bill.docx", data);
|
|
|
|
|
* // 伪代码:上传到对象存储,返回访问地址
|
|
|
|
|
* String objectKey = "word/shippingBill/" + id + ".docx";
|
|
|
|
|
* return ossClient.upload(bytes, objectKey);
|
|
|
|
|
* }
|
|
|
|
|
*
|
|
|
|
|
* 3. 定时任务 / 工具类中写入本地文件(本地调试场景)
|
|
|
|
|
*
|
|
|
|
|
* public void exportToLocalFile(Long id) throws IOException {
|
|
|
|
|
* Map<String, Object> data = wmsShippingBillService.buildWordExportData(id);
|
|
|
|
|
* try (OutputStream out = Files.newOutputStream(Paths.get("D:/temp/shipping_bill_" + id + ".docx"))) {
|
|
|
|
|
* WordTemplateUtil.renderToOutputStream("templates/wms_shipping_bill.docx", data, out);
|
|
|
|
|
* }
|
|
|
|
|
* }
|
|
|
|
|
*
|
|
|
|
|
* 4. 循环表格 + 合计行示例(以发货单明细为例,适用于发货单 / 合同 / 报价等带明细的单据)
|
|
|
|
|
*
|
|
|
|
|
* // 伪代码:组装发货明细行和合计字段
|
|
|
|
|
* List<Map<String, Object>> details = new ArrayList<>();
|
|
|
|
|
* BigDecimal totalQuantity = BigDecimal.ZERO;
|
|
|
|
|
* BigDecimal totalAmount = BigDecimal.ZERO;
|
|
|
|
|
* int seq = 1;
|
|
|
|
|
* for (WmsShippingDetailsVo item : itemsVo) {
|
|
|
|
|
* Map<String, Object> row = new HashMap<>();
|
|
|
|
|
* row.put("seq", String.valueOf(seq++)); // 行号 → {{seq}}
|
|
|
|
|
* row.put("materialName", item.getMaterialName()); // 物料名称 → {{materialName}}
|
|
|
|
|
* row.put("quantity", item.getShippingStockAmount()); // 数量 → {{quantity}}
|
|
|
|
|
* row.put("unit", item.getUnitName()); // 单位 → {{unit}}
|
|
|
|
|
* row.put("unitPrice", item.getUnitPrice()); // 单价 → {{unitPrice}}
|
|
|
|
|
* BigDecimal amount = item.getUnitPrice()
|
|
|
|
|
* .multiply(item.getShippingStockAmount());
|
|
|
|
|
* row.put("amount", amount); // 金额 → {{amount}}
|
|
|
|
|
* details.add(row);
|
|
|
|
|
*
|
|
|
|
|
* totalQuantity = totalQuantity.add(item.getShippingStockAmount());
|
|
|
|
|
* totalAmount = totalAmount.add(amount);
|
|
|
|
|
* }
|
|
|
|
|
* data.put("details", details); // 循环表格数据源 → {{#details}}...{{/details}}
|
|
|
|
|
* data.put("totalQuantity", totalQuantity.toPlainString()); // 合计数量 → {{totalQuantity}}
|
|
|
|
|
* data.put("totalAmount", totalAmount.toPlainString()); // 合计金额 → {{totalAmount}}
|
|
|
|
|
*
|
|
|
|
|
* // Word 模板表格示意(仅说明占位符,不代表真实表格语法):
|
|
|
|
|
* // ┌────┬────────┬──────┬────┬──────┬───────┐
|
|
|
|
|
* // │ 序号 │ 物料名称 │ 数量 │ 单位 │ 单价 │ 金额 │
|
|
|
|
|
* // ├────┼────────┼──────┼────┼──────┼───────┤
|
|
|
|
|
* // │{{seq}}│{{materialName}}│{{quantity}}│{{unit}}│{{unitPrice}}│{{amount}}│ ← 放在 {{#details}}...{{/details}} 内部
|
|
|
|
|
* // └────┴────────┴──────┴────┴──────┴───────┘
|
|
|
|
|
* // 合计:数量 {{totalQuantity}},金额 {{totalAmount}}
|
|
|
|
|
* */
|
|
|
|
|
}
|