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<String, String> 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<String, String> 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);
|
}
|
}
|
|
// ==================== 电子章绘制 ====================
|
|
/**
|
* 按公司名称实时绘制红色圆形电子章:双圆环 + 上方环绕公司名 + 中央五角星 + 下方“报价专用章”。
|
* 不依赖任何静态章图片,避免换公司品牌后仍需单独维护图片资源。
|
*/
|
private 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<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 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, "");
|
}
|
}
|