前言

在后台管理系统里,我们经常要把业务数据导出成 Word 文档,比如报价单对账单,发给客户或留档。前段时间,我就接到了这样一个需求:把接受到的表格数据转化为对应的数据并插入到已有的 Word 模版中(我这里是报价单和对账单)。Word 版式复杂、中文字体要求高,如果全靠 Java 代码「画」表格,后期一改版式就要改代码,维护成本很高。

本项目(project-admin 模块;是我自己开发项目的模块名字)采用了一种另外的做法:在 Word 里做好 .docx 模板,后端用 Apache POI 读取模板、按锚点填充数据,最后在内存里生成文件并直接下载。本文把这套实现的来龙去脉讲清楚,大家照着做就能在本项目里跑通,也能迁移到自己的 Spring Boot 项目。

本方案的主要特点:

(1)版式与代码分离:表格边框、宋体、页眉页脚都在 Word 模板里维护,开发只负责填数据。

(2)全内存生成:不落临时文件,byte[] 直接写入 HttpServletResponse,适合容器部署。

(3)样式尽量保留:填充单元格时复制模板原有 Run 样式。中文默认宋体五号,避免 POI 改字后字体跑偏。

(4)业务逻辑内置:报价单合计自动累加并转中文大写;对账单自动写「本单合计」。

(5)表格能按照数据稍微进行调整:根据数据的行数进行调整

说明:pom.xml 里虽然还引入了 poi-tlfreemarkereasyword,但当前 docx 导出链路并未使用,实际只用 Apache POI(poi-ooxml)。本文讲解的是仓库里真实在跑的代码。


前提内容

参考模板

因为需求方那边就描述了个大致内容(单纯本人自己做的)
报价单模板内容

完成之后

自己按照内容做了个小模板
网页请求
下面就是我做出之后的结果
在这里插入图片描述

功能说明

导出报价单(基础)

报价单导出接口为 POST /office/docx/quote。调用方传入 JSON,服务端返回 .docx 附件。

请求体使用统一的 DocxGenerateParam

@Data
public class DocxGenerateParam {

    @ApiModelProperty(value = "模板类型,quote或statement")
    private String templateType;

    @ApiModelProperty(value = "固定信息")
    private Map<String, Object> baseInfo;

    @ApiModelProperty(value = "表格行")
    private List<Map<String, Object>> tableRows;
}

报价单请求 JSON 示例:

{
  "baseInfo": {
    "customerName": "某某科技有限公司",
    "quoteNo": "QT-20250626-001",
    "date": "2025-06-26",
    "paymentMethod": "月结30天",
    "completionTime": "收到订单后7个工作日"
  },
  "tableRows": [
    {
      "productName": "工业传感器",
      "model": "XS-100",
      "unit": "台",
      "quantity": "10",
      "unitPrice": "1280.00",
      "amount": "12800.00"
    },
    {
      "productName": "安装配件包",
      "model": "PK-02",
      "unit": "套",
      "quantity": "2",
      "unitPrice": "350.00",
      "amount": "700.00"
    }
  ]
}

Controller 层代码很简洁,收到参数后交给 Service 生成字节数组,再写回响应:

@ApiOperation("下载报价单docx")
@PostMapping("/quote")
public void downloadQuote(@RequestBody DocxGenerateParam param, HttpServletResponse response) {
    writeResponse(response, adminDocxService.generateQuoteDocx(param), buildQuoteFilename(param));
}

效果说明:打开下载的 docx,表头会显示 TO:客户名 NO:单号 DATE:日期;表格从第 2 行起填入品名、型号、数量、单价、金额;倒数第 2 行写合计(中文大写 + 数字);倒数第 1 行写付款方式和完成时间;页脚日期与 baseInfo.date 同步。


下载页面:这个是我网页点击导出后的结果
下载页面


导出对账单

对账单接口为 POST /office/docx/statement,请求体结构相同,字段含义不同。

对账单请求 JSON 示例:

