From a647bc8a43c0212f482bee0630ebd14dca120a8a Mon Sep 17 00:00:00 2001
From: 吴羡 <16976746+Xian-TT3@user.noreply.gitee.com>
Date: 星期一, 28 九月 2026 14:13:28 +0800
Subject: [PATCH] feat: CRM 发票/合同导入、MES 质检批量导入与检测台账、回款与报价校验、权限码细化

---
 yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java |  250 ++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 243 insertions(+), 7 deletions(-)

diff --git a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java
index 4d0fd42..a59c979 100644
--- a/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java
+++ b/yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java
@@ -5,12 +5,16 @@
 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;
@@ -36,11 +40,18 @@
 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;
@@ -118,22 +129,247 @@
         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()
+                    + "銆傝鍏堝湪銆孋RM 鍚堝悓绠$悊銆嶄腑鍒涘缓鎴栧鍏ヨ鍚堝悓锛堢紪鍙烽渶涓庢ā鏉垮畬鍏ㄤ竴鑷达級锛屾垨淇敼妯℃澘涓殑鍚堝悓缂栧彿鍚庨噸鏂板鍏�");
+        }
+        if (ObjUtil.notEqual(contract.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) {
+            throw new IllegalArgumentException("鍚堝悓[" + contract.getNo() + "]褰撳墠鐘舵�佷负銆�"
+                    + CrmAuditStatusEnum.getNameByStatus(contract.getAuditStatus()) + "銆嶏紝鍙湁銆屽鏍搁�氳繃銆嶇殑鍚堝悓鎵嶈兘寮�绁ㄣ��"
+                    + "璇峰厛鍦ㄣ�孋RM 鍚堝悓绠$悊銆嶄腑瀹屾垚璇ュ悎鍚屽鎵癸紝鎴栨敼鐢ㄥ叾浠栧凡瀹℃牳閫氳繃鐨勫悎鍚岀紪鍙峰悗閲嶆柊瀵煎叆");
+        }
+
+        // 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("寮�绁ㄦ棩鏈熸牸寮忎笉姝g‘锛�" + 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()); // 鏍¢獙璐熻矗浜哄瓨鍦�

--
Gitblit v1.9.3