package cn.iocoder.yudao.module.crm.service.invoice; 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.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.framework.dict.core.DictFrameworkUtils; import cn.iocoder.yudao.framework.operatelog.core.util.OperateLogUtils; 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.CrmInvoiceEntryReqVO; import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportExcelVO; import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceImportRespVO; import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoicePageReqVO; import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaleOutRespVO; import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaveReqVO; 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.CrmReceivablePlanDO; import cn.iocoder.yudao.module.crm.dal.mysql.invoice.CrmInvoiceMapper; import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivablePlanMapper; 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.CrmPaymentStageEnum; 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.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 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.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; 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.DictTypeConstants.CRM_INVOICE_TYPE; 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 实现类 * * @author 超级管理员 */ @Service @Validated @Slf4j public class CrmInvoiceServiceImpl implements CrmInvoiceService { /** * BPM 开票审批流程标识 */ public static final String BPM_PROCESS_DEFINITION_KEY = "crm-invoice-audit"; /** * 发票附件的业务记录类型,对应 {@link cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum#CRM_INVOICE} */ private static final String INVOICE_RECORD_TYPE = "crm_invoice"; /** * ERP 销售订单审核通过状态,对应 cn.iocoder.yudao.module.erp.enums.ErpAuditStatus#APPROVE */ private static final Integer ERP_SALE_ORDER_STATUS_APPROVE = 20; /** * MES 销售出库单已完成状态,对应 cn.iocoder.yudao.module.mes.enums.wm.MesWmProductSalesStatusEnum#FINISHED */ private static final Integer MES_PRODUCT_SALES_STATUS_FINISHED = 4; /** * MES 销售出库单已取消状态 */ private static final Integer MES_PRODUCT_SALES_STATUS_CANCELED = 5; /** * 开票占用额度的审批状态:审批中 + 审批通过 */ private static final List INVOICE_OCCUPY_STATUSES = List.of( CrmAuditStatusEnum.PROCESS.getStatus(), CrmAuditStatusEnum.APPROVE.getStatus()); @Resource private CrmInvoiceMapper invoiceMapper; @Resource private CrmReceivablePlanMapper receivablePlanMapper; @Resource private ErpSaleOrderApi saleOrderApi; @Resource private MesWmProductSalesApi productSalesApi; @Resource private CrmNoRedisDAO noRedisDAO; @Resource private CrmContractService contractService; @Resource private CrmPermissionService permissionService; @Resource private AdminUserApi adminUserApi; @Resource private BpmProcessInstanceApi bpmProcessInstanceApi; @Resource private StorageAttachmentApi storageAttachmentApi; @Override @Transactional(rollbackFor = Exception.class) @LogRecord(type = CRM_INVOICE_TYPE, subType = CRM_INVOICE_CREATE_SUB_TYPE, bizNo = "{{#invoice.id}}", success = CRM_INVOICE_CREATE_SUCCESS) public Long createInvoice(CrmInvoiceSaveReqVO createReqVO) { // 1.1 校验关联数据存在(合同 -> 客户) validateRelationDataExists(createReqVO); // 1.2 校验销售订单、销售出库单,并回填出库单号 validateSaleOrderAndOut(createReqVO); // 1.3 校验可开票金额超过上限 validateInvoicePriceExceedsLimit(createReqVO); // 1.4 生成开票编号 String no = noRedisDAO.generate(CrmNoRedisDAO.INVOICE_PREFIX); if (invoiceMapper.selectByNo(no) != null) { throw exception(INVOICE_NO_EXISTS); } // 2.1 插入开票:已填写发票号码视为已录入发票 CrmInvoiceDO invoice = BeanUtils.toBean(createReqVO, CrmInvoiceDO.class) .setNo(no).setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus()); applyEntryStatus(invoice, createReqVO.getInvoiceNo(), null); invoiceMapper.insert(invoice); // 2.2 绑定发票附件 if (CollUtil.isNotEmpty(createReqVO.getBlobIds())) { storageAttachmentApi.bindAttachments("file", INVOICE_RECORD_TYPE, invoice.getId(), createReqVO.getBlobIds()); } // 2.3 同步收款节点(合同款/预付款/到货款/验收款) syncPaymentNodes(invoice, createReqVO.getPaymentNodes()); // 3. 创建数据权限 permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_INVOICE.getType()) .setBizId(invoice.getId()).setUserId(createReqVO.getOwnerUserId()) .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); // 设置当前操作的人为负责人 // 4. 记录操作日志上下文 LogRecordContext.putVariable("invoice", invoice); return invoice.getId(); } @Override @Transactional(rollbackFor = Exception.class) @LogRecord(type = CRM_INVOICE_TYPE, subType = CRM_INVOICE_IMPORT_SUB_TYPE, bizNo = "0", success = CRM_INVOICE_IMPORT_SUCCESS) public CrmInvoiceImportRespVO importInvoiceList(List importList) { CrmInvoiceImportRespVO respVO = CrmInvoiceImportRespVO.builder() .createList(new ArrayList<>()) .failureList(new LinkedHashMap<>()) .build(); if (CollUtil.isEmpty(importList)) { // 空文件也要给日志文案兜底,避免 {{#importSummary}} 取不到值 OperateLogUtils.putImportSummary(respVO.getCreateList(), null, respVO.getFailureList()); return respVO; } Map firstRowNoByKey = new HashMap<>(); for (int i = 0; i < importList.size(); i++) { CrmInvoiceImportExcelVO row = importList.get(i); if (isImportRowEmpty(row)) { continue; // 跳过空行 } String label = StrUtil.isNotBlank(row.getInvoiceNo()) ? "发票号码 " + row.getInvoiceNo().trim() : "第 " + (i + 2) + " 行"; String importKey = buildInvoiceImportKey(row); try { Integer firstRowNo = firstRowNoByKey.get(importKey); if (firstRowNo != null) { throw duplicateRowException(firstRowNo, buildInvoiceKeyDesc(row)); } respVO.getCreateList().add(createInvoiceByImport(row)); firstRowNoByKey.putIfAbsent(importKey, i + 2); } catch (Exception ex) { respVO.getFailureList().put(label, resolveImportErrorMessage(ex)); } } // 记录导入结果汇总,供 @LogRecord 的 success 文案引用(开票导入无「更新」分支,故传 null) OperateLogUtils.putImportSummary(respVO.getCreateList(), null, respVO.getFailureList()); return respVO; } private boolean isImportRowEmpty(CrmInvoiceImportExcelVO row) { return row == null || (StrUtil.isBlank(row.getNo()) && StrUtil.isBlank(row.getContractNo()) && StrUtil.isBlank(row.getInvoiceType()) && StrUtil.isBlank(row.getInvoiceNo()) && StrUtil.isBlank(row.getInvoiceTitle()) && row.getPrice() == null && StrUtil.isBlank(row.getInvoiceTime()) && StrUtil.isBlank(row.getRemark())); } /** * 依据导入数据创建一张开票。客户取合同所属客户;负责人=当前登录用户;审批状态=未提交。 */ private String createInvoiceByImport(CrmInvoiceImportExcelVO row) { // 1. 合同校验 if (StrUtil.isBlank(row.getContractNo())) { throw new IllegalArgumentException("合同编号不能为空:请填写模板中该行的「合同编号」列"); } CrmContractDO contract = contractService.getContractByNo(row.getContractNo().trim()); if (contract == null) { throw new IllegalArgumentException("合同编号不存在:" + row.getContractNo().trim() + "。请先在「CRM 合同管理」中创建或导入该合同(编号需与模板完全一致),或修改模板中的合同编号后重新导入"); } if (ObjUtil.notEqual(contract.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) { throw new IllegalArgumentException("合同[" + contract.getNo() + "]当前状态为「" + CrmAuditStatusEnum.getNameByStatus(contract.getAuditStatus()) + "」,只有「审核通过」的合同才能开票。" + "请先在「CRM 合同管理」中完成该合同审批,或改用其他已审核通过的合同编号后重新导入"); } // 2. 开票金额校验 if (row.getPrice() == null) { throw new IllegalArgumentException("开票金额不能为空:请填写模板中该行的「开票金额(元)」列"); } if (row.getPrice().compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("开票金额必须大于 0,当前为 " + row.getPrice().stripTrailingZeros().toPlainString() + "。请修改模板中的「开票金额(元)」后重新导入"); } BigDecimal remainingInvoicePrice = getRemainingInvoicePrice(contract, null); if (row.getPrice().compareTo(remainingInvoicePrice) > 0) { throw new IllegalArgumentException("开票金额超过合同剩余可开票金额:合同[" + contract.getNo() + "]剩余可开票金额 " + remainingInvoicePrice.stripTrailingZeros().toPlainString() + " 元,本次导入金额 " + row.getPrice().stripTrailingZeros().toPlainString() + " 元。请调整模板中的「开票金额(元)」,或先在系统中核对已开票金额后重新导入"); } // 3. 发票类型校验 Integer invoiceType = resolveInvoiceType(row.getInvoiceType()); // 4. 开票编号:填写则使用,留空则自动生成 String no = StrUtil.isNotBlank(row.getNo()) ? row.getNo().trim() : noRedisDAO.generate(CrmNoRedisDAO.INVOICE_PREFIX); if (invoiceMapper.selectByNo(no) != null) { throw new IllegalArgumentException("开票编号已存在:" + no + "。请修改模板中的开票编号,或先在系统中删除该编号的开票后重新导入"); } // 5. 去重:同一合同下,发票号码(留空时按开票金额、发票抬头、开票日期)相同的开票不可重复导入 // 开票日期留空时视作不比较该维度,故按“是否填写”传 null,不能用 parseImportDateTime 的 now() 兜底值 LocalDateTime invoiceTime = parseImportDateTime(row.getInvoiceTime()); CrmInvoiceDO duplicate = findDuplicateInvoice(contract.getId(), row, StrUtil.isNotBlank(row.getInvoiceTime()) ? invoiceTime : null); if (duplicate != null) { throw duplicateExistingException(buildInvoiceKeyDesc(row), duplicate.getNo()); } // 6. 保存开票 CrmInvoiceDO invoice = new CrmInvoiceDO().setNo(no).setCustomerId(contract.getCustomerId()) .setContractId(contract.getId()).setOwnerUserId(getLoginUserId()).setInvoiceType(invoiceType) .setInvoiceNo(row.getInvoiceNo()).setInvoiceTitle(row.getInvoiceTitle()).setPrice(row.getPrice()) .setInvoiceTime(invoiceTime).setRemark(row.getRemark()) .setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus()); invoiceMapper.insert(invoice); // 7. 创建数据权限(否则当前用户看不到该开票) permissionService.createPermission(new CrmPermissionCreateReqBO().setBizType(CrmBizTypeEnum.CRM_INVOICE.getType()) .setBizId(invoice.getId()).setUserId(invoice.getOwnerUserId()) .setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); return invoice.getNo(); } private Integer resolveInvoiceType(String text) { List labelList = DictFrameworkUtils.getDictDataLabelList(CRM_INVOICE_TYPE); if (StrUtil.isBlank(text)) { throw new IllegalArgumentException("发票类型不能为空:请填写模板中该行的「发票类型」列,可选值:" + String.join("、", labelList)); } String value = text.trim(); if (value.matches("\\d+") && DictFrameworkUtils.getDictDataValueList(CRM_INVOICE_TYPE).contains(value)) { return Integer.valueOf(value); } String parsedValue = DictFrameworkUtils.parseDictDataValue(CRM_INVOICE_TYPE, value); if (parsedValue == null) { throw new IllegalArgumentException("发票类型不存在:" + value + "。模板中「发票类型」请填写以下之一:" + String.join("、", labelList)); } return Integer.valueOf(parsedValue); } private LocalDateTime parseImportDateTime(String text) { if (StrUtil.isBlank(text)) { return LocalDateTime.now(); } String value = text.trim(); for (String pattern : new String[]{"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy/MM/dd HH:mm:ss"}) { try { return LocalDateTime.parse(value, DateTimeFormatter.ofPattern(pattern)); } catch (Exception ignored) { // 继续尝试下一个格式 } } for (String pattern : new String[]{"yyyy-MM-dd", "yyyy/M/d"}) { try { return java.time.LocalDate.parse(value, DateTimeFormatter.ofPattern(pattern)).atStartOfDay(); } catch (Exception ignored) { // 继续尝试下一个格式 } } throw new IllegalArgumentException("开票日期格式不正确:" + text + ",示例 2026-01-01 10:00:00"); } private String resolveImportErrorMessage(Exception ex) { if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException serviceException) { return serviceException.getMessage(); } return StrUtil.blankToDefault(ex.getMessage(), "导入失败"); } /** * 导入去重键:发票号码填写时以 合同 + 发票号码 为准;留空时比较 合同 + 开票金额 + 发票抬头 + 开票日期 */ private String buildInvoiceImportKey(CrmInvoiceImportExcelVO row) { if (StrUtil.isNotBlank(row.getInvoiceNo())) { return "no|" + StrUtil.trimToEmpty(row.getContractNo()) + "|" + row.getInvoiceNo().trim(); } return "amt|" + StrUtil.trimToEmpty(row.getContractNo()) + "|" + (row.getPrice() == null ? "" : row.getPrice().stripTrailingZeros().toPlainString()) + "|" + StrUtil.trimToEmpty(row.getInvoiceTitle()) + "|" + StrUtil.trimToEmpty(row.getInvoiceTime()); } private String buildInvoiceKeyDesc(CrmInvoiceImportExcelVO row) { if (StrUtil.isNotBlank(row.getInvoiceNo())) { return "合同编号[" + StrUtil.trimToEmpty(row.getContractNo()) + "]、发票号码[" + row.getInvoiceNo().trim() + "]"; } return "合同编号[" + StrUtil.trimToEmpty(row.getContractNo()) + "]、开票金额[" + (row.getPrice() == null ? "未填写" : row.getPrice().stripTrailingZeros().toPlainString() + " 元") + "]、发票抬头[" + StrUtil.blankToDefault(StrUtil.trimToNull(row.getInvoiceTitle()), "未填写") + "]" + (StrUtil.isBlank(row.getInvoiceTime()) ? "" : "、开票日期[" + row.getInvoiceTime().trim() + "]"); } /** * 查询同合同下业务数据完全相同的开票 */ private CrmInvoiceDO findDuplicateInvoice(Long contractId, CrmInvoiceImportExcelVO row, LocalDateTime invoiceTime) { for (CrmInvoiceDO old : invoiceMapper.selectListByContractId(contractId)) { if (StrUtil.isNotBlank(row.getInvoiceNo())) { if (StrUtil.equals(StrUtil.trimToEmpty(old.getInvoiceNo()), row.getInvoiceNo().trim())) { return old; } continue; } // 发票号码留空时,比较开票金额、发票抬头;开票日期留空则不比较该维度 if (old.getPrice().compareTo(row.getPrice()) != 0 || !StrUtil.equals(StrUtil.trimToEmpty(old.getInvoiceTitle()), StrUtil.trimToEmpty(row.getInvoiceTitle()))) { continue; } if (invoiceTime != null && !invoiceTime.equals(old.getInvoiceTime())) { continue; } return old; } return null; } private IllegalArgumentException duplicateRowException(int firstRowNo, String keyDesc) { return new IllegalArgumentException("检测到重复导入:本次导入的第 " + firstRowNo + " 行与当前行的数据完全相同(" + keyDesc + ")。请删除模板中重复的行后重新导入"); } private IllegalArgumentException duplicateExistingException(String keyDesc, String existingNo) { return new IllegalArgumentException("检测到重复导入:" + keyDesc + " 与已有开票单「" + existingNo + "」完全相同(可能是本次导入前面的行,也可能是系统中已有单据)。请勿重复导入;如确需重新导入,请先在「营销管理-开票管理」中删除该开票单后重试"); } private void validateInvoicePriceExceedsLimit(CrmInvoiceSaveReqVO reqVO) { // 1. 计算剩余可开票金额,不包括 reqVO 自身(审批中也占用额度,防止累计开票超过合同金额) CrmContractDO contract = contractService.validateContract(reqVO.getContractId()); BigDecimal notInvoicePrice = getRemainingInvoicePrice(contract, reqVO.getId()); // 2. 校验金额是否超过 if (reqVO.getPrice().compareTo(notInvoicePrice) > 0) { throw exception(INVOICE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT, notInvoicePrice); } } /** * 计算合同剩余可开票金额(审批中、审核通过的开票占用额度) * * @param contract 合同 * @param excludeInvoiceId 需排除的开票编号,可为空 */ private BigDecimal getRemainingInvoicePrice(CrmContractDO contract, Long excludeInvoiceId) { List invoices = invoiceMapper.selectListByContractIdAndStatus(contract.getId(), List.of(CrmAuditStatusEnum.APPROVE.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus())); if (excludeInvoiceId != null) { invoices.removeIf(invoice -> ObjectUtil.equal(invoice.getId(), excludeInvoiceId)); } return contract.getTotalPrice().subtract( CollectionUtils.getSumValue(invoices, CrmInvoiceDO::getPrice, BigDecimal::add, BigDecimal.ZERO)); } private void validateRelationDataExists(CrmInvoiceSaveReqVO 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(INVOICE_CREATE_FAIL_CONTRACT_NOT_APPROVE); } reqVO.setCustomerId(contract.getCustomerId()); // 设置客户编号 } } /** * 校验销售订单、销售出库单,并回填出库单号 * * 销售订单、销售出库单均为选填:填写了才按对应规则校验,未填写直接跳过(与前端一致)。 * 校验顺序:销售订单存在 -> 已审核 -> 客户/合同一致 -> 订单剩余可开票; * 再校验出库单存在 -> 已完成 -> 属于该订单 -> 客户一致 -> 出库单剩余可开票。 */ private void validateSaleOrderAndOut(CrmInvoiceSaveReqVO reqVO) { // 1. 销售订单(选填) ErpSaleOrderRespDTO saleOrder = null; if (reqVO.getSaleOrderId() != null) { saleOrder = saleOrderApi.getSaleOrder(reqVO.getSaleOrderId()).getCheckedData(); if (saleOrder == null) { throw exception(INVOICE_CREATE_FAIL_SALE_ORDER_NOT_EXISTS, reqVO.getSaleOrderId()); } if (ObjectUtil.notEqual(saleOrder.getStatus(), ERP_SALE_ORDER_STATUS_APPROVE)) { throw exception(INVOICE_CREATE_FAIL_SALE_ORDER_NOT_APPROVE, saleOrder.getNo(), erpAuditStatusName(saleOrder.getStatus())); } if (ObjectUtil.notEqual(saleOrder.getCustomerId(), reqVO.getCustomerId()) || (saleOrder.getContractId() != null && ObjectUtil.notEqual(saleOrder.getContractId(), reqVO.getContractId()))) { throw exception(INVOICE_CREATE_FAIL_SALE_ORDER_MISMATCH, saleOrder.getNo(), saleOrder.getCustomerId(), saleOrder.getContractId(), reqVO.getCustomerId(), reqVO.getContractId()); } // 1.1 订单剩余可开票金额 = 订单金额 - 该订单已占用开票金额(审批中 + 审批通过) BigDecimal orderTotalPrice = zeroIfNull(saleOrder.getTotalPrice()); BigDecimal orderInvoicedPrice = sumInvoicePrice(invoiceMapper.selectListBySaleOrderIdAndStatus( reqVO.getSaleOrderId(), INVOICE_OCCUPY_STATUSES), reqVO.getId()); BigDecimal orderRemainPrice = orderTotalPrice.subtract(orderInvoicedPrice); if (reqVO.getPrice() != null && reqVO.getPrice().compareTo(orderRemainPrice) > 0) { throw exception(INVOICE_CREATE_FAIL_PRICE_EXCEEDS_SALE_ORDER, saleOrder.getNo(), formatPrice(orderRemainPrice), formatPrice(orderTotalPrice), formatPrice(orderInvoicedPrice)); } } // 2. 销售出库单(选填) if (reqVO.getSaleOutId() == null) { return; } MesWmProductSalesRespDTO saleOut = productSalesApi.getProductSales(reqVO.getSaleOutId()); if (saleOut == null) { throw exception(INVOICE_CREATE_FAIL_SALE_OUT_NOT_EXISTS, reqVO.getSaleOutId()); } if (ObjectUtil.notEqual(saleOut.getStatus(), MES_PRODUCT_SALES_STATUS_FINISHED)) { throw exception(INVOICE_CREATE_FAIL_SALE_OUT_NOT_FINISHED, saleOut.getCode(), productSalesStatusName(saleOut.getStatus())); } if (saleOrder != null && saleOut.getSaleOrderId() != null && ObjectUtil.notEqual(saleOut.getSaleOrderId(), reqVO.getSaleOrderId())) { throw exception(INVOICE_CREATE_FAIL_SALE_OUT_MISMATCH_ORDER, saleOut.getCode(), saleOrder.getNo(), saleOut.getSaleOrderId()); } if (saleOut.getClientId() != null && ObjectUtil.notEqual(saleOut.getClientId(), reqVO.getCustomerId())) { throw exception(INVOICE_CREATE_FAIL_SALE_OUT_MISMATCH_CUSTOMER, saleOut.getCode(), saleOut.getClientId(), reqVO.getCustomerId()); } // 2.1 出库单剩余可开票金额 = 出库金额 - 该出库单已占用开票金额(审批中 + 审批通过) BigDecimal saleOutTotalPrice = zeroIfNull(saleOut.getTotalPrice()); BigDecimal saleOutInvoicedPrice = sumInvoicePrice(invoiceMapper.selectListBySaleOutIdAndStatus( reqVO.getSaleOutId(), INVOICE_OCCUPY_STATUSES), reqVO.getId()); BigDecimal saleOutRemainPrice = saleOutTotalPrice.subtract(saleOutInvoicedPrice); if (reqVO.getPrice() != null && reqVO.getPrice().compareTo(saleOutRemainPrice) > 0) { throw exception(INVOICE_CREATE_FAIL_PRICE_EXCEEDS_SALE_OUT, saleOut.getCode(), formatPrice(saleOutRemainPrice), formatPrice(saleOutTotalPrice), formatPrice(saleOutInvoicedPrice)); } reqVO.setSaleOutCode(saleOut.getCode()); } private BigDecimal sumInvoicePrice(List invoices, Long excludeInvoiceId) { if (CollUtil.isEmpty(invoices)) { return BigDecimal.ZERO; } return invoices.stream() .filter(invoice -> excludeInvoiceId == null || ObjectUtil.notEqual(invoice.getId(), excludeInvoiceId)) .map(invoice -> zeroIfNull(invoice.getPrice())) .reduce(BigDecimal.ZERO, BigDecimal::add); } /** * 根据发票号码是否填写,设置发票录入状态 */ private void applyEntryStatus(CrmInvoiceDO invoice, String invoiceNo, CrmInvoiceDO oldInvoice) { if (StrUtil.isBlank(invoiceNo)) { invoice.setEntryStatus(0); return; } invoice.setEntryStatus(10); boolean changed = oldInvoice == null || !StrUtil.equals(invoiceNo, oldInvoice.getInvoiceNo()) || !Integer.valueOf(10).equals(oldInvoice.getEntryStatus()); if (changed) { invoice.setEntryUserId(getLoginUserId()).setEntryTime(LocalDateTime.now()); } } /** * 同步开票单的收款节点到回款计划(crm_receivable_plan) * * 传 null 表示本次不修改节点;传空列表表示清空节点。 */ private void syncPaymentNodes(CrmInvoiceDO invoice, List nodes) { if (nodes == null) { return; } validatePaymentNodes(nodes); // 已产生回款的节点不允许被覆盖 for (CrmReceivablePlanDO oldPlan : receivablePlanMapper.selectListByInvoiceId(invoice.getId())) { if (oldPlan.getReceivableId() != null || zeroIfNull(oldPlan.getReceiptedPrice()).compareTo(BigDecimal.ZERO) > 0) { throw exception(INVOICE_PAYMENT_NODE_ALREADY_RECEIPTED, CrmPaymentStageEnum.getNameByStage(oldPlan.getStage()), formatPrice(zeroIfNull(oldPlan.getReceiptedPrice()))); } } deletePaymentNodesByInvoiceId(invoice.getId()); Long ownerUserId = invoice.getOwnerUserId() != null ? invoice.getOwnerUserId() : getLoginUserId(); for (CrmInvoiceSaveReqVO.PaymentNode node : nodes) { CrmReceivablePlanDO maxPeriodPlan = receivablePlanMapper.selectMaxPeriodByContractId(invoice.getContractId()); int period = maxPeriodPlan == null ? 1 : maxPeriodPlan.getPeriod() + 1; int remindDays = 3; CrmReceivablePlanDO plan = CrmReceivablePlanDO.builder() .period(period).customerId(invoice.getCustomerId()).contractId(invoice.getContractId()) .ownerUserId(ownerUserId).returnTime(node.getReturnTime()) .remindDays(remindDays).remindTime(node.getReturnTime().minusDays(remindDays)) .price(node.getAmount()).stage(node.getStage()).invoiceId(invoice.getId()) .saleOrderId(invoice.getSaleOrderId()) .receiptedPrice(BigDecimal.ZERO).status(0).remark(node.getRemark()) .build(); receivablePlanMapper.insert(plan); permissionService.createPermission(new CrmPermissionCreateReqBO() .setBizType(CrmBizTypeEnum.CRM_RECEIVABLE_PLAN.getType()).setBizId(plan.getId()) .setUserId(ownerUserId).setLevel(CrmPermissionLevelEnum.OWNER.getLevel())); } } private void validatePaymentNodes(List nodes) { if (CollUtil.isEmpty(nodes)) { return; } Set stages = new HashSet<>(); for (int i = 0; i < nodes.size(); i++) { CrmInvoiceSaveReqVO.PaymentNode node = nodes.get(i); int row = i + 1; CrmPaymentStageEnum stageEnum = node == null ? null : CrmPaymentStageEnum.valueOf(node.getStage()); if (stageEnum == null) { throw exception(INVOICE_PAYMENT_NODE_STAGE_INVALID, row, node == null ? "未填写" : node.getStage()); } if (!stages.add(stageEnum.getStage())) { throw exception(INVOICE_PAYMENT_NODE_STAGE_DUPLICATE, stageEnum.getName()); } if (node.getAmount() == null || node.getAmount().compareTo(BigDecimal.ZERO) <= 0) { throw exception(INVOICE_PAYMENT_NODE_INVALID, row, stageEnum.getName(), node.getAmount() == null ? "未填写" : formatPrice(node.getAmount())); } if (node.getReturnTime() == null) { throw exception(INVOICE_PAYMENT_NODE_TIME_REQUIRED, stageEnum.getName()); } } } private void deletePaymentNodesByInvoiceId(Long invoiceId) { for (CrmReceivablePlanDO plan : receivablePlanMapper.selectListByInvoiceId(invoiceId)) { receivablePlanMapper.deleteById(plan.getId()); permissionService.deletePermission(CrmBizTypeEnum.CRM_RECEIVABLE_PLAN.getType(), plan.getId()); } } private String erpAuditStatusName(Integer status) { if (Objects.equals(status, ERP_SALE_ORDER_STATUS_APPROVE)) { return "已审核"; } if (status == null) { return "未知状态"; } return switch (status) { case 0 -> "未提交"; case 10 -> "未审核"; case 15 -> "审批中"; case 30 -> "已作废"; case 35 -> "已驳回"; default -> "状态值 " + status; }; } private String productSalesStatusName(Integer status) { if (status == null) { return "未知状态"; } return switch (status) { case 0 -> "草稿"; case 1 -> "待检测"; case 2 -> "待拣货"; case 3 -> "待执行出库"; case 4 -> "已完成"; case 5 -> "已取消"; case 10 -> "待填写运单"; default -> "状态值 " + status; }; } private static BigDecimal zeroIfNull(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private static String formatPrice(BigDecimal value) { return (value == null ? BigDecimal.ZERO : value).stripTrailingZeros().toPlainString(); } @Override @Transactional(rollbackFor = Exception.class) @LogRecord(type = CRM_INVOICE_TYPE, subType = CRM_INVOICE_UPDATE_SUB_TYPE, bizNo = "{{#updateReqVO.id}}", success = CRM_INVOICE_UPDATE_SUCCESS) @CrmPermission(bizType = CrmBizTypeEnum.CRM_INVOICE, bizId = "#updateReqVO.id", level = CrmPermissionLevelEnum.WRITE) public void updateInvoice(CrmInvoiceSaveReqVO updateReqVO) { Assert.notNull(updateReqVO.getId(), "开票编号不能为空"); updateReqVO.setOwnerUserId(null).setCustomerId(null).setContractId(null); // 不允许修改的字段 // 1.1 校验存在 CrmInvoiceDO oldInvoice = validateInvoiceExists(updateReqVO.getId()); updateReqVO.setOwnerUserId(oldInvoice.getOwnerUserId()).setCustomerId(oldInvoice.getCustomerId()) .setContractId(oldInvoice.getContractId()); // 设置已存在的值 // 1.2 免审批时不限制编辑状态;正常审批仅草稿、审批中可以编辑。 if (!bpmProcessInstanceApi.isGlobalAutoApprove() && !ObjectUtils.equalsAny(oldInvoice.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) { throw exception(INVOICE_UPDATE_FAIL_EDITING_PROHIBITED, StrUtil.blankToDefault(CrmAuditStatusEnum.getNameByStatus(oldInvoice.getAuditStatus()), "未知状态")); } // 1.3 校验销售订单、销售出库单,并回填出库单号 validateSaleOrderAndOut(updateReqVO); // 1.4 校验可开票金额超过上限 validateInvoicePriceExceedsLimit(updateReqVO); // 2. 更新开票 CrmInvoiceDO updateObj = BeanUtils.toBean(updateReqVO, CrmInvoiceDO.class); applyEntryStatus(updateObj, updateReqVO.getInvoiceNo(), oldInvoice); invoiceMapper.updateById(updateObj); // 发票号码被清空时,一并清空录入人、录入时间(updateById 会忽略 null 字段,需显式清空) if (StrUtil.isBlank(updateReqVO.getInvoiceNo())) { invoiceMapper.clearEntryInfo(updateReqVO.getId()); } // 3. 增量更新发票附件 storageAttachmentApi.updateAttachments("file", INVOICE_RECORD_TYPE, updateReqVO.getId(), updateReqVO.getBlobIds()); // 3.1 同步收款节点(仅前端传了 paymentNodes 才覆盖,避免不传时误删) syncPaymentNodes(oldInvoice, updateReqVO.getPaymentNodes()); // 4. 记录操作日志上下文 updateReqVO.setOwnerUserId(oldInvoice.getOwnerUserId()); // 避免操作日志出现“删除负责人”的情况 LogRecordContext.putVariable("invoice", oldInvoice); LogRecordContext.putVariable(DiffParseFunction.OLD_OBJECT, BeanUtils.toBean(oldInvoice, CrmInvoiceSaveReqVO.class)); } @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) @CrmPermission(bizType = CrmBizTypeEnum.CRM_INVOICE, bizId = "#id", level = CrmPermissionLevelEnum.OWNER) public void deleteInvoice(Long id) { // 1.1 校验存在 CrmInvoiceDO invoice = validateInvoiceExists(id); // 1.2 审批通过时,不允许删除 if (ObjUtil.equal(invoice.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) { throw exception(INVOICE_DELETE_FAIL_IS_APPROVE); } // 2.1 删除发票附件 storageAttachmentApi.deleteAttachmentsByRecord(INVOICE_RECORD_TYPE, id); // 2.2 删除开票 invoiceMapper.deleteById(id); // 2.3 删除数据权限 permissionService.deletePermission(CrmBizTypeEnum.CRM_INVOICE.getType(), id); // 2.4 删除开票生成的收款节点(审批通过的开票不允许删除,故此处节点必然未产生回款) deletePaymentNodesByInvoiceId(id); // 3. 记录操作日志上下文 LogRecordContext.putVariable("invoice", invoice); } @Override @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. 校验开票是否在审批 CrmInvoiceDO invoice = validateInvoiceExists(id); if (ObjUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())) { throw exception(INVOICE_SUBMIT_FAIL_NOT_DRAFT); } // 1.1 重新校验可开票金额,防止累计开票超过合同金额(草稿期后可能有其他单进入审批中) validateInvoicePriceExceedsLimit(new CrmInvoiceSaveReqVO() .setId(invoice.getId()).setContractId(invoice.getContractId()).setPrice(invoice.getPrice())); // 2. 创建开票审批流程实例 // 全局免审批:业务校验通过后直接执行审批通过逻辑,无需流程模型。 if (bpmProcessInstanceApi.isGlobalAutoApprove()) { invoiceMapper.updateById(new CrmInvoiceDO().setId(id).setProcessInstanceId("") .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus())); updateInvoiceAuditStatus(id, cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum.APPROVE.getStatus()); LogRecordContext.putVariable("invoiceNo", invoice.getNo()); return; } String processInstanceId = bpmProcessInstanceApi.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO() .setProcessDefinitionKey(BPM_PROCESS_DEFINITION_KEY).setBusinessKey(String.valueOf(id))); // 3. 更新开票工作流编号 invoiceMapper.updateById(new CrmInvoiceDO().setId(id).setProcessInstanceId(processInstanceId) .setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus())); // 4. 记录日志 LogRecordContext.putVariable("invoiceNo", invoice.getNo()); } private CrmInvoiceDO validateInvoiceExists(Long id) { CrmInvoiceDO invoice = invoiceMapper.selectById(id); if (invoice == null) { throw exception(INVOICE_NOT_EXISTS); } return invoice; } @Override @CrmPermission(bizType = CrmBizTypeEnum.CRM_INVOICE, bizId = "#id", level = CrmPermissionLevelEnum.READ) public CrmInvoiceDO getInvoice(Long id) { return invoiceMapper.selectById(id); } @Override public List getInvoiceList(Collection ids) { if (CollUtil.isEmpty(ids)) { return ListUtil.empty(); } return invoiceMapper.selectByIds(ids); } @Override public PageResult getInvoicePage(CrmInvoicePageReqVO pageReqVO, Long userId) { return invoiceMapper.selectPage(pageReqVO, userId); } @Override @CrmPermission(bizType = CrmBizTypeEnum.CRM_CUSTOMER, bizId = "#pageReqVO.customerId", level = CrmPermissionLevelEnum.READ) public PageResult getInvoicePageByCustomerId(CrmInvoicePageReqVO pageReqVO) { return invoiceMapper.selectPageByCustomerId(pageReqVO); } @Override public Long getAuditInvoiceCount(Long userId) { return invoiceMapper.selectCountByAudit(userId); } @Override public List getInvoiceListByContractIdAndStatus(Long contractId, Collection auditStatuses) { return invoiceMapper.selectListByContractIdAndStatus(contractId, auditStatuses); } @Override public Map getInvoicePriceMapByContractId(Collection contractIds) { return invoiceMapper.selectInvoicePriceMapByContractId(contractIds); } @Override public Long getInvoiceCountByContractId(Long contractId) { return invoiceMapper.selectCountByContractId(contractId); } @Override @Transactional(rollbackFor = Exception.class) @LogRecord(type = CRM_INVOICE_TYPE, subType = CRM_INVOICE_ENTRY_SUB_TYPE, bizNo = "{{#reqVO.id}}", success = CRM_INVOICE_ENTRY_SUCCESS) @CrmPermission(bizType = CrmBizTypeEnum.CRM_INVOICE, bizId = "#reqVO.id", level = CrmPermissionLevelEnum.WRITE) public void entryInvoice(CrmInvoiceEntryReqVO reqVO) { // 1.1 校验存在 CrmInvoiceDO invoice = validateInvoiceExists(reqVO.getId()); // 1.2 只有审批通过的开票申请才能录入发票 if (ObjectUtil.notEqual(invoice.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) { throw exception(INVOICE_ENTRY_FAIL_NOT_APPROVE, invoice.getNo(), StrUtil.blankToDefault(CrmAuditStatusEnum.getNameByStatus(invoice.getAuditStatus()), "未知状态")); } // 1.3 发票号码不可与其他开票单重复 String invoiceNo = reqVO.getInvoiceNo().trim(); CrmInvoiceDO invoiceNoExists = invoiceMapper.selectByInvoiceNo(invoiceNo); if (invoiceNoExists != null && ObjectUtil.notEqual(invoiceNoExists.getId(), invoice.getId())) { throw exception(INVOICE_ENTRY_FAIL_INVOICE_NO_EXISTS, invoiceNo, invoiceNoExists.getNo()); } // 2. 录入发票信息,状态置为已录入 invoiceMapper.updateById(new CrmInvoiceDO().setId(invoice.getId()) .setInvoiceNo(invoiceNo).setInvoiceTitle(reqVO.getInvoiceTitle()) .setInvoiceTime(reqVO.getInvoiceTime()).setRemark(reqVO.getRemark()) .setEntryStatus(10).setEntryUserId(getLoginUserId()).setEntryTime(LocalDateTime.now())); // 3. 更新发票附件 if (reqVO.getBlobIds() != null) { storageAttachmentApi.updateAttachments("file", INVOICE_RECORD_TYPE, invoice.getId(), reqVO.getBlobIds()); } // 4. 同步收款节点 syncPaymentNodes(invoice, reqVO.getPaymentNodes()); // 5. 记录操作日志上下文 LogRecordContext.putVariable("invoiceNo", invoice.getNo()); LogRecordContext.putVariable("entryInvoiceNo", invoiceNo); } @Override public List getSaleOutListForInvoice(Long saleOrderId, Long excludeInvoiceId) { if (saleOrderId == null) { return ListUtil.empty(); } List saleOutList = productSalesApi.getProductSalesListBySaleOrderId(saleOrderId); if (CollUtil.isEmpty(saleOutList)) { return ListUtil.empty(); } List result = new ArrayList<>(); for (MesWmProductSalesRespDTO saleOut : saleOutList) { // 已取消的出库单不可开票 if (ObjectUtil.equal(saleOut.getStatus(), MES_PRODUCT_SALES_STATUS_CANCELED)) { continue; } BigDecimal totalPrice = zeroIfNull(saleOut.getTotalPrice()); BigDecimal invoicedPrice = sumInvoicePrice(invoiceMapper.selectListBySaleOutIdAndStatus( saleOut.getId(), INVOICE_OCCUPY_STATUSES), excludeInvoiceId); CrmInvoiceSaleOutRespVO vo = new CrmInvoiceSaleOutRespVO(); vo.setId(saleOut.getId()); vo.setCode(saleOut.getCode()); vo.setSaleOrderId(saleOut.getSaleOrderId()); vo.setSalesOrderCode(saleOut.getSalesOrderCode()); vo.setSalesDate(saleOut.getSalesDate()); vo.setStatus(saleOut.getStatus()); vo.setStatusName(productSalesStatusName(saleOut.getStatus())); vo.setTotalPrice(totalPrice); vo.setInvoicedPrice(invoicedPrice); vo.setRemainInvoicePrice(totalPrice.subtract(invoicedPrice)); result.add(vo); } return result; } @Override public List getPaymentNodeListByInvoiceId(Long invoiceId) { return receivablePlanMapper.selectListByInvoiceId(invoiceId); } }