gaoluyang
5 天以前 d39e4f7d839167c16425086dfa2f8747bccfdff9
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java
@@ -8,29 +8,36 @@
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.CrmReceivableItemDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableItemMapper;
import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableMapper;
import cn.iocoder.yudao.module.crm.dal.redis.no.CrmNoRedisDAO;
import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum;
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.receivable.CrmReceivableBizTypeEnum;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderRespDTO;
import cn.iocoder.yudao.module.mes.api.productsales.MesWmProductSalesApi;
import cn.iocoder.yudao.module.mes.api.productsales.dto.MesWmProductSalesRespDTO;
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.system.api.storage.StorageAttachmentApi;
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;
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;
@@ -42,7 +49,6 @@
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
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 实现类
@@ -51,16 +57,17 @@
 */
@Service
@Validated
@Slf4j
public class CrmReceivableServiceImpl implements CrmReceivableService {
    /**
     * BPM 合同审批流程标识
     */
    public static final String BPM_PROCESS_DEFINITION_KEY = "crm-receivable-audit";
    @Resource
    private CrmReceivableMapper receivableMapper;
    @Resource
    private CrmReceivableItemMapper receivableItemMapper;
    @Resource
    private ErpSaleOrderApi saleOrderApi;
    @Resource
    private MesWmProductSalesApi productSalesApi;
    @Resource
    private CrmNoRedisDAO noRedisDAO;
@@ -72,32 +79,39 @@
    private CrmReceivablePlanService receivablePlanService;
    @Resource
    private CrmPermissionService permissionService;
    @Resource
    private CrmInvoiceService invoiceService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    private StorageAttachmentApi storageAttachmentApi;
    @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 插入回款(创建后即为未审核状态)
        CrmReceivableDO receivable = BeanUtils.toBean(createReqVO, CrmReceivableDO.class)
                .setNo(no).setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus());
                .setNo(no).setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus());
        receivableMapper.insert(receivable);
        // 2.2
        // 2.2 保存核销明细(未传时按关联开票自动生成一条)
        syncReceivableItems(receivable, createReqVO.getItems());
        // 3. 创建数据权限
        permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_RECEIVABLE.getType())
