| | |
| | | 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.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.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.CrmInvoiceSaveReqVO; |
| | | import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO; |
| | |
| | | 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.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | 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; |
| | |
| | | return invoice.getId(); |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public CrmInvoiceImportRespVO importInvoiceList(List<CrmInvoiceImportExcelVO> importList) { |
| | | CrmInvoiceImportRespVO respVO = CrmInvoiceImportRespVO.builder() |
| | | .createList(new ArrayList<>()) |
| | | .failureList(new LinkedHashMap<>()) |
| | | .build(); |
| | | if (CollUtil.isEmpty(importList)) { |
| | | return respVO; |
| | | } |
| | | |
| | | Map<String, Integer> 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)); |
| | | } |
| | | } |
| | | 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<String> 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()); |
| | | List<CrmInvoiceDO> invoices = invoiceMapper.selectListByContractIdAndStatus(reqVO.getContractId(), |
| | | List.of(CrmAuditStatusEnum.APPROVE.getStatus(), CrmAuditStatusEnum.PROCESS.getStatus())); |
| | | if (reqVO.getId() != null) { |
| | | invoices.removeIf(invoice -> ObjectUtil.equal(invoice.getId(), reqVO.getId())); |
| | | } |
| | | BigDecimal notInvoicePrice = contract.getTotalPrice().subtract( |
| | | CollectionUtils.getSumValue(invoices, CrmInvoiceDO::getPrice, BigDecimal::add, BigDecimal.ZERO)); |
| | | 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<CrmInvoiceDO> 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()); // 校验负责人存在 |