{
  "baseInfo": {
    "customerName": "某某贸易有限公司",
    "statementNo": "ST-202506-008",
    "date": "2025-06-26",
    "statementMonth": "6"
  },
  "tableRows": [
    {
      "billDate": "2025-06-03",
      "orderNo": "SO-240603-12",
      "deliveryNo": "DN-88921",
      "goodsName": "控制模块 A 型",
      "amount": "5600.00",
      "note": "已签收"
    },
    {
      "billDate": "2025-06-18",
      "orderNo": "SO-240618-03",
      "deliveryNo": "DN-89102",
      "goodsName": "线缆组件",
      "amount": "1200.50",
      "note": ""
    }
  ]
}
@ApiOperation("下载对账单docx")
@PostMapping("/statement")
public void downloadStatement(@RequestBody DocxGenerateParam param, HttpServletResponse response) {
    writeResponse(response, adminDocxService.generateStatementDocx(param), buildStatementFilename(param));
}

效果说明:表头同样写 TO / NO / DATE;标题段落变为 (06)月份对账单;表格填入账单日期、订单号、送货单号、货品、金额、备注;正文「本单合计」段落自动写入累加金额;页脚日期格式化为 2025年06月26日


下载文件名规则

文件名由 buildFilenameByPrefix 自动生成,格式为:

报价单 - {客户名} - {yyyyMMdd}.docx
对账单 - {客户名} - {yyyyMMdd}.docx

客户名中的 \ / : * ? " < > | 会替换为 _,避免操作系统非法字符。日期取上海时区当天。


整体流程

我们来看一下,从前端发请求到拿到 Word 文件,中间经历了哪些步骤:

Apache_POI_XWPF classpath模板docx AdminDocxServiceImpl AdminDocxController 前端或调用方 Apache_POI_XWPF classpath模板docx AdminDocxServiceImpl AdminDocxController 前端或调用方 Body: DocxGenerateParam POST /office/docx/quote 或 /statement generateQuoteDocx / generateStatementDocx ClassPathResource 读取模板 new XWPFDocument 按锚点文本定位段落和表格并填充 byte[] docx 字节 Content-Disposition attachment 流式下载

从这里可以看出,Controller 只负责「接参 + 下载」,真正的 Word 渲染逻辑都在 AdminDocxServiceImpl 里。


如何实现

下面按实际开发顺序,分步说明每一块是怎么做的。

第一步:引入 Maven 依赖

Word 导出依赖 Apache POI,版本在 project-common/pom.xml 中声明为 5.2.2

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>5.2.2</version>
</dependency>
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.2.2</version>
</dependency>
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-scratchpad</artifactId>
    <version>5.2.2</version>
</dependency>
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.11.0</version>
</dependency>

生成 .docx 主要用到 poi-ooxml 里的 XWPFDocumentproject-admin 通过依赖 project-common 间接引入上述包,无需在 admin 模块重复声明。


第二步:准备 Word 模板与锚点约定

模板放在 classpath 下:

project-admin/src/main/resources/templates/docx/quote-template.docx      # 报价单
project-admin/src/main/resources/templates/docx/statement-template.docx  # 对账单

模板在 Word 里排版好。代码不是{{变量}} 占位符,而是通过锚点文本定位要改的位置:

锚点 用途
段落含 TO 改写表头:客户、单号、日期
段落含 月份对账单 对账单标题(仅对账单)
段落含 本单合计 对账单合计金额(仅对账单)
段落含 日期(且不含 DATE/TO) 报价单页脚日期
段落含 日期+++ 对账单页脚日期
第一张表格 填入明细行

大家改模板时,务必保留这些关键字,否则 Java 侧找不到对应段落,数据就填不进去。


第三步:Controller 接收参数并触发下载

AdminDocxController 映射路径 /office/docx,两个 POST 接口分别对应报价单和对账单。核心下载逻辑在 writeResponse

