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<Document> 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<Element> rows = childElements(table, "tr");
|
if (rowIndex >= rows.size()) return;
|
Element row = rows.get(rowIndex);
|
List<Element> 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<Element> cellRefs(Element row) {
|
List<Element> 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<Node> 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<Node> 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<File> 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<Element> childElements(Element parent, String localName) {
|
List<Element> 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);
|
}
|
}
|