From 5d004a66e8560f67d0b1b56d907dfa4c21239e93 Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Wed, 29 Jul 2026 17:56:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E6=96=B0=E5=8D=8E?= =?UTF-8?q?=E7=A4=BE=E5=B7=A5=E5=95=86=E4=BF=A1=E6=81=AF=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ccdi-info-collection/pom.xml | 6 - .../EnterpriseProfileExecutorConfig.java | 23 ++ .../CcdiEnterpriseBaseInfoController.java | 38 ++- .../domain/CcdiEnterpriseBaseInfo.java | 25 ++ .../domain/CcdiEnterpriseInfoQueryCache.java | 21 ++ .../domain/CcdiEnterpriseShareholder.java | 31 ++ .../domain/model/EnterpriseProfile.java | 29 ++ .../model/EnterpriseProfileQueryResult.java | 12 + .../model/EnterpriseShareholderProfile.java | 15 + .../domain/vo/CcdiEnterpriseBaseInfoVO.java | 36 +++ .../vo/EnterpriseProfileSyncTaskVO.java | 16 + .../domain/vo/EnterpriseShareholderVO.java | 17 + .../mapper/CcdiEnterpriseBaseInfoMapper.java | 4 + .../CcdiEnterpriseInfoQueryCacheMapper.java | 19 ++ .../CcdiEnterpriseShareholderMapper.java | 14 + .../ICcdiEnterpriseBaseInfoImportService.java | 3 +- .../ICcdiEnterpriseBaseInfoService.java | 7 +- .../service/IEnterpriseEntitySyncService.java | 15 + .../IEnterpriseProfileQueryService.java | 11 + ...diEnterpriseBaseInfoImportServiceImpl.java | 12 +- .../CcdiEnterpriseBaseInfoServiceImpl.java | 62 +++- .../impl/EnterpriseEntitySyncServiceImpl.java | 304 ++++++++++++++++++ .../EnterpriseProfileQueryServiceImpl.java | 92 ++++++ .../support/EnterpriseAutoFillService.java | 25 ++ .../XinhuaEnterpriseProfileParser.java | 141 ++++++++ .../CcdiEnterpriseBaseInfoMapper.xml | 51 +++ .../CcdiEnterpriseInfoQueryCacheMapper.xml | 31 ++ .../CcdiEnterpriseShareholderMapper.xml | 29 ++ ...CcdiEnterpriseBaseInfoServiceImplTest.java | 22 +- .../lsfx/client/XinhuaEnterpriseClient.java | 111 +++++++ .../response/XinhuaEnterpriseQueryResult.java | 7 + ccdi-project/pom.xml | 7 + .../controller/CcdiFileUploadController.java | 4 +- ...ojectCounterpartyEnterpriseController.java | 56 ++++ .../dto/ProjectCounterpartyRefreshDTO.java | 14 + .../CcdiProjectCounterpartyEnterprise.java | 41 +++ .../CcdiProjectCounterpartyShareholder.java | 26 ++ ...cdiProjectHistoryImportSubmittedEvent.java | 11 +- .../vo/ProjectCounterpartyEnterpriseVO.java | 35 ++ .../vo/ProjectCounterpartyShareholderVO.java | 14 + .../vo/ProjectCounterpartySyncTaskVO.java | 15 + .../mapper/CcdiBankStatementMapper.java | 4 + ...diProjectCounterpartyEnterpriseMapper.java | 16 + ...iProjectCounterpartyShareholderMapper.java | 15 + .../service/ICcdiFileUploadService.java | 4 +- .../ICcdiProjectHistoryImportService.java | 5 +- ...IProjectCounterpartyEnterpriseService.java | 14 + .../impl/CcdiFileUploadServiceImpl.java | 29 +- ...CcdiProjectHistoryImportEventListener.java | 2 +- .../CcdiProjectHistoryImportServiceImpl.java | 14 +- .../service/impl/CcdiProjectServiceImpl.java | 2 +- ...jectCounterpartyEnterpriseServiceImpl.java | 280 ++++++++++++++++ .../ccdi/project/CcdiBankStatementMapper.xml | 18 ++ ...cdiProjectCounterpartyEnterpriseMapper.xml | 17 + ...diProjectCounterpartyShareholderMapper.xml | 21 ++ .../CcdiFileUploadControllerTest.java | 6 +- .../impl/CcdiFileUploadServiceImplTest.java | 13 +- ...diProjectHistoryImportServiceImplTest.java | 13 +- .../impl/CcdiProjectServiceImplTest.java | 2 +- ...-enterprise-profile-implementation-plan.md | 62 ++++ ...inhua-enterprise-profile-implementation.md | 70 ++++ lsfx-mock-server/main.py | 4 +- lsfx-mock-server/routers/enterprise_api.py | 115 +++++++ .../src/main/resources/application-dev.yml | 6 + .../src/main/resources/application-nas.yml | 6 + .../src/main/resources/application-pro.yml | 6 + .../src/main/resources/application-uat.yml | 6 + ruoyi-ui/src/api/ccdiEnterpriseBaseInfo.js | 12 + .../api/ccdiProjectCounterpartyEnterprise.js | 17 + .../views/ccdiEnterpriseBaseInfo/index.vue | 97 ++++++ .../components/detail/DetailQuery.vue | 161 +++++++++- .../2026-07-29-xinhua-enterprise-profile.sql | 180 +++++++++++ 72 files changed, 2610 insertions(+), 59 deletions(-) create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/config/EnterpriseProfileExecutorConfig.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseInfoQueryCache.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseShareholder.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseProfile.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseProfileQueryResult.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseShareholderProfile.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/EnterpriseProfileSyncTaskVO.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/EnterpriseShareholderVO.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseInfoQueryCacheMapper.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseShareholderMapper.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/IEnterpriseEntitySyncService.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/IEnterpriseProfileQueryService.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/EnterpriseEntitySyncServiceImpl.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/EnterpriseProfileQueryServiceImpl.java create mode 100644 ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/XinhuaEnterpriseProfileParser.java create mode 100644 ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseInfoQueryCacheMapper.xml create mode 100644 ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseShareholderMapper.xml create mode 100644 ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/XinhuaEnterpriseClient.java create mode 100644 ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/XinhuaEnterpriseQueryResult.java create mode 100644 ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectCounterpartyEnterpriseController.java create mode 100644 ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/ProjectCounterpartyRefreshDTO.java create mode 100644 ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectCounterpartyEnterprise.java create mode 100644 ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectCounterpartyShareholder.java create mode 100644 ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartyEnterpriseVO.java create mode 100644 ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartyShareholderVO.java create mode 100644 ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartySyncTaskVO.java create mode 100644 ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectCounterpartyEnterpriseMapper.java create mode 100644 ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectCounterpartyShareholderMapper.java create mode 100644 ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/IProjectCounterpartyEnterpriseService.java create mode 100644 ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/ProjectCounterpartyEnterpriseServiceImpl.java create mode 100644 ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectCounterpartyEnterpriseMapper.xml create mode 100644 ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectCounterpartyShareholderMapper.xml create mode 100644 docs/plans/fullstack/2026-07-29-xinhua-enterprise-profile-implementation-plan.md create mode 100644 docs/reports/implementation/2026-07-29-xinhua-enterprise-profile-implementation.md create mode 100644 lsfx-mock-server/routers/enterprise_api.py create mode 100644 ruoyi-ui/src/api/ccdiProjectCounterpartyEnterprise.js create mode 100644 sql/migration/2026-07-29-xinhua-enterprise-profile.sql diff --git a/ccdi-info-collection/pom.xml b/ccdi-info-collection/pom.xml index 5c99e018..8158c290 100644 --- a/ccdi-info-collection/pom.xml +++ b/ccdi-info-collection/pom.xml @@ -57,12 +57,6 @@ spring-boot-starter-test test - - com.ruoyi - ccdi-lsfx - 3.9.1 - compile - diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/config/EnterpriseProfileExecutorConfig.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/config/EnterpriseProfileExecutorConfig.java new file mode 100644 index 00000000..5436be8b --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/config/EnterpriseProfileExecutorConfig.java @@ -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; + } +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiEnterpriseBaseInfoController.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiEnterpriseBaseInfoController.java index 39680a36..cc1e47e6 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiEnterpriseBaseInfoController.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiEnterpriseBaseInfoController.java @@ -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"); diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseBaseInfo.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseBaseInfo.java index 135f70d7..e08da945 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseBaseInfo.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseBaseInfo.java @@ -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; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseInfoQueryCache.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseInfoQueryCache.java new file mode 100644 index 00000000..e60b1379 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseInfoQueryCache.java @@ -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; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseShareholder.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseShareholder.java new file mode 100644 index 00000000..7d51e25d --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiEnterpriseShareholder.java @@ -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; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseProfile.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseProfile.java new file mode 100644 index 00000000..906dd45e --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseProfile.java @@ -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 shareholders = new ArrayList<>(); +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseProfileQueryResult.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseProfileQueryResult.java new file mode 100644 index 00000000..a40a22e5 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseProfileQueryResult.java @@ -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 +) { +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseShareholderProfile.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseShareholderProfile.java new file mode 100644 index 00000000..ea5d8aeb --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/model/EnterpriseShareholderProfile.java @@ -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; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiEnterpriseBaseInfoVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiEnterpriseBaseInfoVO.java index e0d5e06f..b65c4477 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiEnterpriseBaseInfoVO.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiEnterpriseBaseInfoVO.java @@ -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 shareholders = new ArrayList<>(); + @Schema(description = "法定代表人证件类型") private String legalCertType; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/EnterpriseProfileSyncTaskVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/EnterpriseProfileSyncTaskVO.java new file mode 100644 index 00000000..f16e0f72 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/EnterpriseProfileSyncTaskVO.java @@ -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; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/EnterpriseShareholderVO.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/EnterpriseShareholderVO.java new file mode 100644 index 00000000..d21ebe2d --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/EnterpriseShareholderVO.java @@ -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; +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseBaseInfoMapper.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseBaseInfoMapper.java index 7d714546..528a831c 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseBaseInfoMapper.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseBaseInfoMapper.java @@ -52,4 +52,8 @@ public interface CcdiEnterpriseBaseInfoMapper extends BaseMapper list); + + int updateEnterpriseProfile(CcdiEnterpriseBaseInfo entity); + + List selectEnterpriseProfileCandidates(); } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseInfoQueryCacheMapper.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseInfoQueryCacheMapper.java new file mode 100644 index 00000000..67af5e6e --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseInfoQueryCacheMapper.java @@ -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 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); +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseShareholderMapper.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseShareholderMapper.java new file mode 100644 index 00000000..53b35813 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiEnterpriseShareholderMapper.java @@ -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 { + List selectByCreditCode(@Param("creditCode") String creditCode); + int deleteByCreditCode(@Param("creditCode") String creditCode); + int insertBatch(@Param("list") List list); +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoImportService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoImportService.java index d84e8e4e..39b879f2 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoImportService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoImportService.java @@ -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 excelList, String taskId, String userName); + void importEnterpriseBaseInfoAsync(List excelList, String taskId, CallerContext caller); ImportStatusVO getImportStatus(String taskId); diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoService.java index f0c7dbb7..a3502b98 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiEnterpriseBaseInfoService.java @@ -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 selectEnterpriseBaseInfoListForExport(CcdiEnterpriseBaseInfoQueryDTO queryDTO); - String importEnterpriseBaseInfo(List excelList); + String importEnterpriseBaseInfo(CallerContext caller, List excelList); } diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/IEnterpriseEntitySyncService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/IEnterpriseEntitySyncService.java new file mode 100644 index 00000000..cc3a4266 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/IEnterpriseEntitySyncService.java @@ -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 socialCreditCodes); + void refresh(CallerContext caller, String socialCreditCode); + String startBackfill(CallerContext caller); + EnterpriseProfileSyncTaskVO getBackfillStatus(String taskId); +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/IEnterpriseProfileQueryService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/IEnterpriseProfileQueryService.java new file mode 100644 index 00000000..7ed7adc0 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/IEnterpriseProfileQueryService.java @@ -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); +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoImportServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoImportServiceImpl.java index 71edd7b2..a7b921da 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoImportServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoImportServiceImpl.java @@ -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 redisTemplate; + @Resource + private IEnterpriseEntitySyncService enterpriseEntitySyncService; + @Override @Async - public void importEnterpriseBaseInfoAsync(List excelList, String taskId, String userName) { + public void importEnterpriseBaseInfoAsync(List excelList, String taskId, CallerContext caller) { List successRecords = new ArrayList<>(); List failures = new ArrayList<>(); Set 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()) { diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoServiceImpl.java index 91bc4aad..ad20655a 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoServiceImpl.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoServiceImpl.java @@ -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 redisTemplate; + @Resource + private CcdiEnterpriseShareholderMapper enterpriseShareholderMapper; + + @Resource + private IEnterpriseProfileQueryService enterpriseProfileQueryService; + + @Resource + private IEnterpriseEntitySyncService enterpriseEntitySyncService; + @Override public Page selectEnterpriseBaseInfoPage(Page 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 excelList) { + public String importEnterpriseBaseInfo(CallerContext caller, List 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 buildQueryWrapper(CcdiEnterpriseBaseInfoQueryDTO queryDTO) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); if (queryDTO == null) { diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/EnterpriseEntitySyncServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/EnterpriseEntitySyncServiceImpl.java new file mode 100644 index 00000000..f3d40206 --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/EnterpriseEntitySyncServiceImpl.java @@ -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 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 socialCreditCodes) { + if (socialCreditCodes == null || socialCreditCodes.isEmpty()) { + return; + } + List normalizedCodes = socialCreditCodes.stream().filter(StringUtils::hasText).distinct().toList(); + if (normalizedCodes.isEmpty()) { + return; + } + List entities = enterpriseMapper.selectBatchIds(normalizedCodes); + Map> 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 candidates = enterpriseMapper.selectEnterpriseProfileCandidates(); + Map> 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 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> groups) { + AtomicInteger completed = new AtomicInteger(); + AtomicInteger success = new AtomicInteger(); + AtomicInteger skipped = new AtomicInteger(); + AtomicInteger failure = new AtomicInteger(); + + List>> entries = new ArrayList<>(groups.entrySet()); + for (int start = 0; start < entries.size(); start += 100) { + int end = Math.min(start + 100, entries.size()); + List> 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 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 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 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 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 + } +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/EnterpriseProfileQueryServiceImpl.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/EnterpriseProfileQueryServiceImpl.java new file mode 100644 index 00000000..81f764ad --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/EnterpriseProfileQueryServiceImpl.java @@ -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(); + } +} diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java index 4fd70329..8db3f677 100644 --- a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java @@ -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 normalizeItems(List items) { @@ -121,6 +129,23 @@ public class EnterpriseAutoFillService { } } + private void submitSyncAfterCommit(List entities) { + String username = entities.stream().map(CcdiEnterpriseBaseInfo::getCreatedBy) + .filter(StringUtils::isNotEmpty).findFirst().orElse("system"); + CallerContext caller = CallerContext.of(null, username); + List 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; diff --git a/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/XinhuaEnterpriseProfileParser.java b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/XinhuaEnterpriseProfileParser.java new file mode 100644 index 00000000..fed98bec --- /dev/null +++ b/ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/XinhuaEnterpriseProfileParser.java @@ -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 shareholders(JsonNode holders) { + List 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 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 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; + } + } +} diff --git a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseBaseInfoMapper.xml b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseBaseInfoMapper.xml index 68502958..05b9fc26 100644 --- a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseBaseInfoMapper.xml +++ b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseBaseInfoMapper.xml @@ -14,6 +14,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + + + + @@ -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" + + 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}) + + + + diff --git a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseInfoQueryCacheMapper.xml b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseInfoQueryCacheMapper.xml new file mode 100644 index 00000000..ec51f2f1 --- /dev/null +++ b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseInfoQueryCacheMapper.xml @@ -0,0 +1,31 @@ + + + + + + + + + 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) + + diff --git a/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseShareholderMapper.xml b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseShareholderMapper.xml new file mode 100644 index 00000000..7bcbdb84 --- /dev/null +++ b/ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiEnterpriseShareholderMapper.xml @@ -0,0 +1,29 @@ + + + + + + + delete from ccdi_enterprise_shareholder where social_credit_code = #{creditCode} + + + + 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 + + (#{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}) + + + diff --git a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoServiceImplTest.java b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoServiceImplTest.java index 6021203c..064f0c3b 100644 --- a/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoServiceImplTest.java +++ b/ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoServiceImplTest.java @@ -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 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 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 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 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"); diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/XinhuaEnterpriseClient.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/XinhuaEnterpriseClient.java new file mode 100644 index 00000000..c157c66a --- /dev/null +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/XinhuaEnterpriseClient.java @@ -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 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(); + } +} diff --git a/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/XinhuaEnterpriseQueryResult.java b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/XinhuaEnterpriseQueryResult.java new file mode 100644 index 00000000..ad503290 --- /dev/null +++ b/ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/response/XinhuaEnterpriseQueryResult.java @@ -0,0 +1,7 @@ +package com.ruoyi.lsfx.domain.response; + +import com.fasterxml.jackson.databind.JsonNode; + +/** 新华社工商信息同步查询结果。 */ +public record XinhuaEnterpriseQueryResult(String rawJson, JsonNode profile) { +} diff --git a/ccdi-project/pom.xml b/ccdi-project/pom.xml index 37eb6a99..4fda12c9 100644 --- a/ccdi-project/pom.xml +++ b/ccdi-project/pom.xml @@ -30,6 +30,13 @@ ${ruoyi.version} + + + com.ruoyi + ccdi-info-collection + ${ruoyi.version} + + org.projectlombok diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java index 409d62fb..515033d7 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java @@ -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); } } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectCounterpartyEnterpriseController.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectCounterpartyEnterpriseController.java new file mode 100644 index 00000000..9732148d --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectCounterpartyEnterpriseController.java @@ -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)); + } +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/ProjectCounterpartyRefreshDTO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/ProjectCounterpartyRefreshDTO.java new file mode 100644 index 00000000..93382623 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/ProjectCounterpartyRefreshDTO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectCounterpartyEnterprise.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectCounterpartyEnterprise.java new file mode 100644 index 00000000..e36aef0d --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectCounterpartyEnterprise.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectCounterpartyShareholder.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectCounterpartyShareholder.java new file mode 100644 index 00000000..21bbbc5e --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectCounterpartyShareholder.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/event/CcdiProjectHistoryImportSubmittedEvent.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/event/CcdiProjectHistoryImportSubmittedEvent.java index 3d939563..69372773 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/event/CcdiProjectHistoryImportSubmittedEvent.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/event/CcdiProjectHistoryImportSubmittedEvent.java @@ -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; } } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartyEnterpriseVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartyEnterpriseVO.java new file mode 100644 index 00000000..41a34c3b --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartyEnterpriseVO.java @@ -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 shareholders = new ArrayList<>(); +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartyShareholderVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartyShareholderVO.java new file mode 100644 index 00000000..a67a5148 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartyShareholderVO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartySyncTaskVO.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartySyncTaskVO.java new file mode 100644 index 00000000..b07070e8 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/ProjectCounterpartySyncTaskVO.java @@ -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; +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapper.java index 0836a0f4..75ccb67e 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapper.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapper.java @@ -49,4 +49,8 @@ public interface CcdiBankStatementMapper extends BaseMapper { CcdiBankStatementFilterOptionsVO selectFilterOptions(@Param("projectId") Long projectId); Integer countMatchedStaffCountByProjectId(@Param("projectId") Long projectId); + + List selectDistinctCounterpartyNames(@Param("projectId") Long projectId); + + List selectDistinctProjectIdsWithCounterparties(); } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectCounterpartyEnterpriseMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectCounterpartyEnterpriseMapper.java new file mode 100644 index 00000000..3e4a6899 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectCounterpartyEnterpriseMapper.java @@ -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 selectByProjectAndName(@Param("projectId") Long projectId, + @Param("counterpartyName") String counterpartyName); + + List selectByProjectId(@Param("projectId") Long projectId); + + int deleteByProjectId(@Param("projectId") Long projectId); +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectCounterpartyShareholderMapper.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectCounterpartyShareholderMapper.java new file mode 100644 index 00000000..ba4b26c3 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectCounterpartyShareholderMapper.java @@ -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 { + List selectByEnterpriseId(@Param("counterpartyEnterpriseId") Long counterpartyEnterpriseId); + + int deleteByEnterpriseId(@Param("counterpartyEnterpriseId") Long counterpartyEnterpriseId); + + int deleteByProjectId(@Param("projectId") Long projectId); +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java index daa6bd73..83a93b37 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java @@ -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); /** * 查询上传记录列表 diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectHistoryImportService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectHistoryImportService.java index fb51c134..efa733c7 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectHistoryImportService.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectHistoryImportService.java @@ -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); } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/IProjectCounterpartyEnterpriseService.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/IProjectCounterpartyEnterpriseService.java new file mode 100644 index 00000000..80eb39d0 --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/IProjectCounterpartyEnterpriseService.java @@ -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); +} diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java index bcaa4b9f..d3886b92 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java @@ -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 selectPage(Page 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); + } }); } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportEventListener.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportEventListener.java index 0c3c3573..2bbebea7 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportEventListener.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportEventListener.java @@ -21,7 +21,7 @@ public class CcdiProjectHistoryImportEventListener { event.getTargetProjectId(), event.getTargetLsfxProjectId(), event.getDto(), - event.getOperator() + event.getCaller() ); } } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportServiceImpl.java index ac6eb596..aaac2597 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportServiceImpl.java @@ -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 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); } } diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java index 84ad2395..e27f2fd0 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImpl.java @@ -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) ); } }); diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/ProjectCounterpartyEnterpriseServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/ProjectCounterpartyEnterpriseServiceImpl.java new file mode 100644 index 00000000..86ab3e7b --- /dev/null +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/ProjectCounterpartyEnterpriseServiceImpl.java @@ -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 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 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 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 names = bankStatementMapper.selectDistinctCounterpartyNames(projectId); + if (names.isEmpty()) { + transactionTemplate.executeWithoutResult(status -> deleteProjectData(projectId)); + return; + } + for (int start = 0; start < names.size(); start += 100) { + List batch = names.subList(start, Math.min(start + 100, names.size())); + List> 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 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 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 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) { } +} diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml index f4f29f56..7a3463c5 100644 --- a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml @@ -139,6 +139,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and trim(bs.cret_no) != '' + + + + CASE WHEN bs.TRX_DATE IS NULL OR TRIM(bs.TRX_DATE) = '' THEN NULL diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectCounterpartyEnterpriseMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectCounterpartyEnterpriseMapper.xml new file mode 100644 index 00000000..55f0dd3b --- /dev/null +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectCounterpartyEnterpriseMapper.xml @@ -0,0 +1,17 @@ + + + + + + + + + delete from ccdi_project_counterparty_enterprise where project_id = #{projectId} + + diff --git a/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectCounterpartyShareholderMapper.xml b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectCounterpartyShareholderMapper.xml new file mode 100644 index 00000000..90e08781 --- /dev/null +++ b/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectCounterpartyShareholderMapper.xml @@ -0,0 +1,21 @@ + + + + + + + delete from ccdi_project_counterparty_shareholder + where counterparty_enterprise_id = #{counterpartyEnterpriseId} + + + + 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} + + diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java index 146c923b..367c48ce 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java @@ -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) { diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java index 5eafc3f0..db119d00 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java @@ -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; @@ -695,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()); @@ -704,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.argThat(item -> PROJECT_ID.equals(item.getProjectId()) && Integer.valueOf(2).equals(item.getTargetCount()) )); @@ -716,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("仅支持删除解析成功文件")); } @@ -729,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()); @@ -744,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()); diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportServiceImplTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportServiceImplTest.java index 8cf088d2..272e5ede 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportServiceImplTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectHistoryImportServiceImplTest.java @@ -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.argThat(project -> Long.valueOf(90L).equals(project.getProjectId()) && Integer.valueOf(3).equals(project.getTargetCount()) diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java index 6368ad93..3aadc1bb 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectServiceImplTest.java @@ -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(); diff --git a/docs/plans/fullstack/2026-07-29-xinhua-enterprise-profile-implementation-plan.md b/docs/plans/fullstack/2026-07-29-xinhua-enterprise-profile-implementation-plan.md new file mode 100644 index 00000000..4baa2568 --- /dev/null +++ b/docs/plans/fullstack/2026-07-29-xinhua-enterprise-profile-implementation-plan.md @@ -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` 和股东一致后,再启动历史补全;任何环节失败立即停止上线。 diff --git a/docs/reports/implementation/2026-07-29-xinhua-enterprise-profile-implementation.md b/docs/reports/implementation/2026-07-29-xinhua-enterprise-profile-implementation.md new file mode 100644 index 00000000..163610c4 --- /dev/null +++ b/docs/reports/implementation/2026-07-29-xinhua-enterprise-profile-implementation.md @@ -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` 和股东落库一致;确认后才能启动实体和项目历史补全,失败时停止上线。 diff --git a/lsfx-mock-server/main.py b/lsfx-mock-server/main.py index d0a1cc7a..1e721a94 100644 --- a/lsfx-mock-server/main.py +++ b/lsfx-mock-server/main.py @@ -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="服务根路径") diff --git a/lsfx-mock-server/routers/enterprise_api.py b/lsfx-mock-server/routers/enterprise_api.py new file mode 100644 index 00000000..72aecf61 --- /dev/null +++ b/lsfx-mock-server/routers/enterprise_api.py @@ -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, + } diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index feb6ede9..05f76934 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -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 diff --git a/ruoyi-admin/src/main/resources/application-nas.yml b/ruoyi-admin/src/main/resources/application-nas.yml index cbc346e9..cb33cf5d 100644 --- a/ruoyi-admin/src/main/resources/application-nas.yml +++ b/ruoyi-admin/src/main/resources/application-nas.yml @@ -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 diff --git a/ruoyi-admin/src/main/resources/application-pro.yml b/ruoyi-admin/src/main/resources/application-pro.yml index b73a062e..9b571301 100644 --- a/ruoyi-admin/src/main/resources/application-pro.yml +++ b/ruoyi-admin/src/main/resources/application-pro.yml @@ -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 diff --git a/ruoyi-admin/src/main/resources/application-uat.yml b/ruoyi-admin/src/main/resources/application-uat.yml index ab52f675..2f55b99b 100644 --- a/ruoyi-admin/src/main/resources/application-uat.yml +++ b/ruoyi-admin/src/main/resources/application-uat.yml @@ -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 diff --git a/ruoyi-ui/src/api/ccdiEnterpriseBaseInfo.js b/ruoyi-ui/src/api/ccdiEnterpriseBaseInfo.js index 5afab2bd..031eaded 100644 --- a/ruoyi-ui/src/api/ccdiEnterpriseBaseInfo.js +++ b/ruoyi-ui/src/api/ccdiEnterpriseBaseInfo.js @@ -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' }) +} diff --git a/ruoyi-ui/src/api/ccdiProjectCounterpartyEnterprise.js b/ruoyi-ui/src/api/ccdiProjectCounterpartyEnterprise.js new file mode 100644 index 00000000..ec96445d --- /dev/null +++ b/ruoyi-ui/src/api/ccdiProjectCounterpartyEnterprise.js @@ -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' }) +} diff --git a/ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue b/ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue index 9cfd7157..9d957433 100644 --- a/ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue +++ b/ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue @@ -148,6 +148,17 @@ >查看导入失败记录 + + 工商历史补全 + @@ -407,6 +418,12 @@ {{ parseTime(detailData.establishDate, '{y}-{m}-{d}') || '-' }} {{ detailData.registerAddress || '-' }} {{ detailData.legalRepresentative || '-' }} + {{ formatCapital(detailData.registeredCapital, detailData.registeredCapitalUnit) }} + {{ parseTime(detailData.registerDate, '{y}-{m}-{d}') || '-' }} + {{ detailData.regionName || '-' }} + {{ detailData.regionCode || '-' }} + {{ detailData.employeeCount == null ? '-' : detailData.employeeCount }} + {{ parseTime(detailData.enterpriseSyncTime) || '-' }} {{ detailData.legalCertType || '-' }} {{ detailData.legalCertNo || '-' }} {{ formatStatus(detailData.status) }} @@ -420,7 +437,24 @@ {{ detailData.shareholder4 || '-' }} {{ detailData.shareholder5 || '-' }} +
完整股东信息
+ + + + + + + + @@ -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; +} diff --git a/ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue b/ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue index 9b2e31d0..58446114 100644 --- a/ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue +++ b/ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue @@ -172,6 +172,14 @@ > 导出流水 + 工商历史补全
- {{ formatField(scope.row.customerAccountName) }} + {{ scope.row.customerAccountName }} + -
{{ formatField(scope.row.customerAccountNo) }} @@ -496,6 +510,60 @@ 确定
+ + +
+ + + + {{ formatField(counterpartyData.counterpartyName) }} + {{ formatField(counterpartyData.enterpriseName) }} + {{ formatField(counterpartyData.socialCreditCode) }} + {{ formatField(counterpartyData.legalRepresentative) }} + {{ formatCapital(counterpartyData.registeredCapital, counterpartyData.registeredCapitalUnit) }} + {{ formatDateOnly(counterpartyData.registerDate) }} + {{ formatDateOnly(counterpartyData.establishDate) }} + {{ formatField(counterpartyData.organizationTypeName) }} + {{ formatField(counterpartyData.industryName) }} + {{ formatField(counterpartyData.regionName) }} + {{ counterpartyData.employeeCount == null ? '-' : counterpartyData.employeeCount }} + {{ formatDate(counterpartyData.syncTime) }} + {{ formatField(counterpartyData.registerAddress) }} + +
完整股东信息
+ + + + + + +
+
+ 重新查询 + 关闭 +
+
@@ -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; diff --git a/sql/migration/2026-07-29-xinhua-enterprise-profile.sql b/sql/migration/2026-07-29-xinhua-enterprise-profile.sql new file mode 100644 index 00000000..e5a79271 --- /dev/null +++ b/sql/migration/2026-07-29-xinhua-enterprise-profile.sql @@ -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');