private void writeResponse(HttpServletResponse response, byte[] bytes, String fileName) {
    try {
        String filename = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString());
        response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
        response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
        response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
        response.setContentLength(bytes.length);
        OutputStream outputStream = response.getOutputStream();
        outputStream.write(bytes);
        outputStream.flush();
    } catch (Exception e) {
        throw new RuntimeException("下载文件失败", e);
    }
}

从这里可以看出:

  • Content-Type 必须是 docx 的 MIME,浏览器才会按 Word 文件处理。
  • Content-Disposition: attachment 触发下载而非在线打开。
  • 文件名做了 URLEncoder 编码,避免中文乱码。

第四步:Service 加载模板并填充数据

AdminDocxServiceImpl 是核心。报价单渲染流程如下:

private byte[] renderQuoteTemplate(DocxGenerateParam param) {
    try (InputStream inputStream = new ClassPathResource(QUOTE_TEMPLATE_PATH).getInputStream();
         XWPFDocument document = new XWPFDocument(inputStream);
         ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
        // 1. 解析 baseInfo
        Map<String, Object> baseInfo = safeMap(param.getBaseInfo());
        List<Map<String, Object>> rows = safeRows(param.getTableRows());
        String customer = str(baseInfo, "customerName");
        String quoteNo = str(baseInfo, "quoteNo");
        String date = str(baseInfo, "date");
        // ...

        // 2. 找含 "TO" 的段落,写表头
        for (XWPFParagraph paragraph : document.getParagraphs()) {
            String txt = paragraph.getText();
            if (txt != null && txt.contains("TO")) {
                String line = "TO:" + customer + "    NO:" + quoteNo + "    DATE:" + date;
                setParagraphText(paragraph, line);
                break;
            }
        }

        // 3. 填第一张表:明细 + 合计 + 备注
        if (!document.getTables().isEmpty()) {
            XWPFTable table = document.getTables().get(0);
            int startRow = 1;
            int maxFillRows = Math.max(0, table.getRows().size() - 3);
            BigDecimal total = BigDecimal.ZERO;
            for (int i = 0; i < rows.size() && i < maxFillRows; i++) {
                // setCellText 填序号、品名、型号、单位、数量、单价、金额
                total = total.add(parseDecimal(amount));
            }
            // 倒数第 2 行:中文大写合计
            // 倒数第 1 行:付款方式、完成时间
        }

        // 4. 同步页脚日期
        syncQuoteDateFooter(document, date);
        document.write(outputStream);
        return outputStream.toByteArray();
    } catch (Exception e) {
        throw new RuntimeException("生成报价单失败", e);
    }
}

对账单的 renderStatementTemplate 逻辑类似,额外多了「月份对账单」标题和「本单合计」段落。

报价单表格行数上限maxFillRows = table.getRows().size() - 3,即模板里要预留「表头行 + 若干数据行 + 合计行 + 备注行」。数据行超过预留行数会被截断,不会动态插行——这是固定版式模板的折中做法。


第五步:填充时保留样式(宋体兜底)

POI 直接 setText 容易丢字体。本项目在 setCellText / setParagraphText 里采用「先复制模板 Run 样式,再写新文字」:

private void setCellText(XWPFTableRow row, int index, String text) {
    // ...
    if (!paragraph.getRuns().isEmpty()) {
        templateStyle = extractRunStyle(paragraph.getRuns().get(0));
    }
    // 清空旧 runs
    for (int i = paragraph.getRuns().size() - 1; i >= 0; i--) {
        paragraph.removeRun(i);
    }
    XWPFRun run = paragraph.createRun();
    applyRunStyleOrDefault(run, templateStyle);
    run.setText(text == null ? "" : text);
}

若模板单元格没有可复制的样式,则走 applyDefaultFont:宋体、五号(21 half-points),并设置 eastAsia 字体通道:

private void applyDefaultFont(XWPFRun run) {
    run.setFontFamily(STANDARD_FONT);
    CTRPr rPr = run.getCTR().addNewRPr();
    CTFonts fonts = rPr.addNewRFonts();
    fonts.setAscii(STANDARD_FONT);
    fonts.setHAnsi(STANDARD_FONT);
    fonts.setEastAsia(STANDARD_FONT);
    fonts.setHint(STHint.EAST_ASIA);
    // Sz / SzCs = 21
}

