| | |
| | | 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 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() { } |
| | |
| | | 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(), "合同单价不能为空"); |
| | |
| | | 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, ""); |
| | |
| | | } 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; } |