Merge branch 'codex/results-overview-employee-result-table' into dev

This commit is contained in:
wkc
2026-03-22 12:01:12 +08:00
23 changed files with 1123 additions and 84 deletions

View File

@@ -0,0 +1,64 @@
package com.ruoyi.ccdi.project.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 结果总览员工结果实体
*/
@Data
@TableName("ccdi_project_overview_employee_result")
public class CcdiProjectOverviewEmployeeResult implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private Long projectId;
private String staffIdCard;
private String staffCode;
private String staffName;
private Long deptId;
private String deptName;
private Integer ruleCount;
private Integer modelCount;
private Integer hitCount;
private String riskLevelCode;
private String riskPoint;
private String modelCodesCsv;
private String modelNamesJson;
private String hitRulesJson;
private String modelHitSummaryJson;
private String createBy;
private Date createTime;
private String updateBy;
private Date updateTime;
private String remark;
}

View File

@@ -0,0 +1,32 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 结果总览员工命中明细行
*/
@Data
public class CcdiProjectOverviewEmployeeHitRowVO {
private Long projectId;
private String staffIdCard;
private String staffName;
private String staffCode;
private Long deptId;
private String deptName;
private String modelCode;
private String modelName;
private String ruleCode;
private String ruleName;
private String riskLevel;
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 结果总览员工模型汇总
*/
@Data
public class CcdiProjectOverviewEmployeeModelSummaryVO {
private String modelCode;
private String modelName;
private Integer warningCount;
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
/**
* 结果总览员工规则汇总
*/
@Data
public class CcdiProjectOverviewEmployeeRuleSummaryVO {
private String modelCode;
private String ruleCode;
private String ruleName;
private String riskLevel;
private Integer warningCount;
}

View File

@@ -0,0 +1,46 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 结果总览员工结果 Mapper
*/
public interface CcdiProjectOverviewEmployeeResultMapper extends BaseMapper<CcdiProjectOverviewEmployeeResult> {
/**
* 按项目删除结果
*
* @param projectId 项目ID
* @return 删除条数
*/
int deleteByProjectId(@Param("projectId") Long projectId);
/**
* 批量插入结果
*
* @param list 结果列表
* @return 插入条数
*/
int insertBatch(@Param("list") List<CcdiProjectOverviewEmployeeResult> list);
/**
* 按项目查询结果
*
* @param projectId 项目ID
* @return 结果列表
*/
List<CcdiProjectOverviewEmployeeResult> selectByProjectId(@Param("projectId") Long projectId);
/**
* 按项目查询员工归并命中明细
*
* @param projectId 项目ID
* @return 命中明细
*/
List<CcdiProjectOverviewEmployeeHitRowVO> selectEmployeeHitRowsByProjectId(@Param("projectId") Long projectId);
}

View File

@@ -56,6 +56,14 @@ public interface ICcdiProjectOverviewService {
return new CcdiProjectRiskModelPeopleVO();
}
/**
* 重算结果总览员工结果并同步项目风险人数
*
* @param projectId 项目ID
* @param operator 操作人
*/
void refreshOverviewEmployeeResults(Long projectId, String operator);
/**
* 刷新项目风险人数
*

View File

@@ -129,7 +129,7 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
resultMapper.insertBatch(allResults);
}
projectOverviewService.refreshProjectRiskCounts(projectId, operator);
projectOverviewService.refreshOverviewEmployeeResults(projectId, operator);
task.setStatus(STATUS_SUCCESS);
task.setSuccessRuleCount(rules.size());

View File

@@ -0,0 +1,172 @@
package com.ruoyi.ccdi.project.service.impl;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeModelSummaryVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeRuleSummaryVO;
import org.springframework.stereotype.Component;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 结果总览员工结果构建器
*/
@Component
public class CcdiProjectOverviewEmployeeResultBuilder {
/**
* 按员工归并命中明细并构建结果表实体
*
* @param projectId 项目ID
* @param hitRows 命中明细
* @param operator 操作人
* @return 结果表实体列表
*/
public List<CcdiProjectOverviewEmployeeResult> build(Long projectId,
List<CcdiProjectOverviewEmployeeHitRowVO> hitRows,
String operator) {
if (hitRows == null || hitRows.isEmpty()) {
return List.of();
}
Date now = new Date();
return hitRows.stream()
.filter(item -> isNotBlank(item.getStaffIdCard()))
.collect(Collectors.groupingBy(
CcdiProjectOverviewEmployeeHitRowVO::getStaffIdCard,
LinkedHashMap::new,
Collectors.toList()
))
.entrySet()
.stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> buildSingleResult(projectId, entry.getKey(), entry.getValue(), operator, now))
.toList();
}
private CcdiProjectOverviewEmployeeResult buildSingleResult(Long projectId,
String staffIdCard,
List<CcdiProjectOverviewEmployeeHitRowVO> staffRows,
String operator,
Date now) {
List<CcdiProjectOverviewEmployeeRuleSummaryVO> ruleSummaries = buildRuleSummaries(staffRows);
List<CcdiProjectOverviewEmployeeModelSummaryVO> modelSummaries = buildModelSummaries(staffRows);
CcdiProjectOverviewEmployeeResult result = new CcdiProjectOverviewEmployeeResult();
result.setProjectId(projectId);
result.setStaffIdCard(staffIdCard);
result.setStaffName(firstNonBlank(staffRows, CcdiProjectOverviewEmployeeHitRowVO::getStaffName));
result.setStaffCode(firstNonBlank(staffRows, CcdiProjectOverviewEmployeeHitRowVO::getStaffCode));
result.setDeptId(firstNonNull(staffRows, CcdiProjectOverviewEmployeeHitRowVO::getDeptId));
result.setDeptName(firstNonBlank(staffRows, CcdiProjectOverviewEmployeeHitRowVO::getDeptName));
result.setRuleCount(ruleSummaries.size());
result.setModelCount(modelSummaries.size());
result.setHitCount(staffRows.size());
result.setRiskLevelCode(resolveRiskLevelCode(ruleSummaries.size()));
result.setRiskPoint(ruleSummaries.stream()
.map(CcdiProjectOverviewEmployeeRuleSummaryVO::getRuleName)
.filter(this::isNotBlank)
.collect(Collectors.joining("")));
result.setModelCodesCsv(modelSummaries.stream()
.map(CcdiProjectOverviewEmployeeModelSummaryVO::getModelCode)
.collect(Collectors.joining(",")));
result.setModelNamesJson(JSON.toJSONString(modelSummaries.stream()
.map(CcdiProjectOverviewEmployeeModelSummaryVO::getModelName)
.toList()));
result.setHitRulesJson(JSON.toJSONString(ruleSummaries));
result.setModelHitSummaryJson(JSON.toJSONString(modelSummaries));
result.setCreateBy(operator);
result.setCreateTime(now);
result.setUpdateBy(operator);
result.setUpdateTime(now);
return result;
}
private List<CcdiProjectOverviewEmployeeRuleSummaryVO> buildRuleSummaries(
List<CcdiProjectOverviewEmployeeHitRowVO> staffRows
) {
return staffRows.stream()
.collect(Collectors.groupingBy(
CcdiProjectOverviewEmployeeHitRowVO::getRuleCode,
LinkedHashMap::new,
Collectors.toList()
))
.values()
.stream()
.map(rows -> {
CcdiProjectOverviewEmployeeRuleSummaryVO summary = new CcdiProjectOverviewEmployeeRuleSummaryVO();
CcdiProjectOverviewEmployeeHitRowVO first = rows.getFirst();
summary.setModelCode(first.getModelCode());
summary.setRuleCode(first.getRuleCode());
summary.setRuleName(first.getRuleName());
summary.setRiskLevel(first.getRiskLevel());
summary.setWarningCount(rows.size());
return summary;
})
.sorted(Comparator.comparing(CcdiProjectOverviewEmployeeRuleSummaryVO::getWarningCount).reversed()
.thenComparing(CcdiProjectOverviewEmployeeRuleSummaryVO::getRuleCode))
.toList();
}
private List<CcdiProjectOverviewEmployeeModelSummaryVO> buildModelSummaries(
List<CcdiProjectOverviewEmployeeHitRowVO> staffRows
) {
return staffRows.stream()
.collect(Collectors.groupingBy(
CcdiProjectOverviewEmployeeHitRowVO::getModelCode,
LinkedHashMap::new,
Collectors.toList()
))
.values()
.stream()
.map(rows -> {
CcdiProjectOverviewEmployeeModelSummaryVO summary = new CcdiProjectOverviewEmployeeModelSummaryVO();
CcdiProjectOverviewEmployeeHitRowVO first = rows.getFirst();
summary.setModelCode(first.getModelCode());
summary.setModelName(first.getModelName());
summary.setWarningCount(rows.size());
return summary;
})
.sorted(Comparator.comparing(CcdiProjectOverviewEmployeeModelSummaryVO::getModelCode))
.toList();
}
private String resolveRiskLevelCode(int ruleCount) {
if (ruleCount >= 5) {
return "HIGH";
}
if (ruleCount >= 2) {
return "MEDIUM";
}
return "LOW";
}
private String firstNonBlank(List<CcdiProjectOverviewEmployeeHitRowVO> staffRows,
java.util.function.Function<CcdiProjectOverviewEmployeeHitRowVO, String> getter) {
return staffRows.stream()
.map(getter)
.filter(this::isNotBlank)
.findFirst()
.orElse(null);
}
private <T> T firstNonNull(List<CcdiProjectOverviewEmployeeHitRowVO> staffRows,
java.util.function.Function<CcdiProjectOverviewEmployeeHitRowVO, T> getter) {
return staffRows.stream()
.map(getter)
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
private boolean isNotBlank(String value) {
return value != null && !value.isBlank();
}
}

View File

@@ -3,8 +3,10 @@ package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
@@ -14,6 +16,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.common.exception.ServiceException;
@@ -37,6 +40,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
@Resource
private CcdiProjectMapper projectMapper;
@Resource
private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper;
@Resource
private CcdiProjectOverviewEmployeeResultBuilder overviewEmployeeResultBuilder;
@Override
public CcdiProjectOverviewDashboardVO getDashboard(Long projectId) {
CcdiProject project = overviewMapper.selectDashboardBaseByProjectId(projectId);
@@ -122,6 +131,29 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return people;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void refreshOverviewEmployeeResults(Long projectId, String operator) {
getRequiredProject(projectId);
overviewEmployeeResultMapper.deleteByProjectId(projectId);
List<CcdiProjectOverviewEmployeeHitRowVO> hitRows =
overviewEmployeeResultMapper.selectEmployeeHitRowsByProjectId(projectId);
List<CcdiProjectOverviewEmployeeResult> results =
overviewEmployeeResultBuilder.build(projectId, hitRows, operator);
if (!results.isEmpty()) {
overviewEmployeeResultMapper.insertBatch(results);
}
projectMapper.updateRiskCountsByProjectId(
projectId,
countRiskLevel(results, "HIGH"),
countRiskLevel(results, "MEDIUM"),
countRiskLevel(results, "LOW"),
operator
);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void refreshProjectRiskCounts(Long projectId, String operator) {
@@ -196,6 +228,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
throw new ServiceException("项目风险人数统计结果类型异常");
}
private Integer countRiskLevel(List<CcdiProjectOverviewEmployeeResult> results, String riskLevelCode) {
return Math.toIntExact(results.stream()
.filter(item -> riskLevelCode.equals(item.getRiskLevelCode()))
.count());
}
private Integer defaultZero(Integer value) {
return value == null ? 0 : value;
}

View File

@@ -0,0 +1,157 @@
<?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.CcdiProjectOverviewEmployeeResultMapper">
<resultMap id="CcdiProjectOverviewEmployeeResultMap"
type="com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult">
<id property="id" column="id"/>
<result property="projectId" column="project_id"/>
<result property="staffIdCard" column="staff_id_card"/>
<result property="staffCode" column="staff_code"/>
<result property="staffName" column="staff_name"/>
<result property="deptId" column="dept_id"/>
<result property="deptName" column="dept_name"/>
<result property="ruleCount" column="rule_count"/>
<result property="modelCount" column="model_count"/>
<result property="hitCount" column="hit_count"/>
<result property="riskLevelCode" column="risk_level_code"/>
<result property="riskPoint" column="risk_point"/>
<result property="modelCodesCsv" column="model_codes_csv"/>
<result property="modelNamesJson" column="model_names_json"/>
<result property="hitRulesJson" column="hit_rules_json"/>
<result property="modelHitSummaryJson" column="model_hit_summary_json"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
<result property="remark" column="remark"/>
</resultMap>
<resultMap id="CcdiProjectOverviewEmployeeHitRowMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO">
<result property="projectId" column="project_id"/>
<result property="staffIdCard" column="staff_id_card"/>
<result property="staffName" column="staff_name"/>
<result property="staffCode" column="staff_code"/>
<result property="deptId" column="dept_id"/>
<result property="deptName" column="dept_name"/>
<result property="modelCode" column="model_code"/>
<result property="modelName" column="model_name"/>
<result property="ruleCode" column="rule_code"/>
<result property="ruleName" column="rule_name"/>
<result property="riskLevel" column="risk_level"/>
</resultMap>
<sql id="resolvedEmployeeHitRowsSql">
select distinct
tr.project_id,
coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card) as staff_id_card,
coalesce(direct_staff.name, statement_staff.name, family_staff.name) as staff_name,
cast(coalesce(direct_staff.staff_id, statement_staff.staff_id, family_staff.staff_id) as char) as staff_code,
coalesce(direct_staff.dept_id, statement_staff.dept_id, family_staff.dept_id) as dept_id,
dept.dept_name,
tr.model_code,
tr.model_name,
tr.rule_code,
tr.rule_name,
tr.risk_level
from ccdi_bank_statement_tag_result tr
left join ccdi_base_staff direct_staff
on tr.object_type = 'STAFF_ID_CARD'
and tr.object_key = direct_staff.id_card
left join ccdi_bank_statement bs
on tr.bank_statement_id = bs.bank_statement_id
left join ccdi_base_staff statement_staff
on (tr.object_key is null or tr.object_key = '')
and bs.cret_no = statement_staff.id_card
left join ccdi_staff_fmy_relation relation
on relation.status = 1
and (
((tr.object_key is null or tr.object_key = '') and bs.cret_no = relation.relation_cert_no)
or ((tr.object_key is not null and tr.object_key != '') and tr.object_type != 'STAFF_ID_CARD'
and tr.object_key = relation.relation_cert_no)
)
left join ccdi_base_staff family_staff
on relation.person_id = family_staff.id_card
left join sys_dept dept
on dept.dept_id = coalesce(direct_staff.dept_id, statement_staff.dept_id, family_staff.dept_id)
where tr.project_id = #{projectId}
and coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card) is not null
</sql>
<delete id="deleteByProjectId">
delete from ccdi_project_overview_employee_result
where project_id = #{projectId}
</delete>
<insert id="insertBatch" parameterType="java.util.List">
insert into ccdi_project_overview_employee_result (
project_id, staff_id_card, staff_code, staff_name, dept_id, dept_name,
rule_count, model_count, hit_count, risk_level_code, risk_point,
model_codes_csv, model_names_json, hit_rules_json, model_hit_summary_json,
create_by, create_time, update_by, update_time, remark
) values
<foreach collection="list" item="item" separator=",">
(
#{item.projectId}, #{item.staffIdCard}, #{item.staffCode}, #{item.staffName},
#{item.deptId}, #{item.deptName}, #{item.ruleCount}, #{item.modelCount},
#{item.hitCount}, #{item.riskLevelCode}, #{item.riskPoint},
#{item.modelCodesCsv}, #{item.modelNamesJson}, #{item.hitRulesJson}, #{item.modelHitSummaryJson},
#{item.createBy}, #{item.createTime}, #{item.updateBy}, #{item.updateTime}, #{item.remark}
)
</foreach>
on duplicate key update
staff_code = values(staff_code),
staff_name = values(staff_name),
dept_id = values(dept_id),
dept_name = values(dept_name),
rule_count = values(rule_count),
model_count = values(model_count),
hit_count = values(hit_count),
risk_level_code = values(risk_level_code),
risk_point = values(risk_point),
model_codes_csv = values(model_codes_csv),
model_names_json = values(model_names_json),
hit_rules_json = values(hit_rules_json),
model_hit_summary_json = values(model_hit_summary_json),
update_by = values(update_by),
update_time = values(update_time),
remark = values(remark)
</insert>
<select id="selectByProjectId" resultMap="CcdiProjectOverviewEmployeeResultMap">
select
id,
project_id,
staff_id_card,
staff_code,
staff_name,
dept_id,
dept_name,
rule_count,
model_count,
hit_count,
risk_level_code,
risk_point,
model_codes_csv,
model_names_json,
hit_rules_json,
model_hit_summary_json,
create_by,
create_time,
update_by,
update_time,
remark
from ccdi_project_overview_employee_result
where project_id = #{projectId}
order by id asc
</select>
<select id="selectEmployeeHitRowsByProjectId" resultMap="CcdiProjectOverviewEmployeeHitRowMap">
<include refid="resolvedEmployeeHitRowsSql"/>
order by staff_id_card asc, model_code asc, rule_code asc
</select>
</mapper>

View File

@@ -33,6 +33,29 @@
select="selectRiskHitTagsByScope"/>
</resultMap>
<sql id="digitTableSql">
select 0 as digit
union all select 1
union all select 2
union all select 3
union all select 4
union all select 5
union all select 6
union all select 7
union all select 8
union all select 9
</sql>
<sql id="jsonArrayIndexSql">
select ones.digit + tens.digit * 10 as idx
from (
<include refid="digitTableSql"/>
) ones
cross join (
<include refid="digitTableSql"/>
) tens
</sql>
<sql id="resolvedEmployeeRiskBaseSql">
select distinct
tr.id,
@@ -186,14 +209,60 @@
</select>
<select id="selectRiskPeopleOverviewByProjectId" resultMap="EmployeeRiskAggregateResultMap">
<include refid="employeeRiskAggregateSql"/>
order by risk_level_sort asc, model_count desc, rule_count desc, staff_id_card asc
select
result.staff_id_card,
result.staff_name,
result.dept_id,
result.dept_name,
result.rule_count,
result.model_count,
result.hit_count,
null as top_rule_code,
null as top_rule_name,
result.risk_point,
result.risk_level_code,
case
when result.risk_level_code = 'HIGH' then '高风险'
when result.risk_level_code = 'MEDIUM' then '中风险'
else '低风险'
end as risk_level_name,
case
when result.risk_level_code = 'HIGH' then 1
when result.risk_level_code = 'MEDIUM' then 2
else 3
end as risk_level_sort
from ccdi_project_overview_employee_result result
where result.project_id = #{projectId}
order by risk_level_sort asc, result.model_count desc, result.rule_count desc, result.staff_id_card asc
</select>
<select id="selectTopRiskPeopleByProjectId" resultMap="EmployeeRiskAggregateResultMap">
<include refid="employeeRiskAggregateSql"/>
where rule_count >= 2
order by risk_level_sort asc, model_count desc, rule_count desc, staff_id_card asc
select
result.staff_id_card,
result.staff_name,
result.dept_id,
result.dept_name,
result.rule_count,
result.model_count,
result.hit_count,
null as top_rule_code,
null as top_rule_name,
result.risk_point,
result.risk_level_code,
case
when result.risk_level_code = 'HIGH' then '高风险'
when result.risk_level_code = 'MEDIUM' then '中风险'
else '低风险'
end as risk_level_name,
case
when result.risk_level_code = 'HIGH' then 1
when result.risk_level_code = 'MEDIUM' then 2
else 3
end as risk_level_sort
from ccdi_project_overview_employee_result result
where result.project_id = #{projectId}
and result.risk_level_code in ('HIGH', 'MEDIUM')
order by risk_level_sort asc, result.model_count desc, result.rule_count desc, result.staff_id_card asc
limit 10
</select>
@@ -213,13 +282,18 @@
) models
left join (
select
base.model_code,
count(1) as warning_count,
count(distinct base.staff_id_card) as people_count
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) base
group by base.model_code
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelCode'))) as model_code,
sum(cast(json_unquote(json_extract(
result.model_hit_summary_json,
concat('$[', idx.idx, '].warningCount')
)) as unsigned)) as warning_count,
count(distinct result.staff_id_card) as people_count
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}
group by json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelCode')))
) stats on models.model_code = stats.model_code
order by warning_count desc, model_code asc
</select>
@@ -227,73 +301,88 @@
<select id="selectRiskModelPeoplePage" resultMap="RiskModelPeopleItemResultMap">
<bind name="projectId" value="query.projectId"/>
select
base.project_id,
base.staff_id_card,
max(base.staff_name) as staff_name,
max(base.staff_code) as staff_code,
max(dept.dept_name) as department,
result.project_id,
result.staff_id_card,
result.staff_name,
result.staff_code,
result.dept_name as department,
#{query.modelCodesCsv} as selected_model_codes
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) base
left join sys_dept dept on base.dept_id = dept.dept_id
from ccdi_project_overview_employee_result result
where 1 = 1
and result.project_id = #{query.projectId}
<if test="query.modelCodes != null and query.modelCodes.size() > 0">
and base.model_code in
<foreach collection="query.modelCodes" item="modelCode" open="(" separator="," close=")">
#{modelCode}
</foreach>
<choose>
<when test="query.matchMode == 'ALL'">
<foreach collection="query.modelCodes" item="modelCode">
and find_in_set(#{modelCode}, result.model_codes_csv)
</foreach>
</when>
<otherwise>
and (
<foreach collection="query.modelCodes" item="modelCode" separator=" or ">
find_in_set(#{modelCode}, result.model_codes_csv)
</foreach>
)
</otherwise>
</choose>
</if>
<if test="query.keyword != null and query.keyword != ''">
and (
base.staff_name like concat('%', trim(#{query.keyword}), '%')
or cast(base.staff_code as char) like concat('%', trim(#{query.keyword}), '%')
result.staff_name like concat('%', trim(#{query.keyword}), '%')
or result.staff_code like concat('%', trim(#{query.keyword}), '%')
)
</if>
<if test="query.deptId != null">
and base.dept_id = #{query.deptId}
and result.dept_id = #{query.deptId}
</if>
group by base.project_id, base.staff_id_card
<if test="query.modelCodes != null and query.modelCodes.size() > 0 and query.matchMode == 'ALL'">
having count(distinct base.model_code) = #{query.modelCodesCount}
</if>
order by max(base.staff_name) asc, base.staff_id_card asc
order by result.staff_name asc, result.staff_id_card asc
</select>
<select id="selectRiskModelNamesByScope" resultType="java.lang.String">
select scoped.model_name
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) scoped
where scoped.project_id = #{projectId}
and scoped.staff_id_card = #{staffIdCard}
select
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelName'))) as model_name
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}
and result.staff_id_card = #{staffIdCard}
<if test="selectedModelCodes != null and selectedModelCodes != ''">
and find_in_set(scoped.model_code, #{selectedModelCodes})
and find_in_set(
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelCode'))),
#{selectedModelCodes}
)
</if>
group by scoped.model_code, scoped.model_name
order by scoped.model_code asc
group by
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelCode'))),
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelName')))
order by json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelCode'))) asc
</select>
<select id="selectRiskHitTagsByScope" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO">
select
scoped.rule_code,
max(scoped.rule_name) as rule_name,
max(scoped.risk_level) as risk_level
from (
<include refid="resolvedEmployeeRiskBaseSql"/>
) scoped
where scoped.project_id = #{projectId}
and scoped.staff_id_card = #{staffIdCard}
json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].ruleCode'))) as rule_code,
max(json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].ruleName')))) as rule_name,
max(json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].riskLevel')))) as risk_level
from ccdi_project_overview_employee_result result
join (
<include refid="jsonArrayIndexSql"/>
) idx on idx.idx &lt; json_length(result.hit_rules_json)
where result.project_id = #{projectId}
and result.staff_id_card = #{staffIdCard}
<if test="selectedModelCodes != null and selectedModelCodes != ''">
and find_in_set(scoped.model_code, #{selectedModelCodes})
and find_in_set(
json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].modelCode'))),
#{selectedModelCodes}
)
</if>
group by scoped.rule_code
order by case max(scoped.risk_level)
group by json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].ruleCode')))
order by case max(json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].riskLevel'))))
when 'HIGH' then 1
when 'MEDIUM' then 2
else 3
end,
scoped.rule_code asc
json_unquote(json_extract(result.hit_rules_json, concat('$[', idx.idx, '].ruleCode'))) asc
</select>
<select id="selectRiskCountSummaryByProjectId" resultType="map">

View File

@@ -0,0 +1,56 @@
package com.ruoyi.ccdi.project.domain.entity;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewEmployeeResultEntityTest {
private static final Path ENTITY_PATH = Path.of(
"src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectOverviewEmployeeResult.java"
);
@Test
void entityAndSqlShouldDefineProjectOverviewEmployeeResultTable() throws Exception {
String entitySource = Files.readString(ENTITY_PATH);
String sql = Files.readString(resolveSqlPath());
String normalizedSql = sql.toLowerCase().replace("`", "");
assertAll(
() -> assertTrue(entitySource.contains("@TableName(\"ccdi_project_overview_employee_result\")")),
() -> assertTrue(entitySource.contains("private Long projectId;")),
() -> assertTrue(entitySource.contains("private String staffIdCard;")),
() -> assertTrue(entitySource.contains("private Integer ruleCount;")),
() -> assertTrue(entitySource.contains("private Integer modelCount;")),
() -> assertTrue(entitySource.contains("private Integer hitCount;")),
() -> assertTrue(entitySource.contains("private String riskLevelCode;")),
() -> assertTrue(entitySource.contains("private String modelCodesCsv;")),
() -> assertTrue(entitySource.contains("private String modelNamesJson;")),
() -> assertTrue(entitySource.contains("private String hitRulesJson;")),
() -> assertTrue(entitySource.contains("private String modelHitSummaryJson;")),
() -> assertTrue(normalizedSql.contains("create table if not exists ccdi_project_overview_employee_result")),
() -> assertTrue(normalizedSql.contains("unique key")),
() -> assertTrue(normalizedSql.contains("project_id")),
() -> assertTrue(normalizedSql.contains("staff_id_card")),
() -> assertTrue(normalizedSql.contains("rule_count")),
() -> assertTrue(normalizedSql.contains("model_count")),
() -> assertTrue(normalizedSql.contains("hit_count")),
() -> assertTrue(normalizedSql.contains("risk_level_code")),
() -> assertTrue(normalizedSql.contains("model_codes_csv")),
() -> assertTrue(normalizedSql.contains("model_names_json")),
() -> assertTrue(normalizedSql.contains("hit_rules_json")),
() -> assertTrue(normalizedSql.contains("model_hit_summary_json"))
);
}
private Path resolveSqlPath() {
Path moduleRelative = Path.of("../sql/migration/2026-03-20-create-project-overview-employee-result-table.sql");
if (Files.exists(moduleRelative)) {
return moduleRelative;
}
return Path.of("sql/migration/2026-03-20-create-project-overview-employee-result-table.sql");
}
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.ccdi.project.mapper;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewEmployeeResultMapperXmlTest {
private static final Path MAPPER_PATH = Path.of(
"src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewEmployeeResultMapper.java"
);
private static final Path XML_PATH = Path.of(
"src/main/resources/mapper/ccdi/project/CcdiProjectOverviewEmployeeResultMapper.xml"
);
@Test
void mapperAndXmlShouldExposeMinimumCrudStatements() throws Exception {
String mapperSource = Files.readString(MAPPER_PATH);
String xml = Files.readString(XML_PATH);
assertAll(
() -> assertTrue(mapperSource.contains("interface CcdiProjectOverviewEmployeeResultMapper")),
() -> assertTrue(mapperSource.contains("deleteByProjectId")),
() -> assertTrue(mapperSource.contains("insertBatch")),
() -> assertTrue(mapperSource.contains("selectByProjectId")),
() -> assertTrue(xml.contains("delete id=\"deleteByProjectId\"")),
() -> assertTrue(xml.contains("insert id=\"insertBatch\"")),
() -> assertTrue(xml.contains("select id=\"selectByProjectId\"")),
() -> assertTrue(xml.contains("ccdi_project_overview_employee_result"))
);
}
}

View File

@@ -17,18 +17,19 @@ class CcdiProjectOverviewMapperRiskModelCardsTest {
}
@Test
void shouldDefineRiskModelCardsSqlUsingEmployeeResolvedBase() throws Exception {
void shouldDefineRiskModelCardsSqlUsingEmployeeResultSnapshot() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
assertTrue(xml.contains("<select id=\"selectRiskModelCardsByProjectId\""));
assertTrue(xml.contains("from ("));
assertTrue(xml.contains("from ccdi_project_overview_employee_result"));
assertTrue(xml.contains("from ccdi_bank_tag_rule"));
assertTrue(xml.contains("where enabled = 1"));
assertTrue(xml.contains("left join ("));
assertTrue(xml.contains("<include refid=\"resolvedEmployeeRiskBaseSql\"/>"));
assertTrue(xml.contains("model_hit_summary_json"));
assertTrue(xml.contains("json_extract("));
assertTrue(xml.contains("coalesce(stats.warning_count, 0) as warning_count"));
assertTrue(xml.contains("coalesce(stats.people_count, 0) as people_count"));
assertTrue(xml.contains("count(1) as warning_count"));
assertTrue(xml.contains(".warningCount"));
assertTrue(xml.contains("order by warning_count desc, model_code asc"));
}
}

View File

@@ -34,18 +34,19 @@ class CcdiProjectOverviewMapperRiskModelPeopleTest {
assertTrue(xml.contains("query.modelCodes != null and query.modelCodes.size() > 0"));
assertTrue(xml.contains("query.matchMode == 'ALL'"));
assertFalse(xml.contains("#{query.modelCodes.size}"));
assertTrue(xml.contains("count(distinct base.model_code) = #{query.modelCodesCount}"));
assertTrue(xml.contains("find_in_set(#{modelCode}, result.model_codes_csv)"));
assertTrue(xml.contains("<bind name=\"projectId\" value=\"query.projectId\"/>"));
assertTrue(xml.contains("base.staff_name like concat('%', trim(#{query.keyword}), '%')"));
assertTrue(xml.contains("cast(base.staff_code as char) like concat('%', trim(#{query.keyword}), '%')"));
assertTrue(xml.contains("base.dept_id = #{query.deptId}"));
assertTrue(xml.contains("result.staff_name like concat('%', trim(#{query.keyword}), '%')"));
assertTrue(xml.contains("result.staff_code like concat('%', trim(#{query.keyword}), '%')"));
assertTrue(xml.contains("result.dept_id = #{query.deptId}"));
assertTrue(xml.contains("select=\"selectRiskModelNamesByScope\""));
assertTrue(xml.contains("select=\"selectRiskHitTagsByScope\""));
assertTrue(xml.contains("find_in_set(scoped.model_code, #{selectedModelCodes})"));
assertFalse(xml.contains("select distinct scoped.model_name"));
assertTrue(xml.contains("group by scoped.model_code, scoped.model_name"));
assertTrue(xml.contains("order by scoped.model_code asc"));
assertTrue(xml.contains("order by case max(scoped.risk_level)"));
assertTrue(xml.contains("scoped.rule_code asc"));
assertTrue(xml.contains("model_hit_summary_json"));
assertTrue(xml.contains("hit_rules_json"));
assertTrue(xml.contains("json_extract("));
assertTrue(xml.contains(".modelCode"));
assertTrue(xml.contains(".modelName"));
assertTrue(xml.contains(".ruleCode"));
assertTrue(xml.contains(".riskLevel"));
}
}

View File

@@ -11,18 +11,30 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewMapperSqlTest {
@Test
void shouldContainEmployeeRiskAggregationSql() throws Exception {
void shouldReadOverviewQueriesFromEmployeeResultTable() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String riskPeopleSql = extractSelect(xml, "selectRiskPeopleOverviewByProjectId");
String topRiskPeopleSql = extractSelect(xml, "selectTopRiskPeopleByProjectId");
String riskModelCardsSql = extractSelect(xml, "selectRiskModelCardsByProjectId");
String riskModelPeopleSql = extractSelect(xml, "selectRiskModelPeoplePage");
assertTrue(xml.contains("count(distinct base.rule_code)"));
assertTrue(xml.contains("count(distinct base.model_code)"));
assertTrue(xml.contains("count(1) as hit_count"));
assertTrue(xml.contains("agg.hit_count"));
assertTrue(xml.contains("when agg.rule_count >= 5 then 'HIGH'"));
assertTrue(xml.contains("when agg.rule_count between 2 and 4 then 'MEDIUM'"));
assertTrue(xml.contains("group_concat("));
assertTrue(xml.contains("as risk_point"));
assertTrue(xml.contains("order by grouped.hit_count desc, grouped.rule_code asc"));
assertTrue(riskPeopleSql.contains("from ccdi_project_overview_employee_result"));
assertTrue(riskPeopleSql.contains("risk_level_code"));
assertTrue(riskPeopleSql.contains("model_count"));
assertTrue(riskPeopleSql.contains("risk_point"));
assertFalse(riskPeopleSql.contains("resolvedEmployeeRiskBaseSql"));
assertTrue(topRiskPeopleSql.contains("from ccdi_project_overview_employee_result"));
assertTrue(topRiskPeopleSql.contains("risk_level_code in ('HIGH', 'MEDIUM')"));
assertFalse(topRiskPeopleSql.contains("resolvedEmployeeRiskBaseSql"));
assertTrue(riskModelCardsSql.contains("from ccdi_project_overview_employee_result"));
assertTrue(riskModelCardsSql.contains("model_hit_summary_json"));
assertFalse(riskModelCardsSql.contains("resolvedEmployeeRiskBaseSql"));
assertTrue(riskModelPeopleSql.contains("from ccdi_project_overview_employee_result"));
assertTrue(riskModelPeopleSql.contains("model_codes_csv"));
assertFalse(riskModelPeopleSql.contains("resolvedEmployeeRiskBaseSql"));
}
@Test
@@ -30,6 +42,13 @@ class CcdiProjectOverviewMapperSqlTest {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
assertFalse(xml.contains("row_number() over"), xml);
assertTrue(xml.contains("not exists"), xml);
assertFalse(xml.contains("json_table("), xml);
}
private String extractSelect(String xml, String selectId) {
String start = "<select id=\"" + selectId + "\"";
int startIndex = xml.indexOf(start);
int endIndex = xml.indexOf("</select>", startIndex);
return xml.substring(startIndex, endIndex);
}
}

View File

@@ -13,6 +13,7 @@ class CcdiProjectOverviewServiceStructureTest {
assertNotNull(clazz.getMethod("getDashboard", Long.class));
assertNotNull(clazz.getMethod("getRiskPeopleOverview", Long.class));
assertNotNull(clazz.getMethod("getTopRiskPeople", Long.class));
assertNotNull(clazz.getMethod("refreshOverviewEmployeeResults", Long.class, String.class));
assertNotNull(clazz.getMethod("refreshProjectRiskCounts", Long.class, String.class));
}
}

View File

@@ -62,7 +62,7 @@ class CcdiBankTagServiceRiskCountRefreshTest {
private ProjectBankTagRebuildCoordinator coordinator;
@Test
void shouldRefreshProjectRiskCountsAfterTagRebuildSuccess() {
void shouldRefreshOverviewEmployeeResultsAfterTagRebuildSuccess() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule();
@@ -84,13 +84,13 @@ class CcdiBankTagServiceRiskCountRefreshTest {
inOrder.verify(projectService).updateProjectStatus(40L, "3", "tester");
inOrder.verify(resultMapper).deleteByProjectAndModel(40L, null);
inOrder.verify(resultMapper).insertBatch(anyList());
inOrder.verify(projectOverviewService).refreshProjectRiskCounts(40L, "tester");
inOrder.verify(projectOverviewService).refreshOverviewEmployeeResults(40L, "tester");
inOrder.verify(taskMapper).updateTask(argThat(task -> "SUCCESS".equals(task.getStatus())));
inOrder.verify(projectService).updateProjectStatus(40L, "1", "tester");
}
@Test
void shouldFailTaskWhenRiskCountRefreshFails() {
void shouldFailTaskWhenOverviewEmployeeResultRefreshFails() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule();
@@ -105,7 +105,7 @@ class CcdiBankTagServiceRiskCountRefreshTest {
when(configResolver.resolve(40L, rule)).thenReturn(config);
when(analysisMapper.selectHouseOrCarExpenseStatements(40L)).thenReturn(List.of(buildHit()));
doThrow(new RuntimeException("refresh failed"))
.when(projectOverviewService).refreshProjectRiskCounts(40L, "tester");
.when(projectOverviewService).refreshOverviewEmployeeResults(40L, "tester");
assertThrows(RuntimeException.class,
() -> service.rebuildProject(40L, null, "tester", TriggerType.MANUAL));

View File

@@ -0,0 +1,118 @@
package com.ruoyi.ccdi.project.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class CcdiProjectOverviewEmployeeResultBuilderTest {
@Test
@SuppressWarnings("unchecked")
void shouldAggregateHitRowsIntoEmployeeResultSnapshots() throws Exception {
Class<?> rowClass = Class.forName("com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO");
Class<?> builderClass = Class.forName("com.ruoyi.ccdi.project.service.impl.CcdiProjectOverviewEmployeeResultBuilder");
Object builder = builderClass.getDeclaredConstructor().newInstance();
Method buildMethod = builderClass.getMethod("build", Long.class, List.class, String.class);
List<Object> hitRows = List.of(
buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部",
"LARGE_TRANSACTION", "大额交易", "HOUSE_OR_CAR_EXPENSE", "房车消费支出交易", "HIGH"),
buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部",
"LARGE_TRANSACTION", "大额交易", "HOUSE_OR_CAR_EXPENSE", "房车消费支出交易", "HIGH"),
buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部",
"LARGE_TRANSACTION", "大额交易", "TAX_EXPENSE", "税务支出交易", "HIGH"),
buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部",
"ABNORMAL_TRANSACTION", "异常交易", "ABNORMAL_CUSTOMER_TRANSACTION", "异常客户交易", "HIGH"),
buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部",
"ABNORMAL_TRANSACTION", "异常交易", "ABNORMAL_CUSTOMER_TRANSACTION", "异常客户交易", "HIGH"),
buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部",
"SUSPICIOUS_PART_TIME", "可疑兼职", "MONTHLY_FIXED_INCOME", "疑似兼职", "MEDIUM"),
buildHitRow(rowClass, "330000000000000001", "李四", "E1001", 12L, "信息二部",
"SUSPICIOUS_PROPERTY", "可疑财产", "HOUSE_REGISTRATION_MISMATCH", "房产登记不匹配", "LOW")
);
List<CcdiProjectOverviewEmployeeResult> results =
(List<CcdiProjectOverviewEmployeeResult>) buildMethod.invoke(builder, 40L, hitRows, "tester");
assertEquals(1, results.size());
CcdiProjectOverviewEmployeeResult result = results.getFirst();
assertEquals(40L, result.getProjectId());
assertEquals("330000000000000001", result.getStaffIdCard());
assertEquals("李四", result.getStaffName());
assertEquals("E1001", result.getStaffCode());
assertEquals(12L, result.getDeptId());
assertEquals("信息二部", result.getDeptName());
assertEquals(5, result.getRuleCount());
assertEquals(4, result.getModelCount());
assertEquals(7, result.getHitCount());
assertEquals("HIGH", result.getRiskLevelCode());
assertEquals("ABNORMAL_TRANSACTION,LARGE_TRANSACTION,SUSPICIOUS_PART_TIME,SUSPICIOUS_PROPERTY",
result.getModelCodesCsv());
assertNotNull(result.getRiskPoint());
JSONArray modelNames = JSON.parseArray(result.getModelNamesJson());
assertEquals(List.of("异常交易", "大额交易", "可疑兼职", "可疑财产"),
modelNames.toList(String.class));
JSONArray hitRules = JSON.parseArray(result.getHitRulesJson());
assertEquals(5, hitRules.size());
JSONObject firstRule = hitRules.getJSONObject(0);
assertEquals("ABNORMAL_CUSTOMER_TRANSACTION", firstRule.getString("ruleCode"));
assertEquals("异常客户交易", firstRule.getString("ruleName"));
assertEquals("HIGH", firstRule.getString("riskLevel"));
assertEquals(2, firstRule.getIntValue("warningCount"));
JSONArray modelSummary = JSON.parseArray(result.getModelHitSummaryJson());
Map<String, Integer> warningCountByModel = modelSummary.toList(JSONObject.class).stream()
.collect(java.util.stream.Collectors.toMap(
item -> item.getString("modelCode"),
item -> item.getIntValue("warningCount")
));
assertEquals(2, warningCountByModel.get("ABNORMAL_TRANSACTION"));
assertEquals(3, warningCountByModel.get("LARGE_TRANSACTION"));
assertEquals(1, warningCountByModel.get("SUSPICIOUS_PROPERTY"));
assertEquals(1, warningCountByModel.get("SUSPICIOUS_PART_TIME"));
}
private Object buildHitRow(Class<?> rowClass,
String staffIdCard,
String staffName,
String staffCode,
Long deptId,
String deptName,
String modelCode,
String modelName,
String ruleCode,
String ruleName,
String riskLevel) throws Exception {
Object row = rowClass.getDeclaredConstructor().newInstance();
setField(rowClass, row, "setProjectId", Long.class, 40L);
setField(rowClass, row, "setStaffIdCard", String.class, staffIdCard);
setField(rowClass, row, "setStaffName", String.class, staffName);
setField(rowClass, row, "setStaffCode", String.class, staffCode);
setField(rowClass, row, "setDeptId", Long.class, deptId);
setField(rowClass, row, "setDeptName", String.class, deptName);
setField(rowClass, row, "setModelCode", String.class, modelCode);
setField(rowClass, row, "setModelName", String.class, modelName);
setField(rowClass, row, "setRuleCode", String.class, ruleCode);
setField(rowClass, row, "setRuleName", String.class, ruleName);
setField(rowClass, row, "setRiskLevel", String.class, riskLevel);
return row;
}
private void setField(Class<?> rowClass, Object row, String methodName, Class<?> parameterType, Object value)
throws Exception {
Method method = rowClass.getMethod(methodName, parameterType);
method.invoke(row, value);
}
}

View File

@@ -3,8 +3,10 @@ package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
@@ -12,6 +14,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
import com.ruoyi.common.exception.ServiceException;
import java.math.BigDecimal;
@@ -44,6 +47,12 @@ class CcdiProjectOverviewServiceImplTest {
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper;
@Mock
private CcdiProjectOverviewEmployeeResultBuilder overviewEmployeeResultBuilder;
@Test
void shouldBuildDashboardWithNoRiskCount() {
CcdiProject project = new CcdiProject();
@@ -182,6 +191,36 @@ class CcdiProjectOverviewServiceImplTest {
assertEquals("查看详情", result.getRows().getFirst().getActionLabel());
}
@Test
void shouldRefreshOverviewEmployeeResultsAndSyncRiskCounts() {
CcdiProject project = new CcdiProject();
project.setProjectId(43L);
when(projectMapper.selectById(43L)).thenReturn(project);
List<CcdiProjectOverviewEmployeeHitRowVO> hitRows = List.of(new CcdiProjectOverviewEmployeeHitRowVO());
List<CcdiProjectOverviewEmployeeResult> results = List.of(
buildEmployeeResult("HIGH"),
buildEmployeeResult("MEDIUM"),
buildEmployeeResult("LOW")
);
when(overviewEmployeeResultMapper.selectEmployeeHitRowsByProjectId(43L)).thenReturn(hitRows);
when(overviewEmployeeResultBuilder.build(43L, hitRows, "tester")).thenReturn(results);
service.refreshOverviewEmployeeResults(43L, "tester");
org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(
projectMapper,
overviewEmployeeResultMapper,
overviewEmployeeResultBuilder
);
inOrder.verify(projectMapper).selectById(43L);
inOrder.verify(overviewEmployeeResultMapper).deleteByProjectId(43L);
inOrder.verify(overviewEmployeeResultMapper).selectEmployeeHitRowsByProjectId(43L);
inOrder.verify(overviewEmployeeResultBuilder).build(43L, hitRows, "tester");
inOrder.verify(overviewEmployeeResultMapper).insertBatch(results);
inOrder.verify(projectMapper).updateRiskCountsByProjectId(43L, 1, 1, 1, "tester");
}
@Test
void shouldReturnEmptyCollectionsForRiskModelCardsAndPeople() {
CcdiProject project = new CcdiProject();
@@ -234,4 +273,10 @@ class CcdiProjectOverviewServiceImplTest {
queryDTO.setPageSize(10);
return queryDTO;
}
private CcdiProjectOverviewEmployeeResult buildEmployeeResult(String riskLevelCode) {
CcdiProjectOverviewEmployeeResult result = new CcdiProjectOverviewEmployeeResult();
result.setRiskLevelCode(riskLevelCode);
return result;
}
}

View File

@@ -0,0 +1,50 @@
# 结果总览员工结果表后端实施记录
## 设计对照
- 本次实现对照设计文档 [`2026-03-20-results-overview-employee-result-table-design.md`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/docs/design/2026-03-20-results-overview-employee-result-table-design.md) 落地。
- 实现范围与设计保持一致:
- 新增结果总览员工结果表
- 命中结果写库后同事务重算员工结果表
- 结果总览 4 类查询切换为只读员工结果表
- 模型卡片 `warningCount` 保持为原始标签命中次数
## 本次改动
- 在 [`2026-03-20-create-project-overview-employee-result-table.sql`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/sql/migration/2026-03-20-create-project-overview-employee-result-table.sql) 新增 `ccdi_project_overview_employee_result` 建表脚本,落地 `project_id + staff_id_card` 唯一键、风险统计字段、模型/规则快照字段和审计字段。
- 在 [`CcdiProjectOverviewEmployeeResult.java`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectOverviewEmployeeResult.java)、[`CcdiProjectOverviewEmployeeResultMapper.java`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectOverviewEmployeeResultMapper.java)、[`CcdiProjectOverviewEmployeeResultMapper.xml`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewEmployeeResultMapper.xml) 补齐结果表实体、最小 CRUD 和“按项目查询员工归并命中明细”的内部查询。
- 新增 [`CcdiProjectOverviewEmployeeHitRowVO.java`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewEmployeeHitRowVO.java)、[`CcdiProjectOverviewEmployeeRuleSummaryVO.java`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewEmployeeRuleSummaryVO.java)、[`CcdiProjectOverviewEmployeeModelSummaryVO.java`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiProjectOverviewEmployeeModelSummaryVO.java) 与 [`CcdiProjectOverviewEmployeeResultBuilder.java`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewEmployeeResultBuilder.java),把“员工本人 + 亲属归并到员工本人”的现有口径前移到写库后的重算阶段,并输出:
- `risk_point`
- `model_codes_csv`
- `model_names_json`
- `hit_rules_json`
- `model_hit_summary_json`
- 在 [`ICcdiProjectOverviewService.java`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiProjectOverviewService.java) 与 [`CcdiProjectOverviewServiceImpl.java`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiProjectOverviewServiceImpl.java) 新增 `refreshOverviewEmployeeResults`,按固定顺序执行:
- 校验项目存在
- 删除当前项目历史员工结果
- 查询全量员工归并命中明细
- 通过 builder 聚合为结果表实体
- 批量写入员工结果表
- 基于聚合结果同步项目高/中/低风险人数
- 在 [`CcdiBankTagServiceImpl.java`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java) 把标签成功链路从“只刷新项目风险人数”切换为“重算员工结果表并同步风险人数”,使标签重算失败时整条任务失败并回滚。
- 在 [`CcdiProjectOverviewMapper.xml`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml) 将以下 4 类结果总览查询切换为只读 `ccdi_project_overview_employee_result`
- 风险人员总览
- 中高风险 TOP10
- 风险模型卡片
- 风险模型命中人员分页
- 模型卡片改为基于 `model_hit_summary_json` 展开统计,`warningCount` 继续按原始标签命中次数累加。
- 模型人员分页改为基于 `model_codes_csv``ANY / ALL` 过滤,并通过 `model_hit_summary_json``hit_rules_json` 还原 `modelNames``hitTagList`,保留 `keyword``deptId``pageNum``pageSize` 能力。
## 处理说明
- 没有新增平行 Controller 或对外接口,继续复用现有 `CcdiProjectOverviewController + Service + Mapper` 入口。
- 没有引入异步刷新、缓存或补丁式兜底逻辑,按设计保持“按项目整块重算”的最短实现路径。
- 为兼容现有 MySQL 能力,结果表快照查询使用 `json_extract + JSON 数组下标展开`,没有引入 `json_table` 或窗口函数。
- `hit_rules_json` 在规则快照中补充 `modelCode`,以便模型人员分页在按模型筛选时仍能只返回当前筛选模型范围内的异常标签。
## 提交记录
- `ec006f2` 新增结果总览员工结果表结构
- `0a58ac3` 实现结果总览员工结果聚合构建
- `f539c4b` 接入结果总览员工结果同步重算
- `ef10616` 切换结果总览查询到员工结果表

View File

@@ -0,0 +1,43 @@
# 结果总览员工结果表后端验证记录
## 验证范围
- 员工结果表 DDL、实体、Mapper 与 XML 基础映射
- 员工归并命中明细聚合与快照字段生成
- 标签写库后的同事务重算链路
- 结果总览 4 类查询切换到员工结果表后的 SQL 与服务封装
## 验证命令
```bash
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewEmployeeResultEntityTest,CcdiProjectOverviewEmployeeResultMapperXmlTest
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewEmployeeResultBuilderTest
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceStructureTest,CcdiProjectOverviewServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest
mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewMapperRiskModelCardsTest,CcdiProjectOverviewMapperRiskModelPeopleTest,CcdiProjectOverviewServiceImplTest
```
## 验证结果
- 2026-03-22 执行 `mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewEmployeeResultEntityTest,CcdiProjectOverviewEmployeeResultMapperXmlTest`
- 首次红灯确认缺少结果表实体、Mapper 与 SQL 脚本。
- 完成最小实现后复跑2 个测试全部通过。
- 2026-03-22 执行 `mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewEmployeeResultBuilderTest`
- 首次红灯,确认缺少员工归并命中明细 VO 与 builder。
- 完成聚合实现并修正测试样本后复跑1 个测试通过。
- 2026-03-22 执行 `mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewServiceStructureTest,CcdiProjectOverviewServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest`
- 首次红灯,确认 `ICcdiProjectOverviewService` 尚未暴露员工结果表重算方法,标签重算链路尚未接入新方法。
- 接入 `refreshOverviewEmployeeResults` 并切换标签成功链路后复跑13 个测试全部通过。
- 2026-03-22 执行 `mvn test -pl ccdi-project -Dtest=CcdiProjectOverviewMapperSqlTest,CcdiProjectOverviewMapperRiskModelCardsTest,CcdiProjectOverviewMapperRiskModelPeopleTest,CcdiProjectOverviewServiceImplTest`
- 首次红灯,确认结果总览 SQL 仍直接依赖运行时归并基表。
- 切换到员工结果表与快照展开 SQL并收敛测试断言后复跑16 个测试全部通过。
## SQL 执行情况
- 本次未在数据库执行 [`2026-03-20-create-project-overview-employee-result-table.sql`](/Users/wkc/Desktop/ccdi/ccdi/.worktrees/codex/results-overview-employee-result-table/sql/migration/2026-03-20-create-project-overview-employee-result-table.sql)。
- 因此未执行 `bin/mysql_utf8_exec.sh sql/migration/2026-03-20-create-project-overview-employee-result-table.sql`
## 结论
- 结果总览员工结果表后端链路已按设计落地,覆盖建表、聚合构建、同事务重算与页面查询切换。
- 模型卡片 `warningCount` 仍保持为原始标签命中次数,没有退化为员工人数或规则去重数。
- 本轮验证全部基于 Maven 定向测试完成,相关命令均已在 2026-03-22 实际执行且通过。

View File

@@ -0,0 +1,27 @@
create table if not exists `ccdi_project_overview_employee_result` (
`id` bigint not null auto_increment comment '主键ID',
`project_id` bigint not null comment '项目ID',
`staff_id_card` varchar(18) not null comment '员工身份证号',
`staff_code` varchar(64) default null comment '员工工号',
`staff_name` varchar(64) default null comment '员工姓名',
`dept_id` bigint default null comment '部门ID',
`dept_name` varchar(128) default null comment '部门名称',
`rule_count` int not null default 0 comment '命中规则数',
`model_count` int not null default 0 comment '命中模型数',
`hit_count` int not null default 0 comment '命中次数',
`risk_level_code` varchar(32) not null comment '风险等级编码',
`risk_point` varchar(1000) default null comment '风险点',
`model_codes_csv` varchar(1000) default null comment '命中模型编码CSV',
`model_names_json` json default null comment '命中模型名称快照',
`hit_rules_json` json default null comment '命中规则快照',
`model_hit_summary_json` json default null comment '模型命中汇总快照',
`create_by` varchar(64) default null comment '创建者',
`create_time` datetime default current_timestamp comment '创建时间',
`update_by` varchar(64) default null comment '更新者',
`update_time` datetime default current_timestamp on update current_timestamp comment '更新时间',
`remark` varchar(500) default null comment '备注',
primary key (`id`),
unique key `uk_ccdi_project_overview_employee_result` (`project_id`, `staff_id_card`),
key `idx_ccdi_project_overview_employee_result_risk_level` (`project_id`, `risk_level_code`),
key `idx_ccdi_project_overview_employee_result_dept` (`project_id`, `dept_id`)
) engine=innodb default charset=utf8mb4 comment='结果总览员工结果表';