84 Commits

Author SHA1 Message Date
wjj
75d094dad4 支持拉取行内和金综流水 2026-07-20 13:08:33 +08:00
wjj
1561df924f 修复流水明细导出权限校验 2026-07-20 11:26:58 +08:00
wjj
657977dfb7 完善异常账户预警口径和账户查询 2026-07-20 11:13:54 +08:00
wjj
4a75d68bf6 修复流水上传身份证号回填 2026-07-20 11:03:05 +08:00
wjj
b70f967153 调整涉疑交易默认每页十条 2026-07-16 19:05:34 +08:00
wjj
6492fca2dd 设计涉疑交易默认分页调整 2026-07-16 18:52:47 +08:00
wjj
e0188b5e5b 修复涉疑交易筛选与标签错乱 2026-07-16 18:46:51 +08:00
wjj
c3b56bdf2a 设计涉疑交易筛选一致性优化 2026-07-16 18:01:14 +08:00
wjj
6d90bb4e58 优化无外部人员涉疑交易查询 2026-07-16 15:26:14 +08:00
wjj
4a7cfde4af 调整专项排查资产收入匹配口径 2026-07-16 14:47:04 +08:00
wjj
15aa9117c3 调整员工信息维护列表和个人资料校验 2026-07-16 11:28:55 +08:00
wjj
ded07a2c33 优化前端页面布局细节 2026-07-13 16:46:37 +08:00
wjj
7a317204f7 调整流水打标规则口径 2026-07-13 13:05:35 +08:00
wjj
2c6c982ed3 账户库中介风险等级固定为高 2026-07-10 17:46:51 +08:00
wjj
486a499af7 优化流水账号入库和明细筛选 2026-07-10 17:33:16 +08:00
wjj
c999497693 优化外部人员风险明细模型筛选 2026-07-09 16:19:08 +08:00
wjj
3f3e9268f2 实现结果总览排除可疑预警 2026-07-09 14:49:02 +08:00
wjj
9c02812675 合并dev最新代码到dev-ui 2026-07-06 09:05:10 +08:00
wkc
b7e9a8da03 校验上传流水文件名身份证信息 2026-07-03 17:38:46 +08:00
wjj
bb41fd7e89 新增新增贷款查询和导出 2026-07-03 10:01:17 +08:00
wjj
8b9226643c 新增涉疑交易明细筛选 2026-07-03 09:15:14 +08:00
wjj
6d2f40843a 调整项目列表入口和操作按钮 2026-07-02 17:48:08 +08:00
wkc
21c4e91f41 修复专项核查图谱项目ID传递 2026-07-02 17:04:22 +08:00
wkc
87fb6443e6 test 2026-07-02 16:48:17 +08:00
wkc
ce66dc3ba8 项目上传删除暂不调用流水分析平台 2026-06-26 15:19:23 +08:00
wkc
f3c1e2ea93 移除风险模型导出按钮 2026-06-26 15:18:46 +08:00
wkc
87b2352001 为风险明细列表添加分页选择器 2026-06-26 15:16:30 +08:00
wjj
999350265b 优化项目详情资金流向图谱展示 2026-06-26 10:05:46 +08:00
wjj
f8ee1ecf1c 调整流水模型理财剔除与提现规则 2026-06-26 10:05:46 +08:00
wjj
35467fd361 放开涉疑交易流水级模型命中 2026-06-26 10:05:46 +08:00
wjj
64cb847db3 优化涉疑交易模型口径和报告展示 2026-06-26 10:03:33 +08:00
wjj
bf290c509c 补充项目分析个人详情页正式化样式 2026-06-26 10:00:09 +08:00
wkc
c5b2033a3d 优化资金图谱主题节点检索 2026-06-03 17:11:09 +08:00
wkc
d45e9410ef 实现结果总览详情资产和征信页签 2026-06-02 17:17:49 +08:00
wkc
457e6c1d27 调整项目人数按流水证件号统计 2026-06-02 10:38:43 +08:00
wkc
850f97ea22 调整专项核查图谱展示 2026-06-01 17:37:59 +08:00
wjj
de6e6bd628 放开涉疑交易流水级模型命中 2026-06-01 17:22:51 +08:00
wjj
3a867e5857 修正短时间多次存现本人口径 2026-06-01 17:22:51 +08:00
wjj
19a60c987e 新增图谱功能及验收清单 2026-06-01 17:22:51 +08:00
wkc
7ce721ef93 Refactor project pages and update related docs 2026-06-01 15:52:50 +08:00
wjj
000e8698a5 优化涉疑交易模型口径和报告展示 2026-06-01 15:52:50 +08:00
wjj
9d3e8beceb 补充项目分析个人详情页正式化样式 2026-06-01 15:50:26 +08:00
wkc
0ea504f6b3 修正双员工夫妻家庭专项核查口径 2026-05-26 17:18:26 +08:00
wkc
a39594faf8 修复风险总览无风险人员负数问题 2026-05-26 16:55:53 +08:00
wkc
1b45296df3 优化资产估值万元展示 2026-05-26 16:53:45 +08:00
wkc
1fadb38d99 Implement credit parse result polling and sentinel handling 2026-05-18 10:56:25 +08:00
wkc
9917d10e59 调整征信解析返回解析和日志 2026-05-13 16:28:57 +08:00
wkc
be443d1b31 Refactor credit parse to use remote HTML paths 2026-05-13 14:20:42 +08:00
wkc
b822cc202e Remove obsolete code and documentation 2026-05-12 17:53:02 +08:00
wkc
598f5dec1c 移除前端默认登录凭据 2026-05-11 16:32:20 +08:00
wkc
0bf73a923f 生产配置 2026-05-09 10:28:00 +08:00
wkc
ec67794f88 新增生产统一部署脚本 2026-05-08 13:32:07 +08:00
wkc
3ef45bc398 Fix PDF font loading for project overview reports 2026-05-08 10:51:42 +08:00
wkc
37e17ac903 新增专项排查图谱展示 2026-05-08 10:22:00 +08:00
wkc
d561d068d6 新增专项排查图谱前端实施计划 2026-05-07 18:53:00 +08:00
wkc
43bc0e4f65 新增专项排查图谱嵌入设计 2026-05-07 18:41:55 +08:00
wkc
3fe78d8d3a 展示员工亲属实体关联统信码 2026-05-07 09:20:06 +08:00
wkc
4c58966529 调整招聘信息毕业年月选择控件 2026-05-07 01:07:52 +08:00
wkc
3bc60fedeb 完善招聘信息主键关联与工作经历维护 2026-05-07 01:04:23 +08:00
wkc
4d1acc7484 招聘导入模板增加招聘类型下拉框 2026-05-07 00:13:00 +08:00
wkc
402a0c3e2f 修复导入模板格式和必填标记 2026-05-07 00:01:27 +08:00
wkc
5980ed0790 Update import templates and relation query fields 2026-05-06 23:37:32 +08:00
wkc
75cb8967da 回测四类导入自动补入实体库 2026-05-06 23:31:43 +08:00
wkc
90a5c42313 合并实体库自动补入与双Sheet导入修复 2026-05-06 20:53:29 +08:00
wkc
356bcdd6de 修复双Sheet资产单独导入任务ID 2026-05-06 20:50:09 +08:00
wkc
9a60371a8f uat配置更新 2026-05-06 20:33:40 +08:00
wkc
380f9b4e7a 移除.DS_Store跟踪 2026-05-06 20:33:01 +08:00
wkc
928f65dfca 修订: 中介实体补入无需机构名称 2026-05-06 18:30:03 +08:00
wkc
c64146ac40 调整信息维护页面并修复项目概览统计 2026-05-06 18:22:26 +08:00
wkc
0541ce0ac6 计划: 员工资产导入与实体库自动补入修复 2026-05-06 18:02:19 +08:00
wkc
26c639134e 修订: 明确资产Sheet单独导入规则 2026-05-06 17:29:32 +08:00
wkc
0f7b57e824 修订: 完善员工资产导入与实体库补入设计 2026-05-06 17:24:25 +08:00
wkc
104e8697fe 设计: 员工资产导入与实体库自动补入修复 2026-05-06 17:18:21 +08:00
wkc
bbc6a2050b 统一项目分析弹窗圆角样式 2026-05-06 17:03:55 +08:00
wkc
bf7a4c0538 Merge remote-tracking branch 'origin/dev-ui' into dev 2026-05-06 16:15:20 +08:00
wkc
b2e177dd24 修复流水上传原始文件名保持 2026-05-06 15:05:36 +08:00
wkc
2071d04c08 修复流水分析上传文件名传递 2026-05-06 14:49:47 +08:00
wkc
4988ab5944 设计: 保持上传流水原始文件名 2026-05-06 14:22:05 +08:00
wkc
c00d5475e6 Add import dropdown validation 2026-05-06 14:04:21 +08:00
wkc
0b64532959 新增导入下拉框校验实施计划 2026-04-30 16:58:34 +08:00
wkc
9f0ad4ce87 完善导入下拉框校验设计 2026-04-30 16:39:58 +08:00
wkc
75b5989774 新增导入下拉框校验设计文档 2026-04-30 16:36:52 +08:00
wkc
d8c069a836 uat配置文件 2026-04-30 09:36:34 +08:00
wkc
26be75adad 实现关联业务自动补入实体库 2026-04-26 17:23:47 +08:00
187 changed files with 8237 additions and 656 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

@@ -28,6 +28,14 @@ public interface CcdiBaseStaffMapper extends BaseMapper<CcdiBaseStaff> {
Page<CcdiBaseStaffVO> selectBaseStaffPageWithDept(@Param("page") Page<CcdiBaseStaffVO> page,
@Param("query") CcdiBaseStaffQueryDTO queryDTO);
/**
* 查询员工详情(包含部门名称)
*
* @param staffId 员工ID
* @return 员工详情
*/
CcdiBaseStaffVO selectBaseStaffByIdWithDept(@Param("staffId") Long staffId);
int insertOrUpdateBatch(@Param("list") List<CcdiBaseStaff> list);
/**

View File

@@ -49,6 +49,10 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
private static final Set<String> ACCOUNT_TYPES = Set.of("BANK", "SECURITIES", "PAYMENT", "OTHER");
private static final Set<String> BANK_SCOPES = Set.of("INTERNAL", "EXTERNAL");
private static final Set<String> LEVELS = Set.of("LOW", "MEDIUM", "HIGH");
private static final String OWNER_TYPE_INTERMEDIARY = "INTERMEDIARY";
private static final String BANK_SCOPE_EXTERNAL = "EXTERNAL";
private static final String RISK_LEVEL_LOW = "LOW";
private static final String RISK_LEVEL_HIGH = "HIGH";
private final List<AccountInfoImportFailureVO> latestImportFailures = new CopyOnWriteArrayList<>();
@Resource
@@ -195,12 +199,12 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
private void validateOwner(String ownerType, String ownerId) {
if (StringUtils.isEmpty(ownerId)) {
if ("EXTERNAL".equals(ownerType) || "INTERMEDIARY".equals(ownerType)) {
if ("EXTERNAL".equals(ownerType) || OWNER_TYPE_INTERMEDIARY.equals(ownerType)) {
throw new RuntimeException("证件号不能为空");
}
throw new RuntimeException("所属人不能为空");
}
if ("EXTERNAL".equals(ownerType) || "INTERMEDIARY".equals(ownerType)) {
if ("EXTERNAL".equals(ownerType) || OWNER_TYPE_INTERMEDIARY.equals(ownerType)) {
return;
}
if ("EMPLOYEE".equals(ownerType)) {
@@ -232,8 +236,9 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
}
private void prepareAnalysisFields(CcdiAccountInfo accountInfo) {
if (!"EXTERNAL".equals(accountInfo.getBankScope())) {
if (!BANK_SCOPE_EXTERNAL.equals(accountInfo.getBankScope())) {
clearAnalysisFields(accountInfo);
applyIntermediaryRiskLevel(accountInfo);
return;
}
if (accountInfo.getIsActualControl() == null) {
@@ -249,8 +254,9 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
accountInfo.setTxnFrequencyLevel("MEDIUM");
}
if (StringUtils.isEmpty(accountInfo.getTxnRiskLevel())) {
accountInfo.setTxnRiskLevel("LOW");
accountInfo.setTxnRiskLevel(RISK_LEVEL_LOW);
}
applyIntermediaryRiskLevel(accountInfo);
}
private void clearAnalysisFields(CcdiAccountInfo accountInfo) {
@@ -265,6 +271,12 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
accountInfo.setTxnRiskLevel(null);
}
private void applyIntermediaryRiskLevel(CcdiAccountInfo accountInfo) {
if (OWNER_TYPE_INTERMEDIARY.equals(accountInfo.getOwnerType())) {
accountInfo.setTxnRiskLevel(RISK_LEVEL_HIGH);
}
}
private void validateAmount(BigDecimal amount, String fieldLabel) {
if (amount == null) {
return;
@@ -285,6 +297,7 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
addDTO.setTxnFrequencyLevel(toUpper(addDTO.getTxnFrequencyLevel()));
addDTO.setTxnRiskLevel(toUpper(addDTO.getTxnRiskLevel()));
addDTO.setOwnerId(normalizeOwnerId(addDTO.getOwnerId()));
applyIntermediaryRiskLevel(addDTO);
}
private void normalizeEditDto(CcdiAccountInfoEditDTO editDTO) {
@@ -295,6 +308,19 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
editDTO.setTxnFrequencyLevel(toUpper(editDTO.getTxnFrequencyLevel()));
editDTO.setTxnRiskLevel(toUpper(editDTO.getTxnRiskLevel()));
editDTO.setOwnerId(normalizeOwnerId(editDTO.getOwnerId()));
applyIntermediaryRiskLevel(editDTO);
}
private void applyIntermediaryRiskLevel(CcdiAccountInfoAddDTO addDTO) {
if (OWNER_TYPE_INTERMEDIARY.equals(addDTO.getOwnerType())) {
addDTO.setTxnRiskLevel(RISK_LEVEL_HIGH);
}
}
private void applyIntermediaryRiskLevel(CcdiAccountInfoEditDTO editDTO) {
if (OWNER_TYPE_INTERMEDIARY.equals(editDTO.getOwnerType())) {
editDTO.setTxnRiskLevel(RISK_LEVEL_HIGH);
}
}
private String normalizeCurrency(String currency) {
@@ -362,7 +388,8 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
private CcdiAccountInfoAddDTO toAddDto(CcdiAccountInfoExcel excel) {
CcdiAccountInfoAddDTO dto = new CcdiAccountInfoAddDTO();
dto.setOwnerType(parseOwnerType(excel.getOwnerType()));
String ownerType = parseOwnerType(excel.getOwnerType());
dto.setOwnerType(ownerType);
dto.setOwnerId(normalizeOwnerId(excel.getOwnerId()));
dto.setAccountName(trimToNull(excel.getAccountName()));
dto.setAccountNo(trimToNull(excel.getAccountNo()));
@@ -382,7 +409,9 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
dto.setCreditSingleMaxAmount(parseDecimal(excel.getCreditSingleMaxAmount()));
dto.setDebitDailyMaxAmount(parseDecimal(excel.getDebitDailyMaxAmount()));
dto.setCreditDailyMaxAmount(parseDecimal(excel.getCreditDailyMaxAmount()));
dto.setTxnRiskLevel(parseLevel(excel.getTxnRiskLevel(), "风险等级"));
dto.setTxnRiskLevel(OWNER_TYPE_INTERMEDIARY.equals(ownerType)
? RISK_LEVEL_HIGH
: parseLevel(excel.getTxnRiskLevel(), "风险等级"));
return dto;
}
@@ -418,7 +447,7 @@ public class CcdiAccountInfoServiceImpl implements ICcdiAccountInfoService {
return "RELATION";
}
if ("中介".equals(value)) {
return "INTERMEDIARY";
return OWNER_TYPE_INTERMEDIARY;
}
if ("外部人员".equals(value)) {
return "EXTERNAL";

View File

@@ -119,10 +119,10 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
*/
@Override
public CcdiBaseStaffVO selectBaseStaffById(Long staffId) {
CcdiBaseStaff staff = baseStaffMapper.selectById(staffId);
CcdiBaseStaffVO vo = convertToVO(staff);
if (staff != null) {
vo.setAssetInfoList(assetInfoService.selectByFamilyIdAndPersonId(staff.getIdCard(), staff.getIdCard()).stream().map(asset -> {
CcdiBaseStaffVO vo = baseStaffMapper.selectBaseStaffByIdWithDept(staffId);
if (vo != null) {
vo.setStatusDesc(EmployeeStatus.getDescByCode(vo.getStatus()));
vo.setAssetInfoList(assetInfoService.selectByFamilyIdAndPersonId(vo.getIdCard(), vo.getIdCard()).stream().map(asset -> {
CcdiAssetInfoVO assetInfoVO = new CcdiAssetInfoVO();
BeanUtils.copyProperties(asset, assetInfoVO);
return assetInfoVO;

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

@@ -42,7 +42,16 @@
AND e.status = #{query.status}
</if>
</where>
ORDER BY e.create_time DESC, e.staff_id DESC
ORDER BY e.hire_date DESC, e.staff_id DESC
</select>
<select id="selectBaseStaffByIdWithDept" resultMap="CcdiBaseStaffVOResult">
SELECT
e.staff_id, e.name, e.dept_id, e.id_card, e.phone, e.annual_income, e.hire_date, e.is_party_member, e.status, e.create_time,
d.dept_name
FROM ccdi_base_staff e
LEFT JOIN sys_dept d ON e.dept_id = d.dept_id
WHERE e.staff_id = #{staffId}
</select>
<!-- 批量插入或更新员工信息只更新非null字段 -->

View File

@@ -0,0 +1,147 @@
package com.ruoyi.info.collection.controller;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
import com.ruoyi.info.collection.domain.vo.BaseStaffImportSubmitResultVO;
import com.ruoyi.info.collection.service.ICcdiBaseStaffAssetImportService;
import com.ruoyi.info.collection.service.ICcdiBaseStaffImportService;
import com.ruoyi.info.collection.service.ICcdiBaseStaffService;
import com.ruoyi.info.collection.utils.EasyExcelUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiBaseStaffControllerTest {
@InjectMocks
private CcdiBaseStaffController controller;
@Mock
private ICcdiBaseStaffService baseStaffService;
@Mock
private ICcdiBaseStaffImportService importAsyncService;
@Mock
private ICcdiBaseStaffAssetImportService baseStaffAssetImportService;
@Test
void importTemplate_shouldDownloadDualSheetTemplate() {
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
controller.importTemplate(null);
mocked.verify(() -> EasyExcelUtil.importTemplateWithDictDropdown(
null,
CcdiBaseStaffExcel.class,
"员工信息",
CcdiBaseStaffAssetInfoExcel.class,
"员工资产信息",
"员工信息维护导入模板"
));
}
}
@Test
void importData_shouldWarnWhenBothSheetsAreEmpty() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"base-staff-empty.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"empty".getBytes(StandardCharsets.UTF_8)
);
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiBaseStaffExcel.class), eq("员工信息")))
.thenReturn(List.of());
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiBaseStaffAssetInfoExcel.class), eq("员工资产信息")))
.thenReturn(List.of());
AjaxResult result = controller.importData(file);
assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
assertEquals("至少需要一条数据", result.get(AjaxResult.MSG_TAG));
verifyNoInteractions(baseStaffService);
}
}
@Test
void importData_shouldSubmitOnlyStaffTaskWhenOnlyStaffSheetHasRows() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"base-staff.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"staff".getBytes(StandardCharsets.UTF_8)
);
CcdiBaseStaffExcel staffExcel = new CcdiBaseStaffExcel();
staffExcel.setStaffId(1001L);
BaseStaffImportSubmitResultVO submitResult = new BaseStaffImportSubmitResultVO();
submitResult.setStaffTaskId("staff-task-1");
when(baseStaffService.importBaseStaffWithAssets(List.of(staffExcel), List.of())).thenReturn(submitResult);
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiBaseStaffExcel.class), eq("员工信息")))
.thenReturn(List.of(staffExcel));
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiBaseStaffAssetInfoExcel.class), eq("员工资产信息")))
.thenReturn(List.of());
AjaxResult result = controller.importData(file);
assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
assertEquals("导入任务已提交,正在后台处理", result.get(AjaxResult.MSG_TAG));
Object data = result.get(AjaxResult.DATA_TAG);
assertEquals("staff-task-1", data.getClass().getMethod("getStaffTaskId").invoke(data));
assertNull(data.getClass().getMethod("getAssetTaskId").invoke(data));
}
}
@Test
void importData_shouldSubmitTwoTasksWhenBothSheetsHaveRows() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"base-staff-both.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"both".getBytes(StandardCharsets.UTF_8)
);
CcdiBaseStaffExcel staffExcel = new CcdiBaseStaffExcel();
staffExcel.setStaffId(1002L);
CcdiBaseStaffAssetInfoExcel assetExcel = new CcdiBaseStaffAssetInfoExcel();
assetExcel.setPersonId("320101199001010011");
BaseStaffImportSubmitResultVO submitResult = new BaseStaffImportSubmitResultVO();
submitResult.setStaffTaskId("staff-task-2");
submitResult.setAssetTaskId("asset-task-2");
when(baseStaffService.importBaseStaffWithAssets(List.of(staffExcel), List.of(assetExcel))).thenReturn(submitResult);
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiBaseStaffExcel.class), eq("员工信息")))
.thenReturn(List.of(staffExcel));
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiBaseStaffAssetInfoExcel.class), eq("员工资产信息")))
.thenReturn(List.of(assetExcel));
AjaxResult result = controller.importData(file);
assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
Object data = result.get(AjaxResult.DATA_TAG);
assertEquals("staff-task-2", data.getClass().getMethod("getStaffTaskId").invoke(data));
assertEquals("asset-task-2", data.getClass().getMethod("getAssetTaskId").invoke(data));
}
}
}

View File

@@ -0,0 +1,37 @@
package com.ruoyi.info.collection.controller;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.info.collection.domain.vo.EnumOptionVO;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class CcdiEnumControllerTest {
private final CcdiEnumController controller = new CcdiEnumController();
@Test
void getEnterpriseRiskLevelOptions_shouldReturnConfiguredOptions() {
AjaxResult result = controller.getEnterpriseRiskLevelOptions();
List<?> data = (List<?>) result.get("data");
assertEquals(3, data.size());
EnumOptionVO first = (EnumOptionVO) data.get(0);
assertEquals("1", first.getValue());
assertEquals("高风险", first.getLabel());
}
@Test
void getEnterpriseSourceOptions_shouldReturnConfiguredOptions() {
AjaxResult result = controller.getEnterpriseSourceOptions();
List<?> data = (List<?>) result.get("data");
assertFalse(data.isEmpty());
EnumOptionVO first = (EnumOptionVO) data.get(0);
assertEquals("GENERAL", first.getValue());
assertEquals("一般企业", first.getLabel());
}
}

View File

@@ -0,0 +1,173 @@
package com.ruoyi.info.collection.controller;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel;
import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportSubmitResultVO;
import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportFailureVO;
import com.ruoyi.info.collection.service.ICcdiAssetInfoImportService;
import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationImportService;
import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationService;
import com.ruoyi.info.collection.utils.EasyExcelUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiStaffFmyRelationControllerTest {
@InjectMocks
private CcdiStaffFmyRelationController controller;
@Mock
private ICcdiStaffFmyRelationService relationService;
@Mock
private ICcdiStaffFmyRelationImportService relationImportService;
@Mock
private ICcdiAssetInfoImportService assetInfoImportService;
@Test
void importTemplate_shouldDownloadDualSheetTemplate() {
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
controller.importTemplate(null);
mocked.verify(() -> EasyExcelUtil.importTemplateWithDictDropdown(
null,
CcdiStaffFmyRelationExcel.class,
"员工亲属关系信息",
CcdiAssetInfoExcel.class,
"亲属资产信息",
"员工亲属关系维护导入模板"
));
}
}
@Test
void importData_shouldErrorWhenBothSheetsAreEmpty() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"staff-family-empty.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"empty".getBytes(StandardCharsets.UTF_8)
);
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiStaffFmyRelationExcel.class), eq("员工亲属关系信息")))
.thenReturn(List.of());
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiAssetInfoExcel.class), eq("亲属资产信息")))
.thenReturn(List.of());
AjaxResult result = controller.importData(file);
assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
assertEquals("至少需要一条数据", result.get(AjaxResult.MSG_TAG));
verifyNoInteractions(relationService, assetInfoImportService);
}
}
@Test
void importData_shouldSubmitOnlyRelationTaskWhenOnlyRelationSheetHasRows() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"staff-family-relation.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"relation".getBytes(StandardCharsets.UTF_8)
);
CcdiStaffFmyRelationExcel relationExcel = new CcdiStaffFmyRelationExcel();
relationExcel.setPersonId("320101199001010011");
StaffFmyRelationImportSubmitResultVO submitResult = new StaffFmyRelationImportSubmitResultVO();
submitResult.setRelationTaskId("relation-task-1");
when(relationService.importRelationWithAssets(List.of(relationExcel), List.of())).thenReturn(submitResult);
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiStaffFmyRelationExcel.class), eq("员工亲属关系信息")))
.thenReturn(List.of(relationExcel));
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiAssetInfoExcel.class), eq("亲属资产信息")))
.thenReturn(List.of());
AjaxResult result = controller.importData(file);
assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
assertEquals("导入任务已提交,正在后台处理", result.get(AjaxResult.MSG_TAG));
Object data = result.get(AjaxResult.DATA_TAG);
assertEquals("relation-task-1", data.getClass().getMethod("getRelationTaskId").invoke(data));
assertNull(data.getClass().getMethod("getAssetTaskId").invoke(data));
}
}
@Test
void importData_shouldSubmitTwoTasksWhenBothSheetsHaveRows() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"staff-family-both.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"both".getBytes(StandardCharsets.UTF_8)
);
CcdiStaffFmyRelationExcel relationExcel = new CcdiStaffFmyRelationExcel();
relationExcel.setPersonId("320101199001010012");
CcdiAssetInfoExcel assetExcel = new CcdiAssetInfoExcel();
assetExcel.setPersonId("320101199001010099");
StaffFmyRelationImportSubmitResultVO submitResult = new StaffFmyRelationImportSubmitResultVO();
submitResult.setRelationTaskId("relation-task-2");
submitResult.setAssetTaskId("asset-task-2");
when(relationService.importRelationWithAssets(List.of(relationExcel), List.of(assetExcel))).thenReturn(submitResult);
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiStaffFmyRelationExcel.class), eq("员工亲属关系信息")))
.thenReturn(List.of(relationExcel));
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiAssetInfoExcel.class), eq("亲属资产信息")))
.thenReturn(List.of(assetExcel));
AjaxResult result = controller.importData(file);
assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
Object data = result.get(AjaxResult.DATA_TAG);
assertEquals("relation-task-2", data.getClass().getMethod("getRelationTaskId").invoke(data));
assertEquals("asset-task-2", data.getClass().getMethod("getAssetTaskId").invoke(data));
}
}
@Test
void getImportFailures_shouldReturnPagedRowsWithSheetAndRowInfo() {
StaffFmyRelationImportFailureVO failure1 = new StaffFmyRelationImportFailureVO();
failure1.setSheetName("员工亲属关系信息");
failure1.setRowNum(2);
failure1.setPersonId("A1");
StaffFmyRelationImportFailureVO failure2 = new StaffFmyRelationImportFailureVO();
failure2.setSheetName("员工亲属关系信息");
failure2.setRowNum(3);
failure2.setPersonId("A2");
when(relationImportService.getImportFailures("task-1")).thenReturn(List.of(failure1, failure2));
TableDataInfo result = controller.getImportFailures("task-1", 2, 1);
assertEquals(2, result.getTotal());
assertEquals(1, result.getRows().size());
StaffFmyRelationImportFailureVO row = (StaffFmyRelationImportFailureVO) result.getRows().get(0);
assertEquals("员工亲属关系信息", row.getSheetName());
assertEquals(3, row.getRowNum());
assertEquals("A2", row.getPersonId());
}
}

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

