package com.trafficaudit.holiday.service; import com.trafficaudit.holiday.HolidayNumberUtils; import com.trafficaudit.holiday.service.HolidayStatService.DailyMetrics; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; import java.math.BigDecimal; import java.nio.file.Files; import java.time.LocalDate; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; @Slf4j @Service public class HolidayBulletinService { private static final String WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; private static final BigDecimal EPSILON = new BigDecimal("0.0000001"); @Value("${holiday.bulletin-template:docs/专项统计/重要节假日/湖北省国庆假期综合运输客流快报模板.docx}") private String bulletinTemplatePath; private final HolidayStatService statService; private static final ThreadLocal CURRENT_DOCUMENT = new ThreadLocal<>(); public HolidayBulletinService(HolidayStatService statService) { this.statService = statService; } public byte[] generate(LocalDate date) { DailyMetrics current = statService.findMetricsByDate(date); if (current == null) { throw new IllegalArgumentException("所选日期不在已配置的节假日范围内"); } DailyMetrics previousDay = current.getDayIndex() > 1 ? statService.dailyMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex() - 1) : null; DailyMetrics previousYear = statService.dailyMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex()); DailyMetrics currentCumulative = statService.cumulativeMetrics(current.getYear(), current.getHolidayType(), current.getDayIndex()); DailyMetrics previousYearCumulative = statService.cumulativeMetrics(current.getYear() - 1, current.getHolidayType(), current.getDayIndex()); if (current.getTotalPersonFlow() == null) { throw new IllegalArgumentException("所选日期的全社会跨区域人员流动量没有足够数据,无法生成快报"); } String currentTotal = formatNumber(current.getTotalPersonFlow()); String cumulativeTotalYi = formatNumber(currentCumulative.getTotalPersonFlow() == null ? null : HolidayNumberUtils.divide(currentCumulative.getTotalPersonFlow(), BigDecimal.valueOf(10000))); String ringRatioClause = previousDay == null ? "" : ",环比" + ratioPhrase( current.getTotalPersonFlow(), previousDay.getTotalPersonFlow()); String paragraph1 = String.format("%d月%d日(假期第%d天),全省全社会跨区域人员流动量%s万人次%s,比2025年同期%s。", date.getMonthValue(), date.getDayOfMonth(), current.getDayIndex(), currentTotal, ringRatioClause, ratioPhrase(current.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow())); String paragraph2 = String.format("假期以来,全省全社会跨区域人员流动量累计%s亿人次,比2025年同期%s。", cumulativeTotalYi, ratioPhrase(currentCumulative.getTotalPersonFlow(), previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow())); File template = resolveTemplateFile(); if (!template.exists() || !template.isFile()) { throw new IllegalArgumentException("快报模板文件不存在: " + template.getAbsolutePath()); } try { byte[] templateBytes; try (InputStream in = new FileInputStream(template)) { templateBytes = readAll(in); } byte[] documentXml = readZipEntry(templateBytes, "word/document.xml"); Document document = parseDocument(documentXml); CURRENT_DOCUMENT.set(document); try { replaceParagraph(document, text -> text.startsWith("10月") && text.contains("假期第"), paragraph1); replaceParagraph(document, text -> text.startsWith("假期以来"), paragraph2); fillTable(document, current, previousDay, previousYear, currentCumulative, previousYearCumulative); byte[] replacedXml = serializeDocument(document); return replaceZipEntry(templateBytes, "word/document.xml", replacedXml); } finally { CURRENT_DOCUMENT.remove(); } } catch (Exception e) { throw new IllegalArgumentException("快报生成失败: " + e.getMessage(), e); } } private void fillTable(Document document, DailyMetrics current, DailyMetrics previousDay, DailyMetrics previousYear, DailyMetrics currentCumulative, DailyMetrics previousYearCumulative) { Element table = firstElement(document, "tbl"); if (table == null) { throw new IllegalArgumentException("快报模板中找不到数据表"); } writeTableRow(table, 2, new String[]{ number(current.getNonBusinessPersonFlow()), ratio(current.getNonBusinessPersonFlow(), previousDay == null ? null : previousDay.getNonBusinessPersonFlow()), ratio(current.getNonBusinessPersonFlow(), previousYear == null ? null : previousYear.getNonBusinessPersonFlow()), "—", number(currentCumulative.getNonBusinessPersonFlow()), ratio(currentCumulative.getNonBusinessPersonFlow(), previousYearCumulative == null ? null : previousYearCumulative.getNonBusinessPersonFlow()), "—" }); writeTableRow(table, 3, new String[]{ number(current.getRoad()), ratio(current.getRoad(), previousDay == null ? null : previousDay.getRoad()), ratio(current.getRoad(), previousYear == null ? null : previousYear.getRoad()), "—", number(currentCumulative.getRoad()), ratio(currentCumulative.getRoad(), previousYearCumulative == null ? null : previousYearCumulative.getRoad()), "—" }); writeTableRow(table, 4, new String[]{ number(current.getRoadPersonFlow()), ratio(current.getRoadPersonFlow(), previousDay == null ? null : previousDay.getRoadPersonFlow()), ratio(current.getRoadPersonFlow(), previousYear == null ? null : previousYear.getRoadPersonFlow()), "—", number(currentCumulative.getRoadPersonFlow()), ratio(currentCumulative.getRoadPersonFlow(), previousYearCumulative == null ? null : previousYearCumulative.getRoadPersonFlow()), "—" }); writeTableRow(table, 5, transportRow(current.getRail(), previousDay == null ? null : previousDay.getRail(), previousYear == null ? null : previousYear.getRail(), currentCumulative.getRail(), previousYearCumulative == null ? null : previousYearCumulative.getRail())); writeTableRow(table, 6, transportRow(current.getCivilAviation(), previousDay == null ? null : previousDay.getCivilAviation(), previousYear == null ? null : previousYear.getCivilAviation(), currentCumulative.getCivilAviation(), previousYearCumulative == null ? null : previousYearCumulative.getCivilAviation())); writeTableRow(table, 7, transportRow(current.getWater(), previousDay == null ? null : previousDay.getWater(), previousYear == null ? null : previousYear.getWater(), currentCumulative.getWater(), previousYearCumulative == null ? null : previousYearCumulative.getWater())); writeTableRow(table, 8, transportRow(current.getTotalPersonFlow(), previousDay == null ? null : previousDay.getTotalPersonFlow(), previousYear == null ? null : previousYear.getTotalPersonFlow(), currentCumulative.getTotalPersonFlow(), previousYearCumulative == null ? null : previousYearCumulative.getTotalPersonFlow())); } private String[] transportRow(BigDecimal current, BigDecimal previousDay, BigDecimal previousYear, BigDecimal currentCumulative, BigDecimal previousYearCumulative) { return new String[]{ number(current), ratio(current, previousDay), ratio(current, previousYear), "—", number(currentCumulative), ratio(currentCumulative, previousYearCumulative), "—" }; } private void writeTableRow(Element table, int rowIndex, String[] values) { List rows = childElements(table, "tr"); if (rowIndex >= rows.size()) return; Element row = rows.get(rowIndex); List cells = cellRefs(row); int logicalColumn = 0; for (Element cell : cells) { int span = gridSpan(cell); if (logicalColumn >= 2 && logicalColumn < 9) { int valueIndex = logicalColumn - 2; if (valueIndex < values.length) { setCellText(cell, values[valueIndex]); } } logicalColumn += span; } } private List cellRefs(Element row) { List cells = new ArrayList<>(); for (Element child : childElements(row, "tc")) cells.add(child); return cells; } private int gridSpan(Element cell) { Element tcPr = firstChildElement(cell, "tcPr"); if (tcPr == null) return 1; Element span = firstChildElement(tcPr, "gridSpan"); if (span == null) return 1; String value = span.getAttributeNS(WORD_NS, "val"); try { return Integer.parseInt(value); } catch (Exception e) { return 1; } } private void setCellText(Element cell, String text) { Element tcPr = firstChildElement(cell, "tcPr"); Element templateParagraph = firstChildElement(cell, "p"); Element paragraph = createElement("p"); if (templateParagraph != null) { Element pPr = firstChildElement(templateParagraph, "pPr"); if (pPr != null) paragraph.appendChild(pPr.cloneNode(true)); } Element run = createElement("r"); Element t = createElement("t"); t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve"); t.setTextContent(text == null ? "" : text); run.appendChild(t); paragraph.appendChild(run); List removals = new ArrayList<>(); Node child = cell.getFirstChild(); while (child != null) { Node next = child.getNextSibling(); if (child instanceof Element && "p".equals(localName(child))) { removals.add(child); } child = next; } for (Node node : removals) cell.removeChild(node); if (tcPr != null && tcPr.getNextSibling() != null) { cell.insertBefore(paragraph, tcPr.getNextSibling()); } else if (tcPr != null) { cell.appendChild(paragraph); } else { cell.insertBefore(paragraph, cell.getFirstChild()); } } private void replaceParagraph(Document document, ParagraphMatcher matcher, String replacement) { NodeList paragraphs = document.getElementsByTagNameNS(WORD_NS, "p"); for (int i = 0; i < paragraphs.getLength(); i++) { Element paragraph = (Element) paragraphs.item(i); String text = paragraphText(paragraph); if (matcher.matches(text)) { setParagraphText(paragraph, replacement); return; } } throw new IllegalArgumentException("快报模板缺少可替换段落: " + replacement); } private void setParagraphText(Element paragraph, String text) { Element pPr = firstChildElement(paragraph, "pPr"); Element firstRun = firstChildElement(paragraph, "r"); Element rPr = firstRun == null ? null : firstChildElement(firstRun, "rPr"); List removals = new ArrayList<>(); Node child = paragraph.getFirstChild(); while (child != null) { Node next = child.getNextSibling(); if (child != pPr) removals.add(child); child = next; } for (Node node : removals) paragraph.removeChild(node); Element run = createElement("r"); if (rPr != null) run.appendChild(rPr.cloneNode(true)); Element t = createElement("t"); t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve"); t.setTextContent(text); run.appendChild(t); paragraph.appendChild(run); } private String paragraphText(Element paragraph) { StringBuilder builder = new StringBuilder(); NodeList texts = paragraph.getElementsByTagNameNS(WORD_NS, "t"); for (int i = 0; i < texts.getLength(); i++) { builder.append(texts.item(i).getTextContent()); } return builder.toString(); } private String ratioPhrase(BigDecimal current, BigDecimal base) { if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return "无可比数据"; BigDecimal ratio = HolidayNumberUtils.divide(current, base); BigDecimal percent = HolidayNumberUtils.multiply(ratio.subtract(BigDecimal.ONE), BigDecimal.valueOf(100)); if (percent == null || percent.abs().compareTo(EPSILON) < 0) return "持平"; return (percent.signum() > 0 ? "增长" : "下降") + HolidayNumberUtils.format1(percent.abs()) + "%"; } private String ratio(BigDecimal current, BigDecimal base) { if (current == null || base == null || base.abs().compareTo(EPSILON) < 0) return "—"; return ratioPhrase(current, base); } private String number(BigDecimal value) { return value == null ? "—" : formatNumber(value); } private String formatNumber(BigDecimal value) { if (value == null) return ""; return HolidayNumberUtils.format2(value); } private File resolveTemplateFile() { List candidates = new ArrayList<>(); File configured = new File(bulletinTemplatePath); candidates.add(configured); if (!configured.isAbsolute()) { candidates.add(new File(System.getProperty("user.dir"), bulletinTemplatePath)); candidates.add(new File(System.getProperty("user.dir"), "../" + bulletinTemplatePath)); } for (File candidate : candidates) { try { if (candidate.exists() && candidate.isFile()) { return candidate.getCanonicalFile(); } } catch (Exception ignored) { // Keep checking. } } return configured; } private byte[] readZipEntry(byte[] zipBytes, String entryName) throws Exception { try (ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { ZipEntry entry; while ((entry = zin.getNextEntry()) != null) { if (entryName.equals(entry.getName())) { return readAll(zin); } } } throw new IllegalArgumentException("模板中缺少 " + entryName); } private byte[] replaceZipEntry(byte[] zipBytes, String entryName, byte[] replacement) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); try (ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(zipBytes)); ZipOutputStream zout = new ZipOutputStream(out)) { ZipEntry entry; byte[] buffer = new byte[8192]; while ((entry = zin.getNextEntry()) != null) { ZipEntry target = new ZipEntry(entry.getName()); zout.putNextEntry(target); if (entryName.equals(entry.getName())) { zout.write(replacement); } else { int length; while ((length = zin.read(buffer)) > 0) { zout.write(buffer, 0, length); } } zout.closeEntry(); } } return out.toByteArray(); } private Document parseDocument(byte[] xml) throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml)); } private byte[] serializeDocument(Document document) throws Exception { Transformer transformer = TransformerFactory.newInstance().newTransformer(); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); ByteArrayOutputStream out = new ByteArrayOutputStream(); transformer.transform(new DOMSource(document), new StreamResult(out)); return out.toByteArray(); } private Element firstElement(Document document, String localName) { NodeList list = document.getElementsByTagNameNS(WORD_NS, localName); return list.getLength() == 0 ? null : (Element) list.item(0); } private Element createElement(String localName) { Document document = CURRENT_DOCUMENT.get(); if (document == null) { throw new IllegalStateException("Word document is not initialized"); } return document.createElementNS(WORD_NS, "w:" + localName); } private List childElements(Element parent, String localName) { List result = new ArrayList<>(); Node child = parent.getFirstChild(); while (child != null) { if (child instanceof Element && localName.equals(localName(child))) { result.add((Element) child); } child = child.getNextSibling(); } return result; } private Element firstChildElement(Element parent, String localName) { Node child = parent.getFirstChild(); while (child != null) { if (child instanceof Element && localName.equals(localName(child))) { return (Element) child; } child = child.getNextSibling(); } return null; } private String localName(Node node) { return node.getLocalName() == null ? node.getNodeName().replaceFirst("^.*:", "") : node.getLocalName(); } private byte[] readAll(InputStream in) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buffer = new byte[8192]; int length; while ((length = in.read(buffer)) > 0) { out.write(buffer, 0, length); } return out.toByteArray(); } private interface ParagraphMatcher { boolean matches(String text); } }