2 Commits

Author SHA1 Message Date
wkc
5d004a66e8 feat: 接入新华社工商信息同步 2026-07-31 09:27:54 +08:00
wjj
2bcba71259 修复金综流水多文件拉取处理 2026-07-21 16:42:45 +08:00
74 changed files with 2766 additions and 79 deletions

View File

@@ -57,12 +57,6 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ccdi-lsfx</artifactId>
<version>3.9.1</version>
<scope>compile</scope>
</dependency>
</dependencies>

View File

@@ -0,0 +1,23 @@
package com.ruoyi.info.collection.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/** 工商信息同步专用线程池。 */
@Configuration
public class EnterpriseProfileExecutorConfig {
@Bean("enterpriseProfileExecutor")
public Executor enterpriseProfileExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(3);
executor.setMaxPoolSize(3);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("enterprise-profile-");
executor.initialize();
return executor;
}
}

View File

@@ -8,6 +8,7 @@ import com.ruoyi.common.core.page.PageDomain;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.core.page.TableSupport;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoQueryDTO;
@@ -18,6 +19,8 @@ import com.ruoyi.info.collection.domain.vo.ImportResultVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoImportService;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoService;
import com.ruoyi.info.collection.service.IEnterpriseEntitySyncService;
import com.ruoyi.lsfx.domain.CallerContext;
import com.ruoyi.info.collection.utils.EasyExcelUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -56,6 +59,9 @@ public class CcdiEnterpriseBaseInfoController extends BaseController {
@Resource
private ICcdiEnterpriseBaseInfoImportService enterpriseBaseInfoImportService;
@Resource
private IEnterpriseEntitySyncService enterpriseEntitySyncService;
@Operation(summary = "查询实体库列表")
@PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:list')")
@GetMapping("/list")
@@ -73,12 +79,36 @@ public class CcdiEnterpriseBaseInfoController extends BaseController {
return success(enterpriseBaseInfoService.selectEnterpriseBaseInfoById(socialCreditCode));
}
@Operation(summary = "重新查询实体工商信息")
@PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:refresh')")
@PostMapping("/enterprise-profile/refresh/{socialCreditCode}")
public AjaxResult refreshEnterpriseProfile(@PathVariable String socialCreditCode) {
enterpriseEntitySyncService.refresh(CallerContext.from(SecurityUtils.getLoginUser()), socialCreditCode);
return AjaxResult.success("工商信息更新成功");
}
@Operation(summary = "启动实体工商信息历史补全")
@PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:backfill')")
@PostMapping("/enterprise-profile/backfill")
public AjaxResult startEnterpriseProfileBackfill() {
String taskId = enterpriseEntitySyncService.startBackfill(CallerContext.from(SecurityUtils.getLoginUser()));
return AjaxResult.success("工商信息补全任务已提交", taskId);
}
@Operation(summary = "查询实体工商信息历史补全状态")
@PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:backfill')")
@GetMapping("/enterprise-profile/backfill/{taskId}")
public AjaxResult getEnterpriseProfileBackfillStatus(@PathVariable String taskId) {
return AjaxResult.success(enterpriseEntitySyncService.getBackfillStatus(taskId));
}
@Operation(summary = "新增实体库信息")
@PreAuthorize("@ss.hasPermi('ccdi:enterpriseBaseInfo:add')")
@Log(title = "实体库管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody CcdiEnterpriseBaseInfoAddDTO addDTO) {
return toAjax(enterpriseBaseInfoService.insertEnterpriseBaseInfo(addDTO));
return toAjax(enterpriseBaseInfoService.insertEnterpriseBaseInfo(
CallerContext.from(SecurityUtils.getLoginUser()), addDTO));
}
@Operation(summary = "修改实体库信息")
@@ -86,7 +116,8 @@ public class CcdiEnterpriseBaseInfoController extends BaseController {
@Log(title = "实体库管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody CcdiEnterpriseBaseInfoEditDTO editDTO) {
return toAjax(enterpriseBaseInfoService.updateEnterpriseBaseInfo(editDTO));
return toAjax(enterpriseBaseInfoService.updateEnterpriseBaseInfo(
CallerContext.from(SecurityUtils.getLoginUser()), editDTO));
}
@Operation(summary = "删除实体库信息")
@@ -113,7 +144,8 @@ public class CcdiEnterpriseBaseInfoController extends BaseController {
return error("至少需要一条数据");
}
String taskId = enterpriseBaseInfoService.importEnterpriseBaseInfo(list);
String taskId = enterpriseBaseInfoService.importEnterpriseBaseInfo(
CallerContext.from(SecurityUtils.getLoginUser()), list);
ImportResultVO result = new ImportResultVO();
result.setTaskId(taskId);
result.setStatus("PROCESSING");

View File

@@ -5,6 +5,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
@@ -48,6 +49,30 @@ public class CcdiEnterpriseBaseInfo implements Serializable {
/** 法定代表人 */
private String legalRepresentative;
/** 注册资本 */
private BigDecimal registeredCapital;
/** 注册资本单位 */
private String registeredCapitalUnit;
/** 注册日期 */
private Date registerDate;
/** 区域编码 */
private String regionCode;
/** 区域名称 */
private String regionName;
/** 从业人数 */
private Integer employeeCount;
/** 工商缓存ID */
private Long cacheInfoId;
/** 工商同步时间 */
private Date enterpriseSyncTime;
/** 法定代表人证件类型 */
private String legalCertType;

View File

@@ -0,0 +1,21 @@
package com.ruoyi.info.collection.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/** 工商信息原始响应缓存。 */
@Data
@TableName("ccdi_enterpriseinfo_query_cache")
public class CcdiEnterpriseInfoQueryCache {
@TableId(type = IdType.AUTO)
private Long infoId;
private String queryParam;
private String queryResult;
private String queryType;
private Date createdDate;
private Date validDate;
}

View File

@@ -0,0 +1,31 @@
package com.ruoyi.info.collection.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 实体库企业完整股东。 */
@Data
@TableName("ccdi_enterprise_shareholder")
public class CcdiEnterpriseShareholder {
@TableId(type = IdType.AUTO)
private Long shareholderId;
private String socialCreditCode;
private Integer shareholderSeq;
private String shareholderName;
private String shareholderType;
private String shareholderCreditCode;
private BigDecimal stockPercent;
private BigDecimal subscribedCapital;
private String capitalUnit;
private Long cacheInfoId;
private Date syncTime;
private String createBy;
private Date createTime;
private String updateBy;
private Date updateTime;
}

View File

@@ -0,0 +1,29 @@
package com.ruoyi.info.collection.domain.model;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/** 统一工商信息结构。 */
@Data
public class EnterpriseProfile {
private String creditCode;
private String enterpriseName;
private BigDecimal registeredCapital;
private String registeredCapitalUnit;
private LocalDate registerDate;
private LocalDate establishDate;
private String industryCode;
private String industryName;
private String organizationTypeCode;
private String organizationTypeName;
private String regionCode;
private String regionName;
private String registerAddress;
private Integer employeeCount;
private String legalRepresentative;
private List<EnterpriseShareholderProfile> shareholders = new ArrayList<>();
}

View File

@@ -0,0 +1,12 @@
package com.ruoyi.info.collection.domain.model;
import java.util.Date;
/** 统一工商查询结果。 */
public record EnterpriseProfileQueryResult(
EnterpriseProfile profile,
Long cacheInfoId,
Date validDate,
boolean fromCache
) {
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.info.collection.domain.model;
import lombok.Data;
import java.math.BigDecimal;
/** 工商股东结构。 */
@Data
public class EnterpriseShareholderProfile {
private Integer sequence;
private String shareholderName;
private BigDecimal stockPercent;
private BigDecimal subscribedCapital;
private String capitalUnit;
}

View File

@@ -5,7 +5,10 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 实体库管理 VO
@@ -47,6 +50,39 @@ public class CcdiEnterpriseBaseInfoVO implements Serializable {
@Schema(description = "法定代表人")
private String legalRepresentative;
@Schema(description = "注册资本")
private BigDecimal registeredCapital;
@Schema(description = "注册资本单位")
private String registeredCapitalUnit;
@Schema(description = "注册日期")
private Date registerDate;
@Schema(description = "区域编码")
private String regionCode;
@Schema(description = "区域名称")
private String regionName;
@Schema(description = "从业人数")
private Integer employeeCount;
@Schema(description = "工商缓存ID")
private Long cacheInfoId;
@Schema(description = "工商同步时间")
private Date enterpriseSyncTime;
@Schema(description = "缓存有效期")
private Date enterpriseCacheValidDate;
@Schema(description = "是否允许重新查询")
private Boolean canRefreshEnterpriseProfile;
@Schema(description = "完整股东列表")
private List<EnterpriseShareholderVO> shareholders = new ArrayList<>();
@Schema(description = "法定代表人证件类型")
private String legalCertType;

View File

@@ -0,0 +1,16 @@
package com.ruoyi.info.collection.domain.vo;
import lombok.Data;
/** 工商信息历史补全任务状态。 */
@Data
public class EnterpriseProfileSyncTaskVO {
private String taskId;
private String status;
private Integer totalCount;
private Integer completedCount;
private Integer successCount;
private Integer skippedCount;
private Integer failureCount;
private String message;
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.info.collection.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/** 企业股东明细。 */
@Data
@Schema(description = "企业股东明细")
public class EnterpriseShareholderVO {
private Integer shareholderSeq;
private String shareholderName;
private BigDecimal stockPercent;
private BigDecimal subscribedCapital;
private String capitalUnit;
}

View File

@@ -52,4 +52,8 @@ public interface CcdiEnterpriseBaseInfoMapper extends BaseMapper<CcdiEnterpriseB
* @return 更新行数
*/
int updateBatch(List<CcdiEnterpriseBaseInfo> list);
int updateEnterpriseProfile(CcdiEnterpriseBaseInfo entity);
List<CcdiEnterpriseBaseInfo> selectEnterpriseProfileCandidates();
}

View File

@@ -0,0 +1,19 @@
package com.ruoyi.info.collection.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import org.apache.ibatis.annotations.Param;
import java.util.Date;
/** 工商信息缓存 Mapper。 */
public interface CcdiEnterpriseInfoQueryCacheMapper extends BaseMapper<CcdiEnterpriseInfoQueryCache> {
CcdiEnterpriseInfoQueryCache selectValidCache(@Param("queryParam") String queryParam,
@Param("queryType") String queryType,
@Param("now") Date now);
int upsertCache(CcdiEnterpriseInfoQueryCache cache);
CcdiEnterpriseInfoQueryCache selectCache(@Param("queryParam") String queryParam,
@Param("queryType") String queryType);
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.info.collection.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.info.collection.domain.CcdiEnterpriseShareholder;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 实体工商股东 Mapper。 */
public interface CcdiEnterpriseShareholderMapper extends BaseMapper<CcdiEnterpriseShareholder> {
List<CcdiEnterpriseShareholder> selectByCreditCode(@Param("creditCode") String creditCode);
int deleteByCreditCode(@Param("creditCode") String creditCode);
int insertBatch(@Param("list") List<CcdiEnterpriseShareholder> list);
}

View File

@@ -4,6 +4,7 @@ import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel;
import com.ruoyi.info.collection.domain.vo.EnterpriseBaseInfoImportFailureVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.lsfx.domain.CallerContext;
import java.util.List;
import java.util.Set;
@@ -16,7 +17,7 @@ import java.util.Set;
*/
public interface ICcdiEnterpriseBaseInfoImportService {
void importEnterpriseBaseInfoAsync(List<CcdiEnterpriseBaseInfoExcel> excelList, String taskId, String userName);
void importEnterpriseBaseInfoAsync(List<CcdiEnterpriseBaseInfoExcel> excelList, String taskId, CallerContext caller);
ImportStatusVO getImportStatus(String taskId);

View File

@@ -6,6 +6,7 @@ import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel;
import com.ruoyi.info.collection.domain.vo.CcdiEnterpriseBaseInfoVO;
import com.ruoyi.lsfx.domain.CallerContext;
import java.util.List;
@@ -22,13 +23,13 @@ public interface ICcdiEnterpriseBaseInfoService {
CcdiEnterpriseBaseInfoVO selectEnterpriseBaseInfoById(String socialCreditCode);
int insertEnterpriseBaseInfo(CcdiEnterpriseBaseInfoAddDTO addDTO);
int insertEnterpriseBaseInfo(CallerContext caller, CcdiEnterpriseBaseInfoAddDTO addDTO);
int updateEnterpriseBaseInfo(CcdiEnterpriseBaseInfoEditDTO editDTO);
int updateEnterpriseBaseInfo(CallerContext caller, CcdiEnterpriseBaseInfoEditDTO editDTO);
int deleteEnterpriseBaseInfoByIds(String[] socialCreditCodes);
List<CcdiEnterpriseBaseInfoExcel> selectEnterpriseBaseInfoListForExport(CcdiEnterpriseBaseInfoQueryDTO queryDTO);
String importEnterpriseBaseInfo(List<CcdiEnterpriseBaseInfoExcel> excelList);
String importEnterpriseBaseInfo(CallerContext caller, List<CcdiEnterpriseBaseInfoExcel> excelList);
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.vo.EnterpriseProfileSyncTaskVO;
import com.ruoyi.lsfx.domain.CallerContext;
import java.util.List;
/** 实体库工商信息同步服务。 */
public interface IEnterpriseEntitySyncService {
void submitSync(CallerContext caller, String socialCreditCode);
void submitSyncBatch(CallerContext caller, List<String> socialCreditCodes);
void refresh(CallerContext caller, String socialCreditCode);
String startBackfill(CallerContext caller);
EnterpriseProfileSyncTaskVO getBackfillStatus(String taskId);
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import com.ruoyi.info.collection.domain.model.EnterpriseProfileQueryResult;
import com.ruoyi.lsfx.domain.CallerContext;
/** 统一工商信息查询服务。 */
public interface IEnterpriseProfileQueryService {
EnterpriseProfileQueryResult query(CallerContext caller, String enterpriseName, boolean forceRefresh);
CcdiEnterpriseInfoQueryCache findCache(String enterpriseName);
}

View File

@@ -13,6 +13,8 @@ import com.ruoyi.info.collection.enums.EnterpriseRiskLevel;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoImportService;
import com.ruoyi.info.collection.service.IEnterpriseEntitySyncService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.data.redis.core.RedisTemplate;
@@ -47,9 +49,12 @@ public class CcdiEnterpriseBaseInfoImportServiceImpl implements ICcdiEnterpriseB
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private IEnterpriseEntitySyncService enterpriseEntitySyncService;
@Override
@Async
public void importEnterpriseBaseInfoAsync(List<CcdiEnterpriseBaseInfoExcel> excelList, String taskId, String userName) {
public void importEnterpriseBaseInfoAsync(List<CcdiEnterpriseBaseInfoExcel> excelList, String taskId, CallerContext caller) {
List<CcdiEnterpriseBaseInfo> successRecords = new ArrayList<>();
List<EnterpriseBaseInfoImportFailureVO> failures = new ArrayList<>();
Set<String> existingCreditCodes = getExistingCreditCodes(excelList);
@@ -57,7 +62,7 @@ public class CcdiEnterpriseBaseInfoImportServiceImpl implements ICcdiEnterpriseB
for (CcdiEnterpriseBaseInfoExcel excel : excelList) {
try {
CcdiEnterpriseBaseInfo entity = validateAndBuildEntity(excel, existingCreditCodes, processedCreditCodes, userName);
CcdiEnterpriseBaseInfo entity = validateAndBuildEntity(excel, existingCreditCodes, processedCreditCodes, caller.username());
successRecords.add(entity);
processedCreditCodes.add(entity.getSocialCreditCode());
} catch (Exception e) {
@@ -70,6 +75,9 @@ public class CcdiEnterpriseBaseInfoImportServiceImpl implements ICcdiEnterpriseB
if (!successRecords.isEmpty()) {
saveBatch(successRecords, 500);
enterpriseEntitySyncService.submitSyncBatch(caller, successRecords.stream()
.map(CcdiEnterpriseBaseInfo::getSocialCreditCode)
.toList());
}
if (!failures.isEmpty()) {

View File

@@ -2,10 +2,10 @@ package com.ruoyi.info.collection.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.info.collection.domain.CcdiCustEnterpriseRelation;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation;
import com.ruoyi.info.collection.domain.CcdiStaffEnterpriseRelation;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoAddDTO;
@@ -13,20 +13,27 @@ import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel;
import com.ruoyi.info.collection.domain.vo.CcdiEnterpriseBaseInfoVO;
import com.ruoyi.info.collection.domain.vo.EnterpriseShareholderVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.enums.EnterpriseRiskLevel;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseShareholderMapper;
import com.ruoyi.info.collection.mapper.CcdiCustEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoImportService;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoService;
import com.ruoyi.info.collection.service.IEnterpriseEntitySyncService;
import com.ruoyi.info.collection.service.IEnterpriseProfileQueryService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.HashMap;
import java.util.List;
@@ -62,6 +69,15 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private CcdiEnterpriseShareholderMapper enterpriseShareholderMapper;
@Resource
private IEnterpriseProfileQueryService enterpriseProfileQueryService;
@Resource
private IEnterpriseEntitySyncService enterpriseEntitySyncService;
@Override
public Page<CcdiEnterpriseBaseInfoVO> selectEnterpriseBaseInfoPage(Page<CcdiEnterpriseBaseInfoVO> page,
CcdiEnterpriseBaseInfoQueryDTO queryDTO) {
@@ -76,12 +92,21 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
}
CcdiEnterpriseBaseInfoVO vo = new CcdiEnterpriseBaseInfoVO();
BeanUtils.copyProperties(entity, vo);
vo.setShareholders(enterpriseShareholderMapper.selectByCreditCode(socialCreditCode).stream().map(item -> {
EnterpriseShareholderVO shareholder = new EnterpriseShareholderVO();
BeanUtils.copyProperties(item, shareholder);
return shareholder;
}).toList());
CcdiEnterpriseInfoQueryCache cache = enterpriseProfileQueryService.findCache(entity.getEnterpriseName());
vo.setEnterpriseCacheValidDate(cache == null ? null : cache.getValidDate());
vo.setCanRefreshEnterpriseProfile(entity.getEnterpriseSyncTime() == null || cache == null
|| cache.getValidDate() == null || !cache.getValidDate().after(new java.util.Date()));
return vo;
}
@Override
@Transactional
public int insertEnterpriseBaseInfo(CcdiEnterpriseBaseInfoAddDTO addDTO) {
public int insertEnterpriseBaseInfo(CallerContext caller, CcdiEnterpriseBaseInfoAddDTO addDTO) {
if (enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode()) != null) {
throw new RuntimeException("该统一社会信用代码已存在");
}
@@ -91,12 +116,16 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
BeanUtils.copyProperties(addDTO, entity);
entity.setStatus(trimToNull(addDTO.getStatus()));
entity.setDataSource(DataSource.MANUAL.getCode());
return enterpriseBaseInfoMapper.insert(entity);
int rows = enterpriseBaseInfoMapper.insert(entity);
if (rows > 0) {
submitSyncAfterCommit(caller, entity.getSocialCreditCode());
}
return rows;
}
@Override
@Transactional
public int updateEnterpriseBaseInfo(CcdiEnterpriseBaseInfoEditDTO editDTO) {
public int updateEnterpriseBaseInfo(CallerContext caller, CcdiEnterpriseBaseInfoEditDTO editDTO) {
CcdiEnterpriseBaseInfo existing = enterpriseBaseInfoMapper.selectById(editDTO.getSocialCreditCode());
if (existing == null) {
throw new RuntimeException("实体库记录不存在");
@@ -107,7 +136,11 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
BeanUtils.copyProperties(editDTO, entity);
entity.setStatus(trimToNull(editDTO.getStatus()));
entity.setDataSource(existing.getDataSource());
return enterpriseBaseInfoMapper.updateById(entity);
int rows = enterpriseBaseInfoMapper.updateById(entity);
if (rows > 0 && !java.util.Objects.equals(trimToNull(existing.getEnterpriseName()), trimToNull(entity.getEnterpriseName()))) {
submitSyncAfterCommit(caller, entity.getSocialCreditCode());
}
return rows;
}
@Override
@@ -118,6 +151,7 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
}
for (String socialCreditCode : socialCreditCodes) {
validateDeleteRelations(socialCreditCode);
enterpriseShareholderMapper.deleteByCreditCode(socialCreditCode);
}
return enterpriseBaseInfoMapper.deleteBatchIds(List.of(socialCreditCodes));
}
@@ -134,7 +168,7 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
@Override
@Transactional
public String importEnterpriseBaseInfo(List<CcdiEnterpriseBaseInfoExcel> excelList) {
public String importEnterpriseBaseInfo(CallerContext caller, List<CcdiEnterpriseBaseInfoExcel> excelList) {
String taskId = UUID.randomUUID().toString();
String statusKey = "import:enterpriseBaseInfo:" + taskId;
@@ -151,10 +185,24 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
redisTemplate.opsForHash().putAll(statusKey, statusData);
redisTemplate.expire(statusKey, 7, TimeUnit.DAYS);
enterpriseBaseInfoImportService.importEnterpriseBaseInfoAsync(excelList, taskId, SecurityUtils.getUsername());
enterpriseBaseInfoImportService.importEnterpriseBaseInfoAsync(
excelList, taskId, caller);
return taskId;
}
private void submitSyncAfterCommit(CallerContext caller, String socialCreditCode) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
enterpriseEntitySyncService.submitSync(caller, socialCreditCode);
}
});
} else {
enterpriseEntitySyncService.submitSync(caller, socialCreditCode);
}
}
private LambdaQueryWrapper<CcdiEnterpriseBaseInfo> buildQueryWrapper(CcdiEnterpriseBaseInfoQueryDTO queryDTO) {
LambdaQueryWrapper<CcdiEnterpriseBaseInfo> wrapper = new LambdaQueryWrapper<>();
if (queryDTO == null) {

View File

@@ -0,0 +1,304 @@
package com.ruoyi.info.collection.service.impl;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import com.ruoyi.info.collection.domain.CcdiEnterpriseShareholder;
import com.ruoyi.info.collection.domain.model.EnterpriseProfile;
import com.ruoyi.info.collection.domain.model.EnterpriseProfileQueryResult;
import com.ruoyi.info.collection.domain.model.EnterpriseShareholderProfile;
import com.ruoyi.info.collection.domain.vo.EnterpriseProfileSyncTaskVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseShareholderMapper;
import com.ruoyi.info.collection.service.IEnterpriseEntitySyncService;
import com.ruoyi.info.collection.service.IEnterpriseProfileQueryService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/** 实体库工商信息同步服务实现。 */
@Service
public class EnterpriseEntitySyncServiceImpl implements IEnterpriseEntitySyncService {
private static final String TASK_PREFIX = "enterprise:profile:backfill:";
@Resource
private CcdiEnterpriseBaseInfoMapper enterpriseMapper;
@Resource
private CcdiEnterpriseShareholderMapper shareholderMapper;
@Resource
private IEnterpriseProfileQueryService queryService;
@Resource
private TransactionTemplate transactionTemplate;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
@Qualifier("enterpriseProfileExecutor")
private Executor enterpriseProfileExecutor;
@Override
public void submitSync(CallerContext caller, String socialCreditCode) {
submitSyncBatch(caller, List.of(socialCreditCode));
}
@Override
public void submitSyncBatch(CallerContext caller, List<String> socialCreditCodes) {
if (socialCreditCodes == null || socialCreditCodes.isEmpty()) {
return;
}
List<String> normalizedCodes = socialCreditCodes.stream().filter(StringUtils::hasText).distinct().toList();
if (normalizedCodes.isEmpty()) {
return;
}
List<CcdiEnterpriseBaseInfo> entities = enterpriseMapper.selectBatchIds(normalizedCodes);
Map<String, List<CcdiEnterpriseBaseInfo>> groups = entities.stream()
.filter(item -> StringUtils.hasText(item.getEnterpriseName()))
.collect(Collectors.groupingBy(item -> item.getEnterpriseName().trim(), LinkedHashMap::new, Collectors.toList()));
groups.forEach((name, sameNameEntities) -> CompletableFuture.runAsync(() -> {
EnterpriseProfileQueryResult result = queryService.query(caller, name, false);
sameNameEntities.stream()
.filter(item -> result.profile().getCreditCode().equals(item.getSocialCreditCode()))
.findFirst()
.ifPresent(item -> applyProfile(item, result, caller.username()));
}, enterpriseProfileExecutor));
}
@Override
public void refresh(CallerContext caller, String socialCreditCode) {
CcdiEnterpriseBaseInfo entity = requireEntity(socialCreditCode);
CcdiEnterpriseInfoQueryCache cache = queryService.findCache(entity.getEnterpriseName());
boolean cacheValid = cache != null && cache.getValidDate() != null && cache.getValidDate().after(new Date());
if (entity.getEnterpriseSyncTime() != null && cacheValid) {
throw new IllegalStateException("工商缓存仍在有效期内,无需重新查询");
}
SyncOutcome outcome = syncByCreditCode(caller, socialCreditCode, !cacheValid);
if (outcome != SyncOutcome.SUCCESS) {
throw new IllegalStateException("工商信用代码与实体主键不一致");
}
}
@Override
public String startBackfill(CallerContext caller) {
String taskId = UUID.randomUUID().toString().replace("-", "");
List<CcdiEnterpriseBaseInfo> candidates = enterpriseMapper.selectEnterpriseProfileCandidates();
Map<String, List<CcdiEnterpriseBaseInfo>> groups = candidates.stream()
.filter(item -> StringUtils.hasText(item.getEnterpriseName()))
.collect(Collectors.groupingBy(item -> item.getEnterpriseName().trim(), LinkedHashMap::new, Collectors.toList()));
initializeTask(taskId, groups.size());
CompletableFuture.runAsync(() -> runBackfill(taskId, caller, groups));
return taskId;
}
@Override
public EnterpriseProfileSyncTaskVO getBackfillStatus(String taskId) {
Map<Object, Object> status = redisTemplate.opsForHash().entries(taskKey(taskId));
if (status.isEmpty()) {
throw new IllegalArgumentException("任务不存在或已过期");
}
EnterpriseProfileSyncTaskVO vo = new EnterpriseProfileSyncTaskVO();
vo.setTaskId(string(status.get("taskId")));
vo.setStatus(string(status.get("status")));
vo.setTotalCount(number(status.get("totalCount")));
vo.setCompletedCount(number(status.get("completedCount")));
vo.setSuccessCount(number(status.get("successCount")));
vo.setSkippedCount(number(status.get("skippedCount")));
vo.setFailureCount(number(status.get("failureCount")));
vo.setMessage(string(status.get("message")));
return vo;
}
private void runBackfill(String taskId, CallerContext caller,
Map<String, List<CcdiEnterpriseBaseInfo>> groups) {
AtomicInteger completed = new AtomicInteger();
AtomicInteger success = new AtomicInteger();
AtomicInteger skipped = new AtomicInteger();
AtomicInteger failure = new AtomicInteger();
List<Map.Entry<String, List<CcdiEnterpriseBaseInfo>>> entries = new ArrayList<>(groups.entrySet());
for (int start = 0; start < entries.size(); start += 100) {
int end = Math.min(start + 100, entries.size());
List<CompletableFuture<Void>> futures = entries.subList(start, end).stream()
.map(entry -> CompletableFuture.runAsync(() -> {
try {
EnterpriseProfileQueryResult result = queryService.query(caller, entry.getKey(), false);
CcdiEnterpriseBaseInfo match = entry.getValue().stream()
.filter(item -> result.profile().getCreditCode().equals(item.getSocialCreditCode()))
.findFirst().orElse(null);
if (match == null) {
skipped.incrementAndGet();
} else {
applyProfile(match, result, caller.username());
success.incrementAndGet();
}
} catch (Exception e) {
failure.incrementAndGet();
} finally {
completed.incrementAndGet();
updateTask(taskId, "PROCESSING", completed.get(), success.get(), skipped.get(), failure.get());
}
}, enterpriseProfileExecutor)).toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
updateTask(taskId, "COMPLETED", completed.get(), success.get(), skipped.get(), failure.get());
}
private SyncOutcome syncByCreditCode(CallerContext caller, String socialCreditCode,
boolean forceRefresh) {
CcdiEnterpriseBaseInfo entity = requireEntity(socialCreditCode);
EnterpriseProfileQueryResult result = queryService.query(caller, entity.getEnterpriseName(), forceRefresh);
if (!result.profile().getCreditCode().equals(entity.getSocialCreditCode())) {
return SyncOutcome.SKIPPED;
}
applyProfile(entity, result, caller.username());
return SyncOutcome.SUCCESS;
}
private void applyProfile(CcdiEnterpriseBaseInfo existing, EnterpriseProfileQueryResult result, String username) {
transactionTemplate.executeWithoutResult(status -> {
EnterpriseProfile profile = result.profile();
Date now = new Date();
CcdiEnterpriseBaseInfo update = new CcdiEnterpriseBaseInfo();
update.setSocialCreditCode(existing.getSocialCreditCode());
update.setEnterpriseName(existing.getEnterpriseName().trim());
update.setEnterpriseType(profile.getOrganizationTypeName());
update.setIndustryClass(profile.getIndustryCode());
update.setIndustryName(profile.getIndustryName());
update.setEstablishDate(toDate(profile.getEstablishDate()));
update.setRegisterAddress(profile.getRegisterAddress());
update.setLegalRepresentative(profile.getLegalRepresentative());
update.setRegisteredCapital(profile.getRegisteredCapital());
update.setRegisteredCapitalUnit(profile.getRegisteredCapitalUnit());
update.setRegisterDate(toDate(profile.getRegisterDate()));
update.setRegionCode(profile.getRegionCode());
update.setRegionName(profile.getRegionName());
update.setEmployeeCount(profile.getEmployeeCount());
update.setCacheInfoId(result.cacheInfoId());
update.setEnterpriseSyncTime(now);
update.setDataSource(DataSource.API.getCode());
update.setUpdatedBy(username);
setTopFive(update, profile.getShareholders());
if (enterpriseMapper.updateEnterpriseProfile(update) != 1) {
throw new IllegalStateException("实体工商信息更新失败");
}
shareholderMapper.deleteByCreditCode(existing.getSocialCreditCode());
List<CcdiEnterpriseShareholder> shareholders = profile.getShareholders().stream()
.map(item -> toEntityShareholder(existing.getSocialCreditCode(), item, result.cacheInfoId(), now, username))
.toList();
if (!shareholders.isEmpty()) {
shareholderMapper.insertBatch(shareholders);
}
});
}
private CcdiEnterpriseShareholder toEntityShareholder(String creditCode,
EnterpriseShareholderProfile item,
Long cacheInfoId,
Date now,
String username) {
CcdiEnterpriseShareholder shareholder = new CcdiEnterpriseShareholder();
shareholder.setSocialCreditCode(creditCode);
shareholder.setShareholderSeq(item.getSequence());
shareholder.setShareholderName(item.getShareholderName());
shareholder.setStockPercent(item.getStockPercent());
shareholder.setSubscribedCapital(item.getSubscribedCapital());
shareholder.setCapitalUnit(item.getCapitalUnit());
shareholder.setCacheInfoId(cacheInfoId);
shareholder.setSyncTime(now);
shareholder.setCreateBy(username);
shareholder.setCreateTime(now);
shareholder.setUpdateBy(username);
shareholder.setUpdateTime(now);
return shareholder;
}
private void setTopFive(CcdiEnterpriseBaseInfo entity, List<EnterpriseShareholderProfile> shareholders) {
String[] names = new String[5];
for (int i = 0; i < Math.min(names.length, shareholders.size()); i++) {
names[i] = shareholders.get(i).getShareholderName();
}
entity.setShareholder1(names[0]);
entity.setShareholder2(names[1]);
entity.setShareholder3(names[2]);
entity.setShareholder4(names[3]);
entity.setShareholder5(names[4]);
}
private CcdiEnterpriseBaseInfo requireEntity(String socialCreditCode) {
CcdiEnterpriseBaseInfo entity = enterpriseMapper.selectById(socialCreditCode);
if (entity == null) {
throw new IllegalArgumentException("实体库记录不存在");
}
if (!StringUtils.hasText(entity.getEnterpriseName())) {
throw new IllegalArgumentException("企业名称不能为空");
}
return entity;
}
private Date toDate(java.time.LocalDate value) {
return value == null ? null : Date.from(value.atStartOfDay(ZoneId.systemDefault()).toInstant());
}
private void initializeTask(String taskId, int totalCount) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("taskId", taskId);
values.put("status", "PROCESSING");
values.put("totalCount", totalCount);
values.put("completedCount", 0);
values.put("successCount", 0);
values.put("skippedCount", 0);
values.put("failureCount", 0);
values.put("message", "正在补全工商信息");
redisTemplate.opsForHash().putAll(taskKey(taskId), values);
redisTemplate.expire(taskKey(taskId), 7, TimeUnit.DAYS);
}
private void updateTask(String taskId, String status, int completed, int success, int skipped, int failure) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("status", status);
values.put("completedCount", completed);
values.put("successCount", success);
values.put("skippedCount", skipped);
values.put("failureCount", failure);
values.put("message", "COMPLETED".equals(status) ? "工商信息补全完成" : "正在补全工商信息");
redisTemplate.opsForHash().putAll(taskKey(taskId), values);
}
private String taskKey(String taskId) {
return TASK_PREFIX + taskId;
}
private String string(Object value) {
return value == null ? null : value.toString();
}
private Integer number(Object value) {
return value == null ? 0 : Integer.valueOf(value.toString());
}
private enum SyncOutcome {
SUCCESS, SKIPPED
}
}

View File

@@ -0,0 +1,92 @@
package com.ruoyi.info.collection.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import com.ruoyi.info.collection.domain.model.EnterpriseProfile;
import com.ruoyi.info.collection.domain.model.EnterpriseProfileQueryResult;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseInfoQueryCacheMapper;
import com.ruoyi.info.collection.service.IEnterpriseProfileQueryService;
import com.ruoyi.info.collection.service.support.XinhuaEnterpriseProfileParser;
import com.ruoyi.lsfx.client.XinhuaEnterpriseClient;
import com.ruoyi.lsfx.domain.CallerContext;
import com.ruoyi.lsfx.domain.response.XinhuaEnterpriseQueryResult;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
/** 统一工商信息查询服务实现。 */
@Service
public class EnterpriseProfileQueryServiceImpl implements IEnterpriseProfileQueryService {
public static final String QUERY_TYPE = "EnterpriseProfile";
private static final int CACHE_VALID_DAYS = 180;
@Resource
private CcdiEnterpriseInfoQueryCacheMapper cacheMapper;
@Resource
private XinhuaEnterpriseClient xinhuaEnterpriseClient;
@Resource
private XinhuaEnterpriseProfileParser profileParser;
@Resource
private ObjectMapper objectMapper;
@Override
public EnterpriseProfileQueryResult query(CallerContext caller, String enterpriseName, boolean forceRefresh) {
String normalizedName = normalize(enterpriseName);
if (!StringUtils.hasText(normalizedName)) {
throw new IllegalArgumentException("企业名称不能为空");
}
Date now = new Date();
if (!forceRefresh) {
CcdiEnterpriseInfoQueryCache cache = cacheMapper.selectValidCache(normalizedName, QUERY_TYPE, now);
if (cache != null) {
return fromCache(cache);
}
}
XinhuaEnterpriseQueryResult external = xinhuaEnterpriseClient.query(caller, normalizedName);
EnterpriseProfile profile = profileParser.parse(external.profile());
Date successTime = new Date();
Date validDate = Date.from(LocalDateTime.ofInstant(successTime.toInstant(), ZoneId.systemDefault())
.plusDays(CACHE_VALID_DAYS)
.atZone(ZoneId.systemDefault()).toInstant());
CcdiEnterpriseInfoQueryCache cache = new CcdiEnterpriseInfoQueryCache();
cache.setQueryParam(normalizedName);
cache.setQueryType(QUERY_TYPE);
cache.setQueryResult(external.rawJson());
cache.setCreatedDate(successTime);
cache.setValidDate(validDate);
cacheMapper.upsertCache(cache);
CcdiEnterpriseInfoQueryCache saved = cacheMapper.selectCache(normalizedName, QUERY_TYPE);
return new EnterpriseProfileQueryResult(profile, saved.getInfoId(), saved.getValidDate(), false);
}
@Override
public CcdiEnterpriseInfoQueryCache findCache(String enterpriseName) {
String normalizedName = normalize(enterpriseName);
return StringUtils.hasText(normalizedName) ? cacheMapper.selectCache(normalizedName, QUERY_TYPE) : null;
}
private EnterpriseProfileQueryResult fromCache(CcdiEnterpriseInfoQueryCache cache) {
try {
JsonNode root = objectMapper.readTree(cache.getQueryResult());
EnterpriseProfile profile = profileParser.parse(root.path("data").path("mappingOutputFields"));
return new EnterpriseProfileQueryResult(profile, cache.getInfoId(), cache.getValidDate(), true);
} catch (Exception e) {
throw new IllegalStateException("工商缓存内容无法解析: infoId=" + cache.getInfoId(), e);
}
}
private String normalize(String value) {
return value == null ? null : value.trim();
}
}

View File

@@ -4,10 +4,14 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.service.IEnterpriseEntitySyncService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -27,6 +31,9 @@ public class EnterpriseAutoFillService {
@Resource
private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper;
@Resource
private IEnterpriseEntitySyncService enterpriseEntitySyncService;
public record EnterpriseFillItem(
String socialCreditCode,
String enterpriseName,
@@ -63,6 +70,7 @@ public class EnterpriseAutoFillService {
return;
}
insertBatchIgnoreDuplicate(missingEntities);
submitSyncAfterCommit(missingEntities);
}
private Map<String, EnterpriseFillItem> normalizeItems(List<EnterpriseFillItem> items) {
@@ -121,6 +129,23 @@ public class EnterpriseAutoFillService {
}
}
private void submitSyncAfterCommit(List<CcdiEnterpriseBaseInfo> entities) {
String username = entities.stream().map(CcdiEnterpriseBaseInfo::getCreatedBy)
.filter(StringUtils::isNotEmpty).findFirst().orElse("system");
CallerContext caller = CallerContext.of(null, username);
List<String> creditCodes = entities.stream().map(CcdiEnterpriseBaseInfo::getSocialCreditCode).toList();
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
enterpriseEntitySyncService.submitSyncBatch(caller, creditCodes);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
enterpriseEntitySyncService.submitSyncBatch(caller, creditCodes);
}
});
}
private String trimToNull(String value) {
if (StringUtils.isEmpty(value)) {
return null;

View File

@@ -0,0 +1,141 @@
package com.ruoyi.info.collection.service.support;
import com.fasterxml.jackson.databind.JsonNode;
import com.ruoyi.info.collection.domain.model.EnterpriseProfile;
import com.ruoyi.info.collection.domain.model.EnterpriseShareholderProfile;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/** 将新华社 mappingOutputFields 解析为统一工商结构。 */
@Component
public class XinhuaEnterpriseProfileParser {
public EnterpriseProfile parse(JsonNode node) {
if (node == null || !node.isObject() || node.isEmpty()) {
throw new IllegalArgumentException("工商信息对象不能为空");
}
EnterpriseProfile profile = new EnterpriseProfile();
profile.setCreditCode(text(node, "creditCode"));
profile.setEnterpriseName(text(node, "enterpriseName"));
JsonNode capital = node.path("regCapitalAmount");
profile.setRegisteredCapital(decimal(text(capital, "regist_capi_value"), false));
profile.setRegisteredCapitalUnit(text(capital, "regist_capi_unit"));
profile.setRegisterDate(date(text(node, "estiblishTime")));
profile.setEstablishDate(date(text(node, "fromTime")));
JsonNode industry = node.path("industry");
profile.setIndustryCode(text(industry, "industry_code"));
profile.setIndustryName(text(industry, "industry"));
JsonNode orgType = node.path("companyOrgType");
if (orgType.isObject()) {
profile.setOrganizationTypeCode(text(orgType, "econ_kind_code"));
profile.setOrganizationTypeName(text(orgType, "econ_kind"));
} else if (orgType.isTextual()) {
profile.setOrganizationTypeName(normalize(orgType.asText()));
}
JsonNode city = node.path("baseCity");
profile.setRegionCode(text(city, "province_code"));
profile.setRegionName(regionName(city));
profile.setRegisterAddress(text(node, "regLocation"));
profile.setEmployeeCount(integer(node.get("employeeCount")));
profile.setLegalRepresentative(text(node, "legalPersonName"));
profile.setShareholders(shareholders(node.path("holders")));
return profile;
}
private List<EnterpriseShareholderProfile> shareholders(JsonNode holders) {
List<EnterpriseShareholderProfile> result = new ArrayList<>();
if (!holders.isArray()) {
return result;
}
int sequence = 1;
for (JsonNode holder : holders) {
String name = text(holder, "stock_name");
if (!StringUtils.hasText(name)) {
continue;
}
EnterpriseShareholderProfile item = new EnterpriseShareholderProfile();
item.setSequence(sequence++);
item.setShareholderName(name);
item.setStockPercent(decimal(text(holder, "stock_percent"), true));
item.setSubscribedCapital(decimal(text(holder, "should_capi"), false));
item.setCapitalUnit("万元");
result.add(item);
}
return result;
}
private String regionName(JsonNode city) {
Set<String> values = new LinkedHashSet<>();
add(values, text(city, "province"));
add(values, text(city, "city"));
add(values, text(city, "county"));
return values.isEmpty() ? null : String.join("", values);
}
private void add(Set<String> values, String value) {
if (StringUtils.hasText(value)) {
values.add(value);
}
}
private String text(JsonNode node, String field) {
if (node == null || !node.isObject()) {
return null;
}
JsonNode value = node.get(field);
return value == null || value.isNull() ? null : normalize(value.asText());
}
private String normalize(String value) {
return value == null ? null : value.trim();
}
private BigDecimal decimal(String value, boolean percentage) {
if (!StringUtils.hasText(value)) {
return null;
}
String normalized = value.replace(",", "").trim();
if (percentage && normalized.endsWith("%")) {
normalized = normalized.substring(0, normalized.length() - 1).trim();
}
try {
return new BigDecimal(normalized);
} catch (NumberFormatException e) {
return null;
}
}
private Integer integer(JsonNode value) {
if (value == null || value.isNull()) {
return null;
}
try {
return Integer.valueOf(value.asText().trim());
} catch (NumberFormatException e) {
return null;
}
}
private LocalDate date(String value) {
if (!StringUtils.hasText(value) || value.length() < 10) {
return null;
}
try {
return LocalDate.parse(value.substring(0, 10));
} catch (DateTimeParseException e) {
return null;
}
}
}

View File

@@ -14,6 +14,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="establishDate" column="establish_date"/>
<result property="registerAddress" column="register_address"/>
<result property="legalRepresentative" column="legal_representative"/>
<result property="registeredCapital" column="registered_capital"/>
<result property="registeredCapitalUnit" column="registered_capital_unit"/>
<result property="registerDate" column="register_date"/>
<result property="regionCode" column="region_code"/>
<result property="regionName" column="region_name"/>
<result property="employeeCount" column="employee_count"/>
<result property="cacheInfoId" column="cache_info_id"/>
<result property="enterpriseSyncTime" column="enterprise_sync_time"/>
<result property="legalCertType" column="legal_cert_type"/>
<result property="legalCertNo" column="legal_cert_no"/>
<result property="shareholder1" column="shareholder1"/>
@@ -39,6 +47,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
establish_date,
register_address,
legal_representative,
registered_capital,
registered_capital_unit,
register_date,
region_code,
region_name,
employee_count,
cache_info_id,
enterprise_sync_time,
legal_cert_type,
legal_cert_no,
shareholder1,
@@ -178,4 +194,39 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</foreach>
</update>
<update id="updateEnterpriseProfile" parameterType="com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo">
update ccdi_enterprise_base_info
set enterprise_type = #{enterpriseType},
industry_class = #{industryClass},
industry_name = #{industryName},
establish_date = #{establishDate},
register_address = #{registerAddress},
legal_representative = #{legalRepresentative},
registered_capital = #{registeredCapital},
registered_capital_unit = #{registeredCapitalUnit},
register_date = #{registerDate},
region_code = #{regionCode},
region_name = #{regionName},
employee_count = #{employeeCount},
cache_info_id = #{cacheInfoId},
enterprise_sync_time = #{enterpriseSyncTime},
shareholder1 = #{shareholder1},
shareholder2 = #{shareholder2},
shareholder3 = #{shareholder3},
shareholder4 = #{shareholder4},
shareholder5 = #{shareholder5},
data_source = #{dataSource},
updated_by = #{updatedBy},
update_time = now()
where social_credit_code = #{socialCreditCode}
and trim(enterprise_name) = trim(#{enterpriseName})
</update>
<select id="selectEnterpriseProfileCandidates" resultType="com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo">
select social_credit_code, enterprise_name, cache_info_id, enterprise_sync_time
from ccdi_enterprise_base_info
where enterprise_name is not null and trim(enterprise_name) != ''
order by enterprise_name, social_credit_code
</select>
</mapper>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.info.collection.mapper.CcdiEnterpriseInfoQueryCacheMapper">
<select id="selectValidCache" resultType="com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache">
select info_id, query_param, query_result, query_type, created_date, valid_date
from ccdi_enterpriseinfo_query_cache
where query_param = #{queryParam}
and query_type = #{queryType}
and valid_date &gt; #{now}
limit 1
</select>
<select id="selectCache" resultType="com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache">
select info_id, query_param, query_result, query_type, created_date, valid_date
from ccdi_enterpriseinfo_query_cache
where query_param = #{queryParam}
and query_type = #{queryType}
limit 1
</select>
<insert id="upsertCache" parameterType="com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache">
insert into ccdi_enterpriseinfo_query_cache
(query_param, query_result, query_type, created_date, valid_date)
values
(#{queryParam}, #{queryResult}, #{queryType}, #{createdDate}, #{validDate})
on duplicate key update
query_result = values(query_result),
created_date = values(created_date),
valid_date = values(valid_date)
</insert>
</mapper>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.info.collection.mapper.CcdiEnterpriseShareholderMapper">
<select id="selectByCreditCode" resultType="com.ruoyi.info.collection.domain.CcdiEnterpriseShareholder">
select shareholder_id, social_credit_code, shareholder_seq, shareholder_name,
shareholder_type, shareholder_credit_code, stock_percent, subscribed_capital,
capital_unit, cache_info_id, sync_time, create_by, create_time, update_by, update_time
from ccdi_enterprise_shareholder
where social_credit_code = #{creditCode}
order by shareholder_seq
</select>
<delete id="deleteByCreditCode">
delete from ccdi_enterprise_shareholder where social_credit_code = #{creditCode}
</delete>
<insert id="insertBatch">
insert into ccdi_enterprise_shareholder (
social_credit_code, shareholder_seq, shareholder_name, shareholder_type,
shareholder_credit_code, stock_percent, subscribed_capital, capital_unit,
cache_info_id, sync_time, create_by, create_time, update_by, update_time
) values
<foreach collection="list" item="item" separator=",">
(#{item.socialCreditCode}, #{item.shareholderSeq}, #{item.shareholderName}, #{item.shareholderType},
#{item.shareholderCreditCode}, #{item.stockPercent}, #{item.subscribedCapital}, #{item.capitalUnit},
#{item.cacheInfoId}, #{item.syncTime}, #{item.createBy}, #{item.createTime}, #{item.updateBy}, #{item.updateTime})
</foreach>
</insert>
</mapper>

View File

@@ -6,9 +6,11 @@ import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO;
import com.ruoyi.info.collection.domain.vo.CcdiEnterpriseBaseInfoVO;
import com.ruoyi.info.collection.mapper.CcdiCustEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseShareholderMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.impl.CcdiEnterpriseBaseInfoServiceImpl;
import com.ruoyi.lsfx.domain.CallerContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -49,13 +51,22 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private CcdiEnterpriseShareholderMapper enterpriseShareholderMapper;
@Mock
private IEnterpriseProfileQueryService enterpriseProfileQueryService;
@Mock
private IEnterpriseEntitySyncService enterpriseEntitySyncService;
@Test
void insertEnterpriseBaseInfo_shouldPersistWhenSocialCreditCodeIsUnique() {
CcdiEnterpriseBaseInfoAddDTO addDTO = buildAddDto();
when(enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode())).thenReturn(null);
when(enterpriseBaseInfoMapper.insert(any(CcdiEnterpriseBaseInfo.class))).thenReturn(1);
int result = service.insertEnterpriseBaseInfo(addDTO);
int result = service.insertEnterpriseBaseInfo(CallerContext.system(), addDTO);
assertEquals(1, result);
ArgumentCaptor<CcdiEnterpriseBaseInfo> captor = ArgumentCaptor.forClass(CcdiEnterpriseBaseInfo.class);
@@ -72,7 +83,7 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
when(enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode())).thenReturn(null);
when(enterpriseBaseInfoMapper.insert(any(CcdiEnterpriseBaseInfo.class))).thenReturn(1);
int result = service.insertEnterpriseBaseInfo(addDTO);
int result = service.insertEnterpriseBaseInfo(CallerContext.system(), addDTO);
assertEquals(1, result);
ArgumentCaptor<CcdiEnterpriseBaseInfo> captor = ArgumentCaptor.forClass(CcdiEnterpriseBaseInfo.class);
@@ -88,7 +99,7 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
when(enterpriseBaseInfoMapper.selectById(addDTO.getSocialCreditCode())).thenReturn(null);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.insertEnterpriseBaseInfo(addDTO));
() -> service.insertEnterpriseBaseInfo(CallerContext.system(), addDTO));
assertEquals("风险等级不在允许范围内", exception.getMessage());
}
@@ -99,7 +110,7 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
when(enterpriseBaseInfoMapper.selectById(editDTO.getSocialCreditCode())).thenReturn(null);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.updateEnterpriseBaseInfo(editDTO));
() -> service.updateEnterpriseBaseInfo(CallerContext.system(), editDTO));
assertEquals("实体库记录不存在", exception.getMessage());
}
@@ -114,7 +125,7 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
when(enterpriseBaseInfoMapper.selectById(editDTO.getSocialCreditCode())).thenReturn(existing);
when(enterpriseBaseInfoMapper.updateById(any(CcdiEnterpriseBaseInfo.class))).thenReturn(1);
int result = service.updateEnterpriseBaseInfo(editDTO);
int result = service.updateEnterpriseBaseInfo(CallerContext.system(), editDTO);
assertEquals(1, result);
ArgumentCaptor<CcdiEnterpriseBaseInfo> captor = ArgumentCaptor.forClass(CcdiEnterpriseBaseInfo.class);
@@ -130,6 +141,7 @@ class CcdiEnterpriseBaseInfoServiceImplTest {
entity.setRiskLevel("1");
entity.setEntSource("GENERAL");
when(enterpriseBaseInfoMapper.selectById("91310000123456789A")).thenReturn(entity);
when(enterpriseShareholderMapper.selectByCreditCode("91310000123456789A")).thenReturn(java.util.List.of());
CcdiEnterpriseBaseInfoVO vo = service.selectEnterpriseBaseInfoById("91310000123456789A");

View File

@@ -0,0 +1,111 @@
package com.ruoyi.lsfx.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.utils.uuid.IdUtils;
import com.ruoyi.lsfx.domain.CallerContext;
import com.ruoyi.lsfx.domain.response.XinhuaEnterpriseQueryResult;
import com.ruoyi.lsfx.exception.LsfxApiException;
import com.ruoyi.lsfx.util.HttpUtil;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.Map;
/** 新华社工商信息同步接口客户端。 */
@Component
public class XinhuaEnterpriseClient {
private static final int PLATFORM_SUCCESS_CODE = 10000;
private static final int BUSINESS_SUCCESS_STATUS = 1;
private static final int BUSINESS_SUCCESS_REASON_CODE = 200;
@Resource
private HttpUtil httpUtil;
@Resource
private ObjectMapper objectMapper;
@Value("${xinhua-enterprise.api.url}")
private String apiUrl;
@Value("${xinhua-enterprise.api.org-code:999000}")
private String orgCode;
@Value("${xinhua-enterprise.api.run-type:1}")
private String runType;
public XinhuaEnterpriseQueryResult query(CallerContext caller, String enterpriseName) {
String normalizedName = normalize(enterpriseName);
if (!StringUtils.hasText(normalizedName)) {
throw new IllegalArgumentException("企业名称不能为空");
}
Map<String, Object> params = new LinkedHashMap<>();
params.put("entName", normalizedName);
params.put("serialNum", buildSerialNum());
params.put("runType", runType);
params.put("orgCode", orgCode);
String rawJson = httpUtil.postUrlEncodedFormForString(caller, apiUrl, params, null);
JsonNode root = parse(rawJson);
JsonNode data = requireSuccess(root);
JsonNode profile = data.path("mappingOutputFields");
if (!profile.isObject() || profile.isEmpty()) {
throw new LsfxApiException("新华社工商接口返回工商信息为空");
}
String responseName = normalize(profile.path("enterpriseName").asText(null));
if (!normalizedName.equals(responseName)) {
throw new LsfxApiException("新华社工商接口返回企业名称与查询名称不一致");
}
if (!StringUtils.hasText(normalize(profile.path("creditCode").asText(null)))) {
throw new LsfxApiException("新华社工商接口未返回统一社会信用代码");
}
return new XinhuaEnterpriseQueryResult(rawJson, profile);
}
private JsonNode parse(String rawJson) {
try {
return objectMapper.readTree(rawJson);
} catch (Exception e) {
throw new LsfxApiException("新华社工商接口响应不是合法JSON", e);
}
}
private JsonNode requireSuccess(JsonNode root) {
if (!root.path("success").asBoolean(false)) {
throw new LsfxApiException(message(root, "新华社工商接口平台调用失败"));
}
if (root.path("code").asInt(Integer.MIN_VALUE) != PLATFORM_SUCCESS_CODE) {
throw new LsfxApiException("新华社工商接口平台状态码异常: " + root.path("code").asText());
}
JsonNode data = root.path("data");
if (!data.isObject()) {
throw new LsfxApiException("新华社工商接口返回data为空");
}
if (data.path("status").asInt(Integer.MIN_VALUE) != BUSINESS_SUCCESS_STATUS) {
throw new LsfxApiException(message(root, "新华社工商接口业务状态异常"));
}
if (data.path("reasonCode").asInt(Integer.MIN_VALUE) != BUSINESS_SUCCESS_REASON_CODE) {
throw new LsfxApiException(message(root, "新华社工商接口业务原因码异常"));
}
return data;
}
private String message(JsonNode root, String defaultMessage) {
String reason = normalize(root.path("data").path("reasonMessage").asText(null));
return StringUtils.hasText(reason) ? reason : defaultMessage;
}
private String buildSerialNum() {
return "CCDI_GS_" + System.currentTimeMillis() + "_" + IdUtils.fastSimpleUUID();
}
private String normalize(String value) {
return value == null ? null : value.trim();
}
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.lsfx.domain.response;
import com.fasterxml.jackson.databind.JsonNode;
/** 新华社工商信息同步查询结果。 */
public record XinhuaEnterpriseQueryResult(String rawJson, JsonNode profile) {
}

View File

@@ -30,6 +30,13 @@
<version>${ruoyi.version}</version>
</dependency>
<!-- 企业工商信息统一查询能力 -->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ccdi-info-collection</artifactId>
<version>${ruoyi.version}</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>

View File

@@ -186,8 +186,8 @@ public class CcdiFileUploadController extends BaseController {
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult deleteFile(@PathVariable Long id) {
projectAccessService.assertCanOperateByFileRecordId(id);
Long userId = SecurityUtils.getUserId();
String message = fileUploadService.deleteFileUploadRecord(id, userId);
CallerContext caller = CallerContext.from(SecurityUtils.getLoginUser());
String message = fileUploadService.deleteFileUploadRecord(id, caller);
return AjaxResult.success(message);
}
}

View File

@@ -0,0 +1,56 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.ProjectCounterpartyRefreshDTO;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.lsfx.domain.CallerContext;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/ccdi/project/counterparty-enterprise")
@Tag(name = "项目对手方工商信息")
public class CcdiProjectCounterpartyEnterpriseController {
@Resource
private IProjectCounterpartyEnterpriseService service;
@GetMapping("/detail")
@Operation(summary = "查询本地项目对手方工商详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult detail(@RequestParam Long projectId, @RequestParam String counterpartyName) {
return AjaxResult.success(service.getDetail(projectId, counterpartyName));
}
@PostMapping("/refresh")
@Operation(summary = "重新查询单个项目对手方工商信息")
@PreAuthorize("@ss.hasPermi('ccdi:project:counterpartyEnterprise:refresh')")
public AjaxResult refresh(@Valid @RequestBody ProjectCounterpartyRefreshDTO dto) {
return AjaxResult.success(service.refresh(CallerContext.from(SecurityUtils.getLoginUser()),
dto.getProjectId(), dto.getCounterpartyName()));
}
@PostMapping("/backfill")
@Operation(summary = "启动项目对手方工商信息历史补全")
@PreAuthorize("@ss.hasPermi('ccdi:project:counterpartyEnterprise:backfill')")
public AjaxResult backfill() {
return AjaxResult.success("任务已提交", service.startBackfill(CallerContext.from(SecurityUtils.getLoginUser())));
}
@GetMapping("/backfill/{taskId}")
@Operation(summary = "查询项目对手方工商信息补全状态")
@PreAuthorize("@ss.hasPermi('ccdi:project:counterpartyEnterprise:backfill')")
public AjaxResult backfillStatus(@PathVariable String taskId) {
return AjaxResult.success(service.getBackfillStatus(taskId));
}
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.ccdi.project.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class ProjectCounterpartyRefreshDTO {
@NotNull(message = "项目ID不能为空")
private Long projectId;
@NotBlank(message = "对手方名称不能为空")
private String counterpartyName;
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.ccdi.project.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Date;
/** 项目对手方工商信息。 */
@Data
@TableName("ccdi_project_counterparty_enterprise")
public class CcdiProjectCounterpartyEnterprise {
@TableId(type = IdType.AUTO)
private Long counterpartyEnterpriseId;
private Long projectId;
private String counterpartyName;
private String socialCreditCode;
private String enterpriseName;
private BigDecimal registeredCapital;
private String registeredCapitalUnit;
private LocalDate registerDate;
private LocalDate establishDate;
private String industryCode;
private String industryName;
private String organizationTypeCode;
private String organizationTypeName;
private String regionCode;
private String regionName;
private String registerAddress;
private Integer employeeCount;
private String legalRepresentative;
private Long cacheInfoId;
private Date syncTime;
private String createBy;
private Date createTime;
private String updateBy;
private Date updateTime;
}

View File

@@ -0,0 +1,26 @@
package com.ruoyi.ccdi.project.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 项目对手方股东信息。 */
@Data
@TableName("ccdi_project_counterparty_shareholder")
public class CcdiProjectCounterpartyShareholder {
@TableId(type = IdType.AUTO)
private Long shareholderId;
private Long counterpartyEnterpriseId;
@TableField("shareholder_seq")
private Integer sequenceNo;
private String shareholderName;
private BigDecimal stockPercent;
private BigDecimal subscribedCapital;
private String capitalUnit;
private Date createTime;
}

View File

@@ -1,6 +1,7 @@
package com.ruoyi.ccdi.project.domain.event;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.lsfx.domain.CallerContext;
/**
* 历史项目导入提交事件
@@ -10,14 +11,14 @@ public class CcdiProjectHistoryImportSubmittedEvent {
private final Long targetProjectId;
private final Integer targetLsfxProjectId;
private final CcdiProjectImportHistoryDTO dto;
private final String operator;
private final CallerContext caller;
public CcdiProjectHistoryImportSubmittedEvent(Long targetProjectId, Integer targetLsfxProjectId,
CcdiProjectImportHistoryDTO dto, String operator) {
CcdiProjectImportHistoryDTO dto, CallerContext caller) {
this.targetProjectId = targetProjectId;
this.targetLsfxProjectId = targetLsfxProjectId;
this.dto = dto;
this.operator = operator;
this.caller = caller;
}
public Long getTargetProjectId() {
@@ -32,7 +33,7 @@ public class CcdiProjectHistoryImportSubmittedEvent {
return dto;
}
public String getOperator() {
return operator;
public CallerContext getCaller() {
return caller;
}
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@Data
public class ProjectCounterpartyEnterpriseVO {
private Long projectId;
private String counterpartyName;
private String status;
private Boolean canRefresh;
private Date cacheValidDate;
private String socialCreditCode;
private String enterpriseName;
private BigDecimal registeredCapital;
private String registeredCapitalUnit;
private LocalDate registerDate;
private LocalDate establishDate;
private String industryCode;
private String industryName;
private String organizationTypeCode;
private String organizationTypeName;
private String regionCode;
private String regionName;
private String registerAddress;
private Integer employeeCount;
private String legalRepresentative;
private Date syncTime;
private List<ProjectCounterpartyShareholderVO> shareholders = new ArrayList<>();
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class ProjectCounterpartyShareholderVO {
private Integer sequenceNo;
private String shareholderName;
private BigDecimal stockPercent;
private BigDecimal subscribedCapital;
private String capitalUnit;
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
@Data
public class ProjectCounterpartySyncTaskVO {
private String taskId;
private String status;
private Integer totalCount;
private Integer completedCount;
private Integer successCount;
private Integer skippedCount;
private Integer failureCount;
private String message;
}

View File

@@ -49,4 +49,8 @@ public interface CcdiBankStatementMapper extends BaseMapper<CcdiBankStatement> {
CcdiBankStatementFilterOptionsVO selectFilterOptions(@Param("projectId") Long projectId);
Integer countMatchedStaffCountByProjectId(@Param("projectId") Long projectId);
List<String> selectDistinctCounterpartyNames(@Param("projectId") Long projectId);
List<Long> selectDistinctProjectIdsWithCounterparties();
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyEnterprise;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface CcdiProjectCounterpartyEnterpriseMapper extends BaseMapper<CcdiProjectCounterpartyEnterprise> {
CcdiProjectCounterpartyEnterprise selectByProjectAndName(@Param("projectId") Long projectId,
@Param("counterpartyName") String counterpartyName);
List<CcdiProjectCounterpartyEnterprise> selectByProjectId(@Param("projectId") Long projectId);
int deleteByProjectId(@Param("projectId") Long projectId);
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyShareholder;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface CcdiProjectCounterpartyShareholderMapper extends BaseMapper<CcdiProjectCounterpartyShareholder> {
List<CcdiProjectCounterpartyShareholder> selectByEnterpriseId(@Param("counterpartyEnterpriseId") Long counterpartyEnterpriseId);
int deleteByEnterpriseId(@Param("counterpartyEnterpriseId") Long counterpartyEnterpriseId);
int deleteByProjectId(@Param("projectId") Long projectId);
}

View File

@@ -58,10 +58,10 @@ public interface ICcdiFileUploadService {
* 删除上传记录并清理关联数据
*
* @param id 上传记录ID
* @param operatorUserId 当前操作用户ID
* @param caller 当前操作人上下文
* @return 删除结果
*/
String deleteFileUploadRecord(Long id, Long operatorUserId);
String deleteFileUploadRecord(Long id, CallerContext caller);
/**
* 查询上传记录列表

View File

@@ -1,6 +1,7 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.lsfx.domain.CallerContext;
/**
* 历史项目导入服务
@@ -15,8 +16,8 @@ public interface ICcdiProjectHistoryImportService {
* @param targetProjectId 目标项目ID
* @param targetLsfxProjectId 目标流水分析项目ID
* @param dto 导入参数
* @param operator 操作人
* @param caller 原始操作人上下文
*/
void submitImport(Long targetProjectId, Integer targetLsfxProjectId,
CcdiProjectImportHistoryDTO dto, String operator);
CcdiProjectImportHistoryDTO dto, CallerContext caller);
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.vo.ProjectCounterpartyEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.ProjectCounterpartySyncTaskVO;
import com.ruoyi.lsfx.domain.CallerContext;
public interface IProjectCounterpartyEnterpriseService {
ProjectCounterpartyEnterpriseVO getDetail(Long projectId, String counterpartyName);
ProjectCounterpartyEnterpriseVO refresh(CallerContext caller, Long projectId, String counterpartyName);
void submitReconcile(CallerContext caller, Long projectId);
String startBackfill(CallerContext caller);
ProjectCounterpartySyncTaskVO getBackfillStatus(String taskId);
void deleteProjectData(Long projectId);
}

View File

@@ -16,6 +16,7 @@ import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import com.ruoyi.lsfx.constants.LsfxConstants;
@@ -106,6 +107,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
@Resource
private ICcdiProjectService projectService;
@Resource
private IProjectCounterpartyEnterpriseService counterpartyEnterpriseService;
/**
* 获取临时文件存储目录
*/
@@ -243,7 +247,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
}
@Override
public String deleteFileUploadRecord(Long id, Long operatorUserId) {
@Transactional(rollbackFor = Exception.class)
public String deleteFileUploadRecord(Long id, CallerContext caller) {
CcdiFileUploadRecord record = recordMapper.selectById(id);
validateDeleteRecord(record);
@@ -252,7 +257,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
* DeleteFilesRequest request = new DeleteFilesRequest();
* request.setGroupId(record.getLsfxProjectId());
* request.setLogIds(new Integer[]{record.getLogId()});
* request.setUserId(toUploadUserId(operatorUserId));
* request.setUserId(toUploadUserId(caller.userId()));
*
* DeleteFilesResponse response = lsfxClient.deleteFiles(request);
* if (response == null || Boolean.FALSE.equals(response.getSuccessResponse())) {
@@ -272,9 +277,23 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
}
bankTagService.submitAutoRebuild(record.getProjectId(), TriggerType.AUTO_FILE_DELETE);
submitCounterpartyReconcileAfterCommit(caller, record.getProjectId());
return "删除成功,已开始项目重新打标";
}
private void submitCounterpartyReconcileAfterCommit(CallerContext caller, Long projectId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
counterpartyEnterpriseService.submitReconcile(caller, projectId);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
counterpartyEnterpriseService.submitReconcile(caller, projectId);
}
});
}
@Override
public Page<CcdiFileUploadRecord> selectPage(Page<CcdiFileUploadRecord> page,
CcdiFileUploadQueryDTO queryDTO) {
@@ -554,6 +573,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
.whenComplete((unused, throwable) -> {
boolean anySuccess = futures.stream().anyMatch(future -> Boolean.TRUE.equals(future.getNow(Boolean.FALSE)));
handleTagRebuildAfterBatchCompletion(projectId, TriggerType.AUTO_BATCH_UPLOAD, anySuccess);
if (anySuccess) {
counterpartyEnterpriseService.submitReconcile(caller, projectId);
}
});
log.info("【文件上传】调度线程完成: projectId={}, batchId={}", projectId, batchId);
@@ -654,6 +676,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
.whenComplete((unused, throwable) -> {
boolean anySuccess = futures.stream().anyMatch(future -> Boolean.TRUE.equals(future.getNow(Boolean.FALSE)));
handleTagRebuildAfterBatchCompletion(projectId, TriggerType.AUTO_PULL_BANK_INFO, anySuccess);
if (anySuccess) {
counterpartyEnterpriseService.submitReconcile(caller, projectId);
}
});
}
@@ -686,13 +711,28 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
throw new RuntimeException("拉取本行信息失败: 未返回logId");
}
Integer logId = response.getData().get(0);
if (logId == null) {
List<Integer> logIds = response.getData().stream()
.filter(Objects::nonNull)
.toList();
if (logIds.isEmpty()) {
throw new RuntimeException("拉取本行信息失败: 未返回logId");
}
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, caller);
return true;
boolean anySuccess = false;
for (int i = 0; i < logIds.size(); i++) {
Integer logId = logIds.get(i);
CcdiFileUploadRecord currentRecord = i == 0
? record
: createAdditionalPullBankInfoRecord(record, idCard);
try {
anySuccess |= processRecordAfterLogIdReady(projectId, lsfxProjectId, currentRecord, logId, caller);
} catch (Exception logException) {
log.error("【拉取本行信息】处理logId失败: idCard={}, logId={}, recordId={}",
idCard, logId, currentRecord.getId(), logException);
updateFailedRecord(currentRecord, logException.getMessage());
}
}
return anySuccess;
} catch (Exception e) {
log.error("【拉取本行信息】处理失败: idCard={}, recordId={}", idCard, record.getId(), e);
updateFailedRecord(record, e.getMessage());
@@ -700,6 +740,24 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
}
}
private CcdiFileUploadRecord createAdditionalPullBankInfoRecord(CcdiFileUploadRecord sourceRecord,
String idCard) {
CcdiFileUploadRecord record = new CcdiFileUploadRecord();
record.setProjectId(sourceRecord.getProjectId());
record.setLsfxProjectId(sourceRecord.getLsfxProjectId());
record.setFileName(idCard);
record.setFileSize(0L);
record.setFileStatus("uploading");
record.setAccountNos(idCard);
record.setUploadTime(new Date());
record.setUploadUser(sourceRecord.getUploadUser());
recordMapper.insertBatch(List.of(record));
if (record.getId() == null) {
throw new RuntimeException("创建金综流水上传记录失败: 未生成记录ID");
}
return record;
}
/**
* 异步处理单个文件的完整流程
* 包含:上传 → 轮询解析状态 → 获取结果 → 保存流水数据
@@ -782,20 +840,20 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
bankTagService.submitAutoRebuild(projectId, triggerType);
}
private void processRecordAfterLogIdReady(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
Integer logId,
CallerContext caller) {
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false, caller);
private boolean processRecordAfterLogIdReady(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
Integer logId,
CallerContext caller) {
return processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false, caller);
}
private void processRecordAfterLogIdReady(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
Integer logId,
boolean preserveRecordFileName,
CallerContext caller) {
private boolean processRecordAfterLogIdReady(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
Integer logId,
boolean preserveRecordFileName,
CallerContext caller) {
log.info("【文件上传】步骤3: 更新状态为解析中, logId={}", logId);
record.setLogId(logId);
record.setFileStatus("parsing");
@@ -840,7 +898,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
if (!parseSuccess) {
log.warn("【文件上传】步骤6: 解析失败: status={}, desc={}", status, uploadStatusDesc);
updateFailedRecord(record, "解析失败: " + uploadStatusDesc);
return;
return false;
}
log.info("【文件上传】步骤6: 解析成功,保存主体信息");
@@ -857,7 +915,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
logId, fallbackCretNo);
if (!fetchResult.isSuccess()) {
updateFailedRecord(record, fetchResult.getErrorMessage());
return;
return false;
}
record.setFileStatus("parsed_success");
@@ -865,6 +923,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
record.setAccountNos(accountNosStr);
record.setErrorMessage(null);
recordMapper.updateById(record);
return true;
}
/**

View File

@@ -21,7 +21,7 @@ public class CcdiProjectHistoryImportEventListener {
event.getTargetProjectId(),
event.getTargetLsfxProjectId(),
event.getDto(),
event.getOperator()
event.getCaller()
);
}
}

View File

@@ -10,6 +10,8 @@ import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiProjectHistoryImportService;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
@@ -52,14 +54,17 @@ public class CcdiProjectHistoryImportServiceImpl implements ICcdiProjectHistoryI
@Resource
private ICcdiBankTagService bankTagService;
@Resource
private IProjectCounterpartyEnterpriseService counterpartyEnterpriseService;
@Override
public void submitImport(Long targetProjectId, Integer targetLsfxProjectId,
CcdiProjectImportHistoryDTO dto, String operator) {
fileUploadExecutor.execute(() -> executeImport(targetProjectId, targetLsfxProjectId, dto, operator));
CcdiProjectImportHistoryDTO dto, CallerContext caller) {
fileUploadExecutor.execute(() -> executeImport(targetProjectId, targetLsfxProjectId, dto, caller));
}
private void executeImport(Long targetProjectId, Integer targetLsfxProjectId,
CcdiProjectImportHistoryDTO dto, String operator) {
CcdiProjectImportHistoryDTO dto, CallerContext caller) {
List<CcdiFileUploadRecord> sourceRecords = recordMapper.selectSuccessfulRecordsByProjectIds(dto.getSourceProjectIds());
if (sourceRecords == null || sourceRecords.isEmpty()) {
log.info("【项目历史导入】无可复制的来源批次: projectId={}, sourceProjectIds={}",
@@ -91,7 +96,7 @@ public class CcdiProjectHistoryImportServiceImpl implements ICcdiProjectHistoryI
if (statementsToInsert.size() > sizeBefore) {
recordsToInsert.add(buildHistoryImportRecord(
sourceRecord, targetProjectId, targetLsfxProjectId, newBatchId, resolveSourceProjectName(sourceRecord.getProjectId()), operator
sourceRecord, targetProjectId, targetLsfxProjectId, newBatchId, resolveSourceProjectName(sourceRecord.getProjectId()), caller.username()
));
}
}
@@ -105,6 +110,7 @@ public class CcdiProjectHistoryImportServiceImpl implements ICcdiProjectHistoryI
if (!statementsToInsert.isEmpty()) {
refreshProjectTargetCount(targetProjectId);
bankTagService.submitAutoRebuild(targetProjectId, TriggerType.AUTO_BATCH_UPLOAD);
counterpartyEnterpriseService.submitReconcile(caller, targetProjectId);
}
}

View File

@@ -175,7 +175,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
@Override
public void afterCommit() {
applicationEventPublisher.publishEvent(
new CcdiProjectHistoryImportSubmittedEvent(project.getProjectId(), project.getLsfxProjectId(), dto, caller.username())
new CcdiProjectHistoryImportSubmittedEvent(project.getProjectId(), project.getLsfxProjectId(), dto, caller)
);
}
});

View File

@@ -0,0 +1,280 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyEnterprise;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyShareholder;
import com.ruoyi.ccdi.project.domain.vo.ProjectCounterpartyEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.ProjectCounterpartyShareholderVO;
import com.ruoyi.ccdi.project.domain.vo.ProjectCounterpartySyncTaskVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectCounterpartyEnterpriseMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectCounterpartyShareholderMapper;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
import com.ruoyi.info.collection.domain.CcdiEnterpriseInfoQueryCache;
import com.ruoyi.info.collection.domain.model.EnterpriseProfile;
import com.ruoyi.info.collection.domain.model.EnterpriseProfileQueryResult;
import com.ruoyi.info.collection.domain.model.EnterpriseShareholderProfile;
import com.ruoyi.info.collection.service.IEnterpriseProfileQueryService;
import com.ruoyi.lsfx.domain.CallerContext;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@Service
public class ProjectCounterpartyEnterpriseServiceImpl implements IProjectCounterpartyEnterpriseService {
private static final String TASK_PREFIX = "project:counterparty:profile:backfill:";
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@Resource
private CcdiProjectCounterpartyEnterpriseMapper enterpriseMapper;
@Resource
private CcdiProjectCounterpartyShareholderMapper shareholderMapper;
@Resource
private IEnterpriseProfileQueryService queryService;
@Resource
private TransactionTemplate transactionTemplate;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
@Qualifier("enterpriseProfileExecutor")
private Executor enterpriseProfileExecutor;
@Override
public ProjectCounterpartyEnterpriseVO getDetail(Long projectId, String counterpartyName) {
String name = normalize(counterpartyName);
requireCurrentCounterparty(projectId, name);
CcdiProjectCounterpartyEnterprise entity = enterpriseMapper.selectByProjectAndName(projectId, name);
CcdiEnterpriseInfoQueryCache cache = queryService.findCache(name);
Date now = new Date();
if (entity == null) {
ProjectCounterpartyEnterpriseVO vo = new ProjectCounterpartyEnterpriseVO();
vo.setProjectId(projectId);
vo.setCounterpartyName(name);
vo.setStatus("NOT_FOUND");
vo.setCanRefresh(true);
vo.setCacheValidDate(cache == null ? null : cache.getValidDate());
return vo;
}
ProjectCounterpartyEnterpriseVO vo = new ProjectCounterpartyEnterpriseVO();
BeanUtils.copyProperties(entity, vo);
vo.setStatus(cache != null && cache.getValidDate() != null && cache.getValidDate().after(now) ? "SUCCESS" : "EXPIRED");
vo.setCanRefresh(!"SUCCESS".equals(vo.getStatus()));
vo.setCacheValidDate(cache == null ? null : cache.getValidDate());
vo.setShareholders(shareholderMapper.selectByEnterpriseId(entity.getCounterpartyEnterpriseId()).stream()
.map(this::toShareholderVO).toList());
return vo;
}
@Override
public ProjectCounterpartyEnterpriseVO refresh(CallerContext caller, Long projectId, String counterpartyName) {
String name = normalize(counterpartyName);
requireCurrentCounterparty(projectId, name);
CcdiProjectCounterpartyEnterprise existing = enterpriseMapper.selectByProjectAndName(projectId, name);
CcdiEnterpriseInfoQueryCache cache = queryService.findCache(name);
boolean cacheValid = cache != null && cache.getValidDate() != null && cache.getValidDate().after(new Date());
if (existing != null && cacheValid) {
throw new IllegalStateException("工商缓存仍在有效期内,无需重新查询");
}
EnterpriseProfileQueryResult result = queryService.query(caller, name, !cacheValid);
upsert(projectId, name, result, caller.username());
return getDetail(projectId, name);
}
@Override
public void submitReconcile(CallerContext caller, Long projectId) {
CompletableFuture.runAsync(() -> reconcile(caller, projectId, null));
}
@Override
public String startBackfill(CallerContext caller) {
String taskId = UUID.randomUUID().toString().replace("-", "");
List<Long> projectIds = bankStatementMapper.selectDistinctProjectIdsWithCounterparties();
int total = projectIds.stream().mapToInt(id -> bankStatementMapper.selectDistinctCounterpartyNames(id).size()).sum();
initializeTask(taskId, total);
CompletableFuture.runAsync(() -> {
TaskCounters counters = new TaskCounters();
for (Long projectId : projectIds) {
reconcile(caller, projectId, new Progress(taskId, counters));
}
updateTask(taskId, "COMPLETED", counters);
});
return taskId;
}
@Override
public ProjectCounterpartySyncTaskVO getBackfillStatus(String taskId) {
Map<Object, Object> values = redisTemplate.opsForHash().entries(taskKey(taskId));
if (values.isEmpty()) {
throw new IllegalArgumentException("任务不存在或已过期");
}
ProjectCounterpartySyncTaskVO vo = new ProjectCounterpartySyncTaskVO();
vo.setTaskId(string(values.get("taskId")));
vo.setStatus(string(values.get("status")));
vo.setTotalCount(number(values.get("totalCount")));
vo.setCompletedCount(number(values.get("completedCount")));
vo.setSuccessCount(number(values.get("successCount")));
vo.setSkippedCount(number(values.get("skippedCount")));
vo.setFailureCount(number(values.get("failureCount")));
vo.setMessage(string(values.get("message")));
return vo;
}
@Override
public void deleteProjectData(Long projectId) {
shareholderMapper.deleteByProjectId(projectId);
enterpriseMapper.deleteByProjectId(projectId);
}
private void reconcile(CallerContext caller, Long projectId, Progress progress) {
List<String> names = bankStatementMapper.selectDistinctCounterpartyNames(projectId);
if (names.isEmpty()) {
transactionTemplate.executeWithoutResult(status -> deleteProjectData(projectId));
return;
}
for (int start = 0; start < names.size(); start += 100) {
List<String> batch = names.subList(start, Math.min(start + 100, names.size()));
List<CompletableFuture<Void>> futures = batch.stream().map(name -> CompletableFuture.runAsync(() -> {
try {
EnterpriseProfileQueryResult result = queryService.query(caller, name, false);
upsert(projectId, name, result, caller.username());
if (progress != null) progress.counters.success.incrementAndGet();
} catch (Exception e) {
if (progress != null) progress.counters.failure.incrementAndGet();
} finally {
if (progress != null) {
progress.counters.completed.incrementAndGet();
updateTask(progress.taskId, "PROCESSING", progress.counters);
}
}
}, enterpriseProfileExecutor)).toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
removeStale(projectId, new HashSet<>(names));
}
private void upsert(Long projectId, String name, EnterpriseProfileQueryResult result, String username) {
transactionTemplate.executeWithoutResult(status -> {
EnterpriseProfile profile = result.profile();
CcdiProjectCounterpartyEnterprise entity = enterpriseMapper.selectByProjectAndName(projectId, name);
Date now = new Date();
boolean insert = entity == null;
if (insert) {
entity = new CcdiProjectCounterpartyEnterprise();
entity.setProjectId(projectId);
entity.setCounterpartyName(name);
entity.setCreateBy(username);
entity.setCreateTime(now);
}
entity.setSocialCreditCode(profile.getCreditCode());
entity.setEnterpriseName(profile.getEnterpriseName());
entity.setRegisteredCapital(profile.getRegisteredCapital());
entity.setRegisteredCapitalUnit(profile.getRegisteredCapitalUnit());
entity.setRegisterDate(profile.getRegisterDate());
entity.setEstablishDate(profile.getEstablishDate());
entity.setIndustryCode(profile.getIndustryCode());
entity.setIndustryName(profile.getIndustryName());
entity.setOrganizationTypeCode(profile.getOrganizationTypeCode());
entity.setOrganizationTypeName(profile.getOrganizationTypeName());
entity.setRegionCode(profile.getRegionCode());
entity.setRegionName(profile.getRegionName());
entity.setRegisterAddress(profile.getRegisterAddress());
entity.setEmployeeCount(profile.getEmployeeCount());
entity.setLegalRepresentative(profile.getLegalRepresentative());
entity.setCacheInfoId(result.cacheInfoId());
entity.setSyncTime(now);
entity.setUpdateBy(username);
entity.setUpdateTime(now);
if (insert) enterpriseMapper.insert(entity); else enterpriseMapper.updateById(entity);
shareholderMapper.deleteByEnterpriseId(entity.getCounterpartyEnterpriseId());
for (EnterpriseShareholderProfile item : profile.getShareholders()) {
CcdiProjectCounterpartyShareholder shareholder = new CcdiProjectCounterpartyShareholder();
shareholder.setCounterpartyEnterpriseId(entity.getCounterpartyEnterpriseId());
shareholder.setSequenceNo(item.getSequence());
shareholder.setShareholderName(item.getShareholderName());
shareholder.setStockPercent(item.getStockPercent());
shareholder.setSubscribedCapital(item.getSubscribedCapital());
shareholder.setCapitalUnit(item.getCapitalUnit());
shareholder.setCreateTime(now);
shareholderMapper.insert(shareholder);
}
});
}
private void removeStale(Long projectId, Set<String> currentNames) {
transactionTemplate.executeWithoutResult(status -> enterpriseMapper.selectByProjectId(projectId).stream()
.filter(item -> !currentNames.contains(item.getCounterpartyName()))
.forEach(item -> {
shareholderMapper.deleteByEnterpriseId(item.getCounterpartyEnterpriseId());
enterpriseMapper.deleteById(item.getCounterpartyEnterpriseId());
}));
}
private void requireCurrentCounterparty(Long projectId, String name) {
if (projectId == null || !StringUtils.hasText(name)
|| !bankStatementMapper.selectDistinctCounterpartyNames(projectId).contains(name)) {
throw new IllegalArgumentException("该对手方不在项目当前流水中");
}
}
private ProjectCounterpartyShareholderVO toShareholderVO(CcdiProjectCounterpartyShareholder entity) {
ProjectCounterpartyShareholderVO vo = new ProjectCounterpartyShareholderVO();
BeanUtils.copyProperties(entity, vo);
return vo;
}
private String normalize(String value) { return value == null ? null : value.trim(); }
private String taskKey(String taskId) { return TASK_PREFIX + taskId; }
private String string(Object value) { return value == null ? null : value.toString(); }
private Integer number(Object value) { return value == null ? 0 : Integer.valueOf(value.toString()); }
private void initializeTask(String taskId, int total) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("taskId", taskId);
values.put("status", "PROCESSING");
values.put("totalCount", total);
values.put("completedCount", 0);
values.put("successCount", 0);
values.put("skippedCount", 0);
values.put("failureCount", 0);
values.put("message", "正在补全项目对手方工商信息");
redisTemplate.opsForHash().putAll(taskKey(taskId), values);
redisTemplate.expire(taskKey(taskId), 7, TimeUnit.DAYS);
}
private void updateTask(String taskId, String status, TaskCounters counters) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("status", status);
values.put("completedCount", counters.completed.get());
values.put("successCount", counters.success.get());
values.put("skippedCount", counters.skipped.get());
values.put("failureCount", counters.failure.get());
values.put("message", "COMPLETED".equals(status) ? "项目对手方工商信息补全完成" : "正在补全项目对手方工商信息");
redisTemplate.opsForHash().putAll(taskKey(taskId), values);
}
private static class TaskCounters {
private final AtomicInteger completed = new AtomicInteger();
private final AtomicInteger success = new AtomicInteger();
private final AtomicInteger skipped = new AtomicInteger();
private final AtomicInteger failure = new AtomicInteger();
}
private record Progress(String taskId, TaskCounters counters) { }
}

View File

@@ -139,6 +139,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and trim(bs.cret_no) != ''
</select>
<select id="selectDistinctCounterpartyNames" resultType="java.lang.String">
select distinct trim(CUSTOMER_ACCOUNT_NAME)
from ccdi_bank_statement
where project_id = #{projectId}
and CUSTOMER_ACCOUNT_NAME is not null
and trim(CUSTOMER_ACCOUNT_NAME) != ''
order by trim(CUSTOMER_ACCOUNT_NAME)
</select>
<select id="selectDistinctProjectIdsWithCounterparties" resultType="java.lang.Long">
select distinct project_id
from ccdi_bank_statement
where project_id is not null
and CUSTOMER_ACCOUNT_NAME is not null
and trim(CUSTOMER_ACCOUNT_NAME) != ''
order by project_id
</select>
<sql id="parsedTrxDateExpr">
CASE
WHEN bs.TRX_DATE IS NULL OR TRIM(bs.TRX_DATE) = '' THEN NULL

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiProjectCounterpartyEnterpriseMapper">
<select id="selectByProjectAndName" resultType="com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyEnterprise">
select * from ccdi_project_counterparty_enterprise
where project_id = #{projectId} and counterparty_name = #{counterpartyName}
limit 1
</select>
<select id="selectByProjectId" resultType="com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyEnterprise">
select * from ccdi_project_counterparty_enterprise where project_id = #{projectId}
</select>
<delete id="deleteByProjectId">
delete from ccdi_project_counterparty_enterprise where project_id = #{projectId}
</delete>
</mapper>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiProjectCounterpartyShareholderMapper">
<select id="selectByEnterpriseId" resultType="com.ruoyi.ccdi.project.domain.entity.CcdiProjectCounterpartyShareholder">
select * from ccdi_project_counterparty_shareholder
where counterparty_enterprise_id = #{counterpartyEnterpriseId}
order by shareholder_seq, shareholder_id
</select>
<delete id="deleteByEnterpriseId">
delete from ccdi_project_counterparty_shareholder
where counterparty_enterprise_id = #{counterpartyEnterpriseId}
</delete>
<delete id="deleteByProjectId">
delete s from ccdi_project_counterparty_shareholder s
inner join ccdi_project_counterparty_enterprise e
on e.counterparty_enterprise_id = s.counterparty_enterprise_id
where e.project_id = #{projectId}
</delete>
</mapper>

View File

@@ -134,16 +134,16 @@ class CcdiFileUploadControllerTest {
}
@Test
void deleteFile_shouldUseCurrentLoginUserId() {
void deleteFile_shouldUseCurrentLoginUser() {
setLoginUser(9527L, "admin");
when(fileUploadService.deleteFileUploadRecord(123L, 9527L))
when(fileUploadService.deleteFileUploadRecord(123L, CALLER))
.thenReturn("删除成功");
AjaxResult result = controller.deleteFile(123L);
assertEquals(200, result.get("code"));
assertEquals("删除成功", result.get("msg"));
verify(fileUploadService).deleteFileUploadRecord(123L, 9527L);
verify(fileUploadService).deleteFileUploadRecord(123L, CALLER);
}
private void setLoginUser(Long userId, String username) {

View File

@@ -14,6 +14,7 @@ import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import com.ruoyi.lsfx.constants.LsfxConstants;
@@ -101,6 +102,9 @@ class CcdiFileUploadServiceImplTest {
@Mock
private ICcdiProjectService projectService;
@Mock
private IProjectCounterpartyEnterpriseService counterpartyEnterpriseService;
@TempDir
Path tempDir;
@@ -510,6 +514,65 @@ class CcdiFileUploadServiceImplTest {
));
}
@Test
void processPullBankInfoAsync_shouldProcessAllJzlLogIds() {
Integer secondLogId = LOG_ID + 1;
Integer thirdLogId = LOG_ID + 2;
List<CcdiFileUploadRecord> insertedAdditionalRecords = new ArrayList<>();
doAnswer(invocation -> {
List<CcdiFileUploadRecord> records = invocation.getArgument(0);
for (int i = 0; i < records.size(); i++) {
records.get(i).setId(RECORD_ID + i + 1);
CcdiFileUploadRecord snapshot = new CcdiFileUploadRecord();
snapshot.setId(records.get(i).getId());
snapshot.setFileName(records.get(i).getFileName());
snapshot.setAccountNos(records.get(i).getAccountNos());
snapshot.setUploadUser(records.get(i).getUploadUser());
snapshot.setFileStatus(records.get(i).getFileStatus());
insertedAdditionalRecords.add(snapshot);
}
return records.size();
}).when(recordMapper).insertBatch(any());
when(lsfxClient.fetchInnerFlow(eq(CALLER), any()))
.thenReturn(buildFetchInnerFlowResponse(LOG_ID, secondLogId, thirdLogId));
when(lsfxClient.checkParseStatus(eq(CALLER), eq(LSFX_PROJECT_ID), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(eq(CALLER), any())).thenReturn(buildParsedSuccessStatusResponse());
when(lsfxClient.getBankStatement(eq(CALLER), any(GetBankStatementRequest.class)))
.thenReturn(buildEmptyBankStatementResponse());
CcdiFileUploadRecord record = buildRecord();
record.setUploadUser("admin");
boolean success = service.processPullBankInfoAsync(
PROJECT_ID,
LSFX_PROJECT_ID,
record,
"110101199001018888",
LsfxConstants.DATA_CHANNEL_JZL,
null,
null,
CALLER
);
assertTrue(success);
assertEquals(2, insertedAdditionalRecords.size());
assertEquals("110101199001018888", insertedAdditionalRecords.get(0).getFileName());
assertEquals("110101199001018888", insertedAdditionalRecords.get(0).getAccountNos());
assertEquals("admin", insertedAdditionalRecords.get(0).getUploadUser());
verify(lsfxClient).checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(LOG_ID));
verify(lsfxClient).checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(secondLogId));
verify(lsfxClient).checkParseStatus(CALLER, LSFX_PROJECT_ID, String.valueOf(thirdLogId));
verify(lsfxClient).getBankStatement(eq(CALLER), org.mockito.ArgumentMatchers.<GetBankStatementRequest>argThat(request ->
LOG_ID.equals(request.getLogId())));
verify(lsfxClient).getBankStatement(eq(CALLER), org.mockito.ArgumentMatchers.<GetBankStatementRequest>argThat(request ->
secondLogId.equals(request.getLogId())));
verify(lsfxClient).getBankStatement(eq(CALLER), org.mockito.ArgumentMatchers.<GetBankStatementRequest>argThat(request ->
thirdLogId.equals(request.getLogId())));
}
@Test
void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException {
when(lsfxClient.uploadFile(eq(CALLER), eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
@@ -636,7 +699,7 @@ class CcdiFileUploadServiceImplTest {
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(2);
when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1);
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
String result = service.deleteFileUploadRecord(RECORD_ID, CALLER);
assertEquals("删除成功已开始项目重新打标", result);
verify(lsfxClient, never()).deleteFiles(any(), any());
@@ -645,6 +708,7 @@ class CcdiFileUploadServiceImplTest {
RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus())
));
verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_FILE_DELETE);
verify(counterpartyEnterpriseService).submitReconcile(CALLER, PROJECT_ID);
verify(projectMapper).updateById(org.mockito.ArgumentMatchers.<CcdiProject>argThat(item ->
PROJECT_ID.equals(item.getProjectId()) && Integer.valueOf(2).equals(item.getTargetCount())
));
@@ -657,7 +721,7 @@ class CcdiFileUploadServiceImplTest {
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
() -> service.deleteFileUploadRecord(RECORD_ID, CALLER));
assertTrue(exception.getMessage().contains("仅支持删除解析成功文件"));
}
@@ -670,7 +734,7 @@ class CcdiFileUploadServiceImplTest {
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
() -> service.deleteFileUploadRecord(RECORD_ID, CALLER));
assertTrue(exception.getMessage().contains("历史导入文件不支持删除"));
verify(lsfxClient, never()).deleteFiles(any(), any());
@@ -685,7 +749,7 @@ class CcdiFileUploadServiceImplTest {
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1);
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
String result = service.deleteFileUploadRecord(RECORD_ID, CALLER);
assertEquals("删除成功已开始项目重新打标", result);
verify(lsfxClient, never()).deleteFiles(any(), any());
@@ -995,9 +1059,9 @@ class CcdiFileUploadServiceImplTest {
return response;
}
private FetchInnerFlowResponse buildFetchInnerFlowResponse(Integer logId) {
private FetchInnerFlowResponse buildFetchInnerFlowResponse(Integer... logIds) {
FetchInnerFlowResponse response = new FetchInnerFlowResponse();
response.setData(List.of(logId));
response.setData(List.of(logIds));
return response;
}

View File

@@ -9,6 +9,8 @@ import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.IProjectCounterpartyEnterpriseService;
import com.ruoyi.lsfx.domain.CallerContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -34,6 +36,8 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiProjectHistoryImportServiceImplTest {
private static final CallerContext CALLER = CallerContext.of(9527L, "tester");
@InjectMocks
private CcdiProjectHistoryImportServiceImpl service;
@@ -52,6 +56,9 @@ class CcdiProjectHistoryImportServiceImplTest {
@Mock
private ICcdiBankTagService bankTagService;
@Mock
private IProjectCounterpartyEnterpriseService counterpartyEnterpriseService;
@Test
void shouldFilterStatementsByTrxDateAndDeduplicateAcrossSourceProjects() {
CcdiProjectImportHistoryDTO dto = buildImportDto();
@@ -80,7 +87,7 @@ class CcdiProjectHistoryImportServiceImplTest {
when(projectMapper.selectById(11L)).thenReturn(buildProject(11L, "历史项目A"));
when(projectMapper.selectById(12L)).thenReturn(buildProject(12L, "历史项目B"));
service.submitImport(90L, 3001, dto, "tester");
service.submitImport(90L, 3001, dto, CALLER);
assertEquals(2, insertedStatements.get().size());
assertTrue(insertedStatements.get().stream().allMatch(item -> Long.valueOf(90L).equals(item.getProjectId())));
@@ -120,7 +127,7 @@ class CcdiProjectHistoryImportServiceImplTest {
when(projectMapper.selectById(11L)).thenReturn(buildProject(11L, "历史项目A"));
service.submitImport(90L, 3001, dto, "tester");
service.submitImport(90L, 3001, dto, CALLER);
assertEquals(1, insertedStatements.get().size());
assertNotEquals(101, insertedStatements.get().get(0).getBatchId());
@@ -148,7 +155,7 @@ class CcdiProjectHistoryImportServiceImplTest {
when(projectMapper.selectById(90L)).thenReturn(buildProject(90L, "新项目"));
when(bankStatementMapper.countMatchedStaffCountByProjectId(90L)).thenReturn(3);
service.submitImport(90L, 3001, dto, "tester");
service.submitImport(90L, 3001, dto, CALLER);
verify(projectMapper).updateById(org.mockito.ArgumentMatchers.<CcdiProject>argThat(project ->
Long.valueOf(90L).equals(project.getProjectId()) && Integer.valueOf(3).equals(project.getTargetCount())

View File

@@ -309,7 +309,7 @@ class CcdiProjectServiceImplTest {
(CcdiProjectHistoryImportSubmittedEvent) eventCaptor.getValue();
assertEquals(90L, event.getTargetProjectId());
assertEquals(3001, event.getTargetLsfxProjectId());
assertEquals("tester", event.getOperator());
assertEquals(CALLER, event.getCaller());
assertEquals(dto, event.getDto());
} finally {
TransactionSynchronizationManager.clearSynchronization();

View File

@@ -0,0 +1,20 @@
# 金综流水多 logId 处理后端实施计划
## 背景
金综 `dataChannelCode=JZL` 拉取链路可能在一次请求中返回多个 XML 文件对应的多个 `logId`。接入端如果只处理首个 `logId`,会导致同一证件号下后续金综 XML 文件未进入解析状态轮询、上传状态查询和流水明细落库。
## 实施内容
- 将金综拉取响应处理从单个 `logId` 改为遍历 `response.data` 中全部非空 `logId`
- 第一个 `logId` 复用拉取提交时创建的上传记录。
- 第二个及后续 `logId` 自动创建独立上传记录,避免多个 XML 的文件名、账号、解析状态相互覆盖。
- 每个 `logId` 独立执行解析状态轮询、文件状态查询、流水明细获取和落库。
- 继续沿用最新代码中的 `CallerContext`,确保流水平台调用仍写入外部接口日志。
- 单个 `logId` 失败只标记对应记录失败,不中断其余 `logId`;本次证件号任务至少一个 `logId` 成功即视为成功。
## 验证计划
- 补充单元测试模拟金综返回 3 个 `logId`
- 验证 3 个 `logId` 均调用解析状态轮询和流水明细查询。
- 验证后续 `logId` 会新增独立上传记录,并保留证件号、上传人和上传中状态。

View File

@@ -0,0 +1,62 @@
# 新华社工商信息同步接入实施计划
## 1. 实施目标
接入新华社同步工商接口 `POST /api/service/interface/invokeService/LSFXXHS`,开发环境仅连接本地 FastAPI Mock生产环境连接真实接口。接口响应直接读取 `data.mappingOutputFields`,统一社会信用代码字段固定为 `creditCode`
本次不实现异步结果查询、任务 Key 提取、轮询、结果缓存接口,也不接入 `LSFXXHSstockrelation`
## 2. 接口与日志
- 使用 `application/x-www-form-urlencoded` 提交 `entName``serialNum``orgCode=999000``runType=1`
- `serialNum` 格式为 `CCDI_GS_时间戳_UUID`,仅作为请求流水号。
- 依次校验 HTTP、JSON、外层状态、业务状态、非空结果对象、企业名称和 `creditCode`;任一校验失败均不得写缓存及业务数据。
- 所有外部 HTTP 调用显式携带不可变 `CallerContext`,异步链路沿用原始发起用户。
- 每次实际外呼写入一条 `sys_api_log`;缓存命中不写日志。日志独立事务保存,日志失败不影响业务请求。
- 接口日志仅提供列表和详情,不提供删除、清空或导出。
## 3. 数据与解析
- 新增工商原始响应缓存、实体完整股东、项目对手方企业、项目对手方股东和接口日志表。
- 缓存键为 `TRIM(entName) + EnterpriseProfile`,有效期为成功调用时间后 180 天,`query_result` 保存完整原始 JSON。
- 实体表补充注册资本、注册日期、区域、从业人数、缓存关联和工商同步时间。
- 映射 `creditCode`、注册资本、日期、机构类型、行业、区域、注册地址、从业人数、法定代表人和股东字段。
- `stock_percent` 去除 `%` 后按百分数值保存,`should_capi` 单位固定为“万元”。文档未提供字段不推断。
- `holders` 为空时整体清空旧股东和实体表前五股东字段。
## 4. 实体库同步
- 新增、导入、关系自动补全和名称变更均在原事务提交后异步执行。
- 单批按去除首尾空格后的名称去重,同名只查询一次。
- 仅在返回名称与请求名称一致且返回 `creditCode` 与实体主键完全一致时回写。
- 同名但信用代码不一致的实体不更新;成功缓存保留,历史任务记录为跳过。
- 工商字段、缓存关联、同步时间和 `data_source=API` 在短事务内更新,完整股东整体替换。
- 不覆盖风险等级、企业来源、企业性质、经营状态和业务关系。
## 5. 项目对手方同步
- 流水上传、平台拉取和历史导入在整个批次完成后各触发一次 reconcile。
- 当前项目全部非空对手方名称去重后按每批 100 条处理,外呼共用最大并发数为 3 的专用执行器。
- 按“项目 ID + 对手方名称”更新或新增,信用代码变化时更新同一记录,并事务性替换股东。
- 单项失败保留旧成功数据;批次结束后删除已不在当前流水集合中的项目工商数据。
- 删除流水后基于剩余流水同步;空集合直接清空且不外呼。删除项目时直接删除项目工商数据。
- 项目工商信息与实体库相互隔离。
## 6. API 与页面
- 实体库提供详情刷新、历史补全启动和 Redis 任务状态查询。
- 项目提供对手方详情、单个刷新、历史补全启动和 Redis 任务状态查询。
- 任务状态包含总数、完成数、成功数、跳过数、失败数和最终状态,不新增任务表。
- 实体详情展示新增工商字段和完整股东;仅未同步或缓存过期时显示重新查询。
- 流水对手方名称打开本地工商详情;打开、翻页和普通刷新不得触发外呼。
- 接口日志页面为安静的只读运维表格,详情展示请求、响应和异常原文。
## 7. 实施顺序与验收
1. 执行 `sql/migration/2026-07-29-xinhua-enterprise-profile.sql`,重复执行验证幂等性。
2. 启动 FastAPI Mock、Java 后端和 Vue 前端,开发配置必须指向本地 Mock。
3. 运行 Mock、Java 模块测试和前端生产构建,确认源码不存在 `LSFXXHSResult`
4. 构造信用代码匹配、同名信用代码不匹配、空股东和多股东场景,核对缓存、实体、项目和日志。
5. 使用应用内浏览器验收实体库、流水明细和接口日志真实页面。
6. 清理业务测试数据并关闭本轮启动的前后端及 Mock 进程。
7. 生产先对单个已知企业烟测,确认一次外呼、一条日志、原始缓存、`creditCode` 和股东一致后,再启动历史补全;任何环节失败立即停止上线。

View File

@@ -0,0 +1,23 @@
# 金综流水多 logId 处理实施记录
## 修改内容
- 修复拉取金综流水时只处理首个 `logId` 的问题。
- `FetchInnerFlowResponse.data` 中多个 `logId` 现在会逐个进入解析状态轮询、上传状态查询和流水明细落库。
- 第一个 `logId` 复用原上传记录,后续 `logId` 自动新增独立上传记录。
- 单个 `logId` 处理失败时只更新对应记录为失败,不影响其余 `logId` 继续处理。
- 基于最新 `origin/dev-ui` 实施,保留 `CallerContext` 外部接口日志链路。
## 影响范围
- `/ccdi/file-upload/pull-bank-info` 金综 `JZL` 拉取后的异步处理链路。
- 项目详情上传数据列表中金综多 XML 文件对应的上传记录展示。
- 后续流水明细保存与自动打标触发判断。
## 验证情况
- 后端单测通过:
- `mvn -pl ccdi-project -am test "-Dtest=CcdiFileUploadServiceImplTest" "-Dsurefire.failIfNoSpecifiedTests=false"`
- 结果36 个测试通过。
- 新增用例覆盖金综返回 3 个 `logId` 的场景,确认 3 个 `logId` 均进入解析状态轮询和流水明细查询,且后续 `logId` 会新增独立上传记录。
- 验证多 `logId` 处理继续传递同一个 `CallerContext`,兼容最新外部接口日志链路。

View File

@@ -0,0 +1,70 @@
# 新华社工商信息同步接入实施报告
## 1. 实施结果
已按同步接口契约完成新华社工商信息接入。开发环境通过 FastAPI Mock 联调,生产配置保留真实接口地址;代码、配置和 Mock 均未实现 `LSFXXHSResult``LSFXXHSstockrelation`
## 2. 主要改动
### 外部接口与日志
- `ccdi-lsfx` 新增新华社同步客户端、同步响应模型和不可变 `CallerContext`
- 重构共享 HTTP 工具统一记录调用人、URL、方法、请求头和参数、HTTP 状态、响应头、原始响应、异常堆栈和耗时。
- `sys_api_log` 使用独立事务写入HTTP 200 的业务失败仍按传输成功保存,业务失败信息保留在原始响应中。
- 恢复只读接口日志列表、详情 API 和页面,无删除、清空、导出能力。
### 缓存、解析与实体库
- 新增 180 天成功缓存,缓存保存同步接口完整原始 JSON。
- 新增工商字段解析、实体完整股东表和股东整体替换。
- 实体新增、导入、关系自动补全及名称变化后,在原事务提交后异步同步。
- 单批按企业名称去重,返回 `creditCode` 仅回写主键完全匹配的实体;同名不匹配实体保持不变。
- 手工刷新和历史补全使用 Redis 状态,不增加任务表。
### 项目对手方
- 新增项目对手方企业、股东、详情、刷新和历史补全能力。
- 流水上传、平台拉取及历史导入完成后按项目当前对手方集合 reconcile。
- 查询按每批 100 条执行,新华社查询共用最大并发数 3 的执行器。
- 项目删除直接清理项目工商数据,流水删除后按剩余流水同步,空集合不外呼。
### 前端与 Mock
- 实体详情新增注册资本、注册日期、区域、从业人数、同步时间和完整股东表。
- 流水对手方名称可打开本地工商详情,仅无数据或缓存过期时显示重新查询。
- Mock 覆盖公司、个体、空股东、多股东、HTTP 失败、超时、空响应、非法 JSON、外层失败、业务失败、结果缺失、名称不一致和信用代码缺失。
## 3. 数据库实施
- 新增迁移:`sql/migration/2026-07-29-xinhua-enterprise-profile.sql`
- 已通过 `bin/mysql_utf8_exec.sh` 连续执行两次,均成功。
- 已核对 5 张新增或校准表均为 `utf8mb4_general_ci`
- 已核对缓存 `query_result``LONGTEXT`,缓存唯一键为 `(query_param, query_type)`,项目唯一键为 `(project_id, counterparty_name)`
- 已核对实体工商扩展字段、菜单权限和管理员角色权限落库。
## 4. 自动化验证
- `python3 -m pytest tests/test_enterprise_api.py -v`:新华社 Mock 专项 4 项通过。
- `mvn -pl ccdi-info-collection -am test`构建成功覆盖新华社客户端、HTTP 日志、字段解析和信息采集模块回归。
- 项目模块定向测试53 项通过,覆盖上传、拉取、历史导入、项目删除及调用人传递。
- `mvn -pl ccdi-project -am -DskipTests compile`:构建成功。
- 前端在 Node 14.21.3 下执行 `npm run build:prod`:构建成功。
- `git diff --check`:通过。
全量项目测试当前仍有 3 个失败和 1 个错误,均位于既有结果总览和银行标签基线断言:按钮文案、规则数量、`modelParamService` 结构断言及对应未配置 Mock。本次新增链路的定向测试均通过未修改这些无关基线。
Mock 子项目全量 99 项测试为 75 项通过、24 项失败;失败用例均属于既有流水 Mock 链路,原因是测试尝试使用账号 `znsj` 从当前宿主连接 MySQL 时被拒绝。新华社工商专项 4 项不依赖该连接并全部通过。
## 5. 应用内浏览器验收
- 实体库真实页面成功展示接口返回的统一社会信用代码、注册资本、日期、区域、从业人数及 7 条完整股东。
- 新增同名但不同信用代码的实体后,确认该实体保持手工数据且无股东回写;缓存命中后新华社日志仍为 1 条。
- 补充验证同名未同步实体即使存在有效缓存仍可发起刷新,并明确返回“工商信用代码与实体主键不一致”;项目无本地记录时可复用有效缓存补齐,两种缓存命中均不新增接口日志。
- 流水明细真实页面确认对手方名称为本地详情入口,打开弹窗不触发新华社;无数据时显示重新查询。
- 对手方手工刷新后展示接口返回的信用代码、结构化工商字段和完整股东。
- 接口日志真实页面确认每次缓存未命中对应一条日志,详情包含调用账号、表单参数、`CCDI_GS_时间戳_UUID`、HTTP 状态、响应头和原始 JSON。
- 联调业务数据已清理;接口历史日志按设计保留。
## 6. 上线要求
生产环境不得配置或恢复异步结果查询。上线先使用一个已知企业完成单次烟测,逐项确认单次外呼、单条日志、原始缓存、`creditCode` 和股东落库一致;确认后才能启动实体和项目历史补全,失败时停止上线。

View File

@@ -7,7 +7,7 @@ import argparse
import os
from fastapi import FastAPI
from routers import api, credit_api
from routers import api, credit_api, enterprise_api
from config.settings import settings
# 创建 FastAPI 应用实例
@@ -27,6 +27,7 @@ app = FastAPI(
- **文件删除** - 批量删除上传的文件
- **流水查询** - 分页获取银行流水数据
- **征信解析** - 发起 HTML 远程解析并通过结果接口返回结构化征信 payload
- **工商信息** - 按企业名称同步返回新华社工商信息
### 错误模拟
@@ -51,6 +52,7 @@ app = FastAPI(
# 包含 API 路由
app.include_router(api.router, tags=["流水分析接口"])
app.include_router(credit_api.router, tags=["征信解析接口"])
app.include_router(enterprise_api.router, tags=["新华社工商信息接口"])
@app.get("/", summary="服务根路径")

View File

@@ -0,0 +1,115 @@
import asyncio
import hashlib
from typing import Optional
from fastapi import APIRouter, Form, Response
from fastapi.responses import PlainTextResponse
router = APIRouter()
@router.post("/api/service/interface/invokeService/LSFXXHS")
async def query_enterprise(
entName: Optional[str] = Form(None),
serialNum: Optional[str] = Form(None),
runType: Optional[str] = Form(None),
orgCode: Optional[str] = Form(None),
):
missing = next((name for name, value in {
"entName": entName,
"serialNum": serialNum,
"runType": runType,
"orgCode": orgCode,
}.items() if not value), None)
if missing:
return business_error(f"缺少参数: {missing}")
name = entName.strip()
if name == "MOCK_HTTP_500":
return Response(content="mock server error", status_code=500)
if name == "MOCK_TIMEOUT":
await asyncio.sleep(5)
return success_wrapper(build_profile(name))
if name == "MOCK_EMPTY_RESPONSE":
return Response(content="", media_type="application/json")
if name == "MOCK_INVALID_JSON":
return PlainTextResponse("not-json")
if name == "MOCK_OUTER_FAILURE":
return {"success": False, "code": 50000, "data": None}
if name == "MOCK_BUSINESS_FAILURE":
return business_error("工商查询失败")
if name == "MOCK_MAPPING_MISSING":
return success_wrapper(None, include_mapping=False)
if name == "MOCK_MAPPING_NULL":
return success_wrapper(None)
if name == "MOCK_MAPPING_EMPTY":
return success_wrapper({})
profile = build_profile(name)
if name == "MOCK_NAME_MISMATCH":
profile["enterpriseName"] = "其他企业有限公司"
if name == "MOCK_CREDIT_CODE_MISSING":
profile["creditCode"] = " "
return success_wrapper(profile)
def success_wrapper(profile, include_mapping=True):
data = {
"reasonMessage": "Running successfully",
"reasonCode": 200,
"status": 1,
"extensionMap": {},
}
if include_mapping:
data["mappingOutputFields"] = profile
return {"success": True, "code": 10000, "data": data}
def business_error(message):
return {
"success": True,
"code": 10000,
"data": {
"mappingOutputFields": None,
"reasonMessage": message,
"reasonCode": 500,
"status": 0,
"extensionMap": {},
},
}
def build_profile(name):
digest = hashlib.sha1(name.encode("utf-8")).hexdigest()
credit_code = "91" + "".join(str(int(ch, 16) % 10) for ch in digest[:16])
holders = [] if "空股东" in name else [
{"stock_name": "张三", "stock_percent": "60%", "should_capi": "600"},
{"stock_name": "李四", "stock_percent": "40%", "should_capi": "400"},
]
if "多股东" in name:
holders = [
{"stock_name": f"股东{i}", "stock_percent": f"{i}%", "should_capi": str(i * 10)}
for i in range(1, 8)
]
individual = "个体" in name
return {
"creditCode": credit_code,
"enterpriseName": name,
"baseCity": {
"province": "浙江省",
"province_code": "330000",
"city": "杭州市" if individual else None,
"county": "西湖区" if individual else None,
},
"regCapitalAmount": {"regist_capi_value": "1000", "regist_capi_unit": "万元"},
"estiblishTime": "2020-01-02",
"fromTime": "2020-01-01",
"companyOrgType": "个体" if individual else {"econ_kind": "有限责任公司", "econ_kind_code": "1100"},
"industry": {"industry_code": "I65", "industry": "软件和信息技术服务业"},
"regLocation": "浙江省杭州市西湖区测试路1号",
"employeeCount": None if individual else 25,
"legalPersonName": "王五",
"holders": holders,
}

View File

@@ -155,3 +155,9 @@ credit-parse:
org-code: 999000
run-type: 1
model: LXCUSTALL
xinhua-enterprise:
api:
url: http://localhost:8000/api/service/interface/invokeService/LSFXXHS
org-code: 999000
run-type: 1

View File

@@ -155,3 +155,9 @@ credit-parse:
org-code: 999000
run-type: 1
model: LXCUSTALL
xinhua-enterprise:
api:
url: http://192.168.0.111:62320/api/service/interface/invokeService/LSFXXHS
org-code: 999000
run-type: 1

View File

@@ -150,3 +150,9 @@ credit-parse:
org-code: 999000
run-type: 1
model: LXCUSTALL
xinhua-enterprise:
api:
url: http://64.202.32.40:8083/api/service/interface/invokeService/LSFXXHS
org-code: 999000
run-type: 1

View File

@@ -153,3 +153,9 @@ credit-parse:
org-code: 999000
run-type: 1
model: LXCUSTALL
xinhua-enterprise:
api:
url: http://192.168.0.111:62320/api/service/interface/invokeService/LSFXXHS
org-code: 999000
run-type: 1

View File

@@ -76,3 +76,15 @@ export function getImportFailures(taskId, pageNum, pageSize) {
params: { pageNum, pageSize }
})
}
export function refreshEnterpriseProfile(socialCreditCode) {
return request({ url: '/ccdi/enterpriseBaseInfo/enterprise-profile/refresh/' + socialCreditCode, method: 'post' })
}
export function startEnterpriseProfileBackfill() {
return request({ url: '/ccdi/enterpriseBaseInfo/enterprise-profile/backfill', method: 'post' })
}
export function getEnterpriseProfileBackfillStatus(taskId) {
return request({ url: '/ccdi/enterpriseBaseInfo/enterprise-profile/backfill/' + taskId, method: 'get' })
}

View File

@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function getCounterpartyEnterpriseDetail(projectId, counterpartyName) {
return request({ url: '/ccdi/project/counterparty-enterprise/detail', method: 'get', params: { projectId, counterpartyName } })
}
export function refreshCounterpartyEnterprise(data) {
return request({ url: '/ccdi/project/counterparty-enterprise/refresh', method: 'post', data })
}
export function startCounterpartyEnterpriseBackfill() {
return request({ url: '/ccdi/project/counterparty-enterprise/backfill', method: 'post' })
}
export function getCounterpartyEnterpriseBackfillStatus(taskId) {
return request({ url: '/ccdi/project/counterparty-enterprise/backfill/' + taskId, method: 'get' })
}

View File

@@ -148,6 +148,17 @@
>查看导入失败记录</el-button>
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-refresh"
size="mini"
:loading="profileBackfillLoading"
@click="handleProfileBackfill"
v-hasPermi="['ccdi:enterpriseBaseInfo:backfill']"
>工商历史补全</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
@@ -407,6 +418,12 @@
<el-descriptions-item label="成立日期">{{ parseTime(detailData.establishDate, '{y}-{m}-{d}') || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册地址">{{ detailData.registerAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="法定代表人">{{ detailData.legalRepresentative || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册资本">{{ formatCapital(detailData.registeredCapital, detailData.registeredCapitalUnit) }}</el-descriptions-item>
<el-descriptions-item label="注册日期">{{ parseTime(detailData.registerDate, '{y}-{m}-{d}') || '-' }}</el-descriptions-item>
<el-descriptions-item label="区域">{{ detailData.regionName || '-' }}</el-descriptions-item>
<el-descriptions-item label="区域编码">{{ detailData.regionCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="从业人数">{{ detailData.employeeCount == null ? '-' : detailData.employeeCount }}</el-descriptions-item>
<el-descriptions-item label="工商同步时间">{{ parseTime(detailData.enterpriseSyncTime) || '-' }}</el-descriptions-item>
<el-descriptions-item label="法定代表人证件类型">{{ detailData.legalCertType || '-' }}</el-descriptions-item>
<el-descriptions-item label="法定代表人证件号码">{{ detailData.legalCertNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="经营状态">{{ formatStatus(detailData.status) }}</el-descriptions-item>
@@ -420,7 +437,24 @@
<el-descriptions-item label="股东4">{{ detailData.shareholder4 || '-' }}</el-descriptions-item>
<el-descriptions-item label="股东5">{{ detailData.shareholder5 || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="shareholder-title">完整股东信息</div>
<el-table :data="detailData.shareholders || []" border size="small">
<el-table-column label="序号" prop="shareholderSeq" width="70" align="center" />
<el-table-column label="股东名称" prop="shareholderName" min-width="220" />
<el-table-column label="持股比例(%)" prop="stockPercent" width="130" align="right" />
<el-table-column label="认缴金额" min-width="150" align="right">
<template slot-scope="scope">{{ formatCapital(scope.row.subscribedCapital, scope.row.capitalUnit) }}</template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer">
<el-button
v-if="detailData.canRefreshEnterpriseProfile"
type="primary"
icon="el-icon-refresh"
:loading="profileRefreshing"
@click="handleProfileRefresh"
v-hasPermi="['ccdi:enterpriseBaseInfo:refresh']"
>重新查询</el-button>
<el-button @click="detailOpen = false"> </el-button>
</div>
</el-dialog>
@@ -506,9 +540,12 @@ import {
addEnterpriseBaseInfo,
delEnterpriseBaseInfo,
getEnterpriseBaseInfo,
getEnterpriseProfileBackfillStatus,
getImportFailures,
getImportStatus,
listEnterpriseBaseInfo,
refreshEnterpriseProfile,
startEnterpriseProfileBackfill,
updateEnterpriseBaseInfo
} from "@/api/ccdiEnterpriseBaseInfo";
import {
@@ -546,6 +583,9 @@ export default {
detailOpen: false,
isAdd: true,
detailData: {},
profileRefreshing: false,
profileBackfillLoading: false,
profileBackfillTimer: null,
corpTypeOptions: [],
corpNatureOptions: [],
certTypeOptions: [],
@@ -626,6 +666,10 @@ export default {
clearInterval(this.pollingTimer);
this.pollingTimer = null;
}
if (this.profileBackfillTimer) {
clearInterval(this.profileBackfillTimer);
this.profileBackfillTimer = null;
}
},
methods: {
loadOptions() {
@@ -709,6 +753,51 @@ export default {
this.detailOpen = true;
});
},
handleProfileRefresh() {
if (!this.detailData.socialCreditCode) return;
this.profileRefreshing = true;
refreshEnterpriseProfile(this.detailData.socialCreditCode).then(() => {
this.$modal.msgSuccess("工商信息已更新");
return getEnterpriseBaseInfo(this.detailData.socialCreditCode);
}).then(response => {
this.detailData = response.data || {};
this.getList();
}).finally(() => {
this.profileRefreshing = false;
});
},
handleProfileBackfill() {
this.$modal.confirm("确认启动实体库工商信息历史补全?").then(() => {
this.profileBackfillLoading = true;
return startEnterpriseProfileBackfill();
}).then(response => {
this.pollProfileBackfill(response.data);
}).catch(() => {
this.profileBackfillLoading = false;
});
},
pollProfileBackfill(taskId) {
const query = () => getEnterpriseProfileBackfillStatus(taskId).then(response => {
const status = response.data || {};
if (status.status === "COMPLETED") {
clearInterval(this.profileBackfillTimer);
this.profileBackfillTimer = null;
this.profileBackfillLoading = false;
this.$notify({ title: "工商历史补全完成", message: `成功 ${status.successCount || 0},跳过 ${status.skippedCount || 0},失败 ${status.failureCount || 0}`, type: status.failureCount ? "warning" : "success" });
this.getList();
}
}).catch(() => {
clearInterval(this.profileBackfillTimer);
this.profileBackfillTimer = null;
this.profileBackfillLoading = false;
});
query();
this.profileBackfillTimer = setInterval(query, 2000);
},
formatCapital(amount, unit) {
if (amount === null || amount === undefined || amount === "") return "-";
return `${amount}${unit || ""}`;
},
handleUpdate(row) {
this.reset();
this.isAdd = false;
@@ -1009,4 +1098,12 @@ export default {
.el-upload__tip {
line-height: 1.6;
}
.shareholder-title {
margin: 18px 0 10px;
color: #303133;
font-size: 15px;
font-weight: 600;
letter-spacing: 0;
}
</style>

View File

@@ -172,6 +172,14 @@
>
导出流水
</el-button>
<el-button
size="small"
plain
icon="el-icon-refresh"
:loading="counterpartyBackfillLoading"
@click="handleCounterpartyBackfill"
v-hasPermi="['ccdi:project:counterpartyEnterprise:backfill']"
>工商历史补全</el-button>
</div>
<el-alert
v-if="resultMode === 'statement' && prefillTip"
@@ -237,7 +245,13 @@
<template slot-scope="scope">
<div class="multi-line-cell">
<div class="primary-text">
{{ formatField(scope.row.customerAccountName) }}
<el-button
v-if="scope.row.customerAccountName"
type="text"
class="counterparty-link"
@click="openCounterpartyDetail(scope.row.customerAccountName)"
>{{ scope.row.customerAccountName }}</el-button>
<span v-else>-</span>
</div>
<div class="secondary-text">
{{ formatField(scope.row.customerAccountNo) }}
@@ -496,6 +510,60 @@
<el-button type="primary" @click="closeDetailDialog">确定</el-button>
</div>
</el-dialog>
<el-dialog title="对手方工商信息" :visible.sync="counterpartyVisible" width="920px" append-to-body>
<div v-loading="counterpartyLoading">
<el-alert
v-if="counterpartyData.status === 'NOT_FOUND'"
title="当前项目尚无该对手方工商数据"
type="info"
:closable="false"
show-icon
class="counterparty-alert"
/>
<el-alert
v-else-if="counterpartyData.status === 'EXPIRED'"
title="工商缓存已过期,可重新查询"
type="warning"
:closable="false"
show-icon
class="counterparty-alert"
/>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="流水对手方">{{ formatField(counterpartyData.counterpartyName) }}</el-descriptions-item>
<el-descriptions-item label="企业名称">{{ formatField(counterpartyData.enterpriseName) }}</el-descriptions-item>
<el-descriptions-item label="统一社会信用代码">{{ formatField(counterpartyData.socialCreditCode) }}</el-descriptions-item>
<el-descriptions-item label="法定代表人">{{ formatField(counterpartyData.legalRepresentative) }}</el-descriptions-item>
<el-descriptions-item label="注册资本">{{ formatCapital(counterpartyData.registeredCapital, counterpartyData.registeredCapitalUnit) }}</el-descriptions-item>
<el-descriptions-item label="注册日期">{{ formatDateOnly(counterpartyData.registerDate) }}</el-descriptions-item>
<el-descriptions-item label="成立日期">{{ formatDateOnly(counterpartyData.establishDate) }}</el-descriptions-item>
<el-descriptions-item label="机构类型">{{ formatField(counterpartyData.organizationTypeName) }}</el-descriptions-item>
<el-descriptions-item label="所属行业">{{ formatField(counterpartyData.industryName) }}</el-descriptions-item>
<el-descriptions-item label="区域">{{ formatField(counterpartyData.regionName) }}</el-descriptions-item>
<el-descriptions-item label="从业人数">{{ counterpartyData.employeeCount == null ? '-' : counterpartyData.employeeCount }}</el-descriptions-item>
<el-descriptions-item label="同步时间">{{ formatDate(counterpartyData.syncTime) }}</el-descriptions-item>
<el-descriptions-item label="注册地址" :span="2">{{ formatField(counterpartyData.registerAddress) }}</el-descriptions-item>
</el-descriptions>
<div class="counterparty-section-title">完整股东信息</div>
<el-table :data="counterpartyData.shareholders || []" border size="small">
<el-table-column label="序号" prop="sequenceNo" width="70" align="center" />
<el-table-column label="股东名称" prop="shareholderName" min-width="220" />
<el-table-column label="持股比例(%)" prop="stockPercent" width="130" align="right" />
<el-table-column label="认缴金额" min-width="150" align="right"><template slot-scope="scope">{{ formatCapital(scope.row.subscribedCapital, scope.row.capitalUnit) }}</template></el-table-column>
</el-table>
</div>
<div slot="footer">
<el-button
v-if="counterpartyData.canRefresh"
type="primary"
icon="el-icon-refresh"
:loading="counterpartyRefreshing"
@click="refreshCounterparty"
v-hasPermi="['ccdi:project:counterpartyEnterprise:refresh']"
>重新查询</el-button>
<el-button @click="counterpartyVisible=false">关闭</el-button>
</div>
</el-dialog>
</div>
</template>
@@ -508,6 +576,12 @@ import {
getBankStatementDetail,
} from "@/api/ccdiProjectBankStatement";
import { buildFlowEvidenceFingerprint, buildFlowEvidenceSnapshot } from "@/utils/ccdiEvidence";
import {
getCounterpartyEnterpriseBackfillStatus,
getCounterpartyEnterpriseDetail,
refreshCounterpartyEnterprise,
startCounterpartyEnterpriseBackfill,
} from "@/api/ccdiProjectCounterpartyEnterprise";
const TAB_MAP = {
all: "all",
@@ -592,6 +666,8 @@ const createEmptyDetailData = () => ({
hitTags: [],
});
const createEmptyCounterpartyData = () => ({ status: "NOT_FOUND", canRefresh: false, shareholders: [] });
export default {
name: "DetailQuery",
props: {
@@ -629,6 +705,12 @@ export default {
listError: "",
prefillTip: "",
detailData: createEmptyDetailData(),
counterpartyVisible: false,
counterpartyLoading: false,
counterpartyRefreshing: false,
counterpartyBackfillLoading: false,
counterpartyBackfillTimer: null,
counterpartyData: createEmptyCounterpartyData(),
queryParams: createDefaultQueryParams(this.projectId),
optionData: createEmptyOptionData(),
};
@@ -642,6 +724,9 @@ export default {
return this.resultMode === "counterparty" ? this.counterpartyTotal : this.total;
},
},
beforeDestroy() {
if (this.counterpartyBackfillTimer) clearInterval(this.counterpartyBackfillTimer);
},
watch: {
dateRange(value) {
this.queryParams.transactionStartTime = value && value[0] ? value[0] : "";
@@ -838,6 +923,53 @@ export default {
this.detailLoading = false;
this.detailData = createEmptyDetailData();
},
async openCounterpartyDetail(counterpartyName) {
if (!this.projectId || !counterpartyName) return;
this.counterpartyVisible = true;
this.counterpartyLoading = true;
try {
const response = await getCounterpartyEnterpriseDetail(this.projectId, counterpartyName);
this.counterpartyData = { ...createEmptyCounterpartyData(), ...(response.data || {}) };
} catch (error) {
this.counterpartyVisible = false;
this.$message.error("加载对手方工商信息失败");
} finally {
this.counterpartyLoading = false;
}
},
async refreshCounterparty() {
this.counterpartyRefreshing = true;
try {
const response = await refreshCounterpartyEnterprise({ projectId: this.projectId, counterpartyName: this.counterpartyData.counterpartyName });
this.counterpartyData = { ...createEmptyCounterpartyData(), ...(response.data || {}) };
this.$message.success("对手方工商信息已更新");
} finally {
this.counterpartyRefreshing = false;
}
},
handleCounterpartyBackfill() {
this.$confirm("确认启动全部项目对手方工商信息历史补全?", "提示", { type: "warning" }).then(() => {
this.counterpartyBackfillLoading = true;
return startCounterpartyEnterpriseBackfill();
}).then(response => this.pollCounterpartyBackfill(response.data)).catch(() => { this.counterpartyBackfillLoading = false; });
},
pollCounterpartyBackfill(taskId) {
const query = () => getCounterpartyEnterpriseBackfillStatus(taskId).then(response => {
const status = response.data || {};
if (status.status === "COMPLETED") {
clearInterval(this.counterpartyBackfillTimer);
this.counterpartyBackfillTimer = null;
this.counterpartyBackfillLoading = false;
this.$notify({ title: "对手方工商补全完成", message: `成功 ${status.successCount || 0},失败 ${status.failureCount || 0}`, type: status.failureCount ? "warning" : "success" });
}
}).catch(() => {
clearInterval(this.counterpartyBackfillTimer);
this.counterpartyBackfillTimer = null;
this.counterpartyBackfillLoading = false;
});
query();
this.counterpartyBackfillTimer = setInterval(query, 2000);
},
handleAddEvidence() {
const detail = this.detailData || {};
const sourceRecordId = buildFlowEvidenceFingerprint(detail);
@@ -881,6 +1013,13 @@ export default {
formatDate(value) {
return value ? parseTime(value, "{y}-{m}-{d} {h}:{i}:{s}") : "-";
},
formatDateOnly(value) {
return value ? parseTime(value, "{y}-{m}-{d}") : "-";
},
formatCapital(amount, unit) {
if (amount === null || amount === undefined || amount === "") return "-";
return `${amount}${unit || ""}`;
},
formatAmount(value) {
if (value === null || value === undefined || value === "") {
return "-";
@@ -1010,6 +1149,26 @@ export default {
}
}
.counterparty-link {
max-width: 100%;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.counterparty-alert {
margin-bottom: 12px;
}
.counterparty-section-title {
margin: 18px 0 10px;
color: #303133;
font-size: 15px;
font-weight: 600;
letter-spacing: 0;
}
.shell-sidebar,
.shell-main {
border: 1px solid #ebeef5;

View File

@@ -0,0 +1,180 @@
-- 新华社工商信息同步接入
-- MySQL 5.7/8.0 幂等迁移,统一字符集与排序规则。
SET NAMES utf8mb4 COLLATE utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `ccdi_enterpriseinfo_query_cache` (
`info_id` bigint NOT NULL AUTO_INCREMENT,
`query_param` varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
`query_result` longtext COLLATE utf8mb4_general_ci NOT NULL,
`query_type` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`created_date` datetime NOT NULL,
`valid_date` datetime NOT NULL,
PRIMARY KEY (`info_id`),
UNIQUE KEY `uk_enterpriseinfo_query` (`query_param`,`query_type`),
KEY `idx_enterpriseinfo_valid_date` (`valid_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='工商信息原始响应缓存';
ALTER TABLE `ccdi_enterpriseinfo_query_cache`
MODIFY `query_result` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `ccdi_enterprise_shareholder` (
`shareholder_id` bigint NOT NULL AUTO_INCREMENT,
`social_credit_code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`shareholder_seq` int DEFAULT NULL,
`shareholder_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`shareholder_type` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL,
`shareholder_credit_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`stock_percent` decimal(12,6) DEFAULT NULL,
`subscribed_capital` decimal(20,6) DEFAULT NULL,
`capital_unit` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`cache_info_id` bigint DEFAULT NULL,
`sync_time` datetime DEFAULT NULL,
`create_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`shareholder_id`),
KEY `idx_enterprise_shareholder_credit` (`social_credit_code`),
KEY `idx_enterprise_shareholder_cache` (`cache_info_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='实体库企业完整股东';
CREATE TABLE IF NOT EXISTS `ccdi_project_counterparty_enterprise` (
`counterparty_enterprise_id` bigint NOT NULL AUTO_INCREMENT,
`project_id` bigint NOT NULL,
`counterparty_name` varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
`social_credit_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`enterprise_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`registered_capital` decimal(20,6) DEFAULT NULL,
`registered_capital_unit` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`register_date` date DEFAULT NULL,
`establish_date` date DEFAULT NULL,
`industry_code` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL,
`industry_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`organization_type_code` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL,
`organization_type_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`region_code` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL,
`region_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`register_address` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`employee_count` int DEFAULT NULL,
`legal_representative` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`cache_info_id` bigint DEFAULT NULL,
`sync_time` datetime DEFAULT NULL,
`create_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`counterparty_enterprise_id`),
UNIQUE KEY `uk_project_counterparty_name` (`project_id`,`counterparty_name`),
KEY `idx_project_counterparty_credit` (`social_credit_code`),
KEY `idx_project_counterparty_cache` (`cache_info_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='项目对手方工商信息';
CREATE TABLE IF NOT EXISTS `ccdi_project_counterparty_shareholder` (
`shareholder_id` bigint NOT NULL AUTO_INCREMENT,
`counterparty_enterprise_id` bigint NOT NULL,
`shareholder_seq` int NOT NULL,
`shareholder_name` varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
`shareholder_type` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL,
`shareholder_credit_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`stock_percent` decimal(12,6) DEFAULT NULL,
`subscribed_capital` decimal(20,6) DEFAULT NULL,
`capital_unit` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`shareholder_id`),
KEY `idx_project_counterparty_shareholder_enterprise` (`counterparty_enterprise_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='项目对手方完整股东';
CREATE TABLE IF NOT EXISTS `sys_api_log` (
`log_id` bigint NOT NULL AUTO_INCREMENT,
`caller_user_id` bigint DEFAULT NULL,
`caller_username` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`api_url` text COLLATE utf8mb4_general_ci NOT NULL,
`http_method` varchar(10) COLLATE utf8mb4_general_ci NOT NULL,
`content_type` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`request_headers` longtext COLLATE utf8mb4_general_ci,
`request_params` longtext COLLATE utf8mb4_general_ci,
`response_status` int DEFAULT NULL,
`response_headers` longtext COLLATE utf8mb4_general_ci,
`response_body` longtext COLLATE utf8mb4_general_ci,
`call_status` char(1) COLLATE utf8mb4_general_ci NOT NULL,
`error_msg` longtext COLLATE utf8mb4_general_ci,
`cost_time` bigint NOT NULL,
`call_time` datetime NOT NULL,
PRIMARY KEY (`log_id`),
KEY `idx_api_log_call_time` (`call_time`),
KEY `idx_api_log_caller` (`caller_user_id`),
KEY `idx_api_log_status` (`call_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='外部接口调用日志';
ALTER TABLE `ccdi_enterprise_shareholder`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `ccdi_project_counterparty_enterprise`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `ccdi_project_counterparty_shareholder`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `sys_api_log`
MODIFY `caller_username` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
MODIFY `api_url` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
MODIFY `http_method` VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
MODIFY `call_status` CHAR(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
MODIFY `cost_time` BIGINT NOT NULL,
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- 企业实体扩展字段MySQL 5.7 不支持 ADD COLUMN IF NOT EXISTS使用 information_schema 判定)。
SET @schema_name = DATABASE();
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=@schema_name AND table_name='ccdi_enterprise_base_info' AND column_name='registered_capital'), 'SELECT 1', 'ALTER TABLE ccdi_enterprise_base_info ADD COLUMN registered_capital DECIMAL(20,6) NULL COMMENT ''注册资本'' AFTER legal_representative');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=@schema_name AND table_name='ccdi_enterprise_base_info' AND column_name='registered_capital_unit'), 'SELECT 1', 'ALTER TABLE ccdi_enterprise_base_info ADD COLUMN registered_capital_unit VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT ''注册资本单位'' AFTER registered_capital');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=@schema_name AND table_name='ccdi_enterprise_base_info' AND column_name='register_date'), 'SELECT 1', 'ALTER TABLE ccdi_enterprise_base_info ADD COLUMN register_date DATE NULL COMMENT ''注册日期'' AFTER registered_capital_unit');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=@schema_name AND table_name='ccdi_enterprise_base_info' AND column_name='region_code'), 'SELECT 1', 'ALTER TABLE ccdi_enterprise_base_info ADD COLUMN region_code VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT ''区域编码'' AFTER register_date');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=@schema_name AND table_name='ccdi_enterprise_base_info' AND column_name='region_name'), 'SELECT 1', 'ALTER TABLE ccdi_enterprise_base_info ADD COLUMN region_name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT ''区域名称'' AFTER region_code');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=@schema_name AND table_name='ccdi_enterprise_base_info' AND column_name='employee_count'), 'SELECT 1', 'ALTER TABLE ccdi_enterprise_base_info ADD COLUMN employee_count INT NULL COMMENT ''从业人数'' AFTER region_name');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=@schema_name AND table_name='ccdi_enterprise_base_info' AND column_name='cache_info_id'), 'SELECT 1', 'ALTER TABLE ccdi_enterprise_base_info ADD COLUMN cache_info_id BIGINT NULL COMMENT ''工商缓存ID'' AFTER employee_count');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=@schema_name AND table_name='ccdi_enterprise_base_info' AND column_name='enterprise_sync_time'), 'SELECT 1', 'ALTER TABLE ccdi_enterprise_base_info ADD COLUMN enterprise_sync_time DATETIME NULL COMMENT ''工商同步时间'' AFTER cache_info_id');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE `ccdi_enterprise_base_info` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- 只读接口日志菜单。
SET @log_parent_id = (SELECT menu_id FROM sys_menu WHERE menu_type='M' AND menu_name='日志管理' ORDER BY menu_id LIMIT 1);
SET @api_log_menu_id = (SELECT menu_id FROM sys_menu WHERE perms='monitor:apilog:list' ORDER BY menu_id LIMIT 1);
SET @next_menu_id = (SELECT next_id FROM (SELECT COALESCE(MAX(menu_id), 0) + 1 AS next_id FROM sys_menu) t);
SET @api_log_menu_id = COALESCE(@api_log_menu_id, @next_menu_id);
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT @api_log_menu_id, '接口日志', @log_parent_id, 3, 'apilog', 'monitor/apilog/index', '', '', 1, 0, 'C', '0', '0', 'monitor:apilog:list', 'form', 'admin', NOW(), '', NULL, '只读外部接口调用日志'
WHERE @log_parent_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='monitor:apilog:list');
-- 工商刷新和历史补全权限。
SET @enterprise_menu_id = (SELECT menu_id FROM sys_menu WHERE perms='ccdi:enterpriseBaseInfo:list' ORDER BY menu_id LIMIT 1);
SET @project_menu_id = (SELECT menu_id FROM sys_menu WHERE perms='ccdi:project:list' ORDER BY menu_id LIMIT 1);
SET @next_menu_id = (SELECT next_id FROM (SELECT COALESCE(MAX(menu_id), 0) + 1 AS next_id FROM sys_menu) t);
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT @next_menu_id, '实体工商刷新', @enterprise_menu_id, 6, '', NULL, '', '', 1, 0, 'F', '0', '0', 'ccdi:enterpriseBaseInfo:refresh', '#', 'admin', NOW(), '', NULL, ''
WHERE @enterprise_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='ccdi:enterpriseBaseInfo:refresh');
SET @next_menu_id = (SELECT next_id FROM (SELECT COALESCE(MAX(menu_id), 0) + 1 AS next_id FROM sys_menu) t);
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT @next_menu_id, '实体工商补全', @enterprise_menu_id, 7, '', NULL, '', '', 1, 0, 'F', '0', '0', 'ccdi:enterpriseBaseInfo:backfill', '#', 'admin', NOW(), '', NULL, ''
WHERE @enterprise_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='ccdi:enterpriseBaseInfo:backfill');
SET @next_menu_id = (SELECT next_id FROM (SELECT COALESCE(MAX(menu_id), 0) + 1 AS next_id FROM sys_menu) t);
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT @next_menu_id, '对手方工商刷新', @project_menu_id, 6, '', NULL, '', '', 1, 0, 'F', '0', '0', 'ccdi:project:counterpartyEnterprise:refresh', '#', 'admin', NOW(), '', NULL, ''
WHERE @project_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='ccdi:project:counterpartyEnterprise:refresh');
SET @next_menu_id = (SELECT next_id FROM (SELECT COALESCE(MAX(menu_id), 0) + 1 AS next_id FROM sys_menu) t);
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
SELECT @next_menu_id, '对手方工商补全', @project_menu_id, 7, '', NULL, '', '', 1, 0, 'F', '0', '0', 'ccdi:project:counterpartyEnterprise:backfill', '#', 'admin', NOW(), '', NULL, ''
WHERE @project_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='ccdi:project:counterpartyEnterprise:backfill');
INSERT IGNORE INTO sys_role_menu (role_id, menu_id)
SELECT 1, menu_id FROM sys_menu
WHERE perms IN ('monitor:apilog:list', 'ccdi:enterpriseBaseInfo:refresh', 'ccdi:enterpriseBaseInfo:backfill',
'ccdi:project:counterpartyEnterprise:refresh', 'ccdi:project:counterpartyEnterprise:backfill');