@@ -0,0 +1,25 @@
package com.ruoyi.info.collection.mapper;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiEnterpriseBaseInfoMapperTest {
@Test
void mapperXml_shouldContainPageQueryAndImportColumns() throws Exception {
try (InputStream inputStream = getClass().getClassLoader()
.getResourceAsStream("mapper/info/collection/CcdiEnterpriseBaseInfoMapper.xml")) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(xml.contains("selectEnterpriseBaseInfoPage"), xml);
assertTrue(xml.contains("risk_level"), xml);
assertTrue(xml.contains("ent_source"), xml);
assertTrue(xml.contains("data_source"), xml);
assertTrue(xml.contains("ORDER BY create_time DESC"), xml);
}
}
}

View File

@@ -0,0 +1,27 @@
package com.ruoyi.info.collection.mapper;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiIntermediaryMapperTest {
@Test
void mapperXml_shouldContainThreeRecordTypesAndRelatedKeywordQuery() throws Exception {
try (InputStream inputStream = getClass().getClassLoader()
.getResourceAsStream("mapper/info/collection/CcdiIntermediaryMapper.xml")) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(xml.contains("INTERMEDIARY"), xml);
assertTrue(xml.contains("RELATIVE"), xml);
assertTrue(xml.contains("ENTERPRISE_RELATION"), xml);
assertTrue(xml.contains("relatedIntermediaryKeyword"), xml);
assertTrue(xml.contains("related_intermediary_name"), xml);
assertTrue(xml.contains("relation_text"), xml);
assertTrue(xml.contains("CAST('实体'"), xml);
}
}
}

View File

@@ -3,6 +3,7 @@ package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiAccountInfo;
import com.ruoyi.info.collection.domain.CcdiBaseStaff;
import com.ruoyi.info.collection.domain.dto.CcdiAccountInfoAddDTO;
import com.ruoyi.info.collection.domain.excel.CcdiAccountInfoExcel;
import com.ruoyi.info.collection.mapper.CcdiAccountInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper;
@@ -16,6 +17,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.BeanWrapperImpl;
import java.math.BigDecimal;
import java.util.List;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -109,6 +111,64 @@ class CcdiAccountInfoServiceImplTest {
assertNull(wrapper.getPropertyValue("txnRiskLevel"));
}
@Test
void insertInternalIntermediaryAccount_shouldForceHighRiskLevelOnAccountInfo() {
CcdiAccountInfoAddDTO dto = buildBaseAddDto();
dto.setOwnerType("INTERMEDIARY");
dto.setOwnerId("91330100MA00000001");
dto.setBankScope("INTERNAL");
dto.setIsActualControl(1);
dto.setAvgMonthTxnCount(8);
dto.setAvgMonthTxnAmount(new BigDecimal("9988.66"));
dto.setTxnFrequencyLevel("HIGH");
dto.setDebitSingleMaxAmount(new BigDecimal("111.11"));
dto.setCreditSingleMaxAmount(new BigDecimal("222.22"));
dto.setDebitDailyMaxAmount(new BigDecimal("333.33"));
dto.setCreditDailyMaxAmount(new BigDecimal("444.44"));
dto.setTxnRiskLevel("LOW");
when(accountInfoMapper.selectCount(any())).thenReturn(0L);
when(accountInfoMapper.insert(any(CcdiAccountInfo.class))).thenReturn(1);
service.insertAccountInfo(dto);
ArgumentCaptor<CcdiAccountInfo> captor = ArgumentCaptor.forClass(CcdiAccountInfo.class);
verify(accountInfoMapper).insert(captor.capture());
BeanWrapperImpl wrapper = new BeanWrapperImpl(captor.getValue());
assertNull(wrapper.getPropertyValue("isActualControl"));
assertNull(wrapper.getPropertyValue("avgMonthTxnCount"));
assertNull(wrapper.getPropertyValue("avgMonthTxnAmount"));
assertNull(wrapper.getPropertyValue("txnFrequencyLevel"));
assertEquals("HIGH", wrapper.getPropertyValue("txnRiskLevel"));
}
@Test
void importExternalIntermediaryAccount_shouldForceHighRiskLevelOnAccountInfo() {
CcdiAccountInfoExcel excel = new CcdiAccountInfoExcel();
excel.setOwnerType("中介");
excel.setOwnerId("91330100MA00000002");
excel.setAccountName("测试中介");
excel.setAccountNo("6222024000000099");
excel.setAccountType("银行账户");
excel.setBankScope("行外");
excel.setOpenBank("中国银行");
excel.setBankCode("BOC");
excel.setCurrency("CNY");
excel.setStatus("正常");
excel.setEffectiveDate("2026-07-10");
excel.setTxnRiskLevel("LOW");
when(accountInfoMapper.selectOne(any())).thenReturn(null);
when(accountInfoMapper.selectCount(any())).thenReturn(0L);
when(accountInfoMapper.insert(any(CcdiAccountInfo.class))).thenReturn(1);
service.importAccountInfo(List.of(excel), false);
ArgumentCaptor<CcdiAccountInfo> captor = ArgumentCaptor.forClass(CcdiAccountInfo.class);
verify(accountInfoMapper).insert(captor.capture());
assertEquals("HIGH", captor.getValue().getTxnRiskLevel());
}
private CcdiAccountInfoAddDTO buildBaseAddDto() {
CcdiAccountInfoAddDTO dto = new CcdiAccountInfoAddDTO();
dto.setAccountNo("6222024000000001");

View File

@@ -0,0 +1,163 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.common.core.domain.entity.SysDept;
import com.ruoyi.info.collection.domain.CcdiBaseStaff;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
import com.ruoyi.info.collection.domain.vo.ImportFailureVO;
import com.ruoyi.info.collection.service.impl.CcdiBaseStaffImportServiceImpl;
import com.ruoyi.system.mapper.SysDeptMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiBaseStaffDualImportServiceTest {
@InjectMocks
private CcdiBaseStaffImportServiceImpl service;
@Mock
private com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper baseStaffMapper;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private HashOperations<String, Object, Object> hashOperations;
@Mock
private ValueOperations<String, Object> valueOperations;
@Mock
private SysDeptMapper deptMapper;
@Test
void importBaseStaffAsync_shouldTreatExistingEmployeeAsFailureInsteadOfUpdate() {
CcdiBaseStaffExcel excel = new CcdiBaseStaffExcel();
excel.setStaffId(1001L);
excel.setName("张三");
excel.setDeptId(10L);
excel.setIdCard("11010519491231002X");
excel.setPhone("13812345678");
excel.setStatus("0");
excel.setPartyMember(1);
CcdiBaseStaff existing = new CcdiBaseStaff();
existing.setStaffId(1001L);
existing.setIdCard("11010519491231002X");
when(baseStaffMapper.selectBatchIds(List.of(1001L))).thenReturn(List.of(existing));
when(baseStaffMapper.selectList(any())).thenReturn(List.of(existing));
lenient().when(deptMapper.selectDeptById(10L)).thenReturn(buildDept(10L, "0", "0"));
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
service.importBaseStaffAsync(List.of(excel), "task-existing");
verify(baseStaffMapper, never()).insertBatch(any());
verify(baseStaffMapper, never()).insertOrUpdateBatch(any());
ArgumentCaptor<Object> failureCaptor = ArgumentCaptor.forClass(Object.class);
verify(valueOperations).set(eq("import:baseStaff:task-existing:failures"), failureCaptor.capture(), eq(7L), eq(TimeUnit.DAYS));
ImportFailureVO failure = (ImportFailureVO) ((List<?>) failureCaptor.getValue()).get(0);
assertEquals("员工信息", failure.getSheetName());
assertEquals(2, failure.getRowNum());
assertEquals(1001L, failure.getStaffId());
assertEquals("该员工ID已存在", failure.getErrorMessage());
}
@Test
void validateStaffData_shouldRejectExistingIdCardWhenStaffIdDoesNotExist() {
when(deptMapper.selectDeptById(10L)).thenReturn(buildDept(10L, "0", "0"));
RuntimeException exception = org.junit.jupiter.api.Assertions.assertThrows(
RuntimeException.class,
() -> service.validateStaffData(buildExcelDto(), Set.of(), Set.of("11010519491231002X"))
);
assertEquals("该身份证号已存在", exception.getMessage());
}
@Test
void importBaseStaffAsync_shouldSaveFailureWhenDeptIsInvalid() {
CcdiBaseStaffExcel validExcel = buildExcel(1001L, 10L, "11010519491231002X");
CcdiBaseStaffExcel invalidExcel = buildExcel(1002L, 99L, "320101199001010014");
when(baseStaffMapper.selectBatchIds(List.of(1001L, 1002L))).thenReturn(List.of());
when(baseStaffMapper.selectList(any())).thenReturn(List.of());
when(deptMapper.selectDeptById(10L)).thenReturn(buildDept(10L, "0", "0"));
when(deptMapper.selectDeptById(99L)).thenReturn(null);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
service.importBaseStaffAsync(List.of(validExcel, invalidExcel), "task-invalid-dept");
verify(baseStaffMapper).insertBatch(any());
ArgumentCaptor<Object> failureCaptor = ArgumentCaptor.forClass(Object.class);
verify(valueOperations).set(eq("import:baseStaff:task-invalid-dept:failures"), failureCaptor.capture(), eq(7L), eq(TimeUnit.DAYS));
ImportFailureVO failure = (ImportFailureVO) ((List<?>) failureCaptor.getValue()).get(0);
assertEquals("员工信息", failure.getSheetName());
assertEquals(3, failure.getRowNum());
assertEquals(1002L, failure.getStaffId());
assertEquals("所属部门ID[99]不存在或已停用/删除,请检查机构号", failure.getErrorMessage());
ArgumentCaptor<Map<String, Object>> statusCaptor = ArgumentCaptor.forClass(Map.class);
verify(hashOperations).putAll(eq("import:baseStaff:task-invalid-dept"), statusCaptor.capture());
assertEquals("PARTIAL_SUCCESS", statusCaptor.getValue().get("status"));
assertEquals(1, statusCaptor.getValue().get("successCount"));
assertEquals(1, statusCaptor.getValue().get("failureCount"));
}
private com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO buildExcelDto() {
com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO dto = new com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO();
dto.setName("李四");
dto.setStaffId(2001L);
dto.setDeptId(10L);
dto.setIdCard("11010519491231002X");
dto.setPhone("13812345678");
dto.setStatus("0");
dto.setPartyMember(1);
return dto;
}
private CcdiBaseStaffExcel buildExcel(Long staffId, Long deptId, String idCard) {
CcdiBaseStaffExcel excel = new CcdiBaseStaffExcel();
excel.setStaffId(staffId);
excel.setName("张三");
excel.setDeptId(deptId);
excel.setIdCard(idCard);
excel.setPhone("13812345678");
excel.setStatus("0");
excel.setPartyMember(1);
return excel;
}
private SysDept buildDept(Long deptId, String status, String delFlag) {
SysDept dept = new SysDept();
dept.setDeptId(deptId);
dept.setDeptName("测试部门");
dept.setStatus(status);
dept.setDelFlag(delFlag);
return dept;
}
}

View File

@@ -0,0 +1,113 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiBizIntermediary;
import com.ruoyi.info.collection.domain.dto.CcdiIntermediaryRelativeAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiIntermediaryPersonAddDTO;
import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryMapper;
import com.ruoyi.info.collection.service.impl.CcdiIntermediaryServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiIntermediaryServiceImplTest {
@InjectMocks
private CcdiIntermediaryServiceImpl service;
@Mock
private CcdiBizIntermediaryMapper bizIntermediaryMapper;
@Mock
private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper;
@Mock
private CcdiIntermediaryMapper intermediaryMapper;
@Mock
private CcdiIntermediaryEnterpriseRelationMapper enterpriseRelationMapper;
@Mock
private ICcdiIntermediaryPersonImportService personImportService;
@Mock
private ICcdiIntermediaryEntityImportService entityImportService;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Test
void insertIntermediaryPerson_shouldForceBenrenAndClearRelatedNumId() {
CcdiIntermediaryPersonAddDTO addDTO = new CcdiIntermediaryPersonAddDTO();
addDTO.setName("测试中介");
addDTO.setPersonId("320101199001010011");
addDTO.setPersonSubType("配偶");
addDTO.setRelatedNumId("parent-id");
when(bizIntermediaryMapper.selectCount(any())).thenReturn(0L);
when(bizIntermediaryMapper.insert(any(CcdiBizIntermediary.class))).thenReturn(1);
int result = service.insertIntermediaryPerson(addDTO);
assertEquals(1, result);
ArgumentCaptor<CcdiBizIntermediary> captor = ArgumentCaptor.forClass(CcdiBizIntermediary.class);
verify(bizIntermediaryMapper).insert(captor.capture());
assertEquals("本人", captor.getValue().getPersonSubType());
assertNull(captor.getValue().getRelatedNumId());
assertEquals("MANUAL", captor.getValue().getDataSource());
}
@Test
void insertIntermediaryRelative_shouldRejectBenrenSubType() {
CcdiBizIntermediary owner = new CcdiBizIntermediary();
owner.setBizId("biz-1");
owner.setPersonSubType("本人");
CcdiIntermediaryRelativeAddDTO addDTO = new CcdiIntermediaryRelativeAddDTO();
addDTO.setName("测试亲属");
addDTO.setPersonId("320101199001010022");
addDTO.setPersonSubType("本人");
when(bizIntermediaryMapper.selectById("biz-1")).thenReturn(owner);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.insertIntermediaryRelative("biz-1", addDTO));
assertEquals("亲属关系不能为本人", exception.getMessage());
verify(bizIntermediaryMapper, never()).insert(any(CcdiBizIntermediary.class));
}
@Test
void deleteIntermediaryByIds_shouldDeleteRelativesAndEnterpriseRelationsWhenRemovingOwner() {
CcdiBizIntermediary owner = new CcdiBizIntermediary();
owner.setBizId("biz-1");
owner.setPersonSubType("本人");
when(bizIntermediaryMapper.selectById("biz-1")).thenReturn(owner);
when(bizIntermediaryMapper.delete(any())).thenReturn(2);
when(enterpriseRelationMapper.delete(any())).thenReturn(1);
when(bizIntermediaryMapper.deleteById("biz-1")).thenReturn(1);
int result = service.deleteIntermediaryByIds(new String[]{"biz-1"});
assertEquals(1, result);
verify(bizIntermediaryMapper).delete(any());
verify(enterpriseRelationMapper).delete(any());
verify(bizIntermediaryMapper).deleteById("biz-1");
}
}

View File

@@ -0,0 +1,78 @@
package com.ruoyi.info.collection.service;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiPurchaseTransactionFeatureContractTest {
@Test
void shouldExposeSupplierListContractsAcrossPurchaseTransactionModels() throws Exception {
assertHasField(
"com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionAddDTO",
"supplierList"
);
assertHasField(
"com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionEditDTO",
"supplierList"
);
assertHasField(
"com.ruoyi.info.collection.domain.vo.CcdiPurchaseTransactionVO",
"supplierList"
);
assertHasField(
"com.ruoyi.info.collection.domain.vo.CcdiPurchaseTransactionVO",
"supplierCount"
);
assertNotNull(Class.forName("com.ruoyi.info.collection.domain.CcdiPurchaseTransactionSupplier"));
assertNotNull(Class.forName("com.ruoyi.info.collection.domain.dto.CcdiPurchaseTransactionSupplierDTO"));
assertNotNull(Class.forName("com.ruoyi.info.collection.domain.vo.CcdiPurchaseTransactionSupplierVO"));
assertNotNull(Class.forName("com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionSupplierExcel"));
}
@Test
void shouldDefineSupplierSubTableAndBiddingMigrationScripts() throws Exception {
String initSql = Files.readString(repoPath("sql/ccdi_purchase_transaction.sql"));
assertTrue(initSql.contains("CREATE TABLE `ccdi_purchase_transaction_supplier`"));
assertTrue(initSql.contains("`is_bid_winner`"));
assertTrue(initSql.contains("`sort_order`"));
assertTrue(initSql.contains("utf8mb4_general_ci"));
String menuSql = Files.readString(repoPath("sql/ccdi_purchase_transaction_menu.sql"));
assertTrue(menuSql.contains("招投标信息维护"));
Path migrationPath = repoPath("sql/migration/2026-04-22-bidding-info-maintenance-supplier-detail.sql");
assertTrue(Files.exists(migrationPath), "应提供招投标供应商明细迁移脚本");
String migrationSql = Files.readString(migrationPath);
assertTrue(migrationSql.contains("ccdi_purchase_transaction_supplier"));
assertTrue(migrationSql.contains("INSERT INTO ccdi_purchase_transaction_supplier"));
assertTrue(migrationSql.contains("UPDATE sys_menu"));
assertTrue(migrationSql.contains("招投标信息维护"));
}
@Test
void shouldUseTwoSheetTemplateForBiddingImport() throws Exception {
assertNotNull(Class.forName("com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionSupplierExcel"));
String controller = Files.readString(
Path.of("src/main/java/com/ruoyi/info/collection/controller/CcdiPurchaseTransactionController.java")
);
assertTrue(controller.contains("招投标主信息"));
assertTrue(controller.contains("供应商明细"));
}
private void assertHasField(String className, String fieldName) throws Exception {
Class<?> clazz = Class.forName(className);
Field field = clazz.getDeclaredField(fieldName);
assertNotNull(field);
}
private Path repoPath(String relativePath) {
return Path.of("..", relativePath);
}
}

View File

