yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java
@@ -5,12 +5,11 @@
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.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.invoice.vo.invoice.CrmInvoicePageReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
@@ -24,8 +23,10 @@
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
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.approval.ApprovalConfigApi;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import com.mzt.logapi.context.LogRecordContext;
import com.mzt.logapi.service.impl.DiffParseFunction;
import com.mzt.logapi.starter.annotation.LogRecord;
@@ -39,11 +40,12 @@
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
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 实现类
@@ -56,9 +58,11 @@
public class CrmInvoiceServiceImpl implements CrmInvoiceService {
    /**
     * BPM 开票审批流程标识
     * CRM 开票审批的业务类型编码
     *
     * 与 system_approval_config.biz_type 对应,审批人在「系统管理 - 审批配置」中维护。
     */
    public static final String BPM_PROCESS_DEFINITION_KEY = "crm-invoice-audit";
    private static final String INVOICE_APPROVE_BIZ_TYPE = "crm_invoice_approve";
    /**
     * 发票附件的业务记录类型,对应 {@link cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum#CRM_INVOICE}
@@ -79,7 +83,7 @@
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    private ApprovalConfigApi approvalConfigApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
@@ -119,10 +123,10 @@
    }
    private void validateInvoicePriceExceedsLimit(CrmInvoiceSaveReqVO reqVO) {
        // 1. 计算剩余可开票金额,不包括 reqVO 自身
        // 1. 计算剩余可开票金额,不包括 reqVO 自身(审批中也占用额度,防止累计开票超过合同金额)
        CrmContractDO contract = contractService.validateContract(reqVO.getContractId());
        List<CrmInvoiceDO> invoices = invoiceMapper.selectListByContractIdAndStatus(reqVO.getContractId(),
                List.of(CrmAuditStatusEnum.APPROVE.getStatus()));
                List.of(CrmAuditStatusEnum.APPROVE.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus()));
        if (reqVO.getId() != null) {
            invoices.removeIf(invoice -> ObjectUtil.equal(invoice.getId(), reqVO.getId()));
        }
@@ -182,22 +186,6 @@
    }
    @Override
    public void updateInvoiceAuditStatus(Long id, Integer bpmResult) {
        // 1.1 校验存在
        CrmInvoiceDO invoice = validateInvoiceExists(id);
        // 1.2 只有审批中,可以更新审批结果
        if (ObjUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
            log.error("[updateInvoiceAuditStatus][invoice({}) 不处于审批中,无法更新审批结果({})]",
                    invoice.getId(), bpmResult);
            throw exception(INVOICE_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS);
        }
        // 2. 更新开票审批状态
        Integer auditStatus = convertBpmResultToAuditStatus(bpmResult);
        invoiceMapper.updateById(new CrmInvoiceDO().setId(id).setAuditStatus(auditStatus));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_INVOICE_TYPE, subType = CRM_INVOICE_DELETE_SUB_TYPE, bizNo = "{{#id}}",
            success = CRM_INVOICE_DELETE_SUCCESS)
@@ -225,25 +213,74 @@
    @Transactional(rollbackFor = Exception.class)
    @LogRecord(type = CRM_INVOICE_TYPE, subType = CRM_INVOICE_SUBMIT_SUB_TYPE, bizNo = "{{#id}}",
            success = CRM_INVOICE_SUBMIT_SUCCESS)
    public void submitInvoice(Long id, Long userId) {
        // 1. 校验开票是否在审批
    public void submitInvoice(Long id) {
        // 1. 校验存在 + 可提交状态(未提交 / 审核不通过)
        CrmInvoiceDO invoice = validateInvoiceExists(id);
        if (ObjUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())) {
            throw exception(INVOICE_SUBMIT_FAIL_NOT_DRAFT);
        if (ObjUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())
                && ObjUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(INVOICE_SUBMIT_FAIL_NOT_EDITABLE);
        }
        // 2. 创建开票审批流程实例
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO()
                .setProcessDefinitionKey(BPM_PROCESS_DEFINITION_KEY).setBusinessKey(String.valueOf(id)));
        // 2. 重新校验可开票金额,防止累计开票超过合同金额(草稿期后可能有其他单进入审批中)
        validateInvoicePriceExceedsLimit(new CrmInvoiceSaveReqVO()
                .setId(invoice.getId()).setContractId(invoice.getContractId()).setPrice(invoice.getPrice()));
        // 3. 更新开票工作流编号
        invoiceMapper.updateById(new CrmInvoiceDO().setId(id).setProcessInstanceId(processInstanceId)
                .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus()));
        // 3. 校验审批配置已启用且配置了有效审批人(未配置时抛出带明确提示的业务异常)
        approvalConfigApi.validateApprovalEnabledAndGetApprovers(INVOICE_APPROVE_BIZ_TYPE);
        // 4. 记录日志
        // 4. 状态置为审批中,并清空上一轮审核结果,避免残留「审核不通过」的原因
        invoiceMapper.submitAndResetAuditInfo(id, CrmAuditStatusEnum.PROCESS.getStatus());
        // 5. 记录日志
        LogRecordContext.putVariable("invoiceNo", invoice.getNo());
    }
    @Override
    public Set<Long> getInvoiceApproverUserIds() {
        return approvalConfigApi.getApproverUserIds(INVOICE_APPROVE_BIZ_TYPE);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void auditInvoice(Long id, Boolean pass, String reviewRemark) {
        // 1. 校验存在
        CrmInvoiceDO invoice = validateInvoiceExists(id);
        // 2. 校验处于审批中状态
        if (ObjUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
            throw exception(INVOICE_AUDIT_FAIL_NOT_PROCESS);
        }
        // 3. 校验当前登录用户是该业务类型的审批人(或签:任一人均可审核)
        Long userId = getLoginUserId();
        approvalConfigApi.validateApprover(INVOICE_APPROVE_BIZ_TYPE, userId);
        // 4. 审核不通过:必须填写原因,状态置为审核不通过,退回提交人修改后可重新提交
        if (!Boolean.TRUE.equals(pass)) {
            if (StrUtil.isBlank(reviewRemark)) {
                throw exception(INVOICE_AUDIT_REJECT_REASON_REQUIRED);
            }
            invoiceMapper.auditInvoice(id, CrmAuditStatusEnum.REJECT.getStatus(),
                    userId, getUserNickname(userId), reviewRemark);
            return;
        }
        // 5. 审核通过:记录审核人与审核意见,状态置为审核通过
        //    (开票无额外后置业务逻辑,审批通过后即可用于创建回款 / 关联开票选择)
        invoiceMapper.auditInvoice(id, CrmAuditStatusEnum.APPROVE.getStatus(),
                userId, getUserNickname(userId), reviewRemark);
    }
    /**
     * 获取用户昵称
     *
     * @param userId 用户编号
     * @return 昵称,用户不存在时返回 null
     */
    private String getUserNickname(Long userId) {
        if (userId == null) {
            return null;
        }
        AdminUserRespDTO user = adminUserApi.getUser(userId);
        return user == null ? null : user.getNickname();
    }
    private CrmInvoiceDO validateInvoiceExists(Long id) {
        CrmInvoiceDO invoice = invoiceMapper.selectById(id);
        if (invoice == null) {