diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java index d67b260e..b3ac38b7 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java @@ -114,16 +114,14 @@ public class CcdiStaffRecruitmentController extends BaseController { @Operation(summary = "下载导入模板") @PostMapping("/importTemplate") public void importTemplate(HttpServletResponse response) { - EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiStaffRecruitmentExcel.class, "员工招聘信息"); - } - - /** - * 下载历史工作经历导入模板 - */ - @Operation(summary = "下载历史工作经历导入模板") - @PostMapping("/workImportTemplate") - public void workImportTemplate(HttpServletResponse response) { - EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiStaffRecruitmentWorkExcel.class, "历史工作经历"); + EasyExcelUtil.importTemplateWithDictDropdown( + response, + CcdiStaffRecruitmentExcel.class, + "招聘信息", + CcdiStaffRecruitmentWorkExcel.class, + "历史工作经历", + "招聘信息管理导入模板" + ); } /** @@ -135,16 +133,25 @@ public class CcdiStaffRecruitmentController extends BaseController { @Log(title = "员工招聘信息", businessType = BusinessType.IMPORT) @PostMapping("/importData") public AjaxResult importData(@Parameter(description = "导入文件") MultipartFile file) throws Exception { - List list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiStaffRecruitmentExcel.class); + List recruitmentList = EasyExcelUtil.importExcel( + file.getInputStream(), + CcdiStaffRecruitmentExcel.class, + "招聘信息" + ); + List workList = EasyExcelUtil.importExcel( + file.getInputStream(), + CcdiStaffRecruitmentWorkExcel.class, + "历史工作经历" + ); - if (list == null || list.isEmpty()) { + boolean hasRecruitmentRows = recruitmentList != null && !recruitmentList.isEmpty(); + boolean hasWorkRows = workList != null && !workList.isEmpty(); + if (!hasRecruitmentRows && !hasWorkRows) { return error("至少需要一条数据"); } - // 提交异步任务 - String taskId = recruitmentService.importRecruitment(list); + String taskId = recruitmentService.importRecruitment(recruitmentList, workList); - // 立即返回,不等待后台任务完成 ImportResultVO result = new ImportResultVO(); result.setTaskId(taskId); result.setStatus("PROCESSING"); @@ -153,31 +160,6 @@ public class CcdiStaffRecruitmentController extends BaseController { return AjaxResult.success("导入任务已提交,正在后台处理", result); } - /** - * 异步导入历史工作经历 - */ - @Operation(summary = "异步导入历史工作经历") - @Parameter(name = "file", description = "导入文件", required = true) - @PreAuthorize("@ss.hasPermi('ccdi:staffRecruitment:import')") - @Log(title = "员工招聘历史工作经历", businessType = BusinessType.IMPORT) - @PostMapping("/importWorkData") - public AjaxResult importWorkData(@Parameter(description = "导入文件") MultipartFile file) throws Exception { - List list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiStaffRecruitmentWorkExcel.class); - - if (list == null || list.isEmpty()) { - return error("至少需要一条数据"); - } - - String taskId = recruitmentService.importRecruitmentWork(list); - - ImportResultVO result = new ImportResultVO(); - result.setTaskId(taskId); - result.setStatus("PROCESSING"); - result.setMessage("历史工作经历导入任务已提交,正在后台处理"); - - return AjaxResult.success("历史工作经历导入任务已提交,正在后台处理", result); - } - /** * 查询导入状态 */ diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java index 4775dfff..e88f740d 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java @@ -13,6 +13,12 @@ import lombok.Data; @Schema(description = "招聘信息导入失败记录") public class RecruitmentImportFailureVO { + @Schema(description = "失败Sheet") + private String sheetName; + + @Schema(description = "失败行号") + private String sheetRowNum; + @Schema(description = "招聘项目编号") private String recruitId; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java index cb1f251e..adf25445 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java @@ -22,21 +22,11 @@ public interface ICcdiStaffRecruitmentImportService { * @param taskId 任务ID * @param userName 用户名 */ - void importRecruitmentAsync(List excelList, + void importRecruitmentAsync(List recruitmentList, + List workList, String taskId, String userName); - /** - * 异步导入招聘记录历史工作经历数据 - * - * @param excelList Excel数据列表 - * @param taskId 任务ID - * @param userName 用户名 - */ - void importRecruitmentWorkAsync(List excelList, - String taskId, - String userName); - /** * 查询导入状态 * diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java index eaa09ab3..dd017d36 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java @@ -81,13 +81,6 @@ public interface ICcdiStaffRecruitmentService { * @param excelList Excel实体列表 * @return 结果 */ - String importRecruitment(List excelList); - - /** - * 导入招聘记录历史工作经历数据(异步) - * - * @param excelList Excel实体列表 - * @return 任务ID - */ - String importRecruitmentWork(List excelList); + String importRecruitment(List recruitmentList, + List workList); } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java index 697c3a84..86c82b95 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java @@ -2,6 +2,8 @@ package com.ruoyi.info.collection.service.impl; import com.alibaba.fastjson2.JSON; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.ruoyi.common.utils.IdCardUtil; +import com.ruoyi.common.utils.StringUtils; import com.ruoyi.info.collection.domain.CcdiStaffRecruitment; import com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork; import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentAddDTO; @@ -16,9 +18,19 @@ import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentMapper; import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentWorkMapper; import com.ruoyi.info.collection.service.ICcdiStaffRecruitmentImportService; import com.ruoyi.info.collection.utils.ImportLogUtils; -import com.ruoyi.common.utils.IdCardUtil; -import com.ruoyi.common.utils.StringUtils; import jakarta.annotation.Resource; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeanUtils; @@ -28,10 +40,6 @@ import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.*; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - /** * 招聘信息异步导入Service实现 * @@ -44,6 +52,10 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm private static final Logger log = LoggerFactory.getLogger(CcdiStaffRecruitmentImportServiceImpl.class); + private static final String MAIN_SHEET_NAME = "招聘信息"; + private static final String WORK_SHEET_NAME = "历史工作经历"; + private static final int EXCEL_DATA_START_ROW = 2; + @Resource private CcdiStaffRecruitmentMapper recruitmentMapper; @@ -56,181 +68,56 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm @Override @Async @Transactional - public void importRecruitmentAsync(List excelList, + public void importRecruitmentAsync(List recruitmentList, + List workList, String taskId, String userName) { + List safeRecruitmentList = recruitmentList == null + ? Collections.emptyList() + : recruitmentList; + List safeWorkList = workList == null + ? Collections.emptyList() + : workList; + int totalCount = safeRecruitmentList.size() + safeWorkList.size(); long startTime = System.currentTimeMillis(); - // 记录导入开始 - ImportLogUtils.logImportStart(log, taskId, "招聘信息", excelList.size(), userName); + ImportLogUtils.logImportStart(log, taskId, "招聘信息双Sheet", totalCount, userName); - List newRecords = new ArrayList<>(); List failures = new ArrayList<>(); + List indexedMainRows = buildMainImportRows(safeRecruitmentList); + List indexedWorkRows = buildWorkImportRows(safeWorkList); - // 批量查询已存在的招聘记录编号 - ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的招聘记录编号", excelList.size()); - Set existingRecruitIds = getExistingRecruitIds(excelList); - ImportLogUtils.logBatchQueryComplete(log, taskId, "招聘记录编号", existingRecruitIds.size()); + MainImportResult mainImportResult = importMainSheet(indexedMainRows, failures, userName, taskId); + int workSuccessCount = importWorkSheet( + indexedWorkRows, + mainImportResult.importedRecruitmentMap(), + failures, + userName, + taskId + ); - // 用于检测Excel内部的重复ID - Set excelProcessedIds = new HashSet<>(); - - // 分类数据 - for (int i = 0; i < excelList.size(); i++) { - CcdiStaffRecruitmentExcel excel = excelList.get(i); - - try { - // 转换为AddDTO进行验证 - CcdiStaffRecruitmentAddDTO addDTO = new CcdiStaffRecruitmentAddDTO(); - BeanUtils.copyProperties(excel, addDTO); - addDTO.setRecruitType(RecruitType.inferCode(addDTO.getRecruitName())); - - // 验证数据 - validateRecruitmentData(addDTO, existingRecruitIds); - - CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); - BeanUtils.copyProperties(excel, recruitment); - recruitment.setRecruitType(addDTO.getRecruitType()); - - if (existingRecruitIds.contains(excel.getRecruitId())) { - // 招聘记录编号在数据库中已存在,直接报错 - throw new RuntimeException(String.format("招聘记录编号[%s]已存在,请勿重复导入", excel.getRecruitId())); - } else if (excelProcessedIds.contains(excel.getRecruitId())) { - // 招聘记录编号在Excel文件内部重复 - throw new RuntimeException(String.format("招聘记录编号[%s]在导入文件中重复,已跳过此条记录", excel.getRecruitId())); - } else { - recruitment.setCreatedBy(userName); - recruitment.setUpdatedBy(userName); - newRecords.add(recruitment); - excelProcessedIds.add(excel.getRecruitId()); // 标记为已处理 - } - - // 记录进度 - ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(), - newRecords.size(), failures.size()); - - } catch (Exception e) { - RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO(); - BeanUtils.copyProperties(excel, failure); - failure.setErrorMessage(e.getMessage()); - failures.add(failure); - - // 记录验证失败日志 - String keyData = String.format("招聘记录编号=%s, 项目名称=%s, 应聘人员=%s", - excel.getRecruitId(), excel.getRecruitName(), excel.getCandName()); - ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData); - } - } - - // 批量插入新数据 - if (!newRecords.isEmpty()) { - ImportLogUtils.logBatchOperationStart(log, taskId, "插入", - (newRecords.size() + 499) / 500, 500); - saveBatch(newRecords, 500); - } - - // 保存失败记录到Redis if (!failures.isEmpty()) { - try { - String failuresKey = "import:recruitment:" + taskId + ":failures"; - redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); - ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size()); - } catch (Exception e) { - ImportLogUtils.logRedisError(log, taskId, "保存失败记录", e); - } + saveFailures(taskId, failures); } ImportResult result = new ImportResult(); - result.setTotalCount(excelList.size()); - result.setSuccessCount(newRecords.size()); - result.setFailureCount(failures.size()); + result.setTotalCount(totalCount); + result.setSuccessCount(mainImportResult.successCount() + workSuccessCount); + result.setFailureCount(Math.max(totalCount - result.getSuccessCount(), 0)); - // 更新最终状态 - String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; - updateImportStatus(taskId, finalStatus, result); - - // 记录导入完成 - long duration = System.currentTimeMillis() - startTime; - ImportLogUtils.logImportComplete(log, taskId, "招聘信息", - excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); - } - - @Override - @Async - @Transactional - public void importRecruitmentWorkAsync(List excelList, - String taskId, - String userName) { - long startTime = System.currentTimeMillis(); - ImportLogUtils.logImportStart(log, taskId, "招聘历史工作经历", excelList.size(), userName); - - List failures = new ArrayList<>(); - List validRecords = new ArrayList<>(); - Set failedRecruitIds = new HashSet<>(); - Set processedRecruitSortKeys = new HashSet<>(); - - Map recruitmentMap = getRecruitmentMap(excelList); - - for (int i = 0; i < excelList.size(); i++) { - CcdiStaffRecruitmentWorkExcel excel = excelList.get(i); - try { - CcdiStaffRecruitment recruitment = recruitmentMap.get(trim(excel.getRecruitId())); - validateRecruitmentWorkData(excel, recruitment, processedRecruitSortKeys); - - CcdiStaffRecruitmentWork work = new CcdiStaffRecruitmentWork(); - BeanUtils.copyProperties(excel, work); - work.setRecruitId(trim(excel.getRecruitId())); - work.setCreatedBy(userName); - work.setUpdatedBy(userName); - validRecords.add(work); - - ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(), - validRecords.size(), failures.size()); - } catch (Exception e) { - failedRecruitIds.add(trim(excel.getRecruitId())); - failures.add(buildWorkFailure(excel, e.getMessage())); - String keyData = String.format("招聘记录编号=%s, 候选人=%s, 工作单位=%s", - excel.getRecruitId(), excel.getCandName(), excel.getCompanyName()); - ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData); - } - } - - List importRecords = validRecords.stream() - .filter(work -> !failedRecruitIds.contains(work.getRecruitId())) - .toList(); - appendSkippedFailures(validRecords, failedRecruitIds, failures); - - if (!importRecords.isEmpty()) { - Set importRecruitIds = importRecords.stream() - .map(CcdiStaffRecruitmentWork::getRecruitId) - .collect(Collectors.toSet()); - LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); - deleteWrapper.in(CcdiStaffRecruitmentWork::getRecruitId, importRecruitIds); - recruitmentWorkMapper.delete(deleteWrapper); - - importRecords.forEach(recruitmentWorkMapper::insert); - } - - if (!failures.isEmpty()) { - try { - String failuresKey = "import:recruitment:" + taskId + ":failures"; - redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); - ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size()); - } catch (Exception e) { - ImportLogUtils.logRedisError(log, taskId, "保存失败记录", e); - } - } - - ImportResult result = new ImportResult(); - result.setTotalCount(excelList.size()); - result.setSuccessCount(importRecords.size()); - result.setFailureCount(failures.size()); - String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; + String finalStatus = resolveFinalStatus(result); updateImportStatus(taskId, finalStatus, result); long duration = System.currentTimeMillis() - startTime; - ImportLogUtils.logImportComplete(log, taskId, "招聘历史工作经历", - excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); + ImportLogUtils.logImportComplete( + log, + taskId, + "招聘信息双Sheet", + totalCount, + result.getSuccessCount(), + result.getFailureCount(), + duration + ); } @Override @@ -270,14 +157,188 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm return JSON.parseArray(JSON.toJSONString(failuresObj), RecruitmentImportFailureVO.class); } - /** - * 批量查询已存在的招聘记录编号 - */ - private Set getExistingRecruitIds(List excelList) { - List recruitIds = excelList.stream() - .map(CcdiStaffRecruitmentExcel::getRecruitId) - .filter(StringUtils::isNotEmpty) - .collect(Collectors.toList()); + private MainImportResult importMainSheet(List mainRows, + List failures, + String userName, + String taskId) { + if (mainRows.isEmpty()) { + return new MainImportResult(Collections.emptyMap(), 0); + } + + Set existingRecruitIds = getExistingRecruitIds( + mainRows.stream().map(MainImportRow::data).toList() + ); + Set processedRecruitIds = new HashSet<>(); + List newRecords = new ArrayList<>(); + Map importedRecruitmentMap = new LinkedHashMap<>(); + + for (int index = 0; index < mainRows.size(); index++) { + MainImportRow mainRow = mainRows.get(index); + CcdiStaffRecruitmentExcel excel = mainRow.data(); + try { + CcdiStaffRecruitmentAddDTO addDTO = new CcdiStaffRecruitmentAddDTO(); + BeanUtils.copyProperties(excel, addDTO); + addDTO.setRecruitType(RecruitType.inferCode(addDTO.getRecruitName())); + + validateRecruitmentData(addDTO, mainRow.sheetRowNum()); + + String recruitId = trim(excel.getRecruitId()); + if (existingRecruitIds.contains(recruitId)) { + throw buildValidationException( + MAIN_SHEET_NAME, + List.of(mainRow.sheetRowNum()), + String.format("招聘记录编号[%s]已存在,请勿重复导入", recruitId) + ); + } + if (!processedRecruitIds.add(recruitId)) { + throw buildValidationException( + MAIN_SHEET_NAME, + List.of(mainRow.sheetRowNum()), + String.format("招聘记录编号[%s]在导入文件中重复,已跳过此条记录", recruitId) + ); + } + + CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); + BeanUtils.copyProperties(excel, recruitment); + recruitment.setRecruitId(recruitId); + recruitment.setRecruitType(addDTO.getRecruitType()); + recruitment.setCreatedBy(userName); + recruitment.setUpdatedBy(userName); + newRecords.add(recruitment); + importedRecruitmentMap.put(recruitId, recruitment); + + ImportLogUtils.logProgress(log, taskId, index + 1, mainRows.size(), newRecords.size(), failures.size()); + } catch (Exception exception) { + FailureMeta failureMeta = resolveFailureMeta(exception, List.of(mainRow.sheetRowNum()), MAIN_SHEET_NAME); + failures.add(buildFailure(excel, failureMeta.sheetName(), failureMeta.sheetRowNum(), exception.getMessage())); + ImportLogUtils.logValidationError( + log, + taskId, + index + 1, + exception.getMessage(), + String.format("招聘记录编号=%s, 项目名称=%s, 应聘人员=%s", excel.getRecruitId(), excel.getRecruitName(), excel.getCandName()) + ); + } + } + + if (!newRecords.isEmpty()) { + ImportLogUtils.logBatchOperationStart(log, taskId, "插入招聘信息", (newRecords.size() + 499) / 500, 500); + saveBatch(newRecords, 500); + } + + return new MainImportResult(importedRecruitmentMap, newRecords.size()); + } + + private int importWorkSheet(List workRows, + Map importedRecruitmentMap, + List failures, + String userName, + String taskId) { + if (workRows.isEmpty()) { + return 0; + } + + Map existingRecruitmentMap = + getExistingRecruitmentMap(workRows, importedRecruitmentMap); + Map> groupedRows = groupWorkRows(workRows); + int successCount = 0; + int processedGroups = 0; + + for (List recruitWorkRows : groupedRows.values()) { + processedGroups++; + WorkImportRow firstRow = recruitWorkRows.get(0); + String recruitId = trim(firstRow.data().getRecruitId()); + CcdiStaffRecruitment recruitment = importedRecruitmentMap.get(recruitId); + if (recruitment == null) { + recruitment = existingRecruitmentMap.get(recruitId); + } + + try { + validateWorkGroup(recruitWorkRows, recruitment); + + if (StringUtils.isNotEmpty(recruitId) && hasExistingWorkHistory(recruitId)) { + throw buildValidationException( + WORK_SHEET_NAME, + extractWorkRowNums(recruitWorkRows), + String.format("招聘记录编号[%s]已存在历史工作经历,不允许重复导入", recruitId) + ); + } + + List entities = buildWorkEntities(recruitWorkRows, userName); + entities.forEach(entity -> recruitmentWorkMapper.insert(entity)); + successCount += recruitWorkRows.size(); + + ImportLogUtils.logProgress(log, taskId, processedGroups, groupedRows.size(), successCount, failures.size()); + } catch (Exception exception) { + FailureMeta failureMeta = resolveFailureMeta(exception, extractWorkRowNums(recruitWorkRows), WORK_SHEET_NAME); + failures.add(buildFailure(firstRow.data(), failureMeta.sheetName(), failureMeta.sheetRowNum(), exception.getMessage())); + ImportLogUtils.logValidationError( + log, + taskId, + processedGroups, + exception.getMessage(), + String.format( + "招聘记录编号=%s, 候选人=%s, 工作单位=%s", + firstRow.data().getRecruitId(), + firstRow.data().getCandName(), + firstRow.data().getCompanyName() + ) + ); + } + } + + return successCount; + } + + private Map> groupWorkRows(List workRows) { + Map> groupedRows = new LinkedHashMap<>(); + for (WorkImportRow workRow : workRows) { + groupedRows.computeIfAbsent(buildWorkGroupKey(workRow), key -> new ArrayList<>()).add(workRow); + } + return groupedRows; + } + + private String buildWorkGroupKey(WorkImportRow workRow) { + String recruitId = trim(workRow.data().getRecruitId()); + if (StringUtils.isNotEmpty(recruitId)) { + return recruitId; + } + return "__ROW__" + workRow.sheetRowNum(); + } + + private Map getExistingRecruitmentMap(List workRows, + Map importedRecruitmentMap) { + LinkedHashSet recruitIds = workRows.stream() + .map(row -> trim(row.data().getRecruitId())) + .filter(StringUtils::isNotEmpty) + .filter(recruitId -> !importedRecruitmentMap.containsKey(recruitId)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (recruitIds.isEmpty()) { + return Collections.emptyMap(); + } + List recruitments = recruitmentMapper.selectBatchIds(recruitIds); + return recruitments.stream().collect(Collectors.toMap(CcdiStaffRecruitment::getRecruitId, item -> item)); + } + + private List buildWorkEntities(List workRows, String userName) { + List entities = new ArrayList<>(); + for (WorkImportRow workRow : workRows) { + CcdiStaffRecruitmentWork entity = new CcdiStaffRecruitmentWork(); + BeanUtils.copyProperties(workRow.data(), entity); + entity.setRecruitId(trim(workRow.data().getRecruitId())); + entity.setCreatedBy(userName); + entity.setUpdatedBy(userName); + entities.add(entity); + } + return entities; + } + + private Set getExistingRecruitIds(List recruitmentList) { + List recruitIds = recruitmentList.stream() + .map(CcdiStaffRecruitmentExcel::getRecruitId) + .map(this::trim) + .filter(StringUtils::isNotEmpty) + .toList(); if (recruitIds.isEmpty()) { return Collections.emptySet(); @@ -288,148 +349,138 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm List existingRecruitments = recruitmentMapper.selectList(wrapper); return existingRecruitments.stream() - .map(CcdiStaffRecruitment::getRecruitId) - .collect(Collectors.toSet()); + .map(CcdiStaffRecruitment::getRecruitId) + .collect(Collectors.toSet()); } - /** - * 验证招聘信息数据 - */ - private void validateRecruitmentData(CcdiStaffRecruitmentAddDTO addDTO, - Set existingRecruitIds) { - // 验证必填字段 + private boolean hasExistingWorkHistory(String recruitId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CcdiStaffRecruitmentWork::getRecruitId, recruitId); + return recruitmentWorkMapper.selectCount(wrapper) > 0; + } + + private void validateRecruitmentData(CcdiStaffRecruitmentAddDTO addDTO, int sheetRowNum) { if (StringUtils.isEmpty(addDTO.getRecruitId())) { - throw new RuntimeException("招聘记录编号不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号不能为空"); } if (StringUtils.isEmpty(addDTO.getRecruitName())) { - throw new RuntimeException("招聘项目名称不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "招聘项目名称不能为空"); } if (StringUtils.isEmpty(addDTO.getPosName())) { - throw new RuntimeException("职位名称不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "职位名称不能为空"); } if (StringUtils.isEmpty(addDTO.getPosCategory())) { - throw new RuntimeException("职位类别不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "职位类别不能为空"); } if (StringUtils.isEmpty(addDTO.getPosDesc())) { - throw new RuntimeException("职位描述不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "职位描述不能为空"); } if (StringUtils.isEmpty(addDTO.getCandName())) { - throw new RuntimeException("应聘人员姓名不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员姓名不能为空"); } if (StringUtils.isEmpty(addDTO.getCandEdu())) { - throw new RuntimeException("应聘人员学历不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员学历不能为空"); } if (StringUtils.isEmpty(addDTO.getCandId())) { - throw new RuntimeException("证件号码不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "证件号码不能为空"); } if (StringUtils.isEmpty(addDTO.getCandSchool())) { - throw new RuntimeException("应聘人员毕业院校不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员毕业院校不能为空"); } if (StringUtils.isEmpty(addDTO.getCandMajor())) { - throw new RuntimeException("应聘人员专业不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员专业不能为空"); } if (StringUtils.isEmpty(addDTO.getCandGrad())) { - throw new RuntimeException("应聘人员毕业年月不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员毕业年月不能为空"); } if (StringUtils.isEmpty(addDTO.getAdmitStatus())) { - throw new RuntimeException("录用情况不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "录用情况不能为空"); } if (StringUtils.isEmpty(addDTO.getRecruitType())) { - throw new RuntimeException("招聘类型不能为空"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "招聘类型不能为空"); } - // 验证证件号码格式 String idCardError = IdCardUtil.getErrorMessage(addDTO.getCandId()); if (idCardError != null) { - throw new RuntimeException("证件号码" + idCardError); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "证件号码" + idCardError); } - // 验证毕业年月格式(YYYYMM) if (!addDTO.getCandGrad().matches("^((19|20)\\d{2})(0[1-9]|1[0-2])$")) { - throw new RuntimeException("毕业年月格式不正确,应为YYYYMM"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "毕业年月格式不正确,应为YYYYMM"); } - // 验证录用状态 if (AdmitStatus.getDescByCode(addDTO.getAdmitStatus()) == null) { - throw new RuntimeException("录用情况只能填写'录用'、'未录用'或'放弃'"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "录用情况只能填写'录用'、'未录用'或'放弃'"); } if (RecruitType.getDescByCode(addDTO.getRecruitType()) == null) { - throw new RuntimeException("招聘类型只能填写'SOCIAL'或'CAMPUS'"); + throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "招聘类型只能填写'SOCIAL'或'CAMPUS'"); } } - private Map getRecruitmentMap(List excelList) { - List recruitIds = excelList.stream() - .map(CcdiStaffRecruitmentWorkExcel::getRecruitId) - .map(this::trim) - .filter(StringUtils::isNotEmpty) - .distinct() - .toList(); - if (recruitIds.isEmpty()) { - return Collections.emptyMap(); + private void validateWorkGroup(List workRows, CcdiStaffRecruitment recruitment) { + Set processedSortOrders = new HashSet<>(); + for (WorkImportRow workRow : workRows) { + validateRecruitmentWorkData(workRow.data(), recruitment, processedSortOrders, workRow.sheetRowNum()); } - List recruitments = recruitmentMapper.selectBatchIds(recruitIds); - return recruitments.stream() - .collect(Collectors.toMap(CcdiStaffRecruitment::getRecruitId, item -> item)); } private void validateRecruitmentWorkData(CcdiStaffRecruitmentWorkExcel excel, CcdiStaffRecruitment recruitment, - Set processedRecruitSortKeys) { + Set processedSortOrders, + int sheetRowNum) { if (StringUtils.isEmpty(trim(excel.getRecruitId()))) { - throw new RuntimeException("招聘记录编号不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号不能为空"); } if (StringUtils.isEmpty(trim(excel.getCandName()))) { - throw new RuntimeException("候选人姓名不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "候选人姓名不能为空"); } if (StringUtils.isEmpty(trim(excel.getRecruitName()))) { - throw new RuntimeException("招聘项目名称不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘项目名称不能为空"); } if (StringUtils.isEmpty(trim(excel.getPosName()))) { - throw new RuntimeException("职位名称不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "职位名称不能为空"); } if (excel.getSortOrder() == null || excel.getSortOrder() <= 0) { - throw new RuntimeException("排序号不能为空且必须大于0"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "排序号不能为空且必须大于0"); + } + if (!processedSortOrders.add(excel.getSortOrder())) { + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "同一招聘记录编号下排序号重复"); } if (StringUtils.isEmpty(trim(excel.getCompanyName()))) { - throw new RuntimeException("工作单位不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "工作单位不能为空"); } if (StringUtils.isEmpty(trim(excel.getPositionName()))) { - throw new RuntimeException("岗位不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "岗位不能为空"); } if (StringUtils.isEmpty(trim(excel.getJobStartMonth()))) { - throw new RuntimeException("入职年月不能为空"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "入职年月不能为空"); } - validateMonth(excel.getJobStartMonth(), "入职年月"); + validateMonth(excel.getJobStartMonth(), "入职年月", sheetRowNum); if (StringUtils.isNotEmpty(trim(excel.getJobEndMonth()))) { - validateMonth(excel.getJobEndMonth(), "离职年月"); + validateMonth(excel.getJobEndMonth(), "离职年月", sheetRowNum); } if (recruitment == null) { - throw new RuntimeException("招聘记录编号不存在,请先维护招聘主信息"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号不存在,请先维护招聘主信息"); } if (!"SOCIAL".equals(recruitment.getRecruitType())) { - throw new RuntimeException("该招聘记录不是社招,不允许导入历史工作经历"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "该招聘记录不是社招,不允许导入历史工作经历"); } if (!sameText(excel.getCandName(), recruitment.getCandName())) { - throw new RuntimeException("招聘记录编号与候选人姓名不匹配"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号与候选人姓名不匹配"); } if (!sameText(excel.getRecruitName(), recruitment.getRecruitName())) { - throw new RuntimeException("招聘记录编号与招聘项目名称不匹配"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号与招聘项目名称不匹配"); } if (!sameText(excel.getPosName(), recruitment.getPosName())) { - throw new RuntimeException("招聘记录编号与职位名称不匹配"); - } - String duplicateKey = trim(excel.getRecruitId()) + "#" + excel.getSortOrder(); - if (!processedRecruitSortKeys.add(duplicateKey)) { - throw new RuntimeException("同一招聘记录编号下排序号重复"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号与职位名称不匹配"); } } - private void validateMonth(String value, String fieldName) { + private void validateMonth(String value, String fieldName, int sheetRowNum) { String month = trim(value); if (!month.matches("^((19|20)\\d{2})-(0[1-9]|1[0-2])$")) { - throw new RuntimeException(fieldName + "格式不正确,应为YYYY-MM"); + throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), fieldName + "格式不正确,应为YYYY-MM"); } } @@ -441,32 +492,50 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm return value == null ? null : value.trim(); } - private RecruitmentImportFailureVO buildWorkFailure(CcdiStaffRecruitmentWorkExcel excel, String errorMessage) { + private void saveFailures(String taskId, List failures) { + try { + String failuresKey = "import:recruitment:" + taskId + ":failures"; + redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); + ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size()); + } catch (Exception exception) { + ImportLogUtils.logRedisError(log, taskId, "保存失败记录", exception); + } + } + + private RecruitmentImportFailureVO buildFailure(CcdiStaffRecruitmentExcel excel, + String sheetName, + String sheetRowNum, + String errorMessage) { RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO(); BeanUtils.copyProperties(excel, failure); + failure.setSheetName(sheetName); + failure.setSheetRowNum(sheetRowNum); failure.setErrorMessage(errorMessage); return failure; } - private void appendSkippedFailures(List validRecords, - Set failedRecruitIds, - List failures) { - Set appendedRecruitIds = new HashSet<>(); - for (CcdiStaffRecruitmentWork work : validRecords) { - if (failedRecruitIds.contains(work.getRecruitId()) && appendedRecruitIds.add(work.getRecruitId())) { - RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO(); - failure.setRecruitId(work.getRecruitId()); - failure.setCompanyName(work.getCompanyName()); - failure.setPositionName(work.getPositionName()); - failure.setErrorMessage("同一招聘记录编号存在失败行,已跳过该编号下全部工作经历,避免覆盖旧数据"); - failures.add(failure); - } - } + private RecruitmentImportFailureVO buildFailure(CcdiStaffRecruitmentWorkExcel excel, + String sheetName, + String sheetRowNum, + String errorMessage) { + RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO(); + BeanUtils.copyProperties(excel, failure); + failure.setSheetName(sheetName); + failure.setSheetRowNum(sheetRowNum); + failure.setErrorMessage(errorMessage); + return failure; + } + + private String resolveFinalStatus(ImportResult result) { + if (result.getFailureCount() == 0) { + return "SUCCESS"; + } + if (result.getSuccessCount() == 0) { + return "FAILED"; + } + return "PARTIAL_SUCCESS"; } - /** - * 更新导入状态 - */ private void updateImportStatus(String taskId, String status, ImportResult result) { String key = "import:recruitment:" + taskId; Map statusData = new HashMap<>(); @@ -486,35 +555,100 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm redisTemplate.opsForHash().putAll(key, statusData); } - /** - * 批量保存 - */ private void saveBatch(List list, int batchSize) { - // 使用真正的批量插入,分批次执行以提高性能 for (int i = 0; i < list.size(); i += batchSize) { int end = Math.min(i + batchSize, list.size()); List subList = list.subList(i, end); - // 过滤掉已存在的记录,防止主键冲突 List recruitIds = subList.stream() - .map(CcdiStaffRecruitment::getRecruitId) - .collect(Collectors.toList()); + .map(CcdiStaffRecruitment::getRecruitId) + .toList(); + if (recruitIds.isEmpty()) { + continue; + } - if (!recruitIds.isEmpty()) { - List existingRecords = recruitmentMapper.selectBatchIds(recruitIds); - Set existingIds = existingRecords.stream() - .map(CcdiStaffRecruitment::getRecruitId) - .collect(Collectors.toSet()); + List existingRecords = recruitmentMapper.selectBatchIds(recruitIds); + Set existingIds = existingRecords.stream() + .map(CcdiStaffRecruitment::getRecruitId) + .collect(Collectors.toSet()); - // 只插入不存在的记录 - List toInsert = subList.stream() - .filter(r -> !existingIds.contains(r.getRecruitId())) - .collect(Collectors.toList()); - - if (!toInsert.isEmpty()) { - recruitmentMapper.insertBatch(toInsert); - } + List toInsert = subList.stream() + .filter(record -> !existingIds.contains(record.getRecruitId())) + .toList(); + if (!toInsert.isEmpty()) { + recruitmentMapper.insertBatch(toInsert); } } } + + private List buildMainImportRows(List recruitmentList) { + List rows = new ArrayList<>(); + for (int i = 0; i < recruitmentList.size(); i++) { + rows.add(new MainImportRow(recruitmentList.get(i), i + EXCEL_DATA_START_ROW)); + } + return rows; + } + + private List buildWorkImportRows(List workList) { + List rows = new ArrayList<>(); + for (int i = 0; i < workList.size(); i++) { + rows.add(new WorkImportRow(workList.get(i), i + EXCEL_DATA_START_ROW)); + } + return rows; + } + + private List extractWorkRowNums(List rows) { + return rows.stream().map(WorkImportRow::sheetRowNum).toList(); + } + + private FailureMeta resolveFailureMeta(Exception exception, List rowNums, String defaultSheetName) { + if (exception instanceof ImportValidationException validationException) { + return new FailureMeta(validationException.getSheetName(), validationException.getSheetRowNum()); + } + return new FailureMeta(defaultSheetName, formatSheetRowNum(rowNums)); + } + + private ImportValidationException buildValidationException(String sheetName, List rowNums, String message) { + return new ImportValidationException(sheetName, formatSheetRowNum(rowNums), message); + } + + private String formatSheetRowNum(List rowNums) { + if (rowNums == null || rowNums.isEmpty()) { + return ""; + } + return rowNums.stream() + .filter(Objects::nonNull) + .distinct() + .sorted() + .map(String::valueOf) + .collect(Collectors.joining("、")); + } + + private record MainImportRow(CcdiStaffRecruitmentExcel data, int sheetRowNum) {} + + private record WorkImportRow(CcdiStaffRecruitmentWorkExcel data, int sheetRowNum) {} + + private record MainImportResult(Map importedRecruitmentMap, int successCount) {} + + private record FailureMeta(String sheetName, String sheetRowNum) {} + + private static class ImportValidationException extends RuntimeException { + + private final String sheetName; + private final String sheetRowNum; + + private ImportValidationException(String sheetName, String sheetRowNum, String message) { + super(message); + this.sheetName = sheetName; + this.sheetRowNum = sheetRowNum; + } + + public String getSheetName() { + return sheetName; + } + + public String getSheetRowNum() { + return sheetRowNum; + } + } } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java index e5d9d0b6..c8a799a8 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java @@ -182,24 +182,26 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer */ @Override @Transactional - public String importRecruitment(java.util.List excelList) { - if (StringUtils.isNull(excelList) || excelList.isEmpty()) { + public String importRecruitment(List recruitmentList, + List workList) { + recruitmentList = recruitmentList == null ? List.of() : recruitmentList; + workList = workList == null ? List.of() : workList; + boolean noRecruitmentRows = StringUtils.isNull(recruitmentList) || recruitmentList.isEmpty(); + boolean noWorkRows = StringUtils.isNull(workList) || workList.isEmpty(); + if (noRecruitmentRows && noWorkRows) { throw new RuntimeException("至少需要一条数据"); } - // 生成任务ID String taskId = UUID.randomUUID().toString(); long startTime = System.currentTimeMillis(); - - // 获取当前用户名 String userName = SecurityUtils.getUsername(); + int totalCount = recruitmentList.size() + workList.size(); - // 初始化Redis状态 String statusKey = "import:recruitment:" + taskId; Map statusData = new HashMap<>(); statusData.put("taskId", taskId); statusData.put("status", "PROCESSING"); - statusData.put("totalCount", excelList.size()); + statusData.put("totalCount", totalCount); statusData.put("successCount", 0); statusData.put("failureCount", 0); statusData.put("progress", 0); @@ -209,44 +211,7 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer redisTemplate.opsForHash().putAll(statusKey, statusData); redisTemplate.expire(statusKey, 7, TimeUnit.DAYS); - // 调用异步导入服务 - recruitmentImportService.importRecruitmentAsync(excelList, taskId, userName); - - return taskId; - } - - /** - * 导入招聘记录历史工作经历数据(异步) - * - * @param excelList Excel实体列表 - * @return 任务ID - */ - @Override - @Transactional - public String importRecruitmentWork(List excelList) { - if (StringUtils.isNull(excelList) || excelList.isEmpty()) { - throw new RuntimeException("至少需要一条数据"); - } - - String taskId = UUID.randomUUID().toString(); - long startTime = System.currentTimeMillis(); - String userName = SecurityUtils.getUsername(); - - String statusKey = "import:recruitment:" + taskId; - Map statusData = new HashMap<>(); - statusData.put("taskId", taskId); - statusData.put("status", "PROCESSING"); - statusData.put("totalCount", excelList.size()); - statusData.put("successCount", 0); - statusData.put("failureCount", 0); - statusData.put("progress", 0); - statusData.put("startTime", startTime); - statusData.put("message", "正在处理历史工作经历..."); - - redisTemplate.opsForHash().putAll(statusKey, statusData); - redisTemplate.expire(statusKey, 7, TimeUnit.DAYS); - - recruitmentImportService.importRecruitmentWorkAsync(excelList, taskId, userName); + recruitmentImportService.importRecruitmentAsync(recruitmentList, workList, taskId, userName); return taskId; } diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java new file mode 100644 index 00000000..924e2207 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentDualImportContractTest.java @@ -0,0 +1,64 @@ +package com.ruoyi.info.collection.service; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CcdiStaffRecruitmentDualImportContractTest { + + @Test + void shouldExposeSingleDualSheetImportEntry() throws Exception { + String controller = Files.readString( + Path.of("src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java") + ); + assertTrue(controller.contains("\"招聘信息\"")); + assertTrue(controller.contains("\"历史工作经历\"")); + assertFalse(controller.contains("workImportTemplate")); + assertFalse(controller.contains("importWorkData")); + + String service = Files.readString( + Path.of("src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java") + ); + assertTrue(service.contains("String importRecruitment(")); + assertTrue(service.contains("List recruitmentList")); + assertTrue(service.contains("List workList")); + assertFalse(service.contains("importRecruitmentWork(")); + + String importService = Files.readString( + Path.of("src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java") + ); + assertTrue(importService.contains("void importRecruitmentAsync(")); + assertTrue(importService.contains("List recruitmentList")); + assertTrue(importService.contains("List workList")); + assertFalse(importService.contains("importRecruitmentWorkAsync(")); + } + + @Test + void shouldExposeFailureSheetFieldsAndSingleTaskInit() throws Exception { + assertHasField( + "com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO", + "sheetName" + ); + assertHasField( + "com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO", + "sheetRowNum" + ); + + String serviceImpl = Files.readString( + Path.of("src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java") + ); + assertTrue(serviceImpl.contains("recruitmentList.size() + workList.size()")); + assertFalse(serviceImpl.contains("importRecruitmentWork(")); + } + + private void assertHasField(String className, String fieldName) throws Exception { + Class clazz = Class.forName(className); + Field field = clazz.getDeclaredField(fieldName); + assertNotNull(field); + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentImportServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentImportServiceImplTest.java new file mode 100644 index 00000000..2f3561f4 --- /dev/null +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiStaffRecruitmentImportServiceImplTest.java @@ -0,0 +1,98 @@ +package com.ruoyi.info.collection.service; + +import com.ruoyi.info.collection.domain.CcdiStaffRecruitment; +import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel; +import com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO; +import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentMapper; +import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentWorkMapper; +import com.ruoyi.info.collection.service.impl.CcdiStaffRecruitmentImportServiceImpl; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CcdiStaffRecruitmentImportServiceImplTest { + + @InjectMocks + private CcdiStaffRecruitmentImportServiceImpl service; + + @Mock + private CcdiStaffRecruitmentMapper recruitmentMapper; + + @Mock + private CcdiStaffRecruitmentWorkMapper recruitmentWorkMapper; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private HashOperations hashOperations; + + @Test + void shouldFailWholeWorkGroupWhenExistingHistoryExists() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(recruitmentMapper.selectBatchIds(any())).thenReturn(List.of(buildRecruitment("RC001"))); + when(recruitmentWorkMapper.selectCount(any())).thenReturn(1L); + + CcdiStaffRecruitmentWorkExcel workRow = new CcdiStaffRecruitmentWorkExcel(); + workRow.setRecruitId("RC001"); + workRow.setCandName("张三"); + workRow.setRecruitName("社会招聘项目"); + workRow.setPosName("Java工程师"); + workRow.setSortOrder(1); + workRow.setCompanyName("测试科技"); + workRow.setPositionName("开发工程师"); + workRow.setJobStartMonth("2022-01"); + + service.importRecruitmentAsync(Collections.emptyList(), List.of(workRow), "task-1", "admin"); + + verify(recruitmentWorkMapper, never()).delete(any()); + verify(recruitmentWorkMapper, never()).insert(any(com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork.class)); + + ArgumentCaptor failureCaptor = ArgumentCaptor.forClass(Object.class); + verify(valueOperations).set(eq("import:recruitment:task-1:failures"), failureCaptor.capture(), anyLong(), any()); + Object rawFailures = failureCaptor.getValue(); + assertNotNull(rawFailures); + assertInstanceOf(List.class, rawFailures); + List failures = (List) rawFailures; + assertFalse(failures.isEmpty()); + RecruitmentImportFailureVO failure = (RecruitmentImportFailureVO) failures.get(0); + assertEquals("历史工作经历", failure.getSheetName()); + assertEquals("2", failure.getSheetRowNum()); + assertEquals("招聘记录编号[RC001]已存在历史工作经历,不允许重复导入", failure.getErrorMessage()); + } + + private CcdiStaffRecruitment buildRecruitment(String recruitId) { + CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); + recruitment.setRecruitId(recruitId); + recruitment.setRecruitType("SOCIAL"); + recruitment.setCandName("张三"); + recruitment.setRecruitName("社会招聘项目"); + recruitment.setPosName("Java工程师"); + return recruitment; + } +} diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java index 879c4eac..1cb590d2 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java @@ -4,6 +4,8 @@ import com.ruoyi.common.core.domain.entity.SysDictData; import com.ruoyi.common.utils.DictUtils; import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel; import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel; +import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel; +import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel; import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel; import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.DataValidation; @@ -98,6 +100,35 @@ class EasyExcelUtilTemplateTest { } } + @Test + void importTemplateWithDictDropdown_shouldCreateRecruitmentDualSheetTemplate() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + try (MockedStatic mocked = mockStatic(DictUtils.class)) { + mocked.when(() -> DictUtils.getDictCache("ccdi_admit_status")) + .thenReturn(List.of( + buildDictData("录用"), + buildDictData("未录用"), + buildDictData("放弃") + )); + + EasyExcelUtil.importTemplateWithDictDropdown( + response, + CcdiStaffRecruitmentExcel.class, + "招聘信息", + CcdiStaffRecruitmentWorkExcel.class, + "历史工作经历", + "招聘信息管理导入模板" + ); + } + + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) { + assertEquals(2, workbook.getNumberOfSheets(), "招聘导入模板应输出双Sheet"); + assertEquals("招聘信息", workbook.getSheetAt(0).getSheetName()); + assertEquals("历史工作经历", workbook.getSheetAt(1).getSheetName()); + } + } + private void assertTextColumn(Sheet sheet, int columnIndex) { CellStyle style = sheet.getColumnStyle(columnIndex); assertNotNull(style, "文本列应设置默认样式"); diff --git a/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md b/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md new file mode 100644 index 00000000..50103b61 --- /dev/null +++ b/docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md @@ -0,0 +1,84 @@ +# 招聘信息管理双 Sheet 导入实施记录 + +## 文档信息 + +- 保存路径:`docs/reports/implementation/2026-04-23-staff-recruitment-dual-sheet-import-implementation.md` +- 实施日期:2026-04-23 +- 关联范围:招聘信息管理前后端 + +## 本次修改内容 + +### 后端 + +1. 将招聘导入控制器收口为单一模板下载接口与单一导入入口: + - 模板改为 `招聘信息` + `历史工作经历` 双 Sheet; + - 移除独立 `workImportTemplate`、`importWorkData` 入口。 +2. 调整 `ICcdiStaffRecruitmentService`、`ICcdiStaffRecruitmentImportService` 签名,统一为双 Sheet 单任务提交。 +3. 在 `CcdiStaffRecruitmentServiceImpl` 中统一初始化 Redis 任务状态,任务总数按 `recruitmentList.size() + workList.size()` 统计。 +4. 重写 `CcdiStaffRecruitmentImportServiceImpl` 导入编排: + - 主 Sheet 先校验并落库; + - 工作经历 Sheet 按 `recruitId` 分组校验; + - 工作经历既支持匹配“本次主 Sheet 成功数据”,也支持匹配数据库已有招聘主信息; + - 若数据库已存在该招聘记录的历史工作经历,则整组失败,不做覆盖。 +5. 为 `RecruitmentImportFailureVO` 补充 `sheetName`、`sheetRowNum` 字段,失败记录可直接定位到具体 Sheet 和 Excel 行号。 +6. 新增/补充招聘导入回归测试: + - `CcdiStaffRecruitmentDualImportContractTest` + - `CcdiStaffRecruitmentImportServiceImplTest` + - `EasyExcelUtilTemplateTest` + +### 前端 + +1. 招聘信息管理页工具栏只保留一个“导入”按钮,删除独立“导入工作经历”入口。 +2. 上传弹窗文案统一为双 Sheet 模式,模板说明明确为“招聘信息 + 历史工作经历”。 +3. 页面本地状态收口为单任务轮询: + - 只保存一个 `currentTaskId`; + - 删除按导入类型分流的状态与提示文案。 +4. 失败弹窗统一展示: + - `失败Sheet` + - `失败行号` + - `失败原因` + - 以及招聘编号/项目/岗位/候选人/工作单位等辅助字段。 +5. 新增前端静态契约测试: + - `staff-recruitment-import-toolbar.test.js` + - `staff-recruitment-import-state.test.js` + - `staff-recruitment-import-failure-dialog.test.js` + +## 影响范围 + +- 后端:招聘导入模板下载、导入提交、异步导入编排、失败记录返回。 +- 前端:招聘信息管理页导入入口、上传弹窗、导入轮询、失败弹窗。 +- 测试:招聘导入后端定向测试、前端静态契约测试、真实页面 Playwright 验证。 +- 文档:新增本实施记录。 + +## 验证情况 + +1. 后端定向测试通过: + - 命令: + `mvn -pl ccdi-info-collection -am -Dtest=CcdiStaffRecruitmentDualImportContractTest,CcdiStaffRecruitmentImportServiceImplTest,EasyExcelUtilTemplateTest -Dsurefire.failIfNoSpecifiedTests=false test` + - 结果:7 个测试全部通过。 +2. 后端编译通过: + - 命令: + `mvn -pl ccdi-info-collection,ruoyi-admin -am -DskipTests compile` + - 结果:`BUILD SUCCESS`。 +3. 前端静态契约测试通过: + - 命令: + `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && node ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js && node ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js && node ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js` + - 结果:3 个脚本全部通过。 +4. 前端生产构建通过: + - 命令: + `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod` + - 结果:构建成功,仅存在原有体积告警,无新增构建错误。 +5. 真实页面 Playwright 验证通过: + - 从真实登录页进入 `/maintain/staffRecruitment`; + - 在真实导入弹窗中下载双 Sheet 模板; + - 使用真实模板生成并上传 `只导招聘信息 Sheet` 样本,成功新增 `RC202604230901`; + - 使用真实模板生成并上传 `只导历史工作经历 Sheet` 样本,`RC202604230901` 从 `0段` 变为 `1段`; + - 使用真实模板生成并上传 `双 Sheet 同时导入` 样本,成功新增 `RC202604230902` 且直接显示 `1段`; + - 再次上传 `RC202604230902` 的工作经历样本,页面出现失败按钮; + - 打开失败弹窗后,确认展示了 `失败Sheet / 失败行号 / 失败原因`,并看到错误文案: + `招聘记录编号[RC202604230902]已存在历史工作经历,不允许重复导入`。 +6. 测试数据与缓存清理完成: + - 通过后端删除接口清理 `RC202604230901`、`RC202604230902`; + - 再次查询两条测试招聘名称,返回 `total=0`; + - 浏览器侧 `localStorage.staff_recruitment_import_last_task` 已清空; + - 已关闭本轮 Playwright 浏览器、前端 `8080` dev server 和后端 `62318` 进程。 diff --git a/ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue b/ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue index a4a98c5c..63d303ea 100644 --- a/ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue +++ b/ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue @@ -99,25 +99,6 @@ v-hasPermi="['ccdi:staffRecruitment:import']" >导入 - - 导入工作经历 - 导入工作经历 - + + + + + + - - - + + @@ -845,11 +815,9 @@ export default { // 是否显示弹出层 open: false, // 弹出层标题 - title: "", - // 导入类型 - importType: "recruitment", + title: "招聘信息数据导入", // 弹窗提示 - tip: "仅允许导入\"xls\"或\"xlsx\"格式文件。", + tip: "仅允许导入\"xls\"或\"xlsx\"格式文件。模板包含“招聘信息”和“历史工作经历”两个 Sheet。", // 是否禁用上传 isUploading: false, // 设置上传的请求头部 @@ -866,8 +834,6 @@ export default { showFailureButton: false, // 当前导入任务ID currentTaskId: null, - // 当前导入类型 - currentImportType: "recruitment", // 失败记录对话框 failureDialogVisible: false, failureList: [], @@ -886,7 +852,7 @@ export default { lastImportInfo() { const savedTask = this.getImportTaskFromStorage(); if (savedTask && savedTask.totalCount) { - return `导入类型: ${this.getImportTypeLabel(savedTask.importType || 'recruitment')} | 导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}条`; + return `导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}条`; } return ''; } @@ -1163,9 +1129,7 @@ export default { this.handleDetail({ recruitId }); } else if (mode === "edit") { this.handleUpdate({ recruitId }); - } else if (mode === "workImport") { - this.handleWorkImport(); - } else if (mode === "import") { + } else if (mode === "workImport" || mode === "import") { this.handleImport(); } else if (mode === "add") { this.handleAdd(); @@ -1236,24 +1200,13 @@ export default { }, /** 导入按钮操作 */ handleImport() { - this.openImportDialog("recruitment"); - }, - /** 导入工作经历按钮操作 */ - handleWorkImport() { - this.openImportDialog("work"); + this.openImportDialog(); }, /** 打开导入弹窗 */ - openImportDialog(importType) { - const isWorkImport = importType === "work"; - this.upload.importType = importType; - this.currentImportType = importType; - this.upload.title = isWorkImport ? "历史工作经历数据导入" : "招聘信息数据导入"; - this.upload.url = process.env.VUE_APP_BASE_API + (isWorkImport - ? "/ccdi/staffRecruitment/importWorkData" - : "/ccdi/staffRecruitment/importData"); - this.upload.tip = isWorkImport - ? "仅允许导入\"xls\"或\"xlsx\"格式文件;招聘记录编号用于匹配,姓名/项目/职位用于校验。" - : "仅允许导入\"xls\"或\"xlsx\"格式文件。"; + openImportDialog() { + this.upload.title = "招聘信息数据导入"; + this.upload.url = process.env.VUE_APP_BASE_API + "/ccdi/staffRecruitment/importData"; + this.upload.tip = "仅允许导入\"xls\"或\"xlsx\"格式文件。模板包含“招聘信息”和“历史工作经历”两个 Sheet。"; if (this.isPreviewMode()) { this.upload.open = true; return; @@ -1262,11 +1215,7 @@ export default { }, /** 下载模板操作 */ importTemplate() { - if (this.upload.importType === "work") { - this.download('ccdi/staffRecruitment/workImportTemplate', {}, `历史工作经历导入模板_${new Date().getTime()}.xlsx`); - return; - } - this.download('ccdi/staffRecruitment/importTemplate', {}, `招聘信息导入模板_${new Date().getTime()}.xlsx`); + this.download('ccdi/staffRecruitment/importTemplate', {}, `招聘信息管理导入模板_${new Date().getTime()}.xlsx`); }, // 文件上传中处理 handleFileUploadProgress(event, file, fileList) { @@ -1301,19 +1250,17 @@ export default { taskId: taskId, status: 'PROCESSING', timestamp: Date.now(), - hasFailures: false, - importType: this.upload.importType + hasFailures: false }); // 重置状态 this.showFailureButton = false; this.currentTaskId = taskId; - this.currentImportType = this.upload.importType; // 显示后台处理提示 this.$notify({ title: '导入任务已提交', - message: `${this.getImportTypeLabel(this.upload.importType)}正在后台处理中,处理完成后将通知您`, + message: '招聘信息管理导入任务正在后台处理中,处理完成后将通知您', type: 'info', duration: 3000 }); @@ -1361,15 +1308,14 @@ export default { hasFailures: statusResult.failureCount > 0, totalCount: statusResult.totalCount, successCount: statusResult.successCount, - failureCount: statusResult.failureCount, - importType: this.currentImportType + failureCount: statusResult.failureCount }); if (statusResult.status === 'SUCCESS') { // 全部成功 this.$notify({ title: '导入完成', - message: `${this.getImportTypeLabel(this.currentImportType)}全部成功!共导入${statusResult.totalCount}条数据`, + message: `招聘信息管理全部成功!共导入${statusResult.totalCount}条数据`, type: 'success', duration: 5000 }); @@ -1379,7 +1325,7 @@ export default { // 部分失败 this.$notify({ title: '导入完成', - message: `${this.getImportTypeLabel(this.currentImportType)}成功${statusResult.successCount}条,失败${statusResult.failureCount}条`, + message: `招聘信息管理成功${statusResult.successCount}条,失败${statusResult.failureCount}条`, type: 'warning', duration: 5000 }); @@ -1448,7 +1394,6 @@ export default { // 如果有失败记录,恢复按钮显示 if (savedTask.hasFailures && savedTask.taskId) { this.currentTaskId = savedTask.taskId; - this.currentImportType = savedTask.importType || "recruitment"; this.showFailureButton = true; } }, @@ -1458,7 +1403,7 @@ export default { if (savedTask && savedTask.saveTime) { const date = new Date(savedTask.saveTime); const timeStr = this.parseTime(date, '{y}-{m}-{d} {h}:{i}'); - return `上次${this.getImportTypeLabel(savedTask.importType || 'recruitment')}: ${timeStr}`; + return `上次导入: ${timeStr}`; } return ''; }, @@ -1538,7 +1483,7 @@ export default { // 提交上传文件 submitFileForm() { if (this.isPreviewMode()) { - this.$modal.msgSuccess(`预览模式:已模拟提交${this.getImportTypeLabel(this.upload.importType)}`); + this.$modal.msgSuccess("预览模式:已模拟提交招聘信息管理导入"); this.upload.open = false; return; } @@ -1548,10 +1493,6 @@ export default { handleImportDialogClose() { this.upload.isUploading = false; this.$refs.upload.clearFiles(); - }, - /** 导入类型展示 */ - getImportTypeLabel(importType) { - return importType === "work" ? "历史工作经历导入" : "招聘信息导入"; } } }; diff --git a/ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js b/ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js new file mode 100644 index 00000000..d9576582 --- /dev/null +++ b/ruoyi-ui/tests/unit/staff-recruitment-import-failure-dialog.test.js @@ -0,0 +1,21 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const componentPath = path.resolve( + __dirname, + "../../src/views/ccdiStaffRecruitment/index.vue" +); +const source = fs.readFileSync(componentPath, "utf8"); + +[ + 'label="失败Sheet"', + 'label="失败行号"', + "scope.row.sheetName", + "scope.row.sheetRowNum", + "失败原因" +].forEach((token) => { + assert(source.includes(token), `招聘失败弹窗缺少双Sheet定位列: ${token}`); +}); + +console.log("staff-recruitment-import-failure-dialog test passed"); diff --git a/ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js b/ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js new file mode 100644 index 00000000..df67f10b --- /dev/null +++ b/ruoyi-ui/tests/unit/staff-recruitment-import-state.test.js @@ -0,0 +1,28 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const componentPath = path.resolve( + __dirname, + "../../src/views/ccdiStaffRecruitment/index.vue" +); +const source = fs.readFileSync(componentPath, "utf8"); + +[ + "模板包含“招聘信息”和“历史工作经历”两个 Sheet。", + "this.currentTaskId = taskId", + "this.showFailureButton = false", + "this.startImportStatusPolling(taskId)" +].forEach((token) => { + assert(source.includes(token), `招聘导入状态未统一到单任务: ${token}`); +}); + +[ + "currentImportType", + "upload.importType", + "getImportTypeLabel" +].forEach((token) => { + assert(!source.includes(token), `招聘导入状态不应再按类型拆分: ${token}`); +}); + +console.log("staff-recruitment-import-state test passed"); diff --git a/ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js b/ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js new file mode 100644 index 00000000..fd04e931 --- /dev/null +++ b/ruoyi-ui/tests/unit/staff-recruitment-import-toolbar.test.js @@ -0,0 +1,34 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const pagePath = path.resolve( + __dirname, + "../../src/views/ccdiStaffRecruitment/index.vue" +); +const apiPath = path.resolve( + __dirname, + "../../src/api/ccdiStaffRecruitment.js" +); + +const pageSource = fs.readFileSync(pagePath, "utf8"); +const apiSource = fs.readFileSync(apiPath, "utf8"); +const source = `${pageSource}\n${apiSource}`; + +[ + "handleImport()", + '"/ccdi/staffRecruitment/importData"', + "招聘信息管理导入模板" +].forEach((token) => { + assert(source.includes(token), `招聘导入入口缺少统一双Sheet能力: ${token}`); +}); + +[ + "handleWorkImport", + "importWorkData", + "workImportTemplate" +].forEach((token) => { + assert(!source.includes(token), `招聘页不应继续保留独立工作经历导入: ${token}`); +}); + +console.log("staff-recruitment-import-toolbar test passed");