云
2026-10-24 a4ed0f250009167453ca637ce042ef8e0b69f613
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java
@@ -8,12 +8,10 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivablePageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivableSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableMapper;
@@ -23,9 +21,16 @@
import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum;
import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
import cn.iocoder.yudao.module.crm.service.invoice.CrmInvoiceService;
import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService;
import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO;
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.enums.task.BpmTaskStatusEnum;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.approval.ApprovalConfigApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum;
import com.mzt.logapi.context.LogRecordContext;
import com.mzt.logapi.service.impl.DiffParseFunction;
import com.mzt.logapi.starter.annotation.LogRecord;
@@ -40,9 +45,9 @@
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*;
import static cn.iocoder.yudao.module.crm.util.CrmAuditStatusUtils.convertBpmResultToAuditStatus;
/**
 * CRM 回款 Service 实现类
@@ -54,10 +59,8 @@
@Slf4j
public class CrmReceivableServiceImpl implements CrmReceivableService {
    /**
     * BPM 合同审批流程标识
     */
    public static final String BPM_PROCESS_DEFINITION_KEY = "crm-receivable-audit";
    private static final String RECEIVABLE_PROCESS_KEY = "crm-receivable-audit";
    private static final String RECEIVABLE_APPROVE_BIZ_TYPE = "crm_receivable_approve";
    @Resource
    private CrmReceivableMapper receivableMapper;
@@ -72,28 +75,38 @@
    private CrmReceivablePlanService receivablePlanService;
    @Resource
    private CrmPermissionService permissionService;
    @Resource
    private CrmInvoiceService invoiceService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    @Resource
    private ApprovalConfigApi approvalConfigApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_CREATE_SUB_TYPE, bizNo = "{{#receivable.id}}",
            success = CRM_RECEIVABLE_CREATE_SUCCESS)
    public Long createReceivable(CrmReceivableSaveReqVO createReqVO) {
        // 1.1 校验可回款金额超过上限
        validateReceivablePriceExceedsLimit(createReqVO);
        // 1.2 校验关联数据存在
        // 1.1 校验关联数据存在
        validateRelationDataExists(createReqVO);
        // 1.3 生成回款编号
        // 1.2 校验关联开票合格(审批通过 + 已上传发票附件 + 与合同匹配)
        validateInvoiceForReceivable(createReqVO);
        // 1.3 校验可回款金额不超过合同剩余可回款金额
        validateReceivablePriceExceedsLimit(createReqVO);
        // 1.4 校验可回款金额不超过该开票剩余可回款金额(先开票后回款)
        validateReceivablePriceExceedsInvoiceRemaining(createReqVO);
        // 1.5 生成回款编号
        String no = noRedisDAO.generate(CrmNoRedisDAO.RECEIVABLE_PREFIX);
        if (receivableMapper.selectByNo(no) != null) {
            throw exception(RECEIVABLE_NO_EXISTS);
        }
        // 2.1 插入回款
        // 2.1 插入草稿;提交时再创建 BPM 流程
        CrmReceivableDO receivable = BeanUtils.toBean(createReqVO, CrmReceivableDO.class)
                .setNo(no).setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus());
        receivableMapper.insert(receivable);
@@ -118,8 +131,10 @@
    private void validateReceivablePriceExceedsLimit(CrmReceivableSaveReqVO reqVO) {
        // 1. 计算剩余可退款金额,不包括 reqVO 自身
        CrmContractDO contract = contractService.validateContract(reqVO.getContractId());
        // 统计口径:草稿 + 审批中 + 审批通过(与开票级校验、统计方法 selectReceivablePriceMapByContractId 保持一致)
        List<CrmReceivableDO> receivables = receivableMapper.selectListByContractIdAndStatus(reqVO.getContractId(),
                Arrays.asList(CrmAuditStatusEnum.APPROVE.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus()));
                Arrays.asList(CrmAuditStatusEnum.DRAFT.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus(),
                        CrmAuditStatusEnum.APPROVE.getStatus()));
        if (reqVO.getId() != null) {
            receivables.removeIf(receivable -> ObjectUtil.equal(receivable.getId(), reqVO.getId()));
        }
