From a82d847bbb39e2417d4c9773ebc4fc51fdb0b8fc Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期二, 10 十一月 2026 13:31:31 +0800
Subject: [PATCH] feat(crm): 完善投标保证金与报价单关联及作废功能

---
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java |  295 +++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 279 insertions(+), 16 deletions(-)

diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java
index cba49d1..2893e19 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java
+++ b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java
@@ -3,7 +3,9 @@
 import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.util.ObjUtil;
 import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.exception.ServiceException;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
 import cn.iocoder.yudao.framework.common.util.number.MoneyUtils;
 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
 import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
@@ -15,15 +17,21 @@
 import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractSaveReqVO;
 import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationPageReqVO;
 import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationSaveReqVO;
+import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
 import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
 import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
 import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationProductDO;
+import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationRevisionDO;
+import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
 import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
 import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationProductMapper;
+import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationRevisionMapper;
 import cn.iocoder.yudao.module.crm.dal.redis.no.CrmNoRedisDAO;
+import cn.iocoder.yudao.module.crm.enums.bidbond.CrmBidResultEnum;
 import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
 import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum;
 import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
+import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationVoidTypeEnum;
 import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService;
 import cn.iocoder.yudao.module.crm.service.contact.CrmContactService;
 import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
@@ -35,6 +43,7 @@
 import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
 import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import jakarta.annotation.Resource;
 import lombok.extern.slf4j.Slf4j;
 import org.flowable.engine.repository.ProcessDefinition;
@@ -43,10 +52,13 @@
 import org.springframework.validation.annotation.Validated;
 
 import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.*;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
+import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
 import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*;
 
 /**
@@ -64,10 +76,18 @@
      */
     private static final String SALE_QUOTATION_APPROVE_CATEGORY_CODE = "sale_quotation_approve";
 
+    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
     @Resource
     private CrmSaleQuotationMapper saleQuotationMapper;
     @Resource
     private CrmSaleQuotationProductMapper saleQuotationProductMapper;
+    @Resource
+    private CrmSaleQuotationRevisionMapper revisionMapper;
+    @Resource
+    private CrmBidBondMapper bidBondMapper;
+    @Resource
+    private CrmSaleQuotationExpiryHelper expiryHelper;
 
     @Resource
     private CrmNoRedisDAO noRedisDAO;
@@ -113,6 +133,7 @@
         // 2.1 鎻掑叆鎶ヤ环鍗�
         CrmSaleQuotationDO saleQuotation = BeanUtils.toBean(createReqVO, CrmSaleQuotationDO.class, q -> q
                 .setNo(no)
+                .setNeedBid(!Boolean.FALSE.equals(createReqVO.getNeedBid()))
                 .setStatus(CrmSaleQuotationStatusEnum.DRAFT.getStatus()));
         calculateTotalPrice(saleQuotation, quotationProducts);
         saleQuotationMapper.insert(saleQuotation);
@@ -133,11 +154,18 @@
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void updateSaleQuotation(CrmSaleQuotationSaveReqVO updateReqVO) {
-        // 1.1 鏍¢獙瀛樺湪
+        // 1.1 鏍¢獙瀛樺湪锛屽苟澶勭悊杩囨湡
         CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(updateReqVO.getId());
-        // 1.2 鏍¢獙鐘舵�侊紝鍙湁鑽夌鐘舵�佹墠鑳戒慨鏀�
+        expiryHelper.expireIfNeeded(saleQuotation);
+        validateNotVoid(saleQuotation);
+        // 1.2 鏍¢獙鐘舵�侊細鑽夌鍙洿鎺ヤ慨鏀癸紱缁戝畾鎶曟爣涓旀姇鏍囨湭涓爣鏃讹紝鍏佽淇敼宸插鎵圭殑鎶ヤ环鍗曢噸鏂版姤浠�
+        boolean requote = false;
         if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())) {
-            throw exception(SALE_QUOTATION_UPDATE_FAIL_NOT_DRAFT, saleQuotation.getNo());
+            requote = canRequote(saleQuotation);
+            if (!requote) {
+                throw exception(SALE_QUOTATION_REQUOTE_FAIL_NOT_LOST, saleQuotation.getNo(),
+                        getStatusName(saleQuotation.getStatus()));
+            }
         }
         // 1.3 鏍¢獙鐗╂枡椤圭殑鏈夋晥鎬�
         List<CrmSaleQuotationProductDO> quotationProducts = validateQuotationItems(updateReqVO.getItems());
