gaoluyang
昨天 8c65f898ed6e5b0c7a94c792c4562f6ec831dccd
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java
@@ -5,9 +5,14 @@
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
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.enums.task.BpmProcessInstanceStatusEnum;
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;
@@ -38,6 +43,7 @@
import com.mzt.logapi.service.impl.DiffParseFunction;
import com.mzt.logapi.starter.annotation.LogRecord;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -57,7 +63,15 @@
 */
@Service
@Validated
@Slf4j
public class CrmReceivableServiceImpl implements CrmReceivableService {
    /**
     * BPM 回款审批流程标识
     *
     * 对应 act_re_procdef.KEY_ = crm-receivable-audit;BPM 分类「回款审批」的编码为 1111(bpm_category.id=76)
     */
    public static final String BPM_PROCESS_DEFINITION_KEY = "crm-receivable-audit";
    @Resource
    private CrmReceivableMapper receivableMapper;
@@ -86,6 +100,8 @@
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -106,9 +122,9 @@
            throw exception(RECEIVABLE_NO_EXISTS);
        }
        // 2.1 插入回款(创建后即为未审核状态)
        // 2.1 插入回款(创建后为未提交,需显式「提交审批」才进入 BPM 审批中)
        CrmReceivableDO receivable = BeanUtils.toBean(createReqVO, CrmReceivableDO.class)
                .setNo(no).setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus());
                .setNo(no).setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus());
        receivableMapper.insert(receivable);
        // 2.2 保存核销明细(未传时按关联开票自动生成一条)
        syncReceivableItems(receivable, createReqVO.getItems());
