package cn.iocoder.yudao.module.crm.service.receivable; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.ListUtil; 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; 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; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.util.*; 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.*; /** * CRM 回款 Service 实现类 * * @author 赤焰 */ @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; @Resource private CrmReceivableItemMapper receivableItemMapper; @Resource private ErpSaleOrderApi saleOrderApi; @Resource private MesWmProductSalesApi productSalesApi; @Resource private CrmNoRedisDAO noRedisDAO; @Resource private CrmContractService contractService; @Resource @Lazy // 延迟加载,避免循环依赖 private CrmReceivablePlanService receivablePlanService; @Resource private CrmPermissionService permissionService; @Resource private CrmInvoiceService invoiceService; @Resource private AdminUserApi adminUserApi; @Resource private StorageAttachmentApi storageAttachmentApi; @Resource private BpmProcessInstanceApi bpmProcessInstanceApi; @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 校验关联数据存在 validateRelationDataExists(createReqVO); // 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 插入回款(创建后为未提交,需显式「提交审批」才进入 BPM 审批中) CrmReceivableDO receivable = BeanUtils.toBean(createReqVO, CrmReceivableDO.class) .setNo(no).setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus()); receivableMapper.insert(receivable); // 2.2 保存核销明细(未传时按关联开票自动生成一条) syncReceivableItems(receivable, createReqVO.getItems()); // 3. 创建数据权限 permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_RECEIVABLE.getType()) .setBizId(receivable.getId()).setUserId(createReqVO.getOwnerUserId()) .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); // 设置当前操作的人为负责人 // 4. 更新关联的回款计划 if (createReqVO.getPlanId() != null) { receivablePlanService.updateReceivablePlanReceivableId(receivable.getPlanId(), receivable.getId()); } // 5. 记录操作日志上下文 LogRecordContext.putVariable("receivable", receivable); LogRecordContext.putVariable("period", getReceivablePeriod(receivable.getPlanId())); return receivable.getId(); } /** * 保存核销明细 * * 传 null 时:若关联开票,自动生成一条「开票」核销明细;否则不生成。 */ private void syncReceivableItems(CrmReceivableDO receivable, List 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 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 receivables = receivableMapper.selectListByContractIdAndStatus(reqVO.getContractId(), 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 notReceivablePrice = contract.getTotalPrice().subtract( CollectionUtils.getSumValue(receivables, CrmReceivableDO::getPrice, BigDecimal::add, BigDecimal.ZERO)); // 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) { throw new ServiceException(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_EXISTS.getCode(), "回款必须先关联开票单。请先完成开票申请、审批和发票录入,再创建回款。"); } // 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); } if (!Objects.equals(invoice.getEntryStatus(), 10)) { throw new ServiceException(RECEIVABLE_CREATE_FAIL_INVOICE_NOT_APPROVE.getCode(), "关联开票单尚未录入发票。请先填写发票号码、日期并上传附件,再创建回款。"); } // 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 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); } } private void validateRelationDataExists(CrmReceivableSaveReqVO reqVO) { if (reqVO.getOwnerUserId() != null) { adminUserApi.validateUser(reqVO.getOwnerUserId()); // 校验负责人存在 } if (reqVO.getContractId() != null) { CrmContractDO contract = contractService.validateContract(reqVO.getContractId()); if (ObjectUtil.notEqual(contract.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) { throw exception(RECEIVABLE_CREATE_FAIL_CONTRACT_NOT_APPROVE); } reqVO.setCustomerId(contract.getCustomerId()); // 设置客户编号 } if (reqVO.getPlanId() != null) { CrmReceivablePlanDO receivablePlan = receivablePlanService.getReceivablePlan(reqVO.getPlanId()); if (receivablePlan == null) { throw exception(RECEIVABLE_PLAN_NOT_EXISTS); } if (receivablePlan.getReceivableId() != null) { throw exception(RECEIVABLE_PLAN_EXISTS_RECEIVABLE); } } } @Override @Transactional(rollbackFor = Exception.class) @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}", success = CRM_RECEIVABLE_UPDATE_SUCCESS) @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); // 不允许修改的字段(invoiceId 允许修改) // 1.1 校验存在 CrmReceivableDO oldReceivable = validateReceivableExists(updateReqVO.getId()); updateReqVO.setOwnerUserId(oldReceivable.getOwnerUserId()).setCustomerId(oldReceivable.getCustomerId()) .setContractId(oldReceivable.getContractId()).setPlanId(oldReceivable.getPlanId()); // 设置已存在的值 // 1.2 若未传递关联开票,沿用旧值 if (updateReqVO.getInvoiceId() == null) { updateReqVO.setInvoiceId(oldReceivable.getInvoiceId()); } // 1.3 校验可回款金额不超过合同剩余可回款金额 validateReceivablePriceExceedsLimit(updateReqVO); // 1.4 校验关联开票合格 validateInvoiceForReceivable(updateReqVO); // 1.5 校验可回款金额不超过该开票剩余可回款金额 validateReceivablePriceExceedsInvoiceRemaining(updateReqVO); // 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. 更新回款 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()); // 避免操作日志出现“删除负责人”的情况 LogRecordContext.putVariable("oldReceivable", oldReceivable); LogRecordContext.putVariable("period", getReceivablePeriod(oldReceivable.getPlanId())); LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldReceivable, CrmReceivableSaveReqVO.class)); } private Integer getReceivablePeriod(Long planId) { if (Objects.isNull(planId)) { return null; } CrmReceivablePlanDO receivablePlan = receivablePlanService.getReceivablePlan(planId); return receivablePlan.getPeriod(); } @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 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 已提交 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. 更新状态;反审核时解除 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. 回款计划回写实际回款金额与状态 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()); } } @Override @Transactional(rollbackFor = Exception.class) @LogRecord(type = CRM_RECEIVABLE_TYPE, subType = CRM_RECEIVABLE_DELETE_SUB_TYPE, bizNo = "{{#id}}", success = CRM_RECEIVABLE_DELETE_SUCCESS) @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) public void deleteReceivable(Long id) { // 1.1 校验存在 CrmReceivableDO receivable = validateReceivableExists(id); // 1.2 如果被 CrmReceivablePlanDO 所使用,则不允许删除 if (receivable.getPlanId() != null && receivablePlanService.getReceivablePlan(receivable.getPlanId()) != null) { throw exception(RECEIVABLE_DELETE_FAIL); } // 1.3 审批通过时,不允许删除 if (ObjUtil.equal(receivable.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) { throw exception(RECEIVABLE_DELETE_FAIL_IS_APPROVE); } // 2.1 删除回款 receivableMapper.deleteById(id); // 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())); } private CrmReceivableDO validateReceivableExists(Long id) { CrmReceivableDO receivable = receivableMapper.selectById(id); if (receivable == null) { throw exception(RECEIVABLE_NOT_EXISTS); } return receivable; } @Override @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#id", level = CrmPermissionLevelEnum.READ) public CrmReceivableDO getReceivable(Long id) { return receivableMapper.selectById(id); } @Override public List getReceivableList(Collection ids) { if (CollUtil.isEmpty(ids)) { return ListUtil.empty(); } return receivableMapper.selectByIds(ids); } @Override public PageResult getReceivablePage(CrmReceivablePageReqVO pageReqVO, Long userId) { return receivableMapper.selectPage(pageReqVO, userId); } @Override public List getReceivableItemList(Long receivableId) { return receivableItemMapper.selectListByReceivableId(receivableId); } @Override public List getReceivableItemListByCustomerId(Long customerId) { List 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 getReceivablePageByCustomerId(CrmReceivablePageReqVO pageReqVO) { return receivableMapper.selectPageByCustomerId(pageReqVO); } @Override public Long getAuditReceivableCount(Long userId) { return receivableMapper.selectCountByAudit(userId); } @Override public Map getReceivablePriceMapByContractId(Collection contractIds) { return receivableMapper.selectReceivablePriceMapByContractId(contractIds); } @Override public Map getReceivablePriceMapByInvoiceId(Collection invoiceIds) { return receivableMapper.selectReceivablePriceMapByInvoiceId(invoiceIds); } @Override public Long getReceivableCountByContractId(Long contractId) { return receivableMapper.selectCountByContractId(contractId); } @Override public BigDecimal getReceivablePriceByContractId(Long contractId) { Map priceMap = receivableMapper.selectReceivablePriceMapByContractId( java.util.Collections.singleton(contractId)); return priceMap.getOrDefault(contractId, BigDecimal.ZERO); } @Override public BigDecimal getReceivablePriceSum() { return receivableMapper.selectReceivablePriceSum(); } @Override public BigDecimal getReceivablePriceByOwnerUserId(Long ownerUserId) { return receivableMapper.selectReceivablePriceSumByOwnerUserId(ownerUserId); } }