大家如果导出后发现中文变成英文字体,多半是没设 eastAsiahint,可以对照这段代码检查。


第六步:金额累加与中文大写

报价单合计行由服务端计算,不依赖前端传总额:

setCellText(totalRow, 1, toChineseCurrency(total) + "  ¥" + total.toPlainString());

toChineseCurrency13500.00 转成类似 壹万叁仟伍佰元整 的格式,符合国内报价单习惯。对账单则在含「本单合计」的段落写入 本单合计;¥{总额}


注意

使用过程中有几个常见坑,提前说明:

1. 报错 Zip bomb detected

现象:加载模板时抛出 IOException: Zip bomb detected!

原因:POI 5.x 默认 ZipSecureFile.minInflateRatio = 0.01,Office 模板内嵌的高压缩字体资源可能低于该阈值。

处理:本项目在 AdminDocxServiceImpl 静态块中已设置:

ZipSecureFile.setMinInflateRatio(0.005d);

仅适用于内部可信模板,不要随意对不可信上传文件放宽。

2. 中文显示为英文字体

现象:填充后中文变成 Calibri 等英文字体。

原因:Word 中英文字体分 ascii / eastAsia 等通道,只调 setFontFamily 不够。

处理:使用 applyDefaultFont,同时设置 fonts.setEastAsiafonts.setHint(STHint.EAST_ASIA);或优先复制模板原有 CTRPr 样式。

3. 改了模板后数据填不进去

现象:下载的 docx 里表头、合计仍是模板空白或旧占位文字。

原因:代码通过 contains("TO")contains("本单合计")锚点定位段落,模板里删改这些关键字后匹配失败。

处理:改模板时保留锚点,或与开发约定后同步改 Java 匹配逻辑。

4. 报价单明细行超出模板预留行数

现象:后面几行明细没有出现在 Word 里。

原因maxFillRows = table.getRows().size() - 3,超出部分被丢弃。

处理:在 Word 模板里增加数据行,或改为 POI 动态 insertNewTableRow / 迁移 poi-tl 循环表格。


核心代码

下面贴上本项目完整的导出相关源码,方便大家直接对照。有些内容我自己没处理,做得很粗糙。

DocxGenerateParam.java

package com.uptotrap.group.dto;

import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.util.List;
import java.util.Map;

@Data
public class DocxGenerateParam {

    @ApiModelProperty(value = "模板类型,quote或statement")
    private String templateType;

    @ApiModelProperty(value = "固定信息")
    private Map<String, Object> baseInfo;

    @ApiModelProperty(value = "表格行")
    private List<Map<String, Object>> tableRows;
}

AdminDocxController.java

package com.uptotrap.group.controller;

import com.uptotrap.group.dto.DocxGenerateParam;
import com.uptotrap.group.service.AdminDocxService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Map;

@Api(tags = "admin-docx文件生成")
@RestController
@RequestMapping("/office/docx")
public class AdminDocxController {
    private static final ZoneId SHANGHAI_ZONE = ZoneId.of("Asia/Shanghai");

    @Resource
    private AdminDocxService adminDocxService;

    @ApiOperation("下载报价单docx")
    @PostMapping("/quote")
    public void downloadQuote(@RequestBody DocxGenerateParam param, HttpServletResponse response) {
        writeResponse(response, adminDocxService.generateQuoteDocx(param), buildQuoteFilename(param));
    }

    @ApiOperation("下载对账单docx")
    @PostMapping("/statement")
    public void downloadStatement(@RequestBody DocxGenerateParam param, HttpServletResponse response) {
        writeResponse(response, adminDocxService.generateStatementDocx(param), buildStatementFilename(param));
    }