@@ -115,11 +129,128 @@
        return receivable.getId();
    }
    /**
     * 保存核销明细
     *
     * 传 null 时:若关联开票,自动生成一条「开票」核销明细;否则不生成。
     */
    private void syncReceivableItems(CrmReceivableDO receivable,
                                     List<CrmReceivableSaveReqVO.ReceivableItem> items) {
        if (items == null) {
            if (receivable.getInvoiceId() == null) {
                return;
            }
            CrmReceivableSaveReqVO.ReceivableItem autoItem = new CrmReceivableSaveReqVO.ReceivableItem();
            autoItem.setBizType(CrmReceivableBizTypeEnum.SALE_INVOICE.getType());
            autoItem.setBizId(receivable.getInvoiceId());
            autoItem.setReceiptPrice(receivable.getPrice());
            items = List.of(autoItem);
        }
        // 1. 校验并解析每行关联单据
        List<ResolvedReceivableItem> resolvedItems = new ArrayList<>(items.size());
        BigDecimal itemPriceSum = BigDecimal.ZERO;
        for (int i = 0; i < items.size(); i++) {
            CrmReceivableSaveReqVO.ReceivableItem item = items.get(i);
            int row = i + 1;
            CrmReceivableBizTypeEnum bizType = item == null ? null
                    : CrmReceivableBizTypeEnum.valueOf(item.getBizType());
            if (bizType == null) {
                throw exception(RECEIVABLE_ITEM_BIZ_TYPE_INVALID, row,
                        item == null ? "未填写" : item.getBizType());
            }
            if (item.getReceiptPrice() == null || item.getReceiptPrice().compareTo(BigDecimal.ZERO) <= 0) {
                throw exception(RECEIVABLE_ITEM_PRICE_INVALID, row, bizType.getName(),
                        item.getBizId(), item.getReceiptPrice() == null ? "未填写"
                                : item.getReceiptPrice().stripTrailingZeros().toPlainString());
            }
            ResolvedReceivableItem resolved = resolveReceivableItem(row, bizType, item.getBizId());
            // 1.1 已核销金额(不含本回款单,避免编辑时把自己算进去)
            BigDecimal receiptedPrice = receivableItemMapper.selectListByBiz(bizType.getType(), item.getBizId()).stream()
                    .filter(old -> ObjectUtil.notEqual(old.getReceivableId(), receivable.getId()))
                    .map(old -> old.getReceiptPrice() == null ? BigDecimal.ZERO : old.getReceiptPrice())
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
            BigDecimal remainPrice = resolved.totalPrice.subtract(receiptedPrice);
            if (item.getReceiptPrice().compareTo(remainPrice) > 0) {
                throw exception(RECEIVABLE_ITEM_PRICE_EXCEEDS, row, bizType.getName(), resolved.bizNo,
                        item.getReceiptPrice().stripTrailingZeros().toPlainString(),
                        remainPrice.stripTrailingZeros().toPlainString(),
                        resolved.totalPrice.stripTrailingZeros().toPlainString(),
                        receiptedPrice.stripTrailingZeros().toPlainString());
            }
            resolved.receiptedPrice = receiptedPrice;
            resolved.receiptPrice = item.getReceiptPrice();
            resolved.remark = item.getRemark();
            resolvedItems.add(resolved);
            itemPriceSum = itemPriceSum.add(item.getReceiptPrice());
        }
        // 2. 明细合计必须等于回款金额
        if (itemPriceSum.compareTo(receivable.getPrice()) != 0) {
            throw exception(RECEIVABLE_ITEM_SUM_MISMATCH,
                    itemPriceSum.stripTrailingZeros().toPlainString(),
                    receivable.getPrice().stripTrailingZeros().toPlainString());
        }
        // 3. 重建明细
        receivableItemMapper.deleteByReceivableId(receivable.getId());
        for (ResolvedReceivableItem resolved : resolvedItems) {
            receivableItemMapper.insert(CrmReceivableItemDO.builder()
                    .receivableId(receivable.getId()).bizType(resolved.bizType.getType())
                    .bizId(resolved.bizId).bizNo(resolved.bizNo).totalPrice(resolved.totalPrice)
                    .receiptedPrice(resolved.receiptedPrice).receiptPrice(resolved.receiptPrice)
                    .remark(resolved.remark).build());
        }
    }
    private ResolvedReceivableItem resolveReceivableItem(int row, CrmReceivableBizTypeEnum bizType, Long bizId) {
        ResolvedReceivableItem resolved = new ResolvedReceivableItem();
        resolved.bizType = bizType;
        resolved.bizId = bizId;
        switch (bizType) {
            case SALE_ORDER -> {
                ErpSaleOrderRespDTO saleOrder = bizId == null ? null : saleOrderApi.getSaleOrder(bizId).getCheckedData();
                if (saleOrder == null) {
                    throw exception(RECEIVABLE_ITEM_BIZ_NOT_EXISTS, row, bizType.getName(), bizId);
                }
                resolved.bizNo = saleOrder.getNo();
                resolved.totalPrice = saleOrder.getTotalPrice() == null ? BigDecimal.ZERO : saleOrder.getTotalPrice();
            }
            case SALE_OUT -> {
                MesWmProductSalesRespDTO saleOut = bizId == null ? null : productSalesApi.getProductSales(bizId);
                if (saleOut == null) {
                    throw exception(RECEIVABLE_ITEM_BIZ_NOT_EXISTS, row, bizType.getName(), bizId);
                }
                resolved.bizNo = saleOut.getCode();
                resolved.totalPrice = saleOut.getTotalPrice() == null ? BigDecimal.ZERO : saleOut.getTotalPrice();
            }
            case SALE_INVOICE -> {
                CrmInvoiceDO invoice = bizId == null ? null : invoiceService.getInvoice(bizId);
                if (invoice == null) {
                    throw exception(RECEIVABLE_ITEM_BIZ_NOT_EXISTS, row, bizType.getName(), bizId);
                }
                resolved.bizNo = invoice.getNo();
                resolved.totalPrice = invoice.getPrice() == null ? BigDecimal.ZERO : invoice.getPrice();
            }
            default -> throw exception(RECEIVABLE_ITEM_BIZ_TYPE_INVALID, row, bizType.getType());
        }
        return resolved;
    }
    private static class ResolvedReceivableItem {
        private CrmReceivableBizTypeEnum bizType;
        private Long bizId;
        private String bizNo;
        private BigDecimal totalPrice;
        private BigDecimal receiptedPrice;
        private BigDecimal receiptPrice;
        private String remark;
    }
    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 +259,64 @@
        // 2. 校验金额是否超过
        if (reqVO.getPrice().compareTo(notReceivablePrice) > 0) {
            throw exception(RECEIVABLE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT, notReceivablePrice);
        }
    }
    /**
     * 校验关联开票合格:存在、审批通过、已上传发票附件、与所选合同一致
     */
    private void validateInvoiceForReceivable(CrmReceivableSaveReqVO reqVO) {
        // 未关联开票时直接跳过(允许先回款后开票)
        if (reqVO.getInvoiceId() == null) {
            return;
        }
        // 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) {
        // 未关联开票时直接跳过(金额上限仍由合同级校验 validateReceivablePriceExceedsLimit 兜底)
        if (reqVO.getInvoiceId() == null) {
            return;
        }
        // 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,23 +349,35 @@
    @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.PROCESS.getStatus())) {
            throw exception(RECEIVABLE_UPDATE_FAIL_EDITING_PROHIBITED);
        }
        // 2. 更新回款
        CrmReceivableDO updateObj = BeanUtils.toBean(updateReqVO, CrmReceivableDO.class);
        receivableMapper.updateById(updateObj);
        // 2.1 更新核销明细:传了 items 以 items 为准;未传且关联开票时按开票重建;否则保持不变
        if (updateReqVO.getItems() != null
                || (updateReqVO.getInvoiceId() != null && ObjectUtil.notEqual(updateReqVO.getInvoiceId(), oldReceivable.getInvoiceId()))) {
            syncReceivableItems(updateObj, updateReqVO.getItems());
        }
        // 3. 记录操作日志上下文
        updateReqVO.setOwnerUserId(oldReceivable.getOwnerUserId()); // 避免操作日志出现“删除负责人”的情况
