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.*;
|
|
/** 保留用户合同模板的 OOXML 包,仅填写业务槽位并扩展产品明细行。 */
|
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));
|
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;
|
BigDecimal productTotal = BigDecimal.ZERO;
|
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());
|
String specification = text(product.getItemSpecification());
|
if (!specification.isBlank()) name += "(" + specification + ")";
|
replace(row, Map.of("index", String.valueOf(++index), "itemName", name,
|
"unitName", text(product.getItemUnitName()), "count", count.stripTrailingZeros().toPlainString(),
|
"price", money(price), "amount", money(amount)));
|
rowTemplate.getParentNode().insertBefore(row, rowTemplate);
|
}
|
rowTemplate.getParentNode().removeChild(rowTemplate);
|
BigDecimal total = Objects.requireNonNull(contract.getTotalPrice(), "合同总金额不能为空");
|
BigDecimal discount = productTotal.subtract(total);
|
replace(document, Map.of("customerName", text(contract.getCustomerName()),
|
"contractNo", text(contract.getNo()),
|
"contractDate", contract.getOrderDate() == null ? "____________" : contract.getOrderDate().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")),
|
"totalPrice", money(total), "totalUpper", Convert.digitToChinese(total),
|
"discountNote", discount.signum() == 0 ? "" : "(明细合计 " + money(productTotal) + " 元,整单优惠 " + money(discount) + " 元)"));
|
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 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()) value = value.replace("${" + entry.getKey() + "}", entry.getValue());
|
node.setTextContent(value);
|
return;
|
}
|
for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) replace(child, values);
|
}
|
|
private static String money(BigDecimal value) { return value.setScale(2, RoundingMode.HALF_UP).toPlainString(); }
|
private static String text(String value) { return value == null ? "" : value; }
|
}
|