    private void writeResponse(HttpServletResponse response, byte[] bytes, String fileName) {
        try {
            String filename = URLEncoder.encode(fileName,
                    StandardCharsets.UTF_8.toString());
            response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
            response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
            response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
            response.setContentLength(bytes.length);
            OutputStream outputStream = response.getOutputStream();
            outputStream.write(bytes);
            outputStream.flush();
        } catch (Exception e) {
            throw new RuntimeException("下载文件失败", e);
        }
    }

    private String buildQuoteFilename(DocxGenerateParam param) {
        return buildFilenameByPrefix("报价单", param);
    }

    private String buildStatementFilename(DocxGenerateParam param) {
        return buildFilenameByPrefix("对账单", param);
    }

    private String buildFilenameByPrefix(String prefix, DocxGenerateParam param) {
        Map<String, Object> baseInfo = param == null ? null : param.getBaseInfo();
        String customerName = "未命名客户";
        String date = LocalDate.now(SHANGHAI_ZONE).format(DateTimeFormatter.BASIC_ISO_DATE);
        if (baseInfo != null) {
            Object customerObj = baseInfo.get("customerName");
            if (customerObj != null && !String.valueOf(customerObj).trim().isEmpty()) {
                customerName = String.valueOf(customerObj).trim();
            }
        }
        customerName = customerName.replaceAll("[\\\\/:*?\"<>|]", "_");
        return prefix + " - " + customerName + " - " + date + ".docx";
    }
}

AdminDocxServiceImpl.java

package com.uptotrap.group.service.impl;

import com.uptotrap.group.dto.DocxGenerateParam;
import com.uptotrap.group.service.AdminDocxService;
import org.apache.poi.openxml4j.util.ZipSecureFile;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
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.CTFonts;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTHpsMeasure;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STHint;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.math.BigInteger;
import java.util.Collections;
import java.util.List;
import java.util.Map;

@Service
public class AdminDocxServiceImpl implements AdminDocxService {

    private static final String QUOTE_TEMPLATE_PATH = "templates/docx/quote-template.docx";
    private static final String STATEMENT_TEMPLATE_PATH = "templates/docx/statement-template.docx";
    private static final double DOCX_MIN_INFLATE_RATIO = 0.005d;
    private static final String STANDARD_FONT = "宋体";
    private static final int WUHAO_HALF_POINTS = 21; // 五号 = 10.5pt = 21 half-points

    static {
        // Some office templates embed compressed font resources that are below POI default ratio (0.01).
        // Relax to 0.005 for trusted internal templates to avoid false-positive zip bomb detection.
        ZipSecureFile.setMinInflateRatio(DOCX_MIN_INFLATE_RATIO);
    }

    @Override
    public byte[] generateQuoteDocx(DocxGenerateParam param) {
        return renderQuoteTemplate(param);
    }

    @Override
    public byte[] generateStatementDocx(DocxGenerateParam param) {
        return renderStatementTemplate(param);
    }

