package org.dromara.crm.service.impl; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.StrUtil; import lombok.extern.slf4j.Slf4j; import org.dromara.common.core.exception.ServiceException; import org.dromara.crm.domain.vo.CrmQuoteProductVo; import org.dromara.crm.domain.vo.CrmQuoteVo; import org.dromara.crm.service.ICrmQuotePdfService; import org.dromara.crm.service.ICrmQuoteService; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import java.io.File; import java.io.FileWriter; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; /** * 报价单PDF生成Service实现 * * 说明: 当前采用HTML模板方式生成,可打印为PDF * 后续可集成专业PDF库(如iText、Apache PDFBox等) * * @author 系统管理员 * @date 2025-10-05 */ @Slf4j @Service public class CrmQuotePdfServiceImpl implements ICrmQuotePdfService { private final ICrmQuoteService quoteService; /** * 构造函数 - 使用@Lazy注解打破循环依赖 * CrmQuoteServiceImpl依赖ICrmQuotePdfService * CrmQuotePdfServiceImpl依赖ICrmQuoteService * 通过延迟加载ICrmQuoteService来解决循环依赖 */ public CrmQuotePdfServiceImpl(@Lazy ICrmQuoteService quoteService) { this.quoteService = quoteService; } @Value("${ruoyi.profile:/Users/wuzilong/work/00code/shanxia/backend/ruoyi-admin/uploadPath}") private String uploadPath; @Override public String generateQuotePdf(Long quoteId) { // 1. 查询报价单信息 CrmQuoteVo quote = quoteService.queryById(quoteId); if (quote == null) { throw new ServiceException("报价单不存在"); } // 2. 生成HTML内容 String htmlContent = buildQuoteHtml(quote); // 3. 保存HTML文件(可打印为PDF) // 将报价单编号中的特殊字符替换为横线,避免文件路径错误 String safeQuoteNo = quote.getQuoteNo().replaceAll("[/\\\\:*?\"<>|]", "-"); String fileName = "报价单-" + safeQuoteNo + ".pdf"; String dateStr = LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); String relativePath = "/quote/" + dateStr + "/"; String fullPath = uploadPath + relativePath; // 确保目录存在 FileUtil.mkdir(fullPath); // 保存文件 String filePath = fullPath + fileName; try (FileWriter writer = new FileWriter(filePath, StandardCharsets.UTF_8)) { writer.write(htmlContent); log.info("生成报价单HTML文件成功: quoteId={}, filePath={}", quoteId, filePath); } catch (Exception e) { log.error("生成报价单HTML文件失败: quoteId={}", quoteId, e); throw new ServiceException("生成PDF文件失败: " + e.getMessage()); } // 4. 返回相对路径(供前端访问) String pdfUrl = relativePath + fileName; log.info("生成报价单PDF成功: quoteId={}, pdfUrl={}", quoteId, pdfUrl); return pdfUrl; } @Override public Boolean deleteQuotePdf(Long quoteId) { CrmQuoteVo quote = quoteService.queryById(quoteId); if (quote == null || StrUtil.isBlank(quote.getPdfUrl())) { return true; } String filePath = uploadPath + quote.getPdfUrl(); File file = new File(filePath); if (file.exists()) { boolean deleted = FileUtil.del(file); log.info("删除报价单PDF: quoteId={}, filePath={}, success={}", quoteId, filePath, deleted); return deleted; } return true; } /** * 构建报价单HTML内容 */ private String buildQuoteHtml(CrmQuoteVo quote) { StringBuilder html = new StringBuilder(); html.append("\n"); html.append("\n"); html.append("\n"); html.append(" \n"); html.append(" \n"); html.append(" 报价单 - ").append(quote.getQuoteNo()).append("\n"); html.append(" \n"); html.append("\n"); html.append("\n"); // 页眉 html.append("
\n"); html.append("

产品报价单

\n"); html.append("
杭州第一技师学院智慧校园管理系统
\n"); html.append("
\n"); // 报价单信息 html.append("
\n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append("
报价单号:").append(quote.getQuoteNo()).append("版本号:").append(quote.getVersion()).append("
报价日期:").append(formatDate(quote.getQuoteDate())).append("有效期至:").append(formatDate(quote.getExpireDate())).append("
\n"); html.append("
\n"); // 客户信息 html.append("
\n"); html.append("

客户信息

\n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append("
客户名称:").append(quote.getCustomerName()).append("
联系人:").append(defaultValue(quote.getContactName())).append("联系电话:").append(defaultValue(quote.getContactMobile())).append("
电子邮箱:").append(defaultValue(quote.getContactEmail())) .append("
\n"); html.append("
\n"); // 产品明细 html.append("
\n"); html.append("

产品明细

\n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); List products = quote.getProductList(); if (products != null && !products.isEmpty()) { int index = 1; for (CrmQuoteProductVo product : products) { html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); } } html.append(" \n"); html.append("
序号产品名称规格型号单位数量单价(元)折扣小计(元)
").append(index++).append("").append(product.getProductName()).append("").append(defaultValue(product.getModel())) .append("").append(product.getUnit()).append("").append(product.getQuantity()).append("").append(formatMoney(product.getPrice())) .append("").append(formatDiscount(product.getDiscount())) .append("").append(formatMoney(product.getSubtotal())) .append("
\n"); html.append("
\n"); // 价格汇总 html.append("
\n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); if (quote.getDiscountRate() != null && quote.getDiscountRate().compareTo(BigDecimal.ZERO) > 0) { html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); } html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append(" \n"); html.append("
产品总价:¥ ").append(formatMoney(quote.getTotalAmount())) .append("
整单折扣(").append(quote.getDiscountRate()) .append("%):- ¥ ").append(formatMoney(quote.getDiscountAmount())) .append("
最终金额:¥ ").append(formatMoney(quote.getFinalAmount())) .append("
\n"); html.append("
\n"); // 条款说明 html.append("
\n"); if (StrUtil.isNotBlank(quote.getPaymentTerms())) { html.append("

付款条款

\n"); html.append("

").append(quote.getPaymentTerms()).append("

\n"); } if (StrUtil.isNotBlank(quote.getDeliveryTerms())) { html.append("

交付条款

\n"); html.append("

").append(quote.getDeliveryTerms()).append("

\n"); } if (StrUtil.isNotBlank(quote.getRemark())) { html.append("

备注说明

\n"); html.append("

").append(quote.getRemark()).append("

\n"); } html.append("
\n"); // 页脚 html.append("
\n"); html.append("

销售负责人: ").append(defaultValue(quote.getOwnerUserName())) .append("

\n"); html.append("

联系电话: 请联系您的专属销售

\n"); html.append("

本报价单有效期至 ").append(formatDate(quote.getExpireDate())) .append(",过期需重新确认价格。

\n"); html.append("
\n"); html.append("\n"); html.append(""); return html.toString(); } /** * PDF样式 */ private String getPdfStyles() { return """ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: "Microsoft YaHei", "微软雅黑", Arial, sans-serif; line-height: 1.6; color: #333; padding: 40px; background: #fff; position: relative; } /* 水印层 */ body::before { content: ''; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-image: url('/watermask.jpg'); background-repeat: no-repeat; background-position: center center; background-size: 50%; opacity: 0.15; z-index: 0; pointer-events: none; } /* 确保所有内容在水印上方 */ body > * { position: relative; z-index: 1; } .header { text-align: center; border-bottom: 3px solid #1890ff; padding-bottom: 20px; margin-bottom: 30px; } .header h1 { font-size: 32px; color: #1890ff; margin-bottom: 10px; } .company-name { font-size: 16px; color: #666; } .quote-info, .customer-info { margin-bottom: 30px; } .info-table { width: 100%; border-collapse: collapse; margin-top: 10px; } .info-table td { padding: 8px 12px; border: 1px solid #e8e8e8; } .info-table td:nth-child(odd) { background: #fafafa; width: 120px; } .products { margin-bottom: 30px; } h3 { font-size: 18px; color: #1890ff; margin-bottom: 15px; border-left: 4px solid #1890ff; padding-left: 10px; } .product-table { width: 100%; border-collapse: collapse; margin-top: 10px; } .product-table th { background: #1890ff; color: #fff; padding: 12px 8px; text-align: center; font-weight: normal; } .product-table td { padding: 10px 8px; border: 1px solid #e8e8e8; } .product-table tbody tr:hover { background: #f5f5f5; } .center { text-align: center; } .right { text-align: right; } .price-summary { margin: 30px 0; float: right; width: 350px; } .summary-table { width: 100%; border-collapse: collapse; } .summary-table td { padding: 10px 15px; border: 1px solid #e8e8e8; } .summary-table .label { background: #fafafa; width: 150px; } .summary-table .value { text-align: right; font-size: 16px; } .summary-table .total { background: #fff7e6; } .summary-table .total .value { color: #ff4d4f; font-size: 20px; } .terms { clear: both; margin-top: 50px; } .terms p { padding: 10px 15px; background: #f5f5f5; border-radius: 4px; line-height: 1.8; margin-bottom: 10px; } .footer { margin-top: 50px; padding-top: 20px; border-top: 2px solid #e8e8e8; text-align: center; color: #666; } .footer p { margin: 8px 0; } .footer .notice { color: #ff4d4f; font-size: 14px; margin-top: 20px; } @media print { body { padding: 20px; } /* 打印时也显示水印 */ body::before { position: absolute; print-color-adjust: exact; -webkit-print-color-adjust: exact; } .header { page-break-after: avoid; } .product-table { page-break-inside: avoid; } } """; } /** * 格式化日期 */ private String formatDate(Object date) { if (date == null) { return ""; } if (date instanceof LocalDate) { return ((LocalDate) date).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); } return date.toString(); } /** * 格式化金额 */ private String formatMoney(BigDecimal amount) { if (amount == null) { return "0.00"; } return String.format("%,.2f", amount); } /** * 格式化折扣 */ private String formatDiscount(BigDecimal discount) { if (discount == null || discount.compareTo(BigDecimal.ZERO) == 0) { return "无折扣"; } return discount.stripTrailingZeros().toPlainString() + "%"; } /** * 默认值处理 */ private String defaultValue(String value) { return StrUtil.isNotBlank(value) ? value : "-"; } }