云
5 天以前 a82d847bbb39e2417d4c9773ebc4fc51fdb0b8fc
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/quotation/CrmSaleQuotationServiceImpl.java
@@ -3,7 +3,9 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.number.MoneyUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
@@ -15,15 +17,21 @@
import cn.iocoder.yudao.module.crm.controller.admin.contract.vo.contract.CrmContractSaveReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationPageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.vo.CrmSaleQuotationSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.bidbond.CrmBidBondDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationProductDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.quotation.CrmSaleQuotationRevisionDO;
import cn.iocoder.yudao.module.crm.dal.mysql.bidbond.CrmBidBondMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationProductMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.quotation.CrmSaleQuotationRevisionMapper;
import cn.iocoder.yudao.module.crm.dal.redis.no.CrmNoRedisDAO;
import cn.iocoder.yudao.module.crm.enums.bidbond.CrmBidResultEnum;
import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationStatusEnum;
import cn.iocoder.yudao.module.crm.enums.quotation.CrmSaleQuotationVoidTypeEnum;
import cn.iocoder.yudao.module.crm.service.business.CrmBusinessService;
import cn.iocoder.yudao.module.crm.service.contact.CrmContactService;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
@@ -35,6 +43,7 @@
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.flowable.engine.repository.ProcessDefinition;
@@ -43,10 +52,13 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*;
/**
@@ -64,10 +76,18 @@
     */
    private static final String SALE_QUOTATION_APPROVE_CATEGORY_CODE = "sale_quotation_approve";
    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    @Resource
    private CrmSaleQuotationMapper saleQuotationMapper;
    @Resource
    private CrmSaleQuotationProductMapper saleQuotationProductMapper;
    @Resource
    private CrmSaleQuotationRevisionMapper revisionMapper;
    @Resource
    private CrmBidBondMapper bidBondMapper;
    @Resource
    private CrmSaleQuotationExpiryHelper expiryHelper;
    @Resource
    private CrmNoRedisDAO noRedisDAO;
@@ -113,6 +133,7 @@
        // 2.1 插入报价单
        CrmSaleQuotationDO saleQuotation = BeanUtils.toBean(createReqVO, CrmSaleQuotationDO.class, q -> q
                .setNo(no)
                .setNeedBid(!Boolean.FALSE.equals(createReqVO.getNeedBid()))
                .setStatus(CrmSaleQuotationStatusEnum.DRAFT.getStatus()));
        calculateTotalPrice(saleQuotation, quotationProducts);
        saleQuotationMapper.insert(saleQuotation);
@@ -133,11 +154,18 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateSaleQuotation(CrmSaleQuotationSaveReqVO updateReqVO) {
        // 1.1 校验存在
        // 1.1 校验存在,并处理过期
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(updateReqVO.getId());
        // 1.2 校验状态,只有草稿状态才能修改
        expiryHelper.expireIfNeeded(saleQuotation);
        validateNotVoid(saleQuotation);
        // 1.2 校验状态:草稿可直接修改;绑定投标且投标未中标时,允许修改已审批的报价单重新报价
        boolean requote = false;
        if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())) {
            throw exception(SALE_QUOTATION_UPDATE_FAIL_NOT_DRAFT, saleQuotation.getNo());
            requote = canRequote(saleQuotation);
            if (!requote) {
                throw exception(SALE_QUOTATION_REQUOTE_FAIL_NOT_LOST, saleQuotation.getNo(),
                        getStatusName(saleQuotation.getStatus()));
            }
        }
        // 1.3 校验物料项的有效性
        List<CrmSaleQuotationProductDO> quotationProducts = validateQuotationItems(updateReqVO.getItems());
@@ -155,7 +183,11 @@
        }
        calculateTotalPrice(updateObj, quotationProducts);
        saleQuotationMapper.updateById(updateObj);
        // 2.2 更新报价明细
        // 2.2 重新报价:重置为草稿并清空上一轮流程实例,等待重新提交
        if (requote) {
            saleQuotationMapper.resetToDraft(saleQuotation.getId());
        }
        // 2.3 更新报价明细
        updateQuotationProductList(updateReqVO.getId(), quotationProducts);
        storageAttachmentApi.updateAttachments("file", "crm_sale_quotation", updateReqVO.getId(), updateReqVO.getBlobIds());
    }