    private byte[] renderQuoteTemplate(DocxGenerateParam param) {
        try (InputStream inputStream = new ClassPathResource(QUOTE_TEMPLATE_PATH).getInputStream();
             XWPFDocument document = new XWPFDocument(inputStream);
             ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            Map<String, Object> baseInfo = safeMap(param.getBaseInfo());
            List<Map<String, Object>> rows = safeRows(param.getTableRows());
            String customer = str(baseInfo, "customerName");
            String quoteNo = str(baseInfo, "quoteNo");
            String date = str(baseInfo, "date");
            String paymentMethod = str(baseInfo, "paymentMethod");
            String completionTime = str(baseInfo, "completionTime");
            for (XWPFParagraph paragraph : document.getParagraphs()) {
                String txt = paragraph.getText();
                if (txt != null && txt.contains("TO")) {
                    String line = "TO:" + customer + "    NO:" + quoteNo + "    DATE:" + date;
                    setParagraphText(paragraph, line);
                    break;
                }
            }
            if (!document.getTables().isEmpty()) {
                XWPFTable table = document.getTables().get(0);
                int startRow = 1;
                int maxFillRows = Math.max(0, table.getRows().size() - 3);
                BigDecimal total = BigDecimal.ZERO;
                for (int i = 0; i < rows.size() && i < maxFillRows; i++) {
                    Map<String, Object> row = rows.get(i);
                    XWPFTableRow tableRow = table.getRow(startRow + i);
                    setCellText(tableRow, 0, String.valueOf(i + 1));
                    setCellText(tableRow, 1, str(row, "productName"));
                    setCellText(tableRow, 2, str(row, "model"));
                    setCellText(tableRow, 3, str(row, "unit"));
                    setCellText(tableRow, 4, str(row, "quantity"));
                    setCellText(tableRow, 5, str(row, "unitPrice"));
                    String amount = str(row, "amount");
                    setCellText(tableRow, 6, amount);
                    total = total.add(parseDecimal(amount));
                }
                XWPFTableRow totalRow = table.getRow(table.getRows().size() - 2);
                if (totalRow != null) {
                    setCellText(totalRow, 1, toChineseCurrency(total) + "  ¥" + total.toPlainString());
                }
                XWPFTableRow remarkRow = table.getRow(table.getRows().size() - 1);
                if (remarkRow != null && !remarkRow.getTableCells().isEmpty()) {
                    XWPFTableCell remarkCell = remarkRow.getCell(0);
                    if (remarkCell != null && remarkCell.getParagraphs().size() >= 3) {
                        setParagraphText(remarkCell.getParagraphs().get(1),
                                "1.付款方式 " + paymentMethod + "                               2.完成时间 " + completionTime);
                    }
                }
            }
            syncQuoteDateFooter(document, date);
            document.write(outputStream);
            return outputStream.toByteArray();
        } catch (Exception e) {
            throw new RuntimeException("生成报价单失败", e);
        }
    }

    private byte[] renderStatementTemplate(DocxGenerateParam param) {
        try (InputStream inputStream = new ClassPathResource(STATEMENT_TEMPLATE_PATH).getInputStream();
             XWPFDocument document = new XWPFDocument(inputStream);
             ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            Map<String, Object> baseInfo = safeMap(param.getBaseInfo());
            List<Map<String, Object>> rows = safeRows(param.getTableRows());
            String customer = str(baseInfo, "customerName");
            String statementNo = str(baseInfo, "statementNo");
            String date = str(baseInfo, "date");
            String statementMonth = resolveStatementMonth(str(baseInfo, "statementMonth"), date);
            for (XWPFParagraph paragraph : document.getParagraphs()) {
                String txt = paragraph.getText();
                if (txt != null && txt.contains("TO")) {
                    String line = "TO:" + customer + "    NO:" + statementNo + "    DATE:" + date;
                    setParagraphText(paragraph, line);
                    break;
                }
            }
            for (XWPFParagraph paragraph : document.getParagraphs()) {
                String txt = paragraph.getText();
                if (txt != null && normalizeText(txt).contains("月份对账单")) {
                    setParagraphText(paragraph, "(" + statementMonth + ")月份对账单");
                    break;
                }
            }
            BigDecimal total = BigDecimal.ZERO;
            if (!document.getTables().isEmpty()) {
                XWPFTable table = document.getTables().get(0);
                int startRow = 1;
                for (int i = 0; i < rows.size(); i++) {
                    int rowIndex = startRow + i;
                    if (rowIndex >= table.getRows().size()) {
                        break;
                    }
                    Map<String, Object> row = rows.get(i);
                    XWPFTableRow tableRow = table.getRow(rowIndex);
                    setCellText(tableRow, 0, str(row, "billDate"));
                    setCellText(tableRow, 1, str(row, "orderNo"));
                    setCellText(tableRow, 2, str(row, "deliveryNo"));
                    setCellText(tableRow, 3, str(row, "goodsName"));
                    String amount = str(row, "amount");
                    setCellText(tableRow, 4, amount);
                    setCellText(tableRow, 5, str(row, "note"));
                    total = total.add(parseDecimal(amount));
                }
            }
            for (XWPFParagraph paragraph : document.getParagraphs()) {
                String txt = paragraph.getText();
                if (txt != null && txt.contains("本单合计")) {
                    setParagraphText(paragraph, "本单合计;¥" + total.toPlainString());
                    break;
                }
            }
            syncStatementFooterDate(document, date);
            document.write(outputStream);
            return outputStream.toByteArray();
        } catch (Exception e) {
            throw new RuntimeException("生成对账单失败", e);
        }
    }

