完善异常账户预警口径和账户查询
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
"暂无异常账户信息"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1730,6 +1730,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
and tx.txDate < 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 <= DATE_SUB(min(tx.txDate), INTERVAL 6 MONTH)
|
||||
) t
|
||||
where t.windowTotalAmount >= 500000
|
||||
or t.windowMaxSingleAmount >= 100000
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 <= 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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 应同步真实规则说明")
|
||||
|
||||
Reference in New Issue
Block a user