修复风险总览无风险人员负数问题

This commit is contained in:
wkc
2026-05-26 16:55:53 +08:00
parent 1b45296df3
commit a39594faf8
8 changed files with 167 additions and 10 deletions

View File

@@ -36,9 +36,10 @@ public interface CcdiProjectMapper extends BaseMapper<CcdiProject> {
List<CcdiProjectHistoryListItemVO> selectHistoryProjects(@Param("queryDTO") CcdiProjectQueryDTO queryDTO);
/**
* 更新项目风险人数
* 更新项目总人数与风险人数
*
* @param projectId 项目ID
* @param targetCount 总人数
* @param highRiskCount 高风险人数
* @param mediumRiskCount 中风险人数
* @param lowRiskCount 低风险人数
@@ -46,6 +47,7 @@ public interface CcdiProjectMapper extends BaseMapper<CcdiProject> {
* @return 更新行数
*/
int updateRiskCountsByProjectId(@Param("projectId") Long projectId,
@Param("targetCount") Integer targetCount,
@Param("highRiskCount") Integer highRiskCount,
@Param("mediumRiskCount") Integer mediumRiskCount,
@Param("lowRiskCount") Integer lowRiskCount,

View File

@@ -41,6 +41,7 @@ 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.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
@@ -74,6 +75,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
@Resource
private CcdiProjectMapper projectMapper;
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@Resource
private CcdiModelParamMapper modelParamMapper;
@@ -371,6 +375,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
projectMapper.updateRiskCountsByProjectId(
projectId,
countProjectScopeStaff(projectId),
countRiskLevel(results, "HIGH"),
countRiskLevel(results, "MEDIUM"),
countRiskLevel(results, "LOW"),
@@ -385,6 +390,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
Map<String, Object> summary = overviewMapper.selectRiskCountSummaryByProjectId(projectId);
projectMapper.updateRiskCountsByProjectId(
projectId,
countProjectScopeStaff(projectId),
readCount(summary, "highRiskCount"),
readCount(summary, "mediumRiskCount"),
readCount(summary, "lowRiskCount"),
@@ -392,6 +398,10 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
);
}
private int countProjectScopeStaff(Long projectId) {
return defaultZero(bankStatementMapper.countMatchedStaffCountByProjectId(projectId));
}
private CcdiProjectRiskPeopleOverviewItemVO buildRiskPeopleItem(Long projectId, CcdiProjectEmployeeRiskAggregateVO aggregate) {
CcdiProjectRiskPeopleOverviewItemVO item = new CcdiProjectRiskPeopleOverviewItemVO();
item.setName(aggregate.getStaffName());

View File

@@ -117,12 +117,37 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<select id="countMatchedStaffCountByProjectId" resultType="java.lang.Integer">
select count(distinct trim(bs.cret_no))
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and bs.cret_no is not null
and trim(bs.cret_no) != ''
select count(distinct scope_staff.id_card)
from (
select staff.id_card
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = trim(bs.cret_no)
where bs.project_id = #{projectId}
and bs.cret_no is not null
and trim(bs.cret_no) != ''
union
select family_staff.id_card
from ccdi_bank_statement bs
inner join ccdi_staff_fmy_relation relation
on relation.relation_cert_no = trim(bs.cret_no)
and relation.status = 1
inner join ccdi_base_staff family_staff
on family_staff.id_card = relation.person_id
where bs.project_id = #{projectId}
and bs.cret_no is not null
and trim(bs.cret_no) != ''
union
select account_staff.id_card
from ccdi_bank_statement bs
inner join ccdi_account_info account
on trim(account.account_no) = trim(bs.LE_ACCOUNT_NO)
and account.owner_type = 'EMPLOYEE'
inner join ccdi_base_staff account_staff
on account_staff.id_card = account.owner_id
where bs.project_id = #{projectId}
and bs.LE_ACCOUNT_NO is not null
and trim(bs.LE_ACCOUNT_NO) != ''
) scope_staff
</select>
<sql id="parsedTrxDateExpr">

View File

@@ -70,7 +70,8 @@
<update id="updateRiskCountsByProjectId">
update ccdi_project
set high_risk_count = #{highRiskCount},
set target_count = #{targetCount},
high_risk_count = #{highRiskCount},
medium_risk_count = #{mediumRiskCount},
low_risk_count = #{lowRiskCount},
update_by = #{updateBy},

View File

@@ -220,6 +220,22 @@ class CcdiBankStatementMapperXmlTest {
}
}
@Test
void targetCount_shouldUseResolvedProjectEmployeeScope() throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
String selectSql = extractSelect(xml, "countMatchedStaffCountByProjectId");
assertTrue(selectSql.contains("select count(distinct scope_staff.id_card)"), selectSql);
assertTrue(selectSql.contains("inner join ccdi_base_staff staff on staff.id_card = trim(bs.cret_no)"), selectSql);
assertTrue(selectSql.contains("inner join ccdi_staff_fmy_relation relation"), selectSql);
assertTrue(selectSql.contains("family_staff.id_card = relation.person_id"), selectSql);
assertTrue(selectSql.contains("inner join ccdi_account_info account"), selectSql);
assertTrue(selectSql.contains("account.owner_type = 'EMPLOYEE'"), selectSql);
assertTrue(selectSql.contains("account_staff.id_card = account.owner_id"), selectSql);
}
}
private MappedStatement loadMappedStatement(String statementId) throws Exception {
Configuration configuration = new Configuration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource()));
@@ -242,6 +258,15 @@ class CcdiBankStatementMapperXmlTest {
return boundSql.getSql().replaceAll("\\s+", " ").trim();
}
private String extractSelect(String xml, String selectId) {
String start = "<select id=\"" + selectId + "\"";
int startIndex = xml.indexOf(start);
assertTrue(startIndex >= 0, "missing select: " + selectId);
int endIndex = xml.indexOf("</select>", startIndex);
assertTrue(endIndex >= 0, "missing closing select tag: " + selectId);
return xml.substring(startIndex, endIndex);
}
private void registerTypeAliases(TypeAliasRegistry typeAliasRegistry) {
typeAliasRegistry.registerAlias("map", Map.class);
}