    private void setCellText(XWPFTableRow row, int index, String text) {
        if (row == null || index >= row.getTableCells().size()) {
            return;
        }
        XWPFTableCell cell = row.getCell(index);
        if (cell == null) {
            return;
        }
        CTRPr templateStyle = null;
        XWPFParagraph paragraph;
        if (cell.getParagraphs().isEmpty()) {
            paragraph = cell.addParagraph();
        } else {
            paragraph = cell.getParagraphs().get(0);
            if (!paragraph.getRuns().isEmpty()) {
                templateStyle = extractRunStyle(paragraph.getRuns().get(0));
            }
            for (int i = paragraph.getRuns().size() - 1; i >= 0; i--) {
                paragraph.removeRun(i);
            }
        }
        XWPFRun run = paragraph.createRun();
        applyRunStyleOrDefault(run, templateStyle);
        run.setText(text == null ? "" : text);
    }

    private void setParagraphText(XWPFParagraph paragraph, String text) {
        CTRPr templateStyle = null;
        if (!paragraph.getRuns().isEmpty()) {
            templateStyle = extractRunStyle(paragraph.getRuns().get(0));
        }
        int runSize = paragraph.getRuns().size();
        for (int i = runSize - 1; i >= 0; i--) {
            paragraph.removeRun(i);
        }
        XWPFRun run = paragraph.createRun();
        applyRunStyleOrDefault(run, templateStyle);
        run.setText(text == null ? "" : text);
    }

    private String str(Map<String, Object> map, String key) {
        Object value = map.get(key);
        return value == null ? "" : String.valueOf(value).trim();
    }

    private Map<String, Object> safeMap(Map<String, Object> map) {
        return map == null ? Collections.emptyMap() : map;
    }

    private List<Map<String, Object>> safeRows(List<Map<String, Object>> rows) {
        return rows == null ? Collections.emptyList() : rows;
    }

    private BigDecimal parseDecimal(String value) {
        try {
            if (value == null || value.trim().isEmpty()) {
                return BigDecimal.ZERO;
            }
            return new BigDecimal(value.trim());
        } catch (Exception e) {
            return BigDecimal.ZERO;
        }
    }

    private void syncQuoteDateFooter(XWPFDocument document, String date) {
        boolean replaced = false;
        for (XWPFParagraph paragraph : document.getParagraphs()) {
            String txt = paragraph.getText();
            if (txt != null
                    && txt.contains("日期")
                    && !txt.contains("DATE")
                    && !txt.contains("TO")) {
                setParagraphText(paragraph, "日期                                                      日期 " + date);
                replaced = true;
            }
        }
        if (!replaced) {
            // Fallback: ensure at least one footer-like date line is present
            for (int i = document.getParagraphs().size() - 1; i >= 0; i--) {
                XWPFParagraph paragraph = document.getParagraphs().get(i);
                String txt = paragraph.getText();
                if (txt != null && !txt.trim().isEmpty()) {
                    setParagraphText(paragraph, "日期                                                      日期 " + date);
                    break;
                }
            }
        }
    }