@@ -128,6 +143,56 @@
        // 2. 校验金额是否超过
        if (reqVO.getPrice().compareTo(notReceivablePrice) > 0) {
            throw exception(RECEIVABLE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT, notReceivablePrice);
        }
    }
    /**
     * 校验关联开票合格:存在、审批通过、已上传发票附件、与所选合同一致
     */
    private void validateInvoiceForReceivable(CrmReceivableSaveReqVO reqVO) {
        // 1. 校验开票存在
        CrmInvoiceDO invoice = invoiceService.getInvoice(reqVO.getInvoiceId());
        if (invoice == null) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_EXISTS);
        }
        // 2. 校验开票审批通过
        if (ObjectUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_APPROVE);
        }
        // 3. 校验开票已上传发票附件
        if (CollUtil.isEmpty(storageAttachmentApi.listAttachments(
                StorageRecordTypeEnum.CRM_INVOICE.getType(), invoice.getId()))) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_ATTACHMENT);
        }
        // 4. 校验开票与所选合同一致
        if (ObjectUtil.notEqual(invoice.getContractId(), reqVO.getContractId())) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_CONTRACT_MISMATCH);
        }
        // 5. 校验开票与所选合同客户一致(防御脏数据:合同-开票-回款一对多对多,开票必须属于该合同客户)
        if (reqVO.getCustomerId() != null && ObjectUtil.notEqual(invoice.getCustomerId(), reqVO.getCustomerId())) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_CONTRACT_MISMATCH);
        }
    }
    /**
     * 校验累计回款金额不超过该开票剩余可回款金额(先开票后回款)
     */
    private void validateReceivablePriceExceedsInvoiceRemaining(CrmReceivableSaveReqVO reqVO) {
        // 1. 计算该开票累计回款金额(不含本次,统计口径同合同金额校验:草稿 + 审批中 + 审批通过)
        CrmInvoiceDO invoice = invoiceService.getInvoice(reqVO.getInvoiceId());
        List<CrmReceivableDO> receivables = receivableMapper.selectListByInvoiceIdAndStatus(reqVO.getInvoiceId(),
                Arrays.asList(CrmAuditStatusEnum.DRAFT.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus(),
                        CrmAuditStatusEnum.APPROVE.getStatus()));
        if (reqVO.getId() != null) {
            receivables.removeIf(receivable -> ObjectUtil.equal(receivable.getId(), reqVO.getId()));
        }
        BigDecimal receivablePriceSum = CollectionUtils.getSumValue(receivables, CrmReceivableDO::getPrice,
                BigDecimal::add, BigDecimal.ZERO);
        // 2. 校验本次回款金额不超过该开票剩余可回款金额(开票金额 - 已回款金额)
        BigDecimal availablePrice = invoice.getPrice().subtract(receivablePriceSum);
        if (reqVO.getPrice().compareTo(availablePrice) > 0) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_PRICE_EXCEEDS, availablePrice);
        }
    }
