package cn.iocoder.yudao.module.crm.service.contract;
|
|
import cn.hutool.core.convert.Convert;
|
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractRespVO;
|
import org.springframework.core.io.ClassPathResource;
|
import org.w3c.dom.*;
|
|
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.io.*;
|
import java.math.BigDecimal;
|
import java.math.RoundingMode;
|
import java.time.format.DateTimeFormatter;
|
import java.util.*;
|
import java.util.zip.*;
|
|
/** 在销售合同模板中填写合同信息,保留模板的其他内容及格式。 */
|
public final class CrmContractDocumentRenderer {
|
private static final String W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
private CrmContractDocumentRenderer() { }
|
|
public static byte[] render(CrmContractRespVO contract) throws IOException {
|
if (contract == null || contract.getProducts() == null || contract.getProducts().isEmpty()) {
|
throw new IllegalArgumentException("合同不存在或没有产品明细,无法生成合同文件");
|
}
|
try (var input = new ClassPathResource("templates/contract/sale-contract.docx").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, contract);
|
result.putNextEntry(new ZipEntry(entry.getName()));
|
result.write(content);
|
result.closeEntry();
|
}
|
result.finish();
|
return output.toByteArray();
|
}
|
}
|
|
private static byte[] fillDocument(byte[] xml, CrmContractRespVO contract) 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 paragraphs = document.getElementsByTagNameNS(W, "p");
|
boolean numberFilled = false, dateFilled = false, buyerFilled = false;
|
for (int i = 0; i < paragraphs.getLength(); i++) {
|
Node p = paragraphs.item(i);
|
String value = p.getTextContent();
|
if (value.contains("合同编号:SHHT20260102902-F")) {
|
setParagraphText(p, "合同编号:" + text(contract.getNo()));
|
alignRight(p);
|
numberFilled = true;
|
} else if (value.trim().equals("签订日期:")) {
|
setParagraphText(p, "签订日期:" + date(contract.getOrderDate()));
|
alignRight(p);
|
dateFilled = true;
|
} else if (value.contains("买方(甲方):") && value.contains("签订地点:江苏南通")) {
|
fillBuyer(p, text(contract.getCustomerName()));
|
buyerFilled = true;
|
} else if (value.equals("乙方(盖章):")) {
|
// 原模板的空白买方签章框误写为乙方;同时处理 DrawingML 和 VML 两个兼容分支。
|
setParagraphText(p, "甲方(盖章):" + text(contract.getCustomerName()));
|
}
|
}
|
if (!numberFilled || !dateFilled || !buyerFilled) throw new IOException("销售合同模板表头不完整");
|
|
Node table = document.getElementsByTagNameNS(W, "tbl").item(0);
|
List<Node> rows = children(table, "tr");
|
if (rows.size() != 4 || children(rows.get(1), "tc").size() != 7
|
|| !rows.get(3).getTextContent().contains("合计(含13%增值税)")) {
|
throw new IOException("销售合同模板产品表格不完整");
|
}
|
Node rowTemplate = rows.get(1);
|
BigDecimal productTotal = BigDecimal.ZERO;
|
int index = 0;
|
for (var product : contract.getProducts()) {
|
Node row = rowTemplate.cloneNode(true);
|
BigDecimal price = Objects.requireNonNull(product.getContractPrice(), "合同单价不能为空");
|
BigDecimal count = Objects.requireNonNull(product.getCount(), "合同数量不能为空");
|
BigDecimal amount = product.getTotalPrice() != null ? product.getTotalPrice()
|
: price.multiply(count).setScale(2, RoundingMode.HALF_UP);
|
productTotal = productTotal.add(amount);
|
String name = text(product.getItemName());
|
if (name.isBlank()) name = text(product.getItemCode());
|
List<Node> cells = children(row, "tc");
|
String[] values = {String.valueOf(++index), name, text(product.getItemSpecification()),
|
text(product.getItemUnitName()), count.stripTrailingZeros().toPlainString(), money(price), money(amount)};
|
for (int i = 0; i < values.length; i++) {
|
Node cell = cells.get(i);
|
Node properties = firstChild(cell, "tcPr");
|
Node noWrap = firstChild(properties, "noWrap");
|
if (noWrap != null) properties.removeChild(noWrap);
|
setParagraphText(firstChild(cell, "p"), values[i]);
|
}
|
Node rowProperties = firstChild(row, "trPr");
|
if (firstChild(rowProperties, "cantSplit") == null) {
|
rowProperties.appendChild(document.createElementNS(W, "w:cantSplit"));
|
}
|
rowTemplate.getParentNode().insertBefore(row, rowTemplate);
|
}
|
rowTemplate.getParentNode().removeChild(rowTemplate);
|
BigDecimal total = Objects.requireNonNull(contract.getTotalPrice(), "合同总金额不能为空");
|
BigDecimal discount = productTotal.subtract(total);
|
String summary = "合计(含13%增值税) 人民币:" + money(total) + "元 大写:" + Convert.digitToChinese(total);
|
if (discount.signum() != 0) summary += "(明细合计 " + money(productTotal) + " 元,与合同金额的差额 " + money(discount) + " 元)";
|
Node totalCell = children(rows.get(3), "tc").get(0);
|
Node totalProperties = firstChild(totalCell, "tcPr");
|
Node noWrap = firstChild(totalProperties, "noWrap");
|
if (noWrap != null) totalProperties.removeChild(noWrap);
|
setParagraphText(firstChild(totalCell, "p"), summary);
|
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 void fillBuyer(Node paragraph, String buyer) {
|
var nodes = ((Element) paragraph).getElementsByTagNameNS(W, "t");
|
boolean afterColon = false, inserted = false;
|
for (int i = 0; i < nodes.getLength(); i++) {
|
Node node = nodes.item(i);
|
String value = node.getTextContent();
|
if (value.contains("签订地点:")) break;
|
if (afterColon) {
|
node.setTextContent(inserted ? "" : buyer + " ");
|
inserted = true;
|
} else if (value.equals(":")) afterColon = true;
|
}
|
if (!inserted) throw new IllegalStateException("合同模板缺少买方填写位置");
|
}
|
|
private static void alignRight(Node paragraph) {
|
Node properties = firstChild(paragraph, "pPr");
|
Node indent = firstChild(properties, "ind");
|
if (indent != null) properties.removeChild(indent);
|
Node alignment = firstChild(properties, "jc");
|
if (alignment == null) {
|
alignment = paragraph.getOwnerDocument().createElementNS(W, "w:jc");
|
properties.appendChild(alignment);
|
}
|
((Element) alignment).setAttributeNS(W, "w:val", "right");
|
}
|
|
private static void setParagraphText(Node paragraph, String value) {
|
var nodes = ((Element) paragraph).getElementsByTagNameNS(W, "t");
|
if (nodes.getLength() == 0) {
|
var document = paragraph.getOwnerDocument();
|
Node run = document.createElementNS(W, "w:r");
|
Node text = document.createElementNS(W, "w:t");
|
run.appendChild(text);
|
paragraph.appendChild(run);
|
text.setTextContent(value);
|
} else {
|
nodes.item(0).setTextContent(value);
|
for (int i = 1; i < nodes.getLength(); i++) nodes.item(i).setTextContent("");
|
}
|
}
|
|
private static List<Node> children(Node parent, String localName) {
|
List<Node> result = new ArrayList<>();
|
if (parent == null) return result;
|
for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) {
|
if (W.equals(node.getNamespaceURI()) && localName.equals(node.getLocalName())) result.add(node);
|
}
|
return result;
|
}
|
private static Node firstChild(Node parent, String localName) {
|
List<Node> result = children(parent, localName);
|
return result.isEmpty() ? null : result.get(0);
|
}
|
private static String money(BigDecimal value) { return value.setScale(2, RoundingMode.HALF_UP).toPlainString(); }
|
private static String date(java.time.LocalDateTime value) { return value == null ? "____________" : value.format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")); }
|
private static String text(String value) { return value == null ? "" : value; }
|
}
|