496 lines
19 KiB
Java
496 lines
19 KiB
Java
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("<!DOCTYPE html>\n");
|
|
html.append("<html lang=\"zh-CN\">\n");
|
|
html.append("<head>\n");
|
|
html.append(" <meta charset=\"UTF-8\">\n");
|
|
html.append(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
|
|
html.append(" <title>报价单 - ").append(quote.getQuoteNo()).append("</title>\n");
|
|
html.append(" <style>\n");
|
|
html.append(getPdfStyles());
|
|
html.append(" </style>\n");
|
|
html.append("</head>\n");
|
|
html.append("<body>\n");
|
|
|
|
// 页眉
|
|
html.append(" <div class=\"header\">\n");
|
|
html.append(" <h1>产品报价单</h1>\n");
|
|
html.append(" <div class=\"company-name\">杭州第一技师学院智慧校园管理系统</div>\n");
|
|
html.append(" </div>\n");
|
|
|
|
// 报价单信息
|
|
html.append(" <div class=\"quote-info\">\n");
|
|
html.append(" <table class=\"info-table\">\n");
|
|
html.append(" <tr>\n");
|
|
html.append(" <td><strong>报价单号:</strong></td>\n");
|
|
html.append(" <td>").append(quote.getQuoteNo()).append("</td>\n");
|
|
html.append(" <td><strong>版本号:</strong></td>\n");
|
|
html.append(" <td>").append(quote.getVersion()).append("</td>\n");
|
|
html.append(" </tr>\n");
|
|
html.append(" <tr>\n");
|
|
html.append(" <td><strong>报价日期:</strong></td>\n");
|
|
html.append(" <td>").append(formatDate(quote.getQuoteDate())).append("</td>\n");
|
|
html.append(" <td><strong>有效期至:</strong></td>\n");
|
|
html.append(" <td>").append(formatDate(quote.getExpireDate())).append("</td>\n");
|
|
html.append(" </tr>\n");
|
|
html.append(" </table>\n");
|
|
html.append(" </div>\n");
|
|
|
|
// 客户信息
|
|
html.append(" <div class=\"customer-info\">\n");
|
|
html.append(" <h3>客户信息</h3>\n");
|
|
html.append(" <table class=\"info-table\">\n");
|
|
html.append(" <tr>\n");
|
|
html.append(" <td><strong>客户名称:</strong></td>\n");
|
|
html.append(" <td colspan=\"3\">").append(quote.getCustomerName()).append("</td>\n");
|
|
html.append(" </tr>\n");
|
|
html.append(" <tr>\n");
|
|
html.append(" <td><strong>联系人:</strong></td>\n");
|
|
html.append(" <td>").append(defaultValue(quote.getContactName())).append("</td>\n");
|
|
html.append(" <td><strong>联系电话:</strong></td>\n");
|
|
html.append(" <td>").append(defaultValue(quote.getContactMobile())).append("</td>\n");
|
|
html.append(" </tr>\n");
|
|
html.append(" <tr>\n");
|
|
html.append(" <td><strong>电子邮箱:</strong></td>\n");
|
|
html.append(" <td colspan=\"3\">").append(defaultValue(quote.getContactEmail()))
|
|
.append("</td>\n");
|
|
html.append(" </tr>\n");
|
|
html.append(" </table>\n");
|
|
html.append(" </div>\n");
|
|
|
|
// 产品明细
|
|
html.append(" <div class=\"products\">\n");
|
|
html.append(" <h3>产品明细</h3>\n");
|
|
html.append(" <table class=\"product-table\">\n");
|
|
html.append(" <thead>\n");
|
|
html.append(" <tr>\n");
|
|
html.append(" <th width=\"50\">序号</th>\n");
|
|
html.append(" <th>产品名称</th>\n");
|
|
html.append(" <th width=\"100\">规格型号</th>\n");
|
|
html.append(" <th width=\"60\">单位</th>\n");
|
|
html.append(" <th width=\"80\">数量</th>\n");
|
|
html.append(" <th width=\"100\">单价(元)</th>\n");
|
|
html.append(" <th width=\"80\">折扣</th>\n");
|
|
html.append(" <th width=\"120\">小计(元)</th>\n");
|
|
html.append(" </tr>\n");
|
|
html.append(" </thead>\n");
|
|
html.append(" <tbody>\n");
|
|
|
|
List<CrmQuoteProductVo> products = quote.getProductList();
|
|
if (products != null && !products.isEmpty()) {
|
|
int index = 1;
|
|
for (CrmQuoteProductVo product : products) {
|
|
html.append(" <tr>\n");
|
|
html.append(" <td class=\"center\">").append(index++).append("</td>\n");
|
|
html.append(" <td>").append(product.getProductName()).append("</td>\n");
|
|
html.append(" <td class=\"center\">").append(defaultValue(product.getModel()))
|
|
.append("</td>\n");
|
|
html.append(" <td class=\"center\">").append(product.getUnit()).append("</td>\n");
|
|
html.append(" <td class=\"right\">").append(product.getQuantity()).append("</td>\n");
|
|
html.append(" <td class=\"right\">").append(formatMoney(product.getPrice()))
|
|
.append("</td>\n");
|
|
html.append(" <td class=\"center\">").append(formatDiscount(product.getDiscount()))
|
|
.append("</td>\n");
|
|
html.append(" <td class=\"right\">").append(formatMoney(product.getSubtotal()))
|
|
.append("</td>\n");
|
|
html.append(" </tr>\n");
|
|
}
|
|
}
|
|
|
|
html.append(" </tbody>\n");
|
|
html.append(" </table>\n");
|
|
html.append(" </div>\n");
|
|
|
|
// 价格汇总
|
|
html.append(" <div class=\"price-summary\">\n");
|
|
html.append(" <table class=\"summary-table\">\n");
|
|
html.append(" <tr>\n");
|
|
html.append(" <td class=\"label\">产品总价:</td>\n");
|
|
html.append(" <td class=\"value\">¥ ").append(formatMoney(quote.getTotalAmount()))
|
|
.append("</td>\n");
|
|
html.append(" </tr>\n");
|
|
|
|
if (quote.getDiscountRate() != null && quote.getDiscountRate().compareTo(BigDecimal.ZERO) > 0) {
|
|
html.append(" <tr>\n");
|
|
html.append(" <td class=\"label\">整单折扣(").append(quote.getDiscountRate())
|
|
.append("%):</td>\n");
|
|
html.append(" <td class=\"value\">- ¥ ").append(formatMoney(quote.getDiscountAmount()))
|
|
.append("</td>\n");
|
|
html.append(" </tr>\n");
|
|
}
|
|
|
|
html.append(" <tr class=\"total\">\n");
|
|
html.append(" <td class=\"label\"><strong>最终金额:</strong></td>\n");
|
|
html.append(" <td class=\"value\"><strong>¥ ").append(formatMoney(quote.getFinalAmount()))
|
|
.append("</strong></td>\n");
|
|
html.append(" </tr>\n");
|
|
html.append(" </table>\n");
|
|
html.append(" </div>\n");
|
|
|
|
// 条款说明
|
|
html.append(" <div class=\"terms\">\n");
|
|
|
|
if (StrUtil.isNotBlank(quote.getPaymentTerms())) {
|
|
html.append(" <h3>付款条款</h3>\n");
|
|
html.append(" <p>").append(quote.getPaymentTerms()).append("</p>\n");
|
|
}
|
|
|
|
if (StrUtil.isNotBlank(quote.getDeliveryTerms())) {
|
|
html.append(" <h3>交付条款</h3>\n");
|
|
html.append(" <p>").append(quote.getDeliveryTerms()).append("</p>\n");
|
|
}
|
|
|
|
if (StrUtil.isNotBlank(quote.getRemark())) {
|
|
html.append(" <h3>备注说明</h3>\n");
|
|
html.append(" <p>").append(quote.getRemark()).append("</p>\n");
|
|
}
|
|
|
|
html.append(" </div>\n");
|
|
|
|
// 页脚
|
|
html.append(" <div class=\"footer\">\n");
|
|
html.append(" <p><strong>销售负责人:</strong> ").append(defaultValue(quote.getOwnerUserName()))
|
|
.append("</p>\n");
|
|
html.append(" <p><strong>联系电话:</strong> 请联系您的专属销售</p>\n");
|
|
html.append(" <p class=\"notice\">本报价单有效期至 ").append(formatDate(quote.getExpireDate()))
|
|
.append(",过期需重新确认价格。</p>\n");
|
|
html.append(" </div>\n");
|
|
|
|
html.append("</body>\n");
|
|
html.append("</html>");
|
|
|
|
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 : "-";
|
|
}
|
|
|
|
}
|