云
4 天以前 f02d0df2f1dfc7a8b775cf50d45375e57628a5ef
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/contract/CrmContractDocumentRenderer.java
@@ -4,6 +4,7 @@
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;
@@ -16,7 +17,7 @@
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() { }
@@ -49,14 +50,38 @@
            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; }
            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 (rowTemplate == null) throw new IOException("合同模板缺少产品明细槽位");
            int index = 0;
            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(), "合同单价不能为空");
@@ -66,25 +91,32 @@
                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)));
                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);
            replace(document, Map.of("customerName", text(contract.getCustomerName()),
                    "contractNo", text(contract.getNo()),
                    "contractName", text(contract.getName()),
                    "contractDate", contract.getOrderDate() == null ? "____________" : contract.getOrderDate().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")),
                    "startDate", date(contract.getStartTime()),
                    "endDate", date(contract.getEndTime()),
                    "totalPrice", money(total), "totalUpper", Convert.digitToChinese(total),
                    "discountNote", discount.signum() == 0 ? "" : "(明细合计 " + money(productTotal) + " 元,与合同金额的差额 " + money(discount) + " 元)"));
            if (document.getDocumentElement().getTextContent().contains("${")) throw new IOException("合同模板存在未填写槽位");
            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, "");
@@ -95,16 +127,60 @@
        } 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())) {
    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();
            for (var entry : values.entrySet()) value = value.replace("${" + entry.getKey() + "}", entry.getValue());
            node.setTextContent(value);
            return;
            if (value.contains("签订地点:")) break;
            if (afterColon) {
                node.setTextContent(inserted ? "" : buyer + "  ");
                inserted = true;
            } else if (value.equals(":")) afterColon = true;
        }
        for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) replace(child, values);
        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; }