@@ -155,7 +183,11 @@
         }
         calculateTotalPrice(updateObj, quotationProducts);
         saleQuotationMapper.updateById(updateObj);
-        // 2.2 鏇存柊鎶ヤ环鏄庣粏
+        // 2.2 閲嶆柊鎶ヤ环锛氶噸缃负鑽夌骞舵竻绌轰笂涓�杞祦绋嬪疄渚嬶紝绛夊緟閲嶆柊鎻愪氦
+        if (requote) {
+            saleQuotationMapper.resetToDraft(saleQuotation.getId());
+        }
+        // 2.3 鏇存柊鎶ヤ环鏄庣粏
         updateQuotationProductList(updateReqVO.getId(), quotationProducts);
         storageAttachmentApi.updateAttachments("file", "crm_sale_quotation", updateReqVO.getId(), updateReqVO.getBlobIds());
     }
@@ -171,6 +203,9 @@
         saleQuotations.forEach(saleQuotation -> {
             if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())) {
                 throw exception(SALE_QUOTATION_DELETE_FAIL_NOT_DRAFT, saleQuotation.getNo());
+            }
+            if (saleQuotation.getBidBondId() != null) {
+                throw exception(SALE_QUOTATION_DELETE_FAIL_BOUND, saleQuotation.getNo(), saleQuotation.getBidBondNo());
             }
         });
 