    private String toChineseCurrency(BigDecimal amount) {
        final String[] digit = {"零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"};
        final String[] unit = {"", "拾", "佰", "仟", "万", "拾", "佰", "仟", "亿"};
        BigDecimal normalized = amount == null ? BigDecimal.ZERO : amount.setScale(2, RoundingMode.HALF_UP);
        long integerPart = normalized.longValue();
        int jiao = normalized.movePointRight(1).abs().intValue() % 10;
        int fen = normalized.movePointRight(2).abs().intValue() % 10;
        if (integerPart == 0) {
            return "零元" + (jiao == 0 && fen == 0 ? "整" : "");
        }
        String intStr = String.valueOf(integerPart);
        StringBuilder intBuilder = new StringBuilder();
        boolean lastZero = false;
        for (int i = 0; i < intStr.length(); i++) {
            int num = intStr.charAt(i) - '0';
            int pos = intStr.length() - 1 - i;
            if (num == 0) {
                if (!lastZero && pos != 4 && pos != 8) {
                    intBuilder.append(digit[0]);
                }
                if (pos == 4 || pos == 8) {
                    intBuilder.append(unit[pos]);
                }
                lastZero = true;
            } else {
                intBuilder.append(digit[num]).append(unit[pos]);
                lastZero = false;
            }
        }
        String result = intBuilder.toString().replaceAll("零+", "零")
                .replaceAll("零万", "万")
                .replaceAll("零亿", "亿")
                .replaceAll("亿万", "亿")
                .replaceAll("零$", "") + "元";
        if (jiao == 0 && fen == 0) {
            return result + "整";
        }
        if (jiao > 0) {
            result += digit[jiao] + "角";
        }
        if (fen > 0) {
            result += digit[fen] + "分";
        }
        return result;
    }

    private String resolveStatementMonth(String statementMonth, String date) {
        if (statementMonth != null) {
            String month = statementMonth.replaceAll("\\D", "");
            if (!month.isEmpty()) {
                return month.length() == 1 ? "0" + month : month.substring(0, 2);
            }
        }
        if (date != null && date.matches("\\d{4}-\\d{2}-\\d{2}")) {
            return date.substring(5, 7);
        }
        return "";
    }

    private void syncStatementFooterDate(XWPFDocument document, String date) {
        String dateCn = formatDateCn(date);
        if (dateCn.isEmpty()) {
            return;
        }
        for (XWPFParagraph paragraph : document.getParagraphs()) {
            String txt = paragraph.getText();
            if (txt != null && txt.contains("日期") && txt.contains("年") && txt.contains("月") && txt.contains("日")) {
                setParagraphText(paragraph, "日期:" + dateCn);
                break;
            }
        }
    }

    private String formatDateCn(String date) {
        if (date == null || !date.matches("\\d{4}-\\d{2}-\\d{2}")) {
            return "";
        }
        return date.substring(0, 4) + "年" + date.substring(5, 7) + "月" + date.substring(8, 10) + "日";
    }

    private String normalizeText(String text) {
        if (text == null) {
            return "";
        }
        return text.replaceAll("\\s+", "");
    }

    private void applyRunStyleOrDefault(XWPFRun targetRun, CTRPr templateStyle) {
        if (templateStyle != null) {
            targetRun.getCTR().setRPr((CTRPr) templateStyle.copy());
            return;
        }
        applyDefaultFont(targetRun);
    }

    private CTRPr extractRunStyle(XWPFRun run) {
        try {
            if (run != null && run.getCTR() != null && run.getCTR().getRPr() != null) {
                return (CTRPr) run.getCTR().getRPr().copy();
            }
        } catch (Exception ignored) {
            // Fall back to default font when source style is unavailable.
        }
        return null;
    }

    private void applyDefaultFont(XWPFRun run) {
        run.setFontFamily(STANDARD_FONT);
        CTRPr rPr = run.getCTR().addNewRPr();
        CTFonts fonts = rPr.addNewRFonts();
        fonts.setAscii(STANDARD_FONT);
        fonts.setHAnsi(STANDARD_FONT);
        fonts.setEastAsia(STANDARD_FONT);
        fonts.setHint(STHint.EAST_ASIA);
        CTHpsMeasure size = rPr.addNewSz();
        size.setVal(BigInteger.valueOf(WUHAO_HALF_POINTS));
        CTHpsMeasure sizeCs = rPr.addNewSzCs();
        sizeCs.setVal(BigInteger.valueOf(WUHAO_HALF_POINTS));
    }
}

写在最后

第一次写博客望大家友好交流,纯新手写手,多多关照

更多推荐