@@ -266,9 +282,10 @@
     * 校验关联开票合格:存在、审批通过、已上传发票附件、与所选合同一致
     */
    private void validateInvoiceForReceivable(CrmReceivableSaveReqVO reqVO) {
        // 未关联开票时直接跳过(允许先回款后开票)
        // 回款必须先关联已审批且已录入的开票单。
        if (reqVO.getInvoiceId() == null) {
            return;
            throw new ServiceException(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_EXISTS.getCode(),
                    "回款必须先关联开票单。请先完成开票申请、审批和发票录入,再创建回款。");
        }
        // 1. 校验开票存在
        CrmInvoiceDO invoice = invoiceService.getInvoice(reqVO.getInvoiceId());
@@ -278,6 +295,10 @@
        // 2. 校验开票审批通过
        if (ObjectUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) {
            throw exception(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_APPROVE);
        }
        if (!Objects.equals(invoice.getEntryStatus(), 10)) {
            throw new ServiceException(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_APPROVE.getCode(),
                    "关联开票单尚未录入发票。请先填写发票号码、日期并上传附件,再创建回款。");
        }
        // 3. 校验开票已上传发票附件
        if (CollUtil.isEmpty(storageAttachmentApi.listAttachments(
@@ -365,9 +386,11 @@
        // 1.5 校验可回款金额不超过该开票剩余可回款金额
        validateReceivablePriceExceedsInvoiceRemaining(updateReqVO);
        // 1.6 只有未审核,可以编辑;
        if (ObjectUtil.notEqual(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
            throw exception(RECEIVABLE_UPDATE_FAIL_EDITING_PROHIBITED);
        // 1.6 仅未提交、审核不通过可以编辑;审批中等待 BPM,审核通过需先反审核
        if (ObjectUtil.notEqual(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())
                && ObjectUtil.notEqual(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(RECEIVABLE_UPDATE_FAIL_EDITING_PROHIBITED,
                    StrUtil.blankToDefault(CrmAuditStatusEnum.getNameByStatus(oldReceivable.getAuditStatus()), "未知状态"));
        }
        // 2. 更新回款
@@ -396,30 +419,150 @@
    @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, String remark, Long userId) {
        // 1.1 校验回款存在
        CrmReceivableDO receivable = validateReceivableExists(id);
        // 1.2 仅未提交、审核不通过可以提交审批
        if (ObjectUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())
                && ObjectUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(RECEIVABLE_SUBMIT_FAIL_NOT_DRAFT,
                    StrUtil.blankToDefault(CrmAuditStatusEnum.getNameByStatus(receivable.getAuditStatus()), "未知状态"));
        }
        // 1.3 提交前重新校验开票、合同/开票剩余可回款金额,避免草稿期间额度被其他单据占用
        CrmReceivableSaveReqVO submitReqVO = new CrmReceivableSaveReqVO()
                .setId(receivable.getId()).setCustomerId(receivable.getCustomerId())
                .setContractId(receivable.getContractId()).setInvoiceId(receivable.getInvoiceId())
                .setPrice(receivable.getPrice());
        validateInvoiceForReceivable(submitReqVO);
        validateReceivablePriceExceedsLimit(submitReqVO);
        validateReceivablePriceExceedsInvoiceRemaining(submitReqVO);
        // 2. 全局免审批:业务校验通过后直接执行审批通过逻辑,无需流程模型
        if (bpmProcessInstanceApi.isGlobalAutoApprove()) {
            receivableMapper.updateById(new CrmReceivableDO().setId(id).setProcessInstanceId("")
                    .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus()));
            updateReceivableAuditStatus(id, BpmProcessInstanceStatusEnum.APPROVE.getStatus());
            LogRecordContext.putVariable("receivableNo", receivable.getNo());
            return;
        }
        // 3. 创建 BPM 回款审批流程实例
        Map<String, Object> variables = new HashMap<>();
        if (StrUtil.isNotBlank(remark)) {
            variables.put("submitRemark", remark);
        }
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId,
                new BpmProcessInstanceCreateReqDTO()
                        .setProcessDefinitionKey(BPM_PROCESS_DEFINITION_KEY)
                        .setBusinessKey(String.valueOf(id))
                        .setVariables(variables.isEmpty() ? null : variables));
        // 4. 更新工作流编号 + 审批状态
        receivableMapper.updateById(new CrmReceivableDO().setId(id)
                .setProcessInstanceId(processInstanceId)
                .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus()));
        // 5. 记录操作日志上下文
        LogRecordContext.putVariable("receivableNo", receivable.getNo());
        LogRecordContext.putVariable("submitRemark", remark);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateReceivableAuditStatus(Long id, Integer bpmResult) {
        // 1.1 校验存在:回款可能在审批期间被删除,此时静默忽略,避免影响 BPM 流程完成
        CrmReceivableDO receivable = receivableMapper.selectById(id);
        if (receivable == null) {
            log.warn("[updateReceivableAuditStatus][回款({}) 不存在,忽略 BPM 审批结果({})]", id, bpmResult);
            return;
        }
        // 1.2 只有审批中才处理:避免流程事件重复投递、或人工反审核后迟到的流程事件导致重复回写
        if (ObjectUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
            log.warn("[updateReceivableAuditStatus][回款({}) 当前状态({}) 不是审批中,忽略 BPM 审批结果({})]",
                    id, receivable.getAuditStatus(), bpmResult);
            return;
        }
        // 2. 转换 BPM 审批结果(不复用 CrmAuditStatusUtils:其 CANCEL 分支误返回 BPM 状态 4,而非 CRM 状态 40)
        Integer auditStatus;
        if (BpmProcessInstanceStatusEnum.APPROVE.getStatus().equals(bpmResult)) {
            auditStatus = CrmAuditStatusEnum.APPROVE.getStatus();
        } else if (BpmProcessInstanceStatusEnum.REJECT.getStatus().equals(bpmResult)) {
            auditStatus = CrmAuditStatusEnum.REJECT.getStatus();
        } else if (BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(bpmResult)) {
            auditStatus = CrmAuditStatusEnum.CANCEL.getStatus();
        } else {
            log.warn("[updateReceivableAuditStatus][回款({}) 的 BPM 审批结果({}) 不是终态,忽略]", id, bpmResult);
            return;
        }
        // 3. 条件更新,确保并发/重复事件只有一次生效
        int updateCount = receivableMapper.updateByIdAndStatus(id, CrmAuditStatusEnum.PROCESS.getStatus(),
                new CrmReceivableDO().setAuditStatus(auditStatus));
        if (updateCount == 0) {
            log.warn("[updateReceivableAuditStatus][回款({}) 状态已被其他操作变更,忽略 BPM 审批结果({})]", id, bpmResult);
            return;
        }
        // 4. 仅审批通过时回写回款计划;驳回、取消不回写金额
        if (ObjectUtil.equal(auditStatus, CrmAuditStatusEnum.APPROVE.getStatus())) {
            syncReceivablePlanReceipted(receivable, true);
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateReceivableStatus(Long id, Integer status) {
        boolean approve = CrmAuditStatusEnum.APPROVE.getStatus().equals(status);
        // 1.1 校验存在
        CrmReceivableDO receivable = validateReceivableExists(id);
        // 1.2 校验状态
        // 1.2 已提交 BPM 协同审批的回款,审批结果由流程监听器驱动,禁止手工审核,避免金额双重回写
        if (StrUtil.isNotBlank(receivable.getProcessInstanceId())) {
            if (approve) {
                throw exception(RECEIVABLE_MANUAL_AUDIT_FAIL_BPM_PROCESSING, receivable.getProcessInstanceId());
            }
            // 审批中不能反审核,否则会解除流程关联,导致流程完成后无法回写;审批通过后的反审核仍保留
            if (ObjectUtil.equal(receivable.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
                throw exception(RECEIVABLE_MANUAL_REVERSE_FAIL_BPM_PROCESSING, receivable.getProcessInstanceId());
            }
        }
        // 1.3 校验状态
        if (receivable.getAuditStatus().equals(status)) {
            throw exception(approve ? RECEIVABLE_APPROVE_FAIL : RECEIVABLE_PROCESS_FAIL);
        }
        // 2. 更新状态
        int updateCount = receivableMapper.updateByIdAndStatus(id, receivable.getAuditStatus(),
                new CrmReceivableDO().setAuditStatus(status));
        // 2. 更新状态;反审核时解除 BPM 关联,回归手工审核兼容流程
        CrmReceivableDO updateObj = new CrmReceivableDO().setAuditStatus(status);
        if (!approve) {
            updateObj.setProcessInstanceId("");
        }
        int updateCount = receivableMapper.updateByIdAndStatus(id, receivable.getAuditStatus(), updateObj);
        if (updateCount == 0) {
            throw exception(approve ? RECEIVABLE_APPROVE_FAIL : RECEIVABLE_PROCESS_FAIL);
        }
        // 3. 回款计划回写实际回款金额与状态
        if (receivable.getPlanId() != null) {
            if (approve) {
                receivablePlanService.updateReceivablePlanReceipted(receivable.getPlanId(),
                        receivable.getPrice(), receivable.getReturnTime());
            } else {
                receivablePlanService.resetReceivablePlanReceipted(receivable.getPlanId());
            }
        syncReceivablePlanReceipted(receivable, approve);
    }
    /**
     * 同步回款计划的已回款金额与状态
     *
     * @param receivable 回款单
     * @param approve    是否审批通过:true 回写已回款;false 重置已回款
     */
    private void syncReceivablePlanReceipted(CrmReceivableDO receivable, boolean approve) {
        if (receivable.getPlanId() == null) {
            return;
        }
        if (approve) {
            receivablePlanService.updateReceivablePlanReceipted(receivable.getPlanId(),
                    receivable.getPrice(), receivable.getReturnTime());
        } else {
            receivablePlanService.resetReceivablePlanReceipted(receivable.getPlanId());
        }
    }