@@ -190,11 +225,15 @@
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void submitSaleQuotation(Long id, String processDefinitionKey, Long userId) {
-        // 1. 鏍¢獙鎶ヤ环鍗曞瓨鍦ㄤ笖涓鸿崏绋跨姸鎬�
+        // 1. 鏍¢獙鎶ヤ环鍗曞瓨鍦ㄤ笖涓鸿崏绋跨姸鎬侊紙鍏堝鐞嗚繃鏈燂紝閬垮厤鎻愪氦宸插け鏁堢殑鎶ヤ环锛�
         CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
+        expiryHelper.expireIfNeeded(saleQuotation);
+        validateNotVoid(saleQuotation);
         if (ObjUtil.notEqual(saleQuotation.getStatus(), CrmSaleQuotationStatusEnum.DRAFT.getStatus())) {
             throw exception(SALE_QUOTATION_SUBMIT_FAIL_NOT_DRAFT, saleQuotation.getNo());
         }
+        // 1.1 璁板綍鏈疆鎶ヤ环娴佹按锛堝惈鎶ヤ环蹇収涓庢槑缁� JSON锛�
+        recordRevision(saleQuotation, userId);
 
         // 2. 鍏ㄥ眬鍏嶅鎵癸細鐩存帴缃负瀹℃壒閫氳繃锛屾棤闇�娴佺▼妯″瀷
         if (bpmProcessInstanceApi.isGlobalAutoApprove()) {
@@ -388,24 +427,32 @@
 
     @Override
     public CrmSaleQuotationDO getSaleQuotation(Long id) {
-        return saleQuotationMapper.selectById(id);
+        CrmSaleQuotationDO quotation = saleQuotationMapper.selectById(id);
+        expiryHelper.expireIfNeeded(quotation);
+        return quotation;
     }
 
     @Override
     public PageResult<CrmSaleQuotationDO> getSaleQuotationPage(CrmSaleQuotationPageReqVO pageReqVO, Long userId) {
         // 鑾峰彇鐢ㄦ埛鏈夋潈闄愮殑瀹㈡埛 ID 鍒楄〃
         List<Long> permittedCustomerIds = customerApi.getPermittedCustomerIds();
-        return saleQuotationMapper.selectPage(pageReqVO, userId, permittedCustomerIds);
+        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationMapper.selectPage(pageReqVO, userId, permittedCustomerIds);
+        expiryHelper.expireIfNeeded(pageResult.getList());
+        return pageResult;
     }
 
     @Override
     public PageResult<CrmSaleQuotationDO> getSaleQuotationPageByCustomerId(CrmSaleQuotationPageReqVO pageReqVO) {
-        return saleQuotationMapper.selectPageByCustomerId(pageReqVO);
+        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationMapper.selectPageByCustomerId(pageReqVO);
+        expiryHelper.expireIfNeeded(pageResult.getList());
+        return pageResult;
     }
 
     @Override
     public PageResult<CrmSaleQuotationDO> getSaleQuotationPageByBusinessId(CrmSaleQuotationPageReqVO pageReqVO) {
-        return saleQuotationMapper.selectPageByBusinessId(pageReqVO);
+        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationMapper.selectPageByBusinessId(pageReqVO);
+        expiryHelper.expireIfNeeded(pageResult.getList());
+        return pageResult;
     }
 
     @Override
@@ -424,15 +471,41 @@
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Long convertToContract(Long id, Long userId) {
-        // 1. 鏍¢獙鎶ヤ环鍗曞瓨鍦紝涓斾负鑽夌鎴栧鎵归�氳繃鐘舵��
+        // 1. 鏍¢獙鎶ヤ环鍗曞瓨鍦紝骞跺鐞嗚繃鏈�
         CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
-        if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())
-                && !CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())) {
-            CrmSaleQuotationStatusEnum current = CrmSaleQuotationStatusEnum.getByStatus(saleQuotation.getStatus());
-            throw exception(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT, saleQuotation.getNo(),
-                    current != null ? current.getName() : String.valueOf(saleQuotation.getStatus()));
+        expiryHelper.expireIfNeeded(saleQuotation);
+        validateNotVoid(saleQuotation);
+        // 1.1 闇�瑕佹姇鏍囩殑鎶ヤ环鍗曪細蹇呴』鍏堢粦瀹氭姇鏍�
+        boolean needBid = !Boolean.FALSE.equals(saleQuotation.getNeedBid());
+        if (needBid && saleQuotation.getBidBondId() == null) {
+            throw exception(SALE_QUOTATION_CONVERT_FAIL_BID_REQUIRED, saleQuotation.getNo());
         }
-        // 鏍¢獙鏄惁宸茶浆鍚堝悓
+        // 1.2 缁戝畾鎶曟爣鐨勬姤浠峰崟锛氬繀椤诲凡涓爣锛屼笖鏄鎶曟爣褰撳墠缁戝畾鐨勬姤浠峰崟
+        if (saleQuotation.getBidBondId() != null) {
+            CrmBidBondDO bidBond = bidBondMapper.selectById(saleQuotation.getBidBondId());
+            if (bidBond == null) {
+                throw new ServiceException(BID_BOND_NOT_EXISTS.getCode(),
+                        "閿�鍞姤浠峰崟[" + saleQuotation.getNo() + "]鍏宠仈鐨勬姇鏍�(ID=" + saleQuotation.getBidBondId()
+                                + ")涓嶅瓨鍦紝鏃犳硶杞悎鍚岋紱璇峰厛鍦ㄦ姇鏍囦繚璇侀噾涓噸鏂板叧鑱旀姇鏍囷紝鎴栬В闄ゆ姤浠峰崟缁戝畾銆�");
+            }
+            if (!CrmBidResultEnum.WON.getResult().equals(bidBond.getBidResult())) {
+                throw exception(SALE_QUOTATION_CONVERT_FAIL_BID_NOT_WON, saleQuotation.getNo(),
+                        bidBond.getNo(), getBidResultName(bidBond.getBidResult()));
+            }
+            if (!saleQuotation.getId().equals(bidBond.getQuotationId())) {
+                throw exception(SALE_QUOTATION_CONVERT_FAIL_BID_QUOTATION_MISMATCH,
+                        saleQuotation.getNo(), bidBond.getNo());
+            }
+            if (!CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())) {
+                throw exception(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT, saleQuotation.getNo(),
+                        getStatusName(saleQuotation.getStatus()));
+            }
+        } else if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())
+                && !CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())) {
+            throw exception(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT, saleQuotation.getNo(),
+                    getStatusName(saleQuotation.getStatus()));
+        }
+        // 1.2 鏍¢獙鏄惁宸茶浆鍚堝悓
         if (saleQuotation.getContractId() != null) {
             throw exception(SALE_QUOTATION_CONTRACT_EXISTS);
         }
