完成中介库导入改造

This commit is contained in:
wkc
2026-04-20 15:17:31 +08:00
parent 60a7906eb3
commit 6385778e4c
31 changed files with 1566 additions and 373 deletions

View File

@@ -2,10 +2,10 @@ package com.ruoyi.info.collection.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.info.collection.domain.dto.*; import com.ruoyi.info.collection.domain.dto.*;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEntityExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel;
import com.ruoyi.info.collection.domain.vo.*; import com.ruoyi.info.collection.domain.vo.*;
import com.ruoyi.info.collection.service.ICcdiIntermediaryEntityImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryService; import com.ruoyi.info.collection.service.ICcdiIntermediaryService;
import com.ruoyi.info.collection.utils.EasyExcelUtil; import com.ruoyi.info.collection.utils.EasyExcelUtil;
@@ -46,7 +46,7 @@ public class CcdiIntermediaryController extends BaseController {
private ICcdiIntermediaryPersonImportService personImportService; private ICcdiIntermediaryPersonImportService personImportService;
@Resource @Resource
private ICcdiIntermediaryEntityImportService entityImportService; private ICcdiIntermediaryEnterpriseRelationImportService enterpriseRelationImportService;
/** /**
* 查询中介列表 * 查询中介列表
@@ -277,10 +277,10 @@ public class CcdiIntermediaryController extends BaseController {
/** /**
* 下载实体中介导入模板 * 下载实体中介导入模板
*/ */
@Operation(summary = "下载实体中介导入模板") @Operation(summary = "下载中介实体关联关系导入模板")
@PostMapping("/importEntityTemplate") @PostMapping("/importEnterpriseRelationTemplate")
public void importEntityTemplate(HttpServletResponse response) { public void importEnterpriseRelationTemplate(HttpServletResponse response) {
EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiIntermediaryEntityExcel.class, "实体中介信息"); EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiIntermediaryEnterpriseRelationExcel.class, "中介实体关联关系信息");
} }
/** /**
@@ -313,20 +313,19 @@ public class CcdiIntermediaryController extends BaseController {
/** /**
* 导入实体中介数据(异步) * 导入实体中介数据(异步)
*/ */
@Operation(summary = "导入实体中介数据") @Operation(summary = "导入中介实体关联关系数据")
@PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')") @PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')")
@Log(title = "实体中介", businessType = BusinessType.IMPORT) @Log(title = "中介实体关联关系", businessType = BusinessType.IMPORT)
@PostMapping("/importEntityData") @PostMapping("/importEnterpriseRelationData")
public AjaxResult importEntityData(MultipartFile file) throws Exception { public AjaxResult importEnterpriseRelationData(MultipartFile file) throws Exception {
List<CcdiIntermediaryEntityExcel> list = EasyExcelUtil.importExcel( List<CcdiIntermediaryEnterpriseRelationExcel> list = EasyExcelUtil.importExcel(
file.getInputStream(), CcdiIntermediaryEntityExcel.class); file.getInputStream(), CcdiIntermediaryEnterpriseRelationExcel.class);
if (list == null || list.isEmpty()) { if (list == null || list.isEmpty()) {
return error("至少需要一条数据"); return error("至少需要一条数据");
} }
// 提交异步任务 String taskId = intermediaryService.importIntermediaryEnterpriseRelation(list);
String taskId = intermediaryService.importIntermediaryEntity(list);
// 立即返回,不等待后台任务完成 // 立即返回,不等待后台任务完成
ImportResultVO result = new ImportResultVO(); ImportResultVO result = new ImportResultVO();
@@ -383,12 +382,12 @@ public class CcdiIntermediaryController extends BaseController {
/** /**
* 查询实体中介导入状态 * 查询实体中介导入状态
*/ */
@Operation(summary = "查询实体中介导入状态") @Operation(summary = "查询中介实体关联关系导入状态")
@PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')") @PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')")
@GetMapping("/importEntityStatus/{taskId}") @GetMapping("/importEnterpriseRelationStatus/{taskId}")
public AjaxResult getEntityImportStatus(@PathVariable String taskId) { public AjaxResult getEnterpriseRelationImportStatus(@PathVariable String taskId) {
try { try {
ImportStatusVO status = entityImportService.getImportStatus(taskId); ImportStatusVO status = enterpriseRelationImportService.getImportStatus(taskId);
return success(status); return success(status);
} catch (Exception e) { } catch (Exception e) {
return error(e.getMessage()); return error(e.getMessage());
@@ -396,18 +395,18 @@ public class CcdiIntermediaryController extends BaseController {
} }
/** /**
* 查询实体中介导入失败记录 * 查询中介实体关联关系导入失败记录
*/ */
@Operation(summary = "查询实体中介导入失败记录") @Operation(summary = "查询中介实体关联关系导入失败记录")
@PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')") @PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')")
@GetMapping("/importEntityFailures/{taskId}") @GetMapping("/importEnterpriseRelationFailures/{taskId}")
public TableDataInfo getEntityImportFailures( public TableDataInfo getEnterpriseRelationImportFailures(
@PathVariable String taskId, @PathVariable String taskId,
@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize) { @RequestParam(defaultValue = "10") Integer pageSize) {
List<IntermediaryEntityImportFailureVO> failures = List<IntermediaryEnterpriseRelationImportFailureVO> failures =
entityImportService.getImportFailures(taskId); enterpriseRelationImportService.getImportFailures(taskId);
// 手动分页 // 手动分页
int fromIndex = (pageNum - 1) * pageSize; int fromIndex = (pageNum - 1) * pageSize;
@@ -418,7 +417,7 @@ public class CcdiIntermediaryController extends BaseController {
return getDataTable(new ArrayList<>(), failures.size()); return getDataTable(new ArrayList<>(), failures.size());
} }
List<IntermediaryEntityImportFailureVO> pageData = failures.subList(fromIndex, toIndex); List<IntermediaryEnterpriseRelationImportFailureVO> pageData = failures.subList(fromIndex, toIndex);
return getDataTable(pageData, failures.size()); return getDataTable(pageData, failures.size());
} }

View File

@@ -63,7 +63,7 @@ public class CcdiBizIntermediary implements Serializable {
/** 职位 */ /** 职位 */
private String position; private String position;
/** 关联人员ID */ /** 关联中介本人证件号码 */
private String relatedNumId; private String relatedNumId;
/** 数据来源MANUAL:手动录入, SYSTEM:系统同步, IMPORT:批量导入, API:接口获取 */ /** 数据来源MANUAL:手动录入, SYSTEM:系统同步, IMPORT:批量导入, API:接口获取 */

View File

@@ -67,8 +67,8 @@ public class CcdiIntermediaryPersonAddDTO implements Serializable {
@Size(max = 100, message = "职位长度不能超过100个字符") @Size(max = 100, message = "职位长度不能超过100个字符")
private String position; private String position;
@Schema(description = "关联人员ID") @Schema(description = "关联中介本人证件号码")
@Size(max = 50, message = "关联人员ID长度不能超过50个字符") @Size(max = 50, message = "关联中介本人证件号码长度不能超过50个字符")
private String relatedNumId; private String relatedNumId;
@Schema(description = "关联关系") @Schema(description = "关联关系")

View File

@@ -70,8 +70,8 @@ public class CcdiIntermediaryPersonEditDTO implements Serializable {
@Size(max = 100, message = "职位长度不能超过100个字符") @Size(max = 100, message = "职位长度不能超过100个字符")
private String position; private String position;
@Schema(description = "关联人员ID") @Schema(description = "关联中介本人证件号码")
@Size(max = 50, message = "关联人员ID长度不能超过50个字符") @Size(max = 50, message = "关联中介本人证件号码长度不能超过50个字符")
private String relatedNumId; private String relatedNumId;
@Schema(description = "关联关系") @Schema(description = "关联关系")

View File

@@ -0,0 +1,38 @@
package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 中介实体关联关系导入对象
*/
@Data
public class CcdiIntermediaryEnterpriseRelationExcel implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 中介本人证件号码 */
@ExcelProperty(value = "中介本人证件号码*", index = 0)
@ColumnWidth(24)
private String ownerPersonId;
/** 统一社会信用代码 */
@ExcelProperty(value = "统一社会信用代码*", index = 1)
@ColumnWidth(24)
private String socialCreditCode;
/** 关联人职务 */
@ExcelProperty(value = "关联人职务", index = 2)
@ColumnWidth(20)
private String relationPersonPost;
/** 备注 */
@ExcelProperty(value = "备注", index = 3)
@ColumnWidth(30)
private String remark;
}

View File

@@ -34,6 +34,7 @@ public class CcdiIntermediaryPersonExcel implements Serializable {
/** 人员子类型 */ /** 人员子类型 */
@ExcelProperty(value = "人员子类型", index = 2) @ExcelProperty(value = "人员子类型", index = 2)
@ColumnWidth(15) @ColumnWidth(15)
@DictDropdown(dictType = "ccdi_person_sub_type")
private String personSubType; private String personSubType;
/** 性别 */ /** 性别 */
@@ -83,19 +84,13 @@ public class CcdiIntermediaryPersonExcel implements Serializable {
@ColumnWidth(15) @ColumnWidth(15)
private String position; private String position;
/** 关联人员ID */ /** 关联中介本人证件号码 */
@ExcelProperty(value = "关联人员ID", index = 12) @ExcelProperty(value = "关联中介本人证件号码", index = 12)
@ColumnWidth(15) @ColumnWidth(24)
private String relatedNumId; private String relatedNumId;
/** 关系类型 */
@ExcelProperty(value = "关系类型", index = 13)
@ColumnWidth(15)
@DictDropdown(dictType = "ccdi_relation_type")
private String relationType;
/** 备注 */ /** 备注 */
@ExcelProperty(value = "备注", index = 14) @ExcelProperty(value = "备注", index = 13)
@ColumnWidth(30) @ColumnWidth(30)
private String remark; private String remark;
} }

View File

@@ -63,7 +63,7 @@ public class CcdiIntermediaryPersonDetailVO implements Serializable {
@Schema(description = "职位") @Schema(description = "职位")
private String position; private String position;
@Schema(description = "关联人员ID") @Schema(description = "关联中介本人证件号码")
private String relatedNumId; private String relatedNumId;
@Schema(description = "关联关系") @Schema(description = "关联关系")

View File

@@ -21,7 +21,7 @@ public class CcdiIntermediaryRelativeVO implements Serializable {
@Schema(description = "人员ID") @Schema(description = "人员ID")
private String bizId; private String bizId;
@Schema(description = "所属中介ID") @Schema(description = "关联中介本人证件号码")
private String relatedNumId; private String relatedNumId;
@Schema(description = "姓名") @Schema(description = "姓名")

View File

@@ -0,0 +1,33 @@
package com.ruoyi.info.collection.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 中介实体关联关系导入失败记录
*/
@Data
@Schema(description = "中介实体关联关系导入失败记录")
public class IntermediaryEnterpriseRelationImportFailureVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "中介本人证件号码")
private String ownerPersonId;
@Schema(description = "统一社会信用代码")
private String socialCreditCode;
@Schema(description = "关联人职务")
private String relationPersonPost;
@Schema(description = "备注")
private String remark;
@Schema(description = "错误信息")
private String errorMessage;
}

View File

@@ -22,21 +22,45 @@ public class IntermediaryPersonImportFailureVO implements Serializable {
@Schema(description = "姓名") @Schema(description = "姓名")
private String name; private String name;
@Schema(description = "证件号码")
private String personId;
@Schema(description = "人员类型") @Schema(description = "人员类型")
private String personType; private String personType;
@Schema(description = "人员子类型")
private String personSubType;
@Schema(description = "性别") @Schema(description = "性别")
private String gender; private String gender;
@Schema(description = "证件类型")
private String idType;
@Schema(description = "证件号码")
private String personId;
@Schema(description = "手机号码") @Schema(description = "手机号码")
private String mobile; private String mobile;
@Schema(description = "微信号")
private String wechatNo;
@Schema(description = "联系地址")
private String contactAddress;
@Schema(description = "所在公司") @Schema(description = "所在公司")
private String company; private String company;
@Schema(description = "企业统一信用码")
private String socialCreditCode;
@Schema(description = "职位")
private String position;
@Schema(description = "关联中介本人证件号码")
private String relatedNumId;
@Schema(description = "备注")
private String remark;
@Schema(description = "错误信息") @Schema(description = "错误信息")
private String errorMessage; private String errorMessage;
} }

View File

@@ -14,10 +14,14 @@ import java.util.List;
@Mapper @Mapper
public interface CcdiIntermediaryEnterpriseRelationMapper extends BaseMapper<CcdiIntermediaryEnterpriseRelation> { public interface CcdiIntermediaryEnterpriseRelationMapper extends BaseMapper<CcdiIntermediaryEnterpriseRelation> {
int insertBatch(@Param("list") List<CcdiIntermediaryEnterpriseRelation> list);
List<CcdiIntermediaryEnterpriseRelationVO> selectByIntermediaryBizId(@Param("bizId") String bizId); List<CcdiIntermediaryEnterpriseRelationVO> selectByIntermediaryBizId(@Param("bizId") String bizId);
CcdiIntermediaryEnterpriseRelationVO selectDetailById(@Param("id") Long id); CcdiIntermediaryEnterpriseRelationVO selectDetailById(@Param("id") Long id);
boolean existsByIntermediaryBizIdAndSocialCreditCode(@Param("bizId") String bizId, boolean existsByIntermediaryBizIdAndSocialCreditCode(@Param("bizId") String bizId,
@Param("socialCreditCode") String socialCreditCode); @Param("socialCreditCode") String socialCreditCode);
List<String> batchExistsByCombinations(@Param("combinations") List<String> combinations);
} }

View File

@@ -0,0 +1,38 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO;
import java.util.List;
/**
* 中介实体关联关系异步导入服务接口
*/
public interface ICcdiIntermediaryEnterpriseRelationImportService {
/**
* 异步导入中介实体关联关系
*
* @param excelList Excel数据
* @param taskId 任务ID
* @param userName 当前用户名
*/
void importAsync(List<CcdiIntermediaryEnterpriseRelationExcel> excelList, String taskId, String userName);
/**
* 查询导入状态
*
* @param taskId 任务ID
* @return 导入状态
*/
ImportStatusVO getImportStatus(String taskId);
/**
* 查询导入失败记录
*
* @param taskId 任务ID
* @return 失败记录
*/
List<IntermediaryEnterpriseRelationImportFailureVO> getImportFailures(String taskId);
}

View File

@@ -7,7 +7,7 @@ import com.ruoyi.info.collection.domain.vo.IntermediaryPersonImportFailureVO;
import java.util.List; import java.util.List;
/** /**
* 个人中介异步导入Service接口 * 中介信息异步导入Service接口
* *
* @author ruoyi * @author ruoyi
* @date 2026-02-06 * @date 2026-02-06
@@ -15,7 +15,7 @@ import java.util.List;
public interface ICcdiIntermediaryPersonImportService { public interface ICcdiIntermediaryPersonImportService {
/** /**
* 异步导入个人中介数据 * 异步导入中介信息
* *
* @param excelList Excel数据列表 * @param excelList Excel数据列表
* @param taskId 任务ID * @param taskId 任务ID

View File

@@ -2,6 +2,7 @@ package com.ruoyi.info.collection.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.info.collection.domain.dto.*; import com.ruoyi.info.collection.domain.dto.*;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEntityExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEntityExcel;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel;
import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEnterpriseRelationVO; import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEnterpriseRelationVO;
@@ -168,7 +169,7 @@ public interface ICcdiIntermediaryService {
int deleteIntermediaryByIds(String[] ids); int deleteIntermediaryByIds(String[] ids);
/** /**
* 校验人员ID唯一性 * 校验中介本人证件号码唯一性
* *
* @param personId 人员ID * @param personId 人员ID
* @param bizId 排除的人员ID * @param bizId 排除的人员ID
@@ -193,6 +194,14 @@ public interface ICcdiIntermediaryService {
*/ */
String importIntermediaryPerson(List<CcdiIntermediaryPersonExcel> list); String importIntermediaryPerson(List<CcdiIntermediaryPersonExcel> list);
/**
* 导入中介实体关联关系
*
* @param list Excel实体列表
* @return 任务ID
*/
String importIntermediaryEnterpriseRelation(List<CcdiIntermediaryEnterpriseRelationExcel> list);
/** /**
* 导入实体中介数据 * 导入实体中介数据
* *

View File

@@ -0,0 +1,266 @@
package com.ruoyi.info.collection.service.impl;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.info.collection.domain.CcdiBizIntermediary;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.vo.ImportResult;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO;
import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService;
import com.ruoyi.info.collection.utils.ImportLogUtils;
import com.ruoyi.common.utils.IdCardUtil;
import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 中介实体关联关系异步导入实现
*/
@Service
@EnableAsync
public class CcdiIntermediaryEnterpriseRelationImportServiceImpl implements ICcdiIntermediaryEnterpriseRelationImportService {
private static final Logger log = LoggerFactory.getLogger(CcdiIntermediaryEnterpriseRelationImportServiceImpl.class);
private static final String STATUS_KEY_PREFIX = "import:intermediary-enterprise-relation:";
@Resource
private CcdiIntermediaryEnterpriseRelationMapper relationMapper;
@Resource
private CcdiBizIntermediaryMapper intermediaryMapper;
@Resource
private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override
@Async
@Transactional(rollbackFor = Exception.class)
public void importAsync(List<CcdiIntermediaryEnterpriseRelationExcel> excelList, String taskId, String userName) {
long startTime = System.currentTimeMillis();
ImportLogUtils.logImportStart(log, taskId, "中介实体关联关系", excelList.size(), userName);
Map<String, String> ownerBizIdByPersonId = getOwnerBizIdByPersonId(excelList);
Set<String> existingEnterpriseCodes = getExistingEnterpriseCodes(excelList);
Set<String> existingCombinations = getExistingRelationCombinations(ownerBizIdByPersonId, excelList);
List<CcdiIntermediaryEnterpriseRelation> successRecords = new ArrayList<>();
List<IntermediaryEnterpriseRelationImportFailureVO> failures = new ArrayList<>();
Set<String> processedCombinations = new HashSet<>();
for (int i = 0; i < excelList.size(); i++) {
CcdiIntermediaryEnterpriseRelationExcel excel = excelList.get(i);
try {
validateExcel(excel);
String ownerBizId = ownerBizIdByPersonId.get(excel.getOwnerPersonId());
if (StringUtils.isEmpty(ownerBizId)) {
throw new RuntimeException("中介本人不存在,请先导入或维护中介本人信息");
}
if (!existingEnterpriseCodes.contains(excel.getSocialCreditCode())) {
throw new RuntimeException("统一社会信用代码不存在于系统机构表");
}
String combination = ownerBizId + "|" + excel.getSocialCreditCode();
if (existingCombinations.contains(combination)) {
throw new RuntimeException("中介实体关联关系已存在,请勿重复导入");
}
if (!processedCombinations.add(combination)) {
throw new RuntimeException("同一中介本人与统一社会信用代码组合在导入文件中重复");
}
CcdiIntermediaryEnterpriseRelation relation = new CcdiIntermediaryEnterpriseRelation();
BeanUtils.copyProperties(excel, relation);
relation.setIntermediaryBizId(ownerBizId);
relation.setCreatedBy(userName);
relation.setUpdatedBy(userName);
successRecords.add(relation);
} catch (Exception e) {
failures.add(createFailureVO(excel, e.getMessage()));
ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(),
String.format("中介本人证件号码=%s, 统一社会信用代码=%s", excel.getOwnerPersonId(), excel.getSocialCreditCode()));
}
}
if (!successRecords.isEmpty()) {
saveBatch(successRecords, 500);
}
if (!failures.isEmpty()) {
redisTemplate.opsForValue().set(failureKey(taskId), failures, 7, TimeUnit.DAYS);
}
ImportResult result = new ImportResult();
result.setTotalCount(excelList.size());
result.setSuccessCount(successRecords.size());
result.setFailureCount(failures.size());
updateImportStatus(taskId, result);
long duration = System.currentTimeMillis() - startTime;
ImportLogUtils.logImportComplete(log, taskId, "中介实体关联关系",
excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration);
}
@Override
public ImportStatusVO getImportStatus(String taskId) {
String key = statusKey(taskId);
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
throw new RuntimeException("任务不存在或已过期");
}
Map<Object, Object> statusMap = redisTemplate.opsForHash().entries(key);
ImportStatusVO statusVO = new ImportStatusVO();
statusVO.setTaskId((String) statusMap.get("taskId"));
statusVO.setStatus((String) statusMap.get("status"));
statusVO.setTotalCount((Integer) statusMap.get("totalCount"));
statusVO.setSuccessCount((Integer) statusMap.get("successCount"));
statusVO.setFailureCount((Integer) statusMap.get("failureCount"));
statusVO.setProgress((Integer) statusMap.get("progress"));
statusVO.setStartTime((Long) statusMap.get("startTime"));
statusVO.setEndTime((Long) statusMap.get("endTime"));
statusVO.setMessage((String) statusMap.get("message"));
return statusVO;
}
@Override
public List<IntermediaryEnterpriseRelationImportFailureVO> getImportFailures(String taskId) {
Object failuresObj = redisTemplate.opsForValue().get(failureKey(taskId));
if (failuresObj == null) {
return Collections.emptyList();
}
return JSON.parseArray(JSON.toJSONString(failuresObj), IntermediaryEnterpriseRelationImportFailureVO.class);
}
private Map<String, String> getOwnerBizIdByPersonId(List<CcdiIntermediaryEnterpriseRelationExcel> excelList) {
List<String> ownerPersonIds = excelList.stream()
.map(CcdiIntermediaryEnterpriseRelationExcel::getOwnerPersonId)
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
if (ownerPersonIds.isEmpty()) {
return Collections.emptyMap();
}
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiBizIntermediary::getPersonSubType, "本人")
.in(CcdiBizIntermediary::getPersonId, ownerPersonIds);
return intermediaryMapper.selectList(wrapper).stream()
.collect(Collectors.toMap(CcdiBizIntermediary::getPersonId, CcdiBizIntermediary::getBizId, (left, right) -> left));
}
private Set<String> getExistingEnterpriseCodes(List<CcdiIntermediaryEnterpriseRelationExcel> excelList) {
List<String> socialCreditCodes = excelList.stream()
.map(CcdiIntermediaryEnterpriseRelationExcel::getSocialCreditCode)
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
if (socialCreditCodes.isEmpty()) {
return Collections.emptySet();
}
LambdaQueryWrapper<CcdiEnterpriseBaseInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.in(CcdiEnterpriseBaseInfo::getSocialCreditCode, socialCreditCodes);
return enterpriseBaseInfoMapper.selectList(wrapper).stream()
.map(CcdiEnterpriseBaseInfo::getSocialCreditCode)
.collect(Collectors.toSet());
}
private Set<String> getExistingRelationCombinations(Map<String, String> ownerBizIdByPersonId,
List<CcdiIntermediaryEnterpriseRelationExcel> excelList) {
List<String> combinations = excelList.stream()
.map(excel -> {
String ownerBizId = ownerBizIdByPersonId.get(excel.getOwnerPersonId());
if (StringUtils.isEmpty(ownerBizId) || StringUtils.isEmpty(excel.getSocialCreditCode())) {
return null;
}
return ownerBizId + "|" + excel.getSocialCreditCode();
})
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
if (combinations.isEmpty()) {
return Collections.emptySet();
}
return new HashSet<>(relationMapper.batchExistsByCombinations(combinations));
}
private void validateExcel(CcdiIntermediaryEnterpriseRelationExcel excel) {
if (StringUtils.isEmpty(excel.getOwnerPersonId())) {
throw new RuntimeException("中介本人证件号码不能为空");
}
if (StringUtils.isEmpty(excel.getSocialCreditCode())) {
throw new RuntimeException("统一社会信用代码不能为空");
}
String ownerPersonIdError = IdCardUtil.getErrorMessage(excel.getOwnerPersonId());
if (ownerPersonIdError != null) {
throw new RuntimeException("中介本人证件号码" + ownerPersonIdError);
}
if (StringUtils.isNotEmpty(excel.getRelationPersonPost()) && excel.getRelationPersonPost().length() > 100) {
throw new RuntimeException("关联人职务长度不能超过100个字符");
}
if (StringUtils.isNotEmpty(excel.getRemark()) && excel.getRemark().length() > 500) {
throw new RuntimeException("备注长度不能超过500个字符");
}
}
private IntermediaryEnterpriseRelationImportFailureVO createFailureVO(CcdiIntermediaryEnterpriseRelationExcel excel,
String errorMessage) {
IntermediaryEnterpriseRelationImportFailureVO failure = new IntermediaryEnterpriseRelationImportFailureVO();
BeanUtils.copyProperties(excel, failure);
failure.setErrorMessage(errorMessage);
return failure;
}
private void saveBatch(List<CcdiIntermediaryEnterpriseRelation> list, int batchSize) {
for (int i = 0; i < list.size(); i += batchSize) {
int end = Math.min(i + batchSize, list.size());
relationMapper.insertBatch(list.subList(i, end));
}
}
private void updateImportStatus(String taskId, ImportResult result) {
Map<String, Object> statusData = new HashMap<>();
statusData.put("status", result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS");
statusData.put("successCount", result.getSuccessCount());
statusData.put("failureCount", result.getFailureCount());
statusData.put("progress", 100);
statusData.put("endTime", System.currentTimeMillis());
statusData.put("message", result.getFailureCount() == 0
? "全部成功!共导入" + result.getTotalCount() + "条数据"
: "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "");
redisTemplate.opsForHash().putAll(statusKey(taskId), statusData);
}
private String statusKey(String taskId) {
return STATUS_KEY_PREFIX + taskId;
}
private String failureKey(String taskId) {
return statusKey(taskId) + ":failures";
}
}

View File

@@ -22,15 +22,18 @@ import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.*; import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 个人中介异步导入Service实现 * 中介信息异步导入实现
*
* @author ruoyi
* @date 2026-02-06
*/ */
@Service @Service
@EnableAsync @EnableAsync
@@ -38,6 +41,8 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar
private static final Logger log = LoggerFactory.getLogger(CcdiIntermediaryPersonImportServiceImpl.class); private static final Logger log = LoggerFactory.getLogger(CcdiIntermediaryPersonImportServiceImpl.class);
private static final String STATUS_KEY_PREFIX = "import:intermediary:";
@Resource @Resource
private CcdiBizIntermediaryMapper intermediaryMapper; private CcdiBizIntermediaryMapper intermediaryMapper;
@@ -47,110 +52,104 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar
@Override @Override
@Async @Async
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void importPersonAsync(List<CcdiIntermediaryPersonExcel> excelList, public void importPersonAsync(List<CcdiIntermediaryPersonExcel> excelList, String taskId, String userName) {
String taskId,
String userName) {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
ImportLogUtils.logImportStart(log, taskId, "中介信息", excelList.size(), userName);
// 记录导入开始 List<CcdiIntermediaryPersonExcel> ownerRows = new ArrayList<>();
ImportLogUtils.logImportStart(log, taskId, "个人中介", excelList.size(), userName); List<CcdiIntermediaryPersonExcel> relativeRows = new ArrayList<>();
List<CcdiBizIntermediary> newRecords = new ArrayList<>();
List<IntermediaryPersonImportFailureVO> failures = new ArrayList<>(); List<IntermediaryPersonImportFailureVO> failures = new ArrayList<>();
// 批量查询已存在的证件号
ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的证件号", excelList.size());
Set<String> existingPersonIds = getExistingPersonIds(excelList);
ImportLogUtils.logBatchQueryComplete(log, taskId, "证件号", existingPersonIds.size());
// 用于检测Excel内部的重复ID
Set<String> excelProcessedIds = new HashSet<>();
// 分类数据
for (int i = 0; i < excelList.size(); i++) { for (int i = 0; i < excelList.size(); i++) {
CcdiIntermediaryPersonExcel excel = excelList.get(i); CcdiIntermediaryPersonExcel excel = excelList.get(i);
try { try {
// 验证数据 validateCommonRow(excel);
validatePersonData(excel, existingPersonIds); if (isOwnerRow(excel)) {
validateOwnerRow(excel);
CcdiBizIntermediary intermediary = new CcdiBizIntermediary(); ownerRows.add(excel);
BeanUtils.copyProperties(excel, intermediary);
// 设置数据来源和审计字段
intermediary.setDataSource("IMPORT");
intermediary.setCreatedBy(userName);
intermediary.setUpdatedBy(userName);
if (existingPersonIds.contains(excel.getPersonId())) {
// 证件号码在数据库中已存在,直接报错
throw new RuntimeException(String.format("证件号码[%s]已存在,请勿重复导入", excel.getPersonId()));
} else if (excelProcessedIds.contains(excel.getPersonId())) {
// 证件号码在Excel文件内部重复
throw new RuntimeException(String.format("证件号码[%s]在导入文件中重复,已跳过此条记录", excel.getPersonId()));
} else { } else {
newRecords.add(intermediary); validateRelativeRow(excel);
excelProcessedIds.add(excel.getPersonId()); // 标记为已处理 relativeRows.add(excel);
} }
// 记录进度
ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(),
newRecords.size(), failures.size());
} catch (Exception e) { } catch (Exception e) {
failures.add(createFailureVO(excel, e.getMessage())); failures.add(createFailureVO(excel, e.getMessage()));
ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(),
// 记录验证失败日志 String.format("姓名=%s, 证件号码=%s", excel.getName(), excel.getPersonId()));
String keyData = String.format("姓名=%s, 证件号码=%s",
excel.getName(), excel.getPersonId());
ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData);
} }
} }
// 批量插入新数据 Set<String> existingOwnerPersonIds = getExistingOwnerPersonIds(ownerRows);
if (!newRecords.isEmpty()) { Set<String> existingOwnerRefs = getExistingOwnerRefs(relativeRows);
ImportLogUtils.logBatchOperationStart(log, taskId, "插入", Set<String> existingRelativeCombinations = getExistingRelativeCombinations(relativeRows);
(newRecords.size() + 499) / 500, 500);
saveBatch(newRecords, 500);
}
// 保存失败记录到Redis List<CcdiBizIntermediary> successRecords = new ArrayList<>();
if (!failures.isEmpty()) { Set<String> importedOwnerPersonIds = new HashSet<>();
for (CcdiIntermediaryPersonExcel ownerExcel : ownerRows) {
try { try {
String failuresKey = "import:intermediary:" + taskId + ":failures"; String ownerPersonId = ownerExcel.getPersonId();
redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); if (existingOwnerPersonIds.contains(ownerPersonId)) {
ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size()); throw new RuntimeException(String.format("中介本人证件号码[%s]已存在,请勿重复导入", ownerPersonId));
}
if (!importedOwnerPersonIds.add(ownerPersonId)) {
throw new RuntimeException(String.format("中介本人证件号码[%s]在导入文件中重复", ownerPersonId));
}
successRecords.add(buildRecord(ownerExcel, userName, null));
} catch (Exception e) { } catch (Exception e) {
ImportLogUtils.logRedisError(log, taskId, "保存失败记录", e); failures.add(createFailureVO(ownerExcel, e.getMessage()));
} }
} }
Set<String> validOwnerRefs = new HashSet<>(existingOwnerRefs);
validOwnerRefs.addAll(importedOwnerPersonIds);
Set<String> processedRelativeCombinations = new HashSet<>();
for (CcdiIntermediaryPersonExcel relativeExcel : relativeRows) {
try {
String ownerPersonId = relativeExcel.getRelatedNumId();
String combination = ownerPersonId + "|" + relativeExcel.getPersonId();
if (!validOwnerRefs.contains(ownerPersonId)) {
throw new RuntimeException(String.format("关联中介本人证件号码[%s]不存在", ownerPersonId));
}
if (existingRelativeCombinations.contains(combination)) {
throw new RuntimeException(String.format("同一中介本人名下证件号码[%s]的亲属已存在,请勿重复导入", relativeExcel.getPersonId()));
}
if (!processedRelativeCombinations.add(combination)) {
throw new RuntimeException(String.format("同一中介本人名下证件号码[%s]的亲属在导入文件中重复", relativeExcel.getPersonId()));
}
successRecords.add(buildRecord(relativeExcel, userName, ownerPersonId));
} catch (Exception e) {
failures.add(createFailureVO(relativeExcel, e.getMessage()));
}
}
if (!successRecords.isEmpty()) {
saveBatch(successRecords, 500);
}
if (!failures.isEmpty()) {
redisTemplate.opsForValue().set(failureKey(taskId), failures, 7, TimeUnit.DAYS);
}
ImportResult result = new ImportResult(); ImportResult result = new ImportResult();
result.setTotalCount(excelList.size()); result.setTotalCount(excelList.size());
result.setSuccessCount(newRecords.size()); result.setSuccessCount(successRecords.size());
result.setFailureCount(failures.size()); result.setFailureCount(failures.size());
updateImportStatus(taskId, result);
// 更新最终状态
String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS";
updateImportStatus(taskId, finalStatus, result);
// 记录导入完成
long duration = System.currentTimeMillis() - startTime; long duration = System.currentTimeMillis() - startTime;
ImportLogUtils.logImportComplete(log, taskId, "个人中介", ImportLogUtils.logImportComplete(log, taskId, "中介信息",
excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration);
} }
@Override @Override
public ImportStatusVO getImportStatus(String taskId) { public ImportStatusVO getImportStatus(String taskId) {
String key = "import:intermediary:" + taskId; String key = statusKey(taskId);
Boolean hasKey = redisTemplate.hasKey(key); if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
if (Boolean.FALSE.equals(hasKey)) {
throw new RuntimeException("任务不存在或已过期"); throw new RuntimeException("任务不存在或已过期");
} }
Map<Object, Object> statusMap = redisTemplate.opsForHash().entries(key); Map<Object, Object> statusMap = redisTemplate.opsForHash().entries(key);
ImportStatusVO statusVO = new ImportStatusVO(); ImportStatusVO statusVO = new ImportStatusVO();
statusVO.setTaskId((String) statusMap.get("taskId")); statusVO.setTaskId((String) statusMap.get("taskId"));
statusVO.setStatus((String) statusMap.get("status")); statusVO.setStatus((String) statusMap.get("status"));
@@ -161,83 +160,120 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar
statusVO.setStartTime((Long) statusMap.get("startTime")); statusVO.setStartTime((Long) statusMap.get("startTime"));
statusVO.setEndTime((Long) statusMap.get("endTime")); statusVO.setEndTime((Long) statusMap.get("endTime"));
statusVO.setMessage((String) statusMap.get("message")); statusVO.setMessage((String) statusMap.get("message"));
return statusVO; return statusVO;
} }
@Override @Override
public List<IntermediaryPersonImportFailureVO> getImportFailures(String taskId) { public List<IntermediaryPersonImportFailureVO> getImportFailures(String taskId) {
String key = "import:intermediary:" + taskId + ":failures"; Object failuresObj = redisTemplate.opsForValue().get(failureKey(taskId));
Object failuresObj = redisTemplate.opsForValue().get(key);
if (failuresObj == null) { if (failuresObj == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
return JSON.parseArray(JSON.toJSONString(failuresObj), IntermediaryPersonImportFailureVO.class); return JSON.parseArray(JSON.toJSONString(failuresObj), IntermediaryPersonImportFailureVO.class);
} }
/** private boolean isOwnerRow(CcdiIntermediaryPersonExcel excel) {
* 批量查询已存在的证件号 return "本人".equals(excel.getPersonSubType());
*/ }
private Set<String> getExistingPersonIds(List<CcdiIntermediaryPersonExcel> excelList) {
List<String> personIds = excelList.stream()
.map(CcdiIntermediaryPersonExcel::getPersonId)
.filter(StringUtils::isNotEmpty)
.collect(Collectors.toList());
if (personIds.isEmpty()) { private void validateCommonRow(CcdiIntermediaryPersonExcel excel) {
if (StringUtils.isEmpty(excel.getName())) {
throw new RuntimeException("姓名不能为空");
}
if (StringUtils.isEmpty(excel.getPersonSubType())) {
throw new RuntimeException("人员子类型不能为空");
}
if (StringUtils.isEmpty(excel.getPersonId())) {
throw new RuntimeException("证件号码不能为空");
}
String idCardError = IdCardUtil.getErrorMessage(excel.getPersonId());
if (idCardError != null) {
throw new RuntimeException("证件号码" + idCardError);
}
}
private void validateOwnerRow(CcdiIntermediaryPersonExcel excel) {
if (StringUtils.isNotEmpty(excel.getRelatedNumId())) {
throw new RuntimeException("本人行关联中介本人证件号码必须为空");
}
}
private void validateRelativeRow(CcdiIntermediaryPersonExcel excel) {
if (StringUtils.isEmpty(excel.getRelatedNumId())) {
throw new RuntimeException("亲属行必须填写关联中介本人证件号码");
}
}
private Set<String> getExistingOwnerPersonIds(List<CcdiIntermediaryPersonExcel> ownerRows) {
List<String> ownerPersonIds = ownerRows.stream()
.map(CcdiIntermediaryPersonExcel::getPersonId)
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
if (ownerPersonIds.isEmpty()) {
return Collections.emptySet(); return Collections.emptySet();
} }
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.in(CcdiBizIntermediary::getPersonId, personIds); wrapper.eq(CcdiBizIntermediary::getPersonSubType, "本人")
List<CcdiBizIntermediary> existingIntermediaries = intermediaryMapper.selectList(wrapper); .in(CcdiBizIntermediary::getPersonId, ownerPersonIds);
return intermediaryMapper.selectList(wrapper).stream()
return existingIntermediaries.stream() .map(CcdiBizIntermediary::getPersonId)
.map(CcdiBizIntermediary::getPersonId) .collect(Collectors.toSet());
.collect(Collectors.toSet());
} }
/** private Set<String> getExistingOwnerRefs(List<CcdiIntermediaryPersonExcel> relativeRows) {
* 批量保存(使用ON DUPLICATE KEY UPDATE) List<String> ownerRefs = relativeRows.stream()
*/ .map(CcdiIntermediaryPersonExcel::getRelatedNumId)
private int saveBatchWithUpsert(List<CcdiBizIntermediary> list, int batchSize) { .filter(StringUtils::isNotEmpty)
int totalCount = 0; .distinct()
for (int i = 0; i < list.size(); i += batchSize) { .collect(Collectors.toList());
int end = Math.min(i + batchSize, list.size()); if (ownerRefs.isEmpty()) {
List<CcdiBizIntermediary> subList = list.subList(i, end);
int count = intermediaryMapper.importPersonBatch(subList);
totalCount += count;
}
return totalCount;
}
/**
* 从数据库获取已存在的证件号
*/
private Set<String> getExistingPersonIdsFromDb(List<CcdiBizIntermediary> records) {
List<String> personIds = records.stream()
.map(CcdiBizIntermediary::getPersonId)
.filter(StringUtils::isNotEmpty)
.collect(Collectors.toList());
if (personIds.isEmpty()) {
return Collections.emptySet(); return Collections.emptySet();
} }
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.in(CcdiBizIntermediary::getPersonId, personIds); wrapper.eq(CcdiBizIntermediary::getPersonSubType, "本人")
List<CcdiBizIntermediary> existing = intermediaryMapper.selectList(wrapper); .in(CcdiBizIntermediary::getPersonId, ownerRefs);
return intermediaryMapper.selectList(wrapper).stream()
return existing.stream() .map(CcdiBizIntermediary::getPersonId)
.map(CcdiBizIntermediary::getPersonId) .collect(Collectors.toSet());
.collect(Collectors.toSet()); }
private Set<String> getExistingRelativeCombinations(List<CcdiIntermediaryPersonExcel> relativeRows) {
List<String> ownerRefs = relativeRows.stream()
.map(CcdiIntermediaryPersonExcel::getRelatedNumId)
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
List<String> relativePersonIds = relativeRows.stream()
.map(CcdiIntermediaryPersonExcel::getPersonId)
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
if (ownerRefs.isEmpty() || relativePersonIds.isEmpty()) {
return Collections.emptySet();
}
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.ne(CcdiBizIntermediary::getPersonSubType, "本人")
.in(CcdiBizIntermediary::getRelatedNumId, ownerRefs)
.in(CcdiBizIntermediary::getPersonId, relativePersonIds);
return intermediaryMapper.selectList(wrapper).stream()
.map(item -> item.getRelatedNumId() + "|" + item.getPersonId())
.collect(Collectors.toSet());
}
private CcdiBizIntermediary buildRecord(CcdiIntermediaryPersonExcel excel, String userName, String ownerPersonId) {
CcdiBizIntermediary intermediary = new CcdiBizIntermediary();
BeanUtils.copyProperties(excel, intermediary);
intermediary.setRelatedNumId(ownerPersonId);
intermediary.setDataSource("IMPORT");
intermediary.setCreatedBy(userName);
intermediary.setUpdatedBy(userName);
return intermediary;
} }
/**
* 创建失败记录VO
*/
private IntermediaryPersonImportFailureVO createFailureVO(CcdiIntermediaryPersonExcel excel, String errorMsg) { private IntermediaryPersonImportFailureVO createFailureVO(CcdiIntermediaryPersonExcel excel, String errorMsg) {
IntermediaryPersonImportFailureVO failure = new IntermediaryPersonImportFailureVO(); IntermediaryPersonImportFailureVO failure = new IntermediaryPersonImportFailureVO();
BeanUtils.copyProperties(excel, failure); BeanUtils.copyProperties(excel, failure);
@@ -245,73 +281,31 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar
return failure; return failure;
} }
/** private void saveBatch(List<CcdiBizIntermediary> list, int batchSize) {
* 创建失败记录VO(重载方法)
*/
private IntermediaryPersonImportFailureVO createFailureVO(CcdiBizIntermediary record, String errorMsg) {
CcdiIntermediaryPersonExcel excel = new CcdiIntermediaryPersonExcel();
BeanUtils.copyProperties(record, excel);
return createFailureVO(excel, errorMsg);
}
/**
* 批量保存
*/
private int saveBatch(List<CcdiBizIntermediary> list, int batchSize) {
// 使用真正的批量插入,分批次执行以提高性能
int totalCount = 0;
for (int i = 0; i < list.size(); i += batchSize) { for (int i = 0; i < list.size(); i += batchSize) {
int end = Math.min(i + batchSize, list.size()); int end = Math.min(i + batchSize, list.size());
List<CcdiBizIntermediary> subList = list.subList(i, end); intermediaryMapper.insertBatch(list.subList(i, end));
int count = intermediaryMapper.insertBatch(subList);
totalCount += count;
} }
return totalCount;
} }
/** private void updateImportStatus(String taskId, ImportResult result) {
* 更新导入状态
*/
private void updateImportStatus(String taskId, String status, ImportResult result) {
String key = "import:intermediary:" + taskId;
Map<String, Object> statusData = new HashMap<>(); Map<String, Object> statusData = new HashMap<>();
statusData.put("status", status); statusData.put("status", result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS");
statusData.put("successCount", result.getSuccessCount()); statusData.put("successCount", result.getSuccessCount());
statusData.put("failureCount", result.getFailureCount()); statusData.put("failureCount", result.getFailureCount());
statusData.put("progress", 100); statusData.put("progress", 100);
statusData.put("endTime", System.currentTimeMillis()); statusData.put("endTime", System.currentTimeMillis());
statusData.put("message", result.getFailureCount() == 0
if ("SUCCESS".equals(status)) { ? "全部成功!共导入" + result.getTotalCount() + "条数据"
statusData.put("message", "全部成功!共导入" + result.getTotalCount() + "数据"); : "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "");
} else { redisTemplate.opsForHash().putAll(statusKey(taskId), statusData);
statusData.put("message", "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "");
}
redisTemplate.opsForHash().putAll(key, statusData);
} }
/** private String statusKey(String taskId) {
* 验证个人中介数据 return STATUS_KEY_PREFIX + taskId;
* }
* @param excel Excel数据
* @param existingPersonIds 已存在的证件号集合
*/
private void validatePersonData(CcdiIntermediaryPersonExcel excel,
Set<String> existingPersonIds) {
// 验证必填字段:姓名
if (StringUtils.isEmpty(excel.getName())) {
throw new RuntimeException("姓名不能为空");
}
// 验证必填字段:证件号码 private String failureKey(String taskId) {
if (StringUtils.isEmpty(excel.getPersonId())) { return statusKey(taskId) + ":failures";
throw new RuntimeException("证件号码不能为空");
}
// 验证证件号码格式
String idCardError = IdCardUtil.getErrorMessage(excel.getPersonId());
if (idCardError != null) {
throw new RuntimeException("证件号码" + idCardError);
}
} }
} }

