zlwu 5 месяцев назад
Родитель
Сommit
ca98e9d806

+ 7 - 0
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/service/ICrmProductService.java

@@ -108,4 +108,11 @@ public interface ICrmProductService {
      */
     Long resolveProductIdForQuoteItem(CrmQuoteProductBo quoteProductBo);
 
+    /**
+     * 自动生成唯一产品编号(SXWL + 随机字符)
+     *
+     * @return 产品编号
+     */
+    String generateAutoProductNo();
+
 }

+ 1 - 42
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/service/impl/CrmContractServiceImpl.java

@@ -51,10 +51,6 @@ import java.util.stream.Collectors;
 @Service
 public class CrmContractServiceImpl implements ICrmContractService {
 
-    private static final String CONTRACT_AUTO_PRODUCT_NO_PREFIX = "SXWL";
-    private static final int CONTRACT_AUTO_PRODUCT_NO_START = 101;
-    private static final int CONTRACT_AUTO_PRODUCT_NO_WIDTH = 5;
-
     private final CrmContractMapper baseMapper;
     private final ICrmContractProductService contractProductService;
     private final CrmContractApprovalMapper approvalMapper;
@@ -1523,7 +1519,7 @@ public class CrmContractServiceImpl implements ICrmContractService {
 
         // 未命中则自动新增产品
         CrmProductBo newBo = new CrmProductBo();
-        newBo.setProductNo(generateContractAutoProductNo());
+        newBo.setProductNo(productService.generateAutoProductNo());
         newBo.setProductName(productName);
         newBo.setCategoryId(contractProductBo.getCategoryId());
         newBo.setCategoryName(normalizeText(contractProductBo.getCategoryName()));
@@ -1570,43 +1566,6 @@ public class CrmContractServiceImpl implements ICrmContractService {
         return normalized;
     }
 
-    /**
-     * 合同保存自动新增产品编号规则:SXWL00101起,按当前最大值递增
-     */
-    private String generateContractAutoProductNo() {
-        LambdaQueryWrapper<CrmProduct> lqw = Wrappers.lambdaQuery();
-        lqw.likeRight(CrmProduct::getProductNo, CONTRACT_AUTO_PRODUCT_NO_PREFIX);
-        lqw.orderByDesc(CrmProduct::getProductNo);
-        lqw.last("LIMIT 1");
-        CrmProduct latest = productMapper.selectOne(lqw);
-
-        int nextSeq = CONTRACT_AUTO_PRODUCT_NO_START;
-        if (latest != null && StringUtils.isNotBlank(latest.getProductNo())) {
-            String latestNo = latest.getProductNo().trim();
-            if (latestNo.startsWith(CONTRACT_AUTO_PRODUCT_NO_PREFIX)) {
-                String seqPart = latestNo.substring(CONTRACT_AUTO_PRODUCT_NO_PREFIX.length()).trim();
-                try {
-                    int current = Integer.parseInt(seqPart);
-                    nextSeq = Math.max(current + 1, CONTRACT_AUTO_PRODUCT_NO_START);
-                } catch (NumberFormatException ignored) {
-                    nextSeq = CONTRACT_AUTO_PRODUCT_NO_START;
-                }
-            }
-        }
-
-        // 兜底防碰撞:最多重试20次
-        for (int i = 0; i < 20; i++) {
-            String candidate = CONTRACT_AUTO_PRODUCT_NO_PREFIX
-                    + String.format("%0" + CONTRACT_AUTO_PRODUCT_NO_WIDTH + "d", nextSeq + i);
-            Long count = productMapper.selectCount(Wrappers.lambdaQuery(CrmProduct.class)
-                    .eq(CrmProduct::getProductNo, candidate));
-            if (count == 0) {
-                return candidate;
-            }
-        }
-        throw new ServiceException("自动生成产品编号失败,请稍后重试");
-    }
-
     private void validateContractTypeAndProductsPresence(CrmContractBo bo) {
         if (bo == null || StringUtils.isBlank(bo.getContractType())) {
             return;

+ 32 - 42
crm/backend/ruoyi-modules/ruoyi-crm/src/main/java/org/dromara/crm/service/impl/CrmProductServiceImpl.java

@@ -46,8 +46,8 @@ import org.springframework.web.multipart.MultipartFile;
 public class CrmProductServiceImpl implements ICrmProductService {
 
     private static final String QUOTE_AUTO_PRODUCT_NO_PREFIX = "SXWL";
-    private static final int QUOTE_AUTO_PRODUCT_NO_START = 101;
-    private static final int QUOTE_AUTO_PRODUCT_NO_WIDTH = 5;
+    private static final int QUOTE_AUTO_PRODUCT_NO_RANDOM_LEN = 8;
+    private static final int PRODUCT_NO_GENERATE_MAX_RETRIES = 50;
 
     private final CrmProductMapper baseMapper;
     private final CrmOpportunityProductMapper opportunityProductMapper;
@@ -144,10 +144,9 @@ public class CrmProductServiceImpl implements ICrmProductService {
                 throw new ServiceException("产品编号已存在");
             }
         } else {
-            // 自动生成产品编号:PRD+yyyyMMdd+4位随机数
             String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
-            String random = IdUtil.fastSimpleUUID().substring(0, 4);
-            bo.setProductNo("PRD" + date + random);
+            bo.setProductNo(generateUniqueProductNo(() -> "PRD" + date
+                + IdUtil.fastSimpleUUID().substring(0, 4).toUpperCase()));
         }
 
         // 验证价格合理性
@@ -622,11 +621,38 @@ public class CrmProductServiceImpl implements ICrmProductService {
         // 报价保存自动新增产品时,标准价格默认0
         newBo.setPrice(BigDecimal.ZERO);
         newBo.setStatus("on");
-        newBo.setProductNo(generateQuoteAutoProductNo());
+        newBo.setProductNo(generateAutoProductNo());
         insertByBo(newBo);
         return newBo.getId();
     }
 
+    @Override
+    public String generateAutoProductNo() {
+        return generateUniqueProductNo(() -> QUOTE_AUTO_PRODUCT_NO_PREFIX
+            + IdUtil.fastSimpleUUID().substring(0, QUOTE_AUTO_PRODUCT_NO_RANDOM_LEN).toUpperCase());
+    }
+
+    /**
+     * 生成唯一产品编号:随机生成 → 查库校验 → 已存在则重新生成
+     */
+    private String generateUniqueProductNo(java.util.function.Supplier<String> generator) {
+        for (int i = 0; i < PRODUCT_NO_GENERATE_MAX_RETRIES; i++) {
+            String candidate = generator.get();
+            if (!isProductNoExists(candidate)) {
+                return candidate;
+            }
+        }
+        throw new ServiceException("自动生成产品编号失败,请稍后重试");
+    }
+
+    private boolean isProductNoExists(String productNo) {
+        if (StringUtils.isBlank(productNo)) {
+            return true;
+        }
+        return baseMapper.selectCount(Wrappers.lambdaQuery(CrmProduct.class)
+            .eq(CrmProduct::getProductNo, productNo)) > 0;
+    }
+
     private String normalizeText(String value) {
         if (value == null) {
             return null;
@@ -660,40 +686,4 @@ public class CrmProductServiceImpl implements ICrmProductService {
         return normalized;
     }
 
-    /**
-     * 报价保存自动新增产品编号规则:SXWL00101起,按当前最大值递增
-     */
-    private String generateQuoteAutoProductNo() {
-        LambdaQueryWrapper<CrmProduct> lqw = Wrappers.lambdaQuery();
-        lqw.likeRight(CrmProduct::getProductNo, QUOTE_AUTO_PRODUCT_NO_PREFIX);
-        lqw.orderByDesc(CrmProduct::getProductNo);
-        lqw.last("LIMIT 1");
-        CrmProduct latest = baseMapper.selectOne(lqw);
-
-        int nextSeq = QUOTE_AUTO_PRODUCT_NO_START;
-        if (latest != null && StringUtils.isNotBlank(latest.getProductNo())) {
-            String latestNo = latest.getProductNo().trim();
-            if (latestNo.startsWith(QUOTE_AUTO_PRODUCT_NO_PREFIX)) {
-                String seqPart = latestNo.substring(QUOTE_AUTO_PRODUCT_NO_PREFIX.length()).trim();
-                try {
-                    int current = Integer.parseInt(seqPart);
-                    nextSeq = Math.max(current + 1, QUOTE_AUTO_PRODUCT_NO_START);
-                } catch (NumberFormatException ignored) {
-                    nextSeq = QUOTE_AUTO_PRODUCT_NO_START;
-                }
-            }
-        }
-
-        // 兜底防碰撞:最多重试20次
-        for (int i = 0; i < 20; i++) {
-            String candidate = QUOTE_AUTO_PRODUCT_NO_PREFIX + String.format("%0" + QUOTE_AUTO_PRODUCT_NO_WIDTH + "d", nextSeq + i);
-            Long count = baseMapper.selectCount(Wrappers.lambdaQuery(CrmProduct.class)
-                    .eq(CrmProduct::getProductNo, candidate));
-            if (count == 0) {
-                return candidate;
-            }
-        }
-        throw new ServiceException("自动生成产品编号失败,请稍后重试");
-    }
-
 }

+ 33 - 10
crm/web/src/views/crm/contract/index.vue

@@ -1550,6 +1550,7 @@ import {
   type ContractQuery,
   type ContractVO
 } from '@/api/crm/contract';
+import { getQuote } from '@/api/crm/quote';
 import { useDict } from '@/utils/dict';
 import { parseTime } from '@/utils/ruoyi';
 import { useUserStore } from '@/store/modules/user';
@@ -2369,8 +2370,9 @@ const handleGeneratePdf = async (row: ContractVO) => {
     const contract = res.data;
 
     // 2. 生成HTML内容(先生成带占位符的模板,再替换占位符为最新数据)
+    const quoteFreight = await fetchQuoteFreight(contract.quoteId);
     let htmlContent = generateContractTemplate(contract);
-    htmlContent = replacePlaceholders(htmlContent, contract);
+    htmlContent = replacePlaceholders(htmlContent, contract, quoteFreight);
 
     // 3. 创建临时容器
     const tempDiv = document.createElement('div');
@@ -2729,6 +2731,8 @@ const handleDetailPrint = async () => {
       console.warn('⚠️ 获取最新合同数据失败,使用详情抽屉中的数据:', fetchError);
     }
 
+    const quoteFreight = await fetchQuoteFreight(contractData.quoteId);
+
     // 先尝试从数据库获取已保存的模板
     let htmlContent = '';
     try {
@@ -2753,7 +2757,7 @@ const handleDetailPrint = async () => {
 
     // 统一用数据库最新数据替换占位符(无论模板来自数据库还是新生成的,都可能含占位符)
     try {
-      htmlContent = replacePlaceholders(htmlContent, contractData);
+      htmlContent = replacePlaceholders(htmlContent, contractData, quoteFreight);
       console.log('占位符替换完成');
     } catch (replaceError) {
       console.error('替换占位符失败,使用原始内容:', replaceError);
@@ -3017,8 +3021,21 @@ watch(detailActiveTab, (newTab) => {
   // 目前回款和开票记录在详情中已存在
 });
 
+/** 获取合同关联报价单的运费(无关联或获取失败时返回 0) */
+const fetchQuoteFreight = async (quoteId?: number): Promise<number> => {
+  if (!quoteId) return 0;
+  try {
+    const quoteRes = await getQuote(quoteId);
+    const freight = Number(quoteRes?.data?.freight);
+    return Number.isFinite(freight) && freight > 0 ? freight : 0;
+  } catch (error) {
+    console.warn('获取报价单运费失败:', error);
+    return 0;
+  }
+};
+
 /** 替换模板中的占位符为数据库最新数据 */
-const replacePlaceholders = (htmlContent: string, contractData: ContractVO | undefined): string => {
+const replacePlaceholders = (htmlContent: string, contractData: ContractVO | undefined, quoteFreight = 0): string => {
   if (!htmlContent || !contractData) return htmlContent;
 
   let result = htmlContent;
@@ -3260,12 +3277,18 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
   // 匹配 {{productTable}} 占位符
   result = result.replace(/\{\{productTable\}\}/gi, productRows);
 
-  // 替换产品合计行
-  const totalAmountText = isFrameworkContract ? '随行就市' : `${productTotalChinese}(¥${formatNumber(productTotal)})`;
+  // 替换产品合计行(左侧运费,右侧合计=产品金额+运费)
+  const freightAmount = Number(quoteFreight) || 0;
+  const totalWithFreight = productTotal + freightAmount;
+  const totalWithFreightChinese = numberToChinese(totalWithFreight);
+  const freightText = freightAmount > 0 ? `¥${formatNumber(freightAmount)}` : '-';
+  const totalWithFreightText = isFrameworkContract ? '随行就市' : `${totalWithFreightChinese}(¥${formatNumber(totalWithFreight)})`;
   const productTotalRow = `
             <tr style="background-color: #fff; font-weight: bold;">
-              <td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">合计(人民币)</td>
-              <td colspan="6" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${totalAmountText}</td>
+              <td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">运费</td>
+              <td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${freightText}</td>
+              <td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">合计(人民币)</td>
+              <td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${totalWithFreightText}</td>
             </tr>
             <tr>
               <td colspan="10" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 11px; background-color: #fff;">
@@ -3785,8 +3808,7 @@ const handleSaveTemplate = async () => {
 
       // 替换合计行(如果存在)
       if (totalRowHtml) {
-        // 查找并替换合计行(通过合计人民币文本定位)
-        const totalRowPattern = /<tr style="background-color: #fff; font-weight: bold;">[\s\S]*?<td colspan="4"[\s\S]*?>合计[\s\S]*?<\/tr>/;
+        const totalRowPattern = /<tr style="background-color: #fff; font-weight: bold;">[\s\S]*?<\/tr>/;
         if (totalRowPattern.test(finalTemplate)) {
           finalTemplate = finalTemplate.replace(totalRowPattern, totalRowHtml);
         }
@@ -4087,7 +4109,8 @@ const handleGeneratePdfFromDialog = async () => {
     if (htmlContent.includes('{{') || htmlContent.includes('SETTLEMENT_METHOD_CONTENT')) {
       // 重新获取最新合同数据来替换占位符
       const res = await getContractDetail(detailContract.value.id);
-      htmlContent = replacePlaceholders(htmlContent, res.data);
+      const quoteFreight = await fetchQuoteFreight(res.data?.quoteId);
+      htmlContent = replacePlaceholders(htmlContent, res.data, quoteFreight);
     }
 
     // 1. 创建临时容器