package cn.iocoder.yudao.module.mes.service.wm.document;
|
|
import org.springframework.core.io.ClassPathResource;
|
import org.w3c.dom.Node;
|
|
import javax.imageio.ImageIO;
|
import javax.xml.XMLConstants;
|
import javax.xml.parsers.DocumentBuilderFactory;
|
import javax.xml.transform.TransformerFactory;
|
import javax.xml.transform.dom.DOMSource;
|
import javax.xml.transform.stream.StreamResult;
|
import java.awt.*;
|
import java.awt.geom.Path2D;
|
import java.awt.image.BufferedImage;
|
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayOutputStream;
|
import java.io.IOException;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Objects;
|
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipInputStream;
|
import java.util.zip.ZipOutputStream;
|
|
/**
|
* MES 仓储单据 Word 导出:基于 templates/wm/*.docx 模板填写槽位,
|
* 并按公司名称实时绘制红色电子章(不依赖静态章图片)。
|
*/
|
public final class MesWmDocumentRenderer {
|
|
private static final String W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
private static final String SALES_DELIVERY_TEMPLATE = "templates/wm/sales-delivery.docx";
|
private static final String PRODUCT_OUTBOUND_TEMPLATE = "templates/wm/product-outbound.docx";
|
private static final String PROJECT_TRACKING_TEMPLATE = "templates/pd/project-tracking.docx";
|
private static final String SEAL_ENTRY = "word/media/seal.png";
|
|
private MesWmDocumentRenderer() {
|
}
|
|
public static byte[] renderSalesDelivery(Map<String, String> fields,
|
List<Map<String, String>> items,
|
String companyName) throws IOException {
|
return render(SALES_DELIVERY_TEMPLATE, fields, items, companyName);
|
}
|
|
public static byte[] renderProductOutbound(Map<String, String> fields,
|
List<Map<String, String>> items,
|
String companyName) throws IOException {
|
return render(PRODUCT_OUTBOUND_TEMPLATE, fields, items, companyName);
|
}
|
|
/**
|
* 项目跟踪表:表头字段自动填充,跟踪记录为空白行,供打印后手写或下载后补填。
|
*/
|
public static byte[] renderProjectTracking(Map<String, String> fields,
|
String companyName) throws IOException {
|
byte[] seal = renderSeal(companyName, "项目专用章");
|
try (var input = new ClassPathResource(PROJECT_TRACKING_TEMPLATE).getInputStream();
|
var zip = new ZipInputStream(input);
|
var output = new ByteArrayOutputStream();
|
var result = new ZipOutputStream(output)) {
|
ZipEntry entry;
|
while ((entry = zip.getNextEntry()) != null) {
|
byte[] content = zip.readAllBytes();
|
if ("word/document.xml".equals(entry.getName())) {
|
content = fillDocumentFieldsOnly(content, fields, companyName);
|
} else if (SEAL_ENTRY.equals(entry.getName())) {
|
content = seal;
|
}
|
result.putNextEntry(new ZipEntry(entry.getName()));
|
result.write(content);
|
result.closeEntry();
|
}
|
result.finish();
|
return output.toByteArray();
|
}
|
}
|
|
private static byte[] render(String template, Map<String, String> fields,
|
List<Map<String, String>> items, String companyName) throws IOException {
|
if (items == null || items.isEmpty()) {
|
throw new IllegalArgumentException("单据没有货物明细,无法生成 Word 文件");
|
}
|
byte[] seal = renderSeal(companyName, "发货专用章");
|
try (var input = new ClassPathResource(template).getInputStream();
|
var zip = new ZipInputStream(input);
|
var output = new ByteArrayOutputStream();
|
var result = new ZipOutputStream(output)) {
|
ZipEntry entry;
|
while ((entry = zip.getNextEntry()) != null) {
|
byte[] content = zip.readAllBytes();
|
if ("word/document.xml".equals(entry.getName())) {
|
content = fillDocument(content, fields, items, companyName);
|
} else if (SEAL_ENTRY.equals(entry.getName())) {
|
content = seal;
|
}
|
result.putNextEntry(new ZipEntry(entry.getName()));
|
result.write(content);
|
result.closeEntry();
|
}
|
result.finish();
|
return output.toByteArray();
|
}
|
}
|
|
private static byte[] fillDocument(byte[] xml, Map<String, String> fields,
|
List<Map<String, String>> items, String companyName) throws IOException {
|
try {
|
var factory = DocumentBuilderFactory.newInstance();
|
factory.setNamespaceAware(true);
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
var document = factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
|
|
// 1. 明细行
|
Node rowTemplate = null;
|
var rows = document.getElementsByTagNameNS(W, "tr");
|
for (int i = 0; i < rows.getLength(); i++) {
|
if (rows.item(i).getTextContent().contains("${itemName}")) {
|
rowTemplate = rows.item(i);
|
break;
|
}
|
}
|
if (rowTemplate == null) {
|
throw new IOException("单据模板缺少货物明细槽位");
|
}
|
int index = 0;
|
for (Map<String, String> item : items) {
|
Node row = rowTemplate.cloneNode(true);
|
var values = new java.util.HashMap<String, String>();
|
item.forEach((key, value) -> values.put(key, text(value)));
|
values.put("index", String.valueOf(++index));
|
replace(row, values);
|
rowTemplate.getParentNode().insertBefore(row, rowTemplate);
|
}
|
rowTemplate.getParentNode().removeChild(rowTemplate);
|
|
// 2. 表头字段
|
var values = new java.util.HashMap<String, String>();
|
fields.forEach((key, value) -> values.put(key, text(value)));
|
values.put("companyName", text(companyName));
|
replace(document, values);
|
if (document.getDocumentElement().getTextContent().contains("${")) {
|
throw new IOException("单据模板存在未填写槽位");
|
}
|
|
var transformerFactory = TransformerFactory.newInstance();
|
transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
|
var output = new ByteArrayOutputStream();
|
transformerFactory.newTransformer().transform(new DOMSource(document), new StreamResult(output));
|
return output.toByteArray();
|
} catch (IOException exception) {
|
throw exception;
|
} catch (Exception exception) {
|
throw new IOException("生成单据 Word 文件失败", exception);
|
}
|
}
|
|
private static byte[] fillDocumentFieldsOnly(byte[] xml, Map<String, String> fields,
|
String companyName) throws IOException {
|
try {
|
var factory = DocumentBuilderFactory.newInstance();
|
factory.setNamespaceAware(true);
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
var document = factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml));
|
|
var values = new java.util.HashMap<String, String>();
|
fields.forEach((key, value) -> values.put(key, text(value)));
|
values.put("companyName", text(companyName));
|
replace(document, values);
|
if (document.getDocumentElement().getTextContent().contains("${")) {
|
throw new IOException("单据模板存在未填写槽位");
|
}
|
|
var transformerFactory = TransformerFactory.newInstance();
|
transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
|
var output = new ByteArrayOutputStream();
|
transformerFactory.newTransformer().transform(new DOMSource(document), new StreamResult(output));
|
return output.toByteArray();
|
} catch (IOException exception) {
|
throw exception;
|
} catch (Exception exception) {
|
throw new IOException("生成单据 Word 文件失败", exception);
|
}
|
}
|
|
// ==================== 电子章绘制 ====================
|
|
private static byte[] renderSeal(String companyName, String sealText) throws IOException {
|
int size = 400;
|
var image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
Graphics2D g = image.createGraphics();
|
try {
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
var red = new Color(200, 16, 46);
|
g.setColor(red);
|
g.setStroke(new BasicStroke(9f));
|
g.drawOval(12, 12, size - 24, size - 24);
|
g.setStroke(new BasicStroke(3f));
|
g.drawOval(38, 38, size - 76, size - 76);
|
|
String name = text(companyName).isBlank() ? "公司" : companyName.trim();
|
int fontSize = Math.max(16, Math.min(30, 560 / Math.max(1, name.length())));
|
g.setFont(new Font("SimSun", Font.BOLD, fontSize));
|
drawArcText(g, name, size / 2, size / 2, size / 2 - 62, 195, 150);
|
|
drawStar(g, size / 2, size / 2 - 8, 34, 14);
|
g.setFont(new Font("SimSun", Font.BOLD, 27));
|
drawCentered(g, sealText, size / 2, (int) (size * 0.68));
|
} finally {
|
g.dispose();
|
}
|
var output = new ByteArrayOutputStream();
|
ImageIO.write(image, "png", output);
|
return output.toByteArray();
|
}
|
|
private static void drawArcText(Graphics2D g, String text, int cx, int cy, int radius,
|
double startDeg, double sweepDeg) {
|
FontMetrics fm = g.getFontMetrics();
|
int[] widths = new int[text.length()];
|
double totalWidth = 0;
|
for (int i = 0; i < text.length(); i++) {
|
widths[i] = Math.max(1, fm.charWidth(text.charAt(i)));
|
totalWidth += widths[i];
|
}
|
double angle = startDeg;
|
for (int i = 0; i < text.length(); i++) {
|
double span = widths[i] / totalWidth * sweepDeg;
|
double center = angle + span / 2;
|
double rad = Math.toRadians(center);
|
int x = (int) (cx + radius * Math.cos(rad));
|
int y = (int) (cy + radius * Math.sin(rad));
|
var transform = g.getTransform();
|
g.rotate(Math.toRadians(center + 90), x, y);
|
g.drawString(String.valueOf(text.charAt(i)), x - widths[i] / 2, y + fm.getAscent() / 2 - 3);
|
g.setTransform(transform);
|
angle += span;
|
}
|
}
|
|
private static void drawStar(Graphics2D g, int cx, int cy, int outer, int inner) {
|
var path = new Path2D.Double();
|
for (int i = 0; i < 10; i++) {
|
double r = i % 2 == 0 ? outer : inner;
|
double angle = Math.toRadians(-90 + i * 36);
|
double x = cx + r * Math.cos(angle);
|
double y = cy + r * Math.sin(angle);
|
if (i == 0) {
|
path.moveTo(x, y);
|
} else {
|
path.lineTo(x, y);
|
}
|
}
|
path.closePath();
|
g.fill(path);
|
}
|
|
private static void drawCentered(Graphics2D g, String text, int cx, int baseline) {
|
FontMetrics fm = g.getFontMetrics();
|
g.drawString(text, cx - fm.stringWidth(text) / 2, baseline);
|
}
|
|
private static void replace(Node node, Map<String, String> values) {
|
if (node.getNodeType() == Node.ELEMENT_NODE && "t".equals(node.getLocalName())
|
&& W.equals(node.getNamespaceURI())) {
|
String value = node.getTextContent();
|
for (var entry : values.entrySet()) {
|
// 明细字段可能为 null(如未填批次号),replace 不接受 null,统一回退为占位符
|
String replacement = entry.getValue() == null ? "-" : entry.getValue();
|
value = value.replace("${" + entry.getKey() + "}", replacement);
|
}
|
node.setTextContent(value);
|
return;
|
}
|
for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) {
|
replace(child, values);
|
}
|
}
|
|
private static String text(String value) {
|
return Objects.requireNonNullElse(value, "-").isBlank() ? "-" : value;
|
}
|
}
|