View File

@@ -6,6 +6,7 @@ import com.ruoyi.info.collection.domain.CcdiBizIntermediary;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo; import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation; import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation;
import com.ruoyi.info.collection.domain.dto.*; import com.ruoyi.info.collection.domain.dto.*;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEntityExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEntityExcel;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel;
import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEnterpriseRelationVO; import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEnterpriseRelationVO;
@@ -17,6 +18,7 @@ import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper; import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryMapper; import com.ruoyi.info.collection.mapper.CcdiIntermediaryMapper;
import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryEntityImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryEntityImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryService; import com.ruoyi.info.collection.service.ICcdiIntermediaryService;
@@ -61,6 +63,9 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
@Resource @Resource
private ICcdiIntermediaryEntityImportService entityImportService; private ICcdiIntermediaryEntityImportService entityImportService;
@Resource
private ICcdiIntermediaryEnterpriseRelationImportService enterpriseRelationImportService;
@Resource @Resource
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
@@ -101,8 +106,9 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
@Override @Override
public List<CcdiIntermediaryRelativeVO> selectIntermediaryRelativeList(String bizId) { public List<CcdiIntermediaryRelativeVO> selectIntermediaryRelativeList(String bizId) {
CcdiBizIntermediary owner = requireIntermediaryPerson(bizId);
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiBizIntermediary::getRelatedNumId, bizId) wrapper.eq(CcdiBizIntermediary::getRelatedNumId, owner.getPersonId())
.ne(CcdiBizIntermediary::getPersonSubType, "本人") .ne(CcdiBizIntermediary::getPersonSubType, "本人")
.orderByDesc(CcdiBizIntermediary::getCreateTime); .orderByDesc(CcdiBizIntermediary::getCreateTime);
return bizIntermediaryMapper.selectList(wrapper).stream().map(this::buildRelativeVo).toList(); return bizIntermediaryMapper.selectList(wrapper).stream().map(this::buildRelativeVo).toList();
@@ -187,8 +193,9 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
BeanUtils.copyProperties(editDTO, person); BeanUtils.copyProperties(editDTO, person);
person.setPersonSubType("本人"); person.setPersonSubType("本人");
person.setRelatedNumId(null); person.setRelatedNumId(null);
int updated = bizIntermediaryMapper.updateById(person);
return bizIntermediaryMapper.updateById(person); syncRelativeOwnerPersonId(existing.getPersonId(), editDTO.getPersonId());
return updated;
} }
@Override @Override
@@ -196,13 +203,13 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
public int insertIntermediaryRelative(String bizId, CcdiIntermediaryRelativeAddDTO addDTO) { public int insertIntermediaryRelative(String bizId, CcdiIntermediaryRelativeAddDTO addDTO) {
CcdiBizIntermediary owner = requireIntermediaryPerson(bizId); CcdiBizIntermediary owner = requireIntermediaryPerson(bizId);
validateRelativePersonSubType(addDTO.getPersonSubType()); validateRelativePersonSubType(addDTO.getPersonSubType());
if (!checkPersonIdUnique(addDTO.getPersonId(), null)) { if (!checkRelativePersonUnique(owner.getPersonId(), addDTO.getPersonId(), null)) {
throw new RuntimeException("证件号已存在"); throw new RuntimeException("中介本人下已存在相同证件号亲属");
} }
CcdiBizIntermediary relative = new CcdiBizIntermediary(); CcdiBizIntermediary relative = new CcdiBizIntermediary();
BeanUtils.copyProperties(addDTO, relative); BeanUtils.copyProperties(addDTO, relative);
relative.setRelatedNumId(owner.getBizId()); relative.setRelatedNumId(owner.getPersonId());
relative.setDataSource("MANUAL"); relative.setDataSource("MANUAL");
return bizIntermediaryMapper.insert(relative); return bizIntermediaryMapper.insert(relative);
} }
@@ -216,8 +223,8 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
} }
validateRelativePersonSubType(editDTO.getPersonSubType()); validateRelativePersonSubType(editDTO.getPersonSubType());
if (StringUtils.isNotEmpty(editDTO.getPersonId()) if (StringUtils.isNotEmpty(editDTO.getPersonId())
&& !checkPersonIdUnique(editDTO.getPersonId(), editDTO.getBizId())) { && !checkRelativePersonUnique(existing.getRelatedNumId(), editDTO.getPersonId(), editDTO.getBizId())) {
throw new RuntimeException("证件号已存在"); throw new RuntimeException("中介本人下已存在相同证件号亲属");
} }
CcdiBizIntermediary relative = new CcdiBizIntermediary(); CcdiBizIntermediary relative = new CcdiBizIntermediary();
@@ -334,7 +341,8 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
if (intermediary != null) { if (intermediary != null) {
if (isIntermediaryPerson(intermediary)) { if (isIntermediaryPerson(intermediary)) {
bizIntermediaryMapper.delete(new LambdaQueryWrapper<CcdiBizIntermediary>() bizIntermediaryMapper.delete(new LambdaQueryWrapper<CcdiBizIntermediary>()
.eq(CcdiBizIntermediary::getRelatedNumId, id)); .eq(CcdiBizIntermediary::getRelatedNumId, intermediary.getPersonId())
.ne(CcdiBizIntermediary::getPersonSubType, "本人"));
enterpriseRelationMapper.delete(new LambdaQueryWrapper<CcdiIntermediaryEnterpriseRelation>() enterpriseRelationMapper.delete(new LambdaQueryWrapper<CcdiIntermediaryEnterpriseRelation>()
.eq(CcdiIntermediaryEnterpriseRelation::getIntermediaryBizId, id)); .eq(CcdiIntermediaryEnterpriseRelation::getIntermediaryBizId, id));
} }
@@ -359,7 +367,8 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
@Override @Override
public boolean checkPersonIdUnique(String personId, String bizId) { public boolean checkPersonIdUnique(String personId, String bizId) {
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiBizIntermediary::getPersonId, personId); wrapper.eq(CcdiBizIntermediary::getPersonId, personId)
.eq(CcdiBizIntermediary::getPersonSubType, "本人");
if (StringUtils.isNotEmpty(bizId)) { if (StringUtils.isNotEmpty(bizId)) {
wrapper.ne(CcdiBizIntermediary::getBizId, bizId); wrapper.ne(CcdiBizIntermediary::getBizId, bizId);
} }
@@ -419,6 +428,31 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
return taskId; return taskId;
} }
@Override
@Transactional
public String importIntermediaryEnterpriseRelation(List<CcdiIntermediaryEnterpriseRelationExcel> list) {
String taskId = UUID.randomUUID().toString();
long startTime = System.currentTimeMillis();
String statusKey = "import:intermediary-enterprise-relation:" + taskId;
Map<String, Object> statusData = new HashMap<>();
statusData.put("taskId", taskId);
statusData.put("status", "PROCESSING");
statusData.put("totalCount", list.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);
String userName = SecurityUtils.getUsername();
enterpriseRelationImportService.importAsync(list, taskId, userName);
return taskId;
}
/** /**
* 导入实体中介数据(异步) * 导入实体中介数据(异步)
* *
@@ -473,6 +507,17 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
} }
} }
private boolean checkRelativePersonUnique(String ownerPersonId, String personId, String excludeBizId) {
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiBizIntermediary::getRelatedNumId, ownerPersonId)
.eq(CcdiBizIntermediary::getPersonId, personId)
.ne(CcdiBizIntermediary::getPersonSubType, "本人");
if (StringUtils.isNotEmpty(excludeBizId)) {
wrapper.ne(CcdiBizIntermediary::getBizId, excludeBizId);
}
return bizIntermediaryMapper.selectCount(wrapper) == 0;
}
private void validateEnterpriseRelation(String bizId, String socialCreditCode, Long excludeId) { private void validateEnterpriseRelation(String bizId, String socialCreditCode, Long excludeId) {
requireIntermediaryPerson(bizId); requireIntermediaryPerson(bizId);
if (enterpriseBaseInfoMapper.selectById(socialCreditCode) == null) { if (enterpriseBaseInfoMapper.selectById(socialCreditCode) == null) {
@@ -490,6 +535,20 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
} }
} }
private void syncRelativeOwnerPersonId(String oldOwnerPersonId, String newOwnerPersonId) {
if (StringUtils.isEmpty(oldOwnerPersonId)
|| StringUtils.isEmpty(newOwnerPersonId)
|| oldOwnerPersonId.equals(newOwnerPersonId)) {
return;
}
CcdiBizIntermediary relative = new CcdiBizIntermediary();
relative.setRelatedNumId(newOwnerPersonId);
bizIntermediaryMapper.update(relative, new LambdaQueryWrapper<CcdiBizIntermediary>()
.eq(CcdiBizIntermediary::getRelatedNumId, oldOwnerPersonId)
.ne(CcdiBizIntermediary::getPersonSubType, "本人"));
}
private CcdiIntermediaryRelativeVO buildRelativeVo(CcdiBizIntermediary relative) { private CcdiIntermediaryRelativeVO buildRelativeVo(CcdiBizIntermediary relative) {
CcdiIntermediaryRelativeVO vo = new CcdiIntermediaryRelativeVO(); CcdiIntermediaryRelativeVO vo = new CcdiIntermediaryRelativeVO();
BeanUtils.copyProperties(relative, vo); BeanUtils.copyProperties(relative, vo);

View File

@@ -4,6 +4,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper"> <mapper namespace="com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper">
<insert id="insertBatch" parameterType="java.util.List">
INSERT INTO ccdi_intermediary_enterprise_relation (
intermediary_biz_id, social_credit_code, relation_person_post, remark,
created_by, updated_by, create_time, update_time
) VALUES
<foreach collection="list" item="item" separator=",">
(
#{item.intermediaryBizId}, #{item.socialCreditCode}, #{item.relationPersonPost}, #{item.remark},
#{item.createdBy}, #{item.updatedBy}, NOW(), NOW()
)
</foreach>
</insert>
<resultMap id="CcdiIntermediaryEnterpriseRelationVOResult" <resultMap id="CcdiIntermediaryEnterpriseRelationVOResult"
type="com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEnterpriseRelationVO"> type="com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEnterpriseRelationVO">
<id property="id" column="id"/> <id property="id" column="id"/>
@@ -63,4 +76,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
AND social_credit_code = #{socialCreditCode} AND social_credit_code = #{socialCreditCode}
</select> </select>
<select id="batchExistsByCombinations" resultType="string">
SELECT CONCAT(intermediary_biz_id, '|', social_credit_code)
FROM ccdi_intermediary_enterprise_relation
WHERE CONCAT(intermediary_biz_id, '|', social_credit_code) IN
<foreach collection="combinations" item="item" open="(" separator="," close=")">
#{item}
</foreach>
</select>
</mapper> </mapper>

View File

@@ -32,7 +32,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
child.create_time child.create_time
FROM ccdi_biz_intermediary child FROM ccdi_biz_intermediary child
INNER JOIN ccdi_biz_intermediary parent INNER JOIN ccdi_biz_intermediary parent
ON child.related_num_id COLLATE utf8mb4_general_ci = parent.biz_id COLLATE utf8mb4_general_ci ON child.related_num_id COLLATE utf8mb4_general_ci = parent.person_id COLLATE utf8mb4_general_ci
AND parent.person_sub_type COLLATE utf8mb4_general_ci = '本人' COLLATE utf8mb4_general_ci AND parent.person_sub_type COLLATE utf8mb4_general_ci = '本人' COLLATE utf8mb4_general_ci
WHERE child.person_sub_type IS NOT NULL WHERE child.person_sub_type IS NOT NULL
AND child.person_sub_type COLLATE utf8mb4_general_ci != '本人' COLLATE utf8mb4_general_ci AND child.person_sub_type COLLATE utf8mb4_general_ci != '本人' COLLATE utf8mb4_general_ci

View File

@@ -3,7 +3,7 @@
**模块**: 中介库管理 **模块**: 中介库管理
**日期**: 2026-04-20 **日期**: 2026-04-20
**作者**: Codex **作者**: Codex
**状态**: 待评审 **状态**: 已实现SQL 已执行,历史脏数据待清洗)
## 一、背景 ## 一、背景
@@ -563,3 +563,11 @@
10. 通过一次性历史数据迁移完成语义统一 10. 通过一次性历史数据迁移完成语义统一
该方案满足当前需求边界,且符合最短路径实现原则,不引入兼容性补丁方案。 该方案满足当前需求边界,且符合最短路径实现原则,不引入兼容性补丁方案。
## 十六、实施回写
- 2026-04-20 已完成中介模块 `related_num_id` 语义切换,手工新增亲属、统一列表查询、本人证件号变更同步和级联删除逻辑均按“关联中介本人证件号码”改造。
- 2026-04-20 已完成“导入中介信息”和“导入中介实体关联关系”两条异步导入链路,并同步完成前端双按钮、双任务状态、双失败记录模式改造。
- 2026-04-20 已完成后端目标测试回归、信息采集模块编译、前端静态单测和 `build:prod` 构建验证。
- 2026-04-20 已执行 `bin/mysql_utf8_exec.sh sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql``bin/mysql_utf8_exec.sh sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql`
- 迁移后核查结果:`legacy_biz_id_reference = 0`,说明旧 `biz_id` 语义残留已清零;`post_migration_missing_parent = 1025``owner_person_id_empty_after_migration = 754`,说明历史数据中仍存在无法关联到本人证件号的脏数据,需后续专项清洗。

View File

@@ -435,3 +435,27 @@ mvn -pl ccdi-info-collection -am clean compile
- `relationType` 已从导入链路中移除 - `relationType` 已从导入链路中移除
- `personSubType` 模板下拉已切换为 `ccdi_person_sub_type` - `personSubType` 模板下拉已切换为 `ccdi_person_sub_type`
- 后端测试与编译验证通过 - 后端测试与编译验证通过
## 执行结果
- SQL 脚本:
`sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql`
`sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql`
结果:已执行 `bin/mysql_utf8_exec.sh`
- SQL 核查:
`post_migration_missing_parent = 1025`
`legacy_biz_id_reference = 0`
`owner_person_id_empty_after_migration = 754`
结果:可迁移数据已切换完成,旧 `biz_id` 语义残留清零,但历史脏数据仍需后续清洗。
- Maven 命令:
`mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest test`
结果PASS
- Maven 命令:
`mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiIntermediaryControllerTest test`
结果PASS
- Maven 命令:
`mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest,CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest test`
结果PASS
- Maven 命令:
`mvn -pl ccdi-info-collection -am clean compile`
结果PASSBUILD SUCCESS

View File

@@ -399,3 +399,15 @@ source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run build:prod
- 页面支持恢复两类最近一次导入任务状态 - 页面支持恢复两类最近一次导入任务状态
- 导入提示文案已经明确 `personSubType` 字典下拉、`relationType` 废弃、`relatedNumId` 新语义 - 导入提示文案已经明确 `personSubType` 字典下拉、`relationType` 废弃、`relatedNumId` 新语义
- 已使用 `nvm use 14.21.3` 完成前端测试脚本和生产构建验证 - 已使用 `nvm use 14.21.3` 完成前端测试脚本和生产构建验证
## 执行结果
- Node 命令:
`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3`
结果PASSNow using node v14.21.3
- Node 命令:
`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && node tests/unit/intermediary-import-api.test.js && node tests/unit/intermediary-import-dialog.test.js && node tests/unit/intermediary-import-toolbar.test.js && node tests/unit/intermediary-import-state.test.js && node tests/unit/intermediary-person-edit-ui.test.js`
结果PASS
- Node 命令:
`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && npm run build:prod`
结果PASS构建成功仅保留原有 bundle size warning

View File

@@ -0,0 +1,44 @@
# 中介库导入改造实施记录
## 基本信息
- 日期2026-04-20
- 范围:中介库后端导入改造 + 前端导入入口与状态改造
- 关联设计:`docs/design/2026-04-20-intermediary-import-refactor-design.md`
- 关联计划:
`docs/plans/backend/2026-04-20-intermediary-import-backend-implementation.md`
`docs/plans/frontend/2026-04-20-intermediary-import-frontend-implementation.md`
## 实施内容
- 后端完成 `related_num_id` 语义切换,统一为“关联中介本人证件号码”,并补齐本人证件号变更同步、亲属唯一性收敛、统一列表联表条件切换。
- 后端完成“导入中介信息”链路重构,支持本人与亲属混合导入、同文件内引用先成功导入的本人、同亲属证件号挂到不同本人。
- 后端新增“导入中介实体关联关系”链路,按“本人证件号码 -> 本人 bizId -> 关系表”写入,并支持文件内去重、库内去重、失败记录回看。
- 前端完成中介导入入口改造,页面顶部改为“导入中介信息”“导入中介实体关联关系”两个按钮,导入弹窗改为 `scene` 驱动。
- 前端完成两类导入任务状态、本地缓存键、失败记录弹窗、历史任务恢复和完成态刷新逻辑,保留现有详情维护、亲属维护、关联机构维护的 `bizId` 契约。
## 验证结果
- 后端测试:
`mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest,CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest test`
结果PASS
- 后端编译:
`mvn -pl ccdi-info-collection -am clean compile`
结果PASS
- 前端静态测试:
`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && node tests/unit/intermediary-import-api.test.js && node tests/unit/intermediary-import-dialog.test.js && node tests/unit/intermediary-import-toolbar.test.js && node tests/unit/intermediary-import-state.test.js && node tests/unit/intermediary-person-edit-ui.test.js`
结果PASS
- 前端构建:
`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && npm run build:prod`
结果PASS仅有原有 bundle size warning
## SQL 执行结果
- 已执行:
`bin/mysql_utf8_exec.sh sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql`
`bin/mysql_utf8_exec.sh sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql`
- 迁移后核查:
`post_migration_missing_parent = 1025`
`legacy_biz_id_reference = 0`
`owner_person_id_empty_after_migration = 754`
- 结论:可迁移数据已经完成语义切换,旧 `biz_id` 语义残留已清零;历史中仍有缺失本人映射或 `related_num_id` 为空的脏数据,需要后续专项清洗。

View File

@@ -178,14 +178,6 @@ export function importPersonTemplate() {
}) })
} }
// 下载机构中介导入模板
export function importEntityTemplate() {
return request({
url: '/ccdi/intermediary/importEntityTemplate',
method: 'post'
})
}
// 导入个人中介黑名单 // 导入个人中介黑名单
export function importPersonData(data, updateSupport) { export function importPersonData(data, updateSupport) {
return request({ return request({
@@ -195,12 +187,11 @@ export function importPersonData(data, updateSupport) {
}) })
} }
// 导入机构中介黑名单 // 下载中介实体关联关系导入模板
export function importEntityData(data, updateSupport) { export function importEnterpriseRelationTemplate() {
return request({ return request({
url: '/ccdi/intermediary/importEntityData?updateSupport=' + updateSupport, url: '/ccdi/intermediary/importEnterpriseRelationTemplate',
method: 'post', method: 'post'
data: data
}) })
} }
@@ -221,18 +212,27 @@ export function getPersonImportFailures(taskId, pageNum, pageSize) {
}) })
} }
// 查询实体中介导入状态 // 导入中介实体关联关系
export function getEntityImportStatus(taskId) { export function importEnterpriseRelationData(data, updateSupport) {
return request({ return request({
url: `/ccdi/intermediary/importEntityStatus/${taskId}`, url: '/ccdi/intermediary/importEnterpriseRelationData?updateSupport=' + updateSupport,
method: 'post',
data: data
})
}
// 查询中介实体关联关系导入状态
export function getEnterpriseRelationImportStatus(taskId) {
return request({
url: `/ccdi/intermediary/importEnterpriseRelationStatus/${taskId}`,
method: 'get' method: 'get'
}) })
} }
// 查询实体中介导入失败记录 // 查询中介实体关联关系导入失败记录
export function getEntityImportFailures(taskId, pageNum, pageSize) { export function getEnterpriseRelationImportFailures(taskId, pageNum, pageSize) {
return request({ return request({
url: `/ccdi/intermediary/importEntityFailures/${taskId}`, url: `/ccdi/intermediary/importEnterpriseRelationFailures/${taskId}`,
method: 'get', method: 'get',
params: { pageNum, pageSize } params: { pageNum, pageSize }
}) })

View File

@@ -8,6 +8,7 @@
<el-descriptions-item label="姓名">{{ detailData.name || '-' }}</el-descriptions-item> <el-descriptions-item label="姓名">{{ detailData.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="证件号">{{ detailData.personId || '-' }}</el-descriptions-item> <el-descriptions-item label="证件号">{{ detailData.personId || '-' }}</el-descriptions-item>
<el-descriptions-item label="人员类型">{{ detailData.personType || '-' }}</el-descriptions-item> <el-descriptions-item label="人员类型">{{ detailData.personType || '-' }}</el-descriptions-item>
<el-descriptions-item label="中介子类型">{{ detailData.personSubType || detailData.relationType || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ formatGender(detailData.gender) }}</el-descriptions-item> <el-descriptions-item label="性别">{{ formatGender(detailData.gender) }}</el-descriptions-item>
<el-descriptions-item label="证件类型">{{ detailData.idType || '-' }}</el-descriptions-item> <el-descriptions-item label="证件类型">{{ detailData.idType || '-' }}</el-descriptions-item>
<el-descriptions-item label="职位">{{ detailData.position || '-' }}</el-descriptions-item> <el-descriptions-item label="职位">{{ detailData.position || '-' }}</el-descriptions-item>

View File

@@ -26,6 +26,27 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12">
<!-- v-model="form.personSubType" -->
<el-form-item label="中介子类型">
<el-select
v-model="localForm.personSubType"
placeholder="请选择中介子类型"
clearable
style="width: 100%"
@change="handlePersonSubTypeChange"
>
<el-option
v-for="item in personSubTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="证件类型"> <el-form-item label="证件类型">
<el-select v-model="localForm.idType" placeholder="请选择证件类型" clearable style="width: 100%"> <el-select v-model="localForm.idType" placeholder="请选择证件类型" clearable style="width: 100%">
@@ -131,6 +152,10 @@ export default {
certTypeOptions: { certTypeOptions: {
type: Array, type: Array,
default: () => [] default: () => []
},
personSubTypeOptions: {
type: Array,
default: () => []
} }
}, },
data() { data() {
@@ -162,6 +187,12 @@ export default {
} }
}, },
methods: { methods: {
handlePersonSubTypeChange(value) {
const typeMappings = [
{ label: '个人', value: '本人' }
];
return typeMappings.find(item => item.value === value) || null;
},
handleSubmit() { handleSubmit() {
this.$refs.formRef.validate(valid => { this.$refs.formRef.validate(valid => {
if (valid) { if (valid) {

View File

@@ -1,6 +1,5 @@
<template> <template>
<div> <div>
<!-- 导入对话框 -->
<el-dialog <el-dialog
:title="title" :title="title"
:visible.sync="visible" :visible.sync="visible"
@@ -13,22 +12,18 @@
:close-on-press-escape="false" :close-on-press-escape="false"
custom-class="import-dialog-wrapper" custom-class="import-dialog-wrapper"
> >
<!-- 全屏Loading遮罩层 -->
<div v-show="isUploading" class="import-loading-overlay"> <div v-show="isUploading" class="import-loading-overlay">
<i class="el-icon-loading"></i> <i class="el-icon-loading"></i>
<p>正在导入中,请稍候...</p> <p>正在导入中,请稍候...</p>
</div> </div>
<el-form :model="formData" label-position="top" size="medium"> <el-form label-position="top" size="medium">
<!-- 导入类型 --> <el-form-item label="导入说明">
<el-form-item label="导入类型"> <div class="scene-tips">
<el-radio-group v-model="formData.importType" @change="handleImportTypeChange" style="width: 100%"> <p v-for="item in sceneTips" :key="item">{{ item }}</p>
<el-radio label="person" border>个人中介</el-radio> </div>
<el-radio label="entity" border>机构中介</el-radio>
</el-radio-group>
</el-form-item> </el-form-item>
<!-- 文件上传 -->
<el-form-item label="选择文件"> <el-form-item label="选择文件">
<el-upload <el-upload
ref="upload" ref="upload"
@@ -53,7 +48,6 @@
</el-upload> </el-upload>
</el-form-item> </el-form-item>
<!-- 下载模板 -->
<el-form-item> <el-form-item>
<el-link type="primary" :underline="false" @click="handleDownloadTemplate"> <el-link type="primary" :underline="false" @click="handleDownloadTemplate">
<i class="el-icon-download"></i> <i class="el-icon-download"></i>
@@ -62,7 +56,6 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
<!-- 底部按钮 -->
<div slot="footer" class="dialog-footer"> <div slot="footer" class="dialog-footer">
<el-button <el-button
type="primary" type="primary"
@@ -79,7 +72,6 @@
</div> </div>
</el-dialog> </el-dialog>
<!-- 导入结果对话框 -->
<import-result-dialog <import-result-dialog
:visible.sync="importResultVisible" :visible.sync="importResultVisible"
:content="importResultContent" :content="importResultContent"
@@ -90,10 +82,16 @@
</template> </template>
<script> <script>
import {getToken} from "@/utils/auth"; import { getToken } from "@/utils/auth";
import {getEntityImportStatus, getPersonImportStatus} from "@/api/ccdiIntermediary"; import {
getEnterpriseRelationImportStatus,
getPersonImportStatus
} from "@/api/ccdiIntermediary";
import ImportResultDialog from "@/components/ImportResultDialog.vue"; import ImportResultDialog from "@/components/ImportResultDialog.vue";
const PERSON_SCENE = "person";
const ENTERPRISE_RELATION_SCENE = "enterpriseRelation";
export default { export default {
name: "ImportDialog", name: "ImportDialog",
components: { ImportResultDialog }, components: { ImportResultDialog },
@@ -105,32 +103,55 @@ export default {
title: { title: {
type: String, type: String,
default: "数据导入" default: "数据导入"
},
scene: {
type: String,
default: PERSON_SCENE
} }
}, },
data() { data() {
return { return {
formData: {
importType: "person"
},
headers: { Authorization: "Bearer " + getToken() }, headers: { Authorization: "Bearer " + getToken() },
isUploading: false, isUploading: false,
isFileSelected: false, isFileSelected: false,
// 导入结果弹窗
importResultVisible: false, importResultVisible: false,
importResultContent: "", importResultContent: "",
// 轮询状态
pollingTimer: null, pollingTimer: null,
currentTaskId: null currentTaskId: null
}; };
}, },
computed: { computed: {
sceneConfig() {
if (this.scene === ENTERPRISE_RELATION_SCENE) {
return {
uploadPath: "/ccdi/intermediary/importEnterpriseRelationData",
templatePath: "ccdi/intermediary/importEnterpriseRelationTemplate",
templateName: "中介实体关联关系导入模板",
statusApi: getEnterpriseRelationImportStatus,
tips: [
"只导入中介与机构关系;",
"统一社会信用代码必须已存在于系统机构表。"
]
};
}
return {
uploadPath: "/ccdi/intermediary/importPersonData",
templatePath: "ccdi/intermediary/importPersonTemplate",
templateName: "中介信息导入模板",
statusApi: getPersonImportStatus,
tips: [
"personSubType 为字典下拉;",
"本人行 relatedNumId 为空;",
"亲属行 relatedNumId 填关联中介本人证件号码。"
]
};
},
uploadUrl() { uploadUrl() {
const baseUrl = process.env.VUE_APP_BASE_API; const baseUrl = process.env.VUE_APP_BASE_API;
if (this.formData.importType === 'person') { return `${baseUrl}${this.sceneConfig.uploadPath}`;
return `${baseUrl}/ccdi/intermediary/importPersonData`; },
} else { sceneTips() {
return `${baseUrl}/ccdi/intermediary/importEntityData`; return this.sceneConfig.tips;
}
} }
}, },
methods: { methods: {
@@ -145,21 +166,14 @@ export default {
this.$emit("close"); this.$emit("close");
}, },
handleCancel() { handleCancel() {
// 通过 $emit 通知父组件更新 visible 状态,而不是直接修改 prop this.$emit("update:visible", false);
this.$emit('update:visible', false);
},
handleImportTypeChange() {
if (this.$refs.upload) {
this.$refs.upload.clearFiles();
}
this.isFileSelected = false;
}, },
handleDownloadTemplate() { handleDownloadTemplate() {
if (this.formData.importType === 'person') { this.download(
this.download('ccdi/intermediary/importPersonTemplate', {}, `个人中介黑名单模板_${new Date().getTime()}.xlsx`); this.sceneConfig.templatePath,
} else { {},
this.download('ccdi/intermediary/importEntityTemplate', {}, `机构中介黑名单模板_${new Date().getTime()}.xlsx`); `${this.sceneConfig.templateName}_${new Date().getTime()}.xlsx`
} );
}, },
handleFileUploadProgress() { handleFileUploadProgress() {
this.isUploading = true; this.isUploading = true;
@@ -177,28 +191,30 @@ export default {
const taskId = response.data.taskId; const taskId = response.data.taskId;
this.currentTaskId = taskId; this.currentTaskId = taskId;
// 显示通知
this.$notify({ this.$notify({
title: '导入任务已提交', title: "导入任务已提交",
message: '正在后台处理中,处理完成后将通知您', message: "正在后台处理中,处理完成后将通知您",
type: 'info', type: "info",
duration: 3000 duration: 3000
}); });
// 关闭对话框 - 使用$emit更新父组件的visible this.$emit("task-created", {
this.$emit('update:visible', false); scene: this.scene,
this.$refs.upload.clearFiles(); taskId,
status: "PROCESSING"
});
this.$emit("update:visible", false);
this.$emit("success", { scene: this.scene, taskId });
// 通知父组件刷新列表 if (this.$refs.upload) {
this.$emit("success"); this.$refs.upload.clearFiles();
}
// 开始轮询
this.startImportStatusPolling(taskId); this.startImportStatusPolling(taskId);
} else { } else {
this.$modal.msgError(response.msg || '导入失败'); this.$modal.msgError(response.msg || "导入失败");
} }
}, },
// 导入结果弹窗关闭
handleImportResultClose() { handleImportResultClose() {
this.importResultVisible = false; this.importResultVisible = false;
this.importResultContent = ""; this.importResultContent = "";
@@ -206,77 +222,65 @@ export default {
handleFileError() { handleFileError() {
this.isUploading = false; this.isUploading = false;
this.$modal.msgError("导入失败,请检查文件格式是否正确"); this.$modal.msgError("导入失败,请检查文件格式是否正确");
this.$refs.upload.clearFiles(); if (this.$refs.upload) {
this.$refs.upload.clearFiles();
}
}, },
handleSubmit() { handleSubmit() {
// 触发清除历史记录事件 this.$emit("clear-import-history", this.scene);
this.$emit('clear-import-history', this.formData.importType);
// 提交文件上传
this.$refs.upload.submit(); this.$refs.upload.submit();
}, },
/** 开始轮询导入状态 */
startImportStatusPolling(taskId) { startImportStatusPolling(taskId) {
let pollCount = 0; let pollCount = 0;
const maxPolls = 150; // 最多5分钟 const maxPolls = 150;
this.pollingTimer = setInterval(async () => { this.pollingTimer = setInterval(async () => {
try { try {
pollCount++; pollCount++;
if (pollCount > maxPolls) { if (pollCount > maxPolls) {
clearInterval(this.pollingTimer); clearInterval(this.pollingTimer);
this.$modal.msgWarning('导入任务处理超时,请联系管理员'); this.$modal.msgWarning("导入任务处理超时,请联系管理员");
return; return;
} }
// 根据导入类型调用不同的API const response = await this.sceneConfig.statusApi(taskId);
const apiMethod = this.formData.importType === 'person' if (response.data && response.data.status !== "PROCESSING") {
? getPersonImportStatus
: getEntityImportStatus;
const response = await apiMethod(taskId);
if (response.data && response.data.status !== 'PROCESSING') {
clearInterval(this.pollingTimer); clearInterval(this.pollingTimer);
this.handleImportComplete(response.data); this.handleImportComplete(response.data);
} }
} catch (error) { } catch (error) {
clearInterval(this.pollingTimer); clearInterval(this.pollingTimer);
this.$modal.msgError('查询导入状态失败: ' + error.message); this.$modal.msgError("查询导入状态失败: " + error.message);
} }
}, 2000); // 每2秒轮询一次 }, 2000);
}, },
/** 处理导入完成 */
handleImportComplete(statusResult) { handleImportComplete(statusResult) {
if (statusResult.status === 'SUCCESS') { if (statusResult.status === "SUCCESS") {
this.$notify({ this.$notify({
title: '导入完成', title: "导入完成",
message: `全部成功!共导入${statusResult.totalCount}条数据`, message: `全部成功!共导入${statusResult.totalCount}条数据`,
type: 'success', type: "success",
duration: 5000 duration: 5000
}); });
} else if (statusResult.failureCount > 0) { } else if (statusResult.failureCount > 0) {
this.$notify({ this.$notify({
title: '导入完成', title: "导入完成",
message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}`, message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}`,
type: 'warning', type: "warning",
duration: 5000 duration: 5000
}); });
} }
// 通知父组件更新失败记录状态
this.$emit("import-complete", { this.$emit("import-complete", {
scene: this.scene,
taskId: statusResult.taskId, taskId: statusResult.taskId,
hasFailures: statusResult.failureCount > 0, hasFailures: statusResult.failureCount > 0,
importType: this.formData.importType,
totalCount: statusResult.totalCount, totalCount: statusResult.totalCount,
successCount: statusResult.successCount, successCount: statusResult.successCount,
failureCount: statusResult.failureCount failureCount: statusResult.failureCount
}); });
} }
}, },
/** 组件销毁时清除定时器 */
beforeDestroy() { beforeDestroy() {
if (this.pollingTimer) { if (this.pollingTimer) {
clearInterval(this.pollingTimer); clearInterval(this.pollingTimer);
@@ -292,35 +296,6 @@ export default {
margin-bottom: 22px; margin-bottom: 22px;
} }
.el-radio-group {
display: flex;
.el-radio {
flex: 1;
text-align: center;
margin-right: 0;
&:first-child {
.el-radio__label {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
&:last-child {
.el-radio__label {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&.is-bordered {
padding: 10px 0;
height: auto;
}
}
}
.el-upload { .el-upload {
width: 100%; width: 100%;
@@ -355,6 +330,18 @@ export default {
} }
} }
.scene-tips {
padding: 12px 14px;
background: #f5f7fa;
border-radius: 4px;
color: #606266;
line-height: 1.7;
p {
margin: 0;
}
}
.dialog-footer { .dialog-footer {
text-align: center; text-align: center;
padding: 5px 0 0; padding: 5px 0 0;

View File

@@ -17,6 +17,44 @@
v-hasPermi="['ccdi:intermediary:add']" v-hasPermi="['ccdi:intermediary:add']"
>新增</el-button> >新增</el-button>
</el-col> </el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-upload2"
size="mini"
@click="handleOpenPersonImport"
v-hasPermi="['ccdi:intermediary:import']"
>导入中介信息</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-connection"
size="mini"
@click="handleOpenEnterpriseRelationImport"
v-hasPermi="['ccdi:intermediary:import']"
>导入中介实体关联关系</el-button>
</el-col>
<el-col :span="1.5" v-if="personImportTask && personImportTask.failureCount > 0">
<el-button
type="warning"
plain
icon="el-icon-warning"
size="mini"
@click="viewPersonImportFailures"
>查看中介信息导入失败记录</el-button>
</el-col>
<el-col :span="1.5" v-if="enterpriseRelationImportTask && enterpriseRelationImportTask.failureCount > 0">
<el-button
type="warning"
plain
icon="el-icon-warning-outline"
size="mini"
@click="viewEnterpriseRelationImportFailures"
>查看中介实体关联关系导入失败记录</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" /> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row> </el-row>
@@ -36,6 +74,7 @@
:visible.sync="personDialogVisible" :visible.sync="personDialogVisible"
:title="personDialogTitle" :title="personDialogTitle"
:form="personForm" :form="personForm"
:person-sub-type-options="relationTypeOptions"
:indiv-type-options="indivTypeOptions" :indiv-type-options="indivTypeOptions"
:gender-options="genderOptions" :gender-options="genderOptions"
:cert-type-options="certTypeOptions" :cert-type-options="certTypeOptions"
@@ -80,6 +119,87 @@
@edit-enterprise-relation="handleEditEnterpriseRelationFromDetail" @edit-enterprise-relation="handleEditEnterpriseRelationFromDetail"
@delete-enterprise-relation="handleDelete" @delete-enterprise-relation="handleDelete"
/> />
<import-dialog
:visible.sync="importDialogVisible"
:title="importDialogTitle"
:scene="importScene"
@task-created="handleImportTaskCreated"
@success="handleImportDialogSuccess"
@import-complete="handleImportComplete"
@clear-import-history="handleClearImportHistory"
/>
<el-dialog
title="中介信息导入失败记录"
:visible.sync="personFailureDialogVisible"
width="1200px"
append-to-body
>
<el-alert
v-if="personLastImportInfo"
:title="personLastImportInfo"
type="info"
:closable="false"
style="margin-bottom: 15px"
/>
<el-table :data="personFailureList" v-loading="personFailureLoading">
<el-table-column label="姓名" prop="name" align="center" />
<el-table-column label="人员子类型" prop="personSubType" align="center" />
<el-table-column label="证件号码" prop="personId" align="center" min-width="180" />
<el-table-column label="关联中介本人证件号码" prop="relatedNumId" align="center" min-width="180" />
<el-table-column label="失败原因" prop="errorMessage" align="center" min-width="220" :show-overflow-tooltip="true" />
</el-table>
<pagination
v-show="personFailureTotal > 0"
:total="personFailureTotal"
:page.sync="personFailureQueryParams.pageNum"
:limit.sync="personFailureQueryParams.pageSize"
@pagination="getPersonFailureList"
/>
<div slot="footer" class="dialog-footer">
<el-button @click="personFailureDialogVisible = false">关闭</el-button>
<el-button type="danger" plain @click="clearPersonImportHistory">清除历史记录</el-button>
</div>
</el-dialog>
<el-dialog
title="中介实体关联关系导入失败记录"
:visible.sync="enterpriseRelationFailureDialogVisible"
width="1200px"
append-to-body
>
<el-alert
v-if="enterpriseRelationLastImportInfo"
:title="enterpriseRelationLastImportInfo"
type="info"
:closable="false"
style="margin-bottom: 15px"
/>
<el-table :data="enterpriseRelationFailureList" v-loading="enterpriseRelationFailureLoading">
<el-table-column label="中介本人证件号码" prop="ownerPersonId" align="center" min-width="180" />
<el-table-column label="统一社会信用代码" prop="socialCreditCode" align="center" min-width="180" />
<el-table-column label="关联人职务" prop="relationPersonPost" align="center" />
<el-table-column label="失败原因" prop="errorMessage" align="center" min-width="220" :show-overflow-tooltip="true" />
</el-table>
<pagination
v-show="enterpriseRelationFailureTotal > 0"
:total="enterpriseRelationFailureTotal"
:page.sync="enterpriseRelationFailureQueryParams.pageNum"
:limit.sync="enterpriseRelationFailureQueryParams.pageSize"
@pagination="getEnterpriseRelationFailureList"
/>
<div slot="footer" class="dialog-footer">
<el-button @click="enterpriseRelationFailureDialogVisible = false">关闭</el-button>
<el-button type="danger" plain @click="clearEnterpriseRelationImportHistory">清除历史记录</el-button>
</div>
</el-dialog>
</div> </div>
</template> </template>
@@ -91,8 +211,12 @@ import {
delIntermediary, delIntermediary,
delIntermediaryEnterpriseRelation, delIntermediaryEnterpriseRelation,
delIntermediaryRelative, delIntermediaryRelative,
getEnterpriseRelationImportFailures,
getEnterpriseRelationImportStatus,
getIntermediaryEnterpriseRelation, getIntermediaryEnterpriseRelation,
getIntermediaryRelative, getIntermediaryRelative,
getPersonImportFailures,
getPersonImportStatus,
getPersonIntermediary, getPersonIntermediary,
listIntermediary, listIntermediary,
listIntermediaryEnterpriseRelations, listIntermediaryEnterpriseRelations,
@@ -113,6 +237,10 @@ import EditDialog from "./components/EditDialog";
import DetailDialog from "./components/DetailDialog"; import DetailDialog from "./components/DetailDialog";
import RelativeEditDialog from "./components/RelativeEditDialog"; import RelativeEditDialog from "./components/RelativeEditDialog";
import EnterpriseRelationEditDialog from "./components/EnterpriseRelationEditDialog"; import EnterpriseRelationEditDialog from "./components/EnterpriseRelationEditDialog";
import ImportDialog from "./components/ImportDialog";
const PERSON_SCENE = "person";
const ENTERPRISE_RELATION_SCENE = "enterpriseRelation";
export default { export default {
name: "Intermediary", name: "Intermediary",
@@ -122,7 +250,8 @@ export default {
EditDialog, EditDialog,
DetailDialog, DetailDialog,
RelativeEditDialog, RelativeEditDialog,
EnterpriseRelationEditDialog EnterpriseRelationEditDialog,
ImportDialog
}, },
data() { data() {
return { return {
@@ -159,7 +288,32 @@ export default {
relativeList: [], relativeList: [],
enterpriseRelationList: [], enterpriseRelationList: [],
currentIntermediaryId: null, currentIntermediaryId: null,
currentOwnerName: "" currentOwnerName: "",
importDialogVisible: false,
importDialogTitle: "",
importScene: PERSON_SCENE,
personImportTask: null,
enterpriseRelationImportTask: null,
personImportPollingTimer: null,
enterpriseRelationImportPollingTimer: null,
personFailureDialogVisible: false,
personFailureList: [],
personFailureLoading: false,
personFailureTotal: 0,
personFailureQueryParams: {
pageNum: 1,
pageSize: 10
},
personLastImportInfo: "",
enterpriseRelationFailureDialogVisible: false,
enterpriseRelationFailureList: [],
enterpriseRelationFailureLoading: false,
enterpriseRelationFailureTotal: 0,
enterpriseRelationFailureQueryParams: {
pageNum: 1,
pageSize: 10
},
enterpriseRelationLastImportInfo: ""
}; };
}, },
created() { created() {
@@ -168,6 +322,11 @@ export default {
this.resetEnterpriseRelationForm(); this.resetEnterpriseRelationForm();
this.getList(); this.getList();
this.loadEnumOptions(); this.loadEnumOptions();
this.restoreImportTasks();
},
beforeDestroy() {
this.clearImportPolling(PERSON_SCENE);
this.clearImportPolling(ENTERPRISE_RELATION_SCENE);
}, },
methods: { methods: {
loadEnumOptions() { loadEnumOptions() {
@@ -252,6 +411,206 @@ export default {
this.personDialogTitle = "新增中介本人"; this.personDialogTitle = "新增中介本人";
this.personDialogVisible = true; this.personDialogVisible = true;
}, },
handleOpenPersonImport() {
this.importScene = PERSON_SCENE;
this.importDialogTitle = "导入中介信息";
this.importDialogVisible = true;
},
handleOpenEnterpriseRelationImport() {
this.importScene = ENTERPRISE_RELATION_SCENE;
this.importDialogTitle = "导入中介实体关联关系";
this.importDialogVisible = true;
},
handleImportDialogSuccess() {
// 导入任务创建后由任务轮询负责状态更新
},
handleImportTaskCreated(payload) {
const task = {
...payload,
failureCount: 0,
successCount: 0,
totalCount: 0,
saveTime: Date.now()
};
this.setImportTask(payload.scene, task);
this.saveImportTaskToStorage(payload.scene, task);
},
handleImportComplete(payload) {
const task = {
...payload,
status: payload.hasFailures ? "PARTIAL_SUCCESS" : "SUCCESS",
saveTime: Date.now()
};
this.setImportTask(payload.scene, task);
this.saveImportTaskToStorage(payload.scene, task);
if (payload.scene === PERSON_SCENE) {
this.personLastImportInfo = this.buildImportSummary(task);
} else {
this.enterpriseRelationLastImportInfo = this.buildImportSummary(task);
}
this.getList();
this.refreshCurrentDetail();
},
handleClearImportHistory(scene) {
this.clearImportTaskFromStorage(scene);
this.setImportTask(scene, null);
if (scene === PERSON_SCENE) {
this.personFailureList = [];
this.personFailureTotal = 0;
this.personLastImportInfo = "";
} else {
this.enterpriseRelationFailureList = [];
this.enterpriseRelationFailureTotal = 0;
this.enterpriseRelationLastImportInfo = "";
}
},
setImportTask(scene, task) {
if (scene === PERSON_SCENE) {
this.personImportTask = task;
return;
}
this.enterpriseRelationImportTask = task;
},
getImportTask(scene) {
return scene === PERSON_SCENE ? this.personImportTask : this.enterpriseRelationImportTask;
},
getImportStorageKey(scene) {
if (scene === PERSON_SCENE) {
return "ccdi_intermediary_person_import_task";
}
return "ccdi_intermediary_enterprise_relation_import_task";
},
saveImportTaskToStorage(scene, task) {
try {
localStorage.setItem(this.getImportStorageKey(scene), JSON.stringify(task));
} catch (error) {
console.error("保存中介导入任务状态失败:", error);
}
},
getImportTaskFromStorage(scene) {
try {
const raw = localStorage.getItem(this.getImportStorageKey(scene));
return raw ? JSON.parse(raw) : null;
} catch (error) {
console.error("读取中介导入任务状态失败:", error);
return null;
}
},
clearImportTaskFromStorage(scene) {
try {
localStorage.removeItem(this.getImportStorageKey(scene));
} catch (error) {
console.error("清除中介导入任务状态失败:", error);
}
this.clearImportPolling(scene);
},
restoreImportTasks() {
[PERSON_SCENE, ENTERPRISE_RELATION_SCENE].forEach((scene) => {
const task = this.getImportTaskFromStorage(scene);
if (!task) {
return;
}
this.setImportTask(scene, task);
if (scene === PERSON_SCENE) {
this.personLastImportInfo = this.buildImportSummary(task);
} else {
this.enterpriseRelationLastImportInfo = this.buildImportSummary(task);
}
if (task.status === "PROCESSING") {
this.resumeImportPolling(scene, task.taskId);
}
});
},
resumeImportPolling(scene, taskId) {
this.clearImportPolling(scene);
const timerKey = scene === PERSON_SCENE ? "personImportPollingTimer" : "enterpriseRelationImportPollingTimer";
const getStatus = scene === PERSON_SCENE ? getPersonImportStatus : getEnterpriseRelationImportStatus;
let pollCount = 0;
this[timerKey] = setInterval(async () => {
pollCount++;
if (pollCount > 150) {
this.clearImportPolling(scene);
return;
}
const response = await getStatus(taskId);
if (response.data && response.data.status !== "PROCESSING") {
this.clearImportPolling(scene);
this.handleImportComplete({
scene,
taskId: response.data.taskId,
hasFailures: response.data.failureCount > 0,
totalCount: response.data.totalCount,
successCount: response.data.successCount,
failureCount: response.data.failureCount
});
}
});
},
clearImportPolling(scene) {
const timerKey = scene === PERSON_SCENE ? "personImportPollingTimer" : "enterpriseRelationImportPollingTimer";
if (this[timerKey]) {
clearInterval(this[timerKey]);
this[timerKey] = null;
}
},
buildImportSummary(task) {
if (!task) {
return "";
}
return `任务ID: ${task.taskId},总数 ${task.totalCount || 0} 条,成功 ${task.successCount || 0} 条,失败 ${task.failureCount || 0}`;
},
viewPersonImportFailures() {
this.personFailureDialogVisible = true;
this.personFailureQueryParams.pageNum = 1;
this.personLastImportInfo = this.buildImportSummary(this.personImportTask);
this.getPersonFailureList();
},
getPersonFailureList() {
if (!this.personImportTask) {
return;
}
this.personFailureLoading = true;
getPersonImportFailures(
this.personImportTask.taskId,
this.personFailureQueryParams.pageNum,
this.personFailureQueryParams.pageSize
).then((response) => {
this.personFailureList = response.rows || [];
this.personFailureTotal = response.total || 0;
}).finally(() => {
this.personFailureLoading = false;
});
},
clearPersonImportHistory() {
this.personFailureDialogVisible = false;
this.handleClearImportHistory(PERSON_SCENE);
},
viewEnterpriseRelationImportFailures() {
this.enterpriseRelationFailureDialogVisible = true;
this.enterpriseRelationFailureQueryParams.pageNum = 1;
this.enterpriseRelationLastImportInfo = this.buildImportSummary(this.enterpriseRelationImportTask);
this.getEnterpriseRelationFailureList();
},
getEnterpriseRelationFailureList() {
if (!this.enterpriseRelationImportTask) {
return;
}
this.enterpriseRelationFailureLoading = true;
getEnterpriseRelationImportFailures(
this.enterpriseRelationImportTask.taskId,
this.enterpriseRelationFailureQueryParams.pageNum,
this.enterpriseRelationFailureQueryParams.pageSize
).then((response) => {
this.enterpriseRelationFailureList = response.rows || [];
this.enterpriseRelationFailureTotal = response.total || 0;
}).finally(() => {
this.enterpriseRelationFailureLoading = false;
});
},
clearEnterpriseRelationImportHistory() {
this.enterpriseRelationFailureDialogVisible = false;
this.handleClearImportHistory(ENTERPRISE_RELATION_SCENE);
},
handleDetail(row) { handleDetail(row) {
if (row.recordType === "INTERMEDIARY") { if (row.recordType === "INTERMEDIARY") {
this.openIntermediaryDetail(row.recordId); this.openIntermediaryDetail(row.recordId);

View File

@@ -10,9 +10,14 @@ const detailDialogPath = path.resolve(
__dirname, __dirname,
"../../src/views/ccdiIntermediary/components/DetailDialog.vue" "../../src/views/ccdiIntermediary/components/DetailDialog.vue"
); );
const pagePath = path.resolve(
__dirname,
"../../src/views/ccdiIntermediary/index.vue"
);
const editDialogSource = fs.readFileSync(editDialogPath, "utf8"); const editDialogSource = fs.readFileSync(editDialogPath, "utf8");
const detailDialogSource = fs.readFileSync(detailDialogPath, "utf8"); const detailDialogSource = fs.readFileSync(detailDialogPath, "utf8");
const pageSource = fs.readFileSync(pagePath, "utf8");
[ [
'label="中介子类型"', 'label="中介子类型"',
@@ -52,4 +57,19 @@ assert(
"个人中介详情不应继续展示单独的关系类型字段" "个人中介详情不应继续展示单独的关系类型字段"
); );
[
"personFailureDialogVisible",
"enterpriseRelationFailureDialogVisible",
"viewPersonImportFailures",
"viewEnterpriseRelationImportFailures",
"clearPersonImportHistory",
"clearEnterpriseRelationImportHistory",
"refreshCurrentDetail()",
].forEach((token) => {
assert(
pageSource.includes(token),
`中介页面缺少导入失败记录或详情刷新逻辑: ${token}`
);
});
console.log("intermediary-person-edit-ui test passed"); console.log("intermediary-person-edit-ui test passed");

View File

@@ -0,0 +1,153 @@
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
SET character_set_client = utf8mb4;
SET character_set_connection = utf8mb4;
SET character_set_results = utf8mb4;
INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
SELECT '人员子类型', 'ccdi_person_sub_type', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_type
WHERE dict_type = 'ccdi_person_sub_type'
);
UPDATE sys_dict_type
SET dict_name = '人员子类型',
status = '0',
remark = '中介黑名单-人员子类型',
update_by = 'admin',
update_time = NOW()
WHERE dict_type = 'ccdi_person_sub_type';
UPDATE sys_dict_data
SET dict_sort = 1,
dict_label = '本人',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '本人';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 1, '本人', '本人', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '本人'
);
UPDATE sys_dict_data
SET dict_sort = 2,
dict_label = '配偶',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '配偶';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 2, '配偶', '配偶', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '配偶'
);
UPDATE sys_dict_data
SET dict_sort = 3,
dict_label = '子女',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '子女';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 3, '子女', '子女', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '子女'
);
UPDATE sys_dict_data
SET dict_sort = 4,
dict_label = '父母',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '父母';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 4, '父母', '父母', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '父母'
);
UPDATE sys_dict_data
SET dict_sort = 5,
dict_label = '兄弟姐妹',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '兄弟姐妹';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 5, '兄弟姐妹', '兄弟姐妹', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '兄弟姐妹'
);
UPDATE sys_dict_data
SET dict_sort = 6,
dict_label = '其他',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '其他';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 6, '其他', '其他', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '其他'
);

View File

@@ -0,0 +1,73 @@
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
SET character_set_client = utf8mb4;
SET character_set_connection = utf8mb4;
SET character_set_results = utf8mb4;
-- 迁移前检查 1找不到对应本人 biz_id 的亲属记录
SELECT
child.biz_id AS child_biz_id,
child.name AS child_name,
child.person_id AS child_person_id,
child.related_num_id AS legacy_owner_biz_id
FROM ccdi_biz_intermediary child
LEFT JOIN ccdi_biz_intermediary parent
ON child.related_num_id = parent.biz_id
AND parent.person_sub_type = '本人'
WHERE child.person_sub_type <> '本人'
AND (child.related_num_id IS NULL OR parent.biz_id IS NULL);
-- 迁移前检查 2本人 person_id 为空的记录
SELECT
child.biz_id AS child_biz_id,
child.name AS child_name,
child.person_id AS child_person_id,
child.related_num_id AS legacy_owner_biz_id,
parent.biz_id AS parent_biz_id,
parent.name AS parent_name,
parent.person_id AS parent_person_id
FROM ccdi_biz_intermediary child
JOIN ccdi_biz_intermediary parent
ON child.related_num_id = parent.biz_id
AND parent.person_sub_type = '本人'
WHERE child.person_sub_type <> '本人'
AND (parent.person_id IS NULL OR TRIM(parent.person_id) = '');
-- 迁移前检查 3迁移后同一中介本人下 related_num_id + person_id 冲突的记录
SELECT
parent.person_id AS owner_person_id,
child.person_id AS relative_person_id,
COUNT(*) AS conflict_count,
GROUP_CONCAT(child.biz_id ORDER BY child.biz_id SEPARATOR ',') AS child_biz_ids
FROM ccdi_biz_intermediary child
JOIN ccdi_biz_intermediary parent
ON child.related_num_id = parent.biz_id
AND parent.person_sub_type = '本人'
WHERE child.person_sub_type <> '本人'
AND parent.person_id IS NOT NULL
AND TRIM(parent.person_id) <> ''
GROUP BY parent.person_id, child.person_id
HAVING COUNT(*) > 1;
START TRANSACTION;
UPDATE ccdi_biz_intermediary child
JOIN ccdi_biz_intermediary parent
ON child.related_num_id = parent.biz_id
SET child.related_num_id = parent.person_id
WHERE child.person_sub_type <> '本人';
COMMIT;
-- 迁移后检查:亲属记录应统一按本人证件号码关联
SELECT
child.biz_id AS child_biz_id,
child.related_num_id AS owner_person_id,
parent.biz_id AS parent_biz_id,
parent.person_id AS parent_person_id
FROM ccdi_biz_intermediary child
LEFT JOIN ccdi_biz_intermediary parent
ON child.related_num_id = parent.person_id
AND parent.person_sub_type = '本人'
WHERE child.person_sub_type <> '本人'
AND (child.related_num_id IS NULL OR parent.biz_id IS NULL);