@@ -194,19 +395,32 @@
    }
    @Override
    public void updateReceivableAuditStatus(Long id, Integer bpmResult) {
    @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 只有审批中,可以更新审批结果
        if (ObjUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
            log.error("[updateReceivableAuditStatus][receivable({}) 不处于审批中,无法更新审批结果({})]",
                    receivable.getId(), bpmResult);
            throw exception(RECEIVABLE_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS);
        // 1.2 校验状态
        if (receivable.getAuditStatus().equals(status)) {
            throw exception(approve ? RECEIVABLE_APPROVE_FAIL : RECEIVABLE_PROCESS_FAIL);
        }
        // 2. 更新回款审批状态
        Integer auditStatus = convertBpmResultToAuditStatus(bpmResult);
        receivableMapper.updateById(new CrmReceivableDO().setId(id).setAuditStatus(auditStatus));
        // 2. 更新状态
        int updateCount = receivableMapper.updateByIdAndStatus(id, receivable.getAuditStatus(),
                new CrmReceivableDO().setAuditStatus(status));
        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());
            }
        }
    }
    @Override
@@ -228,35 +442,14 @@
        // 2.1 删除回款
        receivableMapper.deleteById(id);
        // 2.2 删除数据权限
        // 2.2 删除核销明细
        receivableItemMapper.deleteByReceivableId(id);
        // 2.3 删除数据权限
        permissionService.deletePermission(CrmBizTypeEnum.CRM_RECEIVABLE.getType(), id);
        // 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) {
@@ -287,6 +480,21 @@
    }
    @Override
    public List<CrmReceivableItemDO> getReceivableItemList(Long receivableId) {
        return receivableItemMapper.selectListByReceivableId(receivableId);
    }
    @Override
    public List<CrmReceivableItemDO> getReceivableItemListByCustomerId(Long customerId) {
        List<CrmReceivableDO> receivables = receivableMapper.selectListByCustomerId(customerId);
        if (CollUtil.isEmpty(receivables)) {
            return ListUtil.empty();
        }
        return receivableItemMapper.selectListByReceivableIds(
                receivables.stream().map(CrmReceivableDO::getId).toList());
    }
    @Override
    @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#pageReqVO.customerId", level = CrmPermissionLevelEnum.READ)
    public PageResult<CrmReceivableDO> getReceivablePageByCustomerId(CrmReceivablePageReqVO pageReqVO) {
        return receivableMapper.selectPageByCustomerId(pageReqVO);
@@ -303,6 +511,11 @@
    }
    @Override
    public Map<Long, BigDecimal> getReceivablePriceMapByInvoiceId(Collection<Long> invoiceIds) {
        return receivableMapper.selectReceivablePriceMapByInvoiceId(invoiceIds);
    }
    @Override
    public Long getReceivableCountByContractId(Long contractId) {
        return receivableMapper.selectCountByContractId(contractId);
    }