View File

@@ -29,6 +29,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
@@ -69,6 +70,9 @@ class CcdiProjectOverviewServiceImplTest {
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private CcdiBankStatementMapper bankStatementMapper;
@Mock
private CcdiModelParamMapper modelParamMapper;
@@ -427,10 +431,11 @@ class CcdiProjectOverviewServiceImplTest {
summary.put("mediumRiskCount", new BigDecimal("1"));
summary.put("lowRiskCount", new BigDecimal("3"));
when(overviewMapper.selectRiskCountSummaryByProjectId(43L)).thenReturn(summary);
when(bankStatementMapper.countMatchedStaffCountByProjectId(43L)).thenReturn(6);
service.refreshProjectRiskCounts(43L, "tester");
verify(projectMapper).updateRiskCountsByProjectId(eq(43L), eq(2), eq(1), eq(3), eq("tester"));
verify(projectMapper).updateRiskCountsByProjectId(eq(43L), eq(6), eq(2), eq(1), eq(3), eq("tester"));
}
@Test
@@ -490,6 +495,7 @@ class CcdiProjectOverviewServiceImplTest {
);
when(overviewEmployeeResultMapper.selectEmployeeHitRowsByProjectId(43L)).thenReturn(hitRows);
when(overviewEmployeeResultBuilder.build(43L, hitRows, "tester")).thenReturn(results);
when(bankStatementMapper.countMatchedStaffCountByProjectId(43L)).thenReturn(3);
service.refreshOverviewEmployeeResults(43L, "tester");
@@ -503,7 +509,7 @@ class CcdiProjectOverviewServiceImplTest {
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");
inOrder.verify(projectMapper).updateRiskCountsByProjectId(43L, 3, 1, 1, 1, "tester");
}
@Test

View File

@@ -0,0 +1,38 @@
# 风险总览无风险人员负数修复记录
## 问题现象
- 生产项目结果总览出现“无风险人员”为负数。
- 页面展示中“高风险 + 中风险 + 低风险”人数大于“总人数”。
## 根因
- `ccdi_project.target_count` 原口径只统计 `ccdi_bank_statement.cret_no` 直接匹配到员工主数据的去重人数。
- 风险人员结果表 `ccdi_project_overview_employee_result` 的生成口径会把关系人流水、对象规则命中、账户归属等结果归并回员工。
- 当项目存在“关系人流水归属员工”或“项目流水账号归属员工”时,风险人数的员工范围大于总人数范围,导致 `target_count - high - medium - low` 出现负数。
## 修改内容
- 调整 `CcdiBankStatementMapper.countMatchedStaffCountByProjectId`
- 直接员工:流水 `cret_no` 匹配员工身份证。
- 关系人归属员工:流水 `cret_no` 匹配员工关系人证件号后归属到员工。
- 账号归属员工:项目流水账号匹配员工账户信息后归属到员工。
- 调整 `CcdiProjectOverviewServiceImpl`
- 标签重算刷新结果总览员工结果后,同步刷新项目总人数和风险人数。
- 手动刷新项目风险人数时,同步刷新项目总人数。
- 调整 `CcdiProjectMapper.updateRiskCountsByProjectId`
- 更新风险人数时同时写回 `target_count`,保证总人数与风险人数使用同一项目员工范围口径。
- 新增生产回填脚本:
- `sql/migration/2026-05-22-fix-project-target-count-employee-scope.sql`
- 用新口径回填既有项目的 `target_count`
## 影响范围
- 后端结果总览统计。
- 项目列表中的目标人数。
- 生产既有项目需要执行新增 SQL 回填脚本,后续上传、历史导入、标签重算会自动按新口径刷新。
## 验证
- 已补充 `CcdiBankStatementMapperXmlTest.targetCount_shouldUseResolvedProjectEmployeeScope`,约束总人数 SQL 必须包含直接员工、关系人归属员工、账号归属员工三类范围。
- 已更新 `CcdiProjectOverviewServiceImplTest`,约束刷新风险人数时同步写回总人数。

View File

@@ -0,0 +1,50 @@
-- 修复项目总人数统计口径:直接员工、关系人流水归属员工、项目流水账号归属员工统一纳入项目员工范围。
UPDATE ccdi_project project
LEFT JOIN (
SELECT
scope_staff.project_id,
COUNT(DISTINCT scope_staff.id_card) AS target_count
FROM (
SELECT
bs.project_id,
staff.id_card
FROM ccdi_bank_statement bs
INNER JOIN ccdi_base_staff staff
ON staff.id_card = TRIM(bs.cret_no)
WHERE bs.cret_no IS NOT NULL
AND TRIM(bs.cret_no) != ''
UNION
SELECT
bs.project_id,
family_staff.id_card
FROM ccdi_bank_statement bs
INNER JOIN ccdi_staff_fmy_relation relation
ON relation.relation_cert_no = TRIM(bs.cret_no)
AND relation.status = 1
INNER JOIN ccdi_base_staff family_staff
ON family_staff.id_card = relation.person_id
WHERE bs.cret_no IS NOT NULL
AND TRIM(bs.cret_no) != ''
UNION
SELECT
bs.project_id,
account_staff.id_card
FROM ccdi_bank_statement bs
INNER JOIN ccdi_account_info account
ON TRIM(account.account_no) = TRIM(bs.LE_ACCOUNT_NO)
AND account.owner_type = 'EMPLOYEE'
INNER JOIN ccdi_base_staff account_staff
ON account_staff.id_card = account.owner_id
WHERE bs.LE_ACCOUNT_NO IS NOT NULL
AND TRIM(bs.LE_ACCOUNT_NO) != ''
) scope_staff
GROUP BY scope_staff.project_id
) stats ON stats.project_id = project.project_id
SET project.target_count = COALESCE(stats.target_count, 0),
project.update_by = 'system',
project.update_time = NOW()
WHERE project.del_flag = '0';