@@ -160,17 +225,25 @@
    @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#updateReqVO.id", level = CrmPermissionLevelEnum.WRITE)
    public void updateReceivable(CrmReceivableSaveReqVO updateReqVO) {
        Assert.notNull(updateReqVO.getId(), "回款编号不能为空");
        updateReqVO.setOwnerUserId(null).setCustomerId(null).setContractId(null).setPlanId(null); // 不允许修改的字段
        updateReqVO.setOwnerUserId(null).setCustomerId(null).setContractId(null).setPlanId(null); // 不允许修改的字段(invoiceId 允许修改)
        // 1.1 校验存在
        CrmReceivableDO oldReceivable = validateReceivableExists(updateReqVO.getId());
        updateReqVO.setOwnerUserId(oldReceivable.getOwnerUserId()).setCustomerId(oldReceivable.getCustomerId())
                .setContractId(oldReceivable.getContractId()).setPlanId(oldReceivable.getPlanId()); // 设置已存在的值
        // 1.2 校验可回款金额超过上限
        // 1.2 若未传递关联开票,沿用旧值
        if (updateReqVO.getInvoiceId() == null) {
            updateReqVO.setInvoiceId(oldReceivable.getInvoiceId());
        }
        // 1.3 校验可回款金额不超过合同剩余可回款金额
        validateReceivablePriceExceedsLimit(updateReqVO);
        // 1.4 校验关联开票合格
        validateInvoiceForReceivable(updateReqVO);
        // 1.5 校验可回款金额不超过该开票剩余可回款金额
        validateReceivablePriceExceedsInvoiceRemaining(updateReqVO);
        // 1.3 只有草稿、审批中,可以编辑;
        if (!ObjectUtils.equalsAny(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus(),
                CrmAuditStatusEnum.PROCESS.getStatus())) {
        // 1.6 只有草稿或驳回后可以编辑
        if (ObjectUtil.notEqual(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())
                && ObjectUtil.notEqual(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(RECEIVABLE_UPDATE_FAIL_EDITING_PROHIBITED);
        }
@@ -194,19 +267,50 @@
    }
    @Override
    public void updateReceivableAuditStatus(Long id, Integer bpmResult) {
        // 1.1 校验存在
    @Transactional(rollbackFor = Exception.class)
    @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#id", level = CrmPermissionLevelEnum.WRITE)
    public void submitReceivable(Long id) {
        CrmReceivableDO receivable = validateReceivableExists(id);
        // 1.2 只有审批中,可以更新审批结果
        if (ObjUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
            log.error("[updateReceivableAuditStatus][receivable({}) 不处于审批中,无法更新审批结果({})]",
                    receivable.getId(), bpmResult);
            throw exception(RECEIVABLE_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS);
        if (ObjectUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())
                && ObjectUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(RECEIVABLE_SUBMIT_FAIL_NOT_DRAFT);
        }
        // 草稿期可能已产生其他回款,提交时重新校验合同及开票额度。
        CrmReceivableSaveReqVO req = new CrmReceivableSaveReqVO().setId(id)
                .setContractId(receivable.getContractId()).setInvoiceId(receivable.getInvoiceId())
                .setCustomerId(receivable.getCustomerId()).setPrice(receivable.getPrice());
        validateInvoiceForReceivable(req);
        validateReceivablePriceExceedsLimit(req);
        validateReceivablePriceExceedsInvoiceRemaining(req);
        approvalConfigApi.validateApprovalEnabledAndGetApprovers(RECEIVABLE_APPROVE_BIZ_TYPE);
        int updateCount = receivableMapper.updateByIdAndStatus(id, receivable.getAuditStatus(),
                new CrmReceivableDO().setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus()));
        if (updateCount == 0) {
            throw exception(RECEIVABLE_SUBMIT_FAIL_NOT_DRAFT);
        }
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(getLoginUserId(),
                new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(RECEIVABLE_PROCESS_KEY)
                        .setBusinessKey(String.valueOf(id)));
        receivableMapper.updateById(new CrmReceivableDO().setId(id).setProcessInstanceId(processInstanceId));
    }
        // 2. 更新回款审批状态
        Integer auditStatus = convertBpmResultToAuditStatus(bpmResult);
        receivableMapper.updateById(new CrmReceivableDO().setId(id).setAuditStatus(auditStatus));
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateReceivableAuditStatus(Long id, Integer bpmStatus) {
        Integer auditStatus;
        if (BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmStatus)) {
            auditStatus = CrmAuditStatusEnum.APPROVE.getStatus();
        } else if (BpmTaskStatusEnum.REJECT.getStatus().equals(bpmStatus)) {
            auditStatus = CrmAuditStatusEnum.REJECT.getStatus();
        } else if (BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmStatus)) {
            auditStatus = CrmAuditStatusEnum.CANCEL.getStatus();
        } else {
            return;
        }
        if (receivableMapper.updateByIdAndStatus(id, CrmAuditStatusEnum.PROCESS.getStatus(),
                new CrmReceivableDO().setAuditStatus(auditStatus)) != 1) {
            log.warn("[updateReceivableAuditStatus] 回款({}) 已不处于审批中,忽略 BPM 状态({})", id, bpmStatus);
        }
    }
    @Override
@@ -221,8 +325,9 @@
        if (receivable.getPlanId() != null && receivablePlanService.getReceivablePlan(receivable.getPlanId()) != null) {
            throw exception(RECEIVABLE_DELETE_FAIL);
        }
        // 1.3 审批通过时,不允许删除
        if (ObjUtil.equal(receivable.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) {
        // 审批中、审核通过不可删除
        if (ObjUtil.equal(receivable.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())
                || ObjUtil.equal(receivable.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
            throw exception(RECEIVABLE_DELETE_FAIL_IS_APPROVE);
        }
@@ -234,29 +339,6 @@
        // 3. 记录操作日志上下文
        LogRecordContext.putVariable("receivable", receivable);
        LogRecordContext.putVariable("period", getReceivablePeriod(receivable.getPlanId()));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_SUBMIT_SUB_TYPE, bizNo = "{{#id}}",
            success = CRM_RECEIVABLE_SUBMIT_SUCCESS)
    public void submitReceivable(Long id, Long userId) {
        // 1. 校验回款是否在审批
        CrmReceivableDO receivable = validateReceivableExists(id);
        if (ObjUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())) {
            throw exception(RECEIVABLE_SUBMIT_FAIL_NOT_DRAFT);
        }
        // 2. 创建回款审批流程实例
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO()
                .setProcessDefinitionKey(BPM_PROCESS_DEFINITION_KEY).setBusinessKey(String.valueOf(id)));
        // 3. 更新回款工作流编号
        receivableMapper.updateById(new CrmReceivableDO().setId(id).setProcessInstanceId(processInstanceId)
                .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus()));
        // 4. 记录日志
        LogRecordContext.putVariable("receivableNo", receivable.getNo());
    }
    private CrmReceivableDO validateReceivableExists(Long id) {
@@ -303,6 +385,11 @@
    }
    @Override
    public Map<Long, BigDecimal> getReceivablePriceMapByInvoiceId(Collection<Long> invoiceIds) {
        return receivableMapper.selectReceivablePriceMapByInvoiceId(invoiceIds);
    }
    @Override
    public Long getReceivableCountByContractId(Long contractId) {
        return receivableMapper.selectCountByContractId(contractId);
    }