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 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; } }