@@ -0,0 +1,248 @@
package com.ruoyi.info.collection.utils;
import com.alibaba.excel.annotation.ExcelProperty;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.DataValidationHelper;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class EasyExcelUtilImportDropdownValidationTest {
@Test
void importExcel_shouldPassWhenAllDictDropdownColumnsKeepListValidation() throws Exception {
byte[] bytes = baseStaffWorkbook(true, true, true, 2);
List<CcdiBaseStaffExcel> rows = EasyExcelUtil.importExcel(
new ByteArrayInputStream(bytes),
CcdiBaseStaffExcel.class,
"员工信息"
);
assertEquals(2, rows.size());
}
@Test
void importExcel_shouldFailWhenPartyMemberDropdownIsMissing() throws Exception {
byte[] bytes = baseStaffWorkbook(false, true, true, 2);
ServiceException exception = assertThrows(ServiceException.class, () ->
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
);
assertTrue(exception.getMessage().contains("是否党员 列缺少下拉框"));
}
@Test
void importExcel_shouldFailWhenStatusDropdownIsMissing() throws Exception {
byte[] bytes = baseStaffWorkbook(true, false, true, 2);
ServiceException exception = assertThrows(ServiceException.class, () ->
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
);
assertEquals("员工信息 Sheet 的 状态 列缺少下拉框,请下载最新导入模板填写后重新导入", exception.getMessage());
}
@Test
void importExcel_shouldReportAllMissingDropdownColumnsInSameSheet() throws Exception {
byte[] bytes = baseStaffWorkbook(false, false, true, 2);
ServiceException exception = assertThrows(ServiceException.class, () ->
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
);
assertEquals("员工信息 Sheet 的 是否党员、状态 列缺少下拉框,请下载最新导入模板填写后重新导入", exception.getMessage());
}
@Test
void importExcel_shouldFailWhenValidationIsNotListType() throws Exception {
byte[] bytes = baseStaffWorkbook(true, true, false, 2);
ServiceException exception = assertThrows(ServiceException.class, () ->
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
);
assertTrue(exception.getMessage().contains("状态 列缺少下拉框"));
}
@Test
void importExcel_shouldFailWhenListValidationDoesNotCoverEveryActualDataRow() throws Exception {
byte[] bytes = baseStaffWorkbook(true, true, true, 1);
ServiceException exception = assertThrows(ServiceException.class, () ->
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
);
assertTrue(exception.getMessage().contains("状态 列缺少下拉框"));
}
@Test
void importExcel_shouldFailWhenSecondSheetDropdownIsMissing() throws Exception {
byte[] bytes = baseStaffDualSheetWorkbookWithMissingAssetStatusDropdown();
ServiceException exception = assertThrows(ServiceException.class, () ->
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffAssetInfoExcel.class, "员工资产信息")
);
assertEquals("员工资产信息 Sheet 的 资产状态 列缺少下拉框,请下载最新导入模板填写后重新导入", exception.getMessage());
}
@Test
void importExcel_shouldSkipDropdownStructureValidationWhenClassHasNoDictDropdownFields() throws Exception {
byte[] bytes = plainWorkbookWithoutDropdown();
List<PlainExcel> rows = EasyExcelUtil.importExcel(
new ByteArrayInputStream(bytes),
PlainExcel.class,
"普通信息"
);
assertEquals(1, rows.size());
}
private byte[] baseStaffWorkbook(boolean partyDropdown, boolean statusDropdown, boolean statusAsList,
int statusLastRow) throws Exception {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("员工信息");
Row header = sheet.createRow(0);
String[] headers = {"姓名", "员工ID", "所属部门ID", "身份证号", "电话", "年收入(元/年)",
"入职时间", "是否党员", "状态"};
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
createBaseStaffRow(sheet, 1, "张三", 9020001L, "33010619850202101X", "0", "1");
createBaseStaffRow(sheet, 2, "李四", 9020002L, "330106198603031022", "1", "1");
if (partyDropdown) {
addListValidation(sheet, 7, 1, 2, "0", "1");
}
if (statusDropdown) {
if (statusAsList) {
addListValidation(sheet, 8, 1, statusLastRow, "0", "1");
} else {
addIntegerValidation(sheet, 8, 1, 2);
}
}
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private byte[] baseStaffDualSheetWorkbookWithMissingAssetStatusDropdown() throws Exception {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet staffSheet = workbook.createSheet("员工信息");
Row staffHeader = staffSheet.createRow(0);
String[] staffHeaders = {"姓名", "员工ID", "所属部门ID", "身份证号", "电话", "年收入(元/年)",
"入职时间", "是否党员", "状态"};
for (int i = 0; i < staffHeaders.length; i++) {
staffHeader.createCell(i).setCellValue(staffHeaders[i]);
}
createBaseStaffRow(staffSheet, 1, "张三", 9020001L, "33010619850202101X", "0", "1");
addListValidation(staffSheet, 7, 1, 1, "0", "1");
addListValidation(staffSheet, 8, 1, 1, "0", "1");
Sheet assetSheet = workbook.createSheet("员工资产信息");
Row assetHeader = assetSheet.createRow(0);
String[] assetHeaders = {"员工身份证号*", "资产大类*", "资产小类*", "资产名称*", "产权占比",
"购买/评估日期", "资产原值", "当前估值*", "估值截止日期", "资产状态*", "备注"};
for (int i = 0; i < assetHeaders.length; i++) {
assetHeader.createCell(i).setCellValue(assetHeaders[i]);
}
Row assetRow = assetSheet.createRow(1);
assetRow.createCell(0).setCellValue("33010619850202101X");
assetRow.createCell(1).setCellValue("房产");
assetRow.createCell(2).setCellValue("住宅");
assetRow.createCell(3).setCellValue("测试住宅");
assetRow.createCell(7).setCellValue(1000000D);
assetRow.createCell(9).setCellValue("正常");
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private void createBaseStaffRow(Sheet sheet, int rowIndex, String name, long staffId, String idCard,
String partyMember, String status) {
Row row = sheet.createRow(rowIndex);
row.createCell(0).setCellValue(name);
row.createCell(1).setCellValue(staffId);
row.createCell(2).setCellValue(103L);
row.createCell(3, CellType.STRING).setCellValue(idCard);
row.createCell(4, CellType.STRING).setCellValue("13370000001");
row.createCell(5).setCellValue(new BigDecimal("180000").doubleValue());
row.createCell(6).setCellValue("2026-04-30");
row.createCell(7, CellType.STRING).setCellValue(partyMember);
row.createCell(8, CellType.STRING).setCellValue(status);
}
private void addListValidation(Sheet sheet, int columnIndex, int firstRow, int lastRow, String... options) {
DataValidationHelper helper = sheet.getDataValidationHelper();
DataValidationConstraint constraint = helper.createExplicitListConstraint(options);
DataValidation validation = helper.createValidation(
constraint,
new CellRangeAddressList(firstRow, lastRow, columnIndex, columnIndex)
);
sheet.addValidationData(validation);
}
private void addIntegerValidation(Sheet sheet, int columnIndex, int firstRow, int lastRow) {
DataValidationHelper helper = sheet.getDataValidationHelper();
DataValidationConstraint constraint = helper.createIntegerConstraint(
DataValidationConstraint.OperatorType.BETWEEN,
"0",
"1"
);
DataValidation validation = helper.createValidation(
constraint,
new CellRangeAddressList(firstRow, lastRow, columnIndex, columnIndex)
);
sheet.addValidationData(validation);
}
private byte[] plainWorkbookWithoutDropdown() throws Exception {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("普通信息");
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("名称");
Row row = sheet.createRow(1);
row.createCell(0).setCellValue("张三");
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
public static class PlainExcel {
@ExcelProperty(value = "名称", index = 0)
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}

View File

@@ -17,6 +17,7 @@ public class LsfxConstants {
/** 数据渠道编码 */
public static final String DATA_CHANNEL_ZJRCU = "ZJRCU";
public static final String DATA_CHANNEL_JZL = "JZL";
/** 分析类型 */
public static final String ANALYSIS_TYPE = "-1";

View File

@@ -14,7 +14,7 @@ public class FetchInnerFlowRequest {
/** 客户身份证号 */
private String customerNo;
/** 数据渠道编码(固定值:ZJRCU) */
/** 数据渠道编码(ZJRCU-行内JZL-金综) */
private String dataChannelCode;
/** 发起请求的时间(格式:yyyyMMdd) */

View File

@@ -51,7 +51,7 @@ public class GetBankStatementResponse {
private String leName;
/** 企业银行账号 */
private String accountMaskNo;
private String accountNo;
/** 账号日期ID */
private Integer accountingDateId;
@@ -102,7 +102,7 @@ public class GetBankStatementResponse {
private String customerName;
/** 对手方账号 */
private String customerAccountMaskNo;
private String customerAccountNo;
/** 对手方银行 */
private String customerBank;

View File

@@ -0,0 +1,61 @@
package com.ruoyi.lsfx.client;
import com.ruoyi.lsfx.constants.LsfxConstants;
import com.ruoyi.lsfx.domain.response.UploadFileResponse;
import com.ruoyi.lsfx.util.HttpUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LsfxAnalysisClientTest {
@Mock
private HttpUtil httpUtil;
@InjectMocks
private LsfxAnalysisClient client;
@TempDir
Path tempDir;
@Test
void uploadFile_shouldPassOriginalFilenameToMultipartResource() throws Exception {
ReflectionTestUtils.setField(client, "baseUrl", "http://lsfx");
ReflectionTestUtils.setField(client, "uploadFileEndpoint", "/upload");
ReflectionTestUtils.setField(client, "clientId", "client-1");
Path tempFile = tempDir.resolve("batch_0_123456.xlsx");
Files.writeString(tempFile, "content");
UploadFileResponse response = new UploadFileResponse();
response.setData(new UploadFileResponse.UploadData());
ArgumentCaptor<Map<String, Object>> paramsCaptor = ArgumentCaptor.forClass(Map.class);
ArgumentCaptor<Map<String, String>> headersCaptor = ArgumentCaptor.forClass(Map.class);
when(httpUtil.uploadFile(eq("http://lsfx/upload"), paramsCaptor.capture(), headersCaptor.capture(), eq(UploadFileResponse.class)))
.thenReturn(response);
client.uploadFile(200, tempFile.toFile(), "银行流水A.xlsx");
assertEquals(200, paramsCaptor.getValue().get("groupId"));
Resource filePart = assertInstanceOf(Resource.class, paramsCaptor.getValue().get("files"));
assertEquals("银行流水A.xlsx", filePart.getFilename());
assertEquals("client-1", headersCaptor.getValue().get(LsfxConstants.HEADER_CLIENT_ID));
}
}

View File

@@ -0,0 +1,59 @@
package com.ruoyi.lsfx.util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class HttpUtilTest {
@Mock
private RestTemplate restTemplate;
@TempDir
Path tempDir;
@Test
void uploadFile_shouldUseExplicitResourceFilename() throws Exception {
HttpUtil httpUtil = new HttpUtil();
ReflectionTestUtils.setField(httpUtil, "restTemplate", restTemplate);
Path tempFile = tempDir.resolve("batch_0_123456.xlsx");
Files.writeString(tempFile, "content");
ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
when(restTemplate.postForEntity(eq("http://lsfx/upload"), captor.capture(), eq(String.class)))
.thenReturn(ResponseEntity.ok("ok"));
Map<String, Object> params = new HashMap<>();
params.put("groupId", 200);
params.put("files", HttpUtil.namedFileResource(tempFile.toFile(), "银行流水A.xlsx"));
String result = httpUtil.uploadFile("http://lsfx/upload", params, null, String.class);
assertEquals("ok", result);
MultiValueMap<String, Object> body = (MultiValueMap<String, Object>) captor.getValue().getBody();
Object filePart = body.getFirst("files");
Resource resource = assertInstanceOf(Resource.class, filePart);
assertEquals("银行流水A.xlsx", resource.getFilename());
}
}

View File

@@ -0,0 +1 @@
mock-maker-subclass

View File

@@ -18,7 +18,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -72,9 +71,13 @@ public class CcdiBankStatementController extends BaseController {
*/
@GetMapping("/detail/{bankStatementId}")
@Operation(summary = "查询流水详情")
public AjaxResult getDetail(@PathVariable Long bankStatementId) {
public AjaxResult getDetail(@PathVariable Long bankStatementId, String modelCode, String suspiciousType) {
projectAccessService.assertCanReadByBankStatementId(bankStatementId);
CcdiBankStatementDetailVO detail = bankStatementService.getStatementDetail(bankStatementId);
CcdiBankStatementDetailVO detail = bankStatementService.getStatementDetail(
bankStatementId,
modelCode,
suspiciousType
);
return AjaxResult.success(detail);
}
@@ -83,7 +86,6 @@ public class CcdiBankStatementController extends BaseController {
*/
@PostMapping("/export")
@Operation(summary = "导出流水明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:export')")
public void export(HttpServletResponse response, CcdiBankStatementQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiBankStatementExcel> list = bankStatementService.selectStatementListForExport(queryDTO);

View File

@@ -14,6 +14,7 @@ import com.ruoyi.common.core.page.PageDomain;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.core.page.TableSupport;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.lsfx.constants.LsfxConstants;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -65,7 +66,7 @@ public class CcdiFileUploadController extends BaseController {
return AjaxResult.error("单次最多上传100个文件");
}
// 校验文件大小和格式
// 校验文件数量、空文件和大小,文件名业务规则统一在 Service 层处理
for (MultipartFile file : files) {
if (file.isEmpty()) {
return AjaxResult.error("文件不能为空");
@@ -73,15 +74,6 @@ public class CcdiFileUploadController extends BaseController {
if (file.getSize() > 50 * 1024 * 1024) {
return AjaxResult.error("文件 " + file.getOriginalFilename() + " 超过50MB限制");
}
String fileName = file.getOriginalFilename();
if (fileName == null || fileName.trim().isEmpty()) {
return AjaxResult.error("文件名不能为空");
}
String lowerFileName = fileName.toLowerCase();
if (!lowerFileName.endsWith(".xlsx") && !lowerFileName.endsWith(".csv")
&& !lowerFileName.endsWith(".pdf")) {
return AjaxResult.error("文件 " + fileName + " 格式不支持, 仅支持 PDF, CSV, XLSX 文件");
}
}
try {
@@ -112,10 +104,10 @@ public class CcdiFileUploadController extends BaseController {
}
/**
* 提交拉取本行信息任务
* 提交拉取行内/金综流水任务
*/
@PostMapping("/pull-bank-info")
@Operation(summary = "拉取本行信息", description = "身份证号批量提交拉取本行信息任务")
@Operation(summary = "拉取行内/金综流水", description = "证件号码批量提交拉取行内/金综流水任务")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult pullBankInfo(@RequestBody CcdiPullBankInfoSubmitDTO dto) {
if (dto == null || dto.getProjectId() == null) {
@@ -125,7 +117,16 @@ public class CcdiFileUploadController extends BaseController {
if (CollectionUtils.isEmpty(dto.getIdCards())) {
return AjaxResult.error("身份证号不能为空");
}
if (!StringUtils.hasText(dto.getStartDate()) || !StringUtils.hasText(dto.getEndDate())) {
if (!StringUtils.hasText(dto.getDataChannelCode())) {
return AjaxResult.error("流水来源不能为空");
}
String dataChannelCode = dto.getDataChannelCode().trim().toUpperCase();
if (!LsfxConstants.DATA_CHANNEL_ZJRCU.equals(dataChannelCode)
&& !LsfxConstants.DATA_CHANNEL_JZL.equals(dataChannelCode)) {
return AjaxResult.error("流水来源不支持");
}
if (LsfxConstants.DATA_CHANNEL_ZJRCU.equals(dataChannelCode)
&& (!StringUtils.hasText(dto.getStartDate()) || !StringUtils.hasText(dto.getEndDate()))) {
return AjaxResult.error("开始日期和结束日期不能为空");
}
@@ -134,6 +135,7 @@ public class CcdiFileUploadController extends BaseController {
String batchId = fileUploadService.submitPullBankInfo(
dto.getProjectId(),
dto.getIdCards(),
dataChannelCode,
dto.getStartDate(),
dto.getEndDate(),
userId,

View File

@@ -7,6 +7,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQuery
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskExclusionSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
@@ -33,8 +34,10 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@@ -67,6 +70,18 @@ public class CcdiProjectOverviewController extends BaseController {
return AjaxResult.success(dashboard);
}
/**
* 排除单条可疑预警
*/
@PostMapping("/risk-exclusions")
@Operation(summary = "排除单条可疑预警")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult excludeRisk(@Validated @RequestBody CcdiProjectRiskExclusionSaveDTO dto) {
projectAccessService.assertCanOperate(dto.getProjectId());
overviewService.excludeRisk(dto);
return AjaxResult.success("排除成功");
}
/**
* 查询风险人员总览
*/
@@ -293,9 +308,12 @@ public class CcdiProjectOverviewController extends BaseController {
@PostMapping("/risk-details/export")
@Operation(summary = "导出风险明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportRiskDetails(HttpServletResponse response, Long projectId) {
projectAccessService.assertCanRead(projectId);
overviewService.exportRiskDetails(response, projectId);
public void exportRiskDetails(
HttpServletResponse response,
CcdiProjectSuspiciousTransactionQueryDTO queryDTO
) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
overviewService.exportRiskDetails(response, queryDTO);
}
/**

View File

@@ -8,6 +8,8 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExtendedTransferDetailQueryD
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExtendedTransferQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectFamilyAssetLiabilityDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectFamilyAssetLiabilityListQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectIncreaseLendingQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectIncreaseLendingExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedRecruitmentDetailVO;
@@ -16,16 +18,21 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedTransferDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedTransferListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetLiabilityDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetLiabilityListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectIncreaseLendingListVO;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiProjectSpecialCheckService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.poi.ExcelUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -138,4 +145,29 @@ public class CcdiProjectSpecialCheckController extends BaseController {
CcdiProjectExtendedTransferDetailVO result = specialCheckService.getExtendedTransferDetail(queryDTO);
return AjaxResult.success(result);
}
/**
* 查询新增贷款列表
*/
@GetMapping("/increase-lending/list")
@Operation(summary = "查询新增贷款列表")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getIncreaseLendingList(@Validated CcdiProjectIncreaseLendingQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectIncreaseLendingListVO result = specialCheckService.getIncreaseLendingList(queryDTO);
return AjaxResult.success(result);
}
/**
* 导出新增贷款列表
*/
@PostMapping("/increase-lending/export")
@Operation(summary = "导出新增贷款列表")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportIncreaseLending(HttpServletResponse response, @Validated CcdiProjectIncreaseLendingQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiProjectIncreaseLendingExcel> rows = specialCheckService.exportIncreaseLendingList(queryDTO);
ExcelUtil<CcdiProjectIncreaseLendingExcel> util = new ExcelUtil<>(CcdiProjectIncreaseLendingExcel.class);
util.exportExcel(response, rows, "新增贷款查询");
}
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.ccdi.project.domain.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 项目新增贷款查询入参
*/
@Data
public class CcdiProjectIncreaseLendingQueryDTO {
/** 项目ID */
@NotNull(message = "项目ID不能为空")
private Long projectId;
/** 柜员号 */
private String staffId;
/** 员工身份证号 */
private String staffIdCard;
/** 审核人柜员号 */
private String approver;
/** 发放日期起 */
private String loanStartDate;
/** 发放日期止 */
private String loanEndDate;
/** 页码 */
private Integer pageNum = 1;
/** 每页条数 */
private Integer pageSize = 10;
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.ccdi.project.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 项目结果页排除可疑保存DTO
*/
@Data
public class CcdiProjectRiskExclusionSaveDTO {
/** 项目ID */
@NotNull(message = "项目ID不能为空")
private Long projectId;
/** 人员证件号 */
private String staffIdCard;
/** 规则编码 */
@NotBlank(message = "规则编码不能为空")
private String ruleCode;
/** 排除类型STATEMENT/OBJECT */
@NotBlank(message = "排除类型不能为空")
private String exclusionType;
/** 流水ID */
private Long bankStatementId;
/** 排除原因 */
@NotBlank(message = "排除原因不能为空")
@Size(max = 1000, message = "排除原因不能超过1000个字符")
private String excludeReason;
}

View File

@@ -11,6 +11,9 @@ public class CcdiProjectSuspiciousTransactionQueryDTO {
/** 项目ID */
private Long projectId;
/** 模型编码 */
private String modelCode;
/** 涉疑类型 */
private String suspiciousType;
@@ -19,4 +22,7 @@ public class CcdiProjectSuspiciousTransactionQueryDTO {
/** 每页数量 */
private Integer pageSize;
/** 是否包含外部人员预警分支 */
private Boolean includeExternalPerson;
}

View File

@@ -5,7 +5,7 @@ import lombok.Data;
import java.util.List;
/**
* 拉取本行信息提交参数
* 拉取行内/金综流水提交参数
*/
@Data
public class CcdiPullBankInfoSubmitDTO {
@@ -16,6 +16,9 @@ public class CcdiPullBankInfoSubmitDTO {
/** 身份证号列表 */
private List<String> idCards;
/** 数据渠道编码ZJRCU-行内JZL-金综 */
private String dataChannelCode;
/** 开始日期 */
private String startDate;

View File

@@ -194,8 +194,8 @@ public class CcdiBankStatement implements Serializable {
BeanUtils.copyProperties(item, entity);
// 4. 手动映射字段名不一致的情况
entity.setLeAccountNo(item.getAccountMaskNo());
entity.setCustomerAccountNo(item.getCustomerAccountMaskNo());
entity.setLeAccountNo(item.getAccountNo());
entity.setCustomerAccountNo(item.getCustomerAccountNo());
entity.setLeAccountName(item.getLeName());
entity.setAmountDr(item.getDrAmount());
entity.setAmountCr(item.getCrAmount());

View File

@@ -0,0 +1,58 @@
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_risk_exclusion")
public class CcdiProjectRiskExclusion implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 主键ID */
@TableId(type = IdType.AUTO)
private Long id;
/** 项目ID */
private Long projectId;
/** 人员证件号 */
private String staffIdCard;
/** 规则编码 */
private String ruleCode;
/** 排除类型STATEMENT/OBJECT */
private String exclusionType;
/** 流水ID */
private Long bankStatementId;
/** 排除原因 */
private String excludeReason;
/** 创建者 */
private String createBy;
/** 创建时间 */
private Date createTime;
/** 更新者 */
private String updateBy;
/** 更新时间 */
private Date updateTime;
/** 备注 */
private String remark;
}

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

@@ -0,0 +1,84 @@
package com.ruoyi.ccdi.project.domain.excel;
import com.ruoyi.common.annotation.Excel;
import java.math.BigDecimal;
import lombok.Data;
/**
* 新增贷款查询导出对象
*/
@Data
public class CcdiProjectIncreaseLendingExcel {
/** 柜员号 */
@Excel(name = "柜员号")
private String staffId;
/** 员工姓名 */
@Excel(name = "员工姓名")
private String staffName;
/** 员工身份证 */
@Excel(name = "员工身份证")
private String staffIdCard;
/** 部门 */
@Excel(name = "部门")
private String deptName;
/** 合同编号 */
@Excel(name = "合同编号")
private String contractNo;
/** 放款机构号 */
@Excel(name = "放款机构号")
private String lendingOrgNo;
/** 借款人 */
@Excel(name = "借款人")
private String borrowerName;
/** 借款人证件号 */
@Excel(name = "借款人证件号")
private String borrowerCertNo;
/** 贷款产品 */
@Excel(name = "贷款产品")
private String loanProduct;
/** 合同金额 */
@Excel(name = "合同金额")
private BigDecimal contractAmount;
/** 当前余额 */
@Excel(name = "当前余额")
private BigDecimal loanBalance;
/** 发放日期 */
@Excel(name = "发放日期")
private String loanStartDate;
/** 到期日期 */
@Excel(name = "到期日期")
private String loanEndDate;
/** 合同状态 */
@Excel(name = "合同状态")
private String status;
/** 五级分类 */
@Excel(name = "五级分类")
private String fiveClassification;
/** 客户经理ID */
@Excel(name = "客户经理ID")
private String customerManagerId;
/** 客户经理 */
@Excel(name = "客户经理")
private String customerManagerName;
/** 审批人 */
@Excel(name = "审批人")
private String approver;
}

View File

@@ -8,6 +8,9 @@ import lombok.Data;
@Data
public class CcdiBankStatementHitTagVO {
/** 模型编码 */
private String modelCode;
/** 规则编码 */
private String ruleCode;

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

@@ -24,6 +24,15 @@ public class CcdiProjectFamilyAssetLiabilityListItemVO {
/** 家庭总年收入 */
private BigDecimal totalIncome;
/** 本人年收入 */
private BigDecimal selfIncome;
/** 配偶年收入 */
private BigDecimal spouseIncome;
/** 本人入职年限 */
private Integer employmentYears;
/** 家庭总资产 */
private BigDecimal totalAsset;
@@ -33,6 +42,12 @@ public class CcdiProjectFamilyAssetLiabilityListItemVO {
/** 收入负债对比金额 */
private BigDecimal comparisonAmount;
/** 可解释收入 */
private BigDecimal explainableIncome;
/** 资产收入倍数 */
private BigDecimal assetIncomeRatio;
/** 风险等级编码 */
private String riskLevelCode;

View File

@@ -15,6 +15,12 @@ public class CcdiProjectFamilyIncomeDetailVO {
/** 配偶年收入 */
private BigDecimal spouseIncome;
/** 本人入职年限 */
private Integer employmentYears;
/** 家庭总年收入 */
private BigDecimal totalIncome;
/** 可解释收入 */
private BigDecimal explainableIncome;
}

View File

@@ -0,0 +1,65 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.math.BigDecimal;
import lombok.Data;
/**
* 项目新增贷款查询列表项
*/
@Data
public class CcdiProjectIncreaseLendingListItemVO {
/** 员工ID/柜员号 */
private String staffId;
/** 员工姓名 */
private String staffName;
/** 员工身份证号 */
private String staffIdCard;
/** 所属部门 */
private String deptName;
/** 合同编号 */
private String contractNo;
/** 放款机构号 */
private String lendingOrgNo;
/** 借款人名称 */
private String borrowerName;
/** 借款人证件号码 */
private String borrowerCertNo;
/** 贷款产品 */
private String loanProduct;
/** 合同金额 */
private BigDecimal contractAmount;
/** 当前贷款余额 */
private BigDecimal loanBalance;
/** 贷款发放日期 */
private String loanStartDate;
/** 贷款到期日期 */
private String loanEndDate;
/** 合同状态 */
private String status;
/** 五级分类 */
private String fiveClassification;
/** 客户经理ID */
private String customerManagerId;
/** 客户经理姓名 */
private String customerManagerName;
/** 审批人 */
private String approver;
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.util.List;
import lombok.Data;
/**
* 项目新增贷款查询列表结果
*/
@Data
public class CcdiProjectIncreaseLendingListVO {
/** 列表数据 */
private List<CcdiProjectIncreaseLendingListItemVO> rows;
/** 总数 */
private Long total;
}

View File

@@ -12,6 +12,8 @@ public class CcdiProjectPersonAnalysisObjectRecordVO {
private String modelCode;
private String ruleCode;
private String title;
private String subtitle;

View File

@@ -3,6 +3,8 @@ package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 涉疑交易明细行
@@ -14,6 +16,14 @@ public class CcdiProjectSuspiciousTransactionItemVO {
private String trxDate;
private String leAccountNo;
private String leAccountName;
private String customerAccountName;
private String customerAccountNo;
private String suspiciousPersonName;
private String relatedPersonName;
@@ -35,4 +45,6 @@ public class CcdiProjectSuspiciousTransactionItemVO {
private Boolean hasNameListHit;
private String nameListHitType;
private List<CcdiBankStatementHitTagVO> hitTags = new ArrayList<>();
}

View File

@@ -26,11 +26,15 @@ public interface CcdiBankTagResultMapper extends BaseMapper<CcdiBankTagResult> {
*
* @param projectId 项目ID
* @param bankStatementIds 流水ID列表
* @param modelCode 模型编码
* @param suspiciousType 预警类型
* @return 命中的异常标签列表
*/
List<CcdiBankStatementHitTagVO> selectStatementTagsByProjectAndStatementIds(
@Param("projectId") Long projectId,
@Param("bankStatementIds") List<Long> bankStatementIds
@Param("bankStatementIds") List<Long> bankStatementIds,
@Param("modelCode") String modelCode,
@Param("suspiciousType") String suspiciousType
);
/**

View File

@@ -163,6 +163,14 @@ public interface CcdiProjectOverviewMapper {
*/
CcdiProjectExternalRiskSummaryVO selectExternalRiskSummaryByProjectId(@Param("projectId") Long projectId);
/**
* 判断项目是否存在外部人员主体
*
* @param projectId 项目ID
* @return 存在时返回1否则返回空
*/
Integer selectExternalPersonSubjectExistsByProjectId(@Param("projectId") Long projectId);
/**
* 查询外部人员预警模型卡片
*

View File

@@ -0,0 +1,27 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectRiskExclusion;
import org.apache.ibatis.annotations.Param;
/**
* 项目结果页排除可疑记录 Mapper
*/
public interface CcdiProjectRiskExclusionMapper extends BaseMapper<CcdiProjectRiskExclusion> {
/**
* 幂等写入排除记录
*
* @param exclusion 排除记录
* @return 写入条数
*/
int upsertExclusion(@Param("exclusion") CcdiProjectRiskExclusion exclusion);
/**
* 查询待排除命中是否存在
*
* @param exclusion 排除记录
* @return 命中数量
*/
int countExistingRiskHit(@Param("exclusion") CcdiProjectRiskExclusion exclusion);
}

View File

@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExtendedPurchaseQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExtendedRecruitmentQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExtendedTransferQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectIncreaseLendingQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectIncreaseLendingExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseListItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseSupplierVO;
@@ -15,6 +17,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetLiabilityDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetLiabilityListItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyDebtItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectIncreaseLendingListItemVO;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -145,6 +148,28 @@ public interface CcdiProjectSpecialCheckMapper {
@Param("query") CcdiProjectExtendedTransferQueryDTO queryDTO
);
/**
* 查询项目新增贷款列表
*
* @param page 分页对象
* @param queryDTO 查询条件
* @return 分页结果
*/
Page<CcdiProjectIncreaseLendingListItemVO> selectIncreaseLendingPage(
@Param("page") Page<CcdiProjectIncreaseLendingListItemVO> page,
@Param("query") CcdiProjectIncreaseLendingQueryDTO queryDTO
);
/**
* 查询项目新增贷款导出列表
*
* @param queryDTO 查询条件
* @return 导出列表
*/
List<CcdiProjectIncreaseLendingExcel> selectIncreaseLendingExportList(
@Param("query") CcdiProjectIncreaseLendingQueryDTO queryDTO
);
/**
* 查询专项核查调动拓展详情
*

View File

@@ -49,4 +49,18 @@ public interface ICcdiBankStatementService {
* @return 详情
*/
CcdiBankStatementDetailVO getStatementDetail(Long bankStatementId);
/**
* 按涉疑交易筛选范围查询流水详情
*
* @param bankStatementId 流水ID
* @param modelCode 模型编码
* @param suspiciousType 预警类型
* @return 详情
*/
CcdiBankStatementDetailVO getStatementDetail(
Long bankStatementId,
String modelCode,
String suspiciousType
);
}

View File

@@ -35,10 +35,11 @@ public interface ICcdiFileUploadService {
List<String> parseIdCardFile(MultipartFile file);
/**
* 提交拉取本行信息任务
* 提交拉取行内/金综流水任务
*
* @param projectId 项目ID
* @param idCards 身份证号列表
* @param dataChannelCode 数据渠道编码
* @param startDate 开始日期
* @param endDate 结束日期
* @param userId 当前登录用户ID
@@ -47,6 +48,7 @@ public interface ICcdiFileUploadService {
*/
String submitPullBankInfo(Long projectId,
List<String> idCards,
String dataChannelCode,
String startDate,
String endDate,
Long userId,

View File

@@ -7,6 +7,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQuery
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskExclusionSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
@@ -42,6 +43,13 @@ public interface ICcdiProjectOverviewService {
*/
CcdiProjectOverviewDashboardVO getDashboard(Long projectId);
/**
* 排除单条可疑预警
*
* @param dto 排除参数
*/
void excludeRisk(CcdiProjectRiskExclusionSaveDTO dto);
/**
* 查询风险人员总览
*
@@ -205,6 +213,18 @@ public interface ICcdiProjectOverviewService {
default void exportRiskDetails(HttpServletResponse response, Long projectId) {
}
/**
* 按涉疑交易筛选范围统一导出风险明细
*
* @param response 响应流
* @param queryDTO 涉疑交易筛选条件
*/
default void exportRiskDetails(
HttpServletResponse response,
CcdiProjectSuspiciousTransactionQueryDTO queryDTO
) {
}
/**
* 一键导出结果总览报告
*

View File

@@ -8,6 +8,8 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExtendedTransferDetailQueryD
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExtendedTransferQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectFamilyAssetLiabilityDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectFamilyAssetLiabilityListQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectIncreaseLendingQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectIncreaseLendingExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedRecruitmentDetailVO;
@@ -16,6 +18,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedTransferDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedTransferListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetLiabilityDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetLiabilityListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectIncreaseLendingListVO;
import java.util.List;
/**
* 项目专项核查服务接口
@@ -91,4 +95,20 @@ public interface ICcdiProjectSpecialCheckService {
* @return 详情结果
*/
CcdiProjectExtendedTransferDetailVO getExtendedTransferDetail(CcdiProjectExtendedTransferDetailQueryDTO queryDTO);
/**
* 查询项目新增贷款列表
*
* @param queryDTO 查询条件
* @return 列表结果
*/
CcdiProjectIncreaseLendingListVO getIncreaseLendingList(CcdiProjectIncreaseLendingQueryDTO queryDTO);
/**
* 导出项目新增贷款列表
*
* @param queryDTO 查询条件
* @return 导出列表
*/
List<CcdiProjectIncreaseLendingExcel> exportIncreaseLendingList(CcdiProjectIncreaseLendingQueryDTO queryDTO);
}

View File

@@ -32,6 +32,8 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
private static final Set<String> ALLOWED_TAB_TYPES = Set.of("all", "in", "out");
private static final Set<String> ALLOWED_ORDER_DIRECTIONS = Set.of("asc", "desc");
private static final Set<String> ALLOWED_SUSPICIOUS_TYPES =
Set.of("ALL", "MODEL_RULE", "EXTERNAL_PERSON", "NAME_LIST");
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@@ -69,13 +71,24 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
@Override
public CcdiBankStatementDetailVO getStatementDetail(Long bankStatementId) {
return getStatementDetail(bankStatementId, null, null);
}
@Override
public CcdiBankStatementDetailVO getStatementDetail(
Long bankStatementId,
String modelCode,
String suspiciousType
) {
CcdiBankStatementDetailVO detail = bankStatementMapper.selectStatementDetailById(bankStatementId);
if (detail == null || detail.getProjectId() == null || detail.getBankStatementId() == null) {
return detail;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(
detail.getProjectId(),
List.of(detail.getBankStatementId())
detail.getProjectId(),
List.of(detail.getBankStatementId()),
normalizeUpperCase(modelCode),
normalizeSuspiciousType(suspiciousType)
);
detail.setHitTags(new ArrayList<>(hitTagMap.getOrDefault(detail.getBankStatementId(), Collections.emptyList())));
return detail;
@@ -93,18 +106,33 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
if (bankStatementIds.isEmpty()) {
return;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(projectId, bankStatementIds);
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(
projectId,
bankStatementIds,
null,
null
);
rows.forEach(row -> row.setHitTags(new ArrayList<>(
hitTagMap.getOrDefault(row.getBankStatementId(), Collections.emptyList())
)));
}
private Map<Long, List<CcdiBankStatementHitTagVO>> loadHitTagMap(Long projectId, List<Long> bankStatementIds) {
private Map<Long, List<CcdiBankStatementHitTagVO>> loadHitTagMap(
Long projectId,
List<Long> bankStatementIds,
String modelCode,
String suspiciousType
) {
if (projectId == null || bankStatementIds == null || bankStatementIds.isEmpty()) {
return Collections.emptyMap();
}
List<CcdiBankStatementHitTagVO> hitTags =
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(projectId, bankStatementIds);
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
projectId,
bankStatementIds,
modelCode,
suspiciousType
);
if (hitTags == null || hitTags.isEmpty()) {
return Collections.emptyMap();
}
@@ -165,6 +193,16 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
return normalized == null ? null : normalized.toLowerCase(Locale.ROOT);
}
private String normalizeUpperCase(String value) {
String normalized = normalizeText(value);
return normalized == null ? null : normalized.toUpperCase(Locale.ROOT);
}
private String normalizeSuspiciousType(String suspiciousType) {
String normalized = normalizeUpperCase(suspiciousType);
return normalized != null && ALLOWED_SUSPICIOUS_TYPES.contains(normalized) ? normalized : null;
}
private String normalizeText(String value) {
if (value == null) {
return null;

View File

@@ -46,6 +46,7 @@ import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;
@@ -64,6 +65,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
private static final int MAX_ERROR_MESSAGE_LENGTH = 2000;
private static final Pattern ID_CARD_PATTERN =
Pattern.compile("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])([0-2]\\d|3[01])\\d{3}[0-9Xx]$");
private static final Pattern UPLOAD_FILE_NAME_ID_CARD_PATTERN =
Pattern.compile("(?<!\\d)\\d{17}[0-9Xx](?!\\d)");
private static final Pattern FILE_NAME_SPACE_PATTERN = Pattern.compile("[\\s\\u3000]+");
@Data
private static class FetchBankStatementResult {
@@ -150,6 +154,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
@Override
public String submitPullBankInfo(Long projectId,
List<String> idCards,
String dataChannelCode,
String startDate,
String endDate,
Long userId,
@@ -157,17 +162,20 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
if (projectId == null) {
throw new IllegalArgumentException("项目ID不能为空");
}
if (!StringUtils.hasText(startDate) || !StringUtils.hasText(endDate)) {
throw new IllegalArgumentException("开始日期和结束日期不能为空");
}
if (idCards == null || idCards.isEmpty()) {
throw new IllegalArgumentException("身份证号不能为空");
}
String normalizedDataChannelCode = normalizePullBankInfoDataChannelCode(dataChannelCode);
LocalDate start = LocalDate.parse(startDate);
LocalDate end = LocalDate.parse(endDate);
if (start.isAfter(end)) {
throw new IllegalArgumentException("开始日期不能晚于结束日期");
if (LsfxConstants.DATA_CHANNEL_ZJRCU.equals(normalizedDataChannelCode)) {
if (!StringUtils.hasText(startDate) || !StringUtils.hasText(endDate)) {
throw new IllegalArgumentException("开始日期和结束日期不能为空");
}
LocalDate start = LocalDate.parse(startDate);
LocalDate end = LocalDate.parse(endDate);
if (start.isAfter(end)) {
throw new IllegalArgumentException("开始日期不能晚于结束日期");
}
}
projectService.ensureProjectNotArchived(projectId, "已归档项目暂不允许上传或拉取数据");
@@ -214,13 +222,26 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
@Override
public void afterCommit() {
CompletableFuture.runAsync(() -> submitPullBankInfoTasks(
projectId, lsfxProjectId, records, normalizedIdCards, startDate, endDate, batchId
projectId, lsfxProjectId, records, normalizedIdCards,
normalizedDataChannelCode, startDate, endDate, batchId
));
}
});
return batchId;
}
private String normalizePullBankInfoDataChannelCode(String dataChannelCode) {
if (!StringUtils.hasText(dataChannelCode)) {
throw new IllegalArgumentException("流水来源不能为空");
}
String normalized = dataChannelCode.trim().toUpperCase();
if (!LsfxConstants.DATA_CHANNEL_ZJRCU.equals(normalized)
&& !LsfxConstants.DATA_CHANNEL_JZL.equals(normalized)) {
throw new IllegalArgumentException("流水来源不支持");
}
return normalized;
}
@Override
public String deleteFileUploadRecord(Long id, Long operatorUserId) {
CcdiFileUploadRecord record = recordMapper.selectById(id);
@@ -347,6 +368,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
log.info("【文件上传】项目信息验证通过: projectId={}, lsfxProjectId={}", projectId, lsfxProjectId);
List<String> normalizedFileNames = normalizeAndValidateUploadFileNames(files);
// Critical Fix #2 & #4: 保存临时文件和创建记录在同一个循环中,确保一一对应
List<String> tempFilePaths = new ArrayList<>();
List<CcdiFileUploadRecord> records = new ArrayList<>();
@@ -362,10 +385,11 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
// 同一个循环中保存临时文件和创建记录,确保索引一一对应
for (int i = 0; i < files.length; i++) {
MultipartFile file = files[i];
String normalizedFileName = normalizedFileNames.get(i);
// 1. 保存临时文件
String originalFilename = file.getOriginalFilename();
String tempFileName = batchId + "_" + i + "_" + System.currentTimeMillis() + "_" + originalFilename;
String tempFileName = batchId + "_" + i + "_" + System.currentTimeMillis() + "_" + normalizedFileName;
Path tempFilePath = tempDir.resolve(tempFileName);
Files.copy(file.getInputStream(), tempFilePath, StandardCopyOption.REPLACE_EXISTING);
@@ -378,7 +402,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
CcdiFileUploadRecord record = new CcdiFileUploadRecord();
record.setProjectId(projectId);
record.setLsfxProjectId(lsfxProjectId);
record.setFileName(originalFilename);
record.setFileName(normalizedFileName);
record.setFileSize(file.getSize());
record.setFileStatus("uploading");
record.setUploadTime(now);
@@ -423,6 +447,42 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
return batchId;
}
private List<String> normalizeAndValidateUploadFileNames(MultipartFile[] files) {
List<String> normalizedFileNames = new ArrayList<>();
for (MultipartFile file : files) {
String originalFileName = file.getOriginalFilename() == null ? "" : file.getOriginalFilename();
String normalizedFileName = FILE_NAME_SPACE_PATTERN.matcher(originalFileName).replaceAll("");
validateUploadFileName(normalizedFileName);
normalizedFileNames.add(normalizedFileName);
}
return normalizedFileNames;
}
private void validateUploadFileName(String fileName) {
if (!StringUtils.hasText(fileName)) {
throw new IllegalArgumentException("文件名不能为空");
}
String lowerFileName = fileName.toLowerCase(Locale.ROOT);
if (!lowerFileName.endsWith(".xlsx") && !lowerFileName.endsWith(".csv")
&& !lowerFileName.endsWith(".pdf")) {
throw new IllegalArgumentException("文件 " + fileName + " 格式不支持, 仅支持 PDF, CSV, XLSX 文件");
}
String mainFileName = getMainFileName(fileName);
if (!UPLOAD_FILE_NAME_ID_CARD_PATTERN.matcher(mainFileName).find()) {
throw new IllegalArgumentException("文件 " + fileName + " 文件名未包含身份证信息");
}
}
private String getMainFileName(String fileName) {
int extensionIndex = fileName.lastIndexOf('.');
if (extensionIndex <= 0) {
return fileName;
}
return fileName.substring(0, extensionIndex);
}
/**
* 调度线程:循环提交任务到线程池
* 支持等待30秒重试机制
@@ -537,6 +597,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
Integer lsfxProjectId,
List<CcdiFileUploadRecord> records,
List<String> idCards,
String dataChannelCode,
String startDate,
String endDate,
String batchId) {
@@ -558,7 +619,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
while (!submitted && retryCount < 2) {
try {
CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(
() -> processPullBankInfoAsync(projectId, lsfxProjectId, record, idCard, startDate, endDate),
() -> processPullBankInfoAsync(projectId, lsfxProjectId, record, idCard, dataChannelCode, startDate, endDate),
fileUploadExecutor
);
futures.add(future);
@@ -597,16 +658,23 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
Integer lsfxProjectId,
CcdiFileUploadRecord record,
String idCard,
String dataChannelCode,
String startDate,
String endDate ) {
try {
String normalizedDataChannelCode = normalizePullBankInfoDataChannelCode(dataChannelCode);
FetchInnerFlowRequest request = new FetchInnerFlowRequest();
request.setGroupId(lsfxProjectId);
request.setCustomerNo(idCard);
request.setDataChannelCode(LsfxConstants.DEFAULT_DATA_CHANNEL_CODE);
request.setDataChannelCode(normalizedDataChannelCode);
request.setRequestDateId(Integer.parseInt(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)));
request.setDataStartDateId(Integer.parseInt(startDate.replace("-", "")));
request.setDataEndDateId(Integer.parseInt(endDate.replace("-", "")));
if (LsfxConstants.DATA_CHANNEL_JZL.equals(normalizedDataChannelCode)) {
request.setDataStartDateId(0);
request.setDataEndDateId(0);
} else {
request.setDataStartDateId(Integer.parseInt(startDate.replace("-", "")));
request.setDataEndDateId(Integer.parseInt(endDate.replace("-", "")));
}
request.setUploadUserId(LsfxConstants.DEFAULT_USER_ID);
FetchInnerFlowResponse response = lsfxClient.fetchInnerFlow(request);
@@ -777,7 +845,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
enterpriseNamesStr, accountNosStr);
log.info("【文件上传】步骤7: 获取流水数据");
FetchBankStatementResult fetchResult = fetchAndSaveBankStatements(projectId, lsfxProjectId, logId);
String fallbackCretNo = extractIdCardFromFileName(record.getFileName());
FetchBankStatementResult fetchResult = fetchAndSaveBankStatements(projectId, lsfxProjectId, logId,
fallbackCretNo);
if (!fetchResult.isSuccess()) {
updateFailedRecord(record, fetchResult.getErrorMessage());
return;
@@ -850,7 +920,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
* @param logId 文件ID
*/
private FetchBankStatementResult fetchAndSaveBankStatements(Long projectId, Integer groupId,
Integer logId) {
Integer logId,
String fallbackCretNo) {
log.info("【文件上传】开始获取流水数据: projectId={}, groupId={}, logId={}",
projectId, groupId, logId);
@@ -915,6 +986,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
if (statement != null) {
statement.setBatchId(logId);
statement.setProjectId(projectId);
fillMissingCretNo(statement, fallbackCretNo);
normalizeDedupFields(statement);
batchList.add(statement);
@@ -958,6 +1030,22 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
}
}
private String extractIdCardFromFileName(String fileName) {
if (!StringUtils.hasText(fileName)) {
return null;
}
String mainFileName = getMainFileName(fileName);
Matcher matcher = UPLOAD_FILE_NAME_ID_CARD_PATTERN.matcher(mainFileName);
return matcher.find() ? matcher.group() : null;
}
private void fillMissingCretNo(CcdiBankStatement statement, String fallbackCretNo) {
if (statement == null || StringUtils.hasText(statement.getCretNo()) || !StringUtils.hasText(fallbackCretNo)) {
return;
}
statement.setCretNo(fallbackCretNo);
}
private void cleanupBankStatements(Long projectId, Integer logId) {
bankStatementMapper.deleteByProjectIdAndBatchId(projectId, logId);
}

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

@@ -10,6 +10,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQuery
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskExclusionSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
@@ -18,6 +19,7 @@ import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectRiskExclusion;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
@@ -57,14 +59,18 @@ import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectRiskExclusionMapper;
import com.ruoyi.ccdi.project.constants.CcdiProjectStatusConstants;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.annotation.Resource;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -80,6 +86,16 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
private static final String ACTION_LABEL = "查看详情";
private static final String SUSPICIOUS_TYPE_ALL = "ALL";
private static final String SUSPICIOUS_TYPE_MODEL_RULE = "MODEL_RULE";
private static final String SUSPICIOUS_TYPE_EXTERNAL_PERSON = "EXTERNAL_PERSON";
private static final String EXCLUSION_TYPE_STATEMENT = "STATEMENT";
private static final String EXCLUSION_TYPE_OBJECT = "OBJECT";
@Resource
private CcdiProjectOverviewMapper overviewMapper;
@@ -98,6 +114,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
@Resource
private CcdiBankTagResultMapper bankTagResultMapper;
@Resource
private CcdiProjectRiskExclusionMapper riskExclusionMapper;
@Resource
private CcdiProjectOverviewEmployeeResultBuilder overviewEmployeeResultBuilder;
@@ -133,6 +152,24 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return dashboard;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void excludeRisk(CcdiProjectRiskExclusionSaveDTO dto) {
CcdiProjectRiskExclusion exclusion = buildRiskExclusion(dto);
CcdiProject project = getRequiredProject(exclusion.getProjectId());
if (CcdiProjectStatusConstants.ARCHIVED.equals(project.getStatus()) || Integer.valueOf(1).equals(project.getIsArchived())) {
throw new ServiceException("已归档项目暂不允许排除可疑");
}
if (CcdiProjectStatusConstants.TAGGING.equals(project.getStatus())) {
throw new ServiceException("项目正在打标中,暂不允许排除可疑");
}
if (riskExclusionMapper.countExistingRiskHit(exclusion) <= 0) {
throw new ServiceException("未找到可排除的预警命中");
}
riskExclusionMapper.upsertExclusion(exclusion);
refreshOverviewEmployeeResults(exclusion.getProjectId(), exclusion.getUpdateBy());
}
@Override
public CcdiProjectRiskPeopleOverviewVO getRiskPeopleOverview(CcdiProjectRiskPeopleQueryDTO queryDTO) {
Long projectId = queryDTO.getProjectId();
@@ -188,6 +225,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
queryDTO.getStaffIdCard()
));
attachStatementHitTags(statementRows, queryDTO.getProjectId());
statementRows = statementRows.stream()
.filter(row -> row.getHitTags() != null && !row.getHitTags().isEmpty())
.toList();
normalizeObjectRows(objectRows);
CcdiProjectPersonAnalysisDetailVO detail = new CcdiProjectPersonAnalysisDetailVO();
@@ -335,6 +375,14 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
) {
ensureProjectExists(queryDTO.getProjectId());
normalizeSuspiciousTransactionQuery(queryDTO);
if (isSuspiciousTransactionScopeMismatch(queryDTO)) {
return emptySuspiciousTransactionPage();
}
prepareSuspiciousTransactionExternalBranch(queryDTO);
if (isExternalScopeWithoutSubject(queryDTO)) {
return emptySuspiciousTransactionPage();
}
Page<CcdiProjectSuspiciousTransactionItemVO> page = new Page<>(
defaultPageNum(queryDTO.getPageNum()),
@@ -342,9 +390,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
);
Page<CcdiProjectSuspiciousTransactionItemVO> resultPage =
overviewMapper.selectSuspiciousTransactionPage(page, queryDTO);
List<CcdiProjectSuspiciousTransactionItemVO> rows =
defaultList(resultPage == null ? null : resultPage.getRecords());
attachSuspiciousTransactionHitTags(rows, queryDTO);
CcdiProjectSuspiciousTransactionPageVO result = new CcdiProjectSuspiciousTransactionPageVO();
result.setRows(defaultList(resultPage == null ? null : resultPage.getRecords()));
result.setRows(rows);
result.setTotal(resultPage == null ? 0L : resultPage.getTotal());
return result;
}
@@ -355,6 +406,14 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
) {
ensureProjectExists(queryDTO.getProjectId());
normalizeSuspiciousTransactionQuery(queryDTO);
if (isSuspiciousTransactionScopeMismatch(queryDTO)) {
return List.of();
}
prepareSuspiciousTransactionExternalBranch(queryDTO);
if (isExternalScopeWithoutSubject(queryDTO)) {
return List.of();
}
return defaultList(overviewMapper.selectReportSuspiciousTransactionList(queryDTO)).stream()
.map(this::buildSuspiciousTransactionExcelRow)
@@ -419,9 +478,17 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
public void exportRiskDetails(HttpServletResponse response, Long projectId) {
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(projectId);
queryDTO.setSuspiciousType("ALL");
queryDTO.setSuspiciousType(SUSPICIOUS_TYPE_ALL);
exportRiskDetails(response, queryDTO);
}
@Override
public void exportRiskDetails(
HttpServletResponse response,
CcdiProjectSuspiciousTransactionQueryDTO queryDTO
) {
List<CcdiProjectSuspiciousTransactionExcel> suspiciousRows = exportSuspiciousTransactions(queryDTO);
Long projectId = queryDTO.getProjectId();
List<CcdiProjectEmployeeCreditNegativeExcel> creditRows = exportEmployeeCreditNegative(projectId);
List<CcdiProjectAbnormalAccountExcel> abnormalRows = exportAbnormalAccountPeople(projectId);
try {
@@ -608,6 +675,60 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
getRequiredProject(projectId);
}
private CcdiProjectRiskExclusion buildRiskExclusion(CcdiProjectRiskExclusionSaveDTO dto) {
if (dto == null) {
throw new ServiceException("排除参数不能为空");
}
String exclusionType = normalizeExclusionType(dto.getExclusionType());
String ruleCode = normalizeRequired(dto.getRuleCode(), "规则编码不能为空").toUpperCase();
String excludeReason = normalizeRequired(dto.getExcludeReason(), "排除原因不能为空");
if (excludeReason.length() > 1000) {
throw new ServiceException("排除原因不能超过1000个字符");
}
CcdiProjectRiskExclusion exclusion = new CcdiProjectRiskExclusion();
exclusion.setProjectId(dto.getProjectId());
exclusion.setRuleCode(ruleCode);
exclusion.setExclusionType(exclusionType);
exclusion.setExcludeReason(excludeReason);
exclusion.setStaffIdCard(normalizeBlankToNull(dto.getStaffIdCard()));
exclusion.setBankStatementId(dto.getBankStatementId());
if (EXCLUSION_TYPE_STATEMENT.equals(exclusionType) && exclusion.getBankStatementId() == null) {
throw new ServiceException("流水型排除必须指定流水ID");
}
if (EXCLUSION_TYPE_OBJECT.equals(exclusionType) && (exclusion.getStaffIdCard() == null || exclusion.getStaffIdCard().isBlank())) {
throw new ServiceException("对象型排除必须指定人员证件号");
}
String operator = SecurityUtils.getUsername();
Date now = new Date();
exclusion.setCreateBy(operator);
exclusion.setCreateTime(now);
exclusion.setUpdateBy(operator);
exclusion.setUpdateTime(now);
return exclusion;
}
private String normalizeExclusionType(String exclusionType) {
String type = normalizeRequired(exclusionType, "排除类型不能为空").toUpperCase();
if (!EXCLUSION_TYPE_STATEMENT.equals(type) && !EXCLUSION_TYPE_OBJECT.equals(type)) {
throw new ServiceException("排除类型不正确");
}
return type;
}
private String normalizeRequired(String value, String message) {
if (value == null || value.isBlank()) {
throw new ServiceException(message);
}
return value.trim();
}
private String normalizeBlankToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private void normalizeRiskModelPeopleQuery(CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
if (queryDTO.getMatchMode() == null || queryDTO.getMatchMode().isBlank()) {
queryDTO.setMatchMode("ANY");
@@ -625,13 +746,64 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
}
private void normalizeSuspiciousTransactionQuery(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
if (queryDTO.getModelCode() != null && !queryDTO.getModelCode().isBlank()) {
queryDTO.setModelCode(queryDTO.getModelCode().trim().toUpperCase());
} else {
queryDTO.setModelCode(null);
}
if (queryDTO.getSuspiciousType() == null || queryDTO.getSuspiciousType().isBlank()) {
queryDTO.setSuspiciousType("ALL");
queryDTO.setSuspiciousType(SUSPICIOUS_TYPE_ALL);
return;
}
queryDTO.setSuspiciousType(queryDTO.getSuspiciousType().trim().toUpperCase());
}
private void prepareSuspiciousTransactionExternalBranch(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
if (!shouldCheckExternalPersonBranch(queryDTO)) {
queryDTO.setIncludeExternalPerson(false);
return;
}
queryDTO.setIncludeExternalPerson(hasExternalPersonSubject(queryDTO.getProjectId()));
}
private boolean shouldCheckExternalPersonBranch(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
if (SUSPICIOUS_TYPE_EXTERNAL_PERSON.equals(queryDTO.getSuspiciousType())) {
return true;
}
return SUSPICIOUS_TYPE_ALL.equals(queryDTO.getSuspiciousType())
&& (queryDTO.getModelCode() == null || isExternalModelCode(queryDTO.getModelCode()));
}
private boolean hasExternalPersonSubject(Long projectId) {
return overviewMapper.selectExternalPersonSubjectExistsByProjectId(projectId) != null;
}
private boolean isExternalScopeWithoutSubject(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
return (SUSPICIOUS_TYPE_EXTERNAL_PERSON.equals(queryDTO.getSuspiciousType())
|| isExternalModelCode(queryDTO.getModelCode()))
&& !Boolean.TRUE.equals(queryDTO.getIncludeExternalPerson());
}
private boolean isSuspiciousTransactionScopeMismatch(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
if (queryDTO.getModelCode() == null) {
return false;
}
boolean externalModel = isExternalModelCode(queryDTO.getModelCode());
return (SUSPICIOUS_TYPE_MODEL_RULE.equals(queryDTO.getSuspiciousType()) && externalModel)
|| (SUSPICIOUS_TYPE_EXTERNAL_PERSON.equals(queryDTO.getSuspiciousType()) && !externalModel);
}
private boolean isExternalModelCode(String modelCode) {
return modelCode != null && modelCode.startsWith("EXTERNAL_");
}
private CcdiProjectSuspiciousTransactionPageVO emptySuspiciousTransactionPage() {
CcdiProjectSuspiciousTransactionPageVO result = new CcdiProjectSuspiciousTransactionPageVO();
result.setRows(List.of());
result.setTotal(0L);
return result;
}
private CcdiProjectOverviewStatVO buildStat(String key, String label, Integer value) {
CcdiProjectOverviewStatVO stat = new CcdiProjectOverviewStatVO();
stat.setKey(key);
@@ -733,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;
}
@@ -837,7 +1011,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = defaultList(
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(projectId, bankStatementIds)
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
projectId,
bankStatementIds,
null,
null
)
).stream().filter(item -> item.getBankStatementId() != null)
.collect(Collectors.groupingBy(
CcdiBankStatementHitTagVO::getBankStatementId,
@@ -849,6 +1028,39 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
)));
}
private void attachSuspiciousTransactionHitTags(
List<CcdiProjectSuspiciousTransactionItemVO> rows,
CcdiProjectSuspiciousTransactionQueryDTO queryDTO
) {
if (rows.isEmpty()) {
return;
}
List<Long> bankStatementIds = rows.stream()
.map(CcdiProjectSuspiciousTransactionItemVO::getBankStatementId)
.filter(item -> item != null)
.distinct()
.toList();
if (bankStatementIds.isEmpty()) {
return;
}
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = defaultList(
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
queryDTO.getProjectId(),
bankStatementIds,
queryDTO.getModelCode(),
queryDTO.getSuspiciousType()
)
).stream().filter(item -> item.getBankStatementId() != null)
.collect(Collectors.groupingBy(
CcdiBankStatementHitTagVO::getBankStatementId,
LinkedHashMap::new,
Collectors.toList()
));
rows.forEach(row -> row.setHitTags(new ArrayList<>(
hitTagMap.getOrDefault(row.getBankStatementId(), Collections.emptyList())
)));
}
private void normalizeObjectRows(List<CcdiProjectPersonAnalysisObjectRecordVO> objectRows) {
objectRows.forEach(row -> {
if (row.getRiskTags() == null) {

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

@@ -10,6 +10,8 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExtendedTransferDetailQueryD
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExtendedTransferQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectFamilyAssetLiabilityDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectFamilyAssetLiabilityListQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectIncreaseLendingQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectIncreaseLendingExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseListItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExtendedPurchaseListVO;
@@ -25,10 +27,13 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetLiabilityListItemV
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetLiabilityListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyDebtDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyIncomeDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectIncreaseLendingListItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectIncreaseLendingListVO;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectSpecialCheckMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectSpecialCheckService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.util.List;
@@ -178,6 +183,37 @@ public class CcdiProjectSpecialCheckServiceImpl implements ICcdiProjectSpecialCh
return detail;
}
@Override
public CcdiProjectIncreaseLendingListVO getIncreaseLendingList(CcdiProjectIncreaseLendingQueryDTO queryDTO) {
validateIncreaseLendingQuery(queryDTO);
Page<CcdiProjectIncreaseLendingListItemVO> page = new Page<>(
defaultPageNum(queryDTO.getPageNum()),
defaultPageSize(queryDTO.getPageSize())
);
Page<CcdiProjectIncreaseLendingListItemVO> resultPage = specialCheckMapper.selectIncreaseLendingPage(page, queryDTO);
CcdiProjectIncreaseLendingListVO result = new CcdiProjectIncreaseLendingListVO();
result.setRows(resultPage == null ? List.of() : defaultList(resultPage.getRecords()));
result.setTotal(resultPage == null ? 0L : resultPage.getTotal());
return result;
}
@Override
public List<CcdiProjectIncreaseLendingExcel> exportIncreaseLendingList(CcdiProjectIncreaseLendingQueryDTO queryDTO) {
validateIncreaseLendingQuery(queryDTO);
return defaultList(specialCheckMapper.selectIncreaseLendingExportList(queryDTO));
}
private void validateIncreaseLendingQuery(CcdiProjectIncreaseLendingQueryDTO queryDTO) {
ensureProjectExists(queryDTO.getProjectId());
if (StringUtils.isBlank(queryDTO.getStaffId())
&& StringUtils.isBlank(queryDTO.getStaffIdCard())
&& StringUtils.isBlank(queryDTO.getApprover())) {
throw new ServiceException("柜员号、员工身份证和审核人柜员号至少填写一项");
}
}
private void ensureProjectExists(Long projectId) {
CcdiProject project = projectMapper.selectById(projectId);
if (project == null) {
@@ -191,6 +227,7 @@ public class CcdiProjectSpecialCheckServiceImpl implements ICcdiProjectSpecialCh
incomeDetail.setSelfIncome(BigDecimal.ZERO);
incomeDetail.setSpouseIncome(BigDecimal.ZERO);
incomeDetail.setTotalIncome(BigDecimal.ZERO);
incomeDetail.setExplainableIncome(BigDecimal.ZERO);
detail.setIncomeDetail(incomeDetail);
}

View File

@@ -388,11 +388,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
staff.id_card AS idCard,
SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) AS annualAmount
from ccdi_bank_statement bs
inner join (
select
max(COALESCE(
STR_TO_DATE(LEFT(TRIM(anchor_bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(anchor_bs.TRX_DATE), 10), '%Y-%m-%d')
)) AS anchorDate
from ccdi_bank_statement anchor_bs
where anchor_bs.project_id = #{projectId}
) project_anchor on project_anchor.anchorDate is not null
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.LE_ACCOUNT_NAME, '') &lt;&gt; IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
and <include refid="financialProductExclusionPredicate"/>
and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
and COALESCE(
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d')
) >= DATE_SUB(project_anchor.anchorDate, INTERVAL 12 MONTH)
group by staff.id_card
having SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
) t
@@ -541,11 +553,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
max(IFNULL(bs.LE_ACCOUNT_NAME, '')) AS personName,
ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) AS annualAmount
from ccdi_bank_statement bs
inner join (
select
max(COALESCE(
STR_TO_DATE(LEFT(TRIM(anchor_bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(anchor_bs.TRX_DATE), 10), '%Y-%m-%d')
)) AS anchorDate
from ccdi_bank_statement anchor_bs
where anchor_bs.project_id = #{projectId}
) project_anchor on project_anchor.anchorDate is not null
where bs.project_id = #{projectId}
and <include refid="externalPersonPredicateSql"/>
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
and <include refid="financialProductExclusionPredicate"/>
and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
and COALESCE(
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s'),
STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d')
) >= DATE_SUB(project_anchor.anchorDate, INTERVAL 12 MONTH)
group by bs.cret_no
having ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) > #{threshold}
) t
@@ -775,12 +799,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
CONCAT(
'摘要/对手命中赌博敏感词,摘要“', IFNULL(bs.USER_MEMO, ''),
'”,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,支出金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR), ' 元'
'”,支出金额 ', CAST(IFNULL(bs.AMOUNT_DR, 0) AS CHAR),
' 元,达到敏感交易最低金额 200 元'
) AS reasonDetail
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > 0
and IFNULL(bs.AMOUNT_DR, 0) >= 200
and (
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
@@ -796,12 +821,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
'外部人员“', IFNULL(bs.LE_ACCOUNT_NAME, ''),
'”摘要/对手方命中疑似赌博关键词,摘要“', IFNULL(bs.USER_MEMO, ''),
'”,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
'”,交易金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR), ' 元'
'”,交易金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
' 元,达到敏感交易最低金额 200 元'
) AS reasonDetail
from ccdi_bank_statement bs
where bs.project_id = #{projectId}
and <include refid="externalPersonPredicateSql"/>
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) >= 200
and (
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|牌局|捕鱼|电子游艺|VIP666|USDT下注'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|牌局|捕鱼|电子游艺|VIP666|USDT下注'
@@ -1152,7 +1178,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
CONCAT(
'物业缴费金额 ', CAST(trade.amountDr AS CHAR),
' 元,对手方“', IFNULL(trade.customerAccountName, ''),
'”,证件号 ', trade.personId, ' 名下无房产登记'
'”,证件号 ', trade.personId, ' 名下无匹配房产登记'
) AS reasonDetail
from (
select
@@ -1161,14 +1187,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.group_id AS groupId,
bs.batch_id AS logId,
IFNULL(bs.AMOUNT_DR, 0) AS amountDr,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName,
bs.USER_MEMO AS userMemo
from ccdi_bank_statement bs
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
where bs.project_id = #{projectId}
and IFNULL(bs.AMOUNT_DR, 0) > 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '物业|物业费|管理费|物业服务|综合服务'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区'
IFNULL(bs.USER_MEMO, '') REGEXP '物业|物管|业委会|业主委员会|维修基金|住宅专项维修资金|房屋维修资金'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '物业|物管|业委会|业主委员会|维修基金|住宅专项维修资金|房屋维修资金'
)
union all
@@ -1179,26 +1206,39 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
bs.group_id AS groupId,
bs.batch_id AS logId,
IFNULL(bs.AMOUNT_DR, 0) AS amountDr,
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName,
bs.USER_MEMO AS userMemo
from ccdi_bank_statement bs
inner join ccdi_staff_fmy_relation relation on relation.relation_cert_no = bs.cret_no
where bs.project_id = #{projectId}
and relation.status = 1
and IFNULL(bs.AMOUNT_DR, 0) > 0
and (
IFNULL(bs.USER_MEMO, '') REGEXP '物业|物业费|管理费|物业服务|综合服务'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区'
IFNULL(bs.USER_MEMO, '') REGEXP '物业|物管|业委会|业主委员会|维修基金|住宅专项维修资金|房屋维修资金'
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '物业|物管|业委会|业主委员会|维修基金|住宅专项维修资金|房屋维修资金'
)
) trade
left join (
select distinct
asset.person_id AS personId
asset.person_id AS personId,
asset.asset_name AS assetName
from ccdi_asset_info asset
where asset.asset_main_type = '房产'
and asset.asset_sub_type = '住宅'
and asset.asset_status = '正常'
and trim(IFNULL(asset.asset_name, '')) != ''
) asset
on asset.personId = trade.personId
and (
(
trim(IFNULL(trade.customerAccountName, '')) != ''
and IFNULL(trade.customerAccountName, '') LIKE concat('%', asset.assetName, '%')
)
or (
trim(IFNULL(trade.userMemo, '')) != ''
and IFNULL(trade.userMemo, '') LIKE concat('%', asset.assetName, '%')
)
)
where asset.personId is null
</select>
@@ -1690,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">
@@ -1710,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,
@@ -1717,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,
@@ -1734,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

@@ -30,6 +30,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap>
<resultMap id="CcdiBankStatementHitTagVOResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO">
<result property="modelCode" column="model_code"/>
<result property="ruleCode" column="rule_code"/>
<result property="ruleName" column="rule_name"/>
<result property="riskLevel" column="risk_level"/>
@@ -47,6 +48,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectStatementTagsByProjectAndStatementIds" resultMap="CcdiBankStatementHitTagVOResultMap">
select
model_code,
rule_code,
rule_name,
risk_level,
@@ -55,10 +57,29 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from ccdi_bank_statement_tag_result
where project_id = #{projectId}
and bank_statement_id is not null
and not exists (
select 1
from ccdi_project_risk_exclusion ex
where ex.project_id = ccdi_bank_statement_tag_result.project_id
and ex.rule_code = ccdi_bank_statement_tag_result.rule_code
and ex.exclusion_type = 'STATEMENT'
and ex.bank_statement_id = ccdi_bank_statement_tag_result.bank_statement_id
)
and bank_statement_id IN
<foreach collection="bankStatementIds" item="item" open="(" separator="," close=")">
#{item}
</foreach>
<if test="modelCode != null and modelCode != ''">
and model_code = #{modelCode}
</if>
<choose>
<when test="suspiciousType == 'MODEL_RULE'">
and left(model_code, 9) != 'EXTERNAL_'
</when>
<when test="suspiciousType == 'EXTERNAL_PERSON'">
and left(model_code, 9) = 'EXTERNAL_'
</when>
</choose>
order by bank_statement_id asc, id asc
</select>

View File

@@ -79,6 +79,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
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
and not exists (
select 1
from ccdi_project_risk_exclusion ex
where ex.project_id = tr.project_id
and ex.rule_code = tr.rule_code
and (
(ex.exclusion_type = 'STATEMENT'
and ex.bank_statement_id = tr.bank_statement_id)
or (ex.exclusion_type = 'OBJECT'
and ex.staff_id_card = coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card))
)
)
</sql>
<delete id="deleteByProjectId">

View File

@@ -68,6 +68,10 @@
<resultMap id="SuspiciousTransactionItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO">
<id property="bankStatementId" column="bankStatementId"/>
<result property="trxDate" column="trxDate"/>
<result property="leAccountNo" column="leAccountNo"/>
<result property="leAccountName" column="leAccountName"/>
<result property="customerAccountName" column="customerAccountName"/>
<result property="customerAccountNo" column="customerAccountNo"/>
<result property="suspiciousPersonName" column="suspiciousPersonName"/>
<result property="relatedPersonName" column="relatedPersonName"/>
<result property="relatedStaffName" column="relatedStaffName"/>
@@ -88,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"
@@ -187,6 +193,18 @@
on relation.person_id = family_staff.id_card
where tr.project_id = #{projectId}
and coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card) is not null
and not exists (
select 1
from ccdi_project_risk_exclusion ex
where ex.project_id = tr.project_id
and ex.rule_code = tr.rule_code
and (
(ex.exclusion_type = 'STATEMENT'
and ex.bank_statement_id = tr.bank_statement_id)
or (ex.exclusion_type = 'OBJECT'
and ex.staff_id_card = coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card))
)
)
</sql>
<sql id="employeeRiskAggregateSql">
@@ -662,6 +680,14 @@
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
and counter_intermediary.name = trim(bs.CUSTOMER_ACCOUNT_NAME)
where trim(ifnull(bs.LE_ACCOUNT_NAME, '')) != trim(ifnull(bs.CUSTOMER_ACCOUNT_NAME, ''))
and not exists (
select 1
from ccdi_project_risk_exclusion ex
where ex.project_id = tr.project_id
and ex.rule_code = tr.rule_code
and ex.exclusion_type = 'STATEMENT'
and ex.bank_statement_id = tr.bank_statement_id
)
union all
@@ -689,6 +715,14 @@
and tr.object_type = 'EXTERNAL_CERT_NO'
and tr.object_key = subject.cert_no
and tr.model_code in <include refid="externalModelCodeFilterSql"/>
where not exists (
select 1
from ccdi_project_risk_exclusion ex
where ex.project_id = tr.project_id
and ex.rule_code = tr.rule_code
and ex.exclusion_type = 'OBJECT'
and ex.staff_id_card = subject.cert_no
)
</sql>
<sql id="externalPersonAggregateSql">
@@ -783,6 +817,22 @@
and risk.cert_no = subject.cert_no
</select>
<select id="selectExternalPersonSubjectExistsByProjectId" resultType="java.lang.Integer">
select 1
from ccdi_bank_statement bs
left join ccdi_base_staff staff
on staff.id_card = bs.cret_no
left join ccdi_staff_fmy_relation relation
on relation.status = 1
and relation.relation_cert_no = bs.cret_no
where bs.project_id = #{projectId}
and bs.cret_no is not null
and trim(bs.cret_no) != ''
and staff.id_card is null
and relation.relation_cert_no is null
limit 1
</select>
<select id="selectExternalRiskModelCardsByProjectId" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO">
<bind name="externalProjectId" value="projectId"/>
select
@@ -972,6 +1022,29 @@
from ccdi_bank_statement_tag_result tr
where tr.project_id = #{query.projectId}
and tr.bank_statement_id is not null
and not exists (
select 1
from ccdi_project_risk_exclusion ex
where ex.project_id = tr.project_id
and ex.rule_code = tr.rule_code
and ex.exclusion_type = 'STATEMENT'
and ex.bank_statement_id = tr.bank_statement_id
)
<include refid="suspiciousTransactionModelScopeSql"/>
</sql>
<sql id="suspiciousTransactionModelScopeSql">
<if test="query.modelCode != null and query.modelCode != ''">
and tr.model_code = #{query.modelCode}
</if>
<choose>
<when test="query.suspiciousType == 'MODEL_RULE'">
and left(tr.model_code, 9) != 'EXTERNAL_'
</when>
<when test="query.suspiciousType == 'EXTERNAL_PERSON'">
and left(tr.model_code, 9) = 'EXTERNAL_'
</when>
</choose>
</sql>
<sql id="suspiciousTransactionNameHitSql">
@@ -1122,26 +1195,28 @@
<include refid="suspiciousTransactionNameHitSql"/>
) name_hits on name_hits.bankStatementId = base.bankStatementId
union all
<if test="query.includeExternalPerson == true">
union all
select
external_hits.bankStatementId,
external_hits.trxDate,
external_hits.relatedPersonName,
external_hits.relatedStaffName,
external_hits.relatedStaffCode,
external_hits.relationType,
external_hits.userMemo,
external_hits.cashType,
external_hits.displayAmount,
external_hits.hasModelRuleHit,
external_hits.hasNameListHit,
external_hits.suspiciousPersonName,
external_hits.matchPriority,
external_hits.nameListHitType
from (
<include refid="externalSuspiciousTransactionSql"/>
) external_hits
select
external_hits.bankStatementId,
external_hits.trxDate,
external_hits.relatedPersonName,
external_hits.relatedStaffName,
external_hits.relatedStaffCode,
external_hits.relationType,
external_hits.userMemo,
external_hits.cashType,
external_hits.displayAmount,
external_hits.hasModelRuleHit,
external_hits.hasNameListHit,
external_hits.suspiciousPersonName,
external_hits.matchPriority,
external_hits.nameListHitType
from (
<include refid="externalSuspiciousTransactionSql"/>
) external_hits
</if>
</sql>
<sql id="suspiciousTransactionAggregatedSql">
@@ -1203,6 +1278,23 @@
where final_result.hasModelRuleHit = 1 or final_result.hasNameListHit = 1
</otherwise>
</choose>
<if test="query.modelCode != null and query.modelCode != ''">
and exists (
select 1
from ccdi_bank_statement_tag_result model_filter
where model_filter.project_id = #{query.projectId}
and model_filter.bank_statement_id = final_result.bankStatementId
and model_filter.model_code = #{query.modelCode}
and not exists (
select 1
from ccdi_project_risk_exclusion ex
where ex.project_id = model_filter.project_id
and ex.rule_code = model_filter.rule_code
and ex.exclusion_type = 'STATEMENT'
and ex.bank_statement_id = model_filter.bank_statement_id
)
)
</if>
</sql>
<select id="selectSuspiciousTransactionPage" resultMap="SuspiciousTransactionItemResultMap">
@@ -1213,6 +1305,10 @@
select
final_result.bankStatementId,
final_result.trxDate,
bs.LE_ACCOUNT_NO as leAccountNo,
bs.LE_ACCOUNT_NAME as leAccountName,
bs.CUSTOMER_ACCOUNT_NAME as customerAccountName,
bs.CUSTOMER_ACCOUNT_NO as customerAccountNo,
final_result.suspiciousPersonName,
final_result.relatedPersonName,
final_result.relatedStaffName,
@@ -1227,6 +1323,8 @@
from (
<include refid="suspiciousTransactionAggregatedSql"/>
) final_result
inner join ccdi_bank_statement bs
on bs.bank_statement_id = final_result.bankStatementId
<include refid="suspiciousTransactionFilterSql"/>
order by final_result.trxDate desc, final_result.bankStatementId desc
</select>
@@ -1235,6 +1333,10 @@
select
final_result.bankStatementId,
final_result.trxDate,
bs.LE_ACCOUNT_NO as leAccountNo,
bs.LE_ACCOUNT_NAME as leAccountName,
bs.CUSTOMER_ACCOUNT_NAME as customerAccountName,
bs.CUSTOMER_ACCOUNT_NO as customerAccountNo,
final_result.suspiciousPersonName,
final_result.relatedPersonName,
final_result.relatedStaffName,
@@ -1249,6 +1351,8 @@
from (
<include refid="suspiciousTransactionAggregatedSql"/>
) final_result
inner join ccdi_bank_statement bs
on bs.bank_statement_id = final_result.bankStatementId
<include refid="suspiciousTransactionFilterSql"/>
order by final_result.trxDate desc, final_result.bankStatementId desc
</select>
@@ -1293,6 +1397,15 @@
from ccdi_bank_statement_tag_result tr
where tr.project_id = #{query.projectId}
and tr.bank_statement_id is not null
and not exists (
select 1
from ccdi_project_risk_exclusion ex
where ex.project_id = tr.project_id
and ex.rule_code = tr.rule_code
and ex.exclusion_type = 'STATEMENT'
and ex.bank_statement_id = tr.bank_statement_id
)
<include refid="suspiciousTransactionModelScopeSql"/>
group by tr.bank_statement_id
) tag_result on tag_result.bank_statement_id = final_result.bankStatementId
<include refid="suspiciousTransactionFilterSql"/>
@@ -1362,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'
@@ -1389,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}
@@ -1412,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}
@@ -1522,6 +1644,7 @@
<select id="selectPersonAnalysisObjectRows" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO">
select
max(tr.model_code) as modelCode,
tr.rule_code as ruleCode,
coalesce(max(staff.name), max(relation.relation_name), max(tr.object_key), max(tr.object_type)) as title,
max(case
when tr.object_type = 'STAFF_ID_CARD' then '员工对象'
@@ -1538,6 +1661,14 @@
and tr.object_key = relation.relation_cert_no
where tr.project_id = #{projectId}
and tr.bank_statement_id is null
and not exists (
select 1
from ccdi_project_risk_exclusion ex
where ex.project_id = tr.project_id
and ex.rule_code = tr.rule_code
and ex.exclusion_type = 'OBJECT'
and ex.staff_id_card = #{staffIdCard}
)
and (
tr.object_key = #{staffIdCard}
or exists (

View File

@@ -0,0 +1,79 @@
<?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.CcdiProjectRiskExclusionMapper">
<resultMap id="CcdiProjectRiskExclusionMap"
type="com.ruoyi.ccdi.project.domain.entity.CcdiProjectRiskExclusion">
<id property="id" column="id"/>
<result property="projectId" column="project_id"/>
<result property="staffIdCard" column="staff_id_card"/>
<result property="ruleCode" column="rule_code"/>
<result property="exclusionType" column="exclusion_type"/>
<result property="bankStatementId" column="bank_statement_id"/>
<result property="excludeReason" column="exclude_reason"/>
<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>
<insert id="upsertExclusion">
insert into ccdi_project_risk_exclusion (
project_id, staff_id_card, rule_code, exclusion_type, bank_statement_id,
exclude_reason, create_by, create_time, update_by, update_time, remark
) values (
#{exclusion.projectId}, #{exclusion.staffIdCard}, #{exclusion.ruleCode},
#{exclusion.exclusionType}, #{exclusion.bankStatementId}, #{exclusion.excludeReason},
#{exclusion.createBy}, #{exclusion.createTime}, #{exclusion.updateBy},
#{exclusion.updateTime}, #{exclusion.remark}
)
on duplicate key update
exclude_reason = values(exclude_reason),
update_by = values(update_by),
update_time = values(update_time),
remark = values(remark)
</insert>
<select id="countExistingRiskHit" resultType="int">
select count(1)
from ccdi_bank_statement_tag_result tr
where tr.project_id = #{exclusion.projectId}
and tr.rule_code = #{exclusion.ruleCode}
<choose>
<when test="exclusion.exclusionType == 'STATEMENT'">
and tr.bank_statement_id = #{exclusion.bankStatementId}
</when>
<otherwise>
and (
tr.object_key = #{exclusion.staffIdCard}
or exists (
select 1
from ccdi_bank_statement bs
where bs.bank_statement_id = tr.bank_statement_id
and bs.cret_no = #{exclusion.staffIdCard}
)
or exists (
select 1
from ccdi_staff_fmy_relation relation
where relation.status = 1
and relation.person_id = #{exclusion.staffIdCard}
and relation.relation_cert_no = tr.object_key
)
or exists (
select 1
from ccdi_staff_fmy_relation relation
inner join ccdi_bank_statement bs
on bs.cret_no = relation.relation_cert_no
where relation.status = 1
and relation.person_id = #{exclusion.staffIdCard}
and bs.bank_statement_id = tr.bank_statement_id
)
)
</otherwise>
</choose>
</select>
</mapper>

View File

@@ -9,9 +9,14 @@
<result property="staffName" column="staff_name"/>
<result property="deptName" column="dept_name"/>
<result property="totalIncome" column="total_income"/>
<result property="selfIncome" column="self_income"/>
<result property="spouseIncome" column="spouse_income"/>
<result property="employmentYears" column="employment_years"/>
<result property="totalAsset" column="total_asset"/>
<result property="totalDebt" column="total_debt"/>
<result property="comparisonAmount" column="comparison_amount"/>
<result property="explainableIncome" column="explainable_income"/>
<result property="assetIncomeRatio" column="asset_income_ratio"/>
<result property="riskLevelCode" column="risk_level_code"/>
<result property="riskLevelName" column="risk_level_name"/>
</resultMap>
@@ -67,13 +72,37 @@
<result property="transferDate" column="transfer_date"/>
</resultMap>
<resultMap id="IncreaseLendingListItemResultMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectIncreaseLendingListItemVO">
<result property="staffId" column="staff_id"/>
<result property="staffName" column="staff_name"/>
<result property="staffIdCard" column="staff_id_card"/>
<result property="deptName" column="dept_name"/>
<result property="contractNo" column="contract_no"/>
<result property="lendingOrgNo" column="lending_org_no"/>
<result property="borrowerName" column="borrower_name"/>
<result property="borrowerCertNo" column="borrower_cert_no"/>
<result property="loanProduct" column="loan_product"/>
<result property="contractAmount" column="contract_amount"/>
<result property="loanBalance" column="loan_balance"/>
<result property="loanStartDate" column="loan_start_date"/>
<result property="loanEndDate" column="loan_end_date"/>
<result property="status" column="status"/>
<result property="fiveClassification" column="five_classification"/>
<result property="customerManagerId" column="customer_manager_id"/>
<result property="customerManagerName" column="customer_manager_name"/>
<result property="approver" column="approver"/>
</resultMap>
<resultMap id="FamilyAssetLiabilityDetailResultMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetLiabilityDetailVO">
<association property="incomeDetail"
javaType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyIncomeDetailVO">
<result property="selfIncome" column="income_self_income"/>
<result property="spouseIncome" column="income_spouse_income"/>
<result property="employmentYears" column="income_employment_years"/>
<result property="totalIncome" column="income_total_income"/>
<result property="explainableIncome" column="income_explainable_income"/>
</association>
<association property="assetDetail"
javaType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetDetailVO">
@@ -104,9 +133,14 @@
<result property="staffName" column="summary_staff_name"/>
<result property="deptName" column="summary_dept_name"/>
<result property="totalIncome" column="summary_total_income"/>
<result property="selfIncome" column="summary_self_income"/>
<result property="spouseIncome" column="summary_spouse_income"/>
<result property="employmentYears" column="summary_employment_years"/>
<result property="totalAsset" column="summary_total_asset"/>
<result property="totalDebt" column="summary_total_debt"/>
<result property="comparisonAmount" column="summary_comparison_amount"/>
<result property="explainableIncome" column="summary_explainable_income"/>
<result property="assetIncomeRatio" column="summary_asset_income_ratio"/>
<result property="riskLevelCode" column="summary_risk_level_code"/>
<result property="riskLevelName" column="summary_risk_level_name"/>
</association>
@@ -172,44 +206,58 @@
aggregated.staff_name,
aggregated.dept_name,
aggregated.total_income,
aggregated.self_income,
aggregated.spouse_income,
aggregated.employment_years,
aggregated.total_asset,
aggregated.total_debt,
aggregated.comparison_amount,
aggregated.explainable_income,
aggregated.asset_income_ratio,
case
when aggregated.missing_asset_info = 1 or aggregated.missing_debt_info = 1 then 'MISSING_INFO'
when comparison_amount &lt;= total_asset * 1.5 then 'NORMAL'
when comparison_amount &gt; total_asset * 1.5 and comparison_amount &lt;= total_asset * 3 then 'RISK'
when comparison_amount &gt; total_asset * 3 then 'HIGH'
when aggregated.missing_info = 1 then 'MISSING_INFO'
when aggregated.asset_income_ratio &lt;= 1.5 then 'NORMAL'
when aggregated.asset_income_ratio &gt; 1.5 and aggregated.asset_income_ratio &lt;= 3 then 'RISK'
when aggregated.asset_income_ratio &gt; 3 then 'HIGH'
else 'HIGH'
end as risk_level_code,
case
when aggregated.missing_asset_info = 1 or aggregated.missing_debt_info = 1 then '缺少信息'
when comparison_amount &lt;= total_asset * 1.5 then '正常'
when comparison_amount &gt; total_asset * 1.5 and comparison_amount &lt;= total_asset * 3 then '存在风险'
when comparison_amount &gt; total_asset * 3 then '高风险'
else '高风险'
when aggregated.missing_info = 1 then '缺少信息'
when aggregated.asset_income_ratio &lt;= 1.5 then '正常'
when aggregated.asset_income_ratio &gt; 1.5 and aggregated.asset_income_ratio &lt;= 3 then '关注'
when aggregated.asset_income_ratio &gt; 3 then '高关注'
else '高关注'
end as risk_level_name
from (
select
source.*,
case
when source.self_asset_record_count = 0
or source.spouse_staff_asset_record_count = 0 then 1
when source.self_income &lt;= 0
or source.employment_years is null
or source.total_asset &lt;= 0 then 1
else 0
end as missing_asset_info,
end as missing_info,
source.self_income * source.employment_years
+ source.spouse_income
+ source.total_debt as explainable_income,
source.self_income * source.employment_years
+ source.spouse_income
+ source.total_debt as comparison_amount,
case
when source.self_debt_record_count = 0
or source.spouse_staff_debt_record_count = 0 then 1
else 0
end as missing_debt_info,
when source.self_income &lt;= 0
or source.employment_years is null
or source.total_asset &lt;= 0
or (source.self_income * source.employment_years + source.spouse_income + source.total_debt) &lt;= 0 then null
else source.total_asset / (source.self_income * source.employment_years + source.spouse_income + source.total_debt)
end as asset_income_ratio,
case
when source.self_asset_record_count = 0
or source.spouse_staff_asset_record_count = 0
or source.self_debt_record_count = 0
or source.spouse_staff_debt_record_count = 0 then 4
when source.comparison_amount &lt;= source.total_asset * 1.5 then 1
when source.comparison_amount &lt;= source.total_asset * 3 then 2
when source.comparison_amount &gt; source.total_asset * 3 then 3
when source.self_income &lt;= 0
or source.employment_years is null
or source.total_asset &lt;= 0
or (source.self_income * source.employment_years + source.spouse_income + source.total_debt) &lt;= 0 then 0
when source.total_asset / (source.self_income * source.employment_years + source.spouse_income + source.total_debt) &lt;= 1.5 then 1
when source.total_asset / (source.self_income * source.employment_years + source.spouse_income + source.total_debt) &lt;= 3 then 2
when source.total_asset / (source.self_income * source.employment_years + source.spouse_income + source.total_debt) &gt; 3 then 3
else 3
end as risk_level_sort
from (
@@ -218,6 +266,12 @@
scope.staff_code,
scope.staff_name,
scope.dept_name,
coalesce(base_staff.annual_income, 0) as self_income,
coalesce(spouse.spouse_income, 0) as spouse_income,
case
when base_staff.hire_date is null then null
else greatest(1, ceiling(datediff(curdate(), base_staff.hire_date) / 365))
end as employment_years,
coalesce(base_staff.annual_income, 0) + coalesce(spouse.spouse_income, 0) as total_income,
coalesce((
select count(1)
@@ -272,15 +326,7 @@
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as total_debt,
coalesce(base_staff.annual_income, 0)
+ coalesce(spouse.spouse_income, 0)
+ coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as comparison_amount
), 0) as total_debt
from (
<include refid="projectEmployeeScopeSql"/>
) scope
@@ -306,7 +352,9 @@
aggregated.dept_name,
aggregated.self_income as income_self_income,
aggregated.spouse_income as income_spouse_income,
aggregated.employment_years as income_employment_years,
aggregated.total_income as income_total_income,
aggregated.explainable_income as income_explainable_income,
aggregated.missing_self_asset_info as asset_missing_self_asset_info,
aggregated.self_total_asset as asset_self_total_asset,
aggregated.spouse_total_asset as asset_spouse_total_asset,
@@ -320,36 +368,61 @@
aggregated.staff_name as summary_staff_name,
aggregated.dept_name as summary_dept_name,
aggregated.total_income as summary_total_income,
aggregated.self_income as summary_self_income,
aggregated.spouse_income as summary_spouse_income,
aggregated.employment_years as summary_employment_years,
aggregated.total_asset as summary_total_asset,
aggregated.total_debt as summary_total_debt,
aggregated.comparison_amount as summary_comparison_amount,
aggregated.explainable_income as summary_explainable_income,
aggregated.asset_income_ratio as summary_asset_income_ratio,
case
when aggregated.missing_self_asset_info = 1 or aggregated.missing_self_debt_info = 1 then 'MISSING_INFO'
when comparison_amount &lt;= total_asset * 1.5 then 'NORMAL'
when comparison_amount &gt; total_asset * 1.5 and comparison_amount &lt;= total_asset * 3 then 'RISK'
when comparison_amount &gt; total_asset * 3 then 'HIGH'
when aggregated.missing_info = 1 then 'MISSING_INFO'
when aggregated.asset_income_ratio &lt;= 1.5 then 'NORMAL'
when aggregated.asset_income_ratio &gt; 1.5 and aggregated.asset_income_ratio &lt;= 3 then 'RISK'
when aggregated.asset_income_ratio &gt; 3 then 'HIGH'
else 'HIGH'
end as summary_risk_level_code,
case
when aggregated.missing_self_asset_info = 1 or aggregated.missing_self_debt_info = 1 then '缺少信息'
when comparison_amount &lt;= total_asset * 1.5 then '正常'
when comparison_amount &gt; total_asset * 1.5 and comparison_amount &lt;= total_asset * 3 then '存在风险'
when comparison_amount &gt; total_asset * 3 then '高风险'
else '高风险'
when aggregated.missing_info = 1 then '缺少信息'
when aggregated.asset_income_ratio &lt;= 1.5 then '正常'
when aggregated.asset_income_ratio &gt; 1.5 and aggregated.asset_income_ratio &lt;= 3 then '关注'
when aggregated.asset_income_ratio &gt; 3 then '高关注'
else '高关注'
end as summary_risk_level_name
from (
select
source.*,
case
when source.self_asset_record_count = 0
or source.spouse_staff_asset_record_count = 0 then 1
when source.self_income &lt;= 0
or source.employment_years is null
or source.total_asset &lt;= 0 then 1
else 0
end as missing_self_asset_info,
case
when source.self_debt_record_count = 0
or source.spouse_staff_debt_record_count = 0 then 1
else 0
end as missing_self_debt_info
end as missing_self_debt_info,
case
when source.self_income &lt;= 0
or source.employment_years is null
or source.total_asset &lt;= 0 then 1
else 0
end as missing_info,
source.self_income * source.employment_years
+ source.spouse_income
+ source.total_debt as explainable_income,
source.self_income * source.employment_years
+ source.spouse_income
+ source.total_debt as comparison_amount,
case
when source.self_income &lt;= 0
or source.employment_years is null
or source.total_asset &lt;= 0
or (source.self_income * source.employment_years + source.spouse_income + source.total_debt) &lt;= 0 then null
else source.total_asset / (source.self_income * source.employment_years + source.spouse_income + source.total_debt)
end as asset_income_ratio
from (
select
#{projectId} as project_id,
@@ -361,6 +434,10 @@
spouse.spouse_is_staff,
coalesce(base_staff.annual_income, 0) as self_income,
coalesce(spouse.spouse_income, 0) as spouse_income,
case
when base_staff.hire_date is null then null
else greatest(1, ceiling(datediff(curdate(), base_staff.hire_date) / 365))
end as employment_years,
coalesce(base_staff.annual_income, 0) + coalesce(spouse.spouse_income, 0) as total_income,
coalesce((
select count(1)
@@ -449,15 +526,7 @@
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as total_debt,
coalesce(base_staff.annual_income, 0)
+ coalesce(spouse.spouse_income, 0)
+ coalesce((
select sum(coalesce(debt.principal_balance, 0))
from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as comparison_amount
), 0) as total_debt
from (
<include refid="projectEmployeeScopeSql"/>
) scope
@@ -806,4 +875,95 @@
)
</select>
<select id="selectIncreaseLendingPage" resultMap="IncreaseLendingListItemResultMap">
select
trim(loan.customer_manager_id) as staff_id,
coalesce(staff.name, loan.customer_manager_name) as staff_name,
staff.id_card as staff_id_card,
dept.dept_name,
loan.nfaacono as contract_no,
loan.nfaabrno as lending_org_no,
loan.borrower_name,
loan.borrower_cert_no,
loan.loan_product,
loan.contract_amount,
loan.loan_balance,
date_format(loan.loan_start_date, '%Y-%m-%d') as loan_start_date,
date_format(loan.loan_end_date, '%Y-%m-%d') as loan_end_date,
loan.status,
loan.five_classification,
loan.customer_manager_id,
loan.customer_manager_name,
loan.approver
from ccdi_increase_lending loan
left join ccdi_base_staff staff
on trim(loan.customer_manager_id) = cast(staff.staff_id as char)
left join sys_dept dept
on dept.dept_id = staff.dept_id
<where>
<if test="query.staffId != null and query.staffId != ''">
and trim(loan.customer_manager_id) = #{query.staffId}
</if>
<if test="query.staffIdCard != null and query.staffIdCard != ''">
and staff.id_card = #{query.staffIdCard}
</if>
<if test="query.approver != null and query.approver != ''">
and trim(loan.approver) = #{query.approver}
</if>
<if test="query.loanStartDate != null and query.loanStartDate != ''">
and loan.loan_start_date &gt;= #{query.loanStartDate}
</if>
<if test="query.loanEndDate != null and query.loanEndDate != ''">
and loan.loan_start_date &lt;= #{query.loanEndDate}
</if>
</where>
order by loan.loan_start_date desc, loan.nfaacono desc
</select>
<select id="selectIncreaseLendingExportList"
resultType="com.ruoyi.ccdi.project.domain.excel.CcdiProjectIncreaseLendingExcel">
select
trim(loan.customer_manager_id) as staff_id,
coalesce(staff.name, loan.customer_manager_name) as staff_name,
staff.id_card as staff_id_card,
dept.dept_name,
loan.nfaacono as contract_no,
loan.nfaabrno as lending_org_no,
loan.borrower_name,
loan.borrower_cert_no,
loan.loan_product,
loan.contract_amount,
loan.loan_balance,
date_format(loan.loan_start_date, '%Y-%m-%d') as loan_start_date,
date_format(loan.loan_end_date, '%Y-%m-%d') as loan_end_date,
loan.status,
loan.five_classification,
loan.customer_manager_id,
loan.customer_manager_name,
loan.approver
from ccdi_increase_lending loan
left join ccdi_base_staff staff
on trim(loan.customer_manager_id) = cast(staff.staff_id as char)
left join sys_dept dept
on dept.dept_id = staff.dept_id
<where>
<if test="query.staffId != null and query.staffId != ''">
and trim(loan.customer_manager_id) = #{query.staffId}
</if>
<if test="query.staffIdCard != null and query.staffIdCard != ''">
and staff.id_card = #{query.staffIdCard}
</if>
<if test="query.approver != null and query.approver != ''">
and trim(loan.approver) = #{query.approver}
</if>
<if test="query.loanStartDate != null and query.loanStartDate != ''">
and loan.loan_start_date &gt;= #{query.loanStartDate}
</if>
<if test="query.loanEndDate != null and query.loanEndDate != ''">
and loan.loan_start_date &lt;= #{query.loanEndDate}
</if>
</where>
order by loan.loan_start_date desc, loan.nfaacono desc
</select>
</mapper>

View File

@@ -7,6 +7,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.service.ICcdiBankStatementService;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import org.junit.jupiter.api.AfterEach;
@@ -39,6 +40,9 @@ class CcdiBankStatementControllerTest {
@Mock
private ICcdiBankStatementService bankStatementService;
@Mock
private CcdiProjectAccessService projectAccessService;
@AfterEach
void tearDown() {
RequestContextHolder.resetRequestAttributes();
@@ -80,12 +84,17 @@ class CcdiBankStatementControllerTest {
void detail_shouldReturnAjaxResultSuccess() {
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();
detailVO.setBankStatementId(1000L);
when(bankStatementService.getStatementDetail(1000L)).thenReturn(detailVO);
when(bankStatementService.getStatementDetail(
1000L, "SUSPICIOUS_GAMBLING", "MODEL_RULE"
)).thenReturn(detailVO);
AjaxResult result = controller.getDetail(1000L);
AjaxResult result = controller.getDetail(1000L, "SUSPICIOUS_GAMBLING", "MODEL_RULE");
assertEquals(200, result.get("code"));
assertEquals(detailVO, result.get("data"));
verify(bankStatementService).getStatementDetail(
1000L, "SUSPICIOUS_GAMBLING", "MODEL_RULE"
);
}
@Test

View File

@@ -1,23 +1,29 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiPullBankInfoSubmitDTO;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.SecurityUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -31,6 +37,14 @@ class CcdiFileUploadControllerTest {
@Mock
private ICcdiFileUploadService fileUploadService;
@Mock
private CcdiProjectAccessService projectAccessService;
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void parseIdCardFile_shouldReturnAjaxResultSuccess() {
MockMultipartFile file = new MockMultipartFile(
@@ -46,38 +60,96 @@ class CcdiFileUploadControllerTest {
assertEquals(200, result.get("code"));
}
@Test
void batchUpload_shouldDelegateFilenameValidationToService() {
MultipartFile[] files = new MultipartFile[]{
new MockMultipartFile(
"files",
"330101199001010011 - 流水.xl sx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content".getBytes(StandardCharsets.UTF_8)
)
};
setLoginUser(9527L, "admin");
when(fileUploadService.batchUploadFiles(PROJECT_ID, files, "admin"))
.thenReturn("batch-1");
AjaxResult result = controller.batchUpload(PROJECT_ID, files);
assertEquals(200, result.get("code"));
assertEquals("batch-1", result.get("data"));
verify(projectAccessService).assertCanOperate(PROJECT_ID);
verify(fileUploadService).batchUploadFiles(PROJECT_ID, files, "admin");
}
@Test
void pullBankInfo_shouldUseCurrentLoginUserInfo() {
CcdiPullBankInfoSubmitDTO dto = new CcdiPullBankInfoSubmitDTO();
dto.setProjectId(PROJECT_ID);
dto.setIdCards(List.of("110101199001018888"));
dto.setDataChannelCode("ZJRCU");
dto.setStartDate("2026-03-01");
dto.setEndDate("2026-03-10");
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUserId).thenReturn(9527L);
mocked.when(SecurityUtils::getUsername).thenReturn("admin");
when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "2026-03-01", "2026-03-10", 9527L, "admin"))
.thenReturn("batch-1");
setLoginUser(9527L, "admin");
when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "ZJRCU", "2026-03-01", "2026-03-10", 9527L, "admin"))
.thenReturn("batch-1");
AjaxResult result = controller.pullBankInfo(dto);
AjaxResult result = controller.pullBankInfo(dto);
assertEquals(200, result.get("code"));
}
assertEquals(200, result.get("code"));
}
@Test
void pullBankInfo_shouldAllowJzlWithoutDateRange() {
CcdiPullBankInfoSubmitDTO dto = new CcdiPullBankInfoSubmitDTO();
dto.setProjectId(PROJECT_ID);
dto.setIdCards(List.of("110101199001018888"));
dto.setDataChannelCode("JZL");
setLoginUser(9527L, "admin");
when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "JZL", null, null, 9527L, "admin"))
.thenReturn("batch-1");
AjaxResult result = controller.pullBankInfo(dto);
assertEquals(200, result.get("code"));
}
@Test
void pullBankInfo_shouldRejectUnsupportedDataChannelCode() {
CcdiPullBankInfoSubmitDTO dto = new CcdiPullBankInfoSubmitDTO();
dto.setProjectId(PROJECT_ID);
dto.setIdCards(List.of("110101199001018888"));
dto.setDataChannelCode("OTHER");
AjaxResult result = controller.pullBankInfo(dto);
assertEquals(500, result.get("code"));
assertEquals("流水来源不支持", result.get("msg"));
}
@Test
void deleteFile_shouldUseCurrentLoginUserId() {
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUserId).thenReturn(9527L);
when(fileUploadService.deleteFileUploadRecord(123L, 9527L))
.thenReturn("删除成功");
setLoginUser(9527L, "admin");
when(fileUploadService.deleteFileUploadRecord(123L, 9527L))
.thenReturn("删除成功");
AjaxResult result = controller.deleteFile(123L);
AjaxResult result = controller.deleteFile(123L);
assertEquals(200, result.get("code"));
assertEquals("删除成功", result.get("msg"));
verify(fileUploadService).deleteFileUploadRecord(123L, 9527L);
}
assertEquals(200, result.get("code"));
assertEquals("删除成功", result.get("msg"));
verify(fileUploadService).deleteFileUploadRecord(123L, 9527L);
}
private void setLoginUser(Long userId, String username) {
SysUser user = new SysUser();
user.setUserName(username);
LoginUser loginUser = new LoginUser(user, Set.of("*:*:*"));
loginUser.setUserId(userId);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}

View File

@@ -172,7 +172,7 @@ class CcdiProjectOverviewControllerContractTest {
Method method = controllerClass.getMethod(
"exportRiskDetails",
HttpServletResponse.class,
Long.class
Class.forName("com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO")
);
PostMapping postMapping = method.getAnnotation(PostMapping.class);
Operation operation = method.getAnnotation(Operation.class);
@@ -198,7 +198,7 @@ class CcdiProjectOverviewControllerContractTest {
assertEquals("/report/export", requestMapping.value()[0]);
assertEquals(List.of(RequestMethod.GET, RequestMethod.POST), Arrays.asList(requestMapping.method()));
assertNotNull(operation);
assertEquals("一键导出结果总览报告", operation.summary());
assertEquals("导出结果总览报告", operation.summary());
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:query')", preAuthorize.value());
}
@@ -211,7 +211,10 @@ class CcdiProjectOverviewControllerContractTest {
.map(Field::getName)
.collect(Collectors.toList());
assertEquals(List.of("projectId", "suspiciousType", "pageNum", "pageSize"), fieldNames);
assertEquals(
List.of("projectId", "modelCode", "suspiciousType", "pageNum", "pageSize", "includeExternalPerson"),
fieldNames
);
}
@Test

View File

@@ -18,6 +18,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation;
@@ -52,6 +53,9 @@ class CcdiProjectOverviewControllerTest {
@Mock
private ICcdiProjectOverviewService overviewService;
@Mock
private CcdiProjectAccessService projectAccessService;
@Test
void shouldExposeDashboardEndpoint() throws Exception {
when(overviewService.getDashboard(40L)).thenReturn(new CcdiProjectOverviewDashboardVO());
@@ -308,15 +312,20 @@ class CcdiProjectOverviewControllerTest {
@Test
void shouldExposeRiskDetailsExportEndpoint() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
CcdiProjectSuspiciousTransactionQueryDTO queryDTO =
new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(40L);
queryDTO.setModelCode("SUSPICIOUS_GAMBLING");
queryDTO.setSuspiciousType("MODEL_RULE");
controller.exportRiskDetails(response, 40L);
controller.exportRiskDetails(response, queryDTO);
verify(overviewService).exportRiskDetails(same(response), same(40L));
verify(overviewService).exportRiskDetails(same(response), same(queryDTO));
Method method = CcdiProjectOverviewController.class.getMethod(
"exportRiskDetails",
jakarta.servlet.http.HttpServletResponse.class,
Long.class
CcdiProjectSuspiciousTransactionQueryDTO.class
);
PostMapping postMapping = method.getAnnotation(PostMapping.class);
Operation operation = method.getAnnotation(Operation.class);

View File

@@ -23,12 +23,12 @@ class CcdiBankStatementTest {
item.setLeId(100);
item.setAccountId(200L);
item.setLeName("测试企业");
item.setAccountMaskNo("6222****1234");
item.setAccountNo("6222023300001234");
item.setDrAmount(new BigDecimal("1000.00"));
item.setCrAmount(new BigDecimal("500.00"));
item.setBalanceAmount(new BigDecimal("5000.00"));
item.setTrxDate("2026-03-04");
item.setCustomerAccountMaskNo("6228****5678");
item.setCustomerAccountNo("6228483300005678");
// 执行转换
CcdiBankStatement entity = CcdiBankStatement.fromResponse(item);
@@ -41,8 +41,8 @@ class CcdiBankStatementTest {
assertEquals("测试企业", entity.getLeAccountName(), "企业名称应该匹配");
// 验证手动映射的字段
assertEquals("6222****1234", entity.getLeAccountNo(), "企业账号应该从 accountMaskNo 映射");
assertEquals("6228****5678", entity.getCustomerAccountNo(), "对手方账号应该从 customerAccountMaskNo 映射");
assertEquals("6222023300001234", entity.getLeAccountNo(), "企业账号应该从 accountNo 映射");
assertEquals("6228483300005678", entity.getCustomerAccountNo(), "对手方账号应该从 customerAccountNo 映射");
// 验证金额字段
assertEquals(new BigDecimal("1000.00"), entity.getAmountDr(), "付款金额应该匹配");

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);
@@ -152,6 +172,49 @@ class CcdiBankTagAnalysisMapperXmlTest {
assertTrue(!selectSql.contains("social_credit_code = bs"));
}
@Test
void gamblingSensitiveKeywordRules_shouldIgnoreSmallAmountTransactions() throws Exception {
String xml = readXml(RESOURCE);
String staffSelectSql = extractSelectSql(xml, "selectGamblingSensitiveKeywordStatements");
String externalSelectSql = extractSelectSql(xml, "selectExternalGamblingMemoStatements");
assertTrue(staffSelectSql.contains("IFNULL(bs.AMOUNT_DR, 0) >= 200"));
assertTrue(staffSelectSql.contains("达到敏感交易最低金额 200 元"));
assertTrue(externalSelectSql.contains(
"GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) >= 200"
));
assertTrue(externalSelectSql.contains("达到敏感交易最低金额 200 元"));
}
@Test
void annualTurnoverRules_shouldUseProjectStatementDateAsOneYearAnchor() throws Exception {
String xml = readXml(RESOURCE);
String staffSelectSql = extractSelectSql(xml, "selectAnnualTurnoverObjects");
String externalSelectSql = extractSelectSql(xml, "selectExternalAnnualTurnoverObjects");
assertTrue(staffSelectSql.contains("project_anchor.anchorDate"));
assertTrue(staffSelectSql.contains("DATE_SUB(project_anchor.anchorDate, INTERVAL 12 MONTH)"));
assertFalse(staffSelectSql.contains("CURDATE()"));
assertTrue(externalSelectSql.contains("project_anchor.anchorDate"));
assertTrue(externalSelectSql.contains("DATE_SUB(project_anchor.anchorDate, INTERVAL 12 MONTH)"));
assertFalse(externalSelectSql.contains("CURDATE()"));
}
@Test
void propertyFeeMismatchRule_shouldUseReasonablePropertyKeywordsAndAssetNameMatching() throws Exception {
String xml = readXml(RESOURCE);
String selectSql = extractSelectSql(xml, "selectPropertyFeeRegistrationMismatchStatements");
assertTrue(selectSql.contains("物业|物管|业委会|业主委员会|维修基金|住宅专项维修资金|房屋维修资金"));
assertFalse(selectSql.contains("中心"));
assertFalse(selectSql.contains("社区"));
assertFalse(selectSql.contains("大厦"));
assertTrue(selectSql.contains("asset.asset_name AS assetName"));
assertTrue(selectSql.contains("trade.customerAccountName"));
assertTrue(selectSql.contains("trade.userMemo"));
assertTrue(selectSql.contains("名下无匹配房产登记"));
}
@Test
void withdrawCntObjectRule_shouldUseRealSqlAndKeepObjectHitFields() throws Exception {
String xml = readXml(RESOURCE);

View File

@@ -32,6 +32,7 @@ class CcdiBankTagResultMapperXmlTest {
.orElse(null);
assertNotNull(method, "应提供按项目和流水ID批量查询异常标签的方法");
assertTrue(method.getParameterCount() == 4, "标签批量查询应接收模型和预警类型范围");
}
@Test
@@ -42,6 +43,9 @@ class CcdiBankTagResultMapperXmlTest {
assertTrue(xml.contains("selectStatementTagsByProjectAndStatementIds"), xml);
assertTrue(xml.contains("bank_statement_id IN"), xml);
assertTrue(xml.contains("project_id = #{projectId}"), xml);
assertTrue(xml.contains("model_code = #{modelCode}"), xml);
assertTrue(xml.contains("left(model_code, 9) != 'EXTERNAL_'"), xml);
assertTrue(xml.contains("left(model_code, 9) = 'EXTERNAL_'"), xml);
}
}
}

View File

@@ -0,0 +1,40 @@
package com.ruoyi.ccdi.project.mapper;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiBankTagTaskMapperXmlTest {
private static final String RESOURCE = "mapper/ccdi/project/CcdiBankTagTaskMapper.xml";
@Test
void mapper_shouldExposeLatestFailedTaskQuery() {
Method method = Arrays.stream(CcdiBankTagTaskMapper.class.getDeclaredMethods())
.filter(item -> "selectLatestFailedTaskByProjectId".equals(item.getName()))
.findFirst()
.orElse(null);
assertNotNull(method, "应提供查询项目最近失败打标任务的方法");
}
@Test
void xml_shouldSelectLatestFailedTaskByProjectId() throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(xml.contains("selectLatestFailedTaskByProjectId"), xml);
assertTrue(xml.contains("from ccdi_bank_tag_task"), xml);
assertTrue(xml.contains("project_id = #{projectId}"), xml);
assertTrue(xml.contains("status = 'FAILED'"), xml);
assertTrue(xml.contains("order by id desc"), xml);
assertTrue(xml.contains("limit 1"), xml);
}
}
}

View File

@@ -0,0 +1,27 @@
package com.ruoyi.ccdi.project.mapper;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectExtendedPurchaseSupplierContractTest {
@Test
void shouldExposeExtendedPurchaseSupplierDetailQuery() throws Exception {
Class<?> mapperClass = Class.forName("com.ruoyi.ccdi.project.mapper.CcdiProjectSpecialCheckMapper");
Method method = mapperClass.getMethod("selectExtendedPurchaseSuppliers", Long.class, String.class);
assertEquals(List.class, method.getReturnType());
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectSpecialCheckMapper.xml"));
assertTrue(xml.contains("select id=\"selectExtendedPurchaseSuppliers\""));
assertTrue(xml.contains("ccdi_purchase_transaction_supplier"));
assertTrue(xml.contains("is_bid_winner"));
assertTrue(xml.contains("sort_order"));
}
}

View File

@@ -0,0 +1,51 @@
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectMapperXmlTest {
@Test
void projectQueriesShouldApplyProjectVisibilityAndDeletedFilters() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectMapper.xml"));
assertFalse(xml.contains("params.dataScope"), xml);
assertFalse(xml.contains("${queryDTO.params.dataScope}"), xml);
String listSql = extractSelect(xml, "selectProjectPage");
assertTrue(listSql.contains("LEFT JOIN sys_user u ON p.create_by = u.user_name"), listSql);
assertTrue(listSql.contains("scope != null and !scope.viewAllProjects"), listSql);
assertTrue(listSql.contains("p.create_by = #{scope.username}"), listSql);
assertTrue(listSql.contains("queryDTO.includeDeleted"), listSql);
assertTrue(listSql.contains("p.del_flag = '2'"), listSql);
assertTrue(listSql.contains("p.status = '5'"), listSql);
assertTrue(listSql.contains("p.del_flag = '0'"), listSql);
assertTrue(listSql.contains("p.status != '5'"), listSql);
String historySql = extractSelect(xml, "selectHistoryProjects");
assertTrue(historySql.contains("p.status in ('1', '2')"), historySql);
assertTrue(historySql.contains("p.del_flag = '0'"), historySql);
assertTrue(xml.contains("<update id=\"markProjectDeleted\">"), xml);
assertTrue(xml.contains("status = '5'"), xml);
assertTrue(xml.contains("del_flag = '2'"), xml);
assertTrue(xml.contains("<update id=\"restoreDeletedProject\">"), xml);
assertTrue(xml.contains("status = '1'"), xml);
assertTrue(xml.contains("del_flag = '0'"), xml);
assertTrue(xml.contains("is_archived = 0"), xml);
}
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);
}
}

View File

@@ -91,12 +91,19 @@ class CcdiProjectOverviewMapperSqlTest {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String suspiciousSql = extractSelect(xml, "selectSuspiciousTransactionPage");
String modelHitSql = extractSqlFragment(xml, "suspiciousTransactionModelHitSql");
String modelScopeSql = extractSqlFragment(xml, "suspiciousTransactionModelScopeSql");
String mergedSql = extractSqlFragment(xml, "suspiciousTransactionMergedSql");
String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql");
String externalSubjectExistsSql = extractSelect(xml, "selectExternalPersonSubjectExistsByProjectId");
assertTrue(modelHitSql.contains("from ccdi_bank_statement_tag_result tr"), modelHitSql);
assertTrue(modelHitSql.contains("tr.bank_statement_id is not null"), modelHitSql);
assertFalse(modelHitSql.contains("rule_name like '%可疑%'"), modelHitSql);
assertFalse(modelHitSql.contains("ABNORMAL_CUSTOMER_TRANSACTION"), modelHitSql);
assertTrue(modelHitSql.contains("suspiciousTransactionModelScopeSql"), modelHitSql);
assertTrue(modelScopeSql.contains("tr.model_code = #{query.modelCode}"), modelScopeSql);
assertTrue(modelScopeSql.contains("left(tr.model_code, 9) != 'EXTERNAL_'"), modelScopeSql);
assertTrue(modelScopeSql.contains("left(tr.model_code, 9) = 'EXTERNAL_'"), modelScopeSql);
assertTrue(suspiciousSql.contains("ccdi_biz_intermediary"), suspiciousSql);
assertTrue(suspiciousSql.contains("ccdi_enterprise_base_info"), suspiciousSql);
assertTrue(suspiciousSql.contains("group by merged.bankStatementId"), suspiciousSql);
@@ -104,12 +111,21 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(suspiciousSql.contains("hasModelRuleHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("hasNameListHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("final_result.nameListHitType"), suspiciousSql);
assertTrue(suspiciousSql.contains("bs.LE_ACCOUNT_NO as leAccountNo"), suspiciousSql);
assertTrue(suspiciousSql.contains("bs.CUSTOMER_ACCOUNT_NO as customerAccountNo"), suspiciousSql);
assertTrue(suspiciousSql.contains("bs.bank_statement_id = final_result.bankStatementId"), suspiciousSql);
assertTrue(mergedSql.contains("<if test=\"query.includeExternalPerson == true\">"), mergedSql);
assertTrue(mergedSql.contains("<include refid=\"externalSuspiciousTransactionSql\"/>"), mergedSql);
assertTrue(externalSubjectExistsSql.contains("limit 1"), externalSubjectExistsSql);
assertTrue(externalSubjectExistsSql.contains("staff.id_card is null"), externalSubjectExistsSql);
assertTrue(externalSubjectExistsSql.contains("relation.relation_cert_no is null"), externalSubjectExistsSql);
String reportSuspiciousSql = extractSelect(xml, "selectReportSuspiciousTransactionList");
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '中介'"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("疑似与中介往来"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '信贷客户'"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("与信贷客户之间非正常资金往来"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("suspiciousTransactionModelScopeSql"), reportSuspiciousSql);
}
@Test
@@ -195,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);
@@ -209,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

@@ -129,7 +129,9 @@ class CcdiBankStatementServiceImplTest {
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(51274L);
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L)))
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
43L, List.of(51274L), null, null
))
.thenReturn(List.of(hitTag));
List<CcdiBankStatementExcel> result = service.selectStatementListForExport(queryDTO);
@@ -152,7 +154,9 @@ class CcdiBankStatementServiceImplTest {
hitTag.setBankStatementId(51274L);
hitTag.setRuleCode("LARGE_CASH_DEPOSIT");
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L)))
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
43L, List.of(51274L), null, null
))
.thenReturn(List.of(hitTag));
Page<CcdiBankStatementListVO> result = service.selectStatementPage(page, queryDTO);
@@ -170,7 +174,9 @@ class CcdiBankStatementServiceImplTest {
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(200L);
hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(200L)))
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
43L, List.of(200L), null, null
))
.thenReturn(List.of(hitTag));
CcdiBankStatementDetailVO result = service.getStatementDetail(200L);
@@ -179,4 +185,21 @@ class CcdiBankStatementServiceImplTest {
assertEquals(1, result.getHitTags().size());
assertEquals("大额存现交易", result.getHitTags().get(0).getRuleName());
}
@Test
void getStatementDetail_shouldApplySuspiciousTransactionScope() {
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();
detailVO.setBankStatementId(200L);
detailVO.setProjectId(43L);
when(bankStatementMapper.selectStatementDetailById(200L)).thenReturn(detailVO);
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
43L, List.of(200L), "SUSPICIOUS_GAMBLING", "MODEL_RULE"
)).thenReturn(List.of());
service.getStatementDetail(200L, "suspicious_gambling", "model_rule");
verify(bankTagResultMapper).selectStatementTagsByProjectAndStatementIds(
43L, List.of(200L), "SUSPICIOUS_GAMBLING", "MODEL_RULE"
);
}
}

View File

@@ -6,6 +6,7 @@ import ch.qos.logback.core.read.ListAppender;
import com.alibaba.excel.EasyExcel;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankStatement;
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
@@ -15,6 +16,8 @@ import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import com.ruoyi.lsfx.constants.LsfxConstants;
import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest;
import com.ruoyi.lsfx.domain.request.GetBankStatementRequest;
import com.ruoyi.lsfx.domain.response.CheckParseStatusResponse;
import com.ruoyi.lsfx.domain.response.DeleteFilesResponse;
@@ -143,6 +146,7 @@ class CcdiFileUploadServiceImplTest {
String batchId = service.submitPullBankInfo(
PROJECT_ID,
List.of("110101199001018888", "110101199001019999"),
LsfxConstants.DATA_CHANNEL_ZJRCU,
"2026-03-01",
"2026-03-10",
9527L,
@@ -170,6 +174,7 @@ class CcdiFileUploadServiceImplTest {
() -> service.submitPullBankInfo(
PROJECT_ID,
List.of("3301"),
LsfxConstants.DATA_CHANNEL_ZJRCU,
"2026-01-01",
"2026-01-31",
1L,
@@ -193,6 +198,88 @@ class CcdiFileUploadServiceImplTest {
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));
}
@Test
void batchUploadFiles_shouldNormalizeFilenameBeforeSavingRecordAndTempFile() throws Exception {
setField("uploadPath", tempDir.toString());
mockProjectWithLsfxProjectId();
AtomicReference<List<CcdiFileUploadRecord>> inserted = new AtomicReference<>();
doAnswer(invocation -> {
List<CcdiFileUploadRecord> records = invocation.getArgument(0);
for (int i = 0; i < records.size(); i++) {
records.get(i).setId((long) (i + 1));
}
inserted.set(new ArrayList<>(records));
return records.size();
}).when(recordMapper).insertBatch(any());
MultipartFile file = new MockMultipartFile(
"files",
"330101199001010011 - 流 水 .xl sx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content".getBytes()
);
TransactionSynchronizationManager.initSynchronization();
try {
String batchId = service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester");
assertNotNull(batchId);
assertNotNull(inserted.get());
assertEquals("330101199001010011-流水.xlsx", inserted.get().get(0).getFileName());
Path tempRoot = tempDir.resolve("temp");
assertTrue(Files.exists(tempRoot));
try (var paths = Files.list(tempRoot)) {
assertTrue(paths.anyMatch(path ->
path.getFileName().toString().endsWith("_330101199001010011-流水.xlsx")));
}
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void batchUploadFiles_shouldRejectFileNameWithoutIdCardBeforeSavingTempFile() throws Exception {
setField("uploadPath", tempDir.toString());
mockProjectWithLsfxProjectId();
MultipartFile file = new MockMultipartFile(
"files",
"普通流水.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content".getBytes()
);
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));
assertTrue(exception.getMessage().contains("身份证"));
assertFalse(Files.exists(tempDir.resolve("temp")));
verify(recordMapper, never()).insertBatch(any());
verify(lsfxClient, never()).uploadFile(any(), org.mockito.ArgumentMatchers.<java.io.File>any(), any());
}
@Test
void batchUploadFiles_shouldRejectBlankNormalizedFilenameBeforeSavingTempFile() throws Exception {
setField("uploadPath", tempDir.toString());
mockProjectWithLsfxProjectId();
MultipartFile file = new MockMultipartFile(
"files",
" \u3000 ",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content".getBytes()
);
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));
assertTrue(exception.getMessage().contains("文件名不能为空"));
assertFalse(Files.exists(tempDir.resolve("temp")));
verify(recordMapper, never()).insertBatch(any());
verify(lsfxClient, never()).uploadFile(any(), org.mockito.ArgumentMatchers.<java.io.File>any(), any());
}
@Test
void submitTasksAsync_shouldNotCreateLocalBatchLogFiles() throws Exception {
setField("uploadPath", tempDir.toString());
@@ -355,6 +442,7 @@ class CcdiFileUploadServiceImplTest {
LSFX_PROJECT_ID,
record,
"110101199001018888",
LsfxConstants.DATA_CHANNEL_ZJRCU,
"2026-03-01",
"2026-03-10"
);
@@ -367,6 +455,34 @@ class CcdiFileUploadServiceImplTest {
);
}
@Test
void processPullBankInfoAsync_shouldFetchJzlWithZeroDateRange() {
when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID));
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
.thenReturn(buildEmptyBankStatementResponse());
CcdiFileUploadRecord record = buildRecord();
service.processPullBankInfoAsync(
PROJECT_ID,
LSFX_PROJECT_ID,
record,
"110101199001018888",
LsfxConstants.DATA_CHANNEL_JZL,
null,
null
);
verify(lsfxClient).fetchInnerFlow(argThat((FetchInnerFlowRequest request) ->
LsfxConstants.DATA_CHANNEL_JZL.equals(request.getDataChannelCode())
&& Integer.valueOf(0).equals(request.getDataStartDateId())
&& Integer.valueOf(0).equals(request.getDataEndDateId())
));
}
@Test
void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException {
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
@@ -388,6 +504,44 @@ class CcdiFileUploadServiceImplTest {
), eq("原始流水.xlsx"));
}
@Test
void processFileAsync_shouldBackfillMissingCretNoFromUploadFileName() throws IOException {
AtomicReference<List<CcdiBankStatement>> insertedStatements = new AtomicReference<>();
doAnswer(invocation -> {
List<CcdiBankStatement> statements = invocation.getArgument(0);
insertedStatements.set(new ArrayList<>(statements));
return statements.size();
}).when(bankStatementMapper).insertBatch(any());
CcdiProject project = new CcdiProject();
project.setProjectId(PROJECT_ID);
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1);
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("张三_330101199001010011_流水.xlsx")))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
.thenAnswer(invocation -> {
GetBankStatementRequest request = invocation.getArgument(0);
if (Integer.valueOf(1).equals(request.getPageSize())) {
return buildBankStatementCountResponse(1);
}
return buildBankStatementResponseWithBlankCretNo();
});
CcdiFileUploadRecord record = buildRecord();
record.setFileName("张三_330101199001010011_流水.xlsx");
Path tempFile = createTempFile();
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
assertNotNull(insertedStatements.get());
assertEquals(1, insertedStatements.get().size());
assertEquals("330101199001010011", insertedStatements.get().get(0).getCretNo());
}
@Test
void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException {
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
@@ -439,7 +593,7 @@ class CcdiFileUploadServiceImplTest {
}
@Test
void deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted() {
void deleteFileUploadRecord_shouldDeleteLocalBankStatementsAndMarkDeleted() {
CcdiFileUploadRecord record = buildRecord();
record.setProjectId(PROJECT_ID);
record.setLsfxProjectId(LSFX_PROJECT_ID);
@@ -449,7 +603,6 @@ class CcdiFileUploadServiceImplTest {
project.setProjectId(PROJECT_ID);
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(lsfxClient.deleteFiles(any())).thenReturn(buildDeleteFilesResponse());
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(2);
when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1);
@@ -457,12 +610,7 @@ class CcdiFileUploadServiceImplTest {
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
assertEquals("删除成功已开始项目重新打标", result);
verify(lsfxClient).deleteFiles(argThat(request ->
request.getGroupId().equals(LSFX_PROJECT_ID)
&& request.getUserId().equals(9527)
&& request.getLogIds().length == 1
&& request.getLogIds()[0].equals(LOG_ID)
));
verify(lsfxClient, never()).deleteFiles(any());
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus())
@@ -500,21 +648,23 @@ class CcdiFileUploadServiceImplTest {
}
@Test
void deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails() {
void deleteFileUploadRecord_shouldSkipLsfxDeleteApi() {
CcdiFileUploadRecord record = buildRecord();
record.setFileStatus("parsed_success");
record.setLogId(LOG_ID);
record.setLsfxProjectId(LSFX_PROJECT_ID);
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(lsfxClient.deleteFiles(any())).thenThrow(new RuntimeException("lsfx delete failed"));
when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1);
assertThrows(RuntimeException.class, () -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
verify(bankStatementMapper, never()).deleteByProjectIdAndBatchId(any(), any());
verify(bankTagService, never()).submitAutoRebuild(any(), any());
verify(recordMapper, never()).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
assertEquals("删除成功已开始项目重新打标", result);
verify(lsfxClient, never()).deleteFiles(any());
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
"deleted".equals(item.getFileStatus())
));
verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_FILE_DELETE);
}
// @Test
@@ -639,7 +789,8 @@ class CcdiFileUploadServiceImplTest {
"fetchAndSaveBankStatements",
PROJECT_ID,
LSFX_PROJECT_ID,
LOG_ID
LOG_ID,
null
);
assertTrue(Boolean.TRUE.equals(ReflectionTestUtils.getField(result, "success")));
@@ -754,6 +905,13 @@ class CcdiFileUploadServiceImplTest {
}).when(recordMapper).updateById(any(CcdiFileUploadRecord.class));
}
private void mockProjectWithLsfxProjectId() {
CcdiProject project = new CcdiProject();
project.setProjectId(PROJECT_ID);
project.setLsfxProjectId(LSFX_PROJECT_ID);
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
}
private CcdiFileUploadRecord buildRecord() {
CcdiFileUploadRecord record = new CcdiFileUploadRecord();
record.setId(RECORD_ID);
@@ -844,6 +1002,27 @@ class CcdiFileUploadServiceImplTest {
return response;
}
private GetBankStatementResponse buildBankStatementCountResponse(int totalCount) {
GetBankStatementResponse.BankStatementData data = new GetBankStatementResponse.BankStatementData();
data.setTotalCount(totalCount);
GetBankStatementResponse response = new GetBankStatementResponse();
response.setData(data);
return response;
}
private GetBankStatementResponse buildBankStatementResponseWithBlankCretNo() {
GetBankStatementResponse.BankStatementItem item = new GetBankStatementResponse.BankStatementItem();
item.setBankStatementId(1L);
item.setLeName("测试主体");
item.setAccountNo("62220001");
item.setCustomerName("交易对手");
item.setCustomerAccountNo("62220002");
item.setDrAmount(BigDecimal.TEN);
item.setCretNo(null);
return buildBankStatementResponseWithItems(1, List.of(item));
}
private void invokeSubmitTasksAsync(List<String> tempFilePaths,
List<CcdiFileUploadRecord> records,
String batchId) throws Exception {
@@ -872,9 +1051,9 @@ class CcdiFileUploadServiceImplTest {
return response;
}
private GetBankStatementResponse.BankStatementItem buildBankStatementItem(String accountMaskNo) {
private GetBankStatementResponse.BankStatementItem buildBankStatementItem(String accountNo) {
GetBankStatementResponse.BankStatementItem item = new GetBankStatementResponse.BankStatementItem();
item.setAccountMaskNo(accountMaskNo);
item.setAccountNo(accountNo);
item.setAccountingDateId(20260310);
item.setDrAmount(new BigDecimal("100.00"));
item.setCrAmount(new BigDecimal("0.00"));

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())
)
);
}
@@ -387,7 +392,9 @@ class CcdiProjectOverviewServiceImplTest {
hitTag.setRuleCode("RULE_A");
hitTag.setRuleName("大额转账");
hitTag.setRiskLevel("HIGH");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(40L, List.of(1L)))
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
40L, List.of(1L), null, null
))
.thenReturn(List.of(hitTag));
CcdiProjectPersonAnalysisObjectRecordVO objectRow = new CcdiProjectPersonAnalysisObjectRecordVO();

View File

@@ -5,6 +5,7 @@ import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
@@ -26,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -93,6 +95,135 @@ class CcdiProjectOverviewServiceSuspiciousTransactionTest {
);
}
@Test
void shouldIncludeExternalBranchWhenExternalSubjectExistsForAllSuspiciousTransactions() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
when(overviewMapper.selectExternalPersonSubjectExistsByProjectId(40L)).thenReturn(1);
Page<CcdiProjectSuspiciousTransactionItemVO> page = new Page<>(1, 10);
page.setRecords(List.of());
page.setTotal(0);
when(overviewMapper.selectSuspiciousTransactionPage(any(Page.class), any(CcdiProjectSuspiciousTransactionQueryDTO.class)))
.thenReturn(page);
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(40L);
CcdiProjectSuspiciousTransactionPageVO result = service.getSuspiciousTransactions(queryDTO);
assertEquals(0L, result.getTotal());
verify(overviewMapper).selectSuspiciousTransactionPage(
any(Page.class),
argThat(query -> "ALL".equals(query.getSuspiciousType())
&& Boolean.TRUE.equals(query.getIncludeExternalPerson()))
);
}
@Test
void shouldSkipExternalBranchAndAttachSelectedEmployeeModelTags() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiProjectSuspiciousTransactionItemVO item = new CcdiProjectSuspiciousTransactionItemVO();
item.setBankStatementId(101L);
Page<CcdiProjectSuspiciousTransactionItemVO> page = new Page<>(1, 10);
page.setRecords(List.of(item));
page.setTotal(1);
when(overviewMapper.selectSuspiciousTransactionPage(
any(Page.class), any(CcdiProjectSuspiciousTransactionQueryDTO.class)
)).thenReturn(page);
CcdiBankStatementHitTagVO tag = new CcdiBankStatementHitTagVO();
tag.setBankStatementId(101L);
tag.setModelCode("SUSPICIOUS_GAMBLING");
tag.setRuleName("疑似赌博");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
40L, List.of(101L), "SUSPICIOUS_GAMBLING", "ALL"
)).thenReturn(List.of(tag));
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(40L);
queryDTO.setModelCode("suspicious_gambling");
queryDTO.setSuspiciousType("all");
CcdiProjectSuspiciousTransactionPageVO result = service.getSuspiciousTransactions(queryDTO);
assertEquals("疑似赌博", result.getRows().getFirst().getHitTags().getFirst().getRuleName());
verify(overviewMapper, never()).selectExternalPersonSubjectExistsByProjectId(40L);
verify(overviewMapper).selectSuspiciousTransactionPage(
any(Page.class),
argThat(query -> !Boolean.TRUE.equals(query.getIncludeExternalPerson()))
);
}
@Test
void shouldKeepEmployeeRuleScopeSeparateFromExternalModels() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
Page<CcdiProjectSuspiciousTransactionItemVO> page = new Page<>(1, 10);
page.setRecords(List.of());
page.setTotal(0);
when(overviewMapper.selectSuspiciousTransactionPage(
any(Page.class), any(CcdiProjectSuspiciousTransactionQueryDTO.class)
)).thenReturn(page);
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(40L);
queryDTO.setSuspiciousType("MODEL_RULE");
service.getSuspiciousTransactions(queryDTO);
verify(overviewMapper, never()).selectExternalPersonSubjectExistsByProjectId(40L);
verify(overviewMapper).selectSuspiciousTransactionPage(
any(Page.class),
argThat(query -> "MODEL_RULE".equals(query.getSuspiciousType())
&& !Boolean.TRUE.equals(query.getIncludeExternalPerson()))
);
}
@Test
void shouldReturnEmptyExternalSuspiciousTransactionsWhenExternalSubjectDoesNotExist() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
when(overviewMapper.selectExternalPersonSubjectExistsByProjectId(40L)).thenReturn(null);
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(40L);
queryDTO.setSuspiciousType("external_person");
CcdiProjectSuspiciousTransactionPageVO result = service.getSuspiciousTransactions(queryDTO);
assertEquals(0L, result.getTotal());
assertTrue(result.getRows().isEmpty());
verify(overviewMapper, never()).selectSuspiciousTransactionPage(
any(Page.class),
any(CcdiProjectSuspiciousTransactionQueryDTO.class)
);
}
@Test
void shouldReturnEmptyExternalSuspiciousTransactionExportWhenExternalSubjectDoesNotExist() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
when(projectMapper.selectById(40L)).thenReturn(project);
when(overviewMapper.selectExternalPersonSubjectExistsByProjectId(40L)).thenReturn(null);
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(40L);
queryDTO.setSuspiciousType("EXTERNAL_PERSON");
List<CcdiProjectSuspiciousTransactionExcel> rows = service.exportSuspiciousTransactions(queryDTO);
assertTrue(rows.isEmpty());
verify(overviewMapper, never()).selectReportSuspiciousTransactionList(any(CcdiProjectSuspiciousTransactionQueryDTO.class));
}
@Test
void shouldExportSuspiciousTransactionsWithCurrentFilter() {
CcdiProject project = new CcdiProject();

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 @@
mock-maker-subclass

View File

@@ -0,0 +1,211 @@
# 项目结果总览排除可疑改造设计
## 1. 背景
项目详情的结果总览当前直接消费银行流水打标结果与员工结果快照。用户在核查过程中如果确认某条预警不是可疑问题,只能在认知上忽略,系统没有持久化排除能力。刷新页面、重新进入项目或导出报告时,该预警仍会按有效命中展示。
仓库初始化 SQL 中已存在 `ccdi_project_risk_exclusion` 表,语义是“项目结果页排除可疑记录表”,字段能够承载项目、人员或流水、规则编码、排除类型与排除原因。本次改造使用该表完成排除记录持久化,不删除原始打标结果。
## 2. 目标
1. 用户可在项目分析详情的异常明细中对单条预警执行“排除可疑”。
2. 排除对象精确到单条规则命中,不影响同一人员或同一流水的其他规则。
3. 排除后当前页面立即刷新,风险人员、模型预警次数、命中人数、涉疑交易明细按有效命中重新展示。
4. 浏览器刷新或重新进入项目后,已排除预警仍不作为有效预警展示。
5. 本阶段不做“恢复预警”入口,保持最短闭环。
## 3. 非目标
1. 不删除 `ccdi_bank_statement_tag_result` 原始命中结果。
2. 不使用 `localStorage``sessionStorage` 或 Cookie 保存排除状态。
3. 不做批量排除、恢复排除、排除记录管理页。
4. 不改变打标规则执行逻辑;重新打标后仍生成原始命中,展示查询阶段通过排除表过滤。
## 4. 用户操作链路
### 4.1 流水明细型预警
示例:某笔流水同时命中“大额转账交易”和“疑似敏感交易”。
1. 用户进入项目详情 > 结果总览。
2. 点击风险人员或模型命中人员的“查看项目”。
3. 在项目分析详情中进入“异常明细”。
4. 在“流水异常明细”表格中,异常标签旁展示小号操作“排除可疑”。
5. 用户点击某个标签的“排除可疑”,填写排除原因并确认。
6. 系统只排除该笔流水上的该条规则标签。
排除键:
```text
project_id + bank_statement_id + rule_code + exclusion_type = STATEMENT
```
效果:
1. 被排除的标签不再显示。
2. 同一笔流水的其他标签继续显示。
3. 如果该笔流水没有剩余有效标签,则不再出现在异常流水明细中。
4. 相关模型预警次数减少。
### 4.2 对象型 / 人员型预警
示例:某人命中“年流水交易额超限”,该预警是按人员聚合生成,不对应单笔流水。
1. 用户进入项目详情 > 结果总览。
2. 点击该人员的“查看项目”。
3. 在项目分析详情中进入“异常明细”。
4. 在“对象异常明细”卡片右上角展示小号操作“排除可疑”,样式与“加入证据库”保持一致。
5. 用户点击“排除可疑”,填写排除原因并确认。
6. 系统只排除该人员命中的该条规则。
排除键:
```text
project_id + staff_id_card + rule_code + exclusion_type = OBJECT
```
效果:
1. 该对象型预警卡片不再显示。
2. 该人员其他规则命中继续保留。
3. 如果该人员没有剩余有效规则,则从风险人员列表中移除。
4. 如果该人员仍有其他有效规则,则人员仍展示,但模型数、规则标签和风险等级按剩余有效命中重新计算。
## 5. 数据设计
使用既有表 `ccdi_project_risk_exclusion`
关键字段:
| 字段 | 用途 |
| --- | --- |
| `project_id` | 项目 ID |
| `staff_id_card` | 对象型预警对应人员证件号;外部人员也使用证件号 |
| `rule_code` | 被排除规则编码 |
| `exclusion_type` | `STATEMENT``OBJECT` |
| `bank_statement_id` | 流水型预警对应流水 ID |
| `exclude_reason` | 排除原因 |
唯一约束:
1. 流水型:`project_id + rule_code + exclusion_type + bank_statement_id`
2. 对象型:`project_id + staff_id_card + rule_code + exclusion_type`
如果目标环境缺少该表,实施时补充增量 SQL使用 `utf8mb4``utf8mb4_general_ci`
## 6. 后端设计
### 6.1 新增接口
新增“排除可疑”接口:
```text
POST /ccdi/project/overview/risk-exclusions
```
请求字段:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `projectId` | 是 | 项目 ID |
| `exclusionType` | 是 | `STATEMENT``OBJECT` |
| `ruleCode` | 是 | 规则编码 |
| `bankStatementId` | 流水型必填 | 流水 ID |
| `staffIdCard` | 对象型必填 | 人员证件号 |
| `excludeReason` | 是 | 排除原因 |
校验规则:
1. `exclusionType=STATEMENT` 时必须传 `bankStatementId`
2. `exclusionType=OBJECT` 时必须传 `staffIdCard`
3. `excludeReason` 不能为空,长度不超过 1000。
4. 先校验用户对项目有读写权限;归档或只读项目不允许排除。
5. 重复排除视为幂等成功,更新原因和更新时间。
### 6.2 查询过滤
所有结果总览相关查询应统一过滤排除表。
过滤规则:
1. 读取 `ccdi_bank_statement_tag_result` 时左关联排除表。
2. 流水型命中用 `project_id + rule_code + bank_statement_id + STATEMENT` 匹配。
3. 对象型命中用 `project_id + rule_code + staff_id_card/object_key + OBJECT` 匹配。
4. 匹配到排除记录的命中不进入后续聚合、列表、标签组装、导出。
重点影响范围:
1. 风险人员列表。
2. 风险模型卡片。
3. 风险模型命中人员。
4. 人员项目分析详情。
5. 涉疑交易明细。
6. 一键 PDF 报告与 Excel 导出。
7. 外部人员预警及外部人员详情。
### 6.3 员工结果快照
当前风险人员列表读取 `ccdi_project_overview_employee_result` 快照。为了让“排除可疑”后统计即时变化,实施时应在排除成功后触发当前项目员工结果快照重算,或将列表查询切回有效命中实时聚合。
本次推荐:
1. 排除接口写入排除表后,同步触发当前项目结果总览员工快照重算。
2. 重算逻辑只使用未排除命中。
3. 页面刷新后读取更新后的快照。
理由:保留现有列表分页性能与页面结构,改动集中,不引入双口径。
## 7. 前端设计
### 7.1 操作入口
流水异常明细:
1. 异常标签展示为标签 + 小号文字按钮。
2. 按钮文案为“排除可疑”。
3. 按钮只作用于当前标签,不作用于整行。
对象异常明细:
1. 卡片右上角增加小号按钮“排除可疑”。
2. 样式仿照“加入证据库”,保持轻量。
3. 不新增大按钮、不改变卡片主视觉。
### 7.2 确认弹窗
使用 Element UI 弹窗或对话框,要求用户填写排除原因。
确认文案需要明确影响范围:
1. 流水型:仅排除当前流水的当前规则标签。
2. 对象型:仅排除当前人员的当前规则预警。
### 7.3 刷新策略
排除成功后,前端通知父组件刷新:
1. 重新加载项目分析详情。
2. 重新加载风险人员列表。
3. 重新加载风险模型卡片与命中人员。
4. 重新加载涉疑交易明细。
不做局部假刷新,避免页面统计与后端状态不一致。
## 8. 风险与约束
1. 如果只过滤详情、不刷新快照,风险人员列表和模型统计会不一致;必须统一刷新统计口径。
2. 对象型预警没有 `bank_statement_id`,必须用人员证件号和规则编码定位。
3. 外部人员对象型预警同样需要按证件号处理,不能只适配员工。
4. 排除记录不删除原始命中,因此重新打标不会覆盖排除结果。
## 9. 验证场景
1. 流水一条多标签,排除其中一个标签后另一个标签仍显示。
2. 排除流水标签后模型预警次数减少。
3. 排除对象型“年流水交易额超限”后对象卡片消失。
4. 人员仅剩一条对象型预警时,排除后人员不再出现在风险人员列表。
5. 人员有多条预警时,排除一条后人员仍保留,标签和统计减少。
6. 刷新浏览器后排除结果仍生效。
7. 导出报告不包含已排除预警。
8. 归档或只读项目不允许排除。

View File

@@ -0,0 +1,18 @@
# 涉疑交易明细默认分页调整设计
## 目标
将项目结果预览中的“涉疑交易明细”默认每页条数由 5 条调整为 10 条,提升列表首屏信息量。
## 设计
- 仅调整涉疑交易明细默认分页条数。
- 员工征信负面和异常账户关联人员继续默认每页 5 条。
- 继续使用现有分页选项 `5 / 10 / 20 / 50`,不修改后端接口、数据库或用户手动切换分页条数的行为。
- 项目切换时,涉疑交易明细恢复为每页 10 条。
## 验证
- 静态单元测试确认涉疑交易默认值为 10。
- 静态单元测试确认另外两个列表仍使用默认值 5。
- 执行前端生产构建,并在真实项目页面确认首次请求的 `pageSize` 为 10。

View File

@@ -0,0 +1,27 @@
# 上传流水文件名校验后端实施计划
## 目标
上传流水文件时,后端统一过滤文件名中的空白字符,并要求过滤后的文件名主干包含 18 位身份证号片段。任一文件不满足规则时整批拦截,不创建上传记录、不保存临时文件、不调用流水分析平台。
## 实施内容
- `CcdiFileUploadController.batchUpload` 只保留项目 ID、文件数量、空文件和文件大小校验文件名业务校验统一下沉到服务层。
- `CcdiFileUploadServiceImpl.batchUploadFiles` 在保存临时文件前完成整批文件名归一化和校验。
- 文件名归一化规则为:`originalFilename == null` 时按空字符串处理,再移除半角空白与全角空格。
- 扩展名校验、身份证号片段校验、上传记录 `fileName` 和流水平台 multipart filename 均使用归一化后的文件名。
- 身份证号片段校验使用 `(?<!\d)\d{17}[0-9Xx](?!\d)` 在文件名主干中查找,不做校验位算法。
## 测试计划
- 覆盖含空格文件名归一化后写入上传记录,并用于临时文件名。
- 覆盖无身份证号片段、空白文件名在保存临时文件前拦截。
- 覆盖 Controller 不再按原始文件名提前拦截,能把文件名业务规则交给 Service。
- 执行 `mvn -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest,CcdiFileUploadControllerTest -Dsurefire.failIfNoSpecifiedTests=false test`
- 执行 `mvn -pl ccdi-project -am -DskipTests compile`
## 范围说明
- 本次不修改前端页面。
- 本次不修改流水平台返回 DTO。
- 本次不修改 `CcdiBankStatement.fromResponse``ccdi_bank_statement` 落库逻辑。

View File

@@ -0,0 +1,264 @@
# 项目结果总览排除可疑后端实施计划
## 1. 目标
后端提供“排除可疑”持久化接口,并在结果总览所有预警查询、统计、导出链路中统一过滤已排除命中。排除粒度为单条规则命中,不删除原始打标结果。
## 2. 涉及范围
模块:
1. `ccdi-project`
2. `ruoyi-admin` 装配依赖无需调整
3. `sql/migration/`
重点文件:
1. `CcdiProjectOverviewController`
2. `ICcdiProjectOverviewService`
3. `CcdiProjectOverviewServiceImpl`
4. `CcdiProjectOverviewMapper`
5. `CcdiBankTagResultMapper`
6. 新增排除记录实体、DTO、Mapper
## 3. 数据库实施
### 3.1 增量脚本
新增 SQL
```text
sql/migration/2026-07-09-create-project-risk-exclusion.sql
```
内容使用 `CREATE TABLE IF NOT EXISTS ccdi_project_risk_exclusion`,字段与 `sql/ccdi_prod_init.sql` 保持一致,并显式声明:
```sql
DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
```
### 3.2 表用途
`ccdi_project_risk_exclusion` 保存排除记录:
1. `STATEMENT`:单笔流水上的单个规则标签。
2. `OBJECT`:某个对象或人员上的单个规则预警。
## 4. 后端对象
### 4.1 新增 Entity
新增:
```text
ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiProjectRiskExclusion.java
```
字段对应表结构,实体类使用 Lombok `@Data`,不继承 `BaseEntity`
### 4.2 新增 DTO
新增:
```text
ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiProjectRiskExclusionSaveDTO.java
```
字段:
1. `projectId`
2. `staffIdCard`
3. `ruleCode`
4. `exclusionType`
5. `bankStatementId`
6. `excludeReason`
校验:
1. `projectId` 必填。
2. `ruleCode` 必填。
3. `exclusionType` 必填且只允许 `STATEMENT``OBJECT`
4. `excludeReason` 必填且最大 1000 字符。
5. `STATEMENT` 要求 `bankStatementId`
6. `OBJECT` 要求 `staffIdCard`
### 4.3 新增 Mapper
新增:
```text
ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiProjectRiskExclusionMapper.java
ccdi-project/src/main/resources/mapper/ccdi/project/CcdiProjectRiskExclusionMapper.xml
```
方法:
1. `upsertExclusion`
2. `selectByProjectId`
3. `selectStatementExclusions`
4. `selectObjectExclusions`
`upsertExclusion` 使用唯一键实现幂等写入;重复排除时更新 `exclude_reason/update_by/update_time`
## 5. 接口实施
`CcdiProjectOverviewController` 新增:
```text
POST /ccdi/project/overview/risk-exclusions
```
返回:
```java
AjaxResult.success("排除成功")
```
权限:
1. `@PreAuthorize("@ss.hasPermi('ccdi:project:query')")`
2. 使用 `projectAccessService.assertCanRead(projectId)` 校验项目访问。
3. 使用项目状态或访问服务校验当前项目可操作;归档或只读项目返回错误。
## 6. Service 实施
`ICcdiProjectOverviewService` 增加:
```java
void excludeRisk(CcdiProjectRiskExclusionSaveDTO dto);
```
`CcdiProjectOverviewServiceImpl` 实现流程:
1. 校验 DTO。
2. 校验规则是否存在于当前项目有效命中中,避免写入无效排除记录。
3. 写入 `ccdi_project_risk_exclusion`
4. 触发当前项目结果总览员工快照重算。
5. 返回成功。
## 7. 查询过滤实施
### 7.1 统一过滤 SQL
`CcdiProjectOverviewMapper.xml` 中新增复用 SQL 片段:
```xml
not exists (
select 1
from ccdi_project_risk_exclusion ex
where ex.project_id = tr.project_id
and ex.rule_code = tr.rule_code
and (
(ex.exclusion_type = 'STATEMENT' and ex.bank_statement_id = tr.bank_statement_id)
or
(ex.exclusion_type = 'OBJECT' and ex.staff_id_card = resolved_staff_id_card)
)
)
```
实际实现时根据不同查询上下文替换 `resolved_staff_id_card`
### 7.2 员工风险基础 SQL
修改 `resolvedEmployeeRiskBaseSql`
1. 先解析 `staff_id_card`
2.`STATEMENT``OBJECT` 分别过滤。
3. 被排除命中不进入员工风险聚合。
### 7.3 风险模型卡片
修改模型统计查询:
1. `warning_count` 按未排除命中统计。
2. `people_count` 按未排除命中涉及人员去重。
### 7.4 风险模型命中人员
修改命中人员查询:
1. `hitTagList` 不包含已排除规则。
2. `modelNames` 按剩余命中规则组装。
3. 过滤后无有效规则的人员不返回。
### 7.5 人员项目分析详情
修改详情组装:
1. `BANK_STATEMENT` 记录的 `hitTags` 排除已排除标签。
2. 如果一条流水没有剩余 `hitTags`,不进入流水异常明细。
3. `OBJECT` 记录排除已排除对象规则。
### 7.6 涉疑交易明细与导出
修改涉疑交易查询:
1. 已排除流水标签不进入 `hitTags`
2. 过滤后无有效标签的流水不作为可疑流水展示。
3. Excel 导出和 PDF 报告复用同一过滤口径。
### 7.7 外部人员预警
外部人员使用证件号作为对象键:
1. `OBJECT` 类型按 `staff_id_card = cert_no` 过滤。
2. `STATEMENT` 类型按 `bank_statement_id` 过滤。
3. 外部人员列表、模型卡片、详情、报告口径一致。
## 8. 快照重算
如果当前已有员工结果快照重算逻辑,排除成功后复用该逻辑。
若没有可复用入口,新增内部方法:
```java
refreshOverviewEmployeeResult(Long projectId)
```
要求:
1. 使用过滤排除后的有效命中重算。
2. 删除或更新当前项目 `ccdi_project_overview_employee_result`
3. 确保列表、模型统计和详情口径一致。
## 9. 测试计划
### 9.1 后端编译
```bash
mvn -pl ccdi-project -am compile -DskipTests
```
### 9.2 单接口验证
验证接口:
```text
POST /ccdi/project/overview/risk-exclusions
```
场景:
1. 缺少原因返回错误。
2. `STATEMENT` 缺少 `bankStatementId` 返回错误。
3. `OBJECT` 缺少 `staffIdCard` 返回错误。
4. 重复排除幂等成功。
5. 归档项目或只读项目不允许排除。
### 9.3 数据口径验证
场景:
1. 一笔流水多个标签,排除一个后另一个仍存在。
2. 人员多个对象型预警,排除一个后人员仍存在。
3. 人员唯一预警被排除后,风险人员列表不再展示该人员。
4. 模型预警次数和命中人数减少。
5. PDF/Excel 不输出已排除预警。
## 10. 风险控制
1. 排除表只影响展示和统计,不影响原始命中结果。
2. 所有新增 SQL 使用 `utf8mb4_general_ci`
3. `rule_code` 保持全大写。
4. 不引入恢复接口,避免扩大范围。

View File

@@ -0,0 +1,25 @@
# 账户库中介风险等级后端实施计划
## 1. 目标
账户库中所属人类型为 `INTERMEDIARY` 的账户,风险等级统一为 `HIGH`。该规则覆盖页面新增、编辑保存、Excel 导入新增和 Excel 导入更新。
## 2. 实施范围
- `ccdi-info-collection` 账户库保存与导入服务
- 账户库服务单元测试
## 3. 实施步骤
1.`CcdiAccountInfoServiceImpl` 增加中介风险等级统一处理方法。
2. 在新增、编辑 DTO 归一化后,将 `ownerType = INTERMEDIARY``txnRiskLevel` 强制置为 `HIGH`
3. 在分析字段准备阶段保留该规则,使行内中介账户即使清空其他分析字段,也保留风险等级 `HIGH`
4. 在 Excel 导入 DTO 转换阶段,对中介行跳过模板风险等级值,直接置为 `HIGH`
5. 补充单元测试覆盖行内中介保存和行外中介导入。
## 4. 验证要点
- 行内中介账户保存后 `trans_risk_level = HIGH`
- 行外中介账户导入时,即使模板填写 `LOW`,保存结果仍为 `HIGH`
- 普通行内员工账户仍清空分析字段,不写风险等级。
- 已有历史数据不会自动更新,只有重新编辑保存或导入更新后才应用该规则。

View File

@@ -0,0 +1,39 @@
# 专项排查资产收入匹配口径后端实施计划
## 保存路径确认
本文档保存于 `docs/plans/backend/`,用于记录专项排查后端口径调整计划。
## 背景
专项排查家庭资产负债原逻辑将无负债记录判为缺少信息,且使用收入、负债、资产之间的旧比较方式,无法满足“无负债仍需输出资产收入匹配判断”的业务口径。
## 实施范围
- 调整 `CcdiProjectSpecialCheckMapper.xml` 中家庭资产负债列表与详情查询。
- 保留家庭总年收入、家庭总资产、家庭总负债等原字段。
- 新增本人年收入、配偶年收入、本人入职年限、可解释收入、资产收入倍数等返回字段。
- 风险等级统一为 `高关注``关注``正常``缺少信息`
## 计算口径
- 本人年收入取 `ccdi_base_staff.annual_income`
- 本人入职年限由 `ccdi_base_staff.hire_date` 按当前日期折算,不足 1 年按 1 年。
- 配偶年收入取配偶员工年收入或亲属关系年收入。
- 家庭总资产取本人及配偶 `ccdi_asset_info.current_value` 合计。
- 家庭总负债取本人及配偶 `ccdi_debts_info.principal_balance` 合计,无记录按 0。
- 可解释收入 = 本人年收入 × 本人入职年限 + 配偶年收入 + 家庭总负债。
- 资产收入倍数 = 家庭总资产 / 可解释收入。
## 风险规则
- 缺少信息:本人年收入为空或小于等于 0、入职时间为空、家庭总资产为空或小于等于 0。
- 正常:资产收入倍数小于等于 1.5。
- 关注:资产收入倍数大于 1.5 且小于等于 3。
- 高关注:资产收入倍数大于 3。
## 验证计划
- 执行后端编译或针对 `ccdi-project` 模块的编译检查。
- 检查 MyBatis XML 中新增字段映射与 SQL 别名一致。
- 对典型数据进行公式复核:年收入 20 万、入职 10 年、资产 586 万、负债 0 时应为关注。

View File

@@ -0,0 +1,28 @@
# 涉疑交易明细筛选一致性后端实施计划
## 目标
- 完成员工模型与外部人员模型隔离。
- 列表接口批量返回当前筛选范围内的标签和展示字段。
- 详情与风险明细导出的涉疑交易标签复用相同筛选口径。
- 保持分页查询性能,不引入逐行 SQL。
## 实施步骤
1. 在涉疑交易查询标准化逻辑中识别员工模型与 `EXTERNAL_*` 外部模型,并按 `suspiciousType` 限制模型范围。
2. 修改 `suspiciousTransactionModelHitSql`,使 `MODEL_RULE` 只读取员工模型、`EXTERNAL_PERSON` 只读取外部模型。
3. 优化外部人员分支开关:仅 `ALL + 全部/外部模型``EXTERNAL_PERSON` 查询检查外部人员主体。
4. 扩展涉疑交易行 VO 和分页 SQL直接返回账户等列表展示字段。
5. 扩展流水标签批量查询,支持 `modelCode``suspiciousType` 范围,并返回 `modelCode`
6. 涉疑交易分页完成后,对当前页流水 ID 执行一次标签批量查询并装配。
7. 流水详情接口增加可选 `modelCode``suspiciousType` 参数,按相同标签范围查询。
8. 风险明细导出接收涉疑交易筛选参数,涉疑交易工作表按当前筛选生成;其他工作表保持原逻辑。
9. 修改涉疑交易导出标签聚合,使其按当前模型范围过滤。
## 测试
- Service员工/外部模型范围、外部分支开关、当前页批量标签装配。
- Mapper SQL模型范围条件、账户字段、标签批量查询和导出标签条件。
- Controller详情与风险明细导出参数透传。
- 性能结构:分页 Service 每次最多调用一次标签批量查询,不按行调用。

View File

@@ -0,0 +1,25 @@
# 涉疑交易无外部人员短路后端实施计划
## 背景
生产结果总览首屏中,`/ccdi/project/overview/suspicious-transactions` 在无外部人员项目上可能超过前端 10 秒超时。该接口的汇总 SQL 无条件拼接外部人员预警分支,导致只上传员工数据的项目仍会执行外部人员复杂关联。
## 目标
- 保持涉疑交易原有口径不变。
- 当项目不存在外部人员主体时,跳过涉疑交易中的外部人员预警分支。
- 当查询类型为 `EXTERNAL_PERSON` 且项目不存在外部人员主体时,直接返回空结果。
## 实施步骤
1.`CcdiProjectSuspiciousTransactionQueryDTO` 增加内部开关 `includeExternalPerson`
2.`CcdiProjectOverviewMapper` 增加轻量外部人员主体存在性判断。
3.`CcdiProjectOverviewMapper.xml` 中将 `externalSuspiciousTransactionSql``union all` 改为按 `includeExternalPerson` 条件拼接。
4.`CcdiProjectOverviewServiceImpl` 中按 `suspiciousType` 设置外部人员分支开关;`EXTERNAL_PERSON` 无主体时直接返回空分页或空导出列表。
5. 补充 Mapper SQL 结构测试和 Service 行为测试。
## 验证
- 运行涉疑交易相关单测。
- 运行结果总览 Mapper SQL 结构测试。
- 验证无外部人员项目不再执行涉疑交易外部人员分支。

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,15 @@
# 流水明细导出项目可读权限后端实施计划
## 目标
普通用户进入本人可读项目后,可以导出该项目的流水明细;不可读项目仍禁止导出。
## 实施范围
1. 调整 `CcdiBankStatementController.export`,移除 `ccdi:project:export` 菜单权限前置校验。
2. 保留并依赖 `projectAccessService.assertCanRead(projectId)`,导出权限与流水列表、筛选项、详情的项目可读口径保持一致。
3. 不调整项目列表导出、结果总览导出、专项核查导出等其他链路;本次仅修正流水明细导出遗留的旧权限标识。
## 验证
执行 `mvn -pl ccdi-project -am -Dtest=CcdiBankStatementControllerTest -Dsurefire.failIfNoSpecifiedTests=false test`,确认流水明细 Controller 单测通过。

View File

@@ -0,0 +1,23 @@
# 上传流水身份证号本地回填后端实施计划
## 目标
修复上传行外流水文件后,流水平台返回明细中 `cretNo` 为空导致 `ccdi_bank_statement.cret_no` 为空、结果总览和打标无法按身份证号关联的问题。
## 范围
- 仅调整后端批量上传流水文件链路。
- 不调整拉取本行信息链路。
- 不调整前端页面、数据库表结构、结果总览查询和打标 SQL。
## 实施方案
1. 复用现有文件名空白清理与身份证号识别规则,从归一化后的上传记录文件名中提取 18 位身份证号。
2. 保持转传流水平台的文件名不变,继续使用当前上传记录文件名。
3. 保存平台返回的流水明细时,若 `CcdiBankStatement.cretNo` 为空,则使用当前上传记录文件名中提取到的身份证号回填。
4. 补充单元测试覆盖平台 `cretNo` 为空时本地回填。
## 验证
- 运行 `mvn -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test`
- 必要时运行 `mvn -pl ccdi-project -am -DskipTests compile`

View File

@@ -0,0 +1,20 @@
# 拉取行内/金综流水后端实施计划
## 背景
项目详情页“上传数据”中的拉取流水能力需要支持两种来源:行内流水 `ZJRCU` 与金综流水 `JZL`。流水平台仍使用原 `/getJZFileOrZjrcuFile` 接口,一次请求只传一种 `dataChannelCode`
## 实施范围
- `CcdiPullBankInfoSubmitDTO` 增加 `dataChannelCode`
- `CcdiFileUploadController#pullBankInfo` 校验流水来源,仅允许 `ZJRCU``JZL`
- `ICcdiFileUploadService#submitPullBankInfo` 与实现类透传来源。
- `CcdiFileUploadServiceImpl#processPullBankInfoAsync` 按来源组装流水平台请求:
- `ZJRCU`:使用页面传入的开始、结束日期。
- `JZL``dataStartDateId=0``dataEndDateId=0`
- `LsfxConstants` 增加 `DATA_CHANNEL_JZL` 常量。
## 验证计划
- 运行 `CcdiFileUploadControllerTest`,验证 Controller 透传来源与金综不要求日期。
- 运行 `CcdiFileUploadServiceImplTest`,验证金综请求传 `JZL``0/0` 日期参数。

View File

@@ -0,0 +1,293 @@
# 项目结果总览排除可疑前端实施计划
## 1. 目标
在项目分析详情的异常明细中提供轻量“排除可疑”操作。用户确认排除后,页面重新请求后端数据,展示过滤后的风险人员、模型预警次数、命中人数与异常明细。
本阶段不做恢复入口,不做排除记录列表。
## 2. 涉及范围
前端模块:
```text
ruoyi-ui
```
重点文件:
1. `ruoyi-ui/src/api/ccdi/projectOverview.js`
2. `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisAbnormalTab.vue`
3. `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
4. `ruoyi-ui/src/views/ccdiProject/components/detail/ExternalPersonDetailDialog.vue`
5. `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
6. `ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`
7. `ruoyi-ui/src/views/ccdiProject/components/detail/RiskModelSection.vue`
8. `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
## 3. API 封装
`projectOverview.js` 新增:
```js
export function excludeOverviewRisk(data) {
return request({
url: '/ccdi/project/overview/risk-exclusions',
method: 'post',
data
})
}
```
请求字段:
```js
{
projectId,
exclusionType,
ruleCode,
bankStatementId,
staffIdCard,
excludeReason
}
```
## 4. UI 设计
### 4.1 流水异常明细
位置:
```text
ProjectAnalysisAbnormalTab.vue > BANK_STATEMENT 表格 > 异常标签列
```
展示方式:
1. 每个异常标签保持 `el-tag`
2. 标签右侧增加小号文字按钮“排除可疑”。
3. 按钮与标签在同一行内,不增加大操作列。
交互:
1. 点击“排除可疑”。
2. 打开确认弹窗。
3. 用户填写排除原因。
4. 确认后调用后端接口。
提交参数:
```js
{
projectId,
exclusionType: 'STATEMENT',
ruleCode: tag.ruleCode,
bankStatementId: row.bankStatementId,
staffIdCard: resolvePersonIdCard(),
excludeReason
}
```
其中 `staffIdCard` 仅作为上下文传递,后端流水型以 `bankStatementId` 为主。
### 4.2 对象异常明细
位置:
```text
ProjectAnalysisAbnormalTab.vue > OBJECT 卡片右上角
```
展示方式:
1. 卡片右上角增加小号按钮“排除可疑”。
2. 样式仿照现有“加入证据库”按钮。
3. 不新增恢复按钮。
提交参数:
```js
{
projectId,
exclusionType: 'OBJECT',
ruleCode: item.ruleCode || item.modelCode,
staffIdCard: resolvePersonIdCard(),
excludeReason
}
```
实施时需要确保对象异常记录保留真实 `ruleCode`。如果当前对象卡片只带 `modelCode`,前后端需补齐 `ruleCode` 字段,不能用 `modelCode` 替代规则编码。
## 5. 确认弹窗
使用 Element UI 对话框或 `$prompt`
推荐文案:
流水型:
```text
确认将当前流水的“{规则名称}”标记为排除可疑吗?
该操作只影响当前流水上的这一个规则标签。
```
对象型:
```text
确认将“{人员姓名}”的“{规则名称}”标记为排除可疑吗?
该操作只影响当前人员的这一个规则预警。
```
输入框:
```text
请输入排除原因
```
校验:
1. 原因不能为空。
2. 原因长度不超过 1000。
## 6. 刷新策略
排除成功后不做前端本地假删除,统一通知父组件刷新。
事件链路:
1. `ProjectAnalysisAbnormalTab` 调用接口成功。
2. 向上 emit `risk-excluded`
3. `ProjectAnalysisDialog` 接收后重新加载当前人员详情,并继续向上 emit。
4. `PreliminaryCheck` 接收后重新加载结果总览数据。
5. 子组件因 props 更新重新加载风险人员、模型卡片、命中人员与涉疑交易。
外部人员详情:
1. `ExternalPersonDetailDialog` 接收 `risk-excluded` 后重新加载外部人员流水。
2. 同时通知 `PreliminaryCheck` 刷新总览数据。
需要刷新的数据:
1. 风险人员列表。
2. 外部人员预警列表。
3. 风险模型卡片。
4. 风险模型命中人员。
5. 涉疑交易明细。
6. 当前弹窗异常明细。
## 7. 页面状态
### 7.1 操作中
点击确认后按钮进入 loading 或禁用状态,避免重复提交。
### 7.2 成功
提示:
```text
排除成功
```
随后刷新页面数据。
### 7.3 失败
提示后端错误信息:
```text
排除失败,请稍后重试
```
不修改当前页面数据。
### 7.4 只读项目
如果 `canOperate=false`,不展示“排除可疑”按钮,或展示禁用态并提示当前项目仅可查看。
## 8. 数据要求
前端需要从后端获取或透传以下字段:
流水标签:
1. `ruleCode`
2. `ruleName`
3. `bankStatementId`
对象卡片:
1. `ruleCode`
2. `ruleName`
3. `modelCode`
4. `modelName`
5. `reasonDetail`
人员上下文:
1. `projectId`
2. `idNo``staffIdCard`
3. `name``staffName`
如果对象异常卡片缺少 `ruleCode`,应先补齐数据映射,再展示排除按钮。
## 9. 测试计划
### 9.1 Node 环境
前端命令执行前按仓库规则:
```bash
cd ruoyi-ui
nvm use
node -v
npm -v
where node
where npm
```
如果 Node 14.21.3 缺少 `npm.cmd`,切换:
```bash
nvm use 22.22.3
```
并在实施记录中说明实际版本。
### 9.2 构建验证
```bash
npm run build:prod
```
### 9.3 浏览器验证
完成页面开发后,使用 `browser-use` 打开真实业务页面验证,不打开 prototype。
验证路径:
1. 登录系统。
2. 进入项目详情。
3. 打开结果总览。
4. 打开某个风险人员项目分析详情。
5. 对流水标签执行“排除可疑”。
6. 确认弹窗填写原因。
7. 验证异常明细、模型统计、风险人员数据刷新。
8. 刷新浏览器,验证排除仍生效。
9. 对对象型“年流水交易额超限”执行同样验证。
### 9.4 关键用例
1. 单笔流水多标签,只排除其中一个标签。
2. 对象型单预警人员,排除后人员从风险列表消失。
3. 对象型多预警人员,排除后人员保留但标签和次数减少。
4. 只读项目不显示或禁用按钮。
5. 排除原因为空时不能提交。
## 10. 不做事项
1. 不做恢复入口。
2. 不做排除记录管理页。
3. 不使用浏览器本地存储。
4. 不新增批量排除。

View File

@@ -0,0 +1,25 @@
# 账户库中介风险等级前端实施计划
## 1. 目标
账户库新增或编辑中介账户时,页面表单与后端强制规则保持一致,风险等级显示为 `HIGH`
## 2. 实施范围
- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
## 3. 实施步骤
1. 增加表单侧中介风险等级处理方法。
2. 切换所属人类型为 `INTERMEDIARY` 时,将 `txnRiskLevel` 设置为 `HIGH`
3. 打开非详情模式的编辑弹窗时,若所属人类型为中介,同步显示 `HIGH`
4. 提交表单前再次应用该规则,避免页面状态被手动或旧数据改回其他等级。
5. 所属人类型为中介时,风险等级下拉置为只读,避免用户选择其他等级后保存结果被后端强制改回 `HIGH`
## 4. 验证要点
- 新增账户时选择“中介”,风险等级显示 `HIGH`
- 编辑中介账户时,风险等级显示 `HIGH`
- 中介账户表单中风险等级不可编辑。
- 提交 payload 中中介账户的 `txnRiskLevel``HIGH`
- 非中介账户不受前端默认值变更影响。

View File

@@ -0,0 +1,42 @@
# 专项排查资产收入匹配口径前端实施计划
## 保存路径确认
本文档保存于 `docs/plans/frontend/`,用于记录专项排查前端展示调整计划。
## 背景
专项排查家庭资产负债详情需要展示新的资产收入匹配结论,并将家庭总年收入改为万元展示,便于业务人员阅读。
## 实施范围
- 调整 `FamilyAssetLiabilitySection.vue` 列表中的家庭总年收入展示为万元。
- 调整 `FamilyAssetLiabilityDetail.vue` 总收入、本人收入、配偶收入展示为万元。
- 将详查结果结论改为分行展示。
- 结论显示风险等级、家庭总资产、可解释收入公式和资产收入倍数。
## 展示口径
详查结果结论示例:
```text
关注
家庭总资产586.00 万元
可解释收入:本人年收入 20.00 万元 × 入职年限 10 年 + 配偶年收入 0.00 万元 + 家庭总负债 0.00 万元 = 200.00 万元
资产收入倍数2.93
```
缺少信息时仅显示:
```text
缺少资产,暂无法判断。
```
缺少项会按实际情况展示为收入、资产或入职时间;多项同时缺失时展示“关键信息不完整,暂无法判断。”。
## 验证计划
- 执行前端构建或静态编译检查。
- 页面验证专项排查列表家庭总年收入为万元。
- 页面验证详查结果按多行展示,且长公式不溢出容器。

View File

@@ -0,0 +1,23 @@
# 涉疑交易明细默认分页调整前端实施计划
## 修改范围
- `ruoyi-ui/src/views/ccdiProject/components/detail/RiskDetailSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/PreliminaryCheck.vue`
- `ruoyi-ui/tests/unit/risk-detail-suspicious-transaction-layout.test.js`
- `ruoyi-ui/tests/unit/preliminary-check-suspicious-transaction-load.test.js`
## 实施步骤
1. 将共用默认分页常量拆分为涉疑交易默认值和其他风险列表默认值。
2. 结果总览首次加载涉疑交易时请求 10 条。
3. 涉疑交易初始化及项目切换时使用 10 条默认值。
4. 员工征信负面和异常账户关联人员继续使用 5 条默认值。
5. 更新定向测试,验证分页默认值和影响范围。
6. 执行前端生产构建和真实页面验证。
## 完成标准
- 涉疑交易明细首次加载请求携带 `pageSize=10`
- 其他两个风险列表默认分页行为不变。
- 分页选项保持 `5 / 10 / 20 / 50`

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