package com.emergencyrepair.business.service; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.emergencyrepair.business.entity.CompletionInfo; import com.emergencyrepair.business.entity.Fault; import com.emergencyrepair.business.entity.WorkOrder; import com.emergencyrepair.business.mapper.CompletionInfoMapper; import com.emergencyrepair.business.mapper.FaultMapper; import com.emergencyrepair.business.mapper.WorkOrderMapper; import com.emergencyrepair.business.entity.ProductNode; import com.emergencyrepair.business.mapper.ProductNodeMapper; import com.emergencyrepair.common.BusinessException; import lombok.RequiredArgsConstructor; import org.apache.poi.ooxml.POIXMLProperties; import org.apache.poi.wp.usermodel.HeaderFooterType; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFFooter; import org.apache.poi.xwpf.usermodel.XWPFHeader; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFTable; import org.apache.poi.xwpf.usermodel.XWPFTableCell; import org.apache.poi.xwpf.usermodel.XWPFTableRow; import org.apache.xmlbeans.XmlObject; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBody; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTHdrFtr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRow; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTrPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTbl; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblBorders; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGrid; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblWidth; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblCellMar; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTc; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcMar; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTText; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STYAlign; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STPageOrientation; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTextDirection; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STVerticalJc; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STVAnchor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import javax.annotation.PostConstruct; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.math.BigInteger; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * 工程单打印(需求 32)。 * *

把选中的故障记录按 Word 模板输出成 A4 横向的四联工程单,明细可自动分页。 * 模板里用 {@code {字段名}} 占位,渲染时替换成真实数据。

* *

模板文件默认放在 {@code config/engineering-order-template.docx},首次启动若不存在会自动生成一份, * 运维/业务人员可以直接用 Word 打开修改标题、列宽、联头文字,不需要改代码。

* *

业务配置放在 {@code config/business-config.yml},可按联号维护第 1~4 联名称。

* *

模板约定(包含明细字段的表格):

