yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseRequestServiceImpl.java
@@ -522,18 +522,51 @@
    @Override
    public ErpPurchaseRequestImportRespVO importPurchaseRequestList(List<ErpPurchaseRequestImportExcelVO> importRequests,
                                                                    ErpPurchaseRequestImportReqVO importReqVO) {
        // 1. 校验非空(过滤完全空白的行)
        importRequests = filterList(importRequests, item -> StrUtil.isNotBlank(item.getProductCode()));
        if (CollUtil.isEmpty(importRequests)) {
            throw exception(PURCHASE_REQUEST_IMPORT_LIST_IS_EMPTY);
        }
        // 1. 逐行分类:有效行 / 被跳过的行。
        //    这里不再用 filterList 静默丢弃「物料编码为空」的行 —— 那会让用户看到「导入成功」
        //    但数据无声消失。被跳过的行必须带 Excel 原始行号返回给前端。
        ErpPurchaseRequestImportRespVO respVO = ErpPurchaseRequestImportRespVO.builder()
                .createPurchaseRequestNos(new ArrayList<>())
                .failurePurchaseRequestNos(new LinkedHashMap<>())
                .skippedRows(new LinkedHashMap<>())
                .build();
        List<ImportRow> validRows = new ArrayList<>();
        for (int i = 0; i < importRequests.size(); i++) {
            ErpPurchaseRequestImportExcelVO row = importRequests.get(i);
            int excelRowNo = i + 2; // +1 转 1 基,+1 跳过表头
            String rowLabel = "第" + excelRowNo + "行";
            // 1.1 完全空白的行(所有列都没有值):Excel 常在末尾留空行,静默跳过即可
            if (isBlankRow(row)) {
                continue;
            }
            // 1.2 物料编码为空:业务上无法定位物料,跳过并明确告知用户
            if (StrUtil.isBlank(row.getProductCode())) {
                respVO.getSkippedRows().put(rowLabel, "物料编码为空,该行未导入");
                continue;
            }
            validRows.add(new ImportRow(excelRowNo, row));
        }
        respVO.setTotalRows(validRows.size() + respVO.getSkippedRows().size());
        if (CollUtil.isEmpty(validRows)) {
            // 没有任何一行可导入:直接报错。
            // 若是有行因「物料编码为空」被跳过,异常信息里带上行号,帮助用户定位。
            if (CollUtil.isNotEmpty(respVO.getSkippedRows())) {
                throw exception(PURCHASE_REQUEST_IMPORT_ALL_ROWS_SKIPPED,
                        String.join("、", respVO.getSkippedRows().keySet()));
            }
            throw exception(PURCHASE_REQUEST_IMPORT_LIST_IS_EMPTY);
        }
        // 2. 按「单号」分组:相同单号的多行合并成一张采购申请;单号为空时每行各自成一张申请
        List<List<ErpPurchaseRequestImportExcelVO>> groups = new ArrayList<>();
        List<List<ImportRow>> groups = new ArrayList<>();
        Map<String, Integer> groupIndexMap = new LinkedHashMap<>();
        int rowIndex = 0;
        for (ErpPurchaseRequestImportExcelVO row : importRequests) {
            rowIndex++;
            String groupKey = StrUtil.isNotBlank(row.getNo()) ? row.getNo() : ("__row_" + rowIndex);
        for (ImportRow row : validRows) {
            String no = row.data.getNo();
            String groupKey = StrUtil.isNotBlank(no) ? no : ("__row_" + row.excelRowNo);
            Integer index = groupIndexMap.get(groupKey);
            if (index == null) {
                index = groups.size();
@@ -542,23 +575,65 @@
            }
            groups.get(index).add(row);
        }
        // 3. 逐组合并创建采购申请
        ErpPurchaseRequestImportRespVO respVO = ErpPurchaseRequestImportRespVO.builder()
                .createPurchaseRequestNos(new ArrayList<>())
                .failurePurchaseRequestNos(new LinkedHashMap<>())
                .build();
        for (List<ErpPurchaseRequestImportExcelVO> group : groups) {
            String groupKey = StrUtil.isNotBlank(group.get(0).getNo()) ? group.get(0).getNo()
                    : "第" + (importRequests.indexOf(group.get(0)) + 1) + "行";
        for (List<ImportRow> group : groups) {
            ImportRow firstRow = group.get(0);
            // 失败/跳过的标识优先用单号,没有单号则用 Excel 里的真实行号
            String groupKey = StrUtil.isNotBlank(firstRow.data.getNo())
                    ? firstRow.data.getNo() : "第" + firstRow.excelRowNo + "行";
            try {
                ErpPurchaseRequestSaveReqVO createReqVO = buildPurchaseRequestImportSaveReqVO(group);
                ErpPurchaseRequestSaveReqVO createReqVO = buildPurchaseRequestImportSaveReqVO(
                        convertList(group, ImportRow::getData));
                Long requestId = createPurchaseRequest(createReqVO);
                respVO.getCreatePurchaseRequestNos().add(purchaseRequestMapper.selectById(requestId).getNo());
                respVO.setSuccessRows(respVO.getSuccessRows() == null
                        ? group.size() : respVO.getSuccessRows() + group.size());
            } catch (ServiceException ex) {
                // 业务校验失败:记录原因,继续处理下一组
                respVO.getFailurePurchaseRequestNos().put(groupKey, ex.getMessage());
            } catch (Exception ex) {
                // 非业务异常(如远程调用 NPE、数据解析失败):不能让整个导入 500,
                // 记录为失败并继续,保证其它能导的行正常入库
                log.error("[importPurchaseRequestList] 导入行({}) 发生非业务异常", groupKey, ex);
                respVO.getFailurePurchaseRequestNos().put(groupKey,
                        "导入失败:" + (ex instanceof NullPointerException ? "数据解析异常,请检查物料/单位编码" : ex.getMessage()));
            }
        }
        return respVO;
    }
    /**
     * 判断一行是否完全空白(所有业务列都没有值)
     */
    private boolean isBlankRow(ErpPurchaseRequestImportExcelVO row) {
        return StrUtil.isBlank(row.getNo())
                && StrUtil.isBlank(row.getProductCode())
                && StrUtil.isBlank(row.getProductUnitCode())
                && row.getRequestTime() == null
                && row.getCount() == null
                && row.getProductPrice() == null
                && StrUtil.isBlank(row.getSupplierName())
                && StrUtil.isBlank(row.getRequestReason())
                && StrUtil.isBlank(row.getRemark())
                && StrUtil.isBlank(row.getItemRemark());
    }
    /**
     * 导入行包装:携带 Excel 中的真实行号,用于错误提示定位。
     */
    private static class ImportRow {
        private final int excelRowNo;
        private final ErpPurchaseRequestImportExcelVO data;
        private ImportRow(int excelRowNo, ErpPurchaseRequestImportExcelVO data) {
            this.excelRowNo = excelRowNo;
            this.data = data;
        }
        private ErpPurchaseRequestImportExcelVO getData() {
            return data;
        }
    }
    /**
@@ -612,4 +687,4 @@
                .setItems(items);
    }
}
}