@@ -171,6 +203,9 @@
        saleQuotations.forEach(saleQuotation -> {
            if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())) {
                throw exception(SALE_QUOTATION_DELETE_FAIL_NOT_DRAFT, saleQuotation.getNo());
            }
            if (saleQuotation.getBidBondId() != null) {
                throw exception(SALE_QUOTATION_DELETE_FAIL_BOUND, saleQuotation.getNo(), saleQuotation.getBidBondNo());
            }
        });
@@ -190,11 +225,15 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void submitSaleQuotation(Long id, String processDefinitionKey, Long userId) {
        // 1. 校验报价单存在且为草稿状态
        // 1. 校验报价单存在且为草稿状态(先处理过期,避免提交已失效的报价)
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
        expiryHelper.expireIfNeeded(saleQuotation);
        validateNotVoid(saleQuotation);
        if (ObjUtil.notEqual(saleQuotation.getStatus(), CrmSaleQuotationStatusEnum.DRAFT.getStatus())) {
            throw exception(SALE_QUOTATION_SUBMIT_FAIL_NOT_DRAFT, saleQuotation.getNo());
        }
        // 1.1 记录本轮报价流水(含报价快照与明细 JSON)
        recordRevision(saleQuotation, userId);
        // 2. 全局免审批:直接置为审批通过,无需流程模型
        if (bpmProcessInstanceApi.isGlobalAutoApprove()) {
@@ -388,24 +427,32 @@
    @Override
    public CrmSaleQuotationDO getSaleQuotation(Long id) {
        return saleQuotationMapper.selectById(id);
        CrmSaleQuotationDO quotation = saleQuotationMapper.selectById(id);
        expiryHelper.expireIfNeeded(quotation);
        return quotation;
    }
    @Override
    public PageResult<CrmSaleQuotationDO> getSaleQuotationPage(CrmSaleQuotationPageReqVO pageReqVO, Long userId) {
        // 获取用户有权限的客户 ID 列表
        List<Long> permittedCustomerIds = customerApi.getPermittedCustomerIds();
        return saleQuotationMapper.selectPage(pageReqVO, userId, permittedCustomerIds);
        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationMapper.selectPage(pageReqVO, userId, permittedCustomerIds);
        expiryHelper.expireIfNeeded(pageResult.getList());
        return pageResult;
    }
    @Override
    public PageResult<CrmSaleQuotationDO> getSaleQuotationPageByCustomerId(CrmSaleQuotationPageReqVO pageReqVO) {
        return saleQuotationMapper.selectPageByCustomerId(pageReqVO);
        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationMapper.selectPageByCustomerId(pageReqVO);
        expiryHelper.expireIfNeeded(pageResult.getList());
        return pageResult;
    }
    @Override
    public PageResult<CrmSaleQuotationDO> getSaleQuotationPageByBusinessId(CrmSaleQuotationPageReqVO pageReqVO) {
        return saleQuotationMapper.selectPageByBusinessId(pageReqVO);
        PageResult<CrmSaleQuotationDO> pageResult = saleQuotationMapper.selectPageByBusinessId(pageReqVO);
        expiryHelper.expireIfNeeded(pageResult.getList());
        return pageResult;
    }
    @Override
@@ -424,15 +471,41 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long convertToContract(Long id, Long userId) {
        // 1. 校验报价单存在,且为草稿或审批通过状态
        // 1. 校验报价单存在,并处理过期
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
        if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())
                && !CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())) {
            CrmSaleQuotationStatusEnum current = CrmSaleQuotationStatusEnum.getByStatus(saleQuotation.getStatus());
            throw exception(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT, saleQuotation.getNo(),
                    current != null ? current.getName() : String.valueOf(saleQuotation.getStatus()));
        expiryHelper.expireIfNeeded(saleQuotation);
        validateNotVoid(saleQuotation);
        // 1.1 需要投标的报价单:必须先绑定投标
        boolean needBid = !Boolean.FALSE.equals(saleQuotation.getNeedBid());
        if (needBid && saleQuotation.getBidBondId() == null) {
            throw exception(SALE_QUOTATION_CONVERT_FAIL_BID_REQUIRED, saleQuotation.getNo());
        }
        // 校验是否已转合同
        // 1.2 绑定投标的报价单:必须已中标,且是该投标当前绑定的报价单
        if (saleQuotation.getBidBondId() != null) {
            CrmBidBondDO bidBond = bidBondMapper.selectById(saleQuotation.getBidBondId());
            if (bidBond == null) {
                throw new ServiceException(BID_BOND_NOT_EXISTS.getCode(),
                        "销售报价单[" + saleQuotation.getNo() + "]关联的投标(ID=" + saleQuotation.getBidBondId()
                                + ")不存在,无法转合同;请先在投标保证金中重新关联投标,或解除报价单绑定。");
            }
            if (!CrmBidResultEnum.WON.getResult().equals(bidBond.getBidResult())) {
                throw exception(SALE_QUOTATION_CONVERT_FAIL_BID_NOT_WON, saleQuotation.getNo(),
                        bidBond.getNo(), getBidResultName(bidBond.getBidResult()));
            }
            if (!saleQuotation.getId().equals(bidBond.getQuotationId())) {
                throw exception(SALE_QUOTATION_CONVERT_FAIL_BID_QUOTATION_MISMATCH,
                        saleQuotation.getNo(), bidBond.getNo());
            }
            if (!CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())) {
                throw exception(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT, saleQuotation.getNo(),
                        getStatusName(saleQuotation.getStatus()));
            }
        } else if (!CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())
                && !CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())) {
            throw exception(SALE_QUOTATION_CONVERT_FAIL_NOT_DRAFT, saleQuotation.getNo(),
                    getStatusName(saleQuotation.getStatus()));
        }
        // 1.2 校验是否已转合同
        if (saleQuotation.getContractId() != null) {
            throw exception(SALE_QUOTATION_CONTRACT_EXISTS);
        }
