云
5 天以前 a82d847bbb39e2417d4c9773ebc4fc51fdb0b8fc
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);
    }
}
    @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);
        }
    }
}