Compare commits

...

9 Commits

118 changed files with 12834 additions and 529 deletions

View File

@@ -49,6 +49,12 @@
<artifactId>easyexcel</artifactId>
</dependency>
<!-- pdf导出工具 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -0,0 +1,78 @@
package com.ruoyi.ccdi.project.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
import com.ruoyi.ccdi.project.service.ICcdiFundGraphService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.PageDomain;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.core.page.TableSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
import com.ruoyi.common.utils.SecurityUtils;
import java.util.List;
/**
* 资金流图谱Controller
*/
@RestController
@RequestMapping("/ccdi/project/fund-graph")
@Tag(name = "资金流图谱")
public class CcdiFundGraphController extends BaseController {
@Resource
private ICcdiFundGraphService fundGraphService;
@GetMapping("/search")
@Operation(summary = "查询资金流图谱主体")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult searchSubjects(CcdiFundGraphQueryDTO queryDTO) {
List<CcdiFundGraphNodeVO> subjects = fundGraphService.searchSubjects(queryDTO);
return AjaxResult.success(subjects);
}
@GetMapping("/graph")
@Operation(summary = "查询一层资金流图谱")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getGraph(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphVO graph = fundGraphService.getFundGraph(queryDTO);
return AjaxResult.success(graph);
}
@GetMapping("/edge-detail")
@Operation(summary = "查询资金边流水明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public TableDataInfo getEdgeDetail(CcdiFundGraphEdgeDetailQueryDTO queryDTO) {
PageDomain pageDomain = TableSupport.buildPageRequest();
Page<CcdiFundGraphStatementVO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
Page<CcdiFundGraphStatementVO> result = fundGraphService.getEdgeDetails(page, queryDTO);
return getDataTable(result.getRecords(), result.getTotal());
}
@PostMapping("/manual-edge")
@Operation(summary = "新增手工资金流向")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult saveManualEdge(@RequestBody CcdiFundGraphManualEdgeSaveDTO saveDTO) {
try {
CcdiFundGraphEdgeVO edge = fundGraphService.saveManualEdge(saveDTO, SecurityUtils.getUsername());
return AjaxResult.success(edge);
} catch (IllegalArgumentException e) {
return AjaxResult.error(e.getMessage());
}
}
}

View File

@@ -29,6 +29,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@@ -181,4 +182,14 @@ public class CcdiProjectOverviewController extends BaseController {
public void exportRiskDetails(HttpServletResponse response, Long projectId) {
overviewService.exportRiskDetails(response, projectId);
}
/**
* 导出结果总览报告
*/
@RequestMapping(value = "/report/export", method = { RequestMethod.GET, RequestMethod.POST })
@Operation(summary = "导出结果总览报告")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportOverviewReport(HttpServletResponse response, Long projectId) {
overviewService.exportOverviewReport(response, projectId);
}
}

View File

@@ -0,0 +1,55 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
import com.ruoyi.ccdi.project.service.ICcdiRelationGraphService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 关系图谱Controller
*/
@RestController
@RequestMapping("/ccdi/project/relation-graph")
@Tag(name = "关系图谱")
public class CcdiRelationGraphController extends BaseController {
@Resource
private ICcdiRelationGraphService relationGraphService;
@GetMapping("/search")
@Operation(summary = "查询关系图谱主体")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult searchSubjects(CcdiRelationGraphQueryDTO queryDTO) {
List<CcdiRelationGraphNodeVO> subjects = relationGraphService.searchSubjects(queryDTO);
return AjaxResult.success(subjects);
}
@GetMapping("/graph")
@Operation(summary = "查询一层关系图谱")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getGraph(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphVO graph = relationGraphService.getRelationGraph(queryDTO);
return AjaxResult.success(graph);
}
@GetMapping("/suspected-enterprises")
@Operation(summary = "查询关系图谱疑似同名企业")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
CcdiRelationGraphSuspectedEnterpriseVO result = relationGraphService.getSuspectedEnterprises(queryDTO);
return AjaxResult.success(result);
}
}

View File