@@ -484,4 +557,194 @@
         saleQuotationMapper.clearContractReference(quotationId);
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void voidSaleQuotation(Long id, String reason) {
+        // 1.1 鏍¢獙浣滃簾鍘熷洜
+        String trimmedReason = StrUtil.trim(reason);
+        if (StrUtil.isBlank(trimmedReason)) {
+            throw exception(SALE_QUOTATION_VOID_REASON_REQUIRED);
+        }
+        if (trimmedReason.length() > 500) {
+            throw new ServiceException(SALE_QUOTATION_VOID_REASON_REQUIRED.getCode(),
+                    "浣滃簾鍘熷洜涓嶈兘瓒呰繃 500 涓瓧绗︼紝褰撳墠涓� " + trimmedReason.length() + " 涓瓧绗︼紝璇风簿绠�鍚庨噸璇�");
+        }
+        // 1.2 鏍¢獙鎶ヤ环鍗曞瓨鍦紝骞跺鐞嗚繃鏈燂紙宸茶繃鏈熷垯鎸夎秴鏈熶綔搴熸彁绀猴紝涓嶅啀璧版墜鍔ㄤ綔搴燂級
+        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
+        expiryHelper.expireIfNeeded(saleQuotation);
+        validateNotVoid(saleQuotation);
+        // 1.3 宸蹭腑鏍囩殑鎶ヤ环鍗曚笉鍏佽浣滃簾锛屽惁鍒欏悎鍚岄棴鐜柇瑁�
+        if (saleQuotation.getBidBondId() != null) {
+            CrmBidBondDO bidBond = bidBondMapper.selectById(saleQuotation.getBidBondId());
+            if (bidBond != null && CrmBidResultEnum.WON.getResult().equals(bidBond.getBidResult())) {
+                throw exception(SALE_QUOTATION_VOID_FAIL_WON, saleQuotation.getNo());
+            }
+        }
+        // 1.4 鏍¢獙褰撳墠鐘舵�佹槸鍚﹀厑璁镐綔搴�
+        boolean wasProcessing = CrmSaleQuotationStatusEnum.PROCESS.getStatus().equals(saleQuotation.getStatus());
+        if (!wasProcessing
+                && !CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())
+                && !CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())
+                && !CrmSaleQuotationStatusEnum.REJECT.getStatus().equals(saleQuotation.getStatus())) {
+            throw exception(SALE_QUOTATION_VOID_FAIL_STATUS, saleQuotation.getNo(),
+                    getStatusName(saleQuotation.getStatus()));
+        }
+
+        // 2.1 鍏堣惤搴撲负宸蹭綔搴燂紝BPM 鎾ら攢鍥炶皟鍙鐞嗗鎵逛腑鐘舵�侊紝涓嶄細瑕嗙洊鏈姸鎬�
+        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
+                .setId(id)
+                .setStatus(CrmSaleQuotationStatusEnum.VOID.getStatus())
+                .setVoidType(CrmSaleQuotationVoidTypeEnum.MANUAL.getType())
+                .setVoidReason(trimmedReason)
+                .setVoidTime(LocalDateTime.now())
+                .setVoidBy(getLoginUserId()));
+        // 2.2 瀹℃壒涓殑鎶ヤ环鍗曞悓姝ユ挙閿�娴佺▼锛岄伩鍏嶅鎵逛汉缁х画澶勭悊宸蹭綔搴熺殑鎶ヤ环
+        if (wasProcessing) {
+            cancelProcessQuietly(saleQuotation.getProcessInstanceId(), trimmedReason);
+        }
+    }
+
+    @Override
+    public List<CrmSaleQuotationRevisionDO> getSaleQuotationRevisionList(Long quotationId) {
+        validateSaleQuotationExists(quotationId);
+        return revisionMapper.selectListByQuotationId(quotationId);
+    }
+
+    @Override
+    public List<CrmSaleQuotationDO> getBindableQuotationList(Long customerId, Long bidBondId) {
+        if (customerId == null) {
+            return Collections.emptyList();
+        }
+        List<CrmSaleQuotationDO> quotations = saleQuotationMapper.selectListByCustomerIdAndStatuses(customerId,
+                List.of(CrmSaleQuotationStatusEnum.DRAFT.getStatus(),
+                        CrmSaleQuotationStatusEnum.APPROVE.getStatus(),
+                        CrmSaleQuotationStatusEnum.REJECT.getStatus()));
+        // 杩囨湡鎶ヤ环鍗曚細鍔ㄦ�佷綔搴燂紝闇�鍏堜粠鍊欓�変腑鍓旈櫎
+        expiryHelper.expireIfNeeded(quotations);
+        return quotations.stream()
+                .filter(q -> q.getBidBondId() == null || Objects.equals(q.getBidBondId(), bidBondId))
+                .collect(java.util.stream.Collectors.toList());
+    }
+
+    /**
+     * 璁板綍鏈疆鎶ヤ环娴佹按锛屼緵浜屾/涓夋鎶ヤ环杩芥函銆�
+     */
+    private void recordRevision(CrmSaleQuotationDO saleQuotation, Long userId) {
+        int revisionNo = (saleQuotation.getRevisionNo() == null ? 0 : saleQuotation.getRevisionNo()) + 1;
+        if (revisionMapper.selectByQuotationIdAndRevisionNo(saleQuotation.getId(), revisionNo) != null) {
+            throw exception(SALE_QUOTATION_REVISION_EXISTS, saleQuotation.getNo(), revisionNo);
+        }
+        List<CrmSaleQuotationProductDO> products =
+                saleQuotationProductMapper.selectListByQuotationId(saleQuotation.getId());
+        revisionMapper.insert(CrmSaleQuotationRevisionDO.builder()
+                .quotationId(saleQuotation.getId())
+                .quotationNo(saleQuotation.getNo())
+                .revisionNo(revisionNo)
+                .bidBondId(saleQuotation.getBidBondId())
+                .bidBondNo(saleQuotation.getBidBondNo())
+                .customerId(saleQuotation.getCustomerId())
+                .name(saleQuotation.getName())
+                .quotationTime(saleQuotation.getQuotationTime())
+                .validUntil(saleQuotation.getValidUntil())
+                .totalProductPrice(saleQuotation.getTotalProductPrice())
+                .discountPercent(saleQuotation.getDiscountPercent())
+                .discountPrice(saleQuotation.getDiscountPrice())
+                .totalPrice(saleQuotation.getTotalPrice())
+                .taxRate(saleQuotation.getTaxRate())
+                .taxPrice(saleQuotation.getTaxPrice())
+                .itemsSnapshot(buildItemsSnapshot(products))
+                .status(CrmSaleQuotationStatusEnum.PROCESS.getStatus())
+                .submitUserId(userId)
+                .submitTime(LocalDateTime.now())
+                .build());
+        // 鍚屾鎶ヤ环鍗曡疆娆�
+        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
+                .setId(saleQuotation.getId())
+                .setRevisionNo(revisionNo));
+        saleQuotation.setRevisionNo(revisionNo);
+        // 鍚屾鎶曟爣璁板綍涓婄殑褰撳墠鎶ヤ环杞
+        if (saleQuotation.getBidBondId() != null) {
+            bidBondMapper.updateById(new CrmBidBondDO()
+                    .setId(saleQuotation.getBidBondId())
+                    .setQuotationRound(revisionNo));
+        }
+    }
+
+    /**
+     * 鏄惁鍏佽閲嶆柊鎶ヤ环锛氱粦瀹氭姇鏍囥�佹姤浠峰凡瀹℃壒杩囥�佷笖鎶曟爣缁撴灉涓恒�屾湭涓爣銆嶃��
+     */
+    private boolean canRequote(CrmSaleQuotationDO saleQuotation) {
+        if (saleQuotation.getBidBondId() == null) {
+            return false;
+        }
+        if (!CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())
+                && !CrmSaleQuotationStatusEnum.REJECT.getStatus().equals(saleQuotation.getStatus())) {
+            return false;
+        }
+        CrmBidBondDO bidBond = bidBondMapper.selectById(saleQuotation.getBidBondId());
+        return bidBond != null && CrmBidResultEnum.LOST.getResult().equals(bidBond.getBidResult());
+    }
+
+    /**
+     * 宸蹭綔搴熺殑鎶ヤ环鍗曚笉鍏佽缁х画鎿嶄綔锛屾寜浣滃簾绫诲瀷缁欏嚭绮剧‘鎻愮ず銆�
+     */
+    private void validateNotVoid(CrmSaleQuotationDO saleQuotation) {
+        if (!CrmSaleQuotationStatusEnum.VOID.getStatus().equals(saleQuotation.getStatus())) {
+            return;
+        }
+        if (CrmSaleQuotationVoidTypeEnum.EXPIRED.getType().equals(saleQuotation.getVoidType())) {
+            String validUntil = saleQuotation.getValidUntil() != null
+                    ? saleQuotation.getValidUntil().format(DATE_FORMATTER) : "鏈煡";
+            throw exception(SALE_QUOTATION_EXPIRED, saleQuotation.getNo(), validUntil);
+        }
+        throw exception(SALE_QUOTATION_VOIDED, saleQuotation.getNo(),
+                StrUtil.blankToDefault(saleQuotation.getVoidReason(), "鏈~鍐欎綔搴熷師鍥�"));
+    }
+
+    private String getStatusName(Integer status) {
+        CrmSaleQuotationStatusEnum statusEnum = CrmSaleQuotationStatusEnum.getByStatus(status);
+        return statusEnum != null ? statusEnum.getName() : "鏈煡鐘舵��(" + status + ")";
+    }
+
+    private String getBidResultName(Integer result) {
+        CrmBidResultEnum resultEnum = CrmBidResultEnum.valueOf(result);
+        return resultEnum != null ? resultEnum.getName() : "鏈煡缁撴灉(" + result + ")";
+    }
+
+    private String buildItemsSnapshot(List<CrmSaleQuotationProductDO> products) {
+        if (CollUtil.isEmpty(products)) {
+            return JsonUtils.toJsonString(Collections.emptyList());
+        }
+        Set<Long> itemIds = convertSet(products, CrmSaleQuotationProductDO::getItemId);
+        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(itemIds);
+        List<Map<String, Object>> items = convertList(products, product -> {
+            MdmItemRespDTO item = itemMap.get(product.getItemId());
+            Map<String, Object> snapshot = new LinkedHashMap<>();
+            snapshot.put("itemId", product.getItemId());
+            snapshot.put("itemCode", item != null ? item.getCode() : null);
+            snapshot.put("itemName", item != null ? item.getName() : null);
+            snapshot.put("itemSpecification", item != null ? item.getSpecification() : null);
+            snapshot.put("count", product.getCount());
+            snapshot.put("itemPrice", product.getItemPrice());
+            snapshot.put("quotationPrice", product.getQuotationPrice());
+            snapshot.put("taxPercent", product.getTaxPercent());
+            snapshot.put("totalPrice", product.getTotalPrice());
+            snapshot.put("taxPrice", product.getTaxPrice());
+            snapshot.put("remark", product.getRemark());
+            return snapshot;
+        });
+        return JsonUtils.toJsonString(items);
+    }
+
+    private void cancelProcessQuietly(String processInstanceId, String reason) {
+        if (StrUtil.isBlank(processInstanceId)) {
+            return;
+        }
+        try {
+            bpmProcessInstanceApi.cancelProcessInstanceByAdmin(getLoginUserId(), processInstanceId, reason);
+        } catch (Exception ex) {
+            log.warn("[cancelProcessQuietly][鎶ヤ环鍗曟祦绋�({})鎾ら攢澶辫触锛屼笉褰卞搷浣滃簾]", processInstanceId, ex);
+        }
+    }
+
 }

--
Gitblit v1.9.3