完善异常账户预警口径和账户查询

This commit is contained in:
wjj
2026-07-20 11:13:54 +08:00
parent 4a75d68bf6
commit 657977dfb7
29 changed files with 286 additions and 14 deletions

View File

@@ -39,6 +39,10 @@ public class CcdiAccountInfoQueryDTO implements Serializable {
@Schema(description = "账户姓名")
private String accountName;
/** 账户号码 */
@Schema(description = "账户号码")
private String accountNo;
/** 账户类型 */
@Schema(description = "账户类型")
private String accountType;

View File

@@ -104,6 +104,9 @@
<if test="query.accountName != null and query.accountName != ''">
AND ai.account_name LIKE CONCAT('%', #{query.accountName}, '%')
</if>
<if test="query.accountNo != null and query.accountNo != ''">
AND ai.account_no LIKE CONCAT('%', #{query.accountNo}, '%')
</if>
<if test="query.accountType != null and query.accountType != ''">
AND ai.account_type = #{query.accountType}
</if>

View File

@@ -38,6 +38,18 @@ class CcdiAccountInfoMapperTest {
assertTrue(sql.contains("ai.owner_type <> 'credit_customer'"), sql);
}
@Test
void selectAccountInfoPage_shouldFilterByAccountNo() throws Exception {
MappedStatement mappedStatement = loadMappedStatement(
"com.ruoyi.info.collection.mapper.CcdiAccountInfoMapper.selectAccountInfoPage");
CcdiAccountInfoQueryDTO queryDTO = new CcdiAccountInfoQueryDTO();
queryDTO.setAccountNo("6222");
String sql = renderSql(mappedStatement, Map.of("query", queryDTO)).toLowerCase();
assertTrue(sql.contains("ai.account_no like concat('%', ?, '%')"), sql);
}
private MappedStatement loadMappedStatement(String statementId) throws Exception {
Configuration configuration = new Configuration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource()));

View File

@@ -1,6 +1,7 @@
package com.ruoyi.ccdi.project.domain.excel;
import com.ruoyi.common.annotation.Excel;
import java.math.BigDecimal;
import lombok.Data;
/**
@@ -26,4 +27,10 @@ public class CcdiProjectAbnormalAccountExcel {
@Excel(name = "状态")
private String status;
@Excel(name = "命中原因")
private String reasonDetail;
@Excel(name = "涉及金额")
private BigDecimal involvedAmount;
}

View File

@@ -1,5 +1,6 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.math.BigDecimal;
import lombok.Data;
/**
@@ -19,4 +20,8 @@ public class CcdiProjectAbnormalAccountItemVO {
private String abnormalTime;
private String status;
private String reasonDetail;
private BigDecimal involvedAmount;
}

View File

@@ -230,7 +230,7 @@ public class CcdiProjectOverviewReportPdfExporter {
writer.subsection("3. 异常账户信息表(共" + report.getAbnormalAccounts().size() + "条)");
writer.table(
List.of("账号", "开户人", "银行", "异常类型", "异常发生时间", "状态"),
List.of("账号", "开户人", "银行", "异常类型", "异常发生时间", "状态", "命中原因", "涉及金额"),
report.getAbnormalAccounts().stream()
.map(item -> List.of(
maskAccount(item.getAccountNo()),
@@ -238,10 +238,12 @@ public class CcdiProjectOverviewReportPdfExporter {
safeText(item.getBankName()),
safeText(item.getAbnormalType()),
safeText(item.getAbnormalTime()),
safeText(item.getStatus())
safeText(item.getStatus()),
safeText(item.getReasonDetail()),
formatMoney(item.getInvolvedAmount())
))
.collect(Collectors.toList()),
new float[] { 0.18F, 0.13F, 0.2F, 0.23F, 0.14F, 0.12F },
new float[] { 0.12F, 0.1F, 0.14F, 0.14F, 0.11F, 0.08F, 0.22F, 0.09F },
"暂无异常账户信息"
);
}

View File

@@ -905,6 +905,8 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
row.setAbnormalType(item.getAbnormalType());
row.setAbnormalTime(item.getAbnormalTime());
row.setStatus(item.getStatus());
row.setReasonDetail(item.getReasonDetail());
row.setInvolvedAmount(item.getInvolvedAmount());
return row;
}

View File

@@ -106,7 +106,7 @@ public class CcdiProjectRiskDetailWorkbookExporter {
private void writeAbnormalAccountSheet(Sheet sheet, List<CcdiProjectAbnormalAccountExcel> rows) {
Row header = sheet.createRow(0);
String[] headers = { "账号", "开户人", "银行", "异常类型", "异常发生时间", "状态" };
String[] headers = { "账号", "开户人", "银行", "异常类型", "异常发生时间", "状态", "命中原因", "涉及金额" };
writeHeader(header, headers);
for (int i = 0; i < rows.size(); i++) {
@@ -118,6 +118,8 @@ public class CcdiProjectRiskDetailWorkbookExporter {
row.createCell(3).setCellValue(safeText(item.getAbnormalType()));
row.createCell(4).setCellValue(safeText(item.getAbnormalTime()));
row.createCell(5).setCellValue(safeText(item.getStatus()));
row.createCell(6).setCellValue(safeText(item.getReasonDetail()));
row.createCell(7).setCellValue(safeNumber(item.getInvolvedAmount()));
}
}

View File

@@ -1730,6 +1730,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and tx.txDate &lt; ai.invalid_date
group by staff.id_card, ai.account_no, ai.invalid_date
) t
where t.windowTotalAmount >= 500000
or t.windowMaxSingleAmount >= 100000
</select>
<select id="selectDormantAccountLargeActivationObjects" resultMap="BankTagObjectHitResultMap">
@@ -1750,6 +1752,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
staff.id_card AS objectKey,
ai.account_no AS accountNo,
ai.effective_date AS effectiveDate,
project_window.projectStartDate AS projectStartDate,
min(tx.txDate) AS firstTxDate,
timestampdiff(MONTH, ai.effective_date, min(tx.txDate)) AS dormantMonths,
round(sum(tx.tradeTotalAmount), 2) AS windowTotalAmount,
@@ -1757,6 +1760,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from ccdi_account_info ai
inner join ccdi_base_staff staff
on staff.id_card = ai.owner_id
inner join (
select
min(COALESCE(
STR_TO_DATE(LEFT(TRIM(project_bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(project_bs.TRX_DATE), 10), '%Y-%m-%d')
)) AS projectStartDate
from ccdi_bank_statement project_bs
where project_bs.project_id = #{projectId}
) project_window
inner join (
select
trim(bs.LE_ACCOUNT_NO) AS accountNo,
@@ -1774,8 +1786,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where ai.owner_type = 'EMPLOYEE'
and ai.status = 1
and ai.effective_date is not null
group by staff.id_card, ai.account_no, ai.effective_date
and project_window.projectStartDate is not null
group by staff.id_card, ai.account_no, ai.effective_date, project_window.projectStartDate
having min(tx.txDate) >= DATE_ADD(ai.effective_date, INTERVAL 6 MONTH)
and project_window.projectStartDate &lt;= DATE_SUB(min(tx.txDate), INTERVAL 6 MONTH)
) t
where t.windowTotalAmount >= 500000
or t.windowMaxSingleAmount >= 100000

View File

@@ -92,6 +92,8 @@
<result property="abnormalType" column="abnormalType"/>
<result property="abnormalTime" column="abnormal_time"/>
<result property="status" column="status"/>
<result property="reasonDetail" column="reasonDetail"/>
<result property="involvedAmount" column="involved_amount"/>
</resultMap>
<resultMap id="ReportUploadSubjectResultMap"
@@ -1473,7 +1475,12 @@
when account.status = 1 then '正常'
when account.status = 2 then '已销户'
else cast(account.status as char)
end as status
end as status,
tr.reason_detail as reasonDetail,
greatest(
ifnull(cast(substring_index(substring_index(tr.reason_detail, '累计交易金额', -1), '元', 1) as decimal(18, 2)), 0),
ifnull(cast(substring_index(substring_index(tr.reason_detail, '单笔最大金额', -1), '元', 1) as decimal(18, 2)), 0)
) as involved_amount
from ccdi_bank_statement_tag_result tr
inner join ccdi_account_info account
on account.owner_type = 'EMPLOYEE'
@@ -1500,7 +1507,9 @@
abnormal.bankName,
abnormal.abnormalType,
abnormal.abnormal_time,
abnormal.status
abnormal.status,
abnormal.reasonDetail,
abnormal.involved_amount
from (
<include refid="abnormalAccountBaseSql"/>
where tr.project_id = #{query.projectId}
@@ -1523,7 +1532,9 @@
abnormal.bankName,
abnormal.abnormalType,
abnormal.abnormal_time,
abnormal.status
abnormal.status,
abnormal.reasonDetail,
abnormal.involved_amount
from (
<include refid="abnormalAccountBaseSql"/>
where tr.project_id = #{projectId}

View File

@@ -72,6 +72,26 @@ class CcdiBankTagAnalysisMapperXmlTest {
assertTrue(xml.contains("AS logId"));
}
@Test
void dormantAccountLargeActivation_shouldRequireCompleteObservationWindow() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectDormantAccountLargeActivationObjects");
assertTrue(selectSql.contains("project_window.projectStartDate"), selectSql);
assertTrue(selectSql.contains("project_window.projectStartDate is not null"), selectSql);
assertTrue(selectSql.contains("project_window.projectStartDate &lt;= DATE_SUB(min(tx.txDate), INTERVAL 6 MONTH)"), selectSql);
}
@Test
void suddenAccountClosure_shouldRequireLargeTransactionInClosureWindow() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectSuddenAccountClosureObjects");
assertTrue(selectSql.contains("tx.txDate >= DATE_SUB(ai.invalid_date, INTERVAL 30 DAY)"), selectSql);
assertTrue(selectSql.contains("where t.windowTotalAmount >= 500000"), selectSql);
assertTrue(selectSql.contains("or t.windowMaxSingleAmount >= 100000"), selectSql);
}
@Test
void houseOrCarExpenseRule_shouldJoinBankStatementAndReturnStatementHitFields() throws Exception {
String xml = readXml(RESOURCE);

View File

@@ -211,6 +211,8 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(abnormalPageSql.contains("tr.bank_statement_id is null"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("account.owner_type = 'EMPLOYEE'"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("tr.reason_detail"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("abnormal.reasonDetail"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("abnormal.involved_amount"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("instr(tr.reason_detail, account.account_no) > 0"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("when account.status = 1 then '正常'"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("when account.status = 2 then '已销户'"), abnormalPageSql);
@@ -225,10 +227,15 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(abnormalExportSql.contains("tr.bank_statement_id is null"), abnormalExportSql);
assertTrue(abnormalExportSql.contains("account.owner_type = 'EMPLOYEE'"), abnormalExportSql);
assertTrue(abnormalExportSql.contains("tr.reason_detail"), abnormalExportSql);
assertTrue(abnormalExportSql.contains("abnormal.reasonDetail"), abnormalExportSql);
assertTrue(abnormalExportSql.contains("abnormal.involved_amount"), abnormalExportSql);
assertTrue(
abnormalExportSql.contains("order by abnormal_time desc, account.account_no asc, tr.rule_code asc"),
abnormalExportSql
);
assertTrue(xml.contains("tr.reason_detail as reasonDetail"), xml);
assertTrue(xml.contains("as involved_amount"), xml);
}
private String extractSelect(String xml, String selectId) {

View File

@@ -163,6 +163,9 @@ class CcdiProjectOverviewReportPdfExporterTest {
private String resolveTestFontPath() {
List<String> candidates = List.of(
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/simsun.ttc",
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
@@ -325,6 +328,8 @@ class CcdiProjectOverviewReportPdfExporterTest {
row.setAbnormalType("突然销户");
row.setAbnormalTime("2026-03-20");
row.setStatus("已销户");
row.setReasonDetail("账户6222000000000003销户前存在异常交易");
row.setInvolvedAmount(new BigDecimal("120000.00"));
return row;
}
}

View File

@@ -11,6 +11,7 @@ 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;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -65,6 +66,8 @@ class CcdiProjectOverviewServiceAbnormalAccountTest {
item.setAbnormalType("突然销户");
item.setAbnormalTime("2026-03-20");
item.setStatus("已销户");
item.setReasonDetail("账户6222000000000001销户前存在异常交易");
item.setInvolvedAmount(new BigDecimal("120000.00"));
Page<CcdiProjectAbnormalAccountItemVO> resultPage = new Page<>(1, 5);
resultPage.setRecords(List.of(item));
@@ -78,6 +81,8 @@ class CcdiProjectOverviewServiceAbnormalAccountTest {
assertEquals(1L, result.getTotal());
assertEquals("6222000000000001", result.getRows().getFirst().getAccountNo());
assertEquals("突然销户", result.getRows().getFirst().getAbnormalType());
assertEquals("账户6222000000000001销户前存在异常交易", result.getRows().getFirst().getReasonDetail());
assertEquals(new BigDecimal("120000.00"), result.getRows().getFirst().getInvolvedAmount());
verify(overviewMapper).selectAbnormalAccountPage(
argThat(page -> page.getCurrent() == 1L && page.getSize() == 5L),
argThat(query -> query.getProjectId().equals(40L))
@@ -115,6 +120,8 @@ class CcdiProjectOverviewServiceAbnormalAccountTest {
item.setAbnormalType("休眠账户大额启用");
item.setAbnormalTime("2025-08-01");
item.setStatus("正常");
item.setReasonDetail("账户6222000000000002休眠后大额启用");
item.setInvolvedAmount(new BigDecimal("500000.00"));
when(overviewMapper.selectAbnormalAccountList(40L)).thenReturn(List.of(item));
List<CcdiProjectAbnormalAccountExcel> rows = service.exportAbnormalAccountPeople(40L);
@@ -126,6 +133,8 @@ class CcdiProjectOverviewServiceAbnormalAccountTest {
assertEquals("休眠账户大额启用", rows.getFirst().getAbnormalType());
assertEquals("2025-08-01", rows.getFirst().getAbnormalTime());
assertEquals("正常", rows.getFirst().getStatus());
assertEquals("账户6222000000000002休眠后大额启用", rows.getFirst().getReasonDetail());
assertEquals(new BigDecimal("500000.00"), rows.getFirst().getInvolvedAmount());
verify(overviewMapper).selectAbnormalAccountList(40L);
}

View File

@@ -295,6 +295,8 @@ class CcdiProjectOverviewServiceImplTest {
abnormalItem.setAbnormalType("突然销户");
abnormalItem.setAbnormalTime("2026-03-20");
abnormalItem.setStatus("已销户");
abnormalItem.setReasonDetail("账户6222000000000001销户前存在异常交易");
abnormalItem.setInvolvedAmount(new BigDecimal("120000.00"));
when(overviewMapper.selectAbnormalAccountList(40L)).thenReturn(List.of(abnormalItem));
MockHttpServletResponse response = new MockHttpServletResponse();
@@ -316,7 +318,10 @@ class CcdiProjectOverviewServiceImplTest {
rows.size() == 1 && "李四".equals(rows.getFirst().getPersonName())
),
argThat((List<CcdiProjectAbnormalAccountExcel> rows) ->
rows.size() == 1 && "6222000000000001".equals(rows.getFirst().getAccountNo())
rows.size() == 1
&& "6222000000000001".equals(rows.getFirst().getAccountNo())
&& "账户6222000000000001销户前存在异常交易".equals(rows.getFirst().getReasonDetail())
&& new BigDecimal("120000.00").equals(rows.getFirst().getInvolvedAmount())
)
);
}

View File

@@ -47,6 +47,8 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
abnormalRow.setAbnormalType("突然销户");
abnormalRow.setAbnormalTime("2026-03-20");
abnormalRow.setStatus("已销户");
abnormalRow.setReasonDetail("账户6222000000000001销户前存在异常交易");
abnormalRow.setInvolvedAmount(new BigDecimal("120000.00"));
exporter.export(response, 40L, List.of(suspiciousRow), List.of(creditRow), List.of(abnormalRow));
@@ -78,12 +80,16 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
assertEquals("异常类型", workbook.getSheetAt(2).getRow(0).getCell(3).getStringCellValue());
assertEquals("异常发生时间", workbook.getSheetAt(2).getRow(0).getCell(4).getStringCellValue());
assertEquals("状态", workbook.getSheetAt(2).getRow(0).getCell(5).getStringCellValue());
assertEquals("命中原因", workbook.getSheetAt(2).getRow(0).getCell(6).getStringCellValue());
assertEquals("涉及金额", workbook.getSheetAt(2).getRow(0).getCell(7).getStringCellValue());
assertEquals("6222000000000001", workbook.getSheetAt(2).getRow(1).getCell(0).getStringCellValue());
assertEquals("李四", workbook.getSheetAt(2).getRow(1).getCell(1).getStringCellValue());
assertEquals("中国农业银行", workbook.getSheetAt(2).getRow(1).getCell(2).getStringCellValue());
assertEquals("突然销户", workbook.getSheetAt(2).getRow(1).getCell(3).getStringCellValue());
assertEquals("2026-03-20", workbook.getSheetAt(2).getRow(1).getCell(4).getStringCellValue());
assertEquals("已销户", workbook.getSheetAt(2).getRow(1).getCell(5).getStringCellValue());
assertEquals("账户6222000000000001销户前存在异常交易", workbook.getSheetAt(2).getRow(1).getCell(6).getStringCellValue());
assertEquals(120000D, workbook.getSheetAt(2).getRow(1).getCell(7).getNumericCellValue());
assertEquals(2, workbook.getSheetAt(2).getPhysicalNumberOfRows());
}
}

View File

@@ -36,11 +36,11 @@ class CcdiBankTagRuleSqlMetadataTest {
"2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql");
assertAll(
() -> assertTrue(migrationSql.contains("员工本人账户已销户且销户日前30天内存在交易记录"),
() -> assertTrue(migrationSql.contains("员工本人账户已销户且销户日前30天内存在大额资金流动"),
"SUDDEN_ACCOUNT_CLOSURE 应使用设计文档中的业务口径"),
() -> assertTrue(migrationSql.contains("员工本人账户开户后长期未使用,首次启用后出现大额资金流动。"),
"DORMANT_ACCOUNT_LARGE_ACTIVATION 应使用设计文档中的业务口径"),
() -> assertTrue(migrationSql.contains("真实规则识别员工本人账户销户前30天内仍有交易的员工对象"),
() -> assertTrue(migrationSql.contains("真实规则识别员工本人账户销户前30天内存在大额资金流动的员工对象"),
"SUDDEN_ACCOUNT_CLOSURE 应同步真实规则说明"),
() -> assertTrue(migrationSql.contains("真实规则:识别长期休眠后首次启用即出现大额资金流动的员工对象"),
"DORMANT_ACCOUNT_LARGE_ACTIVATION 应同步真实规则说明")

View File

@@ -0,0 +1,24 @@
# 结果总览异常账户口径调整后端实施计划
## 目标
修正“休眠账户大额启用”在项目流水不完整时的误报,并在结果总览异常账户数据中透出命中原因和涉及金额。
## 实施范围
- 调整 `DORMANT_ACCOUNT_LARGE_ACTIVATION` SQL项目流水最早日期必须覆盖启用日前 6 个月观察窗口。
- 调整 `SUDDEN_ACCOUNT_CLOSURE` SQL销户前 30 天窗口内需存在大额资金流动,避免普通销户前交易被判定为异常账户线索。
- 异常账户分页和导出查询增加 `reasonDetail``involvedAmount`
- 风险明细工作簿和 PDF 报告的异常账户表增加“命中原因”“涉及金额”。
- 新增规则元数据增量 SQL同步“突然销户”业务口径。
- 更新后端单元测试断言。
## 不包含
- 不开发非实控账户规则。
- 不新增“关联员工”列。
- 不新增“销户后交易”校验类场景。
## 验证
- 执行异常账户规则、结果总览 Mapper、导出器相关测试。

View File

@@ -0,0 +1,22 @@
# 账户库账户号码筛选后端实施计划
## 目标
在账户库管理列表和导出查询中支持按账户号码筛选,方便业务按账号直接定位账户主档。
## 实施范围
- `CcdiAccountInfoQueryDTO` 增加 `accountNo` 查询字段。
- `CcdiAccountInfoMapper.xml` 的账户库查询条件增加 `ai.account_no LIKE` 过滤。
- 页面列表和导出共用同一查询条件,保持筛选口径一致。
- 补充 Mapper SQL 渲染测试,确认账号条件被拼入列表 SQL。
## 不包含
- 不调整账户主档字段结构。
- 不修改账户导入模板。
- 不混入项目风险结果或异常账户预警规则。
## 验证
- 执行账户库 Mapper 测试,确认 SQL 渲染包含账户号码过滤条件。

View File

@@ -0,0 +1,21 @@
# 结果总览异常账户展示调整前端实施计划
## 目标
在结果总览“异常账户人员信息”表格中展示命中原因和涉及金额,降低业务查看预警时的理解成本。
## 实施范围
- 异常账户表格增加“命中原因”列。
- 异常账户表格增加“涉及金额”列,并复用现有金额格式化方法。
- 更新前端结构测试。
## 不包含
- 不新增异常账户筛选栏。
- 不新增详情弹窗。
- 不展示“关联员工”列。
## 验证
- 执行前端结构测试和生产构建。

View File

@@ -0,0 +1,21 @@
# 账户库账户号码筛选前端实施计划
## 目标
在账户库管理查询栏新增“账户号码”筛选项,支持输入部分账号后查询。
## 实施范围
- 在账户库查询表单中新增“账户号码”输入框。
- 查询参数 `queryParams` 增加 `accountNo`
- 重置查询时同步清空 `accountNo`
## 不包含
- 不调整列表列结构。
- 不新增高级搜索或独立详情入口。
- 不改变新增、编辑、导入的账户号码校验逻辑。
## 验证
- 通过前端源码检查确认查询项、请求参数和重置逻辑一致。

View File

@@ -0,0 +1,28 @@
# 结果总览异常账户口径与展示实施记录
## 修改内容
- 修正 `休眠账户大额启用`:项目流水必须覆盖启用日前完整 6 个月观察窗口,避免仅导入某日起流水时误判为首次启用。
- 收紧 `突然销户`:销户前 30 天窗口内需满足累计交易金额 50 万以上或单笔最大金额 10 万以上,避免普通销户前交易被误判为异常账户线索。
- 异常账户结果查询新增 `命中原因``涉及金额`
- 结果总览异常账户表格新增“命中原因”“涉及金额”两列。
- 风险明细工作簿和 PDF 报告的异常账户表同步新增两列。
- 新增 `sql/migration/2026-07-17-update-abnormal-account-rule-caliber.sql` 同步规则元数据。
## 影响范围
- 影响异常账户模型规则 `DORMANT_ACCOUNT_LARGE_ACTIVATION` 的命中口径。
- 影响异常账户模型规则 `SUDDEN_ACCOUNT_CLOSURE` 的命中口径。
- 影响结果总览异常账户分页接口、统一风险明细导出和 PDF 报告。
- 不涉及非实控账户规则,不增加关联员工展示。
## 验证情况
- 已执行:`mvn -pl ccdi-project -am test "-Dtest=CcdiProjectOverviewServiceAbnormalAccountTest,CcdiProjectOverviewMapperSqlTest,CcdiBankTagAnalysisMapperXmlTest,CcdiProjectRiskDetailWorkbookExporterTest,CcdiProjectOverviewReportPdfExporterTest,CcdiProjectOverviewServiceImplTest,CcdiBankTagRuleSqlMetadataTest" "-Dsurefire.failIfNoSpecifiedTests=false"`
- 结果:通过,相关后端测试共 58 个用例成功。
- 已执行:`node tests\unit\risk-detail-abnormal-account-layout.test.js`
- 结果:通过。
- 已执行:`npm run build:prod`
- 结果:构建通过,仅存在既有资源体积警告;当前工作区未跟踪的 `0uS5znL.docx` 被构建统计为大资源,未纳入本次处理范围。
- 已执行:使用 Chrome DevTools 打开本机真实前端 `http://127.0.0.1:8088/`
- 结果:页面停留登录页,`/dev-api/captchaImage` 返回 500同时 `http://127.0.0.1:62318/swagger-ui.html` 无法连接,确认真实后端不可用,因此未进入结果总览业务页做端到端验收。本次未使用 mock 后端替代真实后端。

View File

@@ -0,0 +1,22 @@
# 账户库账户号码筛选实施记录
## 修改内容
- 后端账户库查询 DTO 新增 `accountNo` 字段。
- 账户库列表和导出 SQL 增加 `ai.account_no LIKE` 查询条件。
- 前端账户库管理查询栏新增“账户号码”输入框,并在重置查询时清空。
- 新增 Mapper SQL 渲染测试,覆盖账户号码筛选条件。
## 影响范围
- 影响账户库管理列表查询接口 `/ccdi/accountInfo/list`
- 影响账户库导出接口复用的查询条件。
- 不影响账户新增、编辑、删除、导入和异常账户预警规则。
## 验证情况
- 已执行:`mvn -pl ccdi-info-collection -am test "-Dtest=CcdiAccountInfoMapperTest" "-Dsurefire.failIfNoSpecifiedTests=false"`
- 结果:通过,`CcdiAccountInfoMapperTest` 共 2 个用例成功。
- 已执行:前端环境验证。`.nvmrc` 指向的 Node 14.21.3 在本机切换后 `node``npm` 不可用,按仓库约定改用 Node 22.22.3 + npm 10.9.8。
- 已执行:`npm run build:prod`,构建通过,仅存在既有资源体积警告;当前工作区未跟踪的 `0uS5znL.docx` 被构建统计为大资源,未纳入本次处理范围。
- 已执行:使用 Chrome DevTools 打开临时静态服务页面,登录页可渲染;因静态服务没有 `/prod-api` 后端代理,无法进入需鉴权的账户库业务页,临时静态服务已关闭。

View File

@@ -44,6 +44,11 @@
<el-input v-model="queryParams.accountName" placeholder="请输入账户姓名" clearable style="width: 100%" @keyup.enter.native="handleQuery" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="账户号码">
<el-input v-model="queryParams.accountNo" placeholder="请输入账户号码" clearable style="width: 100%" @keyup.enter.native="handleQuery" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="账户类型">
<el-select v-model="queryParams.accountType" placeholder="请选择账户类型" clearable style="width: 100%">
@@ -532,6 +537,7 @@ export default {
bankScope: '',
relationType: '',
accountName: '',
accountNo: '',
accountType: '',
isActualControl: undefined,
riskLevel: '',
@@ -662,6 +668,7 @@ export default {
bankScope: '',
relationType: '',
accountName: '',
accountNo: '',
accountType: '',
isActualControl: undefined,
riskLevel: '',

View File

@@ -224,6 +224,10 @@
<el-table-column prop="abnormalType" label="异常类型" min-width="160" />
<el-table-column prop="abnormalTime" label="异常发生时间" min-width="160" />
<el-table-column prop="status" label="状态" min-width="120" />
<el-table-column prop="reasonDetail" label="命中原因" min-width="260" show-overflow-tooltip />
<el-table-column prop="involvedAmount" label="涉及金额" min-width="130" align="right">
<template slot-scope="scope">{{ formatAmount(scope.row.involvedAmount) }}</template>
</el-table-column>
</el-table>
<pagination

View File

@@ -18,5 +18,7 @@ const source = fs.readFileSync(
"异常类型",
"异常发生时间",
"状态",
"命中原因",
"涉及金额",
"当前项目暂无异常账户人员信息",
].forEach((token) => assert(source.includes(token), token));

View File

@@ -72,7 +72,7 @@ INSERT INTO ccdi_bank_statement (
meta_json, no_balance, begin_balance, end_balance, group_id, override_bs_id, payment_method, cret_no
) VALUES
(90331, 0, 90331011, '测试员工A', '6222000000000001', 20260305, '2026-03-05', '2026-03-05', 'CNY',
0.00, 70000.00, 180000.00, '转账', -1, '交易对手A1', NULL, '兰溪农商银行', NULL,
0.00, 120000.00, 230000.00, '转账', -1, '交易对手A1', NULL, '兰溪农商银行', NULL,
'异常账户测试A-1', NULL, 'A0001', 'LANXI', '0', 0, '', 0, 1, 1, NOW(), 1, NULL, 0, 0, 0, 90331, 0, NULL, '330101199001010001'),
(90331, 0, 90331011, '测试员工A', '6222000000000001', 20260310, '2026-03-10', '2026-03-10', 'CNY',
50000.00, 0.00, 130000.00, '转账', -1, '交易对手A2', NULL, '兰溪农商银行', NULL,

View File

@@ -46,8 +46,8 @@ INSERT INTO ccdi_bank_tag_rule (
remark
) VALUES
('ABNORMAL_ACCOUNT', '异常账户', 'SUDDEN_ACCOUNT_CLOSURE', '突然销户', NULL, 'OBJECT', 'HIGH',
'员工本人账户已销户且销户日前30天内存在交易记录', 1, 10, 'system',
'真实规则识别员工本人账户销户前30天内仍有交易的员工对象'),
'员工本人账户已销户且销户日前30天内存在大额资金流动', 1, 10, 'system',
'真实规则识别员工本人账户销户前30天内存在大额资金流动的员工对象'),
('ABNORMAL_ACCOUNT', '异常账户', 'DORMANT_ACCOUNT_LARGE_ACTIVATION', '休眠账户大额启用', NULL, 'OBJECT', 'HIGH',
'员工本人账户开户后长期未使用,首次启用后出现大额资金流动。', 1, 20, 'system',
'真实规则:识别长期休眠后首次启用即出现大额资金流动的员工对象')

View File

@@ -0,0 +1,7 @@
UPDATE ccdi_bank_tag_rule
SET business_caliber = '员工本人账户已销户且销户日前30天内存在大额资金流动。',
remark = '真实规则识别员工本人账户销户前30天内存在大额资金流动的员工对象',
update_by = 'system',
update_time = NOW()
WHERE model_code = 'ABNORMAL_ACCOUNT'
AND rule_code = 'SUDDEN_ACCOUNT_CLOSURE';