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 |  606 +++++++++++++++++++++++++++++++++++++++++--------------
 1 files changed, 451 insertions(+), 155 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 6a3f4d8..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
@@ -1,36 +1,49 @@
 package cn.iocoder.yudao.module.crm.service.quotation;
 
 import cn.hutool.core.collection.CollUtil;
-import cn.hutool.core.lang.Assert;
+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;
 import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
 import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
-import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum;
+import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum;
 import cn.iocoder.yudao.module.bpm.service.definition.BpmCategoryService;
 import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
 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;
 import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService;
+import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
 import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService;
 import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO;
+import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
 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;
@@ -39,16 +52,19 @@
 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.*;
 
 /**
  * CRM 閿�鍞姤浠峰崟 Service 瀹炵幇绫�
  *
- * @author 鑺嬮亾婧愮爜
+ * @author 瓒呯骇绠$悊鍛�
  */
 @Service
 @Validated
@@ -56,14 +72,22 @@
 public class CrmSaleQuotationServiceImpl implements CrmSaleQuotationService {
 
     /**
-     * 閿�鍞姤浠� BPM 鍒嗙被缂栫爜
+     * BPM 閿�鍞姤浠峰鎵瑰垎绫荤紪鐮�
      */
     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;
@@ -74,37 +98,42 @@
     @Resource
     private CrmCustomerService customerService;
     @Resource
+    private CrmCustomerApi customerApi;
+    @Resource
     private CrmBusinessService businessService;
     @Resource
     private CrmContactService contactService;
     @Resource
     private CrmPermissionService permissionService;
     @Resource
+    private StorageAttachmentApi storageAttachmentApi;
+    @Resource
     private CrmContractService contractService;
 
+    @Resource
+    private BpmProcessInstanceApi bpmProcessInstanceApi;
     @Resource
     private BpmCategoryService bpmCategoryService;
     @Resource
     private BpmProcessDefinitionService bpmProcessDefinitionService;
-    @Resource
-    private BpmProcessInstanceApi bpmProcessInstanceApi;
 
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Long createSaleQuotation(CrmSaleQuotationSaveReqVO createReqVO, Long userId) {
         // 1.1 鏍¢獙鐗╂枡椤圭殑鏈夋晥鎬�
         List<CrmSaleQuotationProductDO> quotationProducts = validateQuotationItems(createReqVO.getItems());
-        // 1.2 鐢熸垚鎶ヤ环鍗曞彿锛屽苟鏍¢獙鍞竴鎬�
-        String no = noRedisDAO.generate(CrmNoRedisDAO.SALE_QUOTATION_NO_PREFIX);
-        if (saleQuotationMapper.selectByNo(no) != null) {
-            throw exception(SALE_QUOTATION_NO_EXISTS);
-        }
+        // 1.2 纭畾鎶ヤ环鍗曞彿锛堝墠绔~鍐欏垯鐢ㄨ嚜瀹氫箟鍗曞彿锛岀暀绌哄垯鑷姩鐢熸垚锛夛紝骞舵牎楠屽敮涓�鎬�
+        String customNo = StrUtil.trim(createReqVO.getNo());
+        String no = StrUtil.isBlank(customNo)
+                ? noRedisDAO.generate(CrmNoRedisDAO.SALE_QUOTATION_NO_PREFIX) : customNo;
+        validateQuotationNoUnique(no, null);
         // 1.3 鏍¢獙鍏宠仈瀛楁
         validateRelationDataExists(createReqVO);
 
         // 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);
@@ -117,29 +146,50 @@
                 .setBizType(CrmBizTypeEnum.CRM_SALE_QUOTATION.getType())
                 .setBizId(saleQuotation.getId())
                 .setLevel(CrmPermissionLevelEnum.OWNER.getLevel()));
+        storageAttachmentApi.bindAttachments("file", "crm_sale_quotation",
+                saleQuotation.getId(), createReqVO.getBlobIds());
         return saleQuotation.getId();
     }
 
     @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());
         // 1.4 鏍¢獙鍏宠仈瀛楁
         validateRelationDataExists(updateReqVO);
 
-        // 2.1 鏇存柊鎶ヤ环鍗�
+        // 2.1 鏇存柊鎶ヤ环鍗曪紙鎶ヤ环鍗曞彿鐣欑┖琛ㄧず淇濇寔鍘熷�硷紝濉啓鍒欐牎楠屽敮涓�鎬у悗鏇挎崲锛�
         CrmSaleQuotationDO updateObj = BeanUtils.toBean(updateReqVO, CrmSaleQuotationDO.class);
+        String no = StrUtil.trim(updateReqVO.getNo());
+        if (StrUtil.isBlank(no)) {
+            updateObj.setNo(saleQuotation.getNo());
+        } else {
+            validateQuotationNoUnique(no, saleQuotation.getId());
+            updateObj.setNo(no);
+        }
         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());
     }
 
     @Override
@@ -154,10 +204,15 @@
             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());
+            }
         });
 
         // 2. 閬嶅巻鍒犻櫎
         saleQuotations.forEach(saleQuotation -> {
+            // 2.0 鍒犻櫎闄勪欢鍏宠仈
+            storageAttachmentApi.deleteAttachmentsByRecord("crm_sale_quotation", saleQuotation.getId());
             // 2.1 鍒犻櫎鎶ヤ环鍗�
             saleQuotationMapper.deleteById(saleQuotation.getId());
             // 2.2 鍒犻櫎鎶ヤ环鏄庣粏
@@ -167,12 +222,140 @@
         });
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void submitSaleQuotation(Long id, String processDefinitionKey, Long userId) {
+        // 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()) {
+            saleQuotationMapper.updateById(new CrmSaleQuotationDO().setId(id).setProcessInstanceId("")
+                    .setStatus(CrmSaleQuotationStatusEnum.PROCESS.getStatus()));
+            updateSaleQuotationAuditStatus(id, BpmProcessInstanceStatusEnum.APPROVE.getStatus());
+            return;
+        }
+
+        // 3. 鏍¢獙瀹℃壒鍒嗙被鍜屾祦绋嬪畾涔�
+        validateSaleQuotationApproveCategoryAndProcessDefinition();
+        ProcessDefinition processDefinition = bpmProcessDefinitionService.getActiveProcessDefinition(processDefinitionKey);
+        if (processDefinition == null) {
+            throw exception(SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
+        }
+
+        // 4. 鍒涘缓瀹℃壒娴佺▼瀹炰緥
+        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO()
+                .setProcessDefinitionKey(processDefinitionKey).setBusinessKey(String.valueOf(id)));
+
+        // 5. 鏇存柊娴佺▼瀹炰緥缂栧彿鍜岀姸鎬�
+        saleQuotationMapper.updateById(new CrmSaleQuotationDO().setId(id).setProcessInstanceId(processInstanceId)
+                .setStatus(CrmSaleQuotationStatusEnum.PROCESS.getStatus()));
+    }
+
+    @Override
+    public void updateSaleQuotationAuditStatus(Long id, Integer bpmResult) {
+        // 1.1 鏍¢獙鎶ヤ环鍗曟槸鍚﹀瓨鍦�
+        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
+        // 1.2 鍙湁瀹℃壒涓紝鎵嶅彲浠ユ洿鏂板鎵圭粨鏋�
+        if (ObjUtil.notEqual(saleQuotation.getStatus(), CrmSaleQuotationStatusEnum.PROCESS.getStatus())) {
+            log.error("[updateSaleQuotationAuditStatus][鎶ヤ环鍗�({}) 涓嶅浜庡鎵逛腑锛屾棤娉曟洿鏂板鎵圭粨鏋�({})]",
+                    saleQuotation.getNo(), bpmResult);
+            throw exception(SALE_QUOTATION_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS, saleQuotation.getNo());
+        }
+
+        // 2. 杞崲 BPM 瀹℃壒缁撴灉骞舵洿鏂�
+        Integer status;
+        if (ObjUtil.equal(bpmResult, BpmProcessInstanceStatusEnum.APPROVE.getStatus())) {
+            status = CrmSaleQuotationStatusEnum.APPROVE.getStatus();
+        } else if (ObjUtil.equal(bpmResult, BpmProcessInstanceStatusEnum.REJECT.getStatus())) {
+            status = CrmSaleQuotationStatusEnum.REJECT.getStatus();
+        } else if (ObjUtil.equal(bpmResult, BpmProcessInstanceStatusEnum.CANCEL.getStatus())) {
+            status = CrmSaleQuotationStatusEnum.CANCEL.getStatus();
+        } else {
+            log.warn("[updateSaleQuotationAuditStatus][鎶ヤ环鍗�({}) 鏀跺埌鏈煡瀹℃壒缁撴灉({})锛屽拷鐣", id, bpmResult);
+            return;
+        }
+        saleQuotationMapper.updateById(new CrmSaleQuotationDO().setId(id).setStatus(status));
+    }
+
+    @Override
+    public List<Map<String, Object>> getSaleQuotationApproveProcessDefinitionList() {
+        validateSaleQuotationApproveCategoryAndProcessDefinition();
+        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
+                .getProcessDefinitionInfoListByCategory(SALE_QUOTATION_APPROVE_CATEGORY_CODE);
+        Set<String> processDefinitionIds = convertSet(definitionInfoList, BpmProcessDefinitionInfoDO::getProcessDefinitionId);
+        List<ProcessDefinition> processDefinitions = bpmProcessDefinitionService.getProcessDefinitionList(processDefinitionIds);
+        // 鍙繚鐣欐縺娲荤姸鎬侊紝涓旂浉鍚� key 鍙繚鐣欐渶鏂扮増鏈�
+        Map<String, ProcessDefinition> latestVersionMap = new HashMap<>();
+        for (ProcessDefinition pd : processDefinitions) {
+            if (pd.isSuspended()) {
+                continue;
+            }
+            ProcessDefinition existing = latestVersionMap.get(pd.getKey());
+            if (existing == null || pd.getVersion() > existing.getVersion()) {
+                latestVersionMap.put(pd.getKey(), pd);
+            }
+        }
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (ProcessDefinition pd : latestVersionMap.values()) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", pd.getId());
+            item.put("key", pd.getKey());
+            item.put("name", pd.getName());
+            item.put("version", pd.getVersion());
+            result.add(item);
+        }
+        return result;
+    }
+
+    /**
+     * 鏍¢獙 BPM 閿�鍞姤浠峰鎵瑰垎绫诲拰娴佺▼瀹氫箟鏄惁瀛樺湪
+     */
+    private void validateSaleQuotationApproveCategoryAndProcessDefinition() {
+        List<String> codes = Collections.singletonList(SALE_QUOTATION_APPROVE_CATEGORY_CODE);
+        Map<String, ?> categoryMap = bpmCategoryService.getCategoryMap(codes);
+        if (!categoryMap.containsKey(SALE_QUOTATION_APPROVE_CATEGORY_CODE)) {
+            throw exception(SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
+        }
+        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
+                .getProcessDefinitionInfoListByCategory(SALE_QUOTATION_APPROVE_CATEGORY_CODE);
+        if (CollUtil.isEmpty(definitionInfoList)) {
+            throw exception(SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
+        }
+        Set<String> processDefinitionIds = convertSet(definitionInfoList, BpmProcessDefinitionInfoDO::getProcessDefinitionId);
+        List<ProcessDefinition> processDefinitions = bpmProcessDefinitionService.getProcessDefinitionList(processDefinitionIds);
+        boolean hasActiveDefinition = processDefinitions.stream().anyMatch(pd -> !pd.isSuspended());
+        if (!hasActiveDefinition) {
+            throw exception(SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
+        }
+    }
+
     private CrmSaleQuotationDO validateSaleQuotationExists(Long id) {
         CrmSaleQuotationDO saleQuotation = saleQuotationMapper.selectById(id);
         if (saleQuotation == null) {
             throw exception(SALE_QUOTATION_NOT_EXISTS);
         }
         return saleQuotation;
+    }
+
+    /**
+     * 鏍¢獙鎶ヤ环鍗曞彿鍞竴锛堝惈宸查�昏緫鍒犻櫎鐨勬姤浠峰崟锛屽洜 uk_no 鍞竴绱㈠紩瑕嗙洊宸插垹闄よ锛�
+     *
+     * @param no       鎶ヤ环鍗曞彿
+     * @param selfId   鑷韩缂栧彿锛堜慨鏀规椂鎺掗櫎鑷繁锛屾柊澧炰紶 null锛�
+     */
+    private void validateQuotationNoUnique(String no, Long selfId) {
+        CrmSaleQuotationDO exists = saleQuotationMapper.selectAnyByNo(no);
+        if (exists != null && !exists.getId().equals(selfId)) {
+            throw exception(SALE_QUOTATION_NO_EXISTS, no);
+        }
     }
 
     private List<CrmSaleQuotationProductDO> validateQuotationItems(List<CrmSaleQuotationSaveReqVO.Item> list) {
@@ -186,7 +369,8 @@
                 throw exception(SALE_QUOTATION_ITEM_NOT_EXISTS);
             }
             item.setItemUnitId(mdmItem.getUnitMeasureId());
-            item.setItemPrice(mdmItem.getSalesPrice());
+            // 鐗╂枡鍘熶环锛屽鏋� MDM 涓病鏈夊垯榛樿涓� 0
+            item.setItemPrice(mdmItem.getSalesPrice() != null ? mdmItem.getSalesPrice() : BigDecimal.ZERO);
             if (item.getQuotationPrice() != null && item.getCount() != null) {
                 item.setTotalPrice(MoneyUtils.priceMultiply(item.getQuotationPrice(), item.getCount()));
                 if (item.getTaxPercent() != null) {
@@ -243,22 +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) {
-        return saleQuotationMapper.selectPage(pageReqVO, userId);
+        // 鑾峰彇鐢ㄦ埛鏈夋潈闄愮殑瀹㈡埛 ID 鍒楄〃
+        List<Long> permittedCustomerIds = customerApi.getPermittedCustomerIds();
+        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
@@ -276,140 +470,42 @@
 
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void submitSaleQuotation(Long id, String processDefinitionKey, Long userId) {
-        // 1. 鏍¢獙鎶ヤ环鍗曞瓨鍦ㄤ笖涓鸿崏绋跨姸鎬�
-        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
-        if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())) {
-            throw exception(SALE_QUOTATION_SUBMIT_FAIL_NOT_DRAFT);
-        }
-
-        // 2. 鏍¢獙鍒嗙被鍜屾祦绋嬪畾涔夋槸鍚﹀瓨鍦�
-        validateSaleQuotationApproveCategoryAndProcessDefinition();
-
-        // 3. 鏍¢獙浼犲叆鐨勬祦绋嬪畾涔� Key 鏄惁灞炰簬璇ュ垎绫讳笖澶勪簬婵�娲荤姸鎬�
-        ProcessDefinition processDefinition = bpmProcessDefinitionService.getActiveProcessDefinition(processDefinitionKey);
-        if (processDefinition == null) {
-            throw exception(SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
-        }
-
-        // 4. 鍒涘缓娴佺▼瀹炰緥
-        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId,
-                new BpmProcessInstanceCreateReqDTO()
-                        .setProcessDefinitionKey(processDefinitionKey)
-                        .setBusinessKey(String.valueOf(id)));
-
-        // 5. 鏇存柊鎶ヤ环鍗曠姸鎬�
-        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
-                .setId(id)
-                .setProcessInstanceId(processInstanceId)
-                .setStatus(CrmSaleQuotationStatusEnum.PROCESS.getStatus()));
-    }
-
-    /**
-     * 鏍¢獙 BPM 閿�鍞姤浠峰鎵瑰垎绫诲拰娴佺▼瀹氫箟鏄惁瀛樺湪
-     */
-    private void validateSaleQuotationApproveCategoryAndProcessDefinition() {
-        // 1. 鏍¢獙鍒嗙被鏄惁瀛樺湪
-        List<String> codes = Collections.singletonList(SALE_QUOTATION_APPROVE_CATEGORY_CODE);
-        Map<String, ?> categoryMap = bpmCategoryService.getCategoryMap(codes);
-        if (!categoryMap.containsKey(SALE_QUOTATION_APPROVE_CATEGORY_CODE)) {
-            throw exception(SALE_QUOTATION_BPM_CATEGORY_NOT_EXISTS);
-        }
-
-        // 2. 鏍¢獙鍒嗙被涓嬫槸鍚︽湁鍙敤鐨勬祦绋嬪畾涔�
-        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
-                .getProcessDefinitionInfoListByCategory(SALE_QUOTATION_APPROVE_CATEGORY_CODE);
-        if (CollUtil.isEmpty(definitionInfoList)) {
-            throw exception(SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
-        }
-
-        // 3. 鏍¢獙鏄惁鏈夋縺娲荤姸鎬佺殑娴佺▼瀹氫箟
-        Set<String> processDefinitionIds = convertSet(definitionInfoList, BpmProcessDefinitionInfoDO::getProcessDefinitionId);
-        List<ProcessDefinition> processDefinitions = bpmProcessDefinitionService.getProcessDefinitionList(processDefinitionIds);
-        boolean hasActiveDefinition = processDefinitions.stream()
-                .anyMatch(pd -> !pd.isSuspended());
-        if (!hasActiveDefinition) {
-            throw exception(SALE_QUOTATION_BPM_PROCESS_DEFINITION_NOT_EXISTS);
-        }
-    }
-
-    @Override
-    public void updateSaleQuotationAuditStatus(Long id, Integer bpmResult) {
-        // 1. 鏍¢獙鎶ヤ环鍗曞瓨鍦ㄤ笖涓哄鎵逛腑鐘舵��
-        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
-        if (!CrmSaleQuotationStatusEnum.PROCESS.getStatus().equals(saleQuotation.getStatus())) {
-            log.error("[updateSaleQuotationAuditStatus] 鎶ヤ环鍗�({}) 涓嶅浜庡鎵逛腑", id);
-            throw exception(SALE_QUOTATION_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS);
-        }
-
-        // 2. 杞崲瀹℃壒缁撴灉
-        Integer auditStatus = convertBpmResultToAuditStatus(bpmResult);
-        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
-                .setId(id)
-                .setStatus(auditStatus));
-    }
-
-    /**
-     * BPM 瀹℃壒缁撴灉杞崲
-     */
-    private Integer convertBpmResultToAuditStatus(Integer bpmResult) {
-        Integer auditStatus = BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmResult)
-                ? CrmSaleQuotationStatusEnum.APPROVE.getStatus()
-                : BpmTaskStatusEnum.REJECT.getStatus().equals(bpmResult)
-                ? CrmSaleQuotationStatusEnum.REJECT.getStatus()
-                : BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmResult)
-                ? CrmSaleQuotationStatusEnum.CANCEL.getStatus() : null;
-        Assert.notNull(auditStatus, "BPM 瀹℃壒缁撴灉({}) 杞崲澶辫触", bpmResult);
-        return auditStatus;
-    }
-
-    @Override
-    public List<Map<String, Object>> getSaleQuotationApproveProcessDefinitionList() {
-        // 1. 鏍¢獙鍒嗙被鍜屾祦绋嬪畾涔夋槸鍚﹀瓨鍦�
-        validateSaleQuotationApproveCategoryAndProcessDefinition();
-
-        // 2. 鑾峰彇鍒嗙被涓嬬殑娴佺▼瀹氫箟淇℃伅
-        List<BpmProcessDefinitionInfoDO> definitionInfoList = bpmProcessDefinitionService
-                .getProcessDefinitionInfoListByCategory(SALE_QUOTATION_APPROVE_CATEGORY_CODE);
-
-        // 3. 鑾峰彇娴佺▼瀹氫箟璇︽儏
-        Set<String> processDefinitionIds = convertSet(definitionInfoList, BpmProcessDefinitionInfoDO::getProcessDefinitionId);
-        List<ProcessDefinition> processDefinitions = bpmProcessDefinitionService.getProcessDefinitionList(processDefinitionIds);
-
-        // 4. 杩囨护锛氬彧淇濈暀婵�娲荤姸鎬侊紝涓旂浉鍚� key 鍙繚鐣欐渶鏂扮増鏈�
-        Map<String, ProcessDefinition> latestVersionMap = new HashMap<>();
-        for (ProcessDefinition pd : processDefinitions) {
-            if (pd.isSuspended()) {
-                continue;
-            }
-            ProcessDefinition existing = latestVersionMap.get(pd.getKey());
-            if (existing == null || pd.getVersion() > existing.getVersion()) {
-                latestVersionMap.put(pd.getKey(), pd);
-            }
-        }
-
-        // 5. 杩斿洖娴佺▼瀹氫箟鍒楄〃
-        List<Map<String, Object>> result = new ArrayList<>();
-        for (ProcessDefinition pd : latestVersionMap.values()) {
-            Map<String, Object> item = new HashMap<>();
-            item.put("id", pd.getId());
-            item.put("key", pd.getKey());
-            item.put("name", pd.getName());
-            item.put("version", pd.getVersion());
-            result.add(item);
-        }
-        return result;
-    }
-
-    @Override
-    @Transactional(rollbackFor = Exception.class)
     public Long convertToContract(Long id, Long userId) {
-        // 1. 鏍¢獙鎶ヤ环鍗曞瓨鍦ㄤ笖宸插鎵归�氳繃
+        // 1. 鏍¢獙鎶ヤ环鍗曞瓨鍦紝骞跺鐞嗚繃鏈�
         CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
-        if (!CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())) {
-            throw exception(SALE_QUOTATION_NOT_APPROVE);
+        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);
         }
@@ -432,12 +528,12 @@
             product.setItemId(qp.getItemId());
             product.setItemPrice(qp.getItemPrice());
             product.setContractPrice(qp.getQuotationPrice());
-            product.setCount(qp.getCount().intValue());
+            product.setCount(qp.getCount());
             return product;
         });
         contractVO.setProducts(contractProducts);
 
-        // 4. 鍒涘缓鍚堝悓
+        // 4. 鍒涘缓鍚堝悓锛堜細鑷姩妫�娴嬪悎鍚屽鎵规祦绋嬪苟鎻愪氦瀹℃壒锛�
         Long contractId = contractService.createContract(contractVO, userId);
 
         // 5. 鏇存柊鎶ヤ环鍗曞叧鑱斾俊鎭�
@@ -451,4 +547,204 @@
         return contractId;
     }
 
-}
\ No newline at end of file
+    @Override
+    public CrmSaleQuotationDO getByContractId(Long id) {
+        return saleQuotationMapper.selectOne(new LambdaQueryWrapper<CrmSaleQuotationDO>().eq(CrmSaleQuotationDO::getContractId,id));
+    }
+
+    @Override
+    public void clearContractReference(Long quotationId) {
+        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