@@ -484,4 +557,194 @@
        saleQuotationMapper.clearContractReference(quotationId);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void voidSaleQuotation(Long id, String reason) {
        // 1.1 校验作废原因
        String trimmedReason = StrUtil.trim(reason);
        if (StrUtil.isBlank(trimmedReason)) {
            throw exception(SALE_QUOTATION_VOID_REASON_REQUIRED);
        }
        if (trimmedReason.length() > 500) {
            throw new ServiceException(SALE_QUOTATION_VOID_REASON_REQUIRED.getCode(),
                    "作废原因不能超过 500 个字符,当前为 " + trimmedReason.length() + " 个字符,请精简后重试");
        }
        // 1.2 校验报价单存在,并处理过期(已过期则按超期作废提示,不再走手动作废)
        CrmSaleQuotationDO saleQuotation = validateSaleQuotationExists(id);
        expiryHelper.expireIfNeeded(saleQuotation);
        validateNotVoid(saleQuotation);
        // 1.3 已中标的报价单不允许作废,否则合同闭环断裂
        if (saleQuotation.getBidBondId() != null) {
            CrmBidBondDO bidBond = bidBondMapper.selectById(saleQuotation.getBidBondId());
            if (bidBond != null && CrmBidResultEnum.WON.getResult().equals(bidBond.getBidResult())) {
                throw exception(SALE_QUOTATION_VOID_FAIL_WON, saleQuotation.getNo());
            }
        }
        // 1.4 校验当前状态是否允许作废
        boolean wasProcessing = CrmSaleQuotationStatusEnum.PROCESS.getStatus().equals(saleQuotation.getStatus());
        if (!wasProcessing
                && !CrmSaleQuotationStatusEnum.DRAFT.getStatus().equals(saleQuotation.getStatus())
                && !CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())
                && !CrmSaleQuotationStatusEnum.REJECT.getStatus().equals(saleQuotation.getStatus())) {
            throw exception(SALE_QUOTATION_VOID_FAIL_STATUS, saleQuotation.getNo(),
                    getStatusName(saleQuotation.getStatus()));
        }
        // 2.1 先落库为已作废,BPM 撤销回调只处理审批中状态,不会覆盖本状态
        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
                .setId(id)
                .setStatus(CrmSaleQuotationStatusEnum.VOID.getStatus())
                .setVoidType(CrmSaleQuotationVoidTypeEnum.MANUAL.getType())
                .setVoidReason(trimmedReason)
                .setVoidTime(LocalDateTime.now())
                .setVoidBy(getLoginUserId()));
        // 2.2 审批中的报价单同步撤销流程,避免审批人继续处理已作废的报价
        if (wasProcessing) {
            cancelProcessQuietly(saleQuotation.getProcessInstanceId(), trimmedReason);
        }
    }
    @Override
    public List<CrmSaleQuotationRevisionDO> getSaleQuotationRevisionList(Long quotationId) {
        validateSaleQuotationExists(quotationId);
        return revisionMapper.selectListByQuotationId(quotationId);
    }
    @Override
    public List<CrmSaleQuotationDO> getBindableQuotationList(Long customerId, Long bidBondId) {
        if (customerId == null) {
            return Collections.emptyList();
        }
        List<CrmSaleQuotationDO> quotations = saleQuotationMapper.selectListByCustomerIdAndStatuses(customerId,
                List.of(CrmSaleQuotationStatusEnum.DRAFT.getStatus(),
                        CrmSaleQuotationStatusEnum.APPROVE.getStatus(),
                        CrmSaleQuotationStatusEnum.REJECT.getStatus()));
        // 过期报价单会动态作废,需先从候选中剔除
        expiryHelper.expireIfNeeded(quotations);
        return quotations.stream()
                .filter(q -> q.getBidBondId() == null || Objects.equals(q.getBidBondId(), bidBondId))
                .collect(java.util.stream.Collectors.toList());
    }
    /**
     * 记录本轮报价流水,供二次/三次报价追溯。
     */
    private void recordRevision(CrmSaleQuotationDO saleQuotation, Long userId) {
        int revisionNo = (saleQuotation.getRevisionNo() == null ? 0 : saleQuotation.getRevisionNo()) + 1;
        if (revisionMapper.selectByQuotationIdAndRevisionNo(saleQuotation.getId(), revisionNo) != null) {
            throw exception(SALE_QUOTATION_REVISION_EXISTS, saleQuotation.getNo(), revisionNo);
        }
        List<CrmSaleQuotationProductDO> products =
                saleQuotationProductMapper.selectListByQuotationId(saleQuotation.getId());
        revisionMapper.insert(CrmSaleQuotationRevisionDO.builder()
                .quotationId(saleQuotation.getId())
                .quotationNo(saleQuotation.getNo())
                .revisionNo(revisionNo)
                .bidBondId(saleQuotation.getBidBondId())
                .bidBondNo(saleQuotation.getBidBondNo())
                .customerId(saleQuotation.getCustomerId())
                .name(saleQuotation.getName())
                .quotationTime(saleQuotation.getQuotationTime())
                .validUntil(saleQuotation.getValidUntil())
                .totalProductPrice(saleQuotation.getTotalProductPrice())
                .discountPercent(saleQuotation.getDiscountPercent())
                .discountPrice(saleQuotation.getDiscountPrice())
                .totalPrice(saleQuotation.getTotalPrice())
                .taxRate(saleQuotation.getTaxRate())
                .taxPrice(saleQuotation.getTaxPrice())
                .itemsSnapshot(buildItemsSnapshot(products))
                .status(CrmSaleQuotationStatusEnum.PROCESS.getStatus())
                .submitUserId(userId)
                .submitTime(LocalDateTime.now())
                .build());
        // 同步报价单轮次
        saleQuotationMapper.updateById(new CrmSaleQuotationDO()
                .setId(saleQuotation.getId())
                .setRevisionNo(revisionNo));
        saleQuotation.setRevisionNo(revisionNo);
        // 同步投标记录上的当前报价轮次
        if (saleQuotation.getBidBondId() != null) {
            bidBondMapper.updateById(new CrmBidBondDO()
                    .setId(saleQuotation.getBidBondId())
                    .setQuotationRound(revisionNo));
        }
    }
    /**
     * 是否允许重新报价:绑定投标、报价已审批过、且投标结果为「未中标」。
     */
    private boolean canRequote(CrmSaleQuotationDO saleQuotation) {
        if (saleQuotation.getBidBondId() == null) {
            return false;
        }
        if (!CrmSaleQuotationStatusEnum.APPROVE.getStatus().equals(saleQuotation.getStatus())
                && !CrmSaleQuotationStatusEnum.REJECT.getStatus().equals(saleQuotation.getStatus())) {
            return false;
        }
        CrmBidBondDO bidBond = bidBondMapper.selectById(saleQuotation.getBidBondId());
        return bidBond != null && CrmBidResultEnum.LOST.getResult().equals(bidBond.getBidResult());
    }
    /**
     * 已作废的报价单不允许继续操作,按作废类型给出精确提示。
     */
    private void validateNotVoid(CrmSaleQuotationDO saleQuotation) {
        if (!CrmSaleQuotationStatusEnum.VOID.getStatus().equals(saleQuotation.getStatus())) {
            return;
        }
        if (CrmSaleQuotationVoidTypeEnum.EXPIRED.getType().equals(saleQuotation.getVoidType())) {
            String validUntil = saleQuotation.getValidUntil() != null
                    ? saleQuotation.getValidUntil().format(DATE_FORMATTER) : "未知";
            throw exception(SALE_QUOTATION_EXPIRED, saleQuotation.getNo(), validUntil);
        }
        throw exception(SALE_QUOTATION_VOIDED, saleQuotation.getNo(),
                StrUtil.blankToDefault(saleQuotation.getVoidReason(), "未填写作废原因"));
    }
    private String getStatusName(Integer status) {
        CrmSaleQuotationStatusEnum statusEnum = CrmSaleQuotationStatusEnum.getByStatus(status);
        return statusEnum != null ? statusEnum.getName() : "未知状态(" + status + ")";
    }
    private String getBidResultName(Integer result) {
        CrmBidResultEnum resultEnum = CrmBidResultEnum.valueOf(result);
        return resultEnum != null ? resultEnum.getName() : "未知结果(" + result + ")";
    }
    private String buildItemsSnapshot(List<CrmSaleQuotationProductDO> products) {
        if (CollUtil.isEmpty(products)) {
            return JsonUtils.toJsonString(Collections.emptyList());
        }
        Set<Long> itemIds = convertSet(products, CrmSaleQuotationProductDO::getItemId);
        Map<Long, MdmItemRespDTO> itemMap = mdmItemApi.getItemMap(itemIds);
        List<Map<String, Object>> items = convertList(products, product -> {
            MdmItemRespDTO item = itemMap.get(product.getItemId());
            Map<String, Object> snapshot = new LinkedHashMap<>();
            snapshot.put("itemId", product.getItemId());
            snapshot.put("itemCode", item != null ? item.getCode() : null);
            snapshot.put("itemName", item != null ? item.getName() : null);
            snapshot.put("itemSpecification", item != null ? item.getSpecification() : null);
            snapshot.put("count", product.getCount());
            snapshot.put("itemPrice", product.getItemPrice());
            snapshot.put("quotationPrice", product.getQuotationPrice());
            snapshot.put("taxPercent", product.getTaxPercent());
            snapshot.put("totalPrice", product.getTotalPrice());
            snapshot.put("taxPrice", product.getTaxPrice());
            snapshot.put("remark", product.getRemark());
            return snapshot;
        });
        return JsonUtils.toJsonString(items);
    }
    private void cancelProcessQuietly(String processInstanceId, String reason) {
        if (StrUtil.isBlank(processInstanceId)) {
            return;
        }
        try {
            bpmProcessInstanceApi.cancelProcessInstanceByAdmin(getLoginUserId(), processInstanceId, reason);
        } catch (Exception ex) {
            log.warn("[cancelProcessQuietly][报价单流程({})撤销失败,不影响作废]", processInstanceId, ex);
        }
    }
}