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 |  425 +++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 411 insertions(+), 14 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 a65a717..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,22 +1,37 @@
 package cn.iocoder.yudao.module.crm.service.quotation;
 
 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;
+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.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;
@@ -28,17 +43,22 @@
 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;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 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.*;
 
 /**
@@ -51,10 +71,23 @@
 @Slf4j
 public class CrmSaleQuotationServiceImpl implements CrmSaleQuotationService {
 
+    /**
+     * 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;
@@ -77,6 +110,13 @@
     @Resource
     private CrmContractService contractService;
 
+    @Resource
+    private BpmProcessInstanceApi bpmProcessInstanceApi;
+    @Resource
+    private BpmCategoryService bpmCategoryService;
+    @Resource
+    private BpmProcessDefinitionService bpmProcessDefinitionService;
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Long createSaleQuotation(CrmSaleQuotationSaveReqVO createReqVO, Long userId) {
@@ -93,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);
@@ -113,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());
@@ -135,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());
     }
@@ -152,6 +204,9 @@
             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. 閬嶅巻鍒犻櫎
@@ -165,6 +220,121 @@
             // 2.3 鍒犻櫎鏁版嵁鏉冮檺
             permissionService.deletePermission(CrmBizTypeEnum.CRM_SALE_QUOTATION.getType(), saleQuotation.getId());
         });
+    }
+
+    @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) {
@@ -257,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
@@ -293,12 +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())) {
-            throw exception(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT, saleQuotation.getNo());
+        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);
         }
@@ -321,7 +528,7 @@
             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);
@@ -350,4 +557,194 @@
         saleQuotationMapper.clearContractReference(quotationId);
     }
 
-}
\ No newline at end of file
+    @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