* */ @Service @RequiredArgsConstructor public class EngineeringOrderPrintService { private static final Logger log = LoggerFactory.getLogger(EngineeringOrderPrintService.class); /** 一个 Word 文件里的联数。 */ private static final int UNIT_COUNT = 4; private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static final Pattern PLACEHOLDER = Pattern.compile("\\{([^{}]*)\\}"); private static final String TEMPLATE_VERSION = "engineering-order-template-v4"; private static final Pattern TEMPLATE_PLACEHOLDER = Pattern.compile("\\{\\{\\s*([^{}]+?)\\s*\\}\\}|\\{\\s*([^{}]+?)\\s*\\}"); /** 只有包含明细字段的表格才按记录数展开最后一行。 */ private static final Set DETAIL_PLACEHOLDERS = new HashSet<>(Arrays.asList( "faultCode", "engineeringNo", "workOrderNo", "equipmentName", "quantity", "equipmentCount", "parentSystem", "engineeringMajor", "faultImpact", "faultNature", "majorFaultDesc", "repairRequirement", "engineeringNameDesc", "isSelfRepair", "isHRelated", "isMonitorItem", "monitorItem", "monitorValue", "requestedFinishDate", "remark", "contractorName", "contractorNames", "startDate", "finishDate", "owner", "reportUnit", "reportDate" )); private static final BigInteger A4_LANDSCAPE_WIDTH = BigInteger.valueOf(16838); private static final BigInteger A4_LANDSCAPE_HEIGHT = BigInteger.valueOf(11906); private static final BigInteger PAGE_MARGIN = BigInteger.valueOf(567); /** 默认模板的联头占位文字。 */ private static final String DEFAULT_UNIT_HEADER = "X号:{xNo} ZB型号:{zbModel} 专业:{teamMajor} 最大要求完工时间:{maxFinishDate}"; /** 默认模板的列定义:标题 + 字段占位符。 */ private static final String[][] DEFAULT_COLUMNS = { {"故障编号", "{faultCode}"}, {"设备名称", "{equipmentName}"}, {"工程名称及损坏情况", "{engineeringNameDesc}"}, {"修理技术要求", "{repairRequirement}"}, {"承修单位", "{contractorName}"}, {"数量", "{quantity}"}, {"备注", "{remark}"} }; /** 默认模板的列宽(twips),合计约等于 A4 横向可用宽度。 */ private static final int UNIT_COLUMN_WIDTH = 400; private static final int[] TABLE_COLUMN_WIDTHS = {1450, 2750, 3300, 3300, 2000, 800, 1700, UNIT_COLUMN_WIDTH}; private final FaultMapper faultMapper; private final WorkOrderMapper workOrderMapper; private final CompletionInfoMapper completionInfoMapper; private final ProductNodeMapper productNodeMapper; @Value("${app.engineering-print.template-path:${app.engineering-print.template:config/engineering-order-template.docx}}") private String templateLocation; @Value("${app.engineering-print.unit-names.1:上报人}") private String unitName1; @Value("${app.engineering-print.unit-names.2:}") private String unitName2; @Value("${app.engineering-print.unit-names.3:}") private String unitName3; @Value("${app.engineering-print.unit-names.4:}") private String unitName4; @PostConstruct public void ensureTemplate() { Path path = templatePath(); if (path == null) { return; } try { Files.createDirectories(path.getParent()); if (Files.exists(path)) { return; } try (XWPFDocument document = buildDefaultTemplate(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { document.write(out); Files.write(path, out.toByteArray()); } log.info("工程单打印模板已生成或升级:{}", path.toAbsolutePath()); } catch (Exception exception) { log.warn("生成或升级工程单打印模板失败:{}", exception.getMessage()); } } private Map loadUnitNames() { Map names = new LinkedHashMap<>(); names.put(1, text(unitName1).trim()); names.put(2, text(unitName2).trim()); names.put(3, text(unitName3).trim()); names.put(4, text(unitName4).trim()); return names; } private boolean templateNeedsUpgrade(Path path) { try (InputStream input = Files.newInputStream(path); XWPFDocument document = new XWPFDocument(input)) { POIXMLProperties.CoreProperties properties = document.getProperties().getCoreProperties(); return !TEMPLATE_VERSION.equals(properties.getKeywords()) || collectText(document).indexOf("{zbModel}") < 0 || collectText(document).indexOf("{unitDisplay}") < 0; } catch (Exception exception) { log.warn("工程单打印模板无法读取,将重新生成:{}", exception.getMessage()); return true; } } /** * 按选中的故障记录渲染工程单 Word 文档。 * * @param faultIds 故障主键 */ public byte[] render(List faultIds) { return renderInternal(faultIds, false); } /** * 打印工程单2:按联分组输出,第1联全部明细后换页,再输出第2联,以此类推。 */ public byte[] renderVersion2(List faultIds) { return renderInternal(faultIds, true); } private byte[] renderInternal(List faultIds, boolean legacyGroupedUnits) { List ids = normalizeIds(faultIds); if (ids.isEmpty()) { throw new BusinessException("请至少选择一条记录后再打印"); } List> records = loadRecords(ids); if (records.isEmpty()) { throw new BusinessException("选中的记录已不存在,请刷新后重试"); } Map unitNames = loadUnitNames(); try (InputStream template = openTemplate(); XWPFDocument document = new XWPFDocument(template); ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { if (legacyGroupedUnits) { renderLegacyGroupedUnits(document, records, unitNames); } else { renderUnits(document, records, unitNames); } document.write(buffer); return buffer.toByteArray(); } catch (IOException exception) { throw new BusinessException("生成工程单失败:" + exception.getMessage()); } } private List normalizeIds(List faultIds) { if (faultIds == null) { return new ArrayList<>(); } List ids = new ArrayList<>(); for (Long id : faultIds) { if (id != null && !ids.contains(id)) { ids.add(id); } } return ids; } private List> loadRecords(List ids) { List faults = faultMapper.selectBatchIds(ids); Map faultById = new LinkedHashMap<>(); for (Fault fault : faults) { faultById.put(fault.getId(), fault); } Map zbModelByXNo = loadZbModels(faults); List workOrders = workOrderMapper.selectList(Wrappers.lambdaQuery() .in(WorkOrder::getFaultId, ids)); Map workOrderByFault = new LinkedHashMap<>(); List workOrderIds = new ArrayList<>(); for (WorkOrder workOrder : workOrders) { workOrderByFault.putIfAbsent(workOrder.getFaultId(), workOrder); if (workOrder.getId() != null) { workOrderIds.add(workOrder.getId()); } } Map completionByWorkOrder = new LinkedHashMap<>(); if (!workOrderIds.isEmpty()) { List completions = completionInfoMapper.selectList(Wrappers.lambdaQuery() .in(CompletionInfo::getWorkOrderId, workOrderIds)); for (CompletionInfo completion : completions) { completionByWorkOrder.putIfAbsent(completion.getWorkOrderId(), completion); } } LocalDate maxFinishDate = null; for (CompletionInfo completion : completionByWorkOrder.values()) { if (completion.getFinishDate() != null && (maxFinishDate == null || completion.getFinishDate().isAfter(maxFinishDate))) { maxFinishDate = completion.getFinishDate(); } } if (maxFinishDate == null) { for (Long id : ids) { Fault fault = faultById.get(id); if (fault != null && fault.getRequestedFinishDate() != null && (maxFinishDate == null || fault.getRequestedFinishDate().isAfter(maxFinishDate))) { maxFinishDate = fault.getRequestedFinishDate(); } } } List> records = new ArrayList<>(); for (Long id : ids) { Fault fault = faultById.get(id); if (fault == null) { continue; } WorkOrder workOrder = workOrderByFault.get(id); CompletionInfo completion = workOrder == null ? null : completionByWorkOrder.get(workOrder.getId()); String zbModel = zbModelByXNo.get(normalizeXNo(fault.getXNo())); records.add(buildRecord(fault, workOrder, completion, maxFinishDate, zbModel)); } return records; } private Map loadZbModels(List faults) { Map result = new LinkedHashMap<>(); List xNos = new ArrayList<>(); for (Fault fault : faults) { String xNo = normalizeXNo(fault.getXNo()); if (StringUtils.hasText(xNo) && !xNos.contains(xNo)) { xNos.add(xNo); } } if (xNos.isEmpty()) { return result; } List sideNodes = productNodeMapper.selectList(Wrappers.lambdaQuery() .in(ProductNode::getLayer, java.util.Arrays.asList("side", "SIDE")) .in(ProductNode::getName, xNos) .ne(ProductNode::getIsDelete, "1") .orderByAsc(ProductNode::getSortNo) .orderByAsc(ProductNode::getId)); Map parentIdsByXNo = new LinkedHashMap<>(); for (ProductNode side : sideNodes) { String xNo = normalizeXNo(side.getName()); if (StringUtils.hasText(xNo) && side.getParentId() != null && side.getParentId() != 0L) { parentIdsByXNo.putIfAbsent(xNo, side.getParentId()); } } if (parentIdsByXNo.isEmpty()) { return result; } List parentIds = new ArrayList<>(new java.util.LinkedHashSet<>(parentIdsByXNo.values())); List parents = productNodeMapper.selectBatchIds(parentIds); Map parentNameById = new LinkedHashMap<>(); for (ProductNode parent : parents) { if (!"1".equals(parent.getIsDelete())) { parentNameById.put(parent.getId(), text(parent.getName())); } } for (Map.Entry entry : parentIdsByXNo.entrySet()) { result.put(entry.getKey(), text(parentNameById.get(entry.getValue()))); } return result; } private String normalizeXNo(String value) { return StringUtils.hasText(value) ? value.trim() : ""; } private Map buildRecord(Fault fault, WorkOrder workOrder, CompletionInfo completion, LocalDate maxFinishDate, String zbModel) { Map values = new LinkedHashMap<>(); String contractor = firstText( workOrder == null ? null : workOrder.getContractorName(), completion == null ? null : completion.getContractorName(), fault.getContractorNames()); String engineeringNo = firstText( completion == null ? null : completion.getEngineeringNo(), fault.getEngineeringNo()); values.put("faultCode", text(fault.getFaultCode())); values.put("engineeringNo", text(engineeringNo)); values.put("workOrderNo", text(workOrder == null ? null : workOrder.getWorkOrderNo())); values.put("equipmentName", text(fault.getEquipmentName())); values.put("quantity", String.valueOf(equipmentQuantity(fault))); values.put("equipmentCount", values.get("quantity")); values.put("parentSystem", text(fault.getParentSystem())); values.put("engineeringMajor", text(fault.getEngineeringMajor())); values.put("teamMajor", text(fault.getTeamMajor())); values.put("xNo", text(fault.getXNo())); values.put("zbModel", text(zbModel)); values.put("owner", text(fault.getOwner())); values.put("reportUnit", text(fault.getReportUnit())); values.put("reportDate", date(fault.getReportDate())); values.put("faultImpact", text(fault.getFaultImpact())); values.put("faultNature", text(fault.getFaultNature())); values.put("majorFaultDesc", text(fault.getMajorFaultDesc())); values.put("repairRequirement", text(fault.getRepairRequirement())); values.put("engineeringNameDesc", text(fault.getEngineeringNameDesc())); values.put("isSelfRepair", yesNo(fault.getIsSelfRepair())); values.put("isHRelated", yesNo(fault.getIsHRelated())); values.put("isMonitorItem", yesNo(fault.getIsMonitorItem())); values.put("monitorItem", text(fault.getMonitorItem())); values.put("monitorValue", text(fault.getMonitorValue())); values.put("requestedFinishDate", date(fault.getRequestedFinishDate())); values.put("remark", text(fault.getRemark())); values.put("contractorName", text(contractor)); values.put("contractorNames", text(fault.getContractorNames())); values.put("startDate", date(completion == null ? null : completion.getStartDate())); values.put("finishDate", date(completion == null ? null : completion.getFinishDate())); values.put("maxFinishDate", date(maxFinishDate)); return values; } private int equipmentQuantity(Fault fault) { String equipmentIds = fault.getEquipmentIds(); int count = 0; if (StringUtils.hasText(equipmentIds)) { for (String item : equipmentIds.split(",")) { if (StringUtils.hasText(item)) { count++; } } } if (count == 0 && fault.getEquipmentId() != null) { count = 1; } return count; } private void renderUnits(XWPFDocument document, List> records, Map unitNames) { CTBody body = document.getDocument().getBody(); List templateElements = snapshotBodyElements(document); if (templateElements.isEmpty()) { throw new BusinessException("Engineering order template is empty: " + templateLocation); } int detailTableIndex = findDetailTableIndex(templateElements); if (detailTableIndex < 0) { throw new BusinessException("Engineering order template has no detail table: " + templateLocation); } int bottomTableIndex = findBottomTableIndex(templateElements, detailTableIndex); Map shared = new LinkedHashMap<>(records.get(0)); CTSectPr section = body.isSetSectPr() ? (CTSectPr) body.getSectPr().copy() : null; CTBody cleanBody = CTBody.Factory.newInstance(); if (section != null) { cleanBody.addNewSectPr().set(section); } body.set(cleanBody); // Render each dynamically paginated detail page as four consecutive units. renderPagedUnits(body, (CTTbl) templateElements.get(detailTableIndex), records, unitNames, section); moveSectionPropertiesToEnd(body); copyTemplatePrefixToHeader(document, templateElements, detailTableIndex, shared, records); if (bottomTableIndex >= 0) { copyTemplateTableToFooter(document, (CTTbl) templateElements.get(bottomTableIndex), shared, records); } } private void renderPagedUnits(CTBody body, CTTbl templateTable, List> records, Map unitNames, CTSectPr section) { List>> pages = paginateRecords(templateTable, records, section); int renderedPage = 0; for (List> page : pages) { for (int unit = 1; unit <= UNIT_COUNT; unit++) { String unitName = text(unitNames.get(unit)).trim(); Map unitValues = new LinkedHashMap<>(); unitValues.put("unitNo", String.valueOf(unit)); unitValues.put("unitName", unitName); unitValues.put("unitDisplay", "\u7b2c" + unit + "\u8054" + (StringUtils.hasText(unitName) ? " " + unitName : "")); List> unitRecords = new ArrayList<>(); for (Map record : page) { Map unitRecord = new LinkedHashMap<>(record); unitRecord.putAll(unitValues); unitRecords.add(unitRecord); } CTTbl table = body.addNewTbl(); table.set(templateTable); clearTablePosition(table); renderTable(table, unitValues, unitRecords); int headerRowCount = table.sizeOfTrArray() - unitRecords.size(); configureUnitTablePagination(table, renderedPage > 0, headerRowCount); configureUnitColumnVerticalMerge(table); renderedPage++; } } } private List>> paginateRecords(CTTbl templateTable, List> records, CTSectPr section) { List>> pages = new ArrayList<>(); if (records.isEmpty() || templateTable.sizeOfTrArray() == 0) { return pages; } List columnWidths = tableColumnWidths(templateTable); int dataRowIndex = templateTable.sizeOfTrArray() - 1; int headerHeight = 0; for (int i = 0; i < dataRowIndex; i++) { headerHeight += estimateRowHeight(templateTable.getTrArray(i), columnWidths, records.get(0)); } int availableHeight = Math.max(1200, (int) ((pageContentHeight(section) - headerHeight) * 0.50)); List> current = new ArrayList<>(); int usedHeight = 0; for (Map record : records) { int rowHeight = estimateRowHeight(templateTable.getTrArray(dataRowIndex), columnWidths, record); if (!current.isEmpty() && usedHeight + rowHeight > availableHeight) { pages.add(current); current = new ArrayList<>(); usedHeight = 0; } current.add(record); usedHeight += rowHeight; } if (!current.isEmpty()) { pages.add(current); } return pages; } private List tableColumnWidths(CTTbl table) { List widths = new ArrayList<>(); CTTblGrid grid = table.getTblGrid(); if (grid != null) { for (int i = 0; i < grid.sizeOfGridColArray(); i++) { Object value = grid.getGridColArray(i).getW(); widths.add(value == null ? 0 : new BigInteger(String.valueOf(value)).intValue()); } } if (widths.isEmpty()) { for (int width : TABLE_COLUMN_WIDTHS) { widths.add(width); } } return widths; } private int pageContentHeight(CTSectPr section) { int height = A4_LANDSCAPE_HEIGHT.intValue(); int top = PAGE_MARGIN.intValue(); int bottom = PAGE_MARGIN.intValue(); if (section != null) { if (section.isSetPgSz() && section.getPgSz().getH() != null) { height = new BigInteger(String.valueOf(section.getPgSz().getH())).intValue(); } if (section.isSetPgMar()) { if (section.getPgMar().getTop() != null) { top = new BigInteger(String.valueOf(section.getPgMar().getTop())).intValue(); } if (section.getPgMar().getBottom() != null) { bottom = new BigInteger(String.valueOf(section.getPgMar().getBottom())).intValue(); } } } return Math.max(1200, height - top - bottom); } private int estimateRowHeight(CTRow row, List columnWidths, Map values) { int maxHeight = 0; int gridIndex = 0; for (CTTc cell : row.getTcList()) { int gridSpan = 1; if (cell.isSetTcPr() && cell.getTcPr().isSetGridSpan() && cell.getTcPr().getGridSpan().getVal() != null) { gridSpan = new BigInteger(String.valueOf(cell.getTcPr().getGridSpan().getVal())).intValue(); } int cellWidth = cellWidthTwips(cell, columnWidths, gridIndex, gridSpan); int cellHeight = 0; for (CTP paragraph : cell.getPList()) { String paragraphText = substitute(paragraphText(paragraph), values); double fontSize = paragraphFontSize(paragraph, 9.0); double usableWidth = Math.max(8.0, cellWidth / 20.0 - 2.0); int lines = Math.max(1, (int) Math.ceil(textUnits(paragraphText) * fontSize / usableWidth)); cellHeight += (int) Math.ceil(lines * fontSize * 1.28 * 20.0) + 20; } maxHeight = Math.max(maxHeight, cellHeight); gridIndex += gridSpan; } return Math.max(240, maxHeight); } private int cellWidthTwips(CTTc cell, List columnWidths, int gridIndex, int gridSpan) { if (cell.isSetTcPr() && cell.getTcPr().isSetTcW()) { Object width = cell.getTcPr().getTcW().getW(); if (width != null && (!cell.getTcPr().getTcW().isSetType() || cell.getTcPr().getTcW().getType() == STTblWidth.DXA)) { return new BigInteger(String.valueOf(width)).intValue(); } } int width = 0; for (int i = gridIndex; i < gridIndex + gridSpan && i < columnWidths.size(); i++) { width += columnWidths.get(i); } return width > 0 ? width : 1000; } private String paragraphText(CTP paragraph) { StringBuilder builder = new StringBuilder(); for (CTR run : paragraph.getRList()) { for (CTText text : run.getTList()) { builder.append(text.getStringValue()); } } return builder.toString(); } private double paragraphFontSize(CTP paragraph, double fallback) { for (CTR run : paragraph.getRList()) { if (run.isSetRPr() && run.getRPr().isSetSz() && run.getRPr().getSz().getVal() != null) { return new BigInteger(String.valueOf(run.getRPr().getSz().getVal())).intValue() / 2.0; } } return fallback; } private double textUnits(String value) { double units = 0; for (int i = 0; i < value.length(); i++) { char character = value.charAt(i); if (character == '\r' || character == '\n') { continue; } if (character == ' ') { units += 0.5; } else if (character >= 0x2e80 || Character.isIdeographic(character)) { units += 1.0; } else { units += 0.58; } } return units; } private void renderInterleavedUnitTable(CTBody body, CTTbl templateTable, List> records, Map unitNames) { List> repeatedRecords = repeatRecordsByUnit(records, unitNames); CTTbl table = body.addNewTbl(); table.set(templateTable); clearTablePosition(table); Map headerValues = new LinkedHashMap<>(records.get(0)); headerValues.put("unitDisplay", "联号/联名"); renderTable(table, headerValues, repeatedRecords); configureRecordRows(table, table.sizeOfTrArray() - repeatedRecords.size()); } private List> repeatRecordsByUnit(List> records, Map unitNames) { List> repeatedRecords = new ArrayList<>(); for (Map record : records) { for (int unit = 1; unit <= UNIT_COUNT; unit++) { Map values = new LinkedHashMap<>(record); String unitName = text(unitNames.get(unit)).trim(); values.put("unitNo", String.valueOf(unit)); values.put("unitName", unitName); values.put("unitDisplay", "第" + unit + "联" + (StringUtils.hasText(unitName) ? " " + unitName : "")); repeatedRecords.add(values); } } return repeatedRecords; } private void renderLegacyGroupedUnits(XWPFDocument document, List> records, Map unitNames) { CTBody body = document.getDocument().getBody(); List templateElements = snapshotBodyElements(document); if (templateElements.isEmpty()) { throw new BusinessException("Engineering order template is empty: " + templateLocation); } int detailTableIndex = findDetailTableIndex(templateElements); if (detailTableIndex < 0) { throw new BusinessException("Engineering order template has no detail table: " + templateLocation); } int bottomTableIndex = findBottomTableIndex(templateElements, detailTableIndex); Map shared = new LinkedHashMap<>(records.get(0)); // The first and last template tables are page chrome. Put them into the // Word header/footer so Word repeats them on every generated page. copyTemplatePrefixToHeader(document, templateElements, detailTableIndex, shared, records); if (bottomTableIndex >= 0) { copyTemplateTableToFooter(document, (CTTbl) templateElements.get(bottomTableIndex), shared, records); } CTSectPr section = body.isSetSectPr() ? (CTSectPr) body.getSectPr().copy() : null; CTBody cleanBody = CTBody.Factory.newInstance(); if (section != null) { cleanBody.addNewSectPr().set(section); } body.set(cleanBody); for (int unit = 1; unit <= UNIT_COUNT; unit++) { if (unit > 1) { appendPageBreakParagraph(body); } String unitName = text(unitNames.get(unit)).trim(); Map unitValues = new LinkedHashMap<>(); unitValues.put("unitNo", String.valueOf(unit)); unitValues.put("unitName", unitName); unitValues.put("unitDisplay", "第" + unit + "联" + (StringUtils.hasText(unitName) ? " " + unitName : "")); Map values = new LinkedHashMap<>(shared); values.putAll(unitValues); List> unitRecords = new ArrayList<>(); for (Map record : records) { Map unitRecord = new LinkedHashMap<>(record); unitRecord.putAll(unitValues); unitRecords.add(unitRecord); } CTTbl table = body.addNewTbl(); table.set(templateElements.get(detailTableIndex)); clearTablePosition(table); renderTable(table, values, unitRecords); configureRecordRows(table, table.sizeOfTrArray() - unitRecords.size()); configureUnitColumnVerticalMerge(table); } moveSectionPropertiesToEnd(body); } private List snapshotBodyElements(XWPFDocument document) { List elements = new ArrayList<>(); for (Object element : document.getBodyElements()) { if (element instanceof XWPFParagraph) { elements.add(((XWPFParagraph) element).getCTP().copy()); } else if (element instanceof XWPFTable) { elements.add(((XWPFTable) element).getCTTbl().copy()); } } return elements; } private int findDetailTableIndex(List elements) { for (int i = 0; i < elements.size(); i++) { XmlObject element = elements.get(i); if (element instanceof CTTbl && containsDetailPlaceholder((CTTbl) element)) { return i; } } return -1; } private int findBottomTableIndex(List elements, int detailTableIndex) { int bottomTableIndex = -1; for (int i = detailTableIndex + 1; i < elements.size(); i++) { if (elements.get(i) instanceof CTTbl) { bottomTableIndex = i; } } return bottomTableIndex; } private void renderDetailTables(CTBody body, CTTbl table, List> records, Map unitNames) { int rowCount = table.sizeOfTrArray(); if (rowCount == 0) { return; } int headerRowCount = rowCount - 1; List headerRows = new ArrayList<>(); for (int i = 0; i < headerRowCount; i++) { headerRows.add((CTRow) table.getTrArray(i).copy()); } CTRow dataRow = (CTRow) table.getTrArray(rowCount - 1).copy(); CTTbl rowTemplate = (CTTbl) table.copy(); while (rowTemplate.sizeOfTrArray() > 0) { rowTemplate.removeTr(0); } // Each unit is one continuously paginated table. Word fits as many detail // rows per page as their actual rendered heights allow; cantSplit keeps each // individual detail row intact across page boundaries. for (int unit = 1; unit <= UNIT_COUNT; unit++) { String unitName = text(unitNames.get(unit)).trim(); Map unitValues = new LinkedHashMap<>(); unitValues.put("unitNo", String.valueOf(unit)); unitValues.put("unitName", unitName); unitValues.put("unitDisplay", "\u7b2c" + unit + "\u8054" + (StringUtils.hasText(unitName) ? " " + unitName : "")); CTTbl unitTable = (CTTbl) rowTemplate.copy(); for (CTRow headerRow : headerRows) { CTRow renderedHeader = unitTable.addNewTr(); renderedHeader.set(headerRow); replaceInRow(renderedHeader, unitValues); } for (Map record : records) { Map values = new LinkedHashMap<>(record); values.putAll(unitValues); CTRow renderedData = unitTable.addNewTr(); renderedData.set(dataRow); replaceInRow(renderedData, values); } configureUnitTablePagination(unitTable, unit > 1, headerRows.size()); body.addNewTbl().set(unitTable); } } private void configureUnitTablePagination(CTTbl table, boolean pageBreakBefore, int headerRowCount) { configureRecordRows(table, headerRowCount); if (!pageBreakBefore || table.sizeOfTrArray() == 0) { return; } CTRow firstRow = table.getTrArray(0); if (firstRow.sizeOfTcArray() == 0) { return; } CTTc firstCell = firstRow.getTcArray(0); if (firstCell.sizeOfPArray() == 0) { firstCell.addNewP(); } setPageBreakBefore(firstCell.getPArray(0)); } /** Merge the final unit-number column across every row in one unit table. */ private void configureUnitColumnVerticalMerge(CTTbl table) { int rowCount = table.sizeOfTrArray(); for (int i = 0; i < rowCount; i++) { CTRow row = table.getTrArray(i); if (row.sizeOfTcArray() == 0) { continue; } CTTc unitCell = row.getTcArray(row.sizeOfTcArray() - 1); CTTcPr properties = unitCell.isSetTcPr() ? unitCell.getTcPr() : unitCell.addNewTcPr(); if (properties.isSetVMerge()) { properties.unsetVMerge(); } properties.addNewVMerge().setVal(i == 0 ? STMerge.RESTART : STMerge.CONTINUE); if (!properties.isSetTextDirection()) { properties.addNewTextDirection(); } properties.getTextDirection().setVal(STTextDirection.BT_LR); if (!properties.isSetVAlign()) { properties.addNewVAlign(); } properties.getVAlign().setVal(STVerticalJc.CENTER); } } private void configureRecordRows(CTTbl table, int headerRowCount) { clearTablePosition(table); for (int i = 0; i < table.sizeOfTrArray(); i++) { CTRow row = table.getTrArray(i); CTTrPr properties = row.isSetTrPr() ? row.getTrPr() : row.addNewTrPr(); if (properties.sizeOfCantSplitArray() == 0) { properties.addNewCantSplit(); } while (properties.sizeOfTblHeaderArray() > 0) { properties.removeTblHeader(0); } if (i < headerRowCount) { properties.addNewTblHeader(); } configureCellParagraphPagination(row, false); } } private int tableWidth(CTTbl table) { CTTblGrid grid = table.getTblGrid(); if (grid != null) { int width = 0; for (int i = 0; i < grid.sizeOfGridColArray(); i++) { Object value = grid.getGridColArray(i).getW(); if (value != null) { width += new BigInteger(String.valueOf(value)).intValue(); } } if (width > 0) { return width; } } return totalWidth(TABLE_COLUMN_WIDTHS); } private void noBorder(org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder border) { border.setVal(STBorder.NIL); border.setSz(BigInteger.ZERO); border.setSpace(BigInteger.ZERO); } private void zeroMargin(CTTblWidth margin) { margin.setType(STTblWidth.DXA); margin.setW(BigInteger.ZERO); } private void configureCellParagraphPagination(CTRow row, boolean keepWithNext) { // Keep the complete row together. Word measures the final rendered text height dynamically, // so no fixed row height or per-page record count is needed. for (CTTc cell : row.getTcList()) { for (CTP paragraph : cell.getPList()) { CTPPr properties = paragraph.isSetPPr() ? paragraph.getPPr() : paragraph.addNewPPr(); if (!properties.isSetKeepLines()) { properties.addNewKeepLines(); } if (properties.isSetKeepNext()) { properties.unsetKeepNext(); } if (keepWithNext) { properties.addNewKeepNext(); } } } } private void copyTemplatePrefixToHeader(XWPFDocument document, List elements, int detailTableIndex, Map values, List> records) { XWPFHeader header = document.createHeader(HeaderFooterType.DEFAULT); CTHdrFtr headerContent = header._getHdrFtr(); for (int i = 0; i < detailTableIndex; i++) { XmlObject element = elements.get(i); if (element instanceof CTP) { CTP paragraph = headerContent.addNewP(); paragraph.set(element); replaceInParagraph(paragraph, values); } else if (element instanceof CTTbl) { appendHeaderFooterTable(headerContent, (CTTbl) element, values, records); } } } private void copyTemplateTableToFooter(XWPFDocument document, CTTbl source, Map values, List> records) { XWPFFooter footer = document.createFooter(HeaderFooterType.DEFAULT); appendHeaderFooterTable(footer._getHdrFtr(), source, values, records); } private void appendHeaderFooterTable(CTHdrFtr content, CTTbl source, Map values, List> records) { CTTbl table = content.addNewTbl(); table.set(source); clearTablePosition(table); renderTable(table, values, records); } private void clearTablePosition(CTTbl table) { CTTblPr properties = table.getTblPr() != null ? table.getTblPr() : table.addNewTblPr(); if (properties.isSetTblpPr()) { properties.unsetTblpPr(); } } private void renderTable(CTTbl table, Map unitValues, List> records) { boolean expand = containsDetailPlaceholder(table); int rowCount = table.sizeOfTrArray(); if (rowCount == 0) { return; } if (expand) { CTRow dataRow = table.getTrArray(rowCount - 1); for (CTTc cell : dataRow.getTcList()) { CTTcPr properties = cell.isSetTcPr() ? cell.getTcPr() : cell.addNewTcPr(); if (properties.isSetVMerge()) { properties.unsetVMerge(); } } } int dataRowIndex = rowCount - 1; if (expand) { CTRow dataRow = table.getTrArray(dataRowIndex); for (int i = 0; i < records.size(); i++) { table.addNewTr().set(dataRow); } table.removeTr(dataRowIndex); } for (int i = 0; i < table.sizeOfTrArray(); i++) { boolean dataRow = expand && i >= table.sizeOfTrArray() - records.size(); Map values = dataRow ? records.get(i - (table.sizeOfTrArray() - records.size())) : unitValues; replaceInRow(table.getTrArray(i), values); } } private boolean containsDetailPlaceholder(CTTbl table) { StringBuilder builder = new StringBuilder(); for (CTRow row : table.getTrList()) { for (CTTc cell : row.getTcList()) { for (CTP paragraph : cell.getPList()) { for (CTR run : paragraph.getRList()) { for (CTText text : run.getTList()) { builder.append(text.getStringValue()); } } } } } Matcher matcher = TEMPLATE_PLACEHOLDER.matcher(builder.toString()); while (matcher.find()) { String key = matcher.group(1) != null ? matcher.group(1) : matcher.group(2); if (DETAIL_PLACEHOLDERS.contains(key.trim())) { return true; } } return false; } private void replaceInRow(CTRow row, Map values) { for (CTTc cell : row.getTcList()) { for (CTP paragraph : cell.getPList()) { replaceInParagraph(paragraph, values); } } } private void replaceInParagraph(CTP paragraph, Map values) { List runs = paragraph.getRList(); if (runs.isEmpty()) { return; } List chunks = new ArrayList<>(); StringBuilder builder = new StringBuilder(); for (CTR run : runs) { for (CTText text : run.getTList()) { String value = text.getStringValue(); if (value == null) { value = ""; } chunks.add(new TextChunk(text, builder.length(), builder.length() + value.length())); builder.append(value); } } String original = builder.toString(); if (original.indexOf('{') < 0) { return; } Map> edits = new LinkedHashMap<>(); Matcher matcher = TEMPLATE_PLACEHOLDER.matcher(original); while (matcher.find()) { String key = matcher.group(1) != null ? matcher.group(1) : matcher.group(2); String replacement = values.get(key.trim()); if (replacement == null) { replacement = ""; } for (TextChunk chunk : chunks) { int overlapStart = Math.max(matcher.start(), chunk.start); int overlapEnd = Math.min(matcher.end(), chunk.end); if (overlapStart >= overlapEnd) { continue; } String inserted = overlapStart == matcher.start() ? replacement : ""; edits.computeIfAbsent(chunk.text, item -> new ArrayList<>()) .add(new TextEdit(overlapStart - chunk.start, overlapEnd - chunk.start, inserted)); } } for (Map.Entry> entry : edits.entrySet()) { String value = text(entry.getKey().getStringValue()); entry.getValue().sort((left, right) -> Integer.compare(right.start, left.start)); for (TextEdit edit : entry.getValue()) { value = value.substring(0, edit.start) + edit.replacement + value.substring(edit.end); } entry.getKey().setStringValue(value); } } private static final class TextChunk { private final CTText text; private final int start; private final int end; private TextChunk(CTText text, int start, int end) { this.text = text; this.start = start; this.end = end; } } private static final class TextEdit { private final int start; private final int end; private final String replacement; private TextEdit(int start, int end, String replacement) { this.start = start; this.end = end; this.replacement = replacement; } } private void replaceInParagraphLegacy(CTP paragraph, Map values) { List runs = paragraph.getRList(); if (runs.isEmpty()) { return; } StringBuilder builder = new StringBuilder(); for (CTR run : runs) { for (CTText text : run.getTList()) { builder.append(text.getStringValue()); } } String original = builder.toString(); if (original.indexOf('{') < 0) { return; } String replaced = substitute(original, values); if (replaced.equals(original)) { return; } if (runs.size() == 1) { CTR run = runs.get(0); if (run.sizeOfTArray() > 0) { run.getTArray(0).setStringValue(replaced); for (int i = run.sizeOfTArray() - 1; i >= 1; i--) { run.removeT(i); } return; } } for (CTR run : runs) { for (int i = run.sizeOfTArray() - 1; i >= 0; i--) { run.removeT(i); } for (int i = run.sizeOfTabArray() - 1; i >= 0; i--) { run.removeTab(i); } for (int i = run.sizeOfBrArray() - 1; i >= 0; i--) { run.removeBr(i); } } runs.get(0).addNewT().setStringValue(replaced); } private String substitute(String text, Map values) { Matcher matcher = PLACEHOLDER.matcher(text); StringBuffer buffer = new StringBuffer(); while (matcher.find()) { String key = matcher.group(1).trim(); String value = values.get(key); matcher.appendReplacement(buffer, Matcher.quoteReplacement(value == null ? "" : value)); } matcher.appendTail(buffer); return buffer.toString(); } private void appendPageBreakParagraph(CTBody body) { CTP paragraph = body.addNewP(); CTPPr properties = paragraph.addNewPPr(); properties.addNewPageBreakBefore(); properties.addNewSpacing().setBefore(BigInteger.ZERO); properties.getSpacing().setAfter(BigInteger.ZERO); properties.addNewRPr().addNewSz().setVal(BigInteger.valueOf(2)); } private void setPageBreakBefore(CTP paragraph) { CTPPr properties = paragraph.isSetPPr() ? paragraph.getPPr() : paragraph.addNewPPr(); if (!properties.isSetPageBreakBefore()) { properties.addNewPageBreakBefore(); } } private void moveSectionPropertiesToEnd(CTBody body) { if (!body.isSetSectPr()) { return; } CTSectPr section = (CTSectPr) body.getSectPr().copy(); body.unsetSectPr(); body.setSectPr(section); } private String collectText(XWPFDocument document) { StringBuilder builder = new StringBuilder(); for (XWPFParagraph paragraph : document.getParagraphs()) { builder.append(paragraph.getText()).append('\n'); } for (XWPFTable table : document.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { builder.append(cell.getText()).append('\n'); } } } return builder.toString(); } private InputStream openTemplate() throws IOException { Path path = templatePath(); if (path != null && Files.exists(path)) { return Files.newInputStream(path); } ByteArrayOutputStream buffer = new ByteArrayOutputStream(); try (XWPFDocument document = buildDefaultTemplate()) { document.write(buffer); } return new ByteArrayInputStream(buffer.toByteArray()); } private Path templatePath() { if (!StringUtils.hasText(templateLocation)) { return null; } return Paths.get(templateLocation).toAbsolutePath().normalize(); } /** 生成一份带占位符的默认工程单模板。 */ XWPFDocument buildDefaultTemplate() throws IOException { XWPFDocument document = new XWPFDocument(); CTBody body = document.getDocument().getBody(); CTSectPr section = body.isSetSectPr() ? body.getSectPr() : body.addNewSectPr(); CTPageSz pageSize = section.isSetPgSz() ? section.getPgSz() : section.addNewPgSz(); pageSize.setW(A4_LANDSCAPE_WIDTH); pageSize.setH(A4_LANDSCAPE_HEIGHT); pageSize.setOrient(STPageOrientation.LANDSCAPE); CTPageMar margin = section.isSetPgMar() ? section.getPgMar() : section.addNewPgMar(); margin.setTop(PAGE_MARGIN); margin.setBottom(PAGE_MARGIN); margin.setLeft(PAGE_MARGIN); margin.setRight(PAGE_MARGIN); int mainColumns = DEFAULT_COLUMNS.length; int columns = mainColumns + 1; XWPFTable table = document.createTable(3, columns); applyBorders(table.getCTTbl()); applyGrid(table.getCTTbl(), TABLE_COLUMN_WIDTHS); XWPFTableRow headerRow = table.getRow(0); mergeMainColumns(headerRow, mainColumns, document); setCellText(headerRow.getCell(0), DEFAULT_UNIT_HEADER, true); XWPFTableRow titleRow = table.getRow(1); XWPFTableRow fieldRow = table.getRow(2); for (int i = 0; i < mainColumns; i++) { setWidth(titleRow.getCell(i), TABLE_COLUMN_WIDTHS[i]); setWidth(fieldRow.getCell(i), TABLE_COLUMN_WIDTHS[i]); setCellText(titleRow.getCell(i), DEFAULT_COLUMNS[i][0], true); setCellText(fieldRow.getCell(i), DEFAULT_COLUMNS[i][1], false); } configureUnitColumn(titleRow, mainColumns, false); configureUnitColumn(fieldRow, mainColumns, false); moveSectionPropertiesToEnd(body); document.getProperties().getCoreProperties().setKeywords(TEMPLATE_VERSION); return document; } private void applyBorders(CTTbl table) { CTTblPr properties = table.getTblPr() != null ? table.getTblPr() : table.addNewTblPr(); CTTblBorders borders = properties.isSetTblBorders() ? properties.getTblBorders() : properties.addNewTblBorders(); border(borders.isSetTop() ? borders.getTop() : borders.addNewTop()); border(borders.isSetLeft() ? borders.getLeft() : borders.addNewLeft()); border(borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom()); border(borders.isSetRight() ? borders.getRight() : borders.addNewRight()); border(borders.isSetInsideH() ? borders.getInsideH() : borders.addNewInsideH()); border(borders.isSetInsideV() ? borders.getInsideV() : borders.addNewInsideV()); } private void border(org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder border) { border.setVal(STBorder.SINGLE); border.setSz(BigInteger.valueOf(4)); border.setColor("000000"); border.setSpace(BigInteger.ZERO); } private void applyGrid(CTTbl table, int[] widths) { CTTblGrid grid = table.getTblGrid() != null ? table.getTblGrid() : table.addNewTblGrid(); while (grid.sizeOfGridColArray() > widths.length) { grid.removeGridCol(grid.sizeOfGridColArray() - 1); } while (grid.sizeOfGridColArray() < widths.length) { grid.addNewGridCol(); } for (int i = 0; i < widths.length; i++) { grid.getGridColArray(i).setW(BigInteger.valueOf(widths[i])); } CTTblPr properties = table.getTblPr() != null ? table.getTblPr() : table.addNewTblPr(); CTTblWidth width = properties.isSetTblW() ? properties.getTblW() : properties.addNewTblW(); width.setType(STTblWidth.DXA); width.setW(BigInteger.valueOf(totalWidth(widths))); } private int totalWidth(int[] widths) { int total = 0; for (int value : widths) { total += value; } return total; } private void mergeMainColumns(XWPFTableRow row, int mainColumns, XWPFDocument document) { CTRow ctRow = row.getCtRow(); while (ctRow.sizeOfTcArray() > 2) { ctRow.removeTc(ctRow.sizeOfTcArray() - 2); } CTTc cell = ctRow.getTcArray(0); CTTcPr properties = cell.isSetTcPr() ? cell.getTcPr() : cell.addNewTcPr(); if (!properties.isSetGridSpan()) { properties.addNewGridSpan().setVal(BigInteger.valueOf(mainColumns)); } CTTblWidth cellWidth = properties.isSetTcW() ? properties.getTcW() : properties.addNewTcW(); cellWidth.setType(STTblWidth.DXA); cellWidth.setW(BigInteger.valueOf(totalWidth(TABLE_COLUMN_WIDTHS) - UNIT_COLUMN_WIDTH)); CTTc unitCell = ctRow.getTcArray(1); XWPFTableCell unitWrapper = new XWPFTableCell(unitCell, row, document); setWidth(unitWrapper, UNIT_COLUMN_WIDTH); configureUnitCell(unitCell, true); setCellText(unitWrapper, "{unitDisplay}", true); } private void configureUnitColumn(XWPFTableRow row, int mainColumns, boolean restart) { int index = mainColumns; XWPFTableCell cell = row.getCell(index); setWidth(cell, UNIT_COLUMN_WIDTH); configureUnitCell(cell.getCTTc(), restart); } private void configureUnitCell(CTTc cell, boolean restart) { CTTcPr properties = cell.isSetTcPr() ? cell.getTcPr() : cell.addNewTcPr(); if (!properties.isSetVMerge()) { properties.addNewVMerge(); } properties.getVMerge().setVal(restart ? STMerge.RESTART : STMerge.CONTINUE); if (!properties.isSetTextDirection()) { properties.addNewTextDirection(); } properties.getTextDirection().setVal(STTextDirection.BT_LR); if (!properties.isSetVAlign()) { properties.addNewVAlign(); } properties.getVAlign().setVal(STVerticalJc.CENTER); } private void setWidth(XWPFTableCell cell, int width) { CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr(); CTTblWidth cellWidth = properties.isSetTcW() ? properties.getTcW() : properties.addNewTcW(); cellWidth.setType(STTblWidth.DXA); cellWidth.setW(BigInteger.valueOf(width)); } private void setCellText(XWPFTableCell cell, String text, boolean centered) { XWPFParagraph paragraph = cell.getParagraphs().isEmpty() ? cell.addParagraph() : cell.getParagraphs().get(0); if (centered) { paragraph.setAlignment(org.apache.poi.xwpf.usermodel.ParagraphAlignment.CENTER); } org.apache.poi.xwpf.usermodel.XWPFRun run = paragraph.createRun(); run.setFontFamily("SimSun"); run.setFontSize(9); run.setText(text); for (XWPFParagraph extra : new ArrayList<>(cell.getParagraphs())) { if (extra != paragraph) { cell.removeParagraph(cell.getParagraphs().indexOf(extra)); } } } private static String text(String value) { return value == null ? "" : value; } private static String date(LocalDate value) { return value == null ? "" : DATE_FORMAT.format(value); } private static String yesNo(Integer value) { return value != null && value == 1 ? "是" : "否"; } private static String firstText(String... candidates) { for (String candidate : candidates) { if (StringUtils.hasText(candidate)) { return candidate; } } return ""; } }