@@ -0,0 +1,42 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱边明细查询条件
*/
@Data
public class CcdiFundGraphEdgeDetailQueryDTO {
/** 项目ID历史字段资金流图谱不按项目过滤 */
private Long projectId;
/** 身份证号、员工姓名或本方户名 */
private String keyword;
/** 主体节点object_key复用图谱公共SQL片段时兼容条件判断 */
private String objectKey;
/** 边起点 */
private String fromKey;
/** 边终点 */
private String toKey;
/** 方向1支出2收入 */
private String direction;
/** 交易开始时间 */
private String transactionStartTime;
/** 交易结束时间 */
private String transactionEndTime;
/** 最小金额 */
private BigDecimal amountMin;
/** 最大金额 */
private BigDecimal amountMax;
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 手工资金流向保存参数。
*/
@Data
public class CcdiFundGraphManualEdgeSaveDTO {
/** 起点主体object_key为空时默认使用当前查询中心 */
private String fromObjectKey;
/** 起点主体名称 */
private String fromName;
/** 终点主体object_key已有节点时传入 */
private String toObjectKey;
/** 终点主体名称;新建主体时必填 */
private String toName;
/** 终点主体身份证号/证件号有值时按md5(trim(idNo))复用主体 */
private String toIdNo;
/** 手工录入汇总金额 */
private BigDecimal amount;
/** 手工录入笔数 */
private Integer transactionCount;
/** 方向1支出2收入 */
private String direction;
/** 资金流向关系说明 */
private String relationDesc;
/** 来源说明 */
private String sourceDesc;
/** 分析备注 */
private String remark;
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱查询条件
*/
@Data
public class CcdiFundGraphQueryDTO {
/** 项目ID历史字段资金流图谱不按项目过滤 */
private Long projectId;
/** 身份证号、员工姓名或本方户名 */
private String keyword;
/** 主体节点object_key节点穿透时直接使用 */
private String objectKey;
/** 交易开始时间 */
private String transactionStartTime;
/** 交易结束时间 */
private String transactionEndTime;
/** 最小金额 */
private BigDecimal amountMin;
/** 最大金额 */
private BigDecimal amountMax;
/** 最小汇总金额默认1000 */
private BigDecimal minTotalAmount;
/** 方向1支出2收入 */
private String direction;
/** 返回边数量上限 */
private Integer limit;
/** 预留追溯层级,一期固定按一层处理 */
private Integer depth;
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
/**
* 关系图谱查询条件
*/
@Data
public class CcdiRelationGraphQueryDTO {
/** 项目ID历史字段关系图谱不按项目过滤 */
private Long projectId;
/** 身份证号、姓名、统一社会信用代码或节点object_key */
private String keyword;
/** 节点object_key节点穿透时直接使用 */
private String objectKey;
/** 返回边数量上限 */
private Integer limit;
/** 预留追溯层级,一期固定按一层处理 */
private Integer depth;
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
/**
* 关系图谱疑似企业查询条件
*/
@Data
public class CcdiRelationGraphSuspectedEnterpriseQueryDTO {
/** 姓名 */
private String personName;
/** 证件号 */
private String certNo;
/** 出生日期yyyy-MM-dd */
private String birthDate;
/** 返回数量上限 */
private Integer limit;
}

View File

@@ -0,0 +1,50 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱汇总边
*/
@Data
public class CcdiFundGraphEdgeVO {
private String edgeKey;
private String fromKey;
private String toKey;
private String fromObjectKey;
private String toObjectKey;
private String fromName;
private String toName;
private BigDecimal totalAmount;
private Long transactionCount;
private String firstTrxDate;
private String lastTrxDate;
private String direction;
private String familyRelationType;
private String sourceType;
private String relationDesc;
private String sourceDesc;
private String remark;
private Integer depth;
private Boolean canTrace;
}

View File

@@ -0,0 +1,48 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱节点
*/
@Data
public class CcdiFundGraphNodeVO {
private String nodeKey;
private String objectKey;
private String nodeName;
private String idNo;
private String cinocsno;
private String idnoType;
private String staffId;
private String sourceType;
private String nodeType;
private String identityType;
private String relationType;
private Long accountCount;
private String createdTime;
private String updatedTime;
private Boolean canExpand;
private Integer depth;
private BigDecimal totalAmount;
private Long transactionCount;
}

View File

@@ -0,0 +1,34 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 资金流图谱边对应流水明细
*/
@Data
public class CcdiFundGraphStatementVO {
private Long bankStatementId;
private String trxDate;
private String leAccountNo;
private String leAccountName;
private String customerAccountName;
private String customerAccountNo;
private String cashType;
private String userMemo;
private BigDecimal amount;
private String direction;
private String familyRelationType;
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 资金流图谱结果
*/
@Data
public class CcdiFundGraphVO {
private CcdiFundGraphNodeVO centerNode;
private List<CcdiFundGraphNodeVO> nodes = new ArrayList<>();
private List<CcdiFundGraphEdgeVO> edges = new ArrayList<>();
private BigDecimal totalAmount = BigDecimal.ZERO;
private Long transactionCount = 0L;
private Integer maxDepth = 1;
private Boolean traceReserved = true;
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 结果总览报告风险模型汇总
*/
@Data
public class CcdiProjectOverviewReportModelSummaryVO {
private String modelCode;
private String modelName;
private Integer warningCount;
private Integer peopleCount;
private String peopleNames;
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 结果总览报告参数配置项
*/
@Data
public class CcdiProjectOverviewReportParamVO {
private String modelName;
private String paramName;
private String paramValue;
private String paramUnit;
private String paramDesc;
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.math.BigDecimal;
import lombok.Data;
/**
* 结果总览报告涉疑交易明细
*/
@Data
public class CcdiProjectOverviewReportSuspiciousTransactionVO {
private Long bankStatementId;
private String trxDate;
private String leAccountNo;
private String leAccountName;
private String customerAccountName;
private String customerAccountNo;
private String relatedStaffName;
private String relatedStaffCode;
private String userMemo;
private String cashType;
private String hitTags;
private BigDecimal displayAmount;
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 结果总览报告上传主体汇总
*/
@Data
public class CcdiProjectOverviewReportUploadSubjectVO {
private String subjectName;
private String accountNos;
private String minTrxDate;
private String maxTrxDate;
private Integer fileCount;
private String dataPeriod;
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.ccdi.project.domain.vo;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* 结果总览一键导出报告
*/
@Data
public class CcdiProjectOverviewReportVO {
private CcdiProject project;
private List<CcdiProjectOverviewReportUploadSubjectVO> uploadSubjects = new ArrayList<>();
private List<CcdiProjectOverviewReportParamVO> params = new ArrayList<>();
private CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO();
private List<CcdiProjectOverviewReportModelSummaryVO> modelSummaries = new ArrayList<>();
private List<CcdiProjectRiskModelPeopleItemVO> riskPeople = new ArrayList<>();
private List<CcdiProjectOverviewReportSuspiciousTransactionVO> suspiciousTransactions = new ArrayList<>();
private List<CcdiProjectEmployeeCreditNegativeExcel> illegalPeople = new ArrayList<>();
private List<CcdiProjectAbnormalAccountExcel> abnormalAccounts = new ArrayList<>();
}

View File

@@ -33,4 +33,6 @@ public class CcdiProjectSuspiciousTransactionItemVO {
private Boolean hasModelRuleHit;
private Boolean hasNameListHit;
private String nameListHitType;
}

View File

@@ -0,0 +1,90 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 关系图谱边
*/
@Data
public class CcdiRelationGraphEdgeVO {
private String objectKey;
private String fromKey;
private String toKey;
private String fromObjectKey;
private String toObjectKey;
private String fromName;
private String toName;
private String edgeTable;
private String relationType;
private String companyName;
private String stockName;
private String stockType;
private String stockPercent;
private String shouldCapi;
private String shouldCapiValue;
private String shouldCapiUnit;
private String shoudDate;
private String pKeyNo;
private String operName;
private String operKeyNo;
private String personId;
private String relationName;
private String relationCertNo;
private String gender;
private String birthDate;
private String relationCertType;
private String mobilePhone1;
private String mobilePhone2;
private String wechatNo1;
private String wechatNo2;
private String wechatNo3;
private String contactAddress;
private BigDecimal annualIncome;
private String relationDesc;
private String status;
private String effectiveDate;
private String invalidDate;
private String remark;
private String dataSource;
}

View File

@@ -0,0 +1,34 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 关系图谱节点
*/
@Data
public class CcdiRelationGraphNodeVO {
private String objectKey;
private String nodeKey;
private String nodeName;
private String idNumber;
private String subjectType;
private String sourceType;
private String detailRefType;
private String detailRefKey;
private String createdTime;
private String updatedTime;
private Boolean canExpand;
private Integer depth;
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 关系图谱疑似企业明细
*/
@Data
public class CcdiRelationGraphSuspectedEnterpriseItemVO {
private String candidateKeyNo;
private String personName;
private String companyId;
private String companyName;
private String creditCode;
private String enterpriseStatus;
private String industryName;
private String relationType;
private String stockPercent;
/** 企业成立日期或当前可用的工商关系日期 */
private String establishDate;
private Integer ageAtEstablish;
private String matchReason;
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 关系图谱疑似企业结果
*/
@Data
public class CcdiRelationGraphSuspectedEnterpriseVO {
/** 是否因同名候选过多被拦截 */
private Boolean blocked = false;
/** 拦截或空结果说明 */
private String message;
/** 同名工商keyno数量 */
private Integer sameNameKeyNoCount = 0;
/** 表格明细 */
private List<CcdiRelationGraphSuspectedEnterpriseItemVO> rows = new ArrayList<>();
}

View File

@@ -0,0 +1,23 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 关系图谱结果
*/
@Data
public class CcdiRelationGraphVO {
private CcdiRelationGraphNodeVO centerNode;
private List<CcdiRelationGraphNodeVO> nodes = new ArrayList<>();
private List<CcdiRelationGraphEdgeVO> edges = new ArrayList<>();
private Long edgeCount = 0L;
private Integer maxDepth = 1;
}

View File

@@ -272,9 +272,11 @@ public interface CcdiBankTagAnalysisMapper {
* 微信支付宝提现超额
*
* @param projectId 项目ID
* @param amountThreshold 提现金额阈值
* @return 对象命中结果
*/
List<BankTagObjectHitVO> selectWithdrawAmtObjects(@Param("projectId") Long projectId);
List<BankTagObjectHitVO> selectWithdrawAmtObjects(@Param("projectId") Long projectId,
@Param("amountThreshold") BigDecimal amountThreshold);
/**
* 工资快速转出

View File

@@ -0,0 +1,45 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 资金流图谱Mapper
*/
@Mapper
public interface CcdiFundGraphMapper {
List<CcdiFundGraphNodeVO> selectFundGraphSubjects(@Param("query") CcdiFundGraphQueryDTO query);
List<CcdiFundGraphEdgeVO> selectFundGraphEdges(@Param("query") CcdiFundGraphQueryDTO query);
List<CcdiFundGraphEdgeVO> selectFundGraphManualEdges(@Param("query") CcdiFundGraphQueryDTO query);
int countSubjectByObjectKey(@Param("objectKey") String objectKey);
int insertManualSubject(
@Param("objectKey") String objectKey,
@Param("idNo") String idNo,
@Param("name") String name
);
int insertManualEdge(
@Param("objectKey") String objectKey,
@Param("dto") CcdiFundGraphManualEdgeSaveDTO dto,
@Param("operator") String operator
);
Page<CcdiFundGraphStatementVO> selectFundGraphEdgeDetails(
Page<CcdiFundGraphStatementVO> page,
@Param("query") CcdiFundGraphEdgeDetailQueryDTO query
);
}

View File

@@ -13,6 +13,9 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
@@ -72,6 +75,40 @@ public interface CcdiProjectOverviewMapper {
*/
List<CcdiProjectRiskModelCardVO> selectRiskModelCardsByProjectId(@Param("projectId") Long projectId);
/**
* 查询报告上传主体汇总
*
* @param projectId 项目ID
* @return 上传主体汇总
*/
List<CcdiProjectOverviewReportUploadSubjectVO> selectReportUploadSubjects(@Param("projectId") Long projectId);
/**
* 查询报告风险模型汇总
*
* @param projectId 项目ID
* @return 风险模型汇总
*/
List<CcdiProjectOverviewReportModelSummaryVO> selectReportRiskModelSummaries(@Param("projectId") Long projectId);
/**
* 查询报告风险人员与异常点
*
* @param projectId 项目ID
* @return 风险人员与异常点
*/
List<CcdiProjectRiskModelPeopleItemVO> selectReportRiskPeople(@Param("projectId") Long projectId);
/**
* 查询报告涉疑交易明细
*
* @param query 查询条件
* @return 涉疑交易明细
*/
List<CcdiProjectOverviewReportSuspiciousTransactionVO> selectReportSuspiciousTransactionList(
@Param("query") CcdiProjectSuspiciousTransactionQueryDTO query
);
/**
* 分页查询风险模型命中人员
*

View File

@@ -0,0 +1,28 @@
package com.ruoyi.ccdi.project.mapper;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 关系图谱Mapper
*/
@Mapper
public interface CcdiRelationGraphMapper {
List<CcdiRelationGraphNodeVO> selectRelationGraphSubjects(@Param("query") CcdiRelationGraphQueryDTO query);
List<CcdiRelationGraphNodeVO> selectRelationGraphNodesByKeys(@Param("objectKeys") List<String> objectKeys);
List<CcdiRelationGraphEdgeVO> selectRelationGraphEdges(@Param("query") CcdiRelationGraphQueryDTO query);
int countSuspectedEnterpriseKeyNos(@Param("personName") String personName);
List<CcdiRelationGraphSuspectedEnterpriseItemVO> selectSuspectedEnterprises(@Param("personName") String personName,
@Param("limit") Integer limit);
}

View File

@@ -0,0 +1,29 @@
package com.ruoyi.ccdi.project.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
import java.util.List;
/**
* 资金流图谱Service接口
*/
public interface ICcdiFundGraphService {
List<CcdiFundGraphNodeVO> searchSubjects(CcdiFundGraphQueryDTO queryDTO);
CcdiFundGraphVO getFundGraph(CcdiFundGraphQueryDTO queryDTO);
Page<CcdiFundGraphStatementVO> getEdgeDetails(
Page<CcdiFundGraphStatementVO> page,
CcdiFundGraphEdgeDetailQueryDTO queryDTO
);
CcdiFundGraphEdgeVO saveManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO, String operator);
}

View File

@@ -125,6 +125,15 @@ public interface ICcdiProjectOverviewService {
default void exportRiskDetails(HttpServletResponse response, Long projectId) {
}
/**
* 一键导出结果总览报告
*
* @param response 响应流
* @param projectId 项目ID
*/
default void exportOverviewReport(HttpServletResponse response, Long projectId) {
}
/**
* 导出项目员工负面征信
*

View File

@@ -0,0 +1,21 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
import java.util.List;
/**
* 关系图谱Service接口
*/
public interface ICcdiRelationGraphService {
List<CcdiRelationGraphNodeVO> searchSubjects(CcdiRelationGraphQueryDTO queryDTO);
CcdiRelationGraphVO getRelationGraph(CcdiRelationGraphQueryDTO queryDTO);
CcdiRelationGraphSuspectedEnterpriseVO getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO);
}

View File

@@ -34,6 +34,7 @@ public class BankTagRuleConfigResolver {
Map.entry("FOREX_BUY_AMT", Set.of("SINGLE_PURCHASE_AMOUNT")),
Map.entry("FOREX_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")),
Map.entry("WITHDRAW_CNT", Set.of("WITHDRAW_CNT")),
Map.entry("WITHDRAW_AMT", Set.of("WITHDRAW_AMT")),
Map.entry("STOCK_TFR_LARGE", Set.of("STOCK_TFR_LARGE")),
Map.entry("LARGE_STOCK_TRADING", Set.of("STOCK_TFR_LARGE")),
Map.entry("MULTI_PARTY_GAMBLING_TRANSFER", Set.of("MULTI_PARTY_AMT_MIN", "MULTI_PARTY_AMT_MAX")),

View File

@@ -285,7 +285,9 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects(
projectId, toInteger(config.getThresholdValue("WITHDRAW_CNT"))
);
case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(projectId);
case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(
projectId, toBigDecimal(config.getThresholdValue("WITHDRAW_AMT"))
);
case "SALARY_QUICK_TRANSFER" -> analysisMapper.selectSalaryQuickTransferObjects(projectId);
case "SALARY_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId);
case "SUDDEN_ACCOUNT_CLOSURE" -> analysisMapper.selectSuddenAccountClosureObjects(projectId);

View File

@@ -0,0 +1,366 @@
package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
import com.ruoyi.ccdi.project.mapper.CcdiFundGraphMapper;
import com.ruoyi.ccdi.project.service.ICcdiFundGraphService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 资金流图谱Service实现
*/
@Service
public class CcdiFundGraphServiceImpl implements ICcdiFundGraphService {
private static final int DEFAULT_LIMIT = 20;
private static final int MAX_LIMIT = 100;
private static final BigDecimal DEFAULT_MIN_TOTAL_AMOUNT = new BigDecimal("1000");
private static final Comparator<CcdiFundGraphEdgeVO> EDGE_COMPARATOR = Comparator
.comparing(CcdiFundGraphServiceImpl::safeAmount, Comparator.reverseOrder())
.thenComparing(CcdiFundGraphServiceImpl::safeTransactionCount, Comparator.reverseOrder())
.thenComparing(CcdiFundGraphServiceImpl::safeDateText, Comparator.reverseOrder())
.thenComparing(edge -> normalizeSortText(edge == null ? null : edge.getEdgeKey()));
@Resource
private CcdiFundGraphMapper fundGraphMapper;
@Override
public List<CcdiFundGraphNodeVO> searchSubjects(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphQueryDTO query = normalizeGraphQuery(queryDTO);
if (isBlank(query.getKeyword()) && isBlank(query.getObjectKey())) {
return Collections.emptyList();
}
return fundGraphMapper.selectFundGraphSubjects(query);
}
@Override
public CcdiFundGraphVO getFundGraph(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphQueryDTO query = normalizeGraphQuery(queryDTO);
CcdiFundGraphNodeVO centerNode = resolveCenterNode(query);
if (centerNode == null || isBlank(centerNode.getObjectKey())) {
return new CcdiFundGraphVO();
}
query.setObjectKey(centerNode.getObjectKey());
List<CcdiFundGraphEdgeVO> edges = new ArrayList<>();
List<CcdiFundGraphEdgeVO> realEdges = fundGraphMapper.selectFundGraphEdges(query);
if (realEdges != null) {
edges.addAll(realEdges);
}
List<CcdiFundGraphEdgeVO> manualEdges = fundGraphMapper.selectFundGraphManualEdges(query);
if (manualEdges != null) {
edges.addAll(manualEdges);
}
edges = sortAndLimitEdges(edges, query.getLimit());
CcdiFundGraphVO graph = new CcdiFundGraphVO();
graph.setCenterNode(centerNode);
graph.setEdges(edges);
graph.setNodes(buildNodes(centerNode, edges));
graph.setTransactionCount(edges.stream()
.map(CcdiFundGraphEdgeVO::getTransactionCount)
.filter(item -> item != null)
.reduce(0L, Long::sum));
graph.setTotalAmount(edges.stream()
.map(CcdiFundGraphEdgeVO::getTotalAmount)
.filter(item -> item != null)
.reduce(BigDecimal.ZERO, BigDecimal::add));
graph.setMaxDepth(1);
graph.setTraceReserved(true);
return graph;
}
@Override
public Page<CcdiFundGraphStatementVO> getEdgeDetails(
Page<CcdiFundGraphStatementVO> page,
CcdiFundGraphEdgeDetailQueryDTO queryDTO
) {
CcdiFundGraphEdgeDetailQueryDTO query = normalizeDetailQuery(queryDTO);
if (isBlank(query.getFromKey()) || isBlank(query.getToKey())) {
return page;
}
return fundGraphMapper.selectFundGraphEdgeDetails(page, query);
}
@Override
public CcdiFundGraphEdgeVO saveManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO, String operator) {
CcdiFundGraphManualEdgeSaveDTO dto = normalizeManualEdge(saveDTO);
String fromObjectKey = dto.getFromObjectKey();
String toObjectKey = resolveManualToObjectKey(dto);
dto.setToObjectKey(toObjectKey);
String edgeObjectKey = md5("MANUAL_EDGE|" + fromObjectKey + "|" + toObjectKey + "|" + dto.getDirection()
+ "|" + UUID.randomUUID());
fundGraphMapper.insertManualEdge(edgeObjectKey, dto, normalizeText(operator));
CcdiFundGraphEdgeVO edge = new CcdiFundGraphEdgeVO();
edge.setEdgeKey(edgeObjectKey);
edge.setFromKey(toSubjectKey(fromObjectKey));
edge.setToKey(toSubjectKey(toObjectKey));
edge.setFromObjectKey(fromObjectKey);
edge.setToObjectKey(toObjectKey);
edge.setFromName(dto.getFromName());
edge.setToName(dto.getToName());
edge.setTotalAmount(dto.getAmount());
edge.setTransactionCount(dto.getTransactionCount() == null ? 1L : dto.getTransactionCount().longValue());
edge.setDirection(dto.getDirection());
edge.setRelationDesc(dto.getRelationDesc());
edge.setSourceDesc(dto.getSourceDesc());
edge.setRemark(dto.getRemark());
edge.setSourceType("MANUAL");
edge.setDepth(1);
edge.setCanTrace(false);
return edge;
}
private CcdiFundGraphNodeVO resolveCenterNode(CcdiFundGraphQueryDTO query) {
if (isBlank(query.getObjectKey()) && isBlank(query.getKeyword())) {
return null;
}
List<CcdiFundGraphNodeVO> subjects = fundGraphMapper.selectFundGraphSubjects(query);
if (subjects == null || subjects.isEmpty()) {
return null;
}
return subjects.get(0);
}
private List<CcdiFundGraphNodeVO> buildNodes(CcdiFundGraphNodeVO centerNode, List<CcdiFundGraphEdgeVO> edges) {
Map<String, CcdiFundGraphNodeVO> nodeMap = new LinkedHashMap<>();
Map<String, CcdiFundGraphNodeVO> subjectCache = new LinkedHashMap<>();
subjectCache.put(centerNode.getObjectKey(), centerNode);
addNode(nodeMap, centerNode, centerNode.getNodeKey(), centerNode.getObjectKey(), centerNode.getNodeName(),
centerNode.getRelationType(), centerNode.getCanExpand(), BigDecimal.ZERO, 0L);
for (CcdiFundGraphEdgeVO edge : edges) {
String centerObjectKey = centerNode.getObjectKey();
String fromRelationType = centerObjectKey != null && centerObjectKey.equals(edge.getFromObjectKey())
? null
: edge.getFamilyRelationType();
String toRelationType = centerObjectKey != null && centerObjectKey.equals(edge.getToObjectKey())
? null
: edge.getFamilyRelationType();
addNode(nodeMap, lookupSubject(edge.getFromObjectKey(), subjectCache), edge.getFromKey(),
edge.getFromObjectKey(), edge.getFromName(), fromRelationType, true, edge.getTotalAmount(),
edge.getTransactionCount());
addNode(nodeMap, lookupSubject(edge.getToObjectKey(), subjectCache), edge.getToKey(),
edge.getToObjectKey(), edge.getToName(), toRelationType, edge.getCanTrace(),
edge.getTotalAmount(), edge.getTransactionCount());
}
return List.copyOf(nodeMap.values());
}
private CcdiFundGraphNodeVO lookupSubject(String objectKey, Map<String, CcdiFundGraphNodeVO> subjectCache) {
if (isBlank(objectKey)) {
return null;
}
if (subjectCache.containsKey(objectKey)) {
return subjectCache.get(objectKey);
}
CcdiFundGraphQueryDTO query = new CcdiFundGraphQueryDTO();
query.setObjectKey(objectKey);
query.setLimit(DEFAULT_LIMIT);
List<CcdiFundGraphNodeVO> subjects = fundGraphMapper.selectFundGraphSubjects(query);
CcdiFundGraphNodeVO subject = subjects == null || subjects.isEmpty() ? null : subjects.get(0);
subjectCache.put(objectKey, subject);
return subject;
}
private String resolveManualToObjectKey(CcdiFundGraphManualEdgeSaveDTO dto) {
if (!isBlank(dto.getToObjectKey())) {
ensureManualSubject(dto.getToObjectKey(), dto.getToIdNo(), dto.getToName());
return dto.getToObjectKey();
}
String objectKey = !isBlank(dto.getToIdNo())
? md5(dto.getToIdNo())
: md5("MANUAL_NODE|" + dto.getToName() + "|" + UUID.randomUUID());
dto.setToObjectKey(objectKey);
ensureManualSubject(objectKey, dto.getToIdNo(), dto.getToName());
return objectKey;
}
private void ensureManualSubject(String objectKey, String idNo, String name) {
if (fundGraphMapper.countSubjectByObjectKey(objectKey) > 0) {
return;
}
fundGraphMapper.insertManualSubject(objectKey, normalizeText(idNo), normalizeText(name));
}
private CcdiFundGraphManualEdgeSaveDTO normalizeManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO) {
CcdiFundGraphManualEdgeSaveDTO dto = saveDTO == null ? new CcdiFundGraphManualEdgeSaveDTO() : saveDTO;
dto.setFromObjectKey(normalizeText(dto.getFromObjectKey()));
dto.setFromName(normalizeText(dto.getFromName()));
dto.setToObjectKey(normalizeText(dto.getToObjectKey()));
dto.setToName(normalizeText(dto.getToName()));
dto.setToIdNo(normalizeText(dto.getToIdNo()));
dto.setDirection(normalizeText(dto.getDirection()));
dto.setRelationDesc(normalizeText(dto.getRelationDesc()));
dto.setSourceDesc(normalizeText(dto.getSourceDesc()));
dto.setRemark(normalizeText(dto.getRemark()));
if (isBlank(dto.getFromObjectKey())) {
throw new IllegalArgumentException("起点主体不能为空");
}
if (isBlank(dto.getToObjectKey()) && isBlank(dto.getToName())) {
throw new IllegalArgumentException("终点主体不能为空");
}
if (isBlank(dto.getDirection())) {
dto.setDirection("1");
}
if (dto.getAmount() == null) {
dto.setAmount(BigDecimal.ZERO);
}
if (dto.getTransactionCount() == null || dto.getTransactionCount() <= 0) {
dto.setTransactionCount(1);
}
return dto;
}
private void addNode(
Map<String, CcdiFundGraphNodeVO> nodeMap,
CcdiFundGraphNodeVO subject,
String nodeKey,
String objectKey,
String nodeName,
String relationType,
Boolean canExpand,
BigDecimal edgeAmount,
Long edgeCount
) {
if (isBlank(nodeKey)) {
return;
}
CcdiFundGraphNodeVO node = nodeMap.computeIfAbsent(nodeKey, key -> {
CcdiFundGraphNodeVO item = new CcdiFundGraphNodeVO();
item.setNodeKey(key);
item.setObjectKey(objectKey);
item.setNodeName(subject != null && !isBlank(subject.getNodeName())
? subject.getNodeName()
: (isBlank(nodeName) ? "未知主体" : nodeName));
item.setIdNo(subject == null ? null : subject.getIdNo());
item.setCinocsno(subject == null ? null : subject.getCinocsno());
item.setIdnoType(subject == null ? null : subject.getIdnoType());
item.setStaffId(subject == null ? null : subject.getStaffId());
item.setSourceType(subject == null ? null : subject.getSourceType());
item.setNodeType(subject != null && !isBlank(subject.getNodeType()) ? subject.getNodeType() : "PERSON");
item.setIdentityType(subject != null && !isBlank(subject.getIdentityType()) ? subject.getIdentityType() : "IDNO");
item.setRelationType(relationType);
item.setAccountCount(subject == null ? 0L : subject.getAccountCount());
item.setCreatedTime(subject == null ? null : subject.getCreatedTime());
item.setUpdatedTime(subject == null ? null : subject.getUpdatedTime());
item.setCanExpand(Boolean.TRUE.equals(canExpand));
item.setDepth(1);
item.setTotalAmount(BigDecimal.ZERO);
item.setTransactionCount(0L);
return item;
});
if (isBlank(node.getObjectKey())) {
node.setObjectKey(objectKey);
}
if (isBlank(node.getRelationType())) {
node.setRelationType(relationType);
}
if (Boolean.TRUE.equals(canExpand)) {
node.setCanExpand(true);
}
node.setTotalAmount(node.getTotalAmount().add(edgeAmount == null ? BigDecimal.ZERO : edgeAmount));
node.setTransactionCount(node.getTransactionCount() + (edgeCount == null ? 0L : edgeCount));
}
private CcdiFundGraphQueryDTO normalizeGraphQuery(CcdiFundGraphQueryDTO queryDTO) {
CcdiFundGraphQueryDTO query = queryDTO == null ? new CcdiFundGraphQueryDTO() : queryDTO;
query.setKeyword(normalizeText(query.getKeyword()));
query.setObjectKey(normalizeText(query.getObjectKey()));
query.setTransactionStartTime(normalizeText(query.getTransactionStartTime()));
query.setTransactionEndTime(normalizeText(query.getTransactionEndTime()));
query.setDirection(normalizeText(query.getDirection()));
if (query.getMinTotalAmount() == null) {
query.setMinTotalAmount(DEFAULT_MIN_TOTAL_AMOUNT);
}
query.setLimit(normalizeLimit(query.getLimit()));
query.setDepth(1);
return query;
}
private CcdiFundGraphEdgeDetailQueryDTO normalizeDetailQuery(CcdiFundGraphEdgeDetailQueryDTO queryDTO) {
CcdiFundGraphEdgeDetailQueryDTO query = queryDTO == null ? new CcdiFundGraphEdgeDetailQueryDTO() : queryDTO;
query.setKeyword(normalizeText(query.getKeyword()));
query.setFromKey(normalizeText(query.getFromKey()));
query.setToKey(normalizeText(query.getToKey()));
query.setDirection(normalizeText(query.getDirection()));
query.setTransactionStartTime(normalizeText(query.getTransactionStartTime()));
query.setTransactionEndTime(normalizeText(query.getTransactionEndTime()));
return query;
}
private Integer normalizeLimit(Integer limit) {
if (limit == null || limit <= 0) {
return DEFAULT_LIMIT;
}
return Math.min(limit, MAX_LIMIT);
}
private List<CcdiFundGraphEdgeVO> sortAndLimitEdges(List<CcdiFundGraphEdgeVO> edges, Integer limit) {
if (edges == null || edges.isEmpty()) {
return Collections.emptyList();
}
List<CcdiFundGraphEdgeVO> sorted = new ArrayList<>(edges);
sorted.sort(EDGE_COMPARATOR);
int finalLimit = normalizeLimit(limit);
if (sorted.size() > finalLimit) {
return List.copyOf(sorted.subList(0, finalLimit));
}
return List.copyOf(sorted);
}
private String normalizeText(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
private String toSubjectKey(String objectKey) {
return "idno_node/" + objectKey;
}
private String md5(String value) {
return DigestUtils.md5DigestAsHex(value.trim().getBytes(StandardCharsets.UTF_8));
}
private static BigDecimal safeAmount(CcdiFundGraphEdgeVO edge) {
return edge == null || edge.getTotalAmount() == null ? BigDecimal.ZERO : edge.getTotalAmount();
}
private static Long safeTransactionCount(CcdiFundGraphEdgeVO edge) {
return edge == null || edge.getTransactionCount() == null ? 0L : edge.getTransactionCount();
}
private static String safeDateText(CcdiFundGraphEdgeVO edge) {
return normalizeSortText(edge == null ? null : edge.getLastTrxDate());
}
private static String normalizeSortText(String value) {
return value == null ? "" : value;
}
}

View File

@@ -23,6 +23,7 @@ import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -53,6 +54,7 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
private ICcdiProjectService projectService;
@Resource
@Lazy
private ICcdiBankTagService bankTagService;
@Override

View File

@@ -0,0 +1,623 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.file.FileUtils;
import jakarta.servlet.http.HttpServletResponse;
import java.awt.Color;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import org.apache.fontbox.ttf.TrueTypeCollection;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.springframework.stereotype.Component;
/**
* 结果总览PDF报告导出器
*/
@Component
public class CcdiProjectOverviewReportPdfExporter {
private static final String CONTENT_TYPE = "application/pdf";
private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final DecimalFormat MONEY_FORMAT = new DecimalFormat("#,##0.00");
public void export(HttpServletResponse response, CcdiProjectOverviewReportVO report) throws IOException {
response.setContentType(CONTENT_TYPE);
FileUtils.setAttachmentResponseHeader(
response,
safeFileName(report.getProject().getProjectName()) + "_初核结果报告.pdf"
);
try (PDDocument document = new PDDocument()) {
PDType0Font font = loadChineseFont(document);
PdfPageWriter writer = new PdfPageWriter(document, font);
writer.newPage();
writeCover(writer, report);
writeUploadSubjects(writer, report.getUploadSubjects());
writeParams(writer, report.getParams());
writeRiskModels(writer, report);
writeRiskDetails(writer, report);
writer.close();
document.save(response.getOutputStream());
}
}
private void writeCover(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
writer.title("初核结果报告");
writer.text("项目名称:" + safeText(report.getProject().getProjectName()), 12, Color.GRAY);
writer.text("导出时间:" + LocalDateTime.now().format(EXPORT_TIME_FORMATTER), 12, Color.GRAY);
writer.separator();
}
private void writeUploadSubjects(
PdfPageWriter writer,
List<CcdiProjectOverviewReportUploadSubjectVO> rows
) throws IOException {
writer.section("一、上传文件");
writer.table(
List.of("序号", "主体名称", "主体账号", "数据周期", "文件数"),
indexedRows(rows).stream()
.map(item -> List.of(
item.index(),
safeText(item.row().getSubjectName()),
maskAccountList(item.row().getAccountNos()),
safeText(item.row().getDataPeriod()),
formatCount(item.row().getFileCount(), "")
))
.collect(Collectors.toList()),
new float[] { 0.07F, 0.2F, 0.45F, 0.14F, 0.14F },
"暂无上传文件数据"
);
}
private void writeParams(PdfPageWriter writer, List<CcdiProjectOverviewReportParamVO> rows) throws IOException {
writer.section("二、参数配置");
writer.table(
List.of("模型名称", "监测项", "参数值", "单位", "描述"),
rows.stream()
.map(item -> List.of(
safeText(item.getModelName()),
safeText(item.getParamName()),
safeText(item.getParamValue()),
safeText(item.getParamUnit()),
safeText(item.getParamDesc())
))
.collect(Collectors.toList()),
new float[] { 0.18F, 0.26F, 0.14F, 0.12F, 0.3F },
"暂无参数配置数据"
);
}
private void writeRiskModels(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
writer.section("三、风险模型");
writer.metrics(report.getDashboard().getStats());
writer.subsection("风险模型汇总");
writer.table(
List.of("模型名称", "预警数量", "涉及人员"),
report.getModelSummaries().stream()
.map(item -> List.of(
safeText(item.getModelName()),
String.valueOf(defaultZero(item.getWarningCount())),
formatPeopleSummary(item)
))
.collect(Collectors.toList()),
new float[] { 0.26F, 0.14F, 0.6F },
"暂无风险模型汇总数据"
);
writer.subsection("风险人员与异常点");
writer.table(
List.of("姓名", "工号", "身份证号", "所属部门", "命中模型", "异常标签"),
report.getRiskPeople().stream()
.map(item -> List.of(
safeText(item.getStaffName()),
safeText(item.getStaffCode()),
maskIdCard(item.getIdNo()),
safeText(item.getDepartment()),
joinText(item.getModelNames()),
formatHitTags(item.getHitTagList())
))
.collect(Collectors.toList()),
new float[] { 0.1F, 0.11F, 0.16F, 0.14F, 0.24F, 0.25F },
"暂无风险人员与异常点数据"
);
}
private void writeRiskDetails(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
writer.section("四、风险明细");
writer.subsection("1. 涉疑交易明细表(共" + report.getSuspiciousTransactions().size() + "条)");
writer.table(
List.of("交易时间", "本方账户", "对方账户", "关联员工", "摘要/交易类型", "异常标签", "交易金额"),
report.getSuspiciousTransactions().stream()
.map(item -> List.of(
safeText(item.getTrxDate()),
formatAccount(item.getLeAccountNo(), item.getLeAccountName()),
formatAccount(item.getCustomerAccountNo(), item.getCustomerAccountName()),
formatRelatedStaff(item.getRelatedStaffName(), item.getRelatedStaffCode()),
formatSummaryAndCashType(item.getUserMemo(), item.getCashType()),
safeText(item.getHitTags()),
formatMoney(item.getDisplayAmount())
))
.collect(Collectors.toList()),
new float[] { 0.14F, 0.16F, 0.16F, 0.12F, 0.17F, 0.16F, 0.09F },
"暂无涉疑交易明细"
);
writer.subsection("2. 违法信息人员表(共" + report.getIllegalPeople().size() + "人)");
writer.table(
List.of("姓名", "身份证号", "最近查询日期", "民事案件笔数", "民事案件金额", "强制执行笔数", "强制执行金额", "行政处罚笔数", "行政处罚金额"),
report.getIllegalPeople().stream()
.map(item -> List.of(
safeText(item.getPersonName()),
maskIdCard(item.getPersonId()),
safeText(item.getQueryDate()),
String.valueOf(defaultZero(item.getCivilCnt())),
formatMoney(item.getCivilLmt()),
String.valueOf(defaultZero(item.getEnforceCnt())),
formatMoney(item.getEnforceLmt()),
String.valueOf(defaultZero(item.getAdmCnt())),
formatMoney(item.getAdmLmt())
))
.collect(Collectors.toList()),
new float[] { 0.09F, 0.15F, 0.12F, 0.1F, 0.11F, 0.1F, 0.11F, 0.1F, 0.12F },
"暂无违法信息人员数据"
);
writer.subsection("3. 异常账户信息表(共" + report.getAbnormalAccounts().size() + "条)");
writer.table(
List.of("账号", "开户人", "银行", "异常类型", "异常发生时间", "状态"),
report.getAbnormalAccounts().stream()
.map(item -> List.of(
maskAccount(item.getAccountNo()),
safeText(item.getAccountName()),
safeText(item.getBankName()),
safeText(item.getAbnormalType()),
safeText(item.getAbnormalTime()),
safeText(item.getStatus())
))
.collect(Collectors.toList()),
new float[] { 0.18F, 0.13F, 0.2F, 0.23F, 0.14F, 0.12F },
"暂无异常账户信息"
);
}
private PDType0Font loadChineseFont(PDDocument document) throws IOException {
List<String> candidates = List.of(
"C:/Windows/Fonts/NotoSansSC-VF.ttf",
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/simsunb.ttf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttf",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"
);
for (String path : candidates) {
File file = new File(path);
if (!file.exists() || !file.isFile()) {
continue;
}
String lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".ttf")) {
return PDType0Font.load(document, file);
}
if (lowerPath.endsWith(".ttc")) {
PDType0Font font = loadFirstCollectionFont(document, file);
if (font != null) {
return font;
}
}
}
throw new ServiceException("未找到可用中文字体无法导出PDF报告");
}
private PDType0Font loadFirstCollectionFont(PDDocument document, File file) throws IOException {
AtomicReference<PDType0Font> font = new AtomicReference<>();
try (TrueTypeCollection collection = new TrueTypeCollection(file)) {
collection.processAllFonts(typeFont -> {
if (font.get() == null) {
font.set(PDType0Font.load(document, typeFont, true));
}
});
}
return font.get();
}
private List<IndexedUploadSubject> indexedRows(List<CcdiProjectOverviewReportUploadSubjectVO> rows) {
List<IndexedUploadSubject> result = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
result.add(new IndexedUploadSubject(String.valueOf(i + 1), rows.get(i)));
}
return result;
}
private String formatPeopleSummary(CcdiProjectOverviewReportModelSummaryVO item) {
String names = safeText(item.getPeopleNames());
if ("-".equals(names)) {
return names;
}
List<String> people = Arrays.stream(names.split(""))
.filter(value -> value != null && !value.isBlank())
.distinct()
.toList();
if (people.size() <= 4) {
return String.join("", people);
}
return String.join("", people.subList(0, 4)) + "" + defaultZero(item.getPeopleCount()) + "";
}
private String formatHitTags(List<CcdiProjectRiskHitTagVO> tags) {
if (tags == null || tags.isEmpty()) {
return "-";
}
String text = tags.stream()
.map(CcdiProjectRiskHitTagVO::getRuleName)
.filter(Objects::nonNull)
.filter(value -> !value.isBlank())
.distinct()
.collect(Collectors.joining(""));
return text.isBlank() ? "-" : text;
}
private String joinText(List<String> values) {
if (values == null || values.isEmpty()) {
return "-";
}
String text = values.stream()
.filter(Objects::nonNull)
.filter(value -> !value.isBlank())
.distinct()
.collect(Collectors.joining(""));
return text.isBlank() ? "-" : text;
}
private String formatRelatedStaff(String name, String code) {
if (name == null || name.isBlank()) {
return "-";
}
if (code == null || code.isBlank()) {
return name;
}
return name + "(" + code + ")";
}
private String formatSummaryAndCashType(String summary, String cashType) {
return safeText(summary) + "/" + safeText(cashType);
}
private String formatAccount(String accountNo, String accountName) {
String masked = maskAccount(accountNo);
String name = safeText(accountName);
if ("-".equals(name)) {
return masked;
}
return masked + "\n" + name;
}
private String maskAccountList(String value) {
if (value == null || value.isBlank()) {
return "-";
}
return Arrays.stream(value.split("、|,|"))
.map(String::trim)
.filter(item -> !item.isBlank())
.map(this::maskAccount)
.distinct()
.collect(Collectors.joining(""));
}
private String maskAccount(String value) {
if (value == null || value.isBlank()) {
return "-";
}
String text = value.trim().replaceAll("\\s+", "");
if (text.length() <= 8) {
return text.length() <= 4 ? text : text.substring(0, 2) + "****" + text.substring(text.length() - 2);
}
return text.substring(0, 4) + "****" + text.substring(text.length() - 4);
}
private String maskIdCard(String value) {
if (value == null || value.isBlank()) {
return "-";
}
String text = value.trim();
if (text.length() < 10) {
return text;
}
return text.substring(0, 3) + "***********" + text.substring(text.length() - 4);
}
private String formatCount(Integer value, String unit) {
return defaultZero(value) + unit;
}
private String formatMoney(BigDecimal value) {
if (value == null) {
return "-";
}
return MONEY_FORMAT.format(value);
}
private Integer defaultZero(Integer value) {
return value == null ? 0 : value;
}
private String safeText(String value) {
return value == null || value.isBlank() ? "-" : value;
}
private String safeFileName(String value) {
String text = safeText(value);
return text.replaceAll("[\\\\/:*?\"<>|]", "_");
}
private record IndexedUploadSubject(String index, CcdiProjectOverviewReportUploadSubjectVO row) {
}
private static class PdfPageWriter {
private static final float MARGIN = 36F;
private static final PDRectangle LANDSCAPE_A4 = new PDRectangle(
PDRectangle.A4.getHeight(),
PDRectangle.A4.getWidth()
);
private static final float CONTENT_WIDTH = LANDSCAPE_A4.getWidth() - MARGIN * 2;
private static final float PAGE_TOP = LANDSCAPE_A4.getHeight() - MARGIN;
private static final float PAGE_BOTTOM = MARGIN;
private static final float BODY_FONT_SIZE = 9F;
private static final float HEADER_FONT_SIZE = 9F;
private static final float TITLE_FONT_SIZE = 22F;
private static final float SECTION_FONT_SIZE = 15F;
private static final float SUBSECTION_FONT_SIZE = 12F;
private static final float LINE_HEIGHT = 12F;
private static final float CELL_PADDING = 5F;
private static final float TABLE_AFTER_GAP = 32F;
private final PDDocument document;
private final PDType0Font font;
private PDPageContentStream content;
private float y;
PdfPageWriter(PDDocument document, PDType0Font font) {
this.document = document;
this.font = font;
}
void newPage() throws IOException {
close();
PDPage page = new PDPage(LANDSCAPE_A4);
document.addPage(page);
content = new PDPageContentStream(document, page);
y = PAGE_TOP;
}
void close() throws IOException {
if (content != null) {
content.close();
content = null;
}
}
void title(String text) throws IOException {
writeLine(text, TITLE_FONT_SIZE, new Color(18, 56, 93), 0F, 28F, true);
}
void text(String text, float fontSize, Color color) throws IOException {
writeLine(text, fontSize, color, 0F, 18F);
}
void section(String text) throws IOException {
ensureSpace(32F);
writeLine(text, SECTION_FONT_SIZE, new Color(18, 56, 93), 0F, 26F, true);
}
void subsection(String text) throws IOException {
ensureSpace(26F);
writeLine(text, SUBSECTION_FONT_SIZE, new Color(51, 65, 85), 0F, 22F, true);
}
void separator() throws IOException {
ensureSpace(16F);
content.setStrokingColor(new Color(31, 78, 121));
content.setLineWidth(1.4F);
content.moveTo(MARGIN, y);
content.lineTo(MARGIN + CONTENT_WIDTH, y);
content.stroke();
y -= 22F;
}
void metrics(List<CcdiProjectOverviewStatVO> stats) throws IOException {
ensureSpace(58F);
float cellWidth = CONTENT_WIDTH / Math.max(stats.size(), 1);
float x = MARGIN;
float rowHeight = 50F;
for (CcdiProjectOverviewStatVO stat : stats) {
drawRect(x, y - rowHeight, cellWidth, rowHeight, null);
drawCenteredText(String.valueOf(stat.getValue()), x, y - 18F, cellWidth, 18F, new Color(31, 78, 121));
drawCenteredText(stat.getLabel(), x, y - 36F, cellWidth, 10F, Color.GRAY);
x += cellWidth;
}
y -= rowHeight + 18F;
}
void table(
List<String> headers,
List<List<String>> rows,
float[] widthRatios,
String emptyText
) throws IOException {
List<List<String>> safeRows = rows.isEmpty()
? List.of(List.of(emptyText))
: rows;
List<String> safeHeaders = rows.isEmpty()
? List.of(headers.get(0))
: headers;
float[] widths = rows.isEmpty()
? new float[] { CONTENT_WIDTH }
: calculateWidths(widthRatios);
drawHeader(safeHeaders, widths);
for (List<String> row : safeRows) {
drawRow(row, widths, false);
}
y -= TABLE_AFTER_GAP;
}
private float[] calculateWidths(float[] ratios) {
float[] widths = new float[ratios.length];
for (int i = 0; i < ratios.length; i++) {
widths[i] = CONTENT_WIDTH * ratios[i];
}
return widths;
}
private void drawHeader(List<String> headers, float[] widths) throws IOException {
drawRow(headers, widths, true);
}
private void drawRow(List<String> cells, float[] widths, boolean header) throws IOException {
List<List<String>> wrappedCells = new ArrayList<>();
float rowHeight = 0F;
for (int i = 0; i < widths.length; i++) {
String text = i < cells.size() ? cells.get(i) : "";
List<String> lines = wrapText(text, widths[i] - CELL_PADDING * 2, header ? HEADER_FONT_SIZE : BODY_FONT_SIZE);
wrappedCells.add(lines);
rowHeight = Math.max(rowHeight, lines.size() * LINE_HEIGHT + CELL_PADDING * 2);
}
rowHeight = Math.max(rowHeight, 24F);
ensureSpace(rowHeight + 4F);
float x = MARGIN;
for (int i = 0; i < widths.length; i++) {
Color background = header ? new Color(234, 241, 248) : null;
drawRect(x, y - rowHeight, widths[i], rowHeight, background);
drawCellText(wrappedCells.get(i), x + CELL_PADDING, y - CELL_PADDING - (header ? HEADER_FONT_SIZE : BODY_FONT_SIZE), header);
x += widths[i];
}
y -= rowHeight;
}
private void drawRect(float x, float bottomY, float width, float height, Color fill) throws IOException {
if (fill != null) {
content.setNonStrokingColor(fill);
content.addRect(x, bottomY, width, height);
content.fill();
}
content.setStrokingColor(new Color(205, 217, 229));
content.setLineWidth(0.5F);
content.addRect(x, bottomY, width, height);
content.stroke();
}
private void drawCellText(List<String> lines, float x, float startY, boolean header) throws IOException {
content.beginText();
content.setNonStrokingColor(header ? new Color(24, 59, 90) : new Color(31, 41, 55));
content.setFont(font, header ? HEADER_FONT_SIZE : BODY_FONT_SIZE);
content.newLineAtOffset(x, startY);
for (int i = 0; i < lines.size(); i++) {
if (i > 0) {
content.newLineAtOffset(0, -LINE_HEIGHT);
}
content.showText(lines.get(i));
}
content.endText();
}
private void drawCenteredText(String text, float x, float baselineY, float width, float fontSize, Color color)
throws IOException {
float textWidth = textWidth(text, fontSize);
content.beginText();
content.setNonStrokingColor(color);
content.setFont(font, fontSize);
content.newLineAtOffset(x + (width - textWidth) / 2F, baselineY);
content.showText(text);
content.endText();
}
private void writeLine(String text, float fontSize, Color color, float indent, float advance) throws IOException {
writeLine(text, fontSize, color, indent, advance, false);
}
private void writeLine(
String text,
float fontSize,
Color color,
float indent,
float advance,
boolean bold
) throws IOException {
ensureSpace(advance);
content.beginText();
content.setNonStrokingColor(color);
content.setFont(font, fontSize);
content.newLineAtOffset(MARGIN + indent, y);
content.showText(text);
content.endText();
if (bold) {
content.beginText();
content.setNonStrokingColor(color);
content.setFont(font, fontSize);
content.newLineAtOffset(MARGIN + indent + 0.25F, y);
content.showText(text);
content.endText();
}
y -= advance;
}
private void ensureSpace(float height) throws IOException {
if (y - height < PAGE_BOTTOM) {
newPage();
}
}
private List<String> wrapText(String text, float maxWidth, float fontSize) throws IOException {
String safeText = text == null || text.isBlank() ? "-" : text;
List<String> result = new ArrayList<>();
for (String part : safeText.split("\\n")) {
wrapPart(part, maxWidth, fontSize, result);
}
return result.isEmpty() ? List.of("-") : result;
}
private void wrapPart(String text, float maxWidth, float fontSize, List<String> result) throws IOException {
StringBuilder current = new StringBuilder();
for (int i = 0; i < text.length(); i++) {
String next = String.valueOf(text.charAt(i));
if (textWidth(current + next, fontSize) > maxWidth && current.length() > 0) {
result.add(current.toString());
current.setLength(0);
}
current.append(next);
}
if (current.length() > 0) {
result.add(current.toString());
}
}
private float textWidth(String text, float fontSize) throws IOException {
return font.getStringWidth(text) / 1000F * fontSize;
}
}
}

View File

@@ -26,6 +26,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
@@ -37,21 +39,25 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
import com.ruoyi.ccdi.project.service.ICcdiModelParamService;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.common.exception.ServiceException;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.annotation.Resource;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -81,6 +87,13 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
@Resource
private CcdiProjectRiskDetailWorkbookExporter workbookExporter;
@Resource
private CcdiProjectOverviewReportPdfExporter reportPdfExporter;
@Resource
@Lazy
private ICcdiModelParamService modelParamService;
@Override
public CcdiProjectOverviewDashboardVO getDashboard(Long projectId) {
CcdiProject project = overviewMapper.selectDashboardBaseByProjectId(projectId);
@@ -303,6 +316,38 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
}
}
@Override
public void exportOverviewReport(HttpServletResponse response, Long projectId) {
CcdiProject project = getRequiredProject(projectId);
CcdiProjectSuspiciousTransactionQueryDTO suspiciousQuery = new CcdiProjectSuspiciousTransactionQueryDTO();
suspiciousQuery.setProjectId(projectId);
suspiciousQuery.setSuspiciousType("ALL");
CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO();
report.setProject(project);
report.setDashboard(getDashboard(projectId));
report.setUploadSubjects(defaultList(overviewMapper.selectReportUploadSubjects(projectId)).stream()
.peek(item -> item.setDataPeriod(formatDataPeriod(item.getMinTrxDate(), item.getMaxTrxDate())))
.toList());
report.setParams(buildReportParams(projectId));
report.setModelSummaries(defaultList(overviewMapper.selectReportRiskModelSummaries(projectId)));
report.setRiskPeople(defaultList(overviewMapper.selectReportRiskPeople(projectId)).stream()
.peek(item -> item.setActionLabel(ACTION_LABEL))
.toList());
report.setSuspiciousTransactions(defaultList(
overviewMapper.selectReportSuspiciousTransactionList(suspiciousQuery)
));
report.setIllegalPeople(exportEmployeeCreditNegative(projectId));
report.setAbnormalAccounts(exportAbnormalAccountPeople(projectId));
try {
reportPdfExporter.export(response, report);
} catch (IOException e) {
throw new ServiceException("导出结果总览报告失败");
}
}
@Override
public List<CcdiProjectEmployeeCreditNegativeExcel> exportEmployeeCreditNegative(Long projectId) {
ensureProjectExists(projectId);
@@ -511,6 +556,31 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return row;
}
private List<CcdiProjectOverviewReportParamVO> buildReportParams(Long projectId) {
ModelParamAllVO response = modelParamService.selectAllParams(projectId);
return defaultList(response == null ? null : response.getModels()).stream()
.flatMap(model -> defaultList(model.getParams()).stream().map(param -> {
CcdiProjectOverviewReportParamVO row = new CcdiProjectOverviewReportParamVO();
row.setModelName(model.getModelName());
row.setParamName(param.getParamName());
row.setParamValue(param.getParamValue());
row.setParamUnit(param.getParamUnit());
row.setParamDesc(param.getParamDesc());
return row;
}))
.toList();
}
private String formatDataPeriod(String minTrxDate, String maxTrxDate) {
if (minTrxDate == null || minTrxDate.isBlank() || maxTrxDate == null || maxTrxDate.isBlank()) {
return "-";
}
LocalDate start = LocalDate.parse(minTrxDate);
LocalDate end = LocalDate.parse(maxTrxDate);
int months = (end.getYear() - start.getYear()) * 12 + end.getMonthValue() - start.getMonthValue() + 1;
return Math.max(months, 1) + "个月";
}
private String formatRelatedStaff(String relatedStaffName, String relatedStaffCode) {
if (relatedStaffName == null || relatedStaffName.isBlank()) {
return null;

View File

@@ -0,0 +1,284 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
import com.ruoyi.ccdi.project.mapper.CcdiRelationGraphMapper;
import com.ruoyi.ccdi.project.service.ICcdiRelationGraphService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.Period;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 关系图谱Service实现
*/
@Service
public class CcdiRelationGraphServiceImpl implements ICcdiRelationGraphService {
private static final int DEFAULT_LIMIT = 80;
private static final int MAX_LIMIT = 200;
private static final int DEFAULT_SUSPECTED_LIMIT = 10;
private static final int MAX_SUSPECTED_LIMIT = 20;
private static final int SAME_NAME_BLOCK_THRESHOLD = 20;
private static final String NODE_PREFIX = "rel_node/";
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Resource
private CcdiRelationGraphMapper relationGraphMapper;
@Override
public List<CcdiRelationGraphNodeVO> searchSubjects(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphQueryDTO query = normalizeGraphQuery(queryDTO);
if (isBlank(query.getKeyword()) && isBlank(query.getObjectKey())) {
return Collections.emptyList();
}
return relationGraphMapper.selectRelationGraphSubjects(query);
}
@Override
public CcdiRelationGraphVO getRelationGraph(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphQueryDTO query = normalizeGraphQuery(queryDTO);
CcdiRelationGraphNodeVO centerNode = resolveCenterNode(query);
if (centerNode == null || isBlank(centerNode.getObjectKey())) {
return new CcdiRelationGraphVO();
}
query.setObjectKey(centerNode.getObjectKey());
List<CcdiRelationGraphEdgeVO> edges = relationGraphMapper.selectRelationGraphEdges(query);
if (edges == null) {
edges = Collections.emptyList();
}
List<CcdiRelationGraphNodeVO> nodes = buildNodes(centerNode, edges);
CcdiRelationGraphVO graph = new CcdiRelationGraphVO();
graph.setCenterNode(centerNode);
graph.setNodes(nodes);
graph.setEdges(edges);
graph.setEdgeCount((long) edges.size());
graph.setMaxDepth(1);
return graph;
}
@Override
public CcdiRelationGraphSuspectedEnterpriseVO getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
CcdiRelationGraphSuspectedEnterpriseVO result = new CcdiRelationGraphSuspectedEnterpriseVO();
CcdiRelationGraphSuspectedEnterpriseQueryDTO query = normalizeSuspectedEnterpriseQuery(queryDTO);
if (isBlank(query.getPersonName())) {
result.setMessage("缺少人员姓名,无法按工商同名主体召回");
return result;
}
int sameNameKeyNoCount = relationGraphMapper.countSuspectedEnterpriseKeyNos(query.getPersonName());
result.setSameNameKeyNoCount(sameNameKeyNoCount);
if (sameNameKeyNoCount > SAME_NAME_BLOCK_THRESHOLD) {
result.setBlocked(true);
result.setMessage("同名工商主体过多,请结合交易对手企业名称或其他线索进一步筛选");
return result;
}
LocalDate birthDate = resolveBirthDate(query);
List<CcdiRelationGraphSuspectedEnterpriseItemVO> rows =
relationGraphMapper.selectSuspectedEnterprises(query.getPersonName(), MAX_SUSPECTED_LIMIT);
List<CcdiRelationGraphSuspectedEnterpriseItemVO> filteredRows = new ArrayList<>();
if (rows != null) {
for (CcdiRelationGraphSuspectedEnterpriseItemVO row : rows) {
if (row == null) {
continue;
}
row.setPersonName(query.getPersonName());
if (applyAgeRule(row, birthDate)) {
filteredRows.add(row);
if (filteredRows.size() >= query.getLimit()) {
break;
}
}
}
}
result.setRows(filteredRows);
if (filteredRows.isEmpty()) {
result.setMessage("未发现可展示的疑似同名企业");
}
return result;
}
private CcdiRelationGraphNodeVO resolveCenterNode(CcdiRelationGraphQueryDTO query) {
List<CcdiRelationGraphNodeVO> subjects = relationGraphMapper.selectRelationGraphSubjects(query);
if (subjects == null || subjects.isEmpty()) {
return null;
}
return subjects.get(0);
}
private List<CcdiRelationGraphNodeVO> buildNodes(CcdiRelationGraphNodeVO centerNode, List<CcdiRelationGraphEdgeVO> edges) {
Set<String> objectKeys = new LinkedHashSet<>();
objectKeys.add(centerNode.getObjectKey());
for (CcdiRelationGraphEdgeVO edge : edges) {
edge.setFromObjectKey(toObjectKey(edge.getFromKey()));
edge.setToObjectKey(toObjectKey(edge.getToKey()));
if (!isBlank(edge.getFromObjectKey())) {
objectKeys.add(edge.getFromObjectKey());
}
if (!isBlank(edge.getToObjectKey())) {
objectKeys.add(edge.getToObjectKey());
}
}
List<CcdiRelationGraphNodeVO> rawNodes = relationGraphMapper.selectRelationGraphNodesByKeys(new ArrayList<>(objectKeys));
Map<String, CcdiRelationGraphNodeVO> nodeMap = new LinkedHashMap<>();
if (rawNodes != null) {
for (CcdiRelationGraphNodeVO node : rawNodes) {
enrichNode(node, centerNode);
nodeMap.put(node.getObjectKey(), node);
}
}
if (!nodeMap.containsKey(centerNode.getObjectKey())) {
enrichNode(centerNode, centerNode);
nodeMap.put(centerNode.getObjectKey(), centerNode);
}
for (CcdiRelationGraphEdgeVO edge : edges) {
CcdiRelationGraphNodeVO fromNode = nodeMap.get(edge.getFromObjectKey());
CcdiRelationGraphNodeVO toNode = nodeMap.get(edge.getToObjectKey());
if (fromNode != null) {
edge.setFromName(fromNode.getNodeName());
}
if (toNode != null) {
edge.setToName(toNode.getNodeName());
}
}
return List.copyOf(nodeMap.values());
}
private void enrichNode(CcdiRelationGraphNodeVO node, CcdiRelationGraphNodeVO centerNode) {
if (node == null) {
return;
}
node.setNodeKey(NODE_PREFIX + node.getObjectKey());
node.setCanExpand(true);
node.setDepth(node.getObjectKey() != null && node.getObjectKey().equals(centerNode.getObjectKey()) ? 0 : 1);
}
private CcdiRelationGraphQueryDTO normalizeGraphQuery(CcdiRelationGraphQueryDTO queryDTO) {
CcdiRelationGraphQueryDTO query = queryDTO == null ? new CcdiRelationGraphQueryDTO() : queryDTO;
query.setKeyword(normalizeText(query.getKeyword()));
query.setObjectKey(normalizeText(query.getObjectKey()));
query.setLimit(normalizeLimit(query.getLimit()));
query.setDepth(1);
return query;
}
private CcdiRelationGraphSuspectedEnterpriseQueryDTO normalizeSuspectedEnterpriseQuery(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
CcdiRelationGraphSuspectedEnterpriseQueryDTO query =
queryDTO == null ? new CcdiRelationGraphSuspectedEnterpriseQueryDTO() : queryDTO;
query.setPersonName(normalizeText(query.getPersonName()));
query.setCertNo(normalizeText(query.getCertNo()));
query.setBirthDate(normalizeText(query.getBirthDate()));
query.setLimit(normalizeSuspectedLimit(query.getLimit()));
return query;
}
private Integer normalizeSuspectedLimit(Integer limit) {
if (limit == null || limit <= 0) {
return DEFAULT_SUSPECTED_LIMIT;
}
return Math.min(limit, MAX_SUSPECTED_LIMIT);
}
private LocalDate resolveBirthDate(CcdiRelationGraphSuspectedEnterpriseQueryDTO query) {
LocalDate explicitBirthDate = parseDate(query.getBirthDate());
if (explicitBirthDate != null) {
return explicitBirthDate;
}
return parseBirthDateFromCertNo(query.getCertNo());
}
private boolean applyAgeRule(CcdiRelationGraphSuspectedEnterpriseItemVO row, LocalDate birthDate) {
LocalDate establishDate = parseDate(row.getEstablishDate());
if (birthDate == null || establishDate == null) {
row.setMatchReason("姓名一致;企业成立日期或出生日期缺失,年龄无法判断");
return true;
}
int age = Period.between(birthDate, establishDate).getYears();
row.setAgeAtEstablish(age);
if (age < 18) {
return false;
}
row.setMatchReason("姓名一致;成立时年龄" + age + "");
return true;
}
private LocalDate parseBirthDateFromCertNo(String certNo) {
if (isBlank(certNo)) {
return null;
}
String value = certNo.trim();
if (value.matches("^\\d{17}[0-9Xx]$")) {
return parseCompactDate(value.substring(6, 14));
}
if (value.matches("^\\d{15}$")) {
return parseCompactDate("19" + value.substring(6, 12));
}
return null;
}
private LocalDate parseCompactDate(String value) {
try {
return LocalDate.parse(value, DateTimeFormatter.BASIC_ISO_DATE);
} catch (DateTimeParseException ignored) {
return null;
}
}
private LocalDate parseDate(String value) {
if (isBlank(value)) {
return null;
}
try {
return LocalDate.parse(value.trim(), DATE_FORMATTER);
} catch (DateTimeParseException ignored) {
return null;
}
}
private Integer normalizeLimit(Integer limit) {
if (limit == null || limit <= 0) {
return DEFAULT_LIMIT;
}
return Math.min(limit, MAX_LIMIT);
}
private String toObjectKey(String nodeKey) {
if (isBlank(nodeKey)) {
return null;
}
return nodeKey.startsWith(NODE_PREFIX) ? nodeKey.substring(NODE_PREFIX.length()) : nodeKey;
}
private String normalizeText(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
}

View File

@@ -105,36 +105,123 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="salaryExclusionPredicate">
not (
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
and (
IFNULL(bs.USER_MEMO, '') LIKE '%代发%'
or IFNULL(bs.USER_MEMO, '') LIKE '%工资%'
or IFNULL(bs.USER_MEMO, '') LIKE '%奖金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%薪酬%'
or IFNULL(bs.USER_MEMO, '') LIKE '%薪%'
or IFNULL(bs.USER_MEMO, '') LIKE '%补贴%'
or IFNULL(bs.USER_MEMO, '') LIKE '%%'
or IFNULL(bs.USER_MEMO, '') LIKE '%年终奖%'
or IFNULL(bs.USER_MEMO, '') LIKE '%年%'
or IFNULL(bs.USER_MEMO, '') LIKE '%加班费%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务费%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务外包%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提成%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务派遣%'
or IFNULL(bs.USER_MEMO, '') LIKE '%绩效%'
or IFNULL(bs.USER_MEMO, '') LIKE '%酬劳%'
or IFNULL(bs.USER_MEMO, '') LIKE '%PAYROLL%'
or IFNULL(bs.USER_MEMO, '') LIKE '%SALA%'
or IFNULL(bs.USER_MEMO, '') LIKE '%CPF%'
or IFNULL(bs.USER_MEMO, '') LIKE '%directors%fees%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%代发%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%工资%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%劳务费%'
(
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
and (
IFNULL(bs.USER_MEMO, '') LIKE '%代发%'
or IFNULL(bs.USER_MEMO, '') LIKE '%工资%'
or IFNULL(bs.USER_MEMO, '') LIKE '%奖金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%薪%'
or IFNULL(bs.USER_MEMO, '') LIKE '%薪金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%补贴%'
or IFNULL(bs.USER_MEMO, '') LIKE '%%'
or IFNULL(bs.USER_MEMO, '') LIKE '%年终奖%'
or IFNULL(bs.USER_MEMO, '') LIKE '%年金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%加班费%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务外包%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提成%'
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务派遣%'
or IFNULL(bs.USER_MEMO, '') LIKE '%绩效%'
or IFNULL(bs.USER_MEMO, '') LIKE '%酬劳%'
or IFNULL(bs.USER_MEMO, '') LIKE '%PAYROLL%'
or IFNULL(bs.USER_MEMO, '') LIKE '%SALA%'
or IFNULL(bs.USER_MEMO, '') LIKE '%CPF%'
or IFNULL(bs.USER_MEMO, '') LIKE '%directors%fees%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%代发%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%工资%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%劳务费%'
)
)
or (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%公积金中心%'
and (
IFNULL(bs.USER_MEMO, '') LIKE '%公积金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%公积金%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%批量代付%'
)
)
)
</sql>
<sql id="financialProductExclusionPredicate">
not (
(
(
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '理财|理财产品|结构.*存款|结构性存款|理财.*托管|余额宝|朝朝宝|朝朝盈|现金宝|金添利|定存宝'
or IFNULL(bs.USER_MEMO, '') REGEXP '理财|理财产品|结构.*存款|结构性存款|本金划出|本金返还|余额宝|朝朝宝|朝朝盈|现金宝|金添利|定存宝|整存整取|智能存款|通知存款'
or IFNULL(bs.CASH_TYPE, '') REGEXP '受托理财|表内理财|购买理财|理财购买|理财扣款|理财申购|理财认购|结构性存款|存款产品|朝朝宝'
or (
IFNULL(bs.USER_MEMO, '') REGEXP '申购|认购|赎回'
and IFNULL(bs.USER_MEMO, '') REGEXP '理财|产品|存款|本金|余额宝|朝朝宝|朝朝盈'
)
)
and IFNULL(bs.USER_MEMO, '') NOT REGEXP '财务|经理|代理财税'
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') NOT LIKE '%代理财政%'
)
)
</sql>
<sql id="thirdPartyWithdrawIncomePredicate">
(
(
bs.BANK in ('ALIPAY', 'WECHAT')
and (
IFNULL(bs.CASH_TYPE, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%转出到%银行%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%转出到%银行%'
)
)
or (
(
bs.BANK is null
or bs.BANK = ''
or bs.BANK not in ('ALIPAY', 'WECHAT')
)
and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
or IFNULL(bs.USER_MEMO, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
or IFNULL(bs.CASH_TYPE, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
)
and (
IFNULL(bs.CASH_TYPE, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提现%'
or IFNULL(bs.USER_MEMO, '') LIKE '%转出到%银行%'
or IFNULL(bs.USER_MEMO, '') LIKE '%提现到账%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%转出到%银行%'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现到账%'
)
)
)
</sql>
<sql id="abnormalCustomerTransactionSubjectSql">
select
staff.id_card as subjectCertNo,
staff.name as subjectName,
'本人' as subjectType
from ccdi_base_staff staff
union all
select
relation.relation_cert_no as subjectCertNo,
relation.relation_name as subjectName,
case
when relation.relation_type is not null and trim(relation.relation_type) != '' then relation.relation_type
else '关系人'
end as subjectType
from ccdi_staff_fmy_relation relation
where relation.status = 1
and relation.relation_cert_no is not null
and trim(relation.relation_cert_no) != ''
</sql>
<sql id="salaryIncomePredicate">
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
and (
@@ -167,6 +254,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
IFNULL(bs.USER_MEMO, '') REGEXP '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局'
)
and <include refid="financialProductExclusionPredicate"/>
and (
exists (
select 1
@@ -198,6 +286,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
IFNULL(bs.USER_MEMO, '') REGEXP '税务|缴税|税款'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '税务|税务局|国库|国家金库|财政'
)
and <include refid="financialProductExclusionPredicate"/>
and (
exists (
select 1
@@ -235,6 +324,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and relation.person_id is null
and <include refid="salaryExclusionPredicate"/>
and <include refid="financialProductExclusionPredicate"/>
</select>
<select id="selectCumulativeIncomeObjects" resultMap="BankTagObjectHitResultMap">
@@ -262,6 +352,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and relation.person_id is null
and <include refid="salaryExclusionPredicate"/>
and <include refid="financialProductExclusionPredicate"/>
group by staff.id_card, bs.CUSTOMER_ACCOUNT_NAME
having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
) t
@@ -283,6 +374,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and <include refid="financialProductExclusionPredicate"/>
and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
group by staff.id_card
having SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
@@ -302,18 +394,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > #{threshold}
and <include refid="cashDepositPredicate"/>
and (
exists (
select 1
from ccdi_base_staff staff
where staff.id_card = bs.cret_no
)
or exists (
select 1
from ccdi_staff_fmy_relation relation
where relation.relation_cert_no = bs.cret_no
and relation.status = 1
)
and <include refid="financialProductExclusionPredicate"/>
and exists (
select 1
from ccdi_base_staff staff
where staff.id_card = bs.cret_no
)
</select>
@@ -340,16 +425,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > #{amountThreshold}
and <include refid="cashDepositPredicate"/>
union all
select
relation.person_id AS object_key,
LEFT(TRIM(bs.TRX_DATE), 10) AS cash_date
from ccdi_bank_statement bs
inner join ccdi_staff_fmy_relation relation on relation.relation_cert_no = bs.cret_no
where bs.project_id = #{projectId}
and relation.status = 1
and IFNULL(bs.AMOUNT_CR, 0) > #{amountThreshold}
and <include refid="cashDepositPredicate"/>
and <include refid="financialProductExclusionPredicate"/>
) source
group by source.object_key, source.cash_date
having COUNT(1) > #{frequencyThreshold}
@@ -373,8 +449,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
or IFNULL(bs.USER_MEMO, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入'
or IFNULL(bs.CASH_TYPE, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入'
)
and IFNULL(bs.USER_MEMO, '') NOT LIKE '%款%'
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and <include refid="financialProductExclusionPredicate"/>
and (
exists (
select 1
@@ -392,12 +468,130 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectAbnormalCustomerTransactionStatements" resultMap="BankTagStatementHitResultMap">
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
hit.bankStatementId AS bankStatementId,
max(hit.groupId) AS groupId,
max(hit.logId) AS logId,
substring_index(
min(concat(lpad(hit.matchPriority, 2, '0'), '|', hit.reasonDetail)),
'|',
-1
) AS reasonDetail
from (
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
1 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与信贷客户账号发生交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_account_info account
on trim(IFNULL(bs.customer_account_no, '')) != ''
and account.owner_type = 'CREDIT_CUSTOMER'
and account.account_no = bs.customer_account_no
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
2 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介账号发生交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_account_info account
on trim(IFNULL(bs.customer_account_no, '')) != ''
and account.owner_type = 'INTERMEDIARY'
and account.account_no = bs.customer_account_no
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
3 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介关联企业发生交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_enterprise_base_info enterprise
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
and enterprise.enterprise_name = bs.CUSTOMER_ACCOUNT_NAME
and enterprise.ent_source = 'INTERMEDIARY'
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
4 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介库人员发生微信/支付宝交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_biz_intermediary intermediary
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
and trim(IFNULL(intermediary.name, '')) != ''
and bs.CUSTOMER_ACCOUNT_NAME like concat('%', intermediary.name, '%')
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
and bs.bank in ('ALIPAY', 'WECHAT')
union all
select
bs.bank_statement_id AS bankStatementId,
bs.group_id AS groupId,
bs.batch_id AS logId,
5 AS matchPriority,
CONCAT(
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介库人员发生名称精确匹配交易,',
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
) AS reasonDetail
from ccdi_bank_statement bs
inner join (
<include refid="abnormalCustomerTransactionSubjectSql"/>
) subject on subject.subjectCertNo = bs.cret_no
inner join ccdi_biz_intermediary intermediary
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
and intermediary.name = bs.CUSTOMER_ACCOUNT_NAME
where bs.project_id = #{projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
) hit
group by hit.bankStatementId
</select>
<select id="selectLowIncomeRelativeLargeTransactionObjects" resultMap="BankTagObjectHitResultMap">
@@ -416,9 +610,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from ccdi_staff_fmy_relation relation
inner join ccdi_bank_statement bs on relation.relation_cert_no = bs.cret_no
where relation.status = 1
and relation.annual_income is not null
and (
relation.annual_income is null
or relation.annual_income = 0
relation.annual_income = 0
or relation.annual_income / 12 &lt; 3000
)
and bs.project_id = #{projectId}
@@ -503,8 +697,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|注'
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
)
</select>
@@ -658,6 +852,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > 0
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') &lt;&gt; '浙江兰溪农村商业银行股份有限公司'
and not (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%公积金中心%'
and (
IFNULL(bs.USER_MEMO, '') LIKE '%公积金%'
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%公积金%'
or IFNULL(bs.CASH_TYPE, '') LIKE '%批量代付%'
)
)
and (
IFNULL(bs.USER_MEMO, '') REGEXP '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees'
or IFNULL(bs.CASH_TYPE, '') REGEXP '代发|工资|劳务费'
@@ -1055,6 +1258,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
)
and <include refid="financialProductExclusionPredicate"/>
</select>
<select id="selectWithdrawCntObjects" resultMap="BankTagObjectHitResultMap">
@@ -1074,11 +1278,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) >= 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
)
and IFNULL(bs.AMOUNT_CR, 0) > 0
and <include refid="thirdPartyWithdrawIncomePredicate"/>
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10)
having COUNT(1) > #{frequencyThreshold}
) t
@@ -1087,10 +1288,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectWithdrawAmtObjects" resultMap="BankTagObjectHitResultMap">
select
'STAFF_ID_CARD' AS objectType,
'' AS objectKey,
'占位SQL待补充真实规则' AS reasonDetail
from ccdi_bank_statement bs
where 1 = 0
t.objectKey AS objectKey,
CONCAT(
'单日微信/支付宝提现到账金额 ', CAST(t.withdrawAmount AS CHAR),
' 元,超过阈值 ', CAST(#{amountThreshold} AS CHAR),
' 元,交易日:', t.transDate
) AS reasonDetail
from (
select
staff.id_card AS objectKey,
LEFT(TRIM(bs.TRX_DATE), 10) AS transDate,
ROUND(SUM(IFNULL(bs.AMOUNT_CR, 0)), 2) AS withdrawAmount
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_CR, 0) > 0
and <include refid="thirdPartyWithdrawIncomePredicate"/>
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10)
having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{amountThreshold}
) t
</select>
<select id="selectSalaryQuickTransferObjects" resultMap="BankTagObjectHitResultMap">
@@ -1322,10 +1538,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > #{threshold}
and (
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管'
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
)
and <include refid="financialProductExclusionPredicate"/>
</select>
<select id="selectProxyAccountOperationObjects" resultMap="BankTagObjectHitResultMap">

View File

@@ -0,0 +1,384 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiFundGraphMapper">
<resultMap id="FundGraphNodeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO">
<id property="objectKey" column="objectKey"/>
<result property="nodeKey" column="nodeKey"/>
<result property="nodeName" column="nodeName"/>
<result property="idNo" column="idNo"/>
<result property="cinocsno" column="cinocsno"/>
<result property="idnoType" column="idnoType"/>
<result property="staffId" column="staffId"/>
<result property="sourceType" column="sourceType"/>
<result property="nodeType" column="nodeType"/>
<result property="identityType" column="identityType"/>
<result property="relationType" column="relationType"/>
<result property="accountCount" column="accountCount"/>
<result property="createdTime" column="createdTime"/>
<result property="updatedTime" column="updatedTime"/>
<result property="canExpand" column="canExpand"/>
<result property="depth" column="depth"/>
<result property="totalAmount" column="totalAmount"/>
<result property="transactionCount" column="transactionCount"/>
</resultMap>
<resultMap id="FundGraphEdgeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO">
<id property="edgeKey" column="edgeKey"/>
<result property="fromKey" column="fromKey"/>
<result property="toKey" column="toKey"/>
<result property="fromObjectKey" column="fromObjectKey"/>
<result property="toObjectKey" column="toObjectKey"/>
<result property="fromName" column="fromName"/>
<result property="toName" column="toName"/>
<result property="totalAmount" column="totalAmount"/>
<result property="transactionCount" column="transactionCount"/>
<result property="firstTrxDate" column="firstTrxDate"/>
<result property="lastTrxDate" column="lastTrxDate"/>
<result property="direction" column="direction"/>
<result property="familyRelationType" column="familyRelationType"/>
<result property="sourceType" column="sourceType"/>
<result property="relationDesc" column="relationDesc"/>
<result property="sourceDesc" column="sourceDesc"/>
<result property="remark" column="remark"/>
<result property="depth" column="depth"/>
<result property="canTrace" column="canTrace"/>
</resultMap>
<resultMap id="FundGraphStatementResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO">
<id property="bankStatementId" column="bankStatementId"/>
<result property="trxDate" column="trxDate"/>
<result property="leAccountNo" column="leAccountNo"/>
<result property="leAccountName" column="leAccountName"/>
<result property="customerAccountName" column="customerAccountName"/>
<result property="customerAccountNo" column="customerAccountNo"/>
<result property="cashType" column="cashType"/>
<result property="userMemo" column="userMemo"/>
<result property="amount" column="amount"/>
<result property="direction" column="direction"/>
<result property="familyRelationType" column="familyRelationType"/>
</resultMap>
<sql id="detailFilter">
<if test="query.transactionStartTime != null and query.transactionStartTime != ''">
AND d.trx_date <![CDATA[ >= ]]> (#{query.transactionStartTime} COLLATE utf8mb4_general_ci)
</if>
<if test="query.transactionEndTime != null and query.transactionEndTime != ''">
AND d.trx_date <![CDATA[ <= ]]>
(CASE
WHEN LENGTH(TRIM(#{query.transactionEndTime})) = 10
THEN CONCAT(TRIM(#{query.transactionEndTime}), ' 23:59:59')
ELSE TRIM(#{query.transactionEndTime})
END COLLATE utf8mb4_general_ci)
</if>
<if test="query.amountMin != null">
AND d.amount <![CDATA[ >= ]]> #{query.amountMin}
</if>
<if test="query.amountMax != null">
AND d.amount <![CDATA[ <= ]]> #{query.amountMax}
</if>
<if test="query.direction != null and query.direction != ''">
AND d.flag = (#{query.direction} COLLATE utf8mb4_general_ci)
</if>
</sql>
<sql id="subjectJoinRows">
SELECT
d.object_key AS detailObjectKey,
d.bank_statement_id AS bankStatementId,
d.trx_date AS trxDate,
d.le_account_no AS leAccountNo,
d.le_account_name AS leAccountName,
d.customer_account_name AS customerAccountName,
d.customer_account_no AS customerAccountNo,
d.cash_type AS cashType,
d.user_memo AS userMemo,
d.amount,
d.flag AS direction,
d.family_relation_type AS familyRelationType,
from_subject.object_key AS fromObjectKey,
to_subject.object_key AS toObjectKey,
CONCAT('idno_node/', from_subject.object_key) AS fromKey,
CONCAT('idno_node/', to_subject.object_key) AS toKey,
from_subject.name AS fromName,
to_subject.name AS toName,
from_subject.idnocfno AS fromIdNo,
to_subject.idnocfno AS toIdNo
FROM lx_fund_flow_detail_edge d
INNER JOIN lx_fund_flow_own_account_edge from_own
ON from_own.to_key = d.from_key
INNER JOIN lx_fund_flow_subject_node from_subject
ON CONCAT('idno_node/', from_subject.object_key) = from_own.from_key
INNER JOIN lx_fund_flow_own_account_edge to_own
ON to_own.to_key = d.to_key
INNER JOIN lx_fund_flow_subject_node to_subject
ON CONCAT('idno_node/', to_subject.object_key) = to_own.from_key
WHERE 1 = 1
<if test="query.objectKey != null and query.objectKey != ''">
AND (
from_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
OR to_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
)
</if>
<include refid="detailFilter"/>
</sql>
<select id="selectFundGraphSubjects" resultMap="FundGraphNodeResultMap">
SELECT
n.object_key AS objectKey,
CONCAT('idno_node/', n.object_key) AS nodeKey,
n.name AS nodeName,
n.idnocfno AS idNo,
n.cinocsno AS cinocsno,
n.idno_type AS idnoType,
n.staff_id AS staffId,
n.source_type AS sourceType,
CASE
WHEN n.idno_type = 'NAME_PROXY' OR n.source_type LIKE '%COUNTERPARTY%' THEN 'PROXY'
ELSE 'PERSON'
END AS nodeType,
CASE
WHEN n.idnocfno IS NOT NULL AND TRIM(n.idnocfno) != '' THEN 'IDNO'
ELSE 'NAME'
END AS identityType,
CASE
WHEN n.source_type LIKE 'GRAPH_TEST_FAMILY_%' THEN REPLACE(n.source_type, 'GRAPH_TEST_FAMILY_', '')
ELSE NULL
END AS relationType,
CASE
WHEN EXISTS (
SELECT 1
FROM lx_fund_flow_own_account_edge own
WHERE own.from_key = CONCAT('idno_node/', n.object_key)
) THEN 1
ELSE 0
END AS canExpand,
(
SELECT COUNT(1)
FROM lx_fund_flow_own_account_edge own_count
WHERE own_count.from_key = CONCAT('idno_node/', n.object_key)
) AS accountCount,
DATE_FORMAT(n.created_time, '%Y-%m-%d %H:%i:%s') AS createdTime,
DATE_FORMAT(n.updated_time, '%Y-%m-%d %H:%i:%s') AS updatedTime,
0 AS depth,
0 AS totalAmount,
0 AS transactionCount
FROM lx_fund_flow_subject_node n
WHERE 1 = 1
<if test="query.objectKey != null and query.objectKey != ''">
AND n.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
</if>
<if test="query.objectKey == null or query.objectKey == ''">
<if test="query.keyword != null and query.keyword != ''">
AND (
n.idnocfno = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
OR n.name LIKE (CONCAT('%', TRIM(#{query.keyword}), '%') COLLATE utf8mb4_general_ci)
OR n.object_key = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
)
</if>
</if>
ORDER BY
CASE
WHEN n.idnocfno = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 0
WHEN n.object_key = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 1
WHEN n.staff_id IS NOT NULL AND TRIM(n.staff_id) != '' THEN 2
WHEN UPPER(IFNULL(n.source_type, '')) LIKE '%EMPLOYEE%' THEN 2
WHEN n.source_type LIKE '%员工%' THEN 2
ELSE 3
END,
n.name
LIMIT
<choose>
<when test="query.limit != null and query.limit > 0">
#{query.limit}
</when>
<otherwise>
20
</otherwise>
</choose>
</select>
<select id="selectFundGraphEdges" resultMap="FundGraphEdgeResultMap">
SELECT
MD5(CONCAT(graph_rows.fromKey, '|', graph_rows.toKey, '|', graph_rows.direction, '|', IFNULL(graph_rows.familyRelationType, ''))) AS edgeKey,
graph_rows.fromKey,
graph_rows.toKey,
graph_rows.fromObjectKey,
graph_rows.toObjectKey,
MAX(graph_rows.fromName) AS fromName,
MAX(graph_rows.toName) AS toName,
SUM(graph_rows.amount) AS totalAmount,
COUNT(1) AS transactionCount,
MIN(graph_rows.trxDate) AS firstTrxDate,
MAX(graph_rows.trxDate) AS lastTrxDate,
graph_rows.direction,
graph_rows.familyRelationType,
'BANK' AS sourceType,
NULL AS relationDesc,
NULL AS sourceDesc,
NULL AS remark,
1 AS depth,
CASE
WHEN MAX(
CASE
WHEN graph_rows.fromObjectKey = #{query.objectKey} THEN
CASE
WHEN graph_rows.toIdNo IS NOT NULL AND TRIM(graph_rows.toIdNo) != '' THEN 1
ELSE 0
END
ELSE
CASE
WHEN graph_rows.fromIdNo IS NOT NULL AND TRIM(graph_rows.fromIdNo) != '' THEN 1
ELSE 0
END
END
) = 1 THEN 1
WHEN EXISTS (
SELECT 1
FROM lx_fund_flow_own_account_edge own
WHERE own.from_key = CASE
WHEN graph_rows.fromObjectKey = #{query.objectKey}
THEN graph_rows.toKey
ELSE graph_rows.fromKey
END
) THEN 1
ELSE 0
END AS canTrace
FROM (
<include refid="subjectJoinRows"/>
) graph_rows
WHERE 1 = 1
GROUP BY
graph_rows.fromKey,
graph_rows.toKey,
graph_rows.fromObjectKey,
graph_rows.toObjectKey,
graph_rows.direction,
graph_rows.familyRelationType
<if test="query.minTotalAmount != null">
HAVING SUM(graph_rows.amount) <![CDATA[ >= ]]> #{query.minTotalAmount}
</if>
ORDER BY totalAmount DESC, transactionCount DESC
LIMIT #{query.limit}
</select>
<select id="selectFundGraphManualEdges" resultMap="FundGraphEdgeResultMap">
SELECT
m.object_key AS edgeKey,
CONCAT('idno_node/', m.from_object_key) AS fromKey,
CONCAT('idno_node/', m.to_object_key) AS toKey,
m.from_object_key AS fromObjectKey,
m.to_object_key AS toObjectKey,
COALESCE(from_subject.name, m.from_name) AS fromName,
COALESCE(to_subject.name, m.to_name) AS toName,
m.amount AS totalAmount,
m.transaction_count AS transactionCount,
DATE_FORMAT(m.created_time, '%Y-%m-%d %H:%i:%s') AS firstTrxDate,
DATE_FORMAT(m.created_time, '%Y-%m-%d %H:%i:%s') AS lastTrxDate,
m.direction,
NULL AS familyRelationType,
m.source_type AS sourceType,
m.relation_desc AS relationDesc,
m.source_desc AS sourceDesc,
m.remark,
1 AS depth,
0 AS canTrace
FROM lx_fund_flow_manual_edge m
LEFT JOIN lx_fund_flow_subject_node from_subject
ON from_subject.object_key = m.from_object_key
LEFT JOIN lx_fund_flow_subject_node to_subject
ON to_subject.object_key = m.to_object_key
WHERE m.source_type = 'MANUAL'
AND (
m.from_object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
OR m.to_object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
)
ORDER BY m.updated_time DESC
LIMIT #{query.limit}
</select>
<select id="selectFundGraphEdgeDetails" resultMap="FundGraphStatementResultMap">
SELECT
graph_rows.bankStatementId,
graph_rows.trxDate,
graph_rows.leAccountNo,
graph_rows.leAccountName,
graph_rows.customerAccountName,
graph_rows.customerAccountNo,
graph_rows.cashType,
graph_rows.userMemo,
graph_rows.amount,
graph_rows.direction,
graph_rows.familyRelationType
FROM (
<include refid="subjectJoinRows"/>
) graph_rows
WHERE graph_rows.fromKey = (#{query.fromKey} COLLATE utf8mb4_general_ci)
AND graph_rows.toKey = (#{query.toKey} COLLATE utf8mb4_general_ci)
<if test="query.direction != null and query.direction != ''">
AND graph_rows.direction = (#{query.direction} COLLATE utf8mb4_general_ci)
</if>
ORDER BY graph_rows.trxDate DESC, graph_rows.bankStatementId DESC
</select>
<select id="countSubjectByObjectKey" resultType="int">
SELECT COUNT(1)
FROM lx_fund_flow_subject_node
WHERE object_key = (#{objectKey} COLLATE utf8mb4_general_ci)
</select>
<insert id="insertManualSubject">
INSERT IGNORE INTO lx_fund_flow_subject_node (
object_key,
idnocfno,
name,
idno_type,
source_type
) VALUES (
#{objectKey},
#{idNo},
#{name},
CASE
WHEN #{idNo} IS NULL OR TRIM(#{idNo}) = '' THEN 'NAME_PROXY'
ELSE 'PERSON'
END,
'MANUAL'
)
</insert>
<insert id="insertManualEdge">
INSERT INTO lx_fund_flow_manual_edge (
object_key,
from_object_key,
to_object_key,
from_name,
to_name,
amount,
transaction_count,
direction,
relation_desc,
source_desc,
remark,
source_type,
created_by,
updated_by
) VALUES (
#{objectKey},
#{dto.fromObjectKey},
#{dto.toObjectKey},
#{dto.fromName},
#{dto.toName},
#{dto.amount},
#{dto.transactionCount},
#{dto.direction},
#{dto.relationDesc},
#{dto.sourceDesc},
#{dto.remark},
'MANUAL',
#{operator},
#{operator}
)
</insert>
</mapper>

View File

@@ -46,6 +46,7 @@
<result property="displayAmount" column="displayAmount"/>
<result property="hasModelRuleHit" column="hasModelRuleHit"/>
<result property="hasNameListHit" column="hasNameListHit"/>
<result property="nameListHitType" column="nameListHitType"/>
</resultMap>
<resultMap id="AbnormalAccountItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO">
@@ -57,6 +58,40 @@
<result property="status" column="status"/>
</resultMap>
<resultMap id="ReportUploadSubjectResultMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO">
<result property="subjectName" column="subjectName"/>
<result property="accountNos" column="accountNos"/>
<result property="minTrxDate" column="minTrxDate"/>
<result property="maxTrxDate" column="maxTrxDate"/>
<result property="fileCount" column="fileCount"/>
</resultMap>
<resultMap id="ReportModelSummaryResultMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO">
<result property="modelCode" column="modelCode"/>
<result property="modelName" column="modelName"/>
<result property="warningCount" column="warningCount"/>
<result property="peopleCount" column="peopleCount"/>
<result property="peopleNames" column="peopleNames"/>
</resultMap>
<resultMap id="ReportSuspiciousTransactionResultMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO">
<id property="bankStatementId" column="bankStatementId"/>
<result property="trxDate" column="trxDate"/>
<result property="leAccountNo" column="leAccountNo"/>
<result property="leAccountName" column="leAccountName"/>
<result property="customerAccountName" column="customerAccountName"/>
<result property="customerAccountNo" column="customerAccountNo"/>
<result property="relatedStaffName" column="relatedStaffName"/>
<result property="relatedStaffCode" column="relatedStaffCode"/>
<result property="userMemo" column="userMemo"/>
<result property="cashType" column="cashType"/>
<result property="hitTags" column="hitTags"/>
<result property="displayAmount" column="displayAmount"/>
</resultMap>
<sql id="digitTableSql">
select 0 as digit
union all select 1
@@ -338,6 +373,87 @@
order by warning_count desc, model_code asc
</select>
<select id="selectReportUploadSubjects" resultMap="ReportUploadSubjectResultMap">
select
trim(bs.LE_ACCOUNT_NAME) as subjectName,
group_concat(distinct trim(bs.LE_ACCOUNT_NO) order by trim(bs.LE_ACCOUNT_NO) separator '、') as accountNos,
date_format(min(
case
when bs.TRX_DATE is null or trim(bs.TRX_DATE) = '' then null
when length(trim(bs.TRX_DATE)) = 10 then str_to_date(concat(trim(bs.TRX_DATE), ' 00:00:00'), '%Y-%m-%d %H:%i:%s')
else str_to_date(trim(bs.TRX_DATE), '%Y-%m-%d %H:%i:%s')
end
), '%Y-%m-%d') as minTrxDate,
date_format(max(
case
when bs.TRX_DATE is null or trim(bs.TRX_DATE) = '' then null
when length(trim(bs.TRX_DATE)) = 10 then str_to_date(concat(trim(bs.TRX_DATE), ' 00:00:00'), '%Y-%m-%d %H:%i:%s')
else str_to_date(trim(bs.TRX_DATE), '%Y-%m-%d %H:%i:%s')
end
), '%Y-%m-%d') as maxTrxDate,
case
when count(distinct fur.id) > 0 then count(distinct fur.id)
else count(distinct bs.batch_id)
end as fileCount
from ccdi_bank_statement bs
left join ccdi_file_upload_record fur
on fur.project_id = bs.project_id
and fur.log_id = bs.batch_id
where bs.project_id = #{projectId}
and bs.LE_ACCOUNT_NAME is not null
and trim(bs.LE_ACCOUNT_NAME) != ''
group by trim(bs.LE_ACCOUNT_NAME)
order by trim(bs.LE_ACCOUNT_NAME) asc
</select>
<select id="selectReportRiskModelSummaries" resultMap="ReportModelSummaryResultMap">
select
model_scope.modelCode,
max(model_scope.modelName) as modelName,
sum(model_scope.warningCount) as warningCount,
count(distinct model_scope.staffIdCard) as peopleCount,
group_concat(distinct model_scope.staffName order by model_scope.staffName separator '、') as peopleNames
from (
select
result.staff_id_card as staffIdCard,
result.staff_name as staffName,
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelCode'))) as modelCode,
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelName'))) as modelName,
cast(json_unquote(json_extract(
result.model_hit_summary_json,
concat('$[', idx.idx, '].warningCount')
)) as unsigned) as warningCount
from ccdi_project_overview_employee_result result
join (
<include refid="jsonArrayIndexSql"/>
) idx on idx.idx &lt; json_length(result.model_hit_summary_json)
where result.project_id = #{projectId}
) model_scope
where model_scope.modelCode is not null
group by model_scope.modelCode
order by warningCount desc, model_scope.modelCode asc
</select>
<select id="selectReportRiskPeople" resultMap="RiskModelPeopleItemResultMap">
select
result.project_id,
result.staff_id_card,
result.staff_name,
result.staff_code,
result.dept_name as department,
null as selected_model_codes
from ccdi_project_overview_employee_result result
where result.project_id = #{projectId}
order by case result.risk_level_code
when 'HIGH' then 1
when 'MEDIUM' then 2
else 3
end,
result.model_count desc,
result.rule_count desc,
result.staff_id_card asc
</select>
<select id="selectRiskModelPeoplePage" resultMap="RiskModelPeopleItemResultMap">
<bind name="projectId" value="query.projectId"/>
select
@@ -415,57 +531,70 @@
from ccdi_bank_statement_tag_result tr
where tr.project_id = #{query.projectId}
and tr.bank_statement_id is not null
and tr.rule_name like '%可疑%'
</sql>
<sql id="suspiciousTransactionNameHitSql">
select
hits.bankStatementId,
hits.suspiciousPersonName,
hits.matchPriority
hits.matchPriority,
hits.nameListHitType
from (
select
bs.bank_statement_id as bankStatementId,
intermediary.name as suspiciousPersonName,
1 as matchPriority
coalesce(credit_customer.name, account.account_name, '信贷客户账号') as suspiciousPersonName,
1 as matchPriority,
'信贷客户' as nameListHitType
from ccdi_bank_statement bs
inner join ccdi_biz_intermediary intermediary
on trim(bs.customer_cert_no) != ''
and intermediary.person_id = bs.customer_cert_no
inner join ccdi_account_info account
on trim(bs.customer_account_no) != ''
and account.owner_type = 'CREDIT_CUSTOMER'
and account.account_no = bs.customer_account_no
left join ccdi_credit_customer_base credit_customer
on credit_customer.person_id = account.owner_id
where bs.project_id = #{query.projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id as bankStatementId,
enterprise.enterprise_name as suspiciousPersonName,
2 as matchPriority
coalesce(intermediary.name, enterprise.enterprise_name, account.account_name, '中介账号') as suspiciousPersonName,
2 as matchPriority,
'中介' as nameListHitType
from ccdi_bank_statement bs
inner join ccdi_enterprise_base_info enterprise
on trim(bs.customer_social_credit_code) != ''
and enterprise.social_credit_code = bs.customer_social_credit_code
and enterprise.risk_level = '1'
and enterprise.ent_source = 'INTERMEDIARY'
inner join ccdi_account_info account
on trim(bs.customer_account_no) != ''
and account.owner_type = 'INTERMEDIARY'
and account.account_no = bs.customer_account_no
left join ccdi_biz_intermediary intermediary
on intermediary.person_id = account.owner_id
left join ccdi_enterprise_base_info enterprise
on enterprise.social_credit_code = account.owner_id
where bs.project_id = #{query.projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id as bankStatementId,
intermediary.name as suspiciousPersonName,
3 as matchPriority
3 as matchPriority,
'中介' as nameListHitType
from ccdi_bank_statement bs
inner join ccdi_biz_intermediary intermediary
on trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
and intermediary.name = bs.CUSTOMER_ACCOUNT_NAME
where bs.project_id = #{query.projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
union all
select
bs.bank_statement_id as bankStatementId,
enterprise.enterprise_name as suspiciousPersonName,
3 as matchPriority
4 as matchPriority,
'中介' as nameListHitType
from ccdi_bank_statement bs
inner join ccdi_enterprise_base_info enterprise
on trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
@@ -473,6 +602,7 @@
and enterprise.risk_level = '1'
and enterprise.ent_source = 'INTERMEDIARY'
where bs.project_id = #{query.projectId}
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
) hits
</sql>
@@ -490,7 +620,8 @@
1 as hasModelRuleHit,
0 as hasNameListHit,
null as suspiciousPersonName,
null as matchPriority
null as matchPriority,
null as nameListHitType
from (
<include refid="suspiciousTransactionBaseSql"/>
) base
@@ -513,7 +644,8 @@
0 as hasModelRuleHit,
1 as hasNameListHit,
name_hits.suspiciousPersonName,
name_hits.matchPriority
name_hits.matchPriority,
name_hits.nameListHitType
from (
<include refid="suspiciousTransactionBaseSql"/>
) base
@@ -548,7 +680,18 @@
max(merged.cashType) as cashType,
max(merged.displayAmount) as displayAmount,
max(merged.hasModelRuleHit) as hasModelRuleHit,
max(merged.hasNameListHit) as hasNameListHit
max(merged.hasNameListHit) as hasNameListHit,
substring_index(
min(
case
when merged.nameListHitType is not null and merged.nameListHitType != ''
then concat(lpad(merged.matchPriority, 2, '0'), '|', merged.nameListHitType)
else null
end
),
'|',
-1
) as nameListHitType
from (
<include refid="suspiciousTransactionMergedSql"/>
) merged
@@ -570,7 +713,7 @@
</sql>
<select id="selectSuspiciousTransactionPage" resultMap="SuspiciousTransactionItemResultMap">
<!-- rule_name like '%可疑%' -->
<!-- ccdi_bank_statement_tag_result -->
<!-- ccdi_biz_intermediary -->
<!-- ccdi_enterprise_base_info -->
<!-- group by merged.bankStatementId -->
@@ -586,7 +729,8 @@
final_result.cashType,
final_result.displayAmount,
final_result.hasModelRuleHit,
final_result.hasNameListHit
final_result.hasNameListHit,
final_result.nameListHitType
from (
<include refid="suspiciousTransactionAggregatedSql"/>
) final_result
@@ -607,7 +751,8 @@
final_result.cashType,
final_result.displayAmount,
final_result.hasModelRuleHit,
final_result.hasNameListHit
final_result.hasNameListHit,
final_result.nameListHitType
from (
<include refid="suspiciousTransactionAggregatedSql"/>
) final_result
@@ -615,6 +760,52 @@
order by final_result.trxDate desc, final_result.bankStatementId desc
</select>
<select id="selectReportSuspiciousTransactionList" resultMap="ReportSuspiciousTransactionResultMap">
select
final_result.bankStatementId,
final_result.trxDate,
bs.LE_ACCOUNT_NO as leAccountNo,
bs.LE_ACCOUNT_NAME as leAccountName,
bs.CUSTOMER_ACCOUNT_NAME as customerAccountName,
bs.CUSTOMER_ACCOUNT_NO as customerAccountNo,
final_result.relatedStaffName,
final_result.relatedStaffCode,
final_result.userMemo,
final_result.cashType,
case
when final_result.nameListHitType = '中介' then
replace(
replace(ifnull(tag_result.hitTags, ''), '与客户之间非正常资金往来', '疑似与中介往来'),
'异常交易',
'疑似与中介往来'
)
when final_result.nameListHitType = '信贷客户' then
replace(
replace(ifnull(tag_result.hitTags, ''), '与客户之间非正常资金往来', '与信贷客户之间非正常资金往来'),
'异常交易',
'与信贷客户之间非正常资金往来'
)
else tag_result.hitTags
end as hitTags,
final_result.displayAmount
from (
<include refid="suspiciousTransactionAggregatedSql"/>
) final_result
inner join ccdi_bank_statement bs
on bs.bank_statement_id = final_result.bankStatementId
left join (
select
tr.bank_statement_id,
group_concat(distinct tr.rule_name order by tr.id separator '、') as hitTags
from ccdi_bank_statement_tag_result tr
where tr.project_id = #{query.projectId}
and tr.bank_statement_id is not null
group by tr.bank_statement_id
) tag_result on tag_result.bank_statement_id = final_result.bankStatementId
<include refid="suspiciousTransactionFilterSql"/>
order by final_result.trxDate desc, final_result.bankStatementId desc
</select>
<select id="selectEmployeeCreditNegativePage"
resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO">
select

View File

@@ -0,0 +1,330 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiRelationGraphMapper">
<resultMap id="RelationGraphNodeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO">
<id property="objectKey" column="objectKey"/>
<result property="nodeKey" column="nodeKey"/>
<result property="nodeName" column="nodeName"/>
<result property="idNumber" column="idNumber"/>
<result property="subjectType" column="subjectType"/>
<result property="sourceType" column="sourceType"/>
<result property="createdTime" column="createdTime"/>
<result property="updatedTime" column="updatedTime"/>
<result property="canExpand" column="canExpand"/>
<result property="depth" column="depth"/>
</resultMap>
<resultMap id="RelationGraphEdgeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO">
<id property="objectKey" column="objectKey"/>
<result property="fromKey" column="fromKey"/>
<result property="toKey" column="toKey"/>
<result property="edgeTable" column="edgeTable"/>
<result property="relationType" column="relationType"/>
<result property="companyName" column="companyName"/>
<result property="stockName" column="stockName"/>
<result property="stockType" column="stockType"/>
<result property="stockPercent" column="stockPercent"/>
<result property="shouldCapi" column="shouldCapi"/>
<result property="shouldCapiValue" column="shouldCapiValue"/>
<result property="shouldCapiUnit" column="shouldCapiUnit"/>
<result property="shoudDate" column="shoudDate"/>
<result property="pKeyNo" column="pKeyNo"/>
<result property="operName" column="operName"/>
<result property="operKeyNo" column="operKeyNo"/>
<result property="personId" column="personId"/>
<result property="relationName" column="relationName"/>
<result property="relationCertNo" column="relationCertNo"/>
<result property="gender" column="gender"/>
<result property="birthDate" column="birthDate"/>
<result property="relationCertType" column="relationCertType"/>
<result property="mobilePhone1" column="mobilePhone1"/>
<result property="mobilePhone2" column="mobilePhone2"/>
<result property="wechatNo1" column="wechatNo1"/>
<result property="wechatNo2" column="wechatNo2"/>
<result property="wechatNo3" column="wechatNo3"/>
<result property="contactAddress" column="contactAddress"/>
<result property="annualIncome" column="annualIncome"/>
<result property="relationDesc" column="relationDesc"/>
<result property="status" column="status"/>
<result property="effectiveDate" column="effectiveDate"/>
<result property="invalidDate" column="invalidDate"/>
<result property="remark" column="remark"/>
<result property="dataSource" column="dataSource"/>
</resultMap>
<resultMap id="SuspectedEnterpriseResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO">
<result property="candidateKeyNo" column="candidateKeyNo"/>
<result property="personName" column="personName"/>
<result property="companyId" column="companyId"/>
<result property="companyName" column="companyName"/>
<result property="creditCode" column="creditCode"/>
<result property="enterpriseStatus" column="enterpriseStatus"/>
<result property="industryName" column="industryName"/>
<result property="relationType" column="relationType"/>
<result property="stockPercent" column="stockPercent"/>
<result property="establishDate" column="establishDate"/>
</resultMap>
<sql id="nodeColumns">
n.object_key AS objectKey,
CONCAT('rel_node/', n.object_key) AS nodeKey,
n.node_name AS nodeName,
n.id_number AS idNumber,
n.subject_type AS subjectType,
n.source_type AS sourceType,
DATE_FORMAT(n.created_time, '%Y-%m-%d %H:%i:%s') AS createdTime,
DATE_FORMAT(n.updated_time, '%Y-%m-%d %H:%i:%s') AS updatedTime,
1 AS canExpand,
0 AS depth
</sql>
<select id="selectRelationGraphSubjects" resultMap="RelationGraphNodeResultMap">
SELECT
<include refid="nodeColumns"/>
FROM lx_rel_node n
WHERE 1 = 1
<if test="query.objectKey != null and query.objectKey != ''">
AND n.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
</if>
<if test="query.objectKey == null or query.objectKey == ''">
<if test="query.keyword != null and query.keyword != ''">
AND (
n.object_key = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
OR n.id_number = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
OR n.node_name LIKE (CONCAT('%', TRIM(#{query.keyword}), '%') COLLATE utf8mb4_general_ci)
)
</if>
</if>
ORDER BY
CASE
WHEN n.object_key = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 0
WHEN n.id_number = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 1
ELSE 2
END,
n.node_name
LIMIT 20
</select>
<select id="selectRelationGraphNodesByKeys" resultMap="RelationGraphNodeResultMap">
SELECT
<include refid="nodeColumns"/>
FROM lx_rel_node n
WHERE n.object_key IN
<foreach collection="objectKeys" item="objectKey" open="(" separator="," close=")">
#{objectKey}
</foreach>
</select>
<select id="selectRelationGraphEdges" resultMap="RelationGraphEdgeResultMap">
SELECT *
FROM (
SELECT
e.object_key AS objectKey,
e.from_key AS fromKey,
e.to_key AS toKey,
'lx_rel_family_edge' AS edgeTable,
e.relation_type AS relationType,
NULL AS companyName,
NULL AS stockName,
NULL AS stockType,
NULL AS stockPercent,
NULL AS shouldCapi,
NULL AS shouldCapiValue,
NULL AS shouldCapiUnit,
NULL AS shoudDate,
NULL AS pKeyNo,
NULL AS operName,
NULL AS operKeyNo,
e.person_id AS personId,
e.relation_name AS relationName,
e.relation_cert_no AS relationCertNo,
NULL AS gender,
NULL AS birthDate,
NULL AS relationCertType,
NULL AS mobilePhone1,
NULL AS mobilePhone2,
NULL AS wechatNo1,
NULL AS wechatNo2,
NULL AS wechatNo3,
NULL AS contactAddress,
NULL AS annualIncome,
e.relation_desc AS relationDesc,
NULL AS status,
NULL AS effectiveDate,
NULL AS invalidDate,
NULL AS remark,
NULL AS dataSource
FROM lx_rel_family_edge e
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
UNION ALL
SELECT
e.object_key AS objectKey,
e.from_key AS fromKey,
e.to_key AS toKey,
'lx_rel_stock_edge' AS edgeTable,
e.stock_type AS relationType,
e.company_name AS companyName,
e.stock_name AS stockName,
e.stock_type AS stockType,
e.stock_percent AS stockPercent,
e.should_capi AS shouldCapi,
e.should_capi_value AS shouldCapiValue,
e.should_capi_unit AS shouldCapiUnit,
e.shoud_date AS shoudDate,
e.p_key_no AS pKeyNo,
NULL AS operName,
NULL AS operKeyNo,
NULL AS personId,
NULL AS relationName,
NULL AS relationCertNo,
NULL AS gender,
NULL AS birthDate,
NULL AS relationCertType,
NULL AS mobilePhone1,
NULL AS mobilePhone2,
NULL AS wechatNo1,
NULL AS wechatNo2,
NULL AS wechatNo3,
NULL AS contactAddress,
NULL AS annualIncome,
NULL AS relationDesc,
NULL AS status,
NULL AS effectiveDate,
NULL AS invalidDate,
NULL AS remark,
NULL AS dataSource
FROM lx_rel_stock_edge e
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
UNION ALL
SELECT
e.object_key AS objectKey,
e.from_key AS fromKey,
e.to_key AS toKey,
'lx_rel_represent_edge' AS edgeTable,
'法定代表人' AS relationType,
NULL AS companyName,
NULL AS stockName,
NULL AS stockType,
NULL AS stockPercent,
NULL AS shouldCapi,
NULL AS shouldCapiValue,
NULL AS shouldCapiUnit,
NULL AS shoudDate,
NULL AS pKeyNo,
e.oper_name AS operName,
e.oper_key_no AS operKeyNo,
NULL AS personId,
NULL AS relationName,
NULL AS relationCertNo,
NULL AS gender,
NULL AS birthDate,
NULL AS relationCertType,
NULL AS mobilePhone1,
NULL AS mobilePhone2,
NULL AS wechatNo1,
NULL AS wechatNo2,
NULL AS wechatNo3,
NULL AS contactAddress,
NULL AS annualIncome,
NULL AS relationDesc,
NULL AS status,
NULL AS effectiveDate,
NULL AS invalidDate,
NULL AS remark,
NULL AS dataSource
FROM lx_rel_represent_edge e
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
) graph_edges
ORDER BY
CASE edgeTable
WHEN 'lx_rel_family_edge' THEN 0
WHEN 'lx_rel_stock_edge' THEN 1
ELSE 2
END,
relationType,
objectKey
LIMIT #{query.limit}
</select>
<select id="countSuspectedEnterpriseKeyNos" resultType="int">
SELECT COUNT(DISTINCT candidate_key_no)
FROM (
SELECT e.oper_key_no AS candidate_key_no
FROM lx_rel_represent_edge e
WHERE e.oper_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.oper_key_no IS NOT NULL
AND e.oper_key_no != ''
UNION ALL
SELECT e.p_key_no AS candidate_key_no
FROM lx_rel_stock_edge e
WHERE e.stock_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.stock_type = '自然人股东'
AND e.p_key_no IS NOT NULL
AND e.p_key_no != ''
) same_name_candidates
</select>
<select id="selectSuspectedEnterprises" resultMap="SuspectedEnterpriseResultMap">
SELECT *
FROM (
SELECT
e.oper_key_no AS candidateKeyNo,
e.oper_name AS personName,
REPLACE(e.to_key, 'rel_node/', '') AS companyId,
COALESCE(ent.enterprise_name, company_node.node_name) AS companyName,
COALESCE(ent.social_credit_code, company_node.id_number) AS creditCode,
ent.status AS enterpriseStatus,
ent.industry_name AS industryName,
'法定代表人' AS relationType,
NULL AS stockPercent,
DATE_FORMAT(ent.establish_date, '%Y-%m-%d') AS establishDate,
0 AS relationSort
FROM lx_rel_represent_edge e
LEFT JOIN lx_rel_node company_node
ON company_node.object_key = REPLACE(e.to_key, 'rel_node/', '')
LEFT JOIN ccdi_enterprise_base_info ent
ON ent.social_credit_code = company_node.id_number
WHERE e.oper_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.oper_key_no IS NOT NULL
AND e.oper_key_no != ''
UNION ALL
SELECT
e.p_key_no AS candidateKeyNo,
e.stock_name AS personName,
REPLACE(e.to_key, 'rel_node/', '') AS companyId,
COALESCE(ent.enterprise_name, e.company_name, company_node.node_name) AS companyName,
COALESCE(ent.social_credit_code, company_node.id_number) AS creditCode,
ent.status AS enterpriseStatus,
ent.industry_name AS industryName,
e.stock_type AS relationType,
e.stock_percent AS stockPercent,
COALESCE(DATE_FORMAT(ent.establish_date, '%Y-%m-%d'), e.shoud_date) AS establishDate,
1 AS relationSort
FROM lx_rel_stock_edge e
LEFT JOIN lx_rel_node company_node
ON company_node.object_key = REPLACE(e.to_key, 'rel_node/', '')
LEFT JOIN ccdi_enterprise_base_info ent
ON ent.social_credit_code = company_node.id_number
WHERE e.stock_name = (#{personName} COLLATE utf8mb4_general_ci)
AND e.stock_type = '自然人股东'
AND e.p_key_no IS NOT NULL
AND e.p_key_no != ''
) suspected_enterprises
ORDER BY relationSort, companyName, companyId
LIMIT #{limit}
</select>
</mapper>

View File

@@ -13,6 +13,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -181,6 +182,27 @@ class CcdiProjectOverviewControllerContractTest {
assertNotNull(operation);
}
@Test
void shouldExposeOverviewReportExportEndpointContract() throws Exception {
Class<?> controllerClass = Class.forName("com.ruoyi.ccdi.project.controller.CcdiProjectOverviewController");
Method method = controllerClass.getMethod(
"exportOverviewReport",
HttpServletResponse.class,
Long.class
);
RequestMapping requestMapping = method.getAnnotation(RequestMapping.class);
Operation operation = method.getAnnotation(Operation.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
assertNotNull(requestMapping);
assertEquals("/report/export", requestMapping.value()[0]);
assertEquals(List.of(RequestMethod.GET, RequestMethod.POST), Arrays.asList(requestMapping.method()));
assertNotNull(operation);
assertEquals("一键导出结果总览报告", operation.summary());
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:query')", preAuthorize.value());
}
@Test
void shouldExposeSuspiciousTransactionsQueryDtoFields() throws Exception {
Class<?> dtoClass = Class.forName("com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO");

View File

@@ -100,7 +100,7 @@ class CcdiBankTagAnalysisMapperXmlTest {
void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception {
String xml = readXml(RESOURCE);
assertTrue(xml.contains("占位SQL待补充真实规则"));
assertEquals(6, countMatches(xml, "where 1 = 0"));
assertEquals(5, countMatches(xml, "where 1 = 0"));
}
@Test
@@ -116,6 +116,31 @@ class CcdiBankTagAnalysisMapperXmlTest {
}
}
@Test
void lowIncomeRelativeRule_shouldIgnoreNullAnnualIncome() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectLowIncomeRelativeLargeTransactionObjects");
assertTrue(selectSql.contains("relation.annual_income is not null"));
assertTrue(!selectSql.contains("relation.annual_income is null"));
}
@Test
void abnormalCustomerTransactionRule_shouldUseCreditCustomerAndIntermediaryAccountRules() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectAbnormalCustomerTransactionStatements");
assertTrue(selectSql.contains("account.owner_type = 'CREDIT_CUSTOMER'"));
assertTrue(selectSql.contains("account.owner_type = 'INTERMEDIARY'"));
assertTrue(selectSql.contains("account.account_no = bs.customer_account_no"));
assertTrue(selectSql.contains("enterprise.ent_source = 'INTERMEDIARY'"));
assertTrue(selectSql.contains("intermediary.name = bs.CUSTOMER_ACCOUNT_NAME"));
assertTrue(selectSql.contains("bs.CUSTOMER_ACCOUNT_NAME like concat('%', intermediary.name, '%')"));
assertTrue(selectSql.contains("bs.bank in ('ALIPAY', 'WECHAT')"));
assertEquals(5, countMatches(selectSql, "GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000"));
assertTrue(!selectSql.contains("customer_cert_no"));
assertTrue(!selectSql.contains("social_credit_code = bs"));
}
@Test
void withdrawCntObjectRule_shouldUseRealSqlAndKeepObjectHitFields() throws Exception {
String xml = readXml(RESOURCE);

View File

@@ -90,13 +90,50 @@ class CcdiProjectOverviewMapperSqlTest {
void shouldExposeSuspiciousTransactionAggregationQuery() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String suspiciousSql = extractSelect(xml, "selectSuspiciousTransactionPage");
String modelHitSql = extractSqlFragment(xml, "suspiciousTransactionModelHitSql");
String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql");
assertTrue(suspiciousSql.contains("rule_name like '%可疑%'"), suspiciousSql);
assertTrue(modelHitSql.contains("from ccdi_bank_statement_tag_result tr"), modelHitSql);
assertTrue(modelHitSql.contains("tr.bank_statement_id is not null"), modelHitSql);
assertFalse(modelHitSql.contains("rule_name like '%可疑%'"), modelHitSql);
assertFalse(modelHitSql.contains("ABNORMAL_CUSTOMER_TRANSACTION"), modelHitSql);
assertTrue(suspiciousSql.contains("ccdi_biz_intermediary"), suspiciousSql);
assertTrue(suspiciousSql.contains("ccdi_enterprise_base_info"), suspiciousSql);
assertTrue(suspiciousSql.contains("group by merged.bankStatementId"), suspiciousSql);
assertTrue(aggregatedSql.contains("lpad(merged.matchPriority, 2, '0')"), aggregatedSql);
assertTrue(suspiciousSql.contains("hasModelRuleHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("hasNameListHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("final_result.nameListHitType"), suspiciousSql);
String reportSuspiciousSql = extractSelect(xml, "selectReportSuspiciousTransactionList");
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '中介'"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("疑似与中介往来"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '信贷客户'"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("与信贷客户之间非正常资金往来"), reportSuspiciousSql);
}
@Test
void suspiciousTransactionNameListSql_shouldKeepCreditCustomerAndIntermediaryRulesScopedByAmount() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String nameHitSql = extractSqlFragment(xml, "suspiciousTransactionNameHitSql");
String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql");
assertTrue(nameHitSql.contains("account.owner_type = 'CREDIT_CUSTOMER'"), nameHitSql);
assertTrue(nameHitSql.contains("account.owner_type = 'INTERMEDIARY'"), nameHitSql);
assertTrue(nameHitSql.contains("account.account_no = bs.customer_account_no"), nameHitSql);
assertTrue(nameHitSql.contains("'信贷客户' as nameListHitType"), nameHitSql);
assertTrue(nameHitSql.contains("'中介' as nameListHitType"), nameHitSql);
assertTrue(nameHitSql.contains("intermediary.name = bs.CUSTOMER_ACCOUNT_NAME"), nameHitSql);
assertTrue(nameHitSql.contains("enterprise.ent_source = 'INTERMEDIARY'"), nameHitSql);
assertTrue(
nameHitSql.contains("GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000"),
nameHitSql
);
assertFalse(nameHitSql.contains("customer_cert_no"), nameHitSql);
assertFalse(nameHitSql.contains("social_credit_code = bs"), nameHitSql);
assertTrue(aggregatedSql.contains("group by merged.bankStatementId"), aggregatedSql);
assertTrue(aggregatedSql.contains("max(merged.hasModelRuleHit) as hasModelRuleHit"), aggregatedSql);
assertTrue(aggregatedSql.contains("max(merged.hasNameListHit) as hasNameListHit"), aggregatedSql);
}
@Test
@@ -159,4 +196,13 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(endIndex >= 0, "missing closing select tag: " + selectId);
return xml.substring(startIndex, endIndex);
}
private String extractSqlFragment(String xml, String sqlId) {
String start = "<sql id=\"" + sqlId + "\"";
int startIndex = xml.indexOf(start);
assertTrue(startIndex >= 0, "missing sql fragment: " + sqlId);
int endIndex = xml.indexOf("</sql>", startIndex);
assertTrue(endIndex >= 0, "missing closing sql tag: " + sqlId);
return xml.substring(startIndex, endIndex);
}
}

View File

@@ -0,0 +1,169 @@
package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewReportPdfExporterTest {
@Test
void shouldExportOverviewReportPdf() throws Exception {
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter();
MockHttpServletResponse response = new MockHttpServletResponse();
exporter.export(response, buildReport());
assertEquals("application/pdf", response.getContentType());
String header = new String(response.getContentAsByteArray(), 0, 4, StandardCharsets.ISO_8859_1);
assertEquals("%PDF", header);
assertTrue(response.getContentAsByteArray().length > 1000);
}
@Test
void tableGap_shouldLeaveEnoughSpaceForNextSectionTitle() throws Exception {
Class<?> writerClass = Class.forName(
"com.ruoyi.ccdi.project.service.impl.CcdiProjectOverviewReportPdfExporter$PdfPageWriter"
);
float tableAfterGap = readPrivateFloat(writerClass, "TABLE_AFTER_GAP");
float sectionFontSize = readPrivateFloat(writerClass, "SECTION_FONT_SIZE");
assertTrue(tableAfterGap > sectionFontSize);
}
private CcdiProjectOverviewReportVO buildReport() {
CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO();
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setProjectName("导入测试");
report.setProject(project);
report.setDashboard(buildDashboard());
report.setUploadSubjects(List.of(buildUploadSubject()));
report.setParams(List.of(buildParam()));
report.setModelSummaries(List.of(buildModelSummary()));
report.setRiskPeople(List.of(buildRiskPeople()));
report.setSuspiciousTransactions(List.of(buildSuspiciousTransaction()));
report.setIllegalPeople(List.of(buildIllegalPerson()));
report.setAbnormalAccounts(List.of(buildAbnormalAccount()));
return report;
}
private float readPrivateFloat(Class<?> clazz, String fieldName) throws Exception {
java.lang.reflect.Field field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
return field.getFloat(null);
}
private CcdiProjectOverviewDashboardVO buildDashboard() {
CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO();
dashboard.setStats(List.of(
buildStat("总人数", 10),
buildStat("高风险", 2),
buildStat("中风险", 3),
buildStat("低风险", 1),
buildStat("无风险", 4)
));
return dashboard;
}
private CcdiProjectOverviewStatVO buildStat(String label, Integer value) {
CcdiProjectOverviewStatVO stat = new CcdiProjectOverviewStatVO();
stat.setLabel(label);
stat.setValue(value);
return stat;
}
private CcdiProjectOverviewReportUploadSubjectVO buildUploadSubject() {
CcdiProjectOverviewReportUploadSubjectVO row = new CcdiProjectOverviewReportUploadSubjectVO();
row.setSubjectName("测试主体");
row.setAccountNos("6222000000000001、6222000000000002");
row.setDataPeriod("3个月");
row.setFileCount(2);
return row;
}
private CcdiProjectOverviewReportParamVO buildParam() {
CcdiProjectOverviewReportParamVO row = new CcdiProjectOverviewReportParamVO();
row.setModelName("大额交易模型");
row.setParamName("单笔大额转账金额");
row.setParamValue("5000");
row.setParamUnit("");
row.setParamDesc("单笔转账金额超过");
return row;
}
private CcdiProjectOverviewReportModelSummaryVO buildModelSummary() {
CcdiProjectOverviewReportModelSummaryVO row = new CcdiProjectOverviewReportModelSummaryVO();
row.setModelName("大额交易");
row.setWarningCount(6);
row.setPeopleCount(2);
row.setPeopleNames("张三、李四");
return row;
}
private CcdiProjectRiskModelPeopleItemVO buildRiskPeople() {
CcdiProjectRiskModelPeopleItemVO row = new CcdiProjectRiskModelPeopleItemVO();
row.setStaffName("张三");
row.setStaffCode("1001");
row.setIdNo("330000000000000001");
row.setDepartment("财务部");
row.setModelNames(List.of("大额交易"));
CcdiProjectRiskHitTagVO tag = new CcdiProjectRiskHitTagVO();
tag.setRuleName("大额转账交易");
row.setHitTagList(List.of(tag));
return row;
}
private CcdiProjectOverviewReportSuspiciousTransactionVO buildSuspiciousTransaction() {
CcdiProjectOverviewReportSuspiciousTransactionVO row = new CcdiProjectOverviewReportSuspiciousTransactionVO();
row.setTrxDate("2026-03-20 10:00:00");
row.setLeAccountNo("6222000000000001");
row.setLeAccountName("测试主体");
row.setCustomerAccountNo("6222000000000002");
row.setCustomerAccountName("对方账户");
row.setRelatedStaffName("张三");
row.setRelatedStaffCode("1001");
row.setUserMemo("转账");
row.setCashType("支出");
row.setHitTags("大额转账交易");
row.setDisplayAmount(new BigDecimal("-5000.00"));
return row;
}
private CcdiProjectEmployeeCreditNegativeExcel buildIllegalPerson() {
CcdiProjectEmployeeCreditNegativeExcel row = new CcdiProjectEmployeeCreditNegativeExcel();
row.setPersonName("李四");
row.setPersonId("330000000000000002");
row.setQueryDate("2026-03-20");
row.setCivilCnt(1);
row.setCivilLmt(new BigDecimal("20000.00"));
return row;
}
private CcdiProjectAbnormalAccountExcel buildAbnormalAccount() {
CcdiProjectAbnormalAccountExcel row = new CcdiProjectAbnormalAccountExcel();
row.setAccountNo("6222000000000003");
row.setAccountName("王五");
row.setBankName("中国银行");
row.setAbnormalType("突然销户");
row.setAbnormalTime("2026-03-20");
row.setStatus("已销户");
return row;
}
}

View File

@@ -0,0 +1,26 @@
# Fund Graph Backend Implementation Plan
**目标:** 基于图谱结果表和手工补录边提供一期资金流图谱后端能力,支持按身份证号或员工姓名查询一层资金往来,并支持点击图谱边后分页查看该边每一笔流水。
**一期范围:**
- 搜索条件:`projectId` 保留为历史字段但不参与过滤,`keyword` 支持身份证号精确匹配、员工姓名匹配。
- 图谱范围:仅返回当前人员或姓名命中的直接对手方资金往来,不自动追溯二、三层。
- 边明细:按图谱边的 `fromKey/toKey` 查询每笔流水,保留交易时间、本方、对手方、摘要、金额、方向。
- 预留扩展DTO/VO 保留 `depth``canTrace``canExpand` 字段,后续可以扩展节点点击追溯。
**实现内容:**
- 新增 `CcdiFundGraphController`,暴露 `/ccdi/project/fund-graph/search``/ccdi/project/fund-graph/graph``/ccdi/project/fund-graph/edge-detail``/ccdi/project/fund-graph/manual-edge`
- 新增 `ICcdiFundGraphService``CcdiFundGraphServiceImpl`负责查询参数归一化、TopN 限制、节点构建和追溯字段赋值。
- 新增 `CcdiFundGraphMapper` 与 XML SQL基于 `lx_fund_flow_*` 图谱结果表查询主体、汇总边、逐笔流水,并支持手工边落库。
- 新增 DTO/VO`CcdiFundGraphQueryDTO``CcdiFundGraphEdgeDetailQueryDTO``CcdiFundGraphVO``CcdiFundGraphNodeVO``CcdiFundGraphEdgeVO``CcdiFundGraphStatementVO`
**数据口径:**
- 一期查询不按 `projectId` 过滤,统一按全局图谱关系查询。
- 资金边来自 `lx_fund_flow_detail_edge` 归并后的主体层汇总边,并叠加 `lx_fund_flow_manual_edge` 手工边。
- 图谱边按 `fromKey/toKey/direction/familyRelationType` 聚合,统计累计金额、交易笔数、首末交易时间。
- 手工边与真实边统一排序后再按 `limit` 截断,避免结果条数和排序口径不一致。
**后续追溯口子:**
- 当前接口接收 `depth` 但服务层固定为一期一层。
- 节点返回 `canExpand`,边返回 `canTrace`,后续可新增 `rootKey/currentNodeKey/depth` 参数做按节点展开。
- 一期 SQL 已把人员、企业、账号、名称代理做成统一节点键,后续多层追溯可沿用同一套节点键。

View File

@@ -0,0 +1,34 @@
# 员工信息维护与招聘信息管理正式化外壳前端实施计划
## 目标
- 基于当前本地最新代码,为 `ccdiBaseStaff``ccdiStaffRecruitment` 试套正式化外壳样式。
- 仅调整查询区、工具条、表格区、分页区与弹窗壳层视觉。
- 不改字段顺序、不改按钮位置、不改功能块结构。
## 范围
- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- `ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js`
- `ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js`
## 方案
- 复用现有 `app-container``query-form``mb8`、弹窗 class只补最少样式。
- 给列表区新增最小表格外壳,保证分页和表格归一。
- 通过边框、浅底、留白和表头背景统一正式化视觉。
## 验证
- `node tests/unit/base-staff-formal-shell-layout.test.js`
- `node tests/unit/staff-recruitment-formal-shell-layout.test.js`
- `node tests/unit/employee-asset-maintenance-layout.test.js`
- `node tests/unit/staff-recruitment-import-toolbar.test.js`
## 完成标准
- 两个页面外壳样式统一
- 按钮顺序和功能入口保持不变
- 单测通过
- 浏览器实测通过

View File

@@ -0,0 +1,65 @@
# 2026-04-29 批量正式化外壳样式实施计划
## 目标
- 基于当前本地最新前端代码,批量推进信息维护相关页面的正式化外壳样式。
- 严格保持“只改样式、不改内容和功能”的边界。
- 复用已经在详情弹窗、员工信息维护页、招聘信息管理页验证过的正式化样式骨架。
## 范围
- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
## 实施策略
### 1. 查询区统一正式化
- 保留原有查询字段、排布逻辑和按钮位置。
- 为查询区补统一白色面板、边框、克制圆角和更稳重的标签文字。
- 收紧表单项底部留白,统一输入框、下拉框、日期控件的边框和圆角。
### 2. 工具条统一正式化
- 保留搜索、重置、新增、导入、失败记录入口及其相对位置。
- 统一工具条外层白色承载区。
- 按钮仅调整圆角、边框与视觉重量,不改变语义和行为。
### 3. 表格承载区统一正式化
- 新增或复用 `formal-table-shell` 包裹列表表格与分页区。
- 收紧表头和行高,提升单屏信息密度。
- 主体文本尽量左对齐,保留选择列和操作列居中。
### 4. 弹窗与详情区统一正式化
- 统一弹窗圆角、头部下边线、正文浅底。
- 详情区、导入弹窗、编辑弹窗使用更克制的信息面板样式。
- 不重排现有字段,不新增删减交互块。
## 验证计划
- 复用现有样式契约单测,确保已完成页面没有回退。
- 使用浏览器打开真实业务路由进行验证,禁止使用 prototype 页面替代。
- 核对关键页面是否保持:
- 查询区与工具条仍在原位置
- 新增、导入、失败记录按钮仍按原顺序出现
- 表格列和弹窗内容结构不变
## 风险控制
- 不使用旧 patch 中的结构改法,只借用可复用的正式化视觉参数。
- 每个页面只处理最外层承载和控件外观,不触碰业务字段、接口、校验、按钮逻辑。
- 若真实页面路由可访问,则以真实页面结果为准;若不可访问,保留源码级验证说明。

View File

@@ -0,0 +1,56 @@
# 结果总览项目分析详情正式化外壳前端实施计划
## 目标
- 基于 `output/mockups/project-analysis-formal-soft-preview.html` 的静态预览稿,恢复“项目分析详情”弹窗的正式化、去卡片化外壳样式。
- 本次仅调整详情弹窗整体框架、标题区、左侧人物档案区、右侧主承载区与页签外层视觉。
- 不修改“异常明细”页签内部业务结构、分页、按钮、接口与数据逻辑。
## 范围
- 修改 `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- 修改 `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- 修改 `ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js`
- 修改 `ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js`
## 实施方案
### 1. 弹窗外壳正式化
- 将当前偏渐变、大圆角的详情弹窗外壳改为更平直的正式化工作台样式。
- 顶部保留“结果总览 / 项目分析详情”的信息层级,但改成浅边线、弱装饰、明确留白的标题区。
- 调整整体布局间距,让左侧档案区和右侧主区以纵向分隔线形成清晰结构。
### 2. 左侧档案区映射静态稿
- 保留当前姓名、风险等级、工号、部门、所属项目、命中模型数、核心异常标签的数据字段。
- 通过信息头、字段列表、摘要区三段式样式,映射静态稿的人物档案视觉。
- 不新增额外字段、不新增辅助业务区块。
### 3. 右侧主区外层收口
- 保持 `el-tabs`、错误提示、加载逻辑、默认页签逻辑不变。
- 只调整页签外层、内容承载区、主区边界与留白,不进入各 tab 内部重做内容样式。
## 验证计划
### 代码校验
-`ruoyi-ui` 目录执行:
- `node tests/unit/project-analysis-dialog-layout.test.js`
- `node tests/unit/project-analysis-dialog-sidebar.test.js`
- `node tests/unit/project-analysis-dialog-empty-field.test.js`
### 浏览器验证
- 先通过 `nvm use` 确认前端 Node 版本。
- 启动真实前端页面后,使用 `browser-use` 在系统真实页面打开“项目分析详情”弹窗。
- 重点核对:
- 标题区是否为正式化平直样式
- 左侧档案区是否按预览稿形成清晰三段层次
- 右侧主区是否只改外层、不影响“异常明细”内部内容与交互
## 风险控制
- 不改接口、不改 mock 数据、不改异常明细内部组件,避免把外壳样式改动扩大成业务结构调整。
- 单测只更新与外层视觉契约直接相关的断言,避免引入无关回归。

View File

@@ -0,0 +1,24 @@
# 2026-05-06 项目分析个人详情页样式对齐前端实施计划
## 目标
- 将项目分析个人详情页样式对齐到用户提供的参考图。
- 本次只调整前端样式表现,不改接口、字段、交互逻辑和业务内容。
## 范围
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisAbnormalTab.vue`
## 实施要点
- 调整详情弹窗头部、左右分栏比例、页签尺寸和间距。
- 调整左侧人物档案与命中模型摘要区块的标题、信息行、风险徽标和标签样式。
- 调整右侧异常明细内容区的区块标题、表格头部、单元格留白、异常对象摘要卡片和快照块样式。
- 保持现有数据绑定、页签切换、证据库按钮和分页逻辑不变。
## 验证
- 在真实业务页面打开项目总览详情弹窗,检查个人详情页视觉是否与参考图一致。
- 确认异常明细、对象摘要、加入证据库按钮和分页仍可正常显示。

View File

@@ -0,0 +1,29 @@
# Fund Graph Frontend Implementation Plan
**目标:** 在专项排查页签落地完整版图谱工作台,在项目分析弹窗内落地简版图谱展示,支持查看一层资金流和关系图谱,并通过点击资金边查看代表性流水。
**一期范围:**
- 专项排查版保留搜索栏:身份证号/员工姓名、交易时间范围、最小汇总金额。
- 项目分析弹窗版不提供搜索栏与手工新增入口,使用当前人员自动定位图谱。
- 图谱区:用 ECharts force graph 展示人员、企业、账号代理、名称代理节点和有向资金边。
- 汇总区:展示节点数、资金边数、交易笔数、汇总金额。
- 明细区:点击任意边后展示累计金额、交易笔数、最近交易和关系标签。
- 专项排查版保留分页逐笔流水;项目分析弹窗版仅展示最近 5 条代表性流水。
**实现内容:**
- 新增 `src/api/ccdi/graph/fundGraph.js``src/api/ccdi/graph/relationGraph.js`,封装图谱接口。
- 新增 `ProjectAnalysisFundFlowTab.vue`,承接项目分析弹窗内的简版图谱展示。
- 新增 `graph/FundGraphSection.vue`,统一承载完整版和弹窗简版两种模式。
- 修改 `ProjectAnalysisDialog.vue``SpecialCheck.vue`,分别接入简版与完整版图谱组件。
**交互口径:**
- 打开页签时优先使用模型摘要或人员对象中的身份证号/姓名自动查询。
- 专项排查版允许手工输入身份证号或员工姓名重新查询,并支持手工新增资金流向。
- 项目分析弹窗版保留图、基础节点详情、边汇总和轻量明细,不保留搜索、手工新增、疑似企业弹层和复杂操作。
- 默认展示 Top 20 资金边,避免一次渲染过多边影响交互。
- 一期不自动展开追溯层级,节点“一层展开”通过追加一圈节点和边 merge 回现有图谱。
**后续追溯口子:**
- 当前图谱节点已保留原始 `nodeKey``canExpand`
- 未来可在节点点击事件中调用后端追溯接口,把新增节点和边合并进现有图谱。
- 组件已按一层查询和边明细查询拆分,后续追溯不会影响“点边看流水”的核心链路。

View File

@@ -0,0 +1,21 @@
# 结果总览弹窗资金流向逐笔流水展示前端实施计划
## 需求范围
- 修改结果总览“查看详情”弹窗中的“资金流向”页签。
- 去掉资金边详情中的“弹窗速览”提示文案。
- 在弹窗资金边详情中展示逐笔流水明细,并保持分页加载。
- 不修改专项排查页的图谱入口和完整图谱展示逻辑。
## 实施方案
1. 调整 `ProjectAnalysisFundFlowTab.vue` 中传给 `FundGraphSection` 的参数,开启资金边逐笔流水表格。
2. 保持 `FundGraphSection` 现有边明细接口调用逻辑不变,继续使用分页查询。
3. 在弹窗包装组件内改为上方图谱、下方逐笔流水布局,收敛表格和分页样式,避免逐笔流水表撑高或挤压图谱画布。
4. 不新增接口、不修改后端、不改变专项排查页完整下钻能力。
## 验证计划
- 前端构建前按项目规则执行 `nvm use` 并确认 Node 版本。
- 执行前端构建或聚焦测试,确认组件编译通过。
- 使用真实页面打开结果总览“查看详情”弹窗,切换到“资金流向”,点击资金边确认下方显示逐笔流水和分页。

View File

@@ -0,0 +1,36 @@
# 结果总览弹窗资金流向可用性优化前端实施计划
## 目标
- 优化结果总览“查看详情”弹窗内“资金流向”页签的图谱展示空间。
- 降低多条资金边金额标签、节点名称在小画布中重叠的问题。
- 在查看单个对手方资金边明细后,通过点击图谱画布空白区域恢复全量图谱状态。
## 实施范围
- 仅调整结果总览“查看详情”弹窗中的资金流向图谱。
- 不调整专项排查页资金图谱的默认尺寸和业务逻辑。
- 不修改后端接口、数据库和资金流水分页接口。
## 实施方案
1.`ProjectAnalysisFundFlowTab` 中扩大资金流向工作区尺寸:
- 提高弹窗内图谱卡片高度。
- 改为上方图谱、下方逐笔流水布局,给图谱保留更大画布空间。
- 下方逐笔流水表格保持固定高度和分页展示,避免撑高弹窗。
2.`FundGraphSection` 中增加弹窗可配置能力:
- 增加边标签紧凑展示开关,金额使用“万/亿”等短格式展示。
- 支持隐藏资金边汇总卡片,只保留逐笔流水明细。
- 点击图谱画布空白区域时清空选中节点、选中边和逐笔流水明细,并重新渲染图谱。
3. 优化选中状态表达:
- 图谱中当前边和两端节点保持高亮,其他边降低透明度。
- 点空白区域后恢复初始全量图谱状态,不额外增加按钮。
## 验证计划
- 执行前端构建,确认无编译错误。
- 在真实页面进入结果总览“查看详情”弹窗,切换到“资金流向”。
- 选择包含多笔交易金额标签的资金边,验证节点名称和金额标签不再严重叠加,逐笔流水显示在图谱下方。
- 点击图谱画布空白区域,验证逐笔流水清空,图谱恢复全量状态。

View File

@@ -0,0 +1,609 @@
# 图谱开发决策记录
记录当前已确认的资金流图谱和关系图谱开发口径,作为后续开发、验收和跨对话延续的依据。
## 1. 页面嵌入位置
- 图谱功能先嵌入项目详情页的“专项排查”页签。
- 现有前端入口为 `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`
- 页面在项目详情内承载,但资金流图谱本身不按 `project_id` 过滤。
- 查询入口以全局身份证号 `cret_no` 或员工姓名为准。
## 2. 图谱表结构原则
- 建表逻辑尽量保持图谱平台已验证过的 SQL 逻辑。
- 不重新设计统一点表/边表。
- 表名保留图谱平台 SQL 中的五张结果表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_sum_edge`
- 不在五张图谱表中增加 `project_id` 作为查询过滤口径。
- 一条流水可能存在于多个项目中,资金流图谱按全局资金关系构建,避免项目维度导致重复建点或重复算边。
## 3. 五张表职责
| 表名 | 作用 |
| --- | --- |
| `lx_fund_flow_subject_node` | 主体点,表示人员、企业、名称代理主体 |
| `lx_fund_flow_account_node` | 账户点,表示具体账号或名称代理账户 |
| `lx_fund_flow_own_account_edge` | 主体到账户的持有关系 |
| `lx_fund_flow_detail_edge` | 账户层逐笔资金交易边 |
| `lx_fund_flow_sum_edge` | 主体层资金汇总边,前端默认展示 |
关键点:
- 一个人可能有多个账号,所以必须保留主体点、账户点、持有边三层结构。
- 前端默认展示主体层汇总边,不默认展示全部账户层明细边,避免节点过多。
- 点击汇总边后,再查询账户层逐笔流水。
## 4. 构建逻辑
构建逻辑以 `tupu/资金流图谱代码/lanxi_liushui_no_relation_simplified.sql` 为主。
重要口径:
- 项目内 SQL 尽量和图谱平台原 SQL 保持一致。
- 先导入一部分“所有员工流水明细”作为图谱基座。
- 这部分基座数据视为已验证、绝对正确,不应被后续构建流程随意清空或覆盖。
- 后续从 `ccdi_bank_statement` 拉取新增流水时,需要先和图谱基座做一致性判断。
- 如果 `ccdi_bank_statement` 中的流水已经能在图谱基座中匹配到一致流水,则不同步进图谱,避免重复计入。
- 如果没有匹配到一致流水,则按原图谱 SQL 逻辑增量插入图谱。
保留的核心口径:
- 本方证件号 `cret_no` 必须存在。
- 对手方名称必须存在,空值、空串、`0` 过滤。
- 金额必须有效,支出和收入统一成 `amount``flag`
- 支出 `flag = 1`,收入 `flag = 2`
- 明细去重,避免重复流水导致金额和笔数翻倍。
- 同名归并只作用于主体层,不改变账户节点和账户明细边。
- 无账号但有名称的对手方,按原 SQL 逻辑生成名称代理账户和名称代理主体。
一期先不展开追溯能力。需要为后续追溯预留字段和逻辑口子:
- `source_table`
- `penetrate_level`
- 后续可扩展 `FIRST``LEVEL1` 等来源。
## 4.1 基座与增量同步口径
图谱表不是“清空重建”的临时结果表,而是承载已验证图谱基座和后续增量的正式结果表。
基座数据:
- 来源为先导入的所有员工流水明细。
- 按原图谱平台 SQL 生成五张 `lx_*` 表。
- 基座数据作为可信结果保留。
增量数据:
- 来源为后续 `ccdi_bank_statement`
- 拉取后先按原 SQL 的流水标准化和去重口径生成候选流水。
- 候选流水和既有 `lx_fund_flow_detail_edge` 做一致性比对。
- 已存在一致流水时跳过,不插入图谱。
- 不存在一致流水时,再增量生成账户点、主体点、持有边、明细边和汇总边。
一致性比对建议使用稳定业务特征,而不是 `project_id`
- 本方账号
- 本方户名
- 对手方账号
- 对手方户名
- 交易日期
- 金额
- 收支方向 `flag`
- 摘要 `user_memo`
- 银行流水号或交易流水号,如果有
增量插入要求:
- 点表按 `object_key` 去重插入。
- 持有边按 `object_key` 去重插入。
- 明细边先判重,未存在才插入。
- 汇总边需要按主体对和方向重新聚合或局部 upsert不能简单追加导致金额翻倍。
## 4.2 ODPS 基座同步到 MySQL
当前真实部署口径:
- 原图谱 SQL 已在 ODPS 中有一份结果。
- ODPS 结果只涉及行内流水。
- ODPS 已经产出五张图谱结果表。
- 可以先将 ODPS 中五张结果表一次性同步到纪检 MySQL。
- MySQL 同步建表脚本记录在 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`
- 生产数据库表结构变更由人工单独执行,不跟随测试环境或应用发布自动更新。
- 项目内保留 SQL 文件,用于本地验证、评审和生产手动执行参考。
同步后的 MySQL 五张表继续使用原图谱表名:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_sum_edge`
同步要求:
- ODPS 到 MySQL 首次同步只做基座装载。
- 基座装载完成后,后续不再通过清空五张表重建处理。
- ODPS 字段和 MySQL 字段同名的按显式字段列表导入。
- MySQL 侧新增字段如 `family_relation_type``summary_object_key``source_table``penetrate_level``bank_statement_id``bank_trx_number` 可为空。
- `lx_fund_flow_sum_edge.detail_ids` 在 MySQL 中使用 `LONGTEXT` 接收 ODPS ARRAY 同步后的 JSON 或字符串表示,前后端查询不强依赖该字段。
## 4.3 MySQL 后续增量方式
后续新增数据都在纪检 MySQL 内处理:
- 来源表为 `ccdi_bank_statement`
- 增量逻辑从 `ccdi_bank_statement` 抽取候选流水。
- 候选流水按原图谱 SQL 口径标准化、过滤、生成 object_key。
- 先和既有 `lx_fund_flow_detail_edge` 做一致性判重。
- 已存在一致流水时不插入图谱。
- 不存在一致流水时,才增量插入点、账户、持有边、明细边。
- 汇总边 `lx_fund_flow_sum_edge` 按主体对和方向重新聚合或局部 upsert。
调度建议:
- 一期建议做每日定时调度,不建议一开始做实时。
- 推荐使用 RuoYi/Quartz 定时任务,每天凌晨或低峰期执行。
- 同时保留后台手动触发能力,便于首次补跑、排查和修复。
- 实时同步不是不能做,但没有必要优先做;实时会增加事务、锁、重复判断和汇总边更新复杂度。
推荐执行节奏:
1. ODPS 五张结果表一次性同步到 MySQL。
2. MySQL 跑一次家庭关系补充和 `summary_object_key` 回填。
3. 每日 Quartz 调度从 `ccdi_bank_statement` 抽取新增候选流水。
4. 候选流水与 `lx_fund_flow_detail_edge` 判重。
5. 未命中重复的流水增量入图。
6. 更新对应主体层汇总边。
7. 前端始终只基于 MySQL 五张 `lx_*` 图谱表查询展示。
## 4.4 数据库变更执行边界
数据库表结构改动属于生产库手工变更,不纳入测试环境自动更新。
后续开发分工:
- SQL 文件由代码库保留,作为生产手工执行依据和本地验证依据。
- 生产执行由人工确认后单独处理。
- 后端开发默认这些表在目标库中已经存在。
- 前端开发不感知数据库变更,只调用后端接口。
- 测试环境如没有这五张表,需要手动执行 SQL 后再联调。
- 应用发布包不自动执行这些 DDL。
## 5. 家庭关系
家庭关系是本次项目内新增能力,参考 `tupu/资金流图谱代码/资金流图谱_家庭关系补充.sql`
处理原则:
- 不改变资金方向。
- 不改变主体归并逻辑。
- 不改变账户层明细边生成逻辑。
- 只在资金边上增加家庭关系标注。
匹配规则:
- 交易任意一侧可映射到员工主体。
- 员工主体必须有身份证号。
- 对手方姓名命中 `ccdi_staff_fmy_relation.relation_name`
- 同一员工和同一关系人姓名只有一个 `relation_type` 时才标注。
- 多个关系类型冲突时不打标,避免误判。
建议补充字段:
- `lx_fund_flow_detail_edge.family_relation_type`
- `lx_fund_flow_sum_edge.family_relation_type`
## 6. 查询逻辑
搜索主体:
```sql
select *
from lx_fund_flow_subject_node
where idnocfno = #{keyword}
or name like concat('%', #{keyword}, '%');
```
查询主体层资金图:
```sql
select *
from lx_fund_flow_sum_edge
where from_key = concat('idno_node/', #{objectKey})
or to_key = concat('idno_node/', #{objectKey})
order by amount desc, total_trans_cnt desc
limit #{limit};
```
点击汇总边查询逐笔流水:
```sql
select *
from lx_fund_flow_detail_edge
where summary_object_key = #{sumObjectKey}
order by trx_date desc
limit #{offset}, #{pageSize};
```
说明:
- 原图谱平台 `detail_ids` 可以保留。
- MySQL 分页查询建议增加 `summary_object_key`,用于从汇总边直接查明细边。
- `summary_object_key` 是查询优化字段,不改变原图谱平台点边模型。
## 7. 前后端开发边界
后端负责:
- 从五张 `lx_*` 图谱结果表读取数据。
- 按身份证号或姓名定位主体。
- 返回主体层图谱节点和汇总边。
- 支持点击资金汇总边分页查询逐笔流水。
- 透出家庭关系字段。
- 空表或脏数据时返回空结果,不让前端报错。
前端负责:
- 在“专项排查”页签中呈现图谱展示区域。
- 支持身份证号或员工姓名搜索。
- 支持“资金图谱 / 关系图谱”页签。
- 一期资金图谱做实,关系图谱可先保留入口。
- 默认展示主体层资金汇总图。
- 点击边展示逐笔流水明细。
- 展示图谱明细边中已写入的家庭关系标签,如配偶、父母、子女。
## 8. 基座保护与异常数据处理
图谱表承载已验证基座,不按“可随意清空重建”设计。人工可以维护或清理异常数据,但默认应保护已有基座。
处理要求:
- 后续增量同步不得清空五张 `lx_*` 表。
- 增量同步前必须先判断候选流水是否已存在于 `lx_fund_flow_detail_edge`
- 已存在一致流水时跳过,避免重复金额和重复笔数。
- 人工清理异常边后,后端查询需要能容忍局部缺失数据。
- 边表有、点表缺失时,后端过滤无法匹配节点的边,不让前端报错。
- 明细边为空时,点击汇总边提示暂无逐笔流水。
- 如果人工确实清理了部分图谱数据,后续增量插入仍需按 `object_key` 和流水一致性规则防重。
## 9. UI 风格
固定设计口径:
- 浅色系统风格。
- 正式后台质感。
- 与当前纪检系统色调保持一致,蓝、白、灰为主。
- 朝图谱平台式交互靠齐。
- 不做黑色大屏。
- 不做网感、霓虹、炫光科技风。
- 图谱画布使用浅灰白背景,边界清楚。
- 搜索区放在顶部,支持身份证号和姓名。
- 页签为“资金图谱”和“关系图谱”。
- 明细使用右侧抽屉或下方面板展示,优先保证字段清楚和分页性能。
## 10. 后续开发顺序
建议顺序:
1. 先落项目内 SQLDDL、构建 SQL、家庭关系补充 SQL、索引 SQL。
2. 先支持已导入员工流水明细作为图谱基座。
3. 增加 `ccdi_bank_statement` 到图谱表的增量同步逻辑。
4. 增量同步必须先和既有 `lx_fund_flow_detail_edge` 做一致性判重,已存在则不同步。
5. 后端接口改为读取五张 `lx_*` 图谱表。
6. 前端在“专项排查”页签接入图谱展示区域。
7. 完成资金图谱搜索、展示、点击边查明细。
8. 增加家庭关系标签展示。
9. 验证基座保护、增量防重、一个人多个账号、家庭关系命中等场景。
## 11. 当前代码进度与偏差
截至 2026-05-28项目内已经做过一版一期资金流图谱代码但这版实现口径与当前最终方案不完全一致后续需要重构而不是直接当最终版。
已完成过的代码:
- 后端新增 `CcdiFundGraphController`,接口路径为 `/ccdi/project/fund-graph/graph``/ccdi/project/fund-graph/edge-detail`
- 后端新增 DTO/VO`CcdiFundGraphQueryDTO``CcdiFundGraphEdgeDetailQueryDTO``CcdiFundGraphVO``CcdiFundGraphNodeVO``CcdiFundGraphEdgeVO``CcdiFundGraphStatementVO`
- 后端新增 Mapper 和 Service`CcdiFundGraphMapper``ICcdiFundGraphService``CcdiFundGraphServiceImpl``CcdiFundGraphMapper.xml`
- 前端新增接口文件 `ruoyi-ui/src/api/ccdi/fundGraph.js`
- 前端新增组件 `ProjectAnalysisFundFlowTab.vue`
- 前端已在 `ProjectAnalysisDialog.vue` 中接入资金流图谱页签。
旧版已验证情况:
- `mvn -pl ccdi-project -am compile -DskipTests` 通过。
- `npm run build:prod` 通过。
- 真实库只读校验过项目 33 和姓名样例,能查出资金边,点击边能查逐笔流水。
- 曾修正 MySQL 8 保留词别名 `rows` 和字符集排序规则不一致问题。
当前偏差:
- 旧版接口是实时从 `ccdi_bank_statement` 聚合资金图谱,不读取五张 `lx_*` 图谱结果表。
- 旧版查询带项目上下文,当前最终口径是不按 `project_id` 过滤,以全局 `cret_no` 或姓名为入口。
- 旧版前端接在项目分析弹窗 `ProjectAnalysisDialog`,当前最终入口应放在“专项排查”页签。
- 旧版没有按图谱平台五表基座和增量同步口径处理。
- 旧版没有家庭关系标注。
后续处理原则:
- 可复用旧版的图谱展示、点击边查明细、分页表格等前端交互经验。
- 可复用旧版 DTO/VO 中适合前端展示的字段,但字段来源需要改为五张 `lx_*` 表。
- 后端 SQL 必须从实时聚合 `ccdi_bank_statement` 改为读取 `lx_fund_flow_subject_node``lx_fund_flow_account_node``lx_fund_flow_own_account_edge``lx_fund_flow_detail_edge``lx_fund_flow_sum_edge`
- 前端入口需要从项目分析弹窗迁移或重做到“专项排查”页签。
- 旧版文件在重构时应谨慎处理,避免影响当前项目分析弹窗已有功能。
## 12. 页面查询与汇总表最新决策
最新决策:
- 纪检平台资金流图谱页面不强依赖 `lx_fund_flow_sum_edge`
- 页面查询以 `lx_fund_flow_detail_edge` 为事实表,由后端按当前查询条件实时聚合。
- 前端不做金额和笔数聚合,只负责渲染后端返回的节点、边和明细。
- `lx_fund_flow_sum_edge` 如生产侧不需要兼容图谱平台页面,可以不作为纪检页面必需表。
- 如果 ODPS 已有 `lx_fund_flow_sum_edge`,可以选择不同步到 MySQL或同步后仅作为参考缓存不作为纪检页面查询依据。
原因:
- 用户每次查询通常以一个人为中心,一跳图谱范围可控。
- 用户需要按 `trx_date` 任意筛选时间范围。
- 全量 `lx_fund_flow_sum_edge` 不能准确表达任意时间段内的金额和笔数。
- 每天新增明细后维护汇总表会增加复杂度。
- 后端从明细边实时聚合能保证筛选结果准确,且比前端聚合更可靠。
后端聚合口径:
1. 用身份证号、姓名或 `object_key` 定位主体点。
2. 查询该主体名下账户。
3.`lx_fund_flow_detail_edge` 查询这些账户相关流水。
4.`trx_date`、金额、方向、家庭关系等筛选条件过滤。
5. 后端将账户层明细边聚合为主体层资金边。
6. 返回前端用于图谱展示。
时间筛选字段:
- 所有时间筛选基于 `lx_fund_flow_detail_edge.trx_date`
- 不用 `lx_fund_flow_sum_edge.first_trx_date``lastest_trx_date` 判断筛选结果。
## 13. 节点穿透最新决策
节点穿透以 `lx_fund_flow_subject_node.object_key` 为唯一标识,不按姓名穿透。
口径:
- 实名主体按原 SQL 逻辑生成 `object_key`,即 `md5(trim(idnocfno))`
- 用户点击节点后,可选择“以此节点为中心查询”或“展开此节点”。
- 默认不自动穿透,避免图谱过长和误展开。
- 后端按被选节点的 `object_key` 查询其账户和流水。
- 节点是否可穿透由后端返回 `canExpand` 控制。
允许穿透:
- 有明确身份证号或证件号的实名主体。
- 有明确账户归属、能通过 `lx_fund_flow_own_account_edge` 找到账户的主体。
默认不穿透:
- 只有名称、没有证件号、没有明确账户归属的名称代理主体。
- 无法通过 `object_key` 准确定位账户集合的节点。
交互建议:
- 一期做“设为中心查询”,即点击节点后重新以该节点为中心画一跳图。
- 后续再做“在当前图上追加展开”,避免一期图谱状态管理过复杂。
## 14. 最终减法版决策
本节覆盖前文早期关于五张表、`lx_fund_flow_sum_edge`、关系图谱页签的旧设想。后续开发以本节为准。
本节为 2026-05-28 资金流图谱减法版决策,重点是不依赖 `lx_fund_flow_sum_edge`。关于关系图谱页签,后续已在第 16 节更新为“保留关系图谱能力”,以第 16 节为准。
必要表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
不依赖:
- `lx_fund_flow_sum_edge`
页面查询:
- 输入身份证号、姓名或点击节点 `object_key`
- 后端定位主体点。
- 后端查询主体持有账户。
- 后端从 `lx_fund_flow_detail_edge` 按账户、`trx_date`、金额等条件实时聚合资金边。
- 前端只渲染后端返回的资金节点、资金边和逐笔明细。
家庭关系:
- 只作为资金流图谱中的标签展示。
- 家庭关系识别在图谱构建或数据加工阶段完成,并写入 `lx_fund_flow_detail_edge.family_relation_type`;后端查询接口只读取并返回该字段,不实时匹配家庭表。
- 如果生产构建需要按对手方户名匹配 `ccdi_staff_fmy_relation.relation_name`,应在构建 SQL 中完成,并控制同名误判风险。
- 有明确 `relation_cert_no` 的家庭关系人按实名主体处理,`object_key = md5(trim(relation_cert_no))`
- 用户点击该节点时,可按该节点 `object_key` 设为中心继续查询。
测试数据:
- 测试 DDL 为 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`
- 测试数据脚本为 `sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql`
- 测试数据只写四张必要表。
- 测试数据来源于 dev 库 `ccdi_bank_statement``ccdi_staff_fmy_relation`
- 原始流水表不被修改。
## 15. 前后端与页面交互设计
默认查询:
- 默认查询全部流水,不默认带交易日期过滤。
- 用户选择交易日期后,后端才按 `lx_fund_flow_detail_edge.trx_date` 过滤。
- 时间过滤不查汇总表,直接从明细边实时聚合。
后端接口建议:
```text
GET /ccdi/project/fund-graph/search
GET /ccdi/project/fund-graph/graph
GET /ccdi/project/fund-graph/edge-detail
POST /ccdi/project/fund-graph/manual-edge
GET /ccdi/project/relation-graph/search
GET /ccdi/project/relation-graph/graph
GET /ccdi/project/relation-graph/suspected-enterprises
```
接口职责:
- `search`:按身份证号或姓名查主体点,返回候选主体列表。
- `graph`:按主体 `object_key` 查询一跳资金图,默认全部流水,可选日期、金额、方向过滤。
- `edge-detail`:点击资金边后,分页查询该边下的逐笔流水。
`graph` 入参:
```text
objectKey 必填,主体 object_key
startDate 可选,交易开始日期
endDate 可选,交易结束日期
amountMin 可选,最小金额
amountMax 可选,最大金额
direction 可选1支出、2收入
limit 可选默认20
```
`graph` 返回:
```text
centerNode 当前中心主体
nodes 图谱节点
edges 聚合后的资金边
summary 当前查询范围的总金额、总笔数、家庭关系边数量
```
节点字段:
```text
objectKey
nodeKey idno_node/{object_key}
name
idNo
nodeType PERSON / PROXY
canExpand
relationType 如果是家庭关系节点,返回配偶/父亲/母亲等
```
边字段:
```text
edgeKey
fromKey
toKey
fromName
toName
direction 1支出、2收入
amount
transactionCount
firstTrxDate
lastTrxDate
familyRelationType
```
`edge-detail` 入参:
```text
fromObjectKey
toObjectKey
direction
startDate
endDate
pageNum
pageSize
```
页面交互:
- 页面位置:项目详情“专项排查”页签。
- 页面标题:图谱展示。
- 展示“资金流图谱”和“关系图谱”两个页签。
- 搜索区只保留必要控件:身份证号/姓名、交易日期、查询、重置。
- 默认空态提示:请输入身份证号或姓名查询资金流图谱。
- 查询后画一跳图,中心节点为当前人员。
- 边上只显示金额和笔数,家庭关系用标签显示。
- 点击资金边,右侧抽屉展示逐笔流水。
- 点击节点,提供“设为中心查询”;默认不自动穿透。
- 只有 `canExpand = true` 的节点展示“设为中心查询”。
性能口径:
- 前端不聚合金额和笔数。
- 后端只围绕一个主体的账户集合查明细边。
- 默认全部流水也只查当前主体相关边,不扫全表。
- 必须使用 `lx_fund_flow_own_account_edge.from_key``lx_fund_flow_detail_edge.from_key/trx_date``lx_fund_flow_detail_edge.to_key/trx_date` 索引。
- 后端 SQL 参数比较需要显式 `COLLATE utf8mb4_general_ci`,避免当前库连接排序规则和表排序规则不一致。
当前 dev 测试数据:
```text
测试身份证号617673198109148314
测试 object_key以 `MD5('617673198109148314')` 为准
主体点10
账户点14
持有边14
明细边72
```
测试覆盖:
- 默认全部流水聚合。
- 日期范围筛选聚合。
- 支出方向 `flag = 1`
- 收入方向 `flag = 2`
- 家庭关系标签:配偶、父亲、母亲。
- 普通对手方:支付宝、淘宝、美团、财付通、小店、银行转账。
- 点击家庭关系节点按 `object_key` 设为中心查询。
## 16. 2026-05-29 最新验收口径
本节为当前最新口径,用于覆盖前文早期变更记录中的冲突描述。
当前图谱功能保留两类能力:
- 资金流图谱:作为专项排查中的核心图谱能力,读取 `lx_fund_flow_subject_node``lx_fund_flow_account_node``lx_fund_flow_own_account_edge``lx_fund_flow_detail_edge`,并叠加 `lx_fund_flow_manual_edge` 手工资金流向。
- 关系图谱:保留页面页签和接口能力,读取 `lx_rel_node``lx_rel_family_edge``lx_rel_stock_edge``lx_rel_represent_edge`,支持家庭关系、股东持股、法定代表人关系和疑似同名企业召回。
当前页面入口:
- 项目详情“专项排查”页签展示完整图谱工作台,包含“资金流图谱”和“关系图谱”两个页签。
- 项目分析弹窗“资金流向”页签展示简版资金流图谱。
- 项目分析弹窗“关系图谱”页签展示简版关系图谱。
当前接口入口:
```text
GET /ccdi/project/fund-graph/search
GET /ccdi/project/fund-graph/graph
GET /ccdi/project/fund-graph/edge-detail
POST /ccdi/project/fund-graph/manual-edge
GET /ccdi/project/relation-graph/search
GET /ccdi/project/relation-graph/graph
GET /ccdi/project/relation-graph/suspected-enterprises
```
当前数据库执行口径:
- 新环境可参考 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql``sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql`
- 已建资金流图谱表的环境使用 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql` 补字段和补索引,不删除、不重建、不清空基座数据。
- 已建关系图谱表的环境使用 `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql` 中的补充逻辑补字段和补索引。
- 生产 DDL 和补充 SQL 都由人工确认后手动执行,不随应用发布自动执行。
当前验收样例:
```text
资金流图谱测试身份证号617673198109148314
关系图谱测试身份证号330101198001010011
```

View File

@@ -0,0 +1,98 @@
# 图谱生产数据库手工变更清单
本清单只记录资金流图谱涉及的生产数据库表结构和数据准备事项。该部分由人工在生产库手动执行,不随应用发布自动执行,也不要求测试环境自动更新。
## 1. DDL 脚本
生产建表脚本:
```text
sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql
```
当前减法版创建五张资金流图谱必要表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_manual_edge`
不创建、不依赖 `lx_fund_flow_sum_edge`。资金图谱页面由后端基于 `lx_fund_flow_detail_edge.trx_date` 按当前查询条件实时聚合真实资金边,手工资金流向汇总边单独存入 `lx_fund_flow_manual_edge`
## 2. 生产执行边界
- 生产库 DDL 由人工手动执行。
- 应用发布包不自动执行 DDL。
- 测试环境不会自动同步这些变更。
- 代码库保留 SQL 文件,只作为生产执行、评审和本地验证依据。
- 后端开发默认目标库中上述 `lx_*` 表已存在。
## 3. ODPS 基座同步
生产建表后,先从 ODPS 同步已验证的资金流图谱基座到 MySQL。
同步来源:
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_subject_node`
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_account_node`
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_own_account_edge`
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_detail_edge`
同步原则:
- ODPS 基座是已验证数据,作为 MySQL 图谱基座保留。
- 同步时建议使用显式字段列表,不依赖 `select *`
- MySQL 侧新增字段允许为空。
- `lx_fund_flow_sum_edge` 不作为纪检资金图谱页面必要表,可不从 ODPS 同步。
- `lx_fund_flow_manual_edge` 不从 ODPS 同步,生产建表后初始为空,由纪检平台手工分析功能写入。
## 4. 后续增量
ODPS 基座同步后,后续新增流水在纪检 MySQL 内处理。
增量来源:
- `ccdi_bank_statement`
增量原则:
- 先标准化候选流水。
- 再和既有 `lx_fund_flow_detail_edge` 做一致性判重。
- 已存在一致流水,不同步进图谱。
- 不存在一致流水,才增量插入主体点、账户点、持有边、明细边。
- 不维护汇总表;页面查询时实时聚合。
## 5. 调度建议
一期建议采用每日定时任务,不建议一开始做实时。
推荐方式:
- RuoYi/Quartz 定时任务。
- 每日低峰期执行。
- 保留手动触发能力,用于补跑、排查和修复。
## 6. 前后端依赖
前后端开发依赖上述 MySQL 图谱表的查询结果。
- 前端不直接访问数据库。
- 后端接口读取 `lx_*` 表。
- 页面入口放在项目详情的“专项排查”页签。
- 资金流图谱中真实资金边基于 `lx_fund_flow_detail_edge` 实时聚合。
- 手工资金边来自 `lx_fund_flow_manual_edge`,属于主体级汇总边,只存 `from_object_key``to_object_key`,不存冗余 `from_key``to_key`;图谱展示时由后端临时拼出 `idno_node/{object_key}`,不提供逐笔流水下钻。
- 查询按全局 `cret_no`、姓名或节点 `object_key`,不按 `project_id` 过滤。
## 7. 性能和索引口径
一期资金图谱默认只查一个中心主体的一层资金边,并设置 `minTotalAmount = 1000``limit = 20`,不会默认拉全量毛刺边。
生产索引重点:
- `lx_fund_flow_subject_node``PRIMARY KEY(object_key)``idx_lx_fund_flow_subject_idnocfno(idnocfno)``idx_lx_fund_flow_subject_name(name)`
- `lx_fund_flow_own_account_edge``idx_lx_fund_flow_own_from_key(from_key)``idx_lx_fund_flow_own_to_key(to_key)`
- `lx_fund_flow_detail_edge``idx_lx_fund_flow_detail_from_date(from_key, trx_date)``idx_lx_fund_flow_detail_to_date(to_key, trx_date)``idx_lx_fund_flow_detail_from_to(from_key, to_key)`
- `lx_fund_flow_manual_edge``idx_lx_fund_flow_manual_from(from_object_key)``idx_lx_fund_flow_manual_to(to_object_key)``idx_lx_fund_flow_manual_pair_direction(from_object_key, to_object_key, direction)`
如果后续单个主体关联流水达到几十万级,再考虑增加主体级冗余字段或月度汇总表;一期不建 `sum_edge`

View File

@@ -0,0 +1,40 @@
# 员工信息维护与招聘信息管理正式化外壳实施记录
## 变更日期
- 2026-04-29
## 变更范围
- 前端:`ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- 前端:`ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- 单测:`ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js`
- 单测:`ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js`
## 实施内容
### 1. 页面外壳调整
- 复用 `app-container``query-form``mb8` 等现有结构,只补最少样式。
-`ccdiBaseStaff``ccdiStaffRecruitment` 的查询区增加正式化外壳视觉,包括浅底、边框、留白和输入框边线统一。
- 保持搜索、重置、新增、导入、失败记录等按钮原有顺序不变。
- 为两个页面新增 `formal-table-shell`,将表格和分页收口到同一视觉区域内。
### 2. 弹窗外壳调整
- 复用员工页已有 `employee-edit-dialog``employee-detail-dialog` class只调整弹窗圆角、标题分隔线和弹窗正文背景。
- 复用招聘页现有弹窗结构,只补统一的弹窗标题区和正文背景样式。
- 未改动员工资产、历史工作经历等内部功能块结构。
### 3. 验证情况
- 单测通过:
- `node tests/unit/base-staff-formal-shell-layout.test.js`
- `node tests/unit/staff-recruitment-formal-shell-layout.test.js`
- `node tests/unit/staff-recruitment-import-toolbar.test.js`
- 现有单测异常:
- `node tests/unit/employee-asset-maintenance-layout.test.js`
- 失败原因为当前仓库源码不满足既有字符串断言 `createEmptyAssetRow(defaultPersonId = "")`,与本次外壳样式改动无关。
- 浏览器验证:
- 已使用 `browser-use` 打开 `http://localhost/prototype/staff-recruitment`,确认招聘信息管理页查询区、工具条、表格区已切换为正式化外壳,按钮仍保持原位。
- 尝试打开 `http://localhost/ccdiBaseStaff` 时,当前本地前端路由返回 404 页面,因此未能在浏览器内完成员工信息维护页真实页面验证。

View File

@@ -0,0 +1,92 @@
# 2026-04-29 批量正式化外壳样式实施记录
## 本次实施内容
本轮基于当前本地最新代码,批量将信息维护相关页面收口为统一的正式化外壳样式,继续保持“只改样式、不改内容和功能”的边界。
### 覆盖页面
- 账户库管理:`ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
- 征信维护:`ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
- 信贷客户实体关联:`ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
- 信贷客户家庭关系:`ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
- 中介库管理:
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
- 招投标信息维护:`ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- 员工亲属实体关联:`ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
- 员工亲属关系维护:`ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
- 员工调动记录:`ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
## 具体调整
### 查询区
- 将筛选区统一收进白色边框面板。
- 统一标签颜色、控件边框、控件圆角和表单项间距。
- 保留全部原始筛选条件和原始布局顺序。
### 工具条
- 为工具条增加统一白色承载面板。
- 按钮圆角统一收敛到约 4px。
- 不调整搜索、重置、新增、导入、失败记录等按钮的位置和语义。
### 表格
- 为列表页统一增加 `formal-table-shell` 外层承载。
- 收紧表头和表体留白,提升单屏显示密度。
- 统一普通列左对齐,操作列和选择列保持居中。
### 弹窗
- 编辑、详情、导入弹窗统一使用更正式的边界和浅底信息面板风格。
- 去掉原有偏演示感的悬浮和装饰感。
- 不改变弹窗中的字段组织和业务交互。
## 修正项
- 批量调整过程中,`ccdiPurchaseTransaction/index.vue` 样式块曾出现一个多余的 `}`,导致前端编译报错。
- 已在本轮内修正,重新通过真实页面检查。
- 批量将 `.mb8` 统一为 `flex` 承载后,`right-toolbar` 的“显示/隐藏 / 刷新”按钮组一度被挤到左侧。
- 已通过为各列表页补充 `.mb8 ::v-deep .top-right-btn { margin-left: auto; }` 恢复原有靠右位置。
## 验证结果
### 单测
- `node ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js` 通过
- `node ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js` 通过
- `node ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js` 通过
- `node ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js` 通过
### 真实页面浏览器验证
已通过真实业务路由验证以下页面可以打开且关键外壳区域仍保持原有功能结构:
- `http://localhost/maintain/accountInfo`
- `http://localhost/maintain/creditInfo`
- `http://localhost/maintain/intermediary`
- `http://localhost/maintain/purchaseTransaction`
- `http://localhost/maintain/staffTransfer`
- `http://localhost/maintain/staffEnterpriseRelation`
- `http://localhost/maintain/staffFmyRelation`
- `http://localhost/maintain/custEnterpriseRelation`
- `http://localhost/maintain/custFmyRelation`
- `http://localhost/maintain/staffRecruitment`
验证点:
- 页面标题、搜索按钮、新增按钮、导入按钮仍可见
- 查询区与工具条仍位于原位置
- 未发生按钮左右换位
- 表格区与分页区仍按原内容结构展示
## 现有环境问题
- `staffRecruitment` 页面当前仍存在后端返回的字符集排序规则冲突报错:`utf8mb4_0900_ai_ci``utf8mb4_general_ci` 混用。
- 该问题来自现有后端/数据库环境,不是本次样式改动引入的问题。

View File

@@ -0,0 +1,101 @@
# 2026-04-29 正式化样式调整总说明
## 目标边界
- 本轮所有改动都基于当前本地最新代码进行。
- 仅调整页面与弹窗外壳样式,不改变原有内容、字段、按钮语义、交互流程和功能逻辑。
- 不参考 `2026-04-29-dev-ui-style-mixed-stash.patch` 中的结构性和功能性变动。
## 本轮纳入的页面
### 1. 项目分析详情弹窗
- 文件:
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- 调整方向:
- 详情页外壳正式化、去卡片化
- 标题区更平直,人物档案区更规整
- 页签和主区承载更克制
- 不变内容:
- 异常明细、资产分析、征信摘要等业务内容结构不变
- 数据请求、分页、按钮逻辑不变
### 2. 员工信息维护页
- 文件:
- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- 调整方向:
- 筛选区收进统一白色区域
- 工具条按钮外观更正式,圆角收小
- 表格与分页统一收进正式信息面板
- 表格更紧凑、阅读更集中
- 编辑/详情弹窗外壳更像正式信息面板
- 不变内容:
- 查询字段、按钮顺序、导入入口、资产信息与党员信息功能不变
### 3. 招聘信息管理页
- 文件:
- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- 调整方向:
- 筛选区、工具条、表格区统一正式化
- 按钮、输入框、下拉框视觉更稳重
- 表格行高与表头高度适当收紧
- 弹窗外壳更克制
- 不变内容:
- 招聘类型、历史工作经历、导入入口、按钮位置和业务流程不变
### 4. 批量推进的信息维护页面
- 文件:
- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
- 调整方向:
- 查询区、工具条、列表区统一成正式化白色信息面板
- 输入框、下拉框、日期控件边框与圆角统一收敛
- 表格与分页通过 `formal-table-shell` 统一承载
- 中介库的搜索、列表、详情、编辑、导入弹窗统一到相同视觉语言
- 各列表维护页的弹窗边界与留白更克制
- 不变内容:
- 按钮顺序、字段结构、导入流程、失败记录入口、详情内容和业务逻辑不变
## 统一视觉原则
- 筛选区更规整:统一白色面板承载,结构清晰
- 按钮更正式:圆角约 4px弱化轻飘感
- 表单控件更稳重:圆角更小,边框更统一
- 表格更紧凑:降低表头和行内容留白,一屏展示更多信息
- 列表阅读性更好:尽量左对齐,减少长字段换行和大片空白
- 视觉装饰收敛:移除不必要的阴影、渐变、悬浮感
- 卡片感减弱:边界、留白、圆角更克制,保留原有内容结构
## 验证说明
- 项目分析详情弹窗已完成真实页面验证
- 员工信息维护页已完成源码与单测级校验
- 招聘信息管理页和批量推进页面已通过真实业务路由验证:
- `http://localhost/maintain/staffRecruitment`
- `http://localhost/maintain/accountInfo`
- `http://localhost/maintain/creditInfo`
- `http://localhost/maintain/intermediary`
- `http://localhost/maintain/purchaseTransaction`
- `http://localhost/maintain/staffTransfer`
- `http://localhost/maintain/staffEnterpriseRelation`
- `http://localhost/maintain/staffFmyRelation`
- `http://localhost/maintain/custEnterpriseRelation`
- `http://localhost/maintain/custFmyRelation`
- 浏览器验证过程中发现并修复了 `ccdiPurchaseTransaction/index.vue` 的样式编译错误
- `staffRecruitment` 页面仍存在现有数据库字符集排序规则冲突报错,该问题不属于本轮样式改动

View File

@@ -0,0 +1,44 @@
# 结果总览项目分析详情正式化外壳实施记录
## 变更日期
- 2026-04-29
## 变更范围
- 前端:`ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- 前端:`ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- 单测:`ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js`
- 单测:`ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js`
## 实施内容
### 1. 弹窗外壳样式正式化
- 调整 `ProjectAnalysisDialog.vue` 的外层壳样式,去掉旧版渐变大圆角卡片视觉。
- 将弹窗主体改为浅灰外层背景 + 白色内容工作台,强化边线、留白和分栏结构。
- 将标题区改成“结果总览 / 项目分析详情”的正式化层级,保留当前命中模型提示,但收口为弱装饰信息块。
- 调整右侧主区与左侧档案区之间的分隔线和间距,只改外壳,不进入各 tab 内部内容结构。
### 2. 左侧人物档案区样式映射
- 调整 `ProjectAnalysisSidebar.vue`,按“人物档案 + 命中模型摘要”两段式结构重排视觉。
- 姓名、风险等级、工号、部门、所属项目继续沿用现有数据字段,不新增业务字段。
- 将风险等级改为细边框状态标识,字段列表改为规整的标签/值双列展示。
- 核心异常标签保留为现有标签数据,仅更新标签外观,不改渲染逻辑。
### 3. 验证情况
- 单测通过:
- `node tests/unit/project-analysis-dialog-layout.test.js`
- `node tests/unit/project-analysis-dialog-sidebar.test.js`
- `node tests/unit/project-analysis-dialog-empty-field.test.js`
- 浏览器实测:
- 使用 `browser-use` 打开本地真实系统 `http://localhost/`
- 进入项目详情页 `http://localhost/ccdiProject/detail/90337?tab=overview`
- 在“结果总览”页点击“查看详情”,确认“项目分析详情”弹窗已应用正式化外壳样式
- 确认左侧人物档案区样式已按预览稿方向收口,右侧“异常明细”内部业务内容未被重做
- 环境记录:
- `ruoyi-ui/.nvmrc` 期望版本为 `14.21.3`
- 当前终端执行 `nvm use` 失败,原因是 `nvm` 未安装到 PowerShell PATH
- 本次前端校验在当前可用 Node `v22.22.0` 下完成,相关单测通过

View File

@@ -0,0 +1,18 @@
# 2026-05-06 项目分析个人详情页样式对齐实施记录
## 本次修改
- 调整 `ProjectAnalysisDialog.vue`,补齐参考图中的标题区、内容区留白、左右分栏间距和页签样式。
- 调整 `ProjectAnalysisSidebar.vue`,补齐人物档案区、风险等级徽标、命中模型摘要和标签的正式化版式。
- 调整 `ProjectAnalysisAbnormalTab.vue`,补齐流水异常明细表格、异常对象摘要区、快照块和信息行样式。
## 未改内容
- 未改接口请求和数据拼装逻辑。
- 未改页签切换、分页、加入证据库、异常分组和字段内容。
- 未新增或删除业务区块。
## 验证方式
- 使用真实业务页面 `http://localhost/ccdiProject/detail/90337?tab=overview` 打开个人详情弹窗进行样式核对。
- 核对左侧人物档案、右侧页签、表格块和异常对象摘要块的正式化效果。

View File

@@ -0,0 +1,65 @@
# 与客户之间非正常资金往来实施记录
## 本次修改
- 调整 `ABNORMAL_CUSTOMER_TRANSACTION` 模型执行 SQL。
- 调整“涉疑交易明细 -> 名单库命中” SQL。
- 调整涉疑交易明细前端标签展示。
- 补充 SQL 口径测试,不改表结构。
## 模型 SQL 口径
- 位置:`ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- 主体范围:员工本人、`ccdi_staff_fmy_relation.status = 1` 的有效亲属。
- 金额口径:`GREATEST(IFNULL(amount_dr, 0), IFNULL(amount_cr, 0)) > 1000`
- 信贷客户命中:对手方账号命中 `ccdi_account_info.owner_type = 'CREDIT_CUSTOMER'`
- 中介账号命中:对手方账号命中 `ccdi_account_info.owner_type = 'INTERMEDIARY'`
- 中介企业命中:对手方名称精确命中 `ccdi_enterprise_base_info.enterprise_name`,且企业来源为中介。
- 中介人员精确命中:对手方名称精确命中 `ccdi_biz_intermediary.name`
- 中介人员模糊命中:对手方名称包含 `ccdi_biz_intermediary.name`,仅限 `bank in ('ALIPAY', 'WECHAT')`
- 明确不再使用对手方证件号、对手方统一社会信用代码命中。
## 名单库命中口径
- 位置:`ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- 信贷客户账号命中时,`nameListHitType` 返回“信贷客户”。
- 中介账号、人员名称、企业名称命中时,`nameListHitType` 返回“中介”。
- 名单库命中同样要求交易金额大于 1000。
- 涉疑交易明细和 PDF 导出按 `bank_statement_id` 聚合,避免同一流水因“模型规则命中”和“名单库命中”重复展示。
## 前端展示
- 位置:`ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `ABNORMAL_CUSTOMER_TRANSACTION` 在异常标签中优先展示。
- 如果同一流水名单库类型为“中介”,该标签展示为“疑似与中介往来”。
- 如果同一流水名单库类型为“信贷客户”,该标签展示为“与信贷客户之间非正常资金往来”。
- 如果没有名单库类型,兜底展示为“与客户之间非正常资金往来”。
- 不再把“中介/信贷客户”追加成额外异常标签,避免重复展示。
## 报告展示
- 结果总览 PDF 报告中的涉疑交易明细表同步使用上述标签展示口径。
- 同一条流水只替换 `ABNORMAL_CUSTOMER_TRANSACTION` 的展示文案,不额外追加“中介/信贷客户”标签。
## 影响文件
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectSuspiciousTransactionItemVO.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `docs/reports/implementation/2026-05-21-abnormal-customer-transaction-implementation.md`
## 验证情况
- `mvn -pl ccdi-project -am -DskipTests compile` 通过。
- `mvn -pl ccdi-project "-Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiProjectOverviewMapperSqlTest" test` 通过21 个测试。
- 测试覆盖的伪造场景口径:
- 信贷客户账号命中。
- 中介账号命中。
- 中介人员名称精确命中。
- 中介人员名称模糊命中仅限 `ALIPAY/WECHAT`
- 名单库命中金额门槛大于 1000。
- 涉疑交易明细按 `bank_statement_id` 聚合去重。
- 涉疑交易明细和 PDF 报告按名单库类型细分展示异常标签,且不重复展示。

View File

@@ -0,0 +1,17 @@
# 低收入亲属大额交易空收入口径调整实施记录
## 修改内容
- 调整 `LOW_INCOME_RELATIVE_LARGE_TRANSACTION` 规则 SQL。
- 关系人 `annual_income is null` 时不再进入低收入候选,不触发该规则。
- 仅当 `annual_income` 明确有值,且满足 `annual_income = 0``annual_income / 12 < 3000` 时,才进入低收入候选。
## 影响范围
- 后端银行流水打标规则:低收入亲属大额交易。
- 不涉及前端展示、接口入参、数据库表结构。
## 验证
- `mvn -pl ccdi-project -am -DskipTests compile` 通过。
- `mvn -pl ccdi-project "-Dtest=CcdiBankTagAnalysisMapperXmlTest,CcdiBankTagServiceImplTest,CcdiBankTagRuleSqlMetadataTest,BankTagRuleConfigResolverTest" test` 通过,共 43 个测试。

View File

@@ -0,0 +1,63 @@
# dev-ui 本次提交清单
## 提交信息
- 分支:`dev-ui`
- 提交说明:`优化涉疑交易模型口径和报告展示`
## 功能清单
1. 涉疑交易模型口径调整
- `ABNORMAL_CUSTOMER_TRANSACTION` 补充信贷客户账号命中。
- `ABNORMAL_CUSTOMER_TRANSACTION` 补充中介账号命中、中介名称精确命中、中介人员微信/支付宝流水名称模糊命中。
- 名单库命中保留中介和信贷客户两类。
- 金额门槛统一按单边流水金额大于 1000 元判断。
- 低收入亲属大额交易排除年收入为空的亲属;年收入为空不主动预警。
- 大额单笔收入、疑似兼职相关收入预警排除公积金中心收入。
2. 涉疑交易明细展示与导出
- 涉疑交易明细保留“名单库命中”和“模型规则命中”筛选。
- 同一条流水同时命中名单库和模型规则时,按 `bank_statement_id` 聚合去重,不重复展示。
- 名单库命中类型区分为“中介”和“信贷客户”。
- 前端异常标签按名单类型展示:
- 中介:`疑似与中介往来`
- 信贷客户:`与信贷客户之间非正常资金往来`
- 未命中名单类型时:`与客户之间非正常资金往来`
- PDF 导出复用去重后的明细逻辑。
3. 拉取本行流水弹窗
- 证件号码输入提示改为仅支持英文逗号分隔。
- 日期默认开始时间为昨天往前一年,结束时间为昨天。
- 可选日期范围限制为 2025-01-01 到昨天。
4. 报告导出展示
- “一键导出”按钮文案改为“导出报告”。
- PDF 报告章节标题错行问题修正。
- PDF 报告标题加粗展示。
## 涉及文件
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiProjectOverviewController.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectSuspiciousTransactionItemVO.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporter.java`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml`
- `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewMapperSqlTest.java`
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporterTest.java`
## 验证情况
- 已执行后端 Mapper SQL 断言测试。
- 已执行 PDF 导出样式断言测试。
- 已执行前端生产构建。
- 已在本地页面验证涉疑交易明细中“中介/信贷客户”命中展示不重复。
- 已验证 PDF 导出包含“疑似与中介往来”和“与信贷客户之间非正常资金往来”。

View File

@@ -0,0 +1,24 @@
# 拉取本行流水弹窗交互调整实施记录
## 修改内容
- 将证件号码输入提示调整为“仅支持英文逗号分隔”。
- 证件号码解析仅按英文逗号分隔,不再支持中文逗号、顿号、换行等分隔方式。
- 本行存量流水日期默认值调整为:
- 开始日期:昨天往前一年。
- 结束日期:昨天。
- 日期可选范围限制为 `2025-01-01` 到昨天。
## 影响范围
- 前端页面:
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- 不涉及后端接口、不改数据库。
## 验证情况
- 已在真实项目详情页打开“拉取本行流水”弹窗验证:
- 提示文案显示为仅支持英文逗号分隔。
- 默认开始日期为 `2025-05-25`
- 默认结束日期为 `2026-05-25`
- 当前系统日期为 `2026-05-26`,默认日期符合“开始默认一年前、结束默认昨天”。

View File

@@ -0,0 +1,20 @@
# 导出报告章节标题错行修复实施记录
## 修改内容
- 修复结果总览 PDF 导出报告中,表格结束后下一节标题可能压到表格底边框的问题。
- 原因是 PDFBox 绘制文本时 `y` 坐标为文字基线,表格后仅保留 `8F` 间距15 号章节标题的字形会向上占用空间,导致视觉上与上一张表格边框重叠。
- 将表格后间距调整为 `32F`,确保下一节标题与上一张表格有明显稳定留白。
- 章节标题、子标题和封面标题改为加粗展示;正文、表格文字、颜色和列宽不调整。
## 影响范围
- 后端 PDF 报告导出:
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewReportPdfExporter.java`
- 不涉及前端页面、不改接口、不改数据库。
## 验证情况
- `mvn -pl ccdi-project -am -DskipTests compile` 通过。
- `mvn -pl ccdi-project "-Dtest=CcdiProjectOverviewReportPdfExporterTest" test` 通过2 个测试。
- 新增测试断言表格后间距必须大于章节标题字号,避免后续回退成过小间距。

View File

@@ -0,0 +1,75 @@
# 专项排查图谱实施记录
## 实施范围
- 当前口径保留资金流图谱和关系图谱两个页签。
- 页面嵌入项目详情“专项排查”页签。
- 后端不再实时聚合 `ccdi_bank_statement`
- 资金流图谱后端读取四张基础图谱表,并叠加手工资金流向表:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_manual_edge`
- 关系图谱后端读取四张关系图谱表:
- `lx_rel_node`
- `lx_rel_family_edge`
- `lx_rel_stock_edge`
- `lx_rel_represent_edge`
- 默认查询全部流水;用户选择日期时按 `lx_fund_flow_detail_edge.trx_date` 过滤。
- 点击资金边可分页查看逐笔流水。
- 点击可穿透节点可按 `object_key` 设为中心重新查询。
## 关键改动
- 后端 `CcdiFundGraphMapper.xml` 改为四表查询和实时聚合。
- 后端 `CcdiFundGraphServiceImpl` 支持 `objectKey` 查询、节点穿透和边明细查询。
- 后端新增 `/ccdi/project/fund-graph/search` 主体搜索接口。
- 前端新增 `ruoyi-ui/src/api/ccdi/graph/fundGraph.js`
- 前端新增 `ruoyi-ui/src/api/ccdi/graph/relationGraph.js`
- 前端新增 `FundGraphSection.vue`,提供紧凑型图谱卡片和边明细抽屉。
- `SpecialCheck.vue` 中原“图谱外链展示”占位卡替换为图谱组件。
- `ProjectAnalysisDialog.vue` 中“资金流向”和“关系图谱”页签替换为简版图谱展示。
## 测试数据
测试数据脚本:
```text
sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql
```
dev 库测试数据:
```text
资金流图谱测试身份证号617673198109148314
关系图谱测试身份证号330101198001010011
主体点10
账户点14
持有边14
明细边72
```
覆盖场景:
- 默认全部流水聚合。
- 日期范围筛选聚合。
- 支出 `flag = 1`
- 收入 `flag = 2`
- 家庭关系标签:配偶、父亲、母亲。
- 普通对手方:支付宝、淘宝、美团、财付通、小店、银行转账。
- 家庭关系节点按 `object_key` 设为中心查询。
## 验证
- `mvn -pl ccdi-project -am compile -DskipTests` 通过。
- `npm run build:prod` 通过。
- dev 库 SQL 校验通过:身份证号可定位主体,默认全部流水和日期筛选均能聚合出资金边。
## 注意
- 生产库 DDL 不随应用发布自动执行。
- 生产需人工执行 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql` 后再发布后端。
- 生产如已建资金流图谱旧表,优先执行 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql` 补字段和补索引。
- `lx_fund_flow_sum_edge` 不作为当前纪检资金图谱页面依赖。
- 如果目标库未建四张 `lx_*` 表,后端接口会报表不存在。

View File

@@ -0,0 +1,26 @@
# 图谱评审问题修复实施记录
## 本次修改
- 修复资金流图谱手工新增边在参数缺失时直接抛出服务端异常的问题,改为返回明确业务错误信息。
- 修复资金流图谱“展开一层”可追溯判断固定按 `to` 侧判断的问题,改为按当前中心节点的对端主体判断是否可继续扩展。
- 调整资金流图谱边列表组装逻辑,将真实边与手工边合并后统一排序,再按同一 `limit` 截断,避免结果条数放大和排序口径不一致。
- 删除未使用的重复前端 API 文件,收敛资金流图谱接口引用入口。
## 影响范围
- 后端资金流图谱接口:
- `POST /ccdi/project/fund-graph/manual-edge`
- `GET /ccdi/project/fund-graph/graph`
- 前端资金流图谱 API 组织方式:
- 保留 `ruoyi-ui/src/api/ccdi/graph/fundGraph.js`
- 删除未使用的 `ruoyi-ui/src/api/ccdi/fundGraph.js`
## 验证计划
- 后端执行 `mvn -pl ccdi-project -am -DskipTests compile`
- 前端进入 `ruoyi-ui` 后先执行 `nvm use`,再执行 `npm run build:prod`
- 真实页面验收重点:
- 手工新增资金流向缺少必填项时返回明确错误提示
- 资金边“展开一层”按钮在流入/流出两类边下判断更符合对端主体实际可扩展性
- 同一查询条件下真实边与手工边整体排序和数量上限一致

View File

@@ -0,0 +1,18 @@
# 资金流图谱流水查询报错修复实施记录
## 本次修改
- 修复资金流图谱构建节点列表时,对边两端主体二次回查未设置 `limit` 的问题。
- 该问题会使 MyBatis 生成 `LIMIT null`MySQL 报 `near 'null'`,进而导致资金流图谱查询或点击资金边后的流水明细下钻显示失败。
- 保留 Mapper 层默认 `LIMIT 20`,同时在服务层补齐默认上限,避免其他调用路径再次传入空分页上限。
## 影响范围
- 后端接口:`GET /ccdi/project/fund-graph/graph`
- 关联前端表现:资金流图谱加载和资金边流水明细查询
## 验证
- 执行后端编译,确认图谱模块通过编译。
- 重新打包并按项目脚本重启后端,使运行中的 jar 包含本次修复。
- 使用真实接口查询资金流图谱和资金边流水明细,确认不再出现 `LIMIT null` SQL 错误。

View File

@@ -0,0 +1,36 @@
# 图谱改动梳理与验收文档实施记录
## 本次修改
- 新增图谱功能改动梳理与验收清单:`docs/tests/plans/2026-05-29-graph-acceptance-checklist.md`
- 新增本轮图谱验收执行记录:`docs/tests/records/2026-05-29-graph-acceptance-record.md`
- 新增资金流图谱已建表环境补充 SQL`sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql`
- 明确已建表场景下的 SQL 口径:保留 `CREATE TABLE IF NOT EXISTS` 作为新环境参考,已建表只做字段、索引、字符集、排序规则核对和补充。
- 明确当前图谱口径:保留资金流图谱和关系图谱两个页签。
- 明确资金流图谱验收样例:当前可用身份证号为 `617673198109148314`
## 影响范围
- 不修改业务代码或运行配置。
- 新增的 SQL 只用于已建表环境补字段、补索引和补建手工边表,不会删除或清空已有图谱数据。
- 文档覆盖资金流图谱、关系图谱、专项排查页面、项目分析弹窗和数据库补充口径。
## 验证情况
- 后端编译:`mvn -pl ccdi-project -am compile -DskipTests` 通过。
- 前端构建:`npm run build:prod` 通过;`nvm` 在当前 PowerShell 环境不可用,实际 Node 为 `v22.22.0`
- 接口验收:
- 资金图谱查询返回 10 个节点、18 条边、72 笔。
- 资金边明细分页返回正常。
- 关系图谱查询返回 3 个节点、2 条边。
- 疑似同名企业查询返回 1 条候选。
- 页面验收:
- 已打开真实 `http://localhost/` 页面并进入项目 `90342` 专项排查页。
- 资金图谱查询展示 `18 条资金边``72 笔``302,844.78 元`
- 图谱相关网络请求均返回 `200`
- 浏览器控制台未发现 `error`
## 后续事项
- 若生产已建资金流旧表,执行 `06_lx_fund_graph_existing_table_supplement.sql` 前需先人工核对目标库和表结构差异。
- 当前 PowerShell 环境 `nvm` 不可用,前端构建实际使用 Node `v22.22.0`

View File

@@ -0,0 +1,145 @@
# 图谱预备提交改动与功能清单
## 1. 提交范围建议
本清单按“图谱功能”口径整理预备提交内容。提交前建议只纳入下列图谱相关文件,避免混入其他业务、环境配置或本地产物。
### 1.1 后端图谱代码
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFundGraphController.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiRelationGraphController.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiFundGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiRelationGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFundGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiRelationGraph*.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiFundGraphMapper.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiRelationGraphMapper.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFundGraphService.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiRelationGraphService.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFundGraphServiceImpl.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiRelationGraphServiceImpl.java`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFundGraphMapper.xml`
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiRelationGraphMapper.xml`
### 1.2 前端图谱代码
- `ruoyi-ui/src/api/ccdi/graph/fundGraph.js`
- `ruoyi-ui/src/api/ccdi/graph/relationGraph.js`
- `ruoyi-ui/src/views/ccdiProject/components/detail/graph/FundGraphSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisFundFlowTab.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
### 1.3 数据库脚本
- `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`
- `sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql`
- `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql`
- `sql/ccdi/graph/04_lx_relation_graph_build_mysql.sql`
- `sql/ccdi/graph/05_lx_relation_graph_seed_test_data.sql`
- `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql`
说明:
- `01``03` 是新环境建表参考。
- `06` 是资金流图谱已建表环境补字段、补索引脚本,不删除、不重建、不清空基座数据。
- 生产数据库变更由人工确认后手动执行,不随应用发布自动执行。
### 1.4 文档与验收记录
- `docs/plans/backend/2026-05-28-fund-graph-backend-implementation.md`
- `docs/plans/frontend/2026-05-28-fund-graph-frontend-implementation.md`
- `docs/plans/fullstack/2026-05-28-graph-development-decisions.md`
- `docs/plans/fullstack/2026-05-28-graph-production-db-change-list.md`
- `docs/reports/implementation/2026-05-28-fund-graph-special-check-implementation.md`
- `docs/reports/implementation/2026-05-29-fund-graph-review-fixes-implementation.md`
- `docs/reports/implementation/2026-05-29-fund-graph-statement-query-fix.md`
- `docs/reports/implementation/2026-05-29-graph-acceptance-doc-and-verification.md`
- `docs/reports/implementation/2026-05-29-graph-precommit-summary.md`
- `docs/tests/plans/2026-05-29-graph-acceptance-checklist.md`
- `docs/tests/records/2026-05-29-graph-acceptance-record.md`
### 1.5 提交前需要谨慎确认的文件
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiModelParamServiceImpl.java`
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java`
这两个文件只增加 `@Lazy` 解决服务循环依赖,属于图谱运行联动修复,可纳入图谱提交。
以下文件当前工作区存在改动,但是否属于图谱提交需单独确认:
- `ruoyi-admin/src/main/resources/application-dev.yml`
- `tongweb_62318.properties`
- `ruoyi-ui/src/views/ccdiProject/components/detail/FamilyAssetLiabilityDetail.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/FamilyAssetLiabilitySection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
以下内容不建议纳入提交:
- `output/` 下的浏览器截图和验收临时产物。
- `docs/prototypes/` 下的视觉探索图片,除非本次明确要提交设计参考图。
- `ruoyi-admin/src/main/resources/!cLEZGP.docx`
- `.DS_Store`
## 2. 功能清单
### 2.1 资金流图谱
- 主体搜索:按身份证号、姓名或 `object_key` 定位资金流主体。
- 一跳图谱:以当前主体为中心查询一层资金往来。
- 实时聚合:基于 `lx_fund_flow_detail_edge` 按当前筛选条件聚合金额、笔数、首末交易时间。
- 日期筛选:按 `trx_date` 做交易日期范围过滤。
- 金额筛选:支持最小汇总金额和金额范围过滤。
- 方向筛选:支持支出 `1`、收入 `2`
- 家庭关系标签:资金边可展示已写入 `lx_fund_flow_detail_edge.family_relation_type` 的配偶、父母、子女等标签;后端不实时按家庭表匹配。
- 边明细下钻:点击真实资金边分页查看逐笔流水。
- 节点详情:点击节点查看主体字段、证件号、账户数、累计金额和笔数。
- 节点穿透:可穿透节点支持“设为中心查询”和“一层展开”。
- 手工资金流向:支持人工录入主体级资金流向边。
- 手工边展示:手工边参与图谱展示和统一排序,但不提供逐笔流水下钻。
- 排序与上限:真实边和手工边合并后统一按金额、笔数、最近交易时间排序并按 `limit` 截断。
- 缺参提示:手工新增缺少起点或终点时返回明确业务提示。
### 2.2 关系图谱
- 主体搜索:按身份证号、姓名、统一社会信用代码或 `object_key` 查主体。
- 一跳关系图谱:以主体为中心展示家庭、股东、法人关系。
- 家庭关系边:展示员工与家庭成员关系。
- 股东持股边:展示自然人股东、企业股东与企业之间的持股关系。
- 法定代表人边:展示法人和企业之间的代表关系。
- 节点详情:展示主体名称、证件号或统一社会信用代码、主体类型、来源类型。
- 边详情:按关系来源展示关系类型、企业名称、持股比例、出资额、家庭关系字段等。
- 疑似同名企业:按人员姓名召回工商法人和自然人股东候选。
- 同名过多阻断:同名候选过多时提示缩小线索范围。
- 年龄过滤:能解析出生日期时过滤企业成立时未满 18 岁的候选。
### 2.3 页面集成
- 专项排查页:原图谱占位卡替换为真实图谱工作台。
- 专项排查资金图谱:展示搜索区、图谱画布、右侧节点/边详情、边明细分页。
- 专项排查关系图谱:展示关系搜索、图谱画布、右侧节点/边详情、疑似企业面板。
- 项目分析弹窗资金流向:展示简版资金图谱,不展示逐笔流水表。
- 项目分析弹窗关系图谱:展示简版关系图谱,切换页签时触发图谱 resize。
- 图谱画布:使用 ECharts 渲染节点、边、方向、标签和关系区分样式。
### 2.4 数据库与部署
- 资金流图谱新环境表结构脚本。
- 资金流图谱测试数据脚本。
- 资金流图谱已建表补充脚本。
- 关系图谱新环境表结构脚本。
- 关系图谱构建脚本。
- 关系图谱测试数据脚本。
- 统一 `utf8mb4``utf8mb4_general_ci` 口径。
- 图谱 DDL 不自动随应用发布执行。
## 3. 最新内容检查
截至 2026-05-29本预备提交清单已按当前代码和验收结果更新
- 当前范围是“资金流图谱 + 关系图谱”。
- 当前资金流图谱验收样例为 `617673198109148314`
- 当前关系图谱验收样例为 `330101198001010011`
- `docs/tests/` 下本轮图谱验收清单和记录已纳入本次提交清单。
- `output/` 仍保持忽略,浏览器截图不纳入提交。

View File

@@ -0,0 +1,25 @@
# 短时间多次存现本人口径调整实施记录
## 修改内容
- 调整后端大额交易模型规则 `FREQUENT_CASH_DEPOSIT`
- 删除规则 SQL 中关系人流水 `UNION ALL` 分支。
- 规则现仅统计员工本人流水,即 `ccdi_base_staff.id_card = ccdi_bank_statement.cret_no`
## 影响范围
- 影响文件:`ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml`
- 影响规则:短时间多次存现。
- 配偶、父母、子女等员工关系人流水不再自动归集到员工本人名下触发该规则。
- 已生成的历史打标结果不会自动变化,需要重新执行项目分析/打标后生效。
## 验证情况
- 已检查 `selectFrequentCashDepositObjects` 规则片段,确认仅保留员工本人分支。
- 已执行 `mvn -pl ccdi-project -am compile -DskipTests`,编译通过。
- 已通过 `output/frequent_cash_deposit_self_only_verification.sql` 写入临时验证数据并执行规则等价 SQL
- 员工 A 本人 3 笔存现,命中员工 A。
- 配偶 B 同时也是员工B 本人 3 笔存现,命中员工 B。
- 当前本人口径不会把 B 的流水归集到员工 A。
- 对照旧关系人分支会额外命中员工 A验证本次删除关系人分支有效。
- 验证脚本末尾已清理临时员工、关系人与流水数据,并复查三张表残留数均为 0。

View File

@@ -0,0 +1,35 @@
# 涉疑交易明细模型命中范围调整实施记录
## 背景
风险明细模块的「涉疑交易」列表此前在模型规则命中部分限制为 `rule_name like '%可疑%'`,并额外包含 `ABNORMAL_CUSTOMER_TRANSACTION`。该口径会导致大额单笔、短时间多笔存现等已有流水级命中模型无法进入涉疑交易明细。
本次确认的业务口径为:涉疑交易明细应放开展示所有流水级模型命中;名单库命中只在同一笔流水同时命中时优先展示匹配对象和标签,不作为模型命中的过滤条件。
## 修改内容
- 调整 `CcdiProjectOverviewMapper.xml``suspiciousTransactionModelHitSql`
- 保留 `tr.project_id = #{query.projectId}`
- 保留 `tr.bank_statement_id is not null`,仅展示可落到具体流水的命中。
- 删除 `rule_name like '%可疑%'``tr.rule_code = 'ABNORMAL_CUSTOMER_TRANSACTION'` 限制。
- 保持名单库命中逻辑不变:
- 信贷客户账号命中优先级为 1。
- 中介账号、姓名、企业名称命中按原 `matchPriority` 聚合。
- 同一笔流水既有模型命中又有名单命中时,`suspiciousPersonName``nameListHitType` 仍优先取名单命中结果。
- 更新 SQL 单测,明确校验模型命中范围已放开,并校验名单命中优先级聚合逻辑仍存在。
## 影响范围
- 影响页面:项目详情风险明细模块的「涉疑交易」列表及导出。
- 影响数据范围:所有 `ccdi_bank_statement_tag_result.bank_statement_id is not null` 的流水级规则命中均可进入涉疑交易明细。
- 不影响对象级命中:`bank_statement_id is null` 的员工/家庭/资产等对象级结果仍不会进入该流水明细列表。
- 不影响名单库命中阈值和匹配规则。
## 验证
- 已执行 `mvn -pl ccdi-project -Dtest=CcdiProjectOverviewMapperSqlTest test`,结果通过:`Tests run: 7, Failures: 0, Errors: 0, Skipped: 0`
- 验证点:
- `suspiciousTransactionModelHitSql` 不再包含 `rule_name like '%可疑%'`
- `suspiciousTransactionModelHitSql` 不再包含 `ABNORMAL_CUSTOMER_TRANSACTION` 单规则特例。
- `suspiciousTransactionModelHitSql` 保留 `tr.bank_statement_id is not null`
- 聚合 SQL 保留基于 `matchPriority` 的名单命中优先展示逻辑。

View File

@@ -0,0 +1,38 @@
# 银行流水打标理财类流水剔除实施记录
## 修改背景
- 员工纪检场景下,单纯理财申购、理财购买、结构性存款、理财赎回等流水属于资产形态转换,不应仅因金额较大命中大额交易或炒股模型。
- 桌面本地知识库中的流水底层打标规则将“理财支出/理财收回”作为独立标签口径,银证类逻辑更聚焦“证券/期货 + 银证转账/银转证/证转银”等特征。
## 修改内容
-`CcdiBankTagAnalysisMapper.xml` 新增 `financialProductExclusionPredicate` SQL 片段,按当前流水字段识别理财类流水并排除。
- 大额交易模型接入理财排除:
- 房车消费支出交易
- 税务支出交易
- 大额单笔收入
- 累计收入超限
- 年流水交易额超限
- 大额存现交易
- 短时间多次存现
- 大额转账交易
- 炒股相关模型接入理财排除:
- 可疑银证大额转账
- 大额炒股
- 从“大额炒股”关键词中移除 `理财`,并补充银证转账、银证、证转银、银转证等更贴近炒股转账的关键词。
- 大额转账交易删除 `USER_MEMO NOT LIKE '%款%'` 排除条件。业务口径为“大额转账单笔超过设置限额”,不应因摘要包含“借款、还款、工程款、货款、服务款”等“款”字统一剔除,避免员工纪检场景漏报。
## 影响范围
- 仅影响银行流水打标 SQL 命中口径,不修改前端、接口结构、参数编码和数据库结构。
- 理财类流水将不再因金额较大命中大额交易模型,也不会因包含“理财”关键词命中炒股模型。
- 单独出现“申购/认购/赎回”不会直接被排除,必须同时伴随理财、产品、存款、本金、余额宝、朝朝宝等理财特征,避免误排证券申购或证转银场景。
- 大额转账交易会重新纳入摘要包含“款”的大额转账流水,仍保留同名账户排除与理财类流水排除。
## 验证情况
- XML 解析校验通过:`CcdiBankTagAnalysisMapper.xml` 可正常解析,当前包含 35 个 `select` 与 9 个 `sql` 片段。
- 后端编译验证通过:`mvn -pl ccdi-project -am compile -DskipTests` 执行成功。
- 编译过程中 Maven 提示 `ccdi-info-collection` 存在既有 `ccdi-lsfx` 重复依赖声明警告,本次未修改该模块。
- 本次不启动前端页面测试,修改范围为后端 Mapper SQL。

View File

@@ -0,0 +1,42 @@
# 微信支付宝提现打标规则实施记录
## 修改背景
- 当前 `WITHDRAW_CNT` 使用 `AMOUNT_CR >= 0` 与微信、支付宝关键词识别频繁提现,容易把消费、转账、充值、退款等第三方支付流水误计为提现。
- `WITHDRAW_AMT` 规则元数据和参数已存在,但 Mapper SQL 仍为占位实现,实际不会命中。
- 本次参考桌面本地知识库中支付宝、微信提现识别口径:优先使用 `BANK in ('ALIPAY','WECHAT')` 与提现特征;当 `BANK` 未识别到微信或支付宝时,再使用平台关键词与提现特征组合兜底。
## 修改内容
-`CcdiBankTagAnalysisMapper.xml` 新增 `thirdPartyWithdrawIncomePredicate`,统一识别微信、支付宝提现到账流水。
- 调整 `WITHDRAW_CNT`
- 金额方向改为 `AMOUNT_CR > 0`,表示提现到账收入。
- 不再仅凭微信、支付宝、财付通等平台关键词计数。
- 按员工和交易日统计提现到账次数,超过 `WITHDRAW_CNT` 阈值命中。
- 补齐 `WITHDRAW_AMT`
- 按员工和交易日汇总提现到账收入金额。
- 超过 `WITHDRAW_AMT` 阈值命中。
- Java 调用链补充 `WITHDRAW_AMT` 参数传递:
- Mapper 方法增加 `amountThreshold` 参数。
- Service 执行规则时传入 `WITHDRAW_AMT` 参数。
- 参数解析器增加 `WITHDRAW_AMT -> WITHDRAW_AMT` 映射。
## 性能考虑
- SQL 保留 `project_id``AMOUNT_CR > 0`、员工身份证关联等基础过滤,先缩小参与匹配的数据集。
- `BANK in ('ALIPAY','WECHAT')` 使用精确判断优先命中。
- 只有 `BANK` 未识别为 `ALIPAY/WECHAT` 时,才走支付宝、微信、财付通等关键词兜底,降低误报和无谓匹配范围。
## 影响范围
- 仅影响异常行为模型下:
- `WITHDRAW_CNT` 微信支付宝频繁提现
- `WITHDRAW_AMT` 微信支付宝提现超额
- 不修改前端、数据库结构和菜单权限。
## 验证情况
- XML 解析校验通过:`CcdiBankTagAnalysisMapper.xml` 可正常解析,当前包含 35 个 `select` 与 10 个 `sql` 片段。
- 后端编译验证通过:`mvn -pl ccdi-project -am compile -DskipTests` 执行成功。
- 文本核对通过:`WITHDRAW_CNT` 已不再使用 `AMOUNT_CR >= 0``WITHDRAW_AMT` 已不再使用占位 SQL。
- 编译过程中 Maven 提示 `ccdi-info-collection` 存在既有 `ccdi-lsfx` 重复依赖声明警告,本次未修改该模块。

View File

@@ -0,0 +1,32 @@
# 大额存现与赌博敏感关键词调整实施记录
## 修改时间
2026-06-04
## 修改背景
员工纪检流水打标模型中,大额存现交易需要按员工本人名下流水统计,不再纳入家庭成员名下流水。疑似赌博敏感交易需要覆盖交易摘要或交易对手中的“游戏、抖币、体彩、福彩”等关注字眼,同时删除单字“球”以减少正常球类消费误报。
## 修改内容
1. 调整 `selectLargeCashDepositStatements`
- 保留员工本人证件号匹配 `ccdi_base_staff.id_card = ccdi_bank_statement.cret_no`
- 移除家庭成员证件号匹配 `ccdi_staff_fmy_relation.relation_cert_no = ccdi_bank_statement.cret_no`
2. 调整 `selectGamblingSensitiveKeywordStatements`
- 删除宽泛单字关键词 `球`
- 保留并补充博彩及敏感娱乐关键词:`游戏``抖币``体彩``福彩``彩票``赌博``赌球``下注``投注``球赛投注``外围``博彩``六合``时时彩``赛车``赌场``筹码``盘口``返水``洗码``庄家``闲家``百家乐``斗牛``炸金花``牌九``麻将``捕鱼``电子游艺``VIP666``USDT下注`
- 命中字段仍为交易摘要 `USER_MEMO` 和交易对手 `CUSTOMER_ACCOUNT_NAME`
## 影响范围
- 大额存现交易命中范围收窄为员工本人名下流水。
- 疑似赌博敏感交易继续覆盖游戏、抖币等关注字眼,同时减少单字“球”造成的正常球类消费误报。
- 不影响短时间多次存现规则;该规则此前已经仅统计员工本人名下流水。
- 不新增表结构和参数配置。
## 验证情况
- 已执行 MyBatis XML 解析检查。
- 已执行 `mvn -pl ccdi-project -am compile -DskipTests` 编译验证。

View File

@@ -0,0 +1,30 @@
# 结果总览弹窗资金流向逐笔流水展示实施记录
## 修改内容
- 去除结果总览“查看详情”弹窗资金边详情中的“弹窗速览”提示。
- 结果总览弹窗“资金流向”页签启用逐笔流水表格展示。
- 保持逐笔流水按现有接口分页加载,不一次性拉取全量数据。
- 针对弹窗场景改为上方大图谱、下方逐笔流水明细布局,避免详情栏挤压图谱画布。
- 资金边金额标签改为“万/亿”短格式,选中边时高亮当前边和两端节点,其余关系降透明。
- 单边明细不再展示“返回全量”按钮,改为点击图谱画布空白区域恢复到刚进入“资金流向”页签时的全量图谱第一眼状态。
- 空白点击恢复全量时同步清除选中边、选中节点、逐笔流水数据、明细加载状态和 ECharts 内部强调状态,避免画布残留单条资金边高亮。
- 空白点击监听仅绑定资金流向图谱画布,不影响下方逐笔流水表格和分页操作。
- 逐笔流水接口增加请求序号校验,返回全量后丢弃旧选中边的异步响应,避免旧请求回写导致明细区再次出现。
- 弹窗资金流向明细隐藏累计金额、交易笔数、关系三张汇总卡片,减少重复信息占用空间。
## 影响范围
- 前端页面:结果总览“查看详情”弹窗中的“资金流向”页签。
- 复用组件:`FundGraphSection` 新增弹窗场景可配置项,默认行为不变。
- 不影响专项排查页图谱展示,不修改后端接口和数据库。
## 验证情况
- 前端命令执行前已尝试 `nvm use`,当前环境未识别 `nvm`;实际 Node 版本为 `v22.22.0`npm 版本为 `10.9.4`
- 已多次执行 `npm run build:prod`,构建通过,仅保留现有资源体积提示。
- 已使用真实页面验证 `http://localhost/ccdiProject/detail/90342?tab=overview`:在结果总览点击“查看详情”,切换“资金流向”,选中交易笔数最多的资金边。
- 多笔金额场景验证结果:当前图谱 19 条资金边,选中边为“彭静勇 → 张建强”,交易笔数 5逐笔流水表格显示 5 条并分页显示“共 5 条”。
- 展示验证结果:旧“弹窗速览”提示和“项目分析弹窗仅展示汇总信息”说明均不再出现;金额标签压缩为 `2.22万, 5笔`;汇总卡片已隐藏;逐笔流水展示在图谱下方,未裁切图谱区域。
- 恢复全量验证结果:选中“彭静勇 → 张建强”后展示 5 条逐笔流水,其余 18 条资金边降透明;点击图谱空白区域后,旧明细请求未回写,选中边、选中节点、逐笔流水数据和明细总数均清空;图表仍展示 15 个图形节点、19 条资金边,边样式恢复为 opacity `0.9`、width `1.9`,不再残留单条高亮边。
- 页面截图已保存到 `output/browser-use/project-analysis-fund-flow-final-graph.png``output/browser-use/project-analysis-fund-flow-final-detail.png``output/browser-use/project-analysis-fund-flow-reset-all.png``output/browser-use/project-analysis-fund-flow-reset-first-view.png``output/browser-use/project-analysis-fund-flow-blank-click-reset.png`

View File

@@ -0,0 +1,127 @@
# 图谱功能改动梳理与验收清单
## 1. 验收目标
本清单用于继续验收当前图谱相关改动,覆盖资金流图谱和关系图谱两部分,重点确认:
- 已改动内容分别承担什么功能。
- 后端接口是否返回正常,是否出现 `500`、鉴权异常或 SQL 报错。
- 专项排查和项目分析弹窗中的真实页面是否能打开和查询。
- 已建图谱表不重复建表,仅核对必要字段、索引和补充脚本。
## 2. 改动梳理
### 2.1 资金流图谱
| 类型 | 文件/入口 | 功能 |
| --- | --- | --- |
| 后端 Controller | `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFundGraphController.java` | 提供主体搜索、一层资金图谱查询、边流水明细分页、手工资金流向保存接口。 |
| 后端 Service | `CcdiFundGraphServiceImpl.java` | 参数归一化、中心主体定位、真实边和手工边合并排序、节点组装、手工边保存、默认 `limit` 防空。 |
| 后端 Mapper | `CcdiFundGraphMapper.java``CcdiFundGraphMapper.xml` | 读取 `lx_fund_flow_*` 表,按账户明细实时聚合主体资金边,查询逐笔流水,写入手工边。 |
| DTO/VO | `CcdiFundGraph*DTO.java``CcdiFundGraph*VO.java` | 定义查询条件、手工边保存参数、图谱节点、资金边、流水明细返回结构。 |
| 前端 API | `ruoyi-ui/src/api/ccdi/graph/fundGraph.js` | 封装 `/ccdi/project/fund-graph/*` 接口。 |
| 前端组件 | `FundGraphSection.vue` | 资金图谱主工作台搜索、日期范围、最小金额、ECharts 图谱、节点/边详情、边流水分页、手工新增资金流向。 |
| 专项排查入口 | `SpecialCheck.vue` | 将原图谱占位卡替换为真实图谱组件。 |
| 项目分析弹窗 | `ProjectAnalysisDialog.vue``ProjectAnalysisFundFlowTab.vue` | 在“资金流向”页签内展示简版资金图谱,弹窗内不展示逐笔流水表。 |
| SQL | `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql``02_lx_fund_graph_seed_test_data.sql``06_lx_fund_graph_existing_table_supplement.sql` | 记录资金图谱必要表结构、开发联调测试数据,以及已建表环境补字段/补索引脚本。 |
### 2.2 关系图谱
| 类型 | 文件/入口 | 功能 |
| --- | --- | --- |
| 后端 Controller | `CcdiRelationGraphController.java` | 提供主体搜索、一层关系图谱查询、疑似同名企业查询接口。 |
| 后端 Service | `CcdiRelationGraphServiceImpl.java` | 关系图谱主体定位、家庭/股东/法人边合并展示、疑似企业按姓名召回与年龄规则过滤。 |
| 后端 Mapper | `CcdiRelationGraphMapper.java``CcdiRelationGraphMapper.xml` | 读取 `lx_rel_node``lx_rel_family_edge``lx_rel_stock_edge``lx_rel_represent_edge`。 |
| DTO/VO | `CcdiRelationGraph*DTO.java``CcdiRelationGraph*VO.java` | 定义关系图谱查询、节点、边、疑似企业候选返回结构。 |
| 前端 API | `ruoyi-ui/src/api/ccdi/graph/relationGraph.js` | 封装 `/ccdi/project/relation-graph/*` 接口。 |
| 前端组件 | `FundGraphSection.vue` | 同一组件内提供“关系图谱”页签、节点/边详情和疑似同名企业面板。 |
| 项目分析弹窗 | `ProjectAnalysisDialog.vue` | “关系图谱”页签由占位页改为真实关系图谱简版展示。 |
| SQL | `03_lx_relation_graph_mysql_ddl.sql``04_lx_relation_graph_build_mysql.sql``05_lx_relation_graph_seed_test_data.sql` | 关系图谱建表、构建和测试数据脚本。DDL 已包含旧表补字段/补索引逻辑。 |
### 2.3 关联修复
| 文件 | 功能 |
| --- | --- |
| `CcdiModelParamServiceImpl.java` | 对 `ICcdiBankTagService` 增加 `@Lazy`,缓解服务循环依赖启动问题。 |
| `CcdiProjectOverviewServiceImpl.java` | 对 `ICcdiModelParamService` 增加 `@Lazy`,缓解服务循环依赖启动问题。 |
| `application-dev.yml``tongweb_62318.properties` | 本地/部署运行配置存在改动,不纳入本次图谱验收清单。 |
## 3. 建表与补充脚本验收口径
当前表已建好,本轮不重复执行建表脚本。验收时按以下口径处理:
- `CREATE TABLE IF NOT EXISTS` 可保留在仓库内,作为新环境初始化和评审依据。
- 已存在表只做字段、索引、字符集、排序规则核对。
- 如已建资金流图谱表缺字段或缺索引,优先使用 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql` 补充,不要先删表再重建。
- 如已建关系图谱表缺字段或缺索引,优先使用 `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql` 中的补充逻辑。
- 所有图谱表、字符字段和新增索引保持 `utf8mb4``utf8mb4_general_ci` 口径。
- 执行包含中文内容的 SQL 文件时使用 `bin/mysql_utf8_exec.sh <sql-file>`
- 生产环境 DDL 和补充 SQL 由人工确认后手动执行,不随应用发布自动执行。
资金流图谱需核对:
- `lx_fund_flow_subject_node`
- `lx_fund_flow_account_node`
- `lx_fund_flow_own_account_edge`
- `lx_fund_flow_detail_edge`
- `lx_fund_flow_manual_edge`
关系图谱需核对:
- `lx_rel_node`
- `lx_rel_family_edge`
- `lx_rel_stock_edge`
- `lx_rel_represent_edge`
## 4. 验收清单
### 4.1 静态与构建
- [ ] 后端 `mvn -pl ccdi-project -am compile -DskipTests` 通过。
- [ ] 前端执行前确认 Node 版本;如 `nvm` 不可用,记录实际 Node 版本。
- [ ] 前端 `npm run build:prod` 通过,无新增编译错误。
- [ ] 检查暂存区确认验收文档、业务代码、SQL、构建产物边界清晰。
### 4.2 资金流图谱接口
- [ ] 未登录访问图谱接口返回认证失败,不泄露数据。
- [ ] 管理员登录后,`/ccdi/project/fund-graph/search` 可按身份证号查到主体。
- [ ] `/ccdi/project/fund-graph/graph` 默认查询返回节点、边、总金额、交易笔数。
- [ ] 日期范围查询返回正常,不出现 SQL 字符集/排序规则错误。
- [ ] `direction=1` 支出查询返回正常。
- [ ] `direction=2` 收入查询返回正常。
- [ ] 点击边对应的 `/edge-detail` 分页返回正常,不再出现 `LIMIT null`
- [ ] 手工新增资金流向缺少起点时返回明确业务错误,不抛服务端异常栈。
- [ ] 手工边与真实边合并后统一排序和统一 `limit`
### 4.3 关系图谱接口
- [ ] `/ccdi/project/relation-graph/search` 可按身份证号或姓名查到主体。
- [ ] `/ccdi/project/relation-graph/graph` 返回家庭、股东、法人关系边。
- [ ] `/ccdi/project/relation-graph/suspected-enterprises` 可按姓名召回疑似企业。
- [ ] 同名候选过多时返回阻断提示,不直接大批量展示。
- [ ] 出生日期或身份证可用时,成立时未满 18 岁候选被过滤。
### 4.4 专项排查页面
- [ ] 登录后进入真实项目详情页,不打开原型页。
- [ ] 切换到“专项排查”,图谱分析区域出现。
- [ ] 资金流图谱默认空态文案正确。
- [ ] 输入身份证号后点击查询,资金边统计展示正确。
- [ ] 图谱画布非空白,页面无明显遮挡、错位。
- [ ] 点击资金边后右侧展示金额、笔数、关系标签和逐笔流水。
- [ ] 切换到关系图谱页签,搜索后页面无白屏、无控制台错误。
- [ ] 浏览器控制台无 `error`,图谱相关网络请求均为 `200`
### 4.5 项目分析弹窗
- [ ] 在结果总览人员行点击“查看详情”打开项目分析弹窗。
- [ ] “资金流向”页签展示简版图谱,无搜索栏,画布能渲染。
- [ ] “关系图谱”页签展示简版关系图谱,切换页签后图谱重新 resize。
- [ ] 弹窗版不展示逐笔流水表,仅展示汇总信息。
- [ ] 弹窗控制台无图谱 resize、ECharts 初始化、接口调用异常。
### 4.6 待重点复核项
- [ ] `nvm` 在当前 PowerShell 环境不可用;前端构建已记录实际 Node 版本。
- [ ] 已建表环境执行补充前,先人工确认目标库、表结构差异和备份策略。

View File

@@ -0,0 +1,101 @@
# 2026-05-29 图谱功能验收执行记录
## 1. 执行信息
- 执行时间2026-05-29 17:36 ~ 17:43Asia/Shanghai
- 验收环境:本机真实服务
- 前端地址:`http://localhost/`
- 后端地址:`http://localhost:62318`
- 验收清单:`docs/tests/plans/2026-05-29-graph-acceptance-checklist.md`
- 测试项目:`projectId=90342`,项目名 `test拉取行内流水`
- 管理员账号:`admin`
## 2. 构建与静态验证
| 项目 | 命令 | 结果 |
| --- | --- | --- |
| 后端编译 | `mvn -pl ccdi-project -am compile -DskipTests` | 通过 |
| 前端 Node 确认 | `nvm use; node -v` | `nvm` 不可用,实际 Node 为 `v22.22.0` |
| 前端构建 | `npm run build:prod` | 通过,有既有资源体积 warning |
说明:
- Maven 输出存在既有重复依赖声明 warning`ccdi-info-collection``ccdi-lsfx` 依赖重复。
- 前端构建只有资源体积 warning无编译失败。
## 3. 接口验收
### 3.1 鉴权
- 未登录访问 `/ccdi/project/fund-graph/search`:返回 `401`,符合预期。
- 未登录访问 `/ccdi/project/relation-graph/search`:返回 `401`,符合预期。
- 使用 `/login` 登录成功,后续接口带 `Bearer token` 验收。
### 3.2 资金流图谱
测试身份证号:`617673198109148314`
| 接口/场景 | 结果 |
| --- | --- |
| `/ccdi/project/fund-graph/search?keyword=617673198109148314` | `code=200`,查到 1 个主体 |
| `/ccdi/project/fund-graph/graph?keyword=617673198109148314&limit=20&minTotalAmount=0` | `code=200`10 个节点、18 条边、72 笔、总金额 `302844.78` |
| 日期范围查询 | `code=200`18 条边、72 笔 |
| `direction=1` 支出查询 | `code=200`9 条边、45 笔 |
| `direction=2` 收入查询 | `code=200`9 条边、27 笔 |
| 第一条资金边明细分页 | `code=200`,返回 5 条,总数 5 |
| 手工新增资金流向缺少起点 | 返回 `code=500`,业务提示:`起点主体不能为空` |
结论:
- 资金图谱核心查询、方向筛选、边明细分页可用。
- 手工新增缺少必填项时已返回明确提示,未出现服务端异常栈。
### 3.3 关系图谱
测试身份证号:`330101198001010011`
| 接口/场景 | 结果 |
| --- | --- |
| `/ccdi/project/relation-graph/search?keyword=330101198001010011` | `code=200`,查到 1 个主体 |
| `/ccdi/project/relation-graph/graph?keyword=330101198001010011&limit=80` | `code=200`3 个节点、2 条边 |
| `/ccdi/project/relation-graph/suspected-enterprises` | `code=200`,返回 1 条疑似企业,`blocked=false` |
结论:
- 关系图谱接口可用,疑似企业查询可用。
## 4. 页面验收
使用 Playwright CLI 打开真实页面 `http://localhost/` 执行:
1. 登录系统。
2. 进入初核项目管理。
3. 打开项目 `90342` 的详情页。
4. 切换到“专项排查”。
5. 在“资金流图谱”输入 `617673198109148314` 并查询。
6. 切换“关系图谱”,输入 `330101198001010011` 并查询。
7. 检查控制台和网络请求。
结果:
- 专项排查页面可正常加载图谱分析区域。
- 资金流图谱查询后页面展示 `18 条资金边``72 笔``302,844.78 元`
- 关系图谱查询接口返回 `200`,页面无白屏。
- 图谱相关网络请求:
- `/dev-api/ccdi/project/fund-graph/graph?keyword=617673198109148314&minTotalAmount=1000&limit=20` 返回 `200`
- `/dev-api/ccdi/project/relation-graph/graph?keyword=330101198001010011&limit=80` 返回 `200`
- 浏览器控制台 `error` 数量为 0。
- 截图证据:`output/playwright/graph-acceptance-special-check.png`
## 5. 注意事项
| 编号 | 级别 | 事项 | 影响 | 建议 |
| --- | --- | --- | --- | --- |
| GRAPH-002 | P2 | 当前 PowerShell 环境 `nvm` 不可用 | 不满足“前端命令前先 nvm use”的执行规范 | 修复 nvm 安装或 PATH本轮实际使用 Node `v22.22.0` |
| GRAPH-004 | P2 | 资金流 DDL 原先只有新建表口径,已建旧表需要差异补充 | 生产若已建旧表但缺字段,单靠 `CREATE TABLE IF NOT EXISTS` 不会补齐 | 已补充 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql`,执行前需人工核对 |
## 6. 阶段性结论
本轮未发现图谱接口 `500`、页面白屏或浏览器控制台 error。资金流图谱和关系图谱在当前本机真实服务上均可查询。
当前图谱主链路验收通过。剩余事项为环境类或发布前核对类事项:`GRAPH-002` 需要修复本机 `nvm` 环境,`GRAPH-004` 需要在生产执行补充 SQL 前人工核对目标库和表结构差异。

View File

@@ -28,6 +28,7 @@
<oshi.version>6.9.1</oshi.version>
<commons.io.version>2.21.0</commons.io.version>
<poi.version>4.1.2</poi.version>
<pdfbox.version>2.0.30</pdfbox.version>
<easyexcel.version>3.3.4</easyexcel.version>
<velocity.version>2.3</velocity.version>
<jwt.version>0.9.1</jwt.version>
@@ -131,6 +132,13 @@
<version>${poi.version}</version>
</dependency>
<!-- pdf导出工具 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>${pdfbox.version}</version>
</dependency>
<!-- easyexcel工具 -->
<dependency>
<groupId>com.alibaba</groupId>

View File

@@ -0,0 +1,33 @@
import request from "@/utils/request";
export function searchFundGraphSubjects(query) {
return request({
url: "/ccdi/project/fund-graph/search",
method: "get",
params: query,
});
}
export function getFundGraph(query) {
return request({
url: "/ccdi/project/fund-graph/graph",
method: "get",
params: query,
});
}
export function getFundGraphEdgeDetail(query) {
return request({
url: "/ccdi/project/fund-graph/edge-detail",
method: "get",
params: query,
});
}
export function saveFundGraphManualEdge(data) {
return request({
url: "/ccdi/project/fund-graph/manual-edge",
method: "post",
data,
});
}

View File

@@ -0,0 +1,25 @@
import request from "@/utils/request";
export function searchRelationGraphSubjects(query) {
return request({
url: "/ccdi/project/relation-graph/search",
method: "get",
params: query,
});
}
export function getRelationGraph(query) {
return request({
url: "/ccdi/project/relation-graph/graph",
method: "get",
params: query,
});
}
export function getRelationGraphSuspectedEnterprises(query) {
return request({
url: "/ccdi/project/relation-graph/suspected-enterprises",
method: "get",
params: query,
});
}

View File

@@ -91,3 +91,12 @@ export function getOverviewAbnormalAccountPeople(params) {
}
})
}
export function exportOverviewReport(projectId) {
return request({
url: '/ccdi/project/overview/report/export',
method: 'post',
responseType: 'blob',
params: { projectId }
})
}

View File

@@ -113,7 +113,8 @@
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="rows" stripe border class="account-table">
<div class="formal-table-shell">
<el-table v-loading="loading" :data="rows" stripe border class="account-table">
<el-table-column label="员工姓名" prop="staffName" min-width="120">
<template slot-scope="scope">{{ scope.row.staffName || '-' }}</template>
</el-table-column>
@@ -193,9 +194,10 @@
>删除</el-button>
</template>
</el-table-column>
</el-table>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
</div>
</div>
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="1160px" append-to-body>
@@ -872,19 +874,89 @@ export default {
.account-page {
min-height: calc(100vh - 84px);
background: #f5f7fa;
padding: 24px;
background: #f5f6f8;
}
.board {
padding: 20px;
border-radius: 8px;
padding: 0;
border-radius: 0;
background: transparent;
border: 0;
}
.query-form {
margin-bottom: 16px;
padding: 18px 20px 2px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #fff;
border: 1px solid #ebeef5;
}
.query-form ::v-deep .el-form-item {
margin-bottom: 16px;
}
.query-form ::v-deep .el-form-item__label {
color: #637187;
font-weight: 600;
}
.query-form ::v-deep .el-input__inner,
.query-form ::v-deep .el-select .el-input__inner {
border-color: #dde3ec;
border-radius: 3px;
}
.mb8 {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 16px;
padding: 14px 20px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.mb8 ::v-deep .el-button {
border-radius: 4px;
}
.mb8 ::v-deep .top-right-btn {
margin-left: auto;
}
.formal-table-shell {
padding: 4px 0 16px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
overflow: hidden;
}
.account-table ::v-deep .el-table__header th {
background: #f8f8f9;
color: #515a6e;
background: #f6f8fb;
color: #607086;
padding: 9px 0;
}
.account-table ::v-deep .el-table td,
.account-table ::v-deep .el-table th.is-leaf {
border-bottom-color: #edf1f5;
}
.account-table ::v-deep .el-table td {
padding: 8px 0;
}
.account-table ::v-deep .el-table th > .cell,
.account-table ::v-deep .el-table td > .cell {
line-height: 1.4;
}
.formal-table-shell ::v-deep .pagination-container {
padding: 16px 20px 0;
}
.form-section {
@@ -913,4 +985,18 @@ export default {
font-weight: 700;
}
::v-deep .el-dialog {
border-radius: 6px;
overflow: hidden;
}
::v-deep .el-dialog__header {
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
::v-deep .el-dialog__body {
background: #f8fafc;
}
</style>

View File

@@ -112,64 +112,66 @@
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="baseStaffList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="姓名" align="center" prop="name" :show-overflow-tooltip="true"/>
<el-table-column label="柜员号" align="center" prop="staffId" :show-overflow-tooltip="true"/>
<el-table-column label="身份证号" align="center" prop="idCard" :show-overflow-tooltip="true"/>
<el-table-column label="所属部门" align="center" prop="deptName" :show-overflow-tooltip="true"/>
<el-table-column label="电话" align="center" prop="phone" width="120"/>
<el-table-column label="年收入" align="center" prop="annualIncome" width="140"/>
<el-table-column label="是否党员" align="center" prop="partyMember" width="100">
<template slot-scope="scope">
<span>{{ formatPartyMember(scope.row.partyMember) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === '0'" type="success">在职</el-tag>
<el-tag v-else type="danger"></el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="180">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-view"
@click="handleDetail(scope.row)"
v-hasPermi="['ccdi:employee:query']"
>详情</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['ccdi:employee:edit']"
>编辑</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['ccdi:employee:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="formal-table-shell">
<el-table v-loading="loading" :data="baseStaffList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="姓名" align="center" prop="name" :show-overflow-tooltip="true"/>
<el-table-column label="柜员号" align="center" prop="staffId" :show-overflow-tooltip="true"/>
<el-table-column label="身份证号" align="center" prop="idCard" :show-overflow-tooltip="true"/>
<el-table-column label="所属部门" align="center" prop="deptName" :show-overflow-tooltip="true"/>
<el-table-column label="电话" align="center" prop="phone" width="120"/>
<el-table-column label="年收入" align="center" prop="annualIncome" width="140"/>
<el-table-column label="是否党员" align="center" prop="partyMember" width="100">
<template slot-scope="scope">
<span>{{ formatPartyMember(scope.row.partyMember) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.status === '0'" type="success"></el-tag>
<el-tag v-else type="danger">离职</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="180">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-view"
@click="handleDetail(scope.row)"
v-hasPermi="['ccdi:employee:query']"
>详情</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['ccdi:employee:edit']"
>编辑</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['ccdi:employee:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
<!-- 添加或修改对话框 -->
<el-dialog :title="title" :visible.sync="open" width="80%" append-to-body class="employee-edit-dialog">
@@ -1472,6 +1474,12 @@ export default {
</script>
<style scoped>
.app-container {
min-height: calc(100vh - 84px);
padding: 24px;
background: #f5f6f8;
}
.query-form ::v-deep .el-row {
display: flex;
flex-wrap: wrap;
@@ -1483,6 +1491,84 @@ export default {
.query-form ::v-deep .el-form-item {
margin-right: 0;
margin-bottom: 16px;
}
.query-form {
margin-bottom: 16px;
padding: 18px 20px 2px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.query-form ::v-deep .el-form-item__label {
color: #637187;
font-weight: 600;
}
.query-form ::v-deep .el-input__inner,
.query-form ::v-deep .el-select .el-input__inner,
.query-form ::v-deep .vue-treeselect__control {
border-color: #dde3ec;
border-radius: 3px;
}
.mb8 {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 16px;
padding: 14px 20px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.formal-table-shell {
padding: 4px 0 16px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
overflow: hidden;
}
.mb8 ::v-deep .el-button {
border-radius: 4px;
}
.mb8 ::v-deep .top-right-btn {
margin-left: auto;
}
.formal-table-shell ::v-deep .el-table th {
background: #f6f8fb;
color: #607086;
padding: 9px 0;
}
.formal-table-shell ::v-deep .el-table td,
.formal-table-shell ::v-deep .el-table th.is-leaf {
border-bottom-color: #edf1f5;
}
.formal-table-shell ::v-deep .el-table td {
padding: 8px 0;
}
.formal-table-shell ::v-deep .el-table th > .cell,
.formal-table-shell ::v-deep .el-table td > .cell {
text-align: left;
line-height: 1.4;
}
.formal-table-shell ::v-deep .fixed-width .cell,
.formal-table-shell ::v-deep .el-table-column--selection .cell {
text-align: center;
}
.formal-table-shell ::v-deep .pagination-container {
padding: 16px 20px 0;
}
.detail-form .el-form-item {
@@ -1495,8 +1581,9 @@ export default {
}
.employee-detail-dialog .info-section {
background: #f9fafb;
border-radius: 8px;
background: #ffffff;
border: 1px solid #dde3ec;
border-radius: 4px;
padding: 16px;
margin-bottom: 16px;
}
@@ -1509,7 +1596,7 @@ export default {
color: #303133;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #e4e7ed;
border-bottom: 1px solid #edf1f5;
}
.employee-detail-dialog .section-title i {
@@ -1567,9 +1654,9 @@ export default {
align-items: center;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #e4e7ed;
border-bottom: 1px solid #edf1f5;
font-size: 14px;
font-weight: 500;
font-weight: 600;
color: #303133;
}
@@ -1650,6 +1737,23 @@ export default {
font-size: 13px;
margin-right: 8px;
}
.employee-edit-dialog ::v-deep .el-dialog,
.employee-detail-dialog ::v-deep .el-dialog {
border-radius: 6px;
overflow: hidden;
}
.employee-edit-dialog ::v-deep .el-dialog__header,
.employee-detail-dialog ::v-deep .el-dialog__header {
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
.employee-edit-dialog ::v-deep .el-dialog__body,
.employee-detail-dialog ::v-deep .el-dialog__body {
background: #f8fafc;
}
</style>
<!-- 导入结果弹窗已抽离为独立组件 ImportResultDialog -->

View File

@@ -40,7 +40,8 @@
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table :data="creditInfoList" v-loading="loading">
<div class="formal-table-shell">
<el-table :data="creditInfoList" v-loading="loading">
<el-table-column label="姓名" prop="name" align="center" />
<el-table-column label="身份证号" prop="idCard" align="center" />
<el-table-column label="最近征信查询日期" align="center" width="160">
@@ -59,15 +60,16 @@
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
<el-dialog title="批量上传征信HTML" :visible.sync="uploadDialogVisible" width="720px" append-to-body>
<el-upload
@@ -335,6 +337,12 @@ export default {
</script>
<style scoped>
.app-container {
min-height: calc(100vh - 84px);
padding: 24px;
background: #f5f6f8;
}
.query-form ::v-deep .el-row {
display: flex;
flex-wrap: wrap;
@@ -346,6 +354,82 @@ export default {
.query-form ::v-deep .el-form-item {
margin-right: 0;
margin-bottom: 16px;
}
.query-form {
margin-bottom: 16px;
padding: 18px 20px 2px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.query-form ::v-deep .el-form-item__label {
color: #637187;
font-weight: 600;
}
.query-form ::v-deep .el-input__inner,
.query-form ::v-deep .el-select .el-input__inner {
border-color: #dde3ec;
border-radius: 3px;
}
.mb8 {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 16px;
padding: 14px 20px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.mb8 ::v-deep .el-button {
border-radius: 4px;
}
.mb8 ::v-deep .top-right-btn {
margin-left: auto;
}
.formal-table-shell {
padding: 4px 0 16px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
overflow: hidden;
}
.formal-table-shell ::v-deep .el-table th {
background: #f6f8fb;
color: #607086;
padding: 9px 0;
}
.formal-table-shell ::v-deep .el-table td,
.formal-table-shell ::v-deep .el-table th.is-leaf {
border-bottom-color: #edf1f5;
}
.formal-table-shell ::v-deep .el-table td {
padding: 8px 0;
}
.formal-table-shell ::v-deep .el-table th > .cell,
.formal-table-shell ::v-deep .el-table td > .cell {
text-align: left;
line-height: 1.4;
}
.formal-table-shell ::v-deep .fixed-width .cell {
text-align: center;
}
.formal-table-shell ::v-deep .pagination-container {
padding: 16px 20px 0;
}
.upload-result {
@@ -385,4 +469,18 @@ export default {
font-weight: 600;
color: #303133;
}
::v-deep .el-dialog {
border-radius: 6px;
overflow: hidden;
}
::v-deep .el-dialog__header {
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
::v-deep .el-dialog__body {
background: #f8fafc;
}
</style>

View File

@@ -90,7 +90,8 @@
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="relationList" @selection-change="handleSelectionChange">
<div class="formal-table-shell">
<el-table v-loading="loading" :data="relationList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="身份证号" align="center" prop="personId" width="180" :show-overflow-tooltip="true"/>
<el-table-column label="企业名称" align="center" prop="enterpriseName" :show-overflow-tooltip="true"/>
@@ -135,15 +136,16 @@
>删除</el-button>
</template>
</el-table-column>
</el-table>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
<!-- 添加或修改对话框 -->
<el-dialog :title="title" :visible.sync="open" width="800px" append-to-body>
@@ -841,6 +843,12 @@ export default {
</script>
<style scoped>
.app-container {
min-height: calc(100vh - 84px);
padding: 24px;
background: #f5f6f8;
}
.query-form ::v-deep .el-row {
display: flex;
flex-wrap: wrap;
@@ -852,6 +860,83 @@ export default {
.query-form ::v-deep .el-form-item {
margin-right: 0;
margin-bottom: 16px;
}
.query-form {
margin-bottom: 16px;
padding: 18px 20px 2px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.query-form ::v-deep .el-form-item__label {
color: #637187;
font-weight: 600;
}
.query-form ::v-deep .el-input__inner,
.query-form ::v-deep .el-select .el-input__inner {
border-color: #dde3ec;
border-radius: 3px;
}
.mb8 {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 16px;
padding: 14px 20px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.mb8 ::v-deep .el-button {
border-radius: 4px;
}
.mb8 ::v-deep .top-right-btn {
margin-left: auto;
}
.formal-table-shell {
padding: 4px 0 16px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
overflow: hidden;
}
.formal-table-shell ::v-deep .el-table th {
background: #f6f8fb;
color: #607086;
padding: 9px 0;
}
.formal-table-shell ::v-deep .el-table td,
.formal-table-shell ::v-deep .el-table th.is-leaf {
border-bottom-color: #edf1f5;
}
.formal-table-shell ::v-deep .el-table td {
padding: 8px 0;
}
.formal-table-shell ::v-deep .el-table th > .cell,
.formal-table-shell ::v-deep .el-table td > .cell {
text-align: left;
line-height: 1.4;
}
.formal-table-shell ::v-deep .fixed-width .cell,
.formal-table-shell ::v-deep .el-table-column--selection .cell {
text-align: center;
}
.formal-table-shell ::v-deep .pagination-container {
padding: 16px 20px 0;
}
.detail-container {
@@ -861,4 +946,18 @@ export default {
.el-divider {
margin: 16px 0;
}
::v-deep .el-dialog {
border-radius: 6px;
overflow: hidden;
}
::v-deep .el-dialog__header {
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
::v-deep .el-dialog__body {
background: #f8fafc;
}
</style>

View File

@@ -91,7 +91,8 @@
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="relationList" @selection-change="handleSelectionChange">
<div class="formal-table-shell">
<el-table v-loading="loading" :data="relationList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="信贷客户身份证号" align="center" prop="personId" width="180"/>
<el-table-column label="关系类型" align="center" prop="relationType" width="100">
@@ -143,15 +144,16 @@
>删除</el-button>
</template>
</el-table-column>
</el-table>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
<!-- 添加或修改对话框 -->
<el-dialog :title="title" :visible.sync="open" width="800px" append-to-body>
@@ -1097,6 +1099,12 @@ export default {
</script>
<style scoped>
.app-container {
min-height: calc(100vh - 84px);
padding: 24px;
background: #f5f6f8;
}
.query-form ::v-deep .el-row {
display: flex;
flex-wrap: wrap;
@@ -1108,9 +1116,100 @@ export default {
.query-form ::v-deep .el-form-item {
margin-right: 0;
margin-bottom: 16px;
}
.query-form {
margin-bottom: 16px;
padding: 18px 20px 2px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.query-form ::v-deep .el-form-item__label {
color: #637187;
font-weight: 600;
}
.query-form ::v-deep .el-input__inner,
.query-form ::v-deep .el-select .el-input__inner {
border-color: #dde3ec;
border-radius: 3px;
}
.mb8 {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 16px;
padding: 14px 20px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.mb8 ::v-deep .el-button {
border-radius: 4px;
}
.mb8 ::v-deep .top-right-btn {
margin-left: auto;
}
.formal-table-shell {
padding: 4px 0 16px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
overflow: hidden;
}
.formal-table-shell ::v-deep .el-table th {
background: #f6f8fb;
color: #607086;
padding: 9px 0;
}
.formal-table-shell ::v-deep .el-table td,
.formal-table-shell ::v-deep .el-table th.is-leaf {
border-bottom-color: #edf1f5;
}
.formal-table-shell ::v-deep .el-table td {
padding: 8px 0;
}
.formal-table-shell ::v-deep .el-table th > .cell,
.formal-table-shell ::v-deep .el-table td > .cell {
text-align: left;
line-height: 1.4;
}
.formal-table-shell ::v-deep .fixed-width .cell,
.formal-table-shell ::v-deep .el-table-column--selection .cell {
text-align: center;
}
.formal-table-shell ::v-deep .pagination-container {
padding: 16px 20px 0;
}
.detail-container {
padding: 0 20px;
}
::v-deep .el-dialog {
border-radius: 6px;
overflow: hidden;
}
::v-deep .el-dialog__header {
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
::v-deep .el-dialog__body {
background: #f8fafc;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="formal-table-shell">
<el-table v-loading="loading" :data="dataList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="名称" align="center" prop="name" :show-overflow-tooltip="true" />
@@ -74,3 +74,43 @@ export default {
}
};
</script>
<style scoped>
.formal-table-shell {
padding: 4px 0 16px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
overflow: hidden;
}
.formal-table-shell ::v-deep .el-table th {
background: #f6f8fb;
color: #607086;
padding: 9px 0;
}
.formal-table-shell ::v-deep .el-table td,
.formal-table-shell ::v-deep .el-table th.is-leaf {
border-bottom-color: #edf1f5;
}
.formal-table-shell ::v-deep .el-table td {
padding: 8px 0;
}
.formal-table-shell ::v-deep .el-table th > .cell,
.formal-table-shell ::v-deep .el-table td > .cell {
text-align: left;
line-height: 1.4;
}
.formal-table-shell ::v-deep .fixed-width .cell,
.formal-table-shell ::v-deep .el-table-column--selection .cell {
text-align: center;
}
.formal-table-shell ::v-deep .pagination-container {
padding: 16px 20px 0;
}
</style>

View File

@@ -113,6 +113,30 @@ export default {
justify-content: space-between;
align-items: center;
margin: 18px 0 12px;
padding-bottom: 10px;
border-bottom: 1px solid #edf1f5;
font-weight: 600;
}
::v-deep .el-dialog {
border-radius: 6px;
overflow: hidden;
}
::v-deep .el-dialog__header {
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
::v-deep .el-dialog__body {
background: #f8fafc;
}
::v-deep .el-descriptions {
background: #ffffff;
}
::v-deep .el-table th {
background: #f6f8fb;
}
</style>

View File

@@ -207,3 +207,30 @@ export default {
}
};
</script>
<style scoped>
::v-deep .el-dialog {
border-radius: 6px;
overflow: hidden;
}
::v-deep .el-dialog__header {
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
::v-deep .el-dialog__body {
background: #f8fafc;
}
::v-deep .el-form-item {
margin-bottom: 16px;
}
::v-deep .el-input__inner,
::v-deep .el-select .el-input__inner,
::v-deep .el-textarea__inner {
border-radius: 3px;
border-color: #dde3ec;
}
</style>

View File

@@ -332,8 +332,9 @@ export default {
.scene-tips {
padding: 12px 14px;
background: #f5f7fa;
border-radius: 4px;
background: #f8fafc;
border: 1px solid #dde3ec;
border-radius: 3px;
color: #606266;
line-height: 1.7;
@@ -349,6 +350,7 @@ export default {
.el-button {
min-width: 110px;
margin: 0 8px;
border-radius: 4px;
}
}
@@ -386,4 +388,18 @@ export default {
transform: rotate(360deg);
}
}
::v-deep .import-dialog-wrapper {
border-radius: 6px;
overflow: hidden;
}
::v-deep .import-dialog-wrapper .el-dialog__header {
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
::v-deep .import-dialog-wrapper .el-dialog__body {
background: #f8fafc;
}
</style>

View File

@@ -80,5 +80,25 @@ export default {
.query-form ::v-deep .el-form-item {
margin-right: 0;
margin-bottom: 16px;
}
.query-form {
margin-bottom: 16px;
padding: 18px 20px 2px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.query-form ::v-deep .el-form-item__label {
color: #637187;
font-weight: 600;
}
.query-form ::v-deep .el-input__inner,
.query-form ::v-deep .el-select .el-input__inner {
border-color: #dde3ec;
border-radius: 3px;
}
</style>

View File

@@ -788,3 +788,44 @@ export default {
}
};
</script>
<style scoped>
.app-container {
min-height: calc(100vh - 84px);
padding: 24px;
background: #f5f6f8;
}
.mb8 {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 16px;
padding: 14px 20px;
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.mb8 ::v-deep .el-button {
border-radius: 4px;
}
.mb8 ::v-deep .top-right-btn {
margin-left: auto;
}
::v-deep .el-dialog {
border-radius: 6px;
overflow: hidden;
}
::v-deep .el-dialog__header {
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
::v-deep .el-dialog__body {
background: #f8fafc;
}
</style>

View File

@@ -19,6 +19,16 @@
<div class="section-title">风险总览</div>
<div class="section-subtitle">集中展示项目风险统计与命中人员总览</div>
</div>
<el-button
size="mini"
type="primary"
plain
icon="el-icon-download"
:disabled="!projectId"
@click="handleOverviewReportExport"
>
导出报告
</el-button>
</div>
<overview-stats :summary="currentData.summary" />
<risk-people-section
@@ -191,6 +201,18 @@ export default {
riskTags,
};
},
handleOverviewReportExport() {
if (!this.projectId) {
return;
}
this.download(
"ccdi/project/overview/report/export",
{
projectId: this.projectId,
},
`初核结果报告_${this.projectId}_${new Date().getTime()}.pdf`
);
},
async loadOverviewData() {
if (!this.projectId) {
this.realData = this.stateDataMap.empty;
@@ -294,6 +316,10 @@ export default {
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding-left: 12px;
border-left: 4px solid var(--ccdi-primary);

View File

@@ -244,28 +244,31 @@ export default {
.project-analysis-abnormal-tab {
display: flex;
flex-direction: column;
gap: 16px;
gap: 20px;
}
.abnormal-card {
padding: 20px;
border: 1px solid #e2e8f0;
border: 1px solid #d9e1ed;
background: #fff;
overflow: hidden;
}
.abnormal-card__header {
margin-bottom: 14px;
margin-bottom: 0;
padding: 22px 30px;
border-bottom: 1px solid #e3eaf3;
}
.abnormal-card__title {
font-size: 16px;
font-weight: 600;
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
.abnormal-card__content {
display: grid;
gap: 16px;
gap: 0;
background: #ffffff;
}
.abnormal-card__subtitle {
@@ -274,14 +277,15 @@ export default {
}
.abnormal-table {
border-radius: 12px;
border-radius: 0;
overflow: hidden;
}
.abnormal-pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding: 18px 24px 20px;
border-top: 1px solid #e8eef6;
}
.multi-line-cell {
@@ -303,7 +307,7 @@ export default {
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
gap: 10px;
}
.object-card-grid {
@@ -314,8 +318,8 @@ export default {
.object-card {
padding: 16px;
border: 1px solid #e2e8f0;
background: #f8fafc;
border: 1px solid #d9e1ed;
background: #ffffff;
}
.object-card__header {
@@ -364,7 +368,8 @@ export default {
margin-top: 12px;
padding: 12px;
border: 1px solid #dbeafe;
background: #eff6ff;
border-left: 3px solid #245b8f;
background: #f4f8fd;
}
.object-card__snapshot-label {
@@ -392,4 +397,36 @@ export default {
color: #1e293b;
text-align: right;
}
.abnormal-table ::v-deep(.el-table__header-wrapper th) {
height: 58px;
padding: 0 22px;
border-bottom: 1px solid #d9e1ed;
background: #f3f6fa;
color: #5f7592;
font-size: 16px;
font-weight: 700;
}
.abnormal-table ::v-deep(.el-table__body-wrapper td) {
padding: 22px;
border-bottom: 1px solid #e6edf5;
}
.abnormal-table ::v-deep(.cell) {
padding-left: 0;
padding-right: 0;
}
.tag-list ::v-deep(.el-tag) {
height: 28px;
padding: 0 10px;
border: 1px solid #c8d6e8;
border-radius: 2px;
background: #ffffff;
color: #245b8f;
font-size: 12px;
font-weight: 600;
line-height: 26px;
}
</style>

View File

@@ -2,7 +2,7 @@
<el-dialog
title="项目分析"
:visible.sync="visibleProxy"
width="92%"
width="88%"
top="2vh"
append-to-body
custom-class="project-analysis-dialog"
@@ -44,7 +44,7 @@
<el-button type="text" size="mini" @click="handleRetryDetail">重试</el-button>
</template>
</el-alert>
<el-tabs v-model="activeTab" class="project-analysis-tabs" stretch>
<el-tabs v-model="activeTab" class="project-analysis-tabs" stretch @tab-click="handleTabChange">
<el-tab-pane label="异常明细" name="abnormalDetail">
<project-analysis-abnormal-tab
:detail-data="dialogData.abnormalDetail"
@@ -60,10 +60,24 @@
<project-analysis-placeholder-tab :tab-data="getTabData('creditSummary')" />
</el-tab-pane>
<el-tab-pane label="关系图谱" name="relationshipGraph">
<project-analysis-placeholder-tab :tab-data="getTabData('relationshipGraph')" />
<project-analysis-fund-flow-tab
v-if="activeTab === 'relationshipGraph'"
ref="relationshipGraphTab"
:project-id="projectId"
:person="person"
:model-summary="modelSummary"
initial-graph-tab="relation"
:graph-tabs="['relation']"
/>
</el-tab-pane>
<el-tab-pane label="资金流向" name="fundFlow">
<project-analysis-placeholder-tab :tab-data="getTabData('fundFlow')" />
<project-analysis-fund-flow-tab
v-if="activeTab === 'fundFlow'"
ref="fundFlowTab"
:project-id="projectId"
:person="person"
:model-summary="modelSummary"
/>
</el-tab-pane>
</el-tabs>
</div>
@@ -75,6 +89,7 @@
<script>
import { getOverviewPersonAnalysisDetail } from "@/api/ccdi/projectOverview";
import ProjectAnalysisAbnormalTab from "./ProjectAnalysisAbnormalTab";
import ProjectAnalysisFundFlowTab from "./ProjectAnalysisFundFlowTab";
import ProjectAnalysisPlaceholderTab from "./ProjectAnalysisPlaceholderTab";
import ProjectAnalysisSidebar from "./ProjectAnalysisSidebar";
import { buildProjectAnalysisDialogData } from "./preliminaryCheck.mock";
@@ -83,6 +98,7 @@ export default {
name: "ProjectAnalysisDialog",
components: {
ProjectAnalysisAbnormalTab,
ProjectAnalysisFundFlowTab,
ProjectAnalysisPlaceholderTab,
ProjectAnalysisSidebar,
},
@@ -208,6 +224,16 @@ export default {
this.resetDialogState();
this.$emit("close");
},
handleTabChange() {
this.$nextTick(() => {
const tabRef = this.activeTab === "relationshipGraph"
? this.$refs.relationshipGraphTab
: this.$refs.fundFlowTab;
if (tabRef && tabRef.ensureGraphReady) {
tabRef.ensureGraphReady();
}
});
},
},
};
</script>
@@ -216,23 +242,23 @@ export default {
.project-analysis-dialog__body {
display: flex;
flex-direction: column;
border: 1px solid #e2e8f0;
border-radius: 24px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
min-height: calc(92vh - 64px);
border: 1px solid #dde3ec;
background: #ffffff;
overflow: hidden;
}
.project-analysis-header {
padding: 24px 28px 18px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 65%);
padding: 28px 40px 24px;
border-bottom: 1px solid #dde3ec;
background: #ffffff;
}
.project-analysis-header__main {
display: flex;
align-items: flex-start;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
gap: 24px;
}
.project-analysis-header__title-group {
@@ -240,49 +266,52 @@ export default {
}
.project-analysis-header__eyebrow {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
color: #64748b;
color: #65758d;
font-size: 15px;
font-weight: 700;
line-height: 1;
}
.project-analysis-header__title {
margin-top: 8px;
font-size: 24px;
font-weight: 600;
line-height: 1.2;
color: #0f172a;
margin-top: 18px;
color: #101a2b;
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.project-analysis-header__meta {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid #fde68a;
border-radius: 999px;
background: #fffbeb;
gap: 10px;
min-height: 34px;
padding: 0 12px;
border: 1px solid #bfd0e2;
border-radius: 2px;
background: #eef4f9;
}
.project-analysis-header__meta-label {
font-size: 12px;
color: #92400e;
color: #637187;
font-size: 13px;
font-weight: 600;
}
.project-analysis-header__meta-value {
color: #245b8f;
font-size: 13px;
font-weight: 600;
color: #b45309;
}
.project-analysis-workspace {
display: flex;
align-items: flex-start;
gap: 24px;
min-height: 700px;
max-height: calc(96vh - 170px);
padding: 24px 28px 28px;
gap: 36px;
min-height: 640px;
max-height: calc(92vh - 150px);
padding: 20px 40px 28px;
overflow: auto;
background: #ffffff;
}
.project-analysis-layout {
@@ -292,21 +321,74 @@ export default {
}
.project-analysis-layout__sidebar {
flex: 0 0 340px;
flex: 0 0 34%;
max-width: 460px;
}
.project-analysis-layout__main {
flex: 1;
min-width: 0;
border-left: 1px solid #e2e8f0;
padding-left: 24px;
border-left: 1px solid #dde3ec;
padding-left: 36px;
}
.project-analysis-layout__alert {
margin-bottom: 16px;
margin-bottom: 20px;
}
.project-analysis-tabs {
margin-top: -8px;
margin-top: -2px;
}
</style>
<style lang="scss">
.project-analysis-dialog {
margin-top: 2vh !important;
border-radius: 0;
background: #f5f6f8;
overflow: hidden;
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
background: #f5f6f8;
}
}
.project-analysis-tabs {
.el-tabs__header {
margin: 0;
}
.el-tabs__nav-wrap::after {
height: 1px;
background: #dde3ec;
}
.el-tabs__item {
height: 52px;
padding: 0 24px !important;
color: #2a374a;
font-size: 16px;
font-weight: 500;
line-height: 52px;
}
.el-tabs__item.is-active {
color: #245b8f;
font-weight: 700;
}
.el-tabs__active-bar {
height: 4px;
background: #245b8f;
}
.el-tabs__content {
padding-top: 20px;
}
}
</style>

View File

@@ -0,0 +1,259 @@
<template>
<div class="project-analysis-fund-graph" :class="rootClasses">
<fund-graph-section
ref="fundGraphSection"
:initial-keyword="defaultKeyword"
:initial-graph-tab="initialGraphTab"
:auto-scroll="false"
:hide-header="true"
:graph-tabs="graphTabs"
:layout-scale="1.03"
:show-edge-detail-table="true"
:edge-detail-table-height="220"
compact-graph-labels
:show-edge-metrics="!isFundOnly"
:stacked-fund-detail-pane="isFundOnly"
hide-empty-detail-pane
/>
</div>
</template>
<script>
import FundGraphSection from "./graph/FundGraphSection";
export default {
name: "ProjectAnalysisFundFlowTab",
components: {
FundGraphSection,
},
props: {
projectId: {
type: [String, Number],
default: null,
},
person: {
type: Object,
default: () => null,
},
modelSummary: {
type: Object,
default: () => ({}),
},
initialGraphTab: {
type: String,
default: "fund",
validator: (value) => ["fund", "relation"].includes(value),
},
graphTabs: {
type: Array,
default: () => ["fund", "relation"],
},
},
computed: {
isFundOnly() {
return this.initialGraphTab === "fund" && this.graphTabs.length === 2;
},
rootClasses() {
return {
"project-analysis-fund-graph--fund": this.isFundOnly,
"project-analysis-fund-graph--relation": !this.isFundOnly,
};
},
defaultKeyword() {
return (this.modelSummary && this.modelSummary.staffIdCard)
|| (this.person && (this.person.idNo || this.person.staffIdCard || this.person.staffName || this.person.name))
|| "";
},
},
methods: {
ensureGraphReady() {
this.$nextTick(() => {
const graphSection = this.$refs.fundGraphSection;
if (graphSection && graphSection.resizeChart) {
graphSection.resizeChart();
}
if (graphSection && graphSection.resizeRelationChart) {
graphSection.resizeRelationChart();
}
if (graphSection && graphSection.renderChart) {
graphSection.renderChart();
}
if (graphSection && graphSection.renderRelationChart) {
graphSection.renderRelationChart();
}
});
},
},
};
</script>
<style lang="scss" scoped>
.project-analysis-fund-graph {
min-height: 780px;
::v-deep .graph-analysis-section {
margin-top: 0;
box-shadow: none;
}
::v-deep .fund-workbench {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
min-height: 760px;
}
::v-deep .fund-workbench.fund-workbench--single {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
min-height: 560px;
}
::v-deep .fund-workbench--stacked {
display: flex;
flex-direction: column;
}
::v-deep .fund-left-pane {
padding: 14px 16px 16px;
overflow: hidden;
border-right: 0;
}
::v-deep .fund-right-pane {
position: relative;
z-index: 2;
padding: 14px;
background: #ffffff;
border-top: 1px solid #dce7f2;
overflow: hidden;
}
::v-deep .graph-canvas-wrap {
height: auto;
min-height: 520px;
}
::v-deep .detail-empty {
min-height: 240px;
}
::v-deep .query-row {
display: none;
}
::v-deep .keyword-input {
width: 196px;
}
::v-deep .date-range {
width: 216px;
}
::v-deep .detail-table {
font-size: 12px;
}
::v-deep .detail-table .el-table__cell {
padding: 5px 0;
}
::v-deep .pagination-container {
margin-top: 8px;
padding: 0;
}
::v-deep .pagination-container .el-pagination {
display: flex;
justify-content: flex-end;
white-space: nowrap;
}
::v-deep .query-row {
gap: 8px;
margin-bottom: 10px;
}
::v-deep .graph-shell {
border-radius: 10px;
overflow: hidden;
}
::v-deep .graph-canvas {
min-height: 520px;
}
::v-deep .detail-title {
font-size: 15px;
line-height: 1.35;
}
::v-deep .node-field-row,
::v-deep .edge-metrics span,
::v-deep .edge-metrics strong,
::v-deep .detail-subtitle {
font-size: 12px;
}
}
.project-analysis-fund-graph--relation {
min-height: 520px;
::v-deep .fund-workbench {
grid-template-columns: minmax(0, 1fr);
max-width: 760px;
min-height: 450px;
}
::v-deep .graph-canvas-wrap,
::v-deep .detail-empty {
min-height: 360px;
}
::v-deep .graph-canvas {
min-height: 360px;
}
}
@media (max-width: 1440px) {
.project-analysis-fund-graph--fund {
::v-deep .fund-workbench {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
}
::v-deep .fund-workbench.fund-workbench--single {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
}
::v-deep .graph-canvas-wrap {
min-height: 500px;
}
::v-deep .detail-empty {
min-height: 220px;
}
::v-deep .graph-canvas {
min-height: 500px;
}
}
.project-analysis-fund-graph--relation {
::v-deep .fund-workbench {
grid-template-columns: minmax(0, 1fr);
max-width: 720px;
}
::v-deep .graph-canvas-wrap,
::v-deep .detail-empty {
min-height: 340px;
}
::v-deep .graph-canvas {
min-height: 340px;
}
}
}
</style>

View File

@@ -1,51 +1,52 @@
<template>
<aside class="project-analysis-sidebar">
<section class="sidebar-profile">
<div class="sidebar-profile__name-row">
<div>
<div class="sidebar-section__eyebrow">人物档案</div>
<div class="sidebar-profile__name">{{ sidebarData.basicInfo.name || "-" }}</div>
<div class="sidebar-profile-card">
<section class="sidebar-profile">
<div class="sidebar-profile__identity">
<div class="sidebar-profile__identity-label">人物档案</div>
<div class="sidebar-profile__name-row">
<div class="sidebar-profile__name">{{ sidebarData.basicInfo.name || "-" }}</div>
<div class="sidebar-risk-badge">{{ sidebarData.basicInfo.riskLevel || "-" }}</div>
</div>
</div>
<div class="sidebar-risk-badge">{{ sidebarData.basicInfo.riskLevel || "-" }}</div>
</div>
<div class="sidebar-profile__meta">
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">工号</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.staffCode || "-" }}</span>
</div>
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">部门</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.department || "-" }}</span>
</div>
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">所属项目</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.projectName || "-" }}</span>
</div>
</div>
</section>
<div class="sidebar-profile__meta">
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">工号</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.staffCode || "-" }}</span>
<section class="sidebar-summary">
<div class="sidebar-summary__title">命中模型摘要</div>
<div class="sidebar-summary__count">
<span class="sidebar-summary__count-label">命中模型数</span>
<span class="sidebar-summary__count-value">{{ sidebarData.modelSummary.modelCount || "-" }}</span>
</div>
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">部门</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.department || "-" }}</span>
<div class="sidebar-summary__tags">
<span class="sidebar-profile__label">核心异常标签</span>
<div v-if="sidebarData.modelSummary.riskTags.length" class="sidebar-tag-list">
<el-tag
v-for="(tag, index) in sidebarData.modelSummary.riskTags"
:key="`${formatRiskTag(tag)}-${index}`"
size="mini"
effect="plain"
>
{{ formatRiskTag(tag) }}
</el-tag>
</div>
<span v-else class="sidebar-profile__value">暂无异常标签</span>
</div>
<div class="sidebar-profile__item">
<span class="sidebar-profile__label">所属项目</span>
<span class="sidebar-profile__value">{{ sidebarData.basicInfo.projectName || "-" }}</span>
</div>
</div>
</section>
<section class="sidebar-summary">
<div class="sidebar-section__eyebrow">命中模型摘要</div>
<div class="sidebar-summary__count">
<span class="sidebar-summary__count-label">命中模型数</span>
<span class="sidebar-summary__count-value">{{ sidebarData.modelSummary.modelCount || "-" }}</span>
</div>
<div class="sidebar-summary__tags">
<span class="sidebar-profile__label">核心异常标签</span>
<div v-if="sidebarData.modelSummary.riskTags.length" class="sidebar-tag-list">
<el-tag
v-for="(tag, index) in sidebarData.modelSummary.riskTags"
:key="`${formatRiskTag(tag)}-${index}`"
size="mini"
effect="plain"
>
{{ formatRiskTag(tag) }}
</el-tag>
</div>
<span v-else class="sidebar-profile__value">暂无异常标签</span>
</div>
</section>
</section>
</div>
</aside>
</template>
@@ -83,85 +84,124 @@ export default {
align-self: flex-start;
}
.sidebar-profile,
.sidebar-summary {
padding: 20px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.18);
.sidebar-profile-card {
border: 1px solid #dde3ec;
border-radius: 3px;
background: #ffffff;
}
.sidebar-profile {
padding: 34px 42px 28px;
border-bottom: 1px solid #edf1f5;
}
.sidebar-summary {
margin-top: 16px;
padding: 30px 42px 34px;
border-top: 1px solid #dde3ec;
background: #fcfdfe;
}
.sidebar-section__eyebrow {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
color: #64748b;
.sidebar-profile__identity-label {
color: #637187;
font-size: 16px;
font-weight: 700;
margin-bottom: 28px;
}
.sidebar-profile__name-row {
display: flex;
align-items: flex-start;
gap: 12px;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.sidebar-profile__name {
margin-top: 8px;
font-size: 24px;
font-weight: 600;
color: #111827;
font-size: 34px;
font-weight: 700;
line-height: 1.2;
color: #0f172a;
}
.sidebar-risk-badge {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: #fee2e2;
font-size: 12px;
justify-content: center;
min-width: 102px;
height: 44px;
padding: 0 16px;
border: 1px solid #f0c6c1;
border-radius: 2px;
background: #fff3f2;
color: #c43d33;
font-size: 17px;
font-weight: 600;
color: #b91c1c;
line-height: 42px;
text-align: center;
}
.sidebar-profile__meta {
display: grid;
gap: 14px;
margin-top: 20px;
gap: 0;
margin-top: 30px;
}
.sidebar-profile__item {
display: grid;
gap: 6px;
grid-template-columns: 116px minmax(0, 1fr);
gap: 16px;
padding: 18px 0;
border-bottom: 1px solid #edf1f5;
}
.sidebar-profile__item:last-child {
border-bottom: 0;
}
.sidebar-profile__label,
.sidebar-summary__count-label {
font-size: 12px;
color: #64748b;
color: #637187;
font-size: 16px;
}
.sidebar-profile__value,
.sidebar-summary__count-value {
font-size: 13px;
line-height: 1.6;
color: #0f172a;
color: #172033;
font-size: 17px;
font-weight: 500;
line-height: 1.4;
word-break: break-all;
}
.sidebar-summary__title {
margin: 0 0 24px;
color: #223047;
font-size: 18px;
font-weight: 700;
}
.sidebar-summary__count {
display: grid;
gap: 6px;
margin-top: 16px;
display: flex;
align-items: baseline;
gap: 16px;
margin-top: 0;
margin-bottom: 28px;
}
.sidebar-summary__tags {
display: grid;
gap: 10px;
margin-top: 18px;
gap: 16px;
margin-top: 0;
}
.sidebar-summary__count-label {
font-size: 17px;
}
.sidebar-summary__count-value {
color: #172033;
font-size: 40px;
font-weight: 700;
line-height: 1;
}
.sidebar-tag-list {
@@ -170,4 +210,16 @@ export default {
flex-wrap: wrap;
gap: 8px;
}
.sidebar-tag-list ::v-deep(.el-tag) {
height: 38px;
padding: 0 16px;
border: 1px solid #cad8eb;
border-radius: 2px;
background: #ffffff;
color: #245b8f;
font-size: 15px;
font-weight: 600;
line-height: 36px;
}
</style>

View File

@@ -353,7 +353,61 @@ const SUSPICIOUS_TYPE_OPTIONS = [
{ value: "MODEL_RULE", label: "模型规则命中" },
];
const normalizeHitTags = (hitTags) => (Array.isArray(hitTags) ? hitTags : []);
const normalizeSuspiciousType = (value) => (
SUSPICIOUS_TYPE_OPTIONS.some((item) => item.value === value) ? value : "ALL"
);
const ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE = "ABNORMAL_CUSTOMER_TRANSACTION";
const ABNORMAL_CUSTOMER_TRANSACTION_RULE_NAME = "与客户之间非正常资金往来";
const resolveAbnormalCustomerTransactionRuleName = (nameListHitType) => {
if (nameListHitType === "中介") {
return "疑似与中介往来";
}
if (nameListHitType === "信贷客户") {
return "与信贷客户之间非正常资金往来";
}
return ABNORMAL_CUSTOMER_TRANSACTION_RULE_NAME;
};
const normalizeRuleName = (tag, context) => {
if (!tag) {
return "";
}
if (
tag.ruleCode === ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE
|| tag.ruleName === "异常交易"
|| tag.ruleName === ABNORMAL_CUSTOMER_TRANSACTION_RULE_NAME
) {
return resolveAbnormalCustomerTransactionRuleName(context && context.nameListHitType);
}
return tag.ruleName || "";
};
const normalizeHitTags = (hitTags, context = {}) => {
if (!Array.isArray(hitTags)) {
return [];
}
const seen = new Set();
return hitTags
.map((tag) => ({
...tag,
ruleName: normalizeRuleName(tag, context),
}))
.filter((tag) => {
const key = `${tag.ruleCode || ""}|${tag.ruleName || ""}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
})
.sort((left, right) => {
const leftPriority = left.ruleCode === ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE ? 0 : 1;
const rightPriority = right.ruleCode === ABNORMAL_CUSTOMER_TRANSACTION_RULE_CODE ? 0 : 1;
return leftPriority - rightPriority;
});
};
const createEmptyDetailData = () => ({
bankStatementId: "",
@@ -443,7 +497,7 @@ export default {
deep: true,
handler(value) {
this.projectId = value && value.projectId ? value.projectId : null;
this.currentSuspiciousType = value && value.suspiciousType ? value.suspiciousType : "ALL";
this.currentSuspiciousType = normalizeSuspiciousType(value && value.suspiciousType);
this.suspiciousPageNum = 1;
this.suspiciousPageSize = 5;
this.suspiciousTotal = Number(value && value.total) || 0;
@@ -603,7 +657,7 @@ export default {
return {
...detail,
...row,
hitTags: normalizeHitTags(detail.hitTags),
hitTags: normalizeHitTags(detail.hitTags, row),
};
} catch (error) {
return {
@@ -647,7 +701,14 @@ export default {
this.detailVisible = true;
this.detailLoading = true;
try {
this.detailData = await this.fetchStatementDetail(row.bankStatementId, false);
const detail = await this.fetchStatementDetail(row.bankStatementId, false);
this.detailData = {
...detail,
...row,
hitTags: row && Array.isArray(row.hitTags) && row.hitTags.length
? normalizeHitTags(row.hitTags, row)
: normalizeHitTags(detail.hitTags, row),
};
} catch (error) {
this.detailData = createEmptyDetailData();
this.$message.error("加载流水详情失败");

Some files were not shown because too many files have changed in this diff Show More