package cn.iocoder.yudao.module.crm.service.quotation; import cn.hutool.core.convert.Convert; import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationRespVO; import org.springframework.core.io.ClassPathResource; import org.w3c.dom.Node; import javax.imageio.ImageIO; 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.awt.*; import java.awt.geom.Path2D; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; /** * 销售报价单 Word 导出:基于 {@code templates/quotation/sale-quotation.docx} 模板填写业务槽位, * 并在导出时按公司名称实时绘制电子章(不依赖静态章图片资源)。 */ public final class CrmSaleQuotationDocumentRenderer { private static final String W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; private static final String TEMPLATE = "templates/quotation/sale-quotation.docx"; private static final String SEAL_ENTRY = "word/media/seal.png"; private CrmSaleQuotationDocumentRenderer() { } public static byte[] render(CrmSaleQuotationRespVO quotation, String companyName) throws IOException { if (quotation == null) { throw new IllegalArgumentException("报价单不存在,无法生成报价文件"); } if (quotation.getItems() == null || quotation.getItems().isEmpty()) { throw new IllegalArgumentException("报价单没有产品明细,无法生成报价文件"); } byte[] seal = renderSeal(companyName); try (var input = new ClassPathResource(TEMPLATE).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, quotation, companyName); } else if (SEAL_ENTRY.equals(entry.getName())) { content = seal; } result.putNextEntry(new ZipEntry(entry.getName())); result.write(content); result.closeEntry(); } result.finish(); return output.toByteArray(); } } private static byte[] fillDocument(byte[] xml, CrmSaleQuotationRespVO quotation, String companyName) 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)); // 1. 产品明细行 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; for (var item : quotation.getItems()) { Node row = rowTemplate.cloneNode(true); BigDecimal price = item.getQuotationPrice() == null ? BigDecimal.ZERO : item.getQuotationPrice(); BigDecimal count = item.getCount() == null ? BigDecimal.ZERO : item.getCount(); BigDecimal amount = item.getTotalPrice() != null ? item.getTotalPrice() : price.multiply(count).setScale(2, RoundingMode.HALF_UP); String name = text(item.getItemName()); if (name.isBlank()) { name = text(item.getItemCode()); } Map values = new HashMap<>(); values.put("index", String.valueOf(++index)); values.put("itemName", name); values.put("itemSpecification", text(item.getItemSpecification())); values.put("unitName", text(item.getItemUnitName())); values.put("count", count.stripTrailingZeros().toPlainString()); values.put("price", money(price)); values.put("amount", money(amount)); replace(row, values); rowTemplate.getParentNode().insertBefore(row, rowTemplate); } rowTemplate.getParentNode().removeChild(rowTemplate); // 2. 表头与金额 BigDecimal productTotal = quotation.getTotalProductPrice() == null ? BigDecimal.ZERO : quotation.getTotalProductPrice(); BigDecimal discount = quotation.getDiscountPrice() == null ? BigDecimal.ZERO : quotation.getDiscountPrice(); BigDecimal taxRate = quotation.getTaxRate() == null ? BigDecimal.ZERO : quotation.getTaxRate(); BigDecimal taxPrice = quotation.getTaxPrice() == null ? BigDecimal.ZERO : quotation.getTaxPrice(); BigDecimal total = quotation.getTotalPrice() == null ? BigDecimal.ZERO : quotation.getTotalPrice(); Map values = new HashMap<>(); values.put("companyName", text(companyName)); values.put("customerName", text(quotation.getCustomerName())); values.put("quotationNo", text(quotation.getNo())); values.put("quotationName", text(quotation.getName())); values.put("quotationDate", date(quotation.getQuotationTime())); values.put("validUntil", date(quotation.getValidUntil())); values.put("contactName", text(quotation.getContactName())); values.put("ownerUserName", text(quotation.getOwnerUserName())); values.put("productTotal", money(productTotal)); values.put("discountPrice", money(discount)); values.put("taxRate", taxRate.stripTrailingZeros().toPlainString()); values.put("taxPrice", money(taxPrice)); values.put("totalPrice", money(total)); values.put("totalUpper", Convert.digitToChinese(total)); values.put("remark", text(quotation.getRemark()).isBlank() ? "无" : quotation.getRemark()); replace(document, values); 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); } } // ==================== 电子章绘制 ==================== /** * 按公司名称实时绘制红色圆形电子章:双圆环 + 上方环绕公司名 + 中央五角星 + 下方“报价专用章”。 * 不依赖任何静态章图片,避免换公司品牌后仍需单独维护图片资源。 */ static byte[] renderSeal(String companyName) throws IOException { int size = 400; var image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); Graphics2D g = image.createGraphics(); try { g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); var red = new Color(200, 16, 46); g.setColor(red); g.setStroke(new BasicStroke(9f)); g.drawOval(12, 12, size - 24, size - 24); g.setStroke(new BasicStroke(3f)); g.drawOval(38, 38, size - 76, size - 76); String name = text(companyName).isBlank() ? "公司" : companyName.trim(); int fontSize = Math.max(16, Math.min(30, 560 / Math.max(1, name.length()))); g.setFont(new Font("SimSun", Font.BOLD, fontSize)); drawArcText(g, name, size / 2, size / 2, size / 2 - 62, 195, 150); drawStar(g, size / 2, size / 2 - 8, 34, 14); g.setFont(new Font("SimSun", Font.BOLD, 27)); drawCentered(g, "报价专用章", size / 2, (int) (size * 0.68)); } finally { g.dispose(); } var output = new ByteArrayOutputStream(); ImageIO.write(image, "png", output); return output.toByteArray(); } /** 沿圆弧排布文字,startDeg 起、顺时针 sweepDeg 度;字符基线朝圆心。 */ private static void drawArcText(Graphics2D g, String text, int cx, int cy, int radius, double startDeg, double sweepDeg) { FontMetrics fm = g.getFontMetrics(); int[] widths = new int[text.length()]; double totalWidth = 0; for (int i = 0; i < text.length(); i++) { widths[i] = Math.max(1, fm.charWidth(text.charAt(i))); totalWidth += widths[i]; } double angle = startDeg; for (int i = 0; i < text.length(); i++) { double span = widths[i] / totalWidth * sweepDeg; double center = angle + span / 2; double rad = Math.toRadians(center); int x = (int) (cx + radius * Math.cos(rad)); int y = (int) (cy + radius * Math.sin(rad)); var transform = g.getTransform(); g.rotate(Math.toRadians(center + 90), x, y); g.drawString(String.valueOf(text.charAt(i)), x - widths[i] / 2, y + fm.getAscent() / 2 - 3); g.setTransform(transform); angle += span; } } private static void drawStar(Graphics2D g, int cx, int cy, int outer, int inner) { var path = new Path2D.Double(); for (int i = 0; i < 10; i++) { double r = i % 2 == 0 ? outer : inner; double angle = Math.toRadians(-90 + i * 36); double x = cx + r * Math.cos(angle); double y = cy + r * Math.sin(angle); if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } } path.closePath(); g.fill(path); } private static void drawCentered(Graphics2D g, String text, int cx, int baseline) { FontMetrics fm = g.getFontMetrics(); g.drawString(text, cx - fm.stringWidth(text) / 2, baseline); } // ==================== 工具方法 ==================== 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 date(java.time.LocalDateTime value) { return value == null ? "____________" : value.format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")); } private static String text(String value) { return Objects.requireNonNullElse(value, ""); } }