12 Commits

114 changed files with 2679 additions and 409 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,16 @@
AND e.status = #{query.status} AND e.status = #{query.status}
</if> </if>
</where> </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> </select>
<!-- 批量插入或更新员工信息只更新非null字段 --> <!-- 批量插入或更新员工信息只更新非null字段 -->

View File

@@ -38,6 +38,18 @@ class CcdiAccountInfoMapperTest {
assertTrue(sql.contains("ai.owner_type <> 'credit_customer'"), sql); 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 { private MappedStatement loadMappedStatement(String statementId) throws Exception {
Configuration configuration = new Configuration(); Configuration configuration = new Configuration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource())); configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource()));

View File

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

View File

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

View File

@@ -18,7 +18,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -72,9 +71,13 @@ public class CcdiBankStatementController extends BaseController {
*/ */
@GetMapping("/detail/{bankStatementId}") @GetMapping("/detail/{bankStatementId}")
@Operation(summary = "查询流水详情") @Operation(summary = "查询流水详情")
public AjaxResult getDetail(@PathVariable Long bankStatementId) { public AjaxResult getDetail(@PathVariable Long bankStatementId, String modelCode, String suspiciousType) {
projectAccessService.assertCanReadByBankStatementId(bankStatementId); projectAccessService.assertCanReadByBankStatementId(bankStatementId);
CcdiBankStatementDetailVO detail = bankStatementService.getStatementDetail(bankStatementId); CcdiBankStatementDetailVO detail = bankStatementService.getStatementDetail(
bankStatementId,
modelCode,
suspiciousType
);
return AjaxResult.success(detail); return AjaxResult.success(detail);
} }
@@ -83,7 +86,6 @@ public class CcdiBankStatementController extends BaseController {
*/ */
@PostMapping("/export") @PostMapping("/export")
@Operation(summary = "导出流水明细") @Operation(summary = "导出流水明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:export')")
public void export(HttpServletResponse response, CcdiBankStatementQueryDTO queryDTO) { public void export(HttpServletResponse response, CcdiBankStatementQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId()); projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiBankStatementExcel> list = bankStatementService.selectStatementListForExport(queryDTO); 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.TableDataInfo;
import com.ruoyi.common.core.page.TableSupport; import com.ruoyi.common.core.page.TableSupport;
import com.ruoyi.common.utils.SecurityUtils; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -103,10 +104,10 @@ public class CcdiFileUploadController extends BaseController {
} }
/** /**
* 提交拉取本行信息任务 * 提交拉取行内/金综流水任务
*/ */
@PostMapping("/pull-bank-info") @PostMapping("/pull-bank-info")
@Operation(summary = "拉取本行信息", description = "身份证号批量提交拉取本行信息任务") @Operation(summary = "拉取行内/金综流水", description = "证件号码批量提交拉取行内/金综流水任务")
@PreAuthorize("@ss.hasPermi('ccdi:project:edit')") @PreAuthorize("@ss.hasPermi('ccdi:project:edit')")
public AjaxResult pullBankInfo(@RequestBody CcdiPullBankInfoSubmitDTO dto) { public AjaxResult pullBankInfo(@RequestBody CcdiPullBankInfoSubmitDTO dto) {
if (dto == null || dto.getProjectId() == null) { if (dto == null || dto.getProjectId() == null) {
@@ -116,7 +117,16 @@ public class CcdiFileUploadController extends BaseController {
if (CollectionUtils.isEmpty(dto.getIdCards())) { if (CollectionUtils.isEmpty(dto.getIdCards())) {
return AjaxResult.error("身份证号不能为空"); 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("开始日期和结束日期不能为空"); return AjaxResult.error("开始日期和结束日期不能为空");
} }
@@ -125,6 +135,7 @@ public class CcdiFileUploadController extends BaseController {
String batchId = fileUploadService.submitPullBankInfo( String batchId = fileUploadService.submitPullBankInfo(
dto.getProjectId(), dto.getProjectId(),
dto.getIdCards(), dto.getIdCards(),
dataChannelCode,
dto.getStartDate(), dto.getStartDate(),
dto.getEndDate(), dto.getEndDate(),
userId, userId,

View File

@@ -308,9 +308,12 @@ public class CcdiProjectOverviewController extends BaseController {
@PostMapping("/risk-details/export") @PostMapping("/risk-details/export")
@Operation(summary = "导出风险明细") @Operation(summary = "导出风险明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')") @PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportRiskDetails(HttpServletResponse response, Long projectId) { public void exportRiskDetails(
projectAccessService.assertCanRead(projectId); HttpServletResponse response,
overviewService.exportRiskDetails(response, projectId); CcdiProjectSuspiciousTransactionQueryDTO queryDTO
) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
overviewService.exportRiskDetails(response, queryDTO);
} }
/** /**

View File

@@ -22,4 +22,7 @@ public class CcdiProjectSuspiciousTransactionQueryDTO {
/** 每页数量 */ /** 每页数量 */
private Integer pageSize; private Integer pageSize;
/** 是否包含外部人员预警分支 */
private Boolean includeExternalPerson;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/** /**
* 涉疑交易明细行 * 涉疑交易明细行
@@ -14,6 +16,14 @@ public class CcdiProjectSuspiciousTransactionItemVO {
private String trxDate; private String trxDate;
private String leAccountNo;
private String leAccountName;
private String customerAccountName;
private String customerAccountNo;
private String suspiciousPersonName; private String suspiciousPersonName;
private String relatedPersonName; private String relatedPersonName;
@@ -35,4 +45,6 @@ public class CcdiProjectSuspiciousTransactionItemVO {
private Boolean hasNameListHit; private Boolean hasNameListHit;
private String nameListHitType; 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 projectId 项目ID
* @param bankStatementIds 流水ID列表 * @param bankStatementIds 流水ID列表
* @param modelCode 模型编码
* @param suspiciousType 预警类型
* @return 命中的异常标签列表 * @return 命中的异常标签列表
*/ */
List<CcdiBankStatementHitTagVO> selectStatementTagsByProjectAndStatementIds( List<CcdiBankStatementHitTagVO> selectStatementTagsByProjectAndStatementIds(
@Param("projectId") Long projectId, @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); CcdiProjectExternalRiskSummaryVO selectExternalRiskSummaryByProjectId(@Param("projectId") Long projectId);
/**
* 判断项目是否存在外部人员主体
*
* @param projectId 项目ID
* @return 存在时返回1否则返回空
*/
Integer selectExternalPersonSubjectExistsByProjectId(@Param("projectId") Long projectId);
/** /**
* 查询外部人员预警模型卡片 * 查询外部人员预警模型卡片
* *

View File

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

View File

@@ -213,6 +213,18 @@ public interface ICcdiProjectOverviewService {
default void exportRiskDetails(HttpServletResponse response, Long projectId) { default void exportRiskDetails(HttpServletResponse response, Long projectId) {
} }
/**
* 按涉疑交易筛选范围统一导出风险明细
*
* @param response 响应流
* @param queryDTO 涉疑交易筛选条件
*/
default void exportRiskDetails(
HttpServletResponse response,
CcdiProjectSuspiciousTransactionQueryDTO 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_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_ORDER_DIRECTIONS = Set.of("asc", "desc");
private static final Set<String> ALLOWED_SUSPICIOUS_TYPES =
Set.of("ALL", "MODEL_RULE", "EXTERNAL_PERSON", "NAME_LIST");
@Resource @Resource
private CcdiBankStatementMapper bankStatementMapper; private CcdiBankStatementMapper bankStatementMapper;
@@ -69,13 +71,24 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
@Override @Override
public CcdiBankStatementDetailVO getStatementDetail(Long bankStatementId) { 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); CcdiBankStatementDetailVO detail = bankStatementMapper.selectStatementDetailById(bankStatementId);
if (detail == null || detail.getProjectId() == null || detail.getBankStatementId() == null) { if (detail == null || detail.getProjectId() == null || detail.getBankStatementId() == null) {
return detail; return detail;
} }
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap( Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(
detail.getProjectId(), detail.getProjectId(),
List.of(detail.getBankStatementId()) List.of(detail.getBankStatementId()),
normalizeUpperCase(modelCode),
normalizeSuspiciousType(suspiciousType)
); );
detail.setHitTags(new ArrayList<>(hitTagMap.getOrDefault(detail.getBankStatementId(), Collections.emptyList()))); detail.setHitTags(new ArrayList<>(hitTagMap.getOrDefault(detail.getBankStatementId(), Collections.emptyList())));
return detail; return detail;
@@ -93,18 +106,33 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
if (bankStatementIds.isEmpty()) { if (bankStatementIds.isEmpty()) {
return; 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<>( rows.forEach(row -> row.setHitTags(new ArrayList<>(
hitTagMap.getOrDefault(row.getBankStatementId(), Collections.emptyList()) 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()) { if (projectId == null || bankStatementIds == null || bankStatementIds.isEmpty()) {
return Collections.emptyMap(); return Collections.emptyMap();
} }
List<CcdiBankStatementHitTagVO> hitTags = List<CcdiBankStatementHitTagVO> hitTags =
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(projectId, bankStatementIds); bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
projectId,
bankStatementIds,
modelCode,
suspiciousType
);
if (hitTags == null || hitTags.isEmpty()) { if (hitTags == null || hitTags.isEmpty()) {
return Collections.emptyMap(); return Collections.emptyMap();
} }
@@ -165,6 +193,16 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
return normalized == null ? null : normalized.toLowerCase(Locale.ROOT); 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) { private String normalizeText(String value) {
if (value == null) { if (value == null) {
return null; return null;

View File

@@ -46,6 +46,7 @@ import java.nio.file.StandardCopyOption;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.regex.Matcher;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionException;
@@ -153,6 +154,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
@Override @Override
public String submitPullBankInfo(Long projectId, public String submitPullBankInfo(Long projectId,
List<String> idCards, List<String> idCards,
String dataChannelCode,
String startDate, String startDate,
String endDate, String endDate,
Long userId, Long userId,
@@ -160,18 +162,21 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
if (projectId == null) { if (projectId == null) {
throw new IllegalArgumentException("项目ID不能为空"); throw new IllegalArgumentException("项目ID不能为空");
} }
if (!StringUtils.hasText(startDate) || !StringUtils.hasText(endDate)) {
throw new IllegalArgumentException("开始日期和结束日期不能为空");
}
if (idCards == null || idCards.isEmpty()) { if (idCards == null || idCards.isEmpty()) {
throw new IllegalArgumentException("身份证号不能为空"); throw new IllegalArgumentException("身份证号不能为空");
} }
String normalizedDataChannelCode = normalizePullBankInfoDataChannelCode(dataChannelCode);
if (LsfxConstants.DATA_CHANNEL_ZJRCU.equals(normalizedDataChannelCode)) {
if (!StringUtils.hasText(startDate) || !StringUtils.hasText(endDate)) {
throw new IllegalArgumentException("开始日期和结束日期不能为空");
}
LocalDate start = LocalDate.parse(startDate); LocalDate start = LocalDate.parse(startDate);
LocalDate end = LocalDate.parse(endDate); LocalDate end = LocalDate.parse(endDate);
if (start.isAfter(end)) { if (start.isAfter(end)) {
throw new IllegalArgumentException("开始日期不能晚于结束日期"); throw new IllegalArgumentException("开始日期不能晚于结束日期");
} }
}
projectService.ensureProjectNotArchived(projectId, "已归档项目暂不允许上传或拉取数据"); projectService.ensureProjectNotArchived(projectId, "已归档项目暂不允许上传或拉取数据");
projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据"); projectService.ensureProjectWritable(projectId, "当前项目正在进行银行流水打标,暂不允许上传或拉取数据");
@@ -217,13 +222,26 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
@Override @Override
public void afterCommit() { public void afterCommit() {
CompletableFuture.runAsync(() -> submitPullBankInfoTasks( CompletableFuture.runAsync(() -> submitPullBankInfoTasks(
projectId, lsfxProjectId, records, normalizedIdCards, startDate, endDate, batchId projectId, lsfxProjectId, records, normalizedIdCards,
normalizedDataChannelCode, startDate, endDate, batchId
)); ));
} }
}); });
return 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 @Override
public String deleteFileUploadRecord(Long id, Long operatorUserId) { public String deleteFileUploadRecord(Long id, Long operatorUserId) {
CcdiFileUploadRecord record = recordMapper.selectById(id); CcdiFileUploadRecord record = recordMapper.selectById(id);
@@ -579,6 +597,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
Integer lsfxProjectId, Integer lsfxProjectId,
List<CcdiFileUploadRecord> records, List<CcdiFileUploadRecord> records,
List<String> idCards, List<String> idCards,
String dataChannelCode,
String startDate, String startDate,
String endDate, String endDate,
String batchId) { String batchId) {
@@ -600,7 +619,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
while (!submitted && retryCount < 2) { while (!submitted && retryCount < 2) {
try { try {
CompletableFuture<Boolean> future = CompletableFuture.supplyAsync( CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(
() -> processPullBankInfoAsync(projectId, lsfxProjectId, record, idCard, startDate, endDate), () -> processPullBankInfoAsync(projectId, lsfxProjectId, record, idCard, dataChannelCode, startDate, endDate),
fileUploadExecutor fileUploadExecutor
); );
futures.add(future); futures.add(future);
@@ -639,16 +658,23 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
Integer lsfxProjectId, Integer lsfxProjectId,
CcdiFileUploadRecord record, CcdiFileUploadRecord record,
String idCard, String idCard,
String dataChannelCode,
String startDate, String startDate,
String endDate ) { String endDate ) {
try { try {
String normalizedDataChannelCode = normalizePullBankInfoDataChannelCode(dataChannelCode);
FetchInnerFlowRequest request = new FetchInnerFlowRequest(); FetchInnerFlowRequest request = new FetchInnerFlowRequest();
request.setGroupId(lsfxProjectId); request.setGroupId(lsfxProjectId);
request.setCustomerNo(idCard); 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.setRequestDateId(Integer.parseInt(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)));
if (LsfxConstants.DATA_CHANNEL_JZL.equals(normalizedDataChannelCode)) {
request.setDataStartDateId(0);
request.setDataEndDateId(0);
} else {
request.setDataStartDateId(Integer.parseInt(startDate.replace("-", ""))); request.setDataStartDateId(Integer.parseInt(startDate.replace("-", "")));
request.setDataEndDateId(Integer.parseInt(endDate.replace("-", ""))); request.setDataEndDateId(Integer.parseInt(endDate.replace("-", "")));
}
request.setUploadUserId(LsfxConstants.DEFAULT_USER_ID); request.setUploadUserId(LsfxConstants.DEFAULT_USER_ID);
FetchInnerFlowResponse response = lsfxClient.fetchInnerFlow(request); FetchInnerFlowResponse response = lsfxClient.fetchInnerFlow(request);
@@ -819,7 +845,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
enterpriseNamesStr, accountNosStr); enterpriseNamesStr, accountNosStr);
log.info("【文件上传】步骤7: 获取流水数据"); log.info("【文件上传】步骤7: 获取流水数据");
FetchBankStatementResult fetchResult = fetchAndSaveBankStatements(projectId, lsfxProjectId, logId); String fallbackCretNo = extractIdCardFromFileName(record.getFileName());
FetchBankStatementResult fetchResult = fetchAndSaveBankStatements(projectId, lsfxProjectId, logId,
fallbackCretNo);
if (!fetchResult.isSuccess()) { if (!fetchResult.isSuccess()) {
updateFailedRecord(record, fetchResult.getErrorMessage()); updateFailedRecord(record, fetchResult.getErrorMessage());
return; return;
@@ -892,7 +920,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
* @param logId 文件ID * @param logId 文件ID
*/ */
private FetchBankStatementResult fetchAndSaveBankStatements(Long projectId, Integer groupId, private FetchBankStatementResult fetchAndSaveBankStatements(Long projectId, Integer groupId,
Integer logId) { Integer logId,
String fallbackCretNo) {
log.info("【文件上传】开始获取流水数据: projectId={}, groupId={}, logId={}", log.info("【文件上传】开始获取流水数据: projectId={}, groupId={}, logId={}",
projectId, groupId, logId); projectId, groupId, logId);
@@ -957,6 +986,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
if (statement != null) { if (statement != null) {
statement.setBatchId(logId); statement.setBatchId(logId);
statement.setProjectId(projectId); statement.setProjectId(projectId);
fillMissingCretNo(statement, fallbackCretNo);
normalizeDedupFields(statement); normalizeDedupFields(statement);
batchList.add(statement); batchList.add(statement);
@@ -1000,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) { private void cleanupBankStatements(Long projectId, Integer logId) {
bankStatementMapper.deleteByProjectIdAndBatchId(projectId, logId); bankStatementMapper.deleteByProjectIdAndBatchId(projectId, logId);
} }

View File

@@ -230,7 +230,7 @@ public class CcdiProjectOverviewReportPdfExporter {
writer.subsection("3. 异常账户信息表(共" + report.getAbnormalAccounts().size() + "条)"); writer.subsection("3. 异常账户信息表(共" + report.getAbnormalAccounts().size() + "条)");
writer.table( writer.table(
List.of("账号", "开户人", "银行", "异常类型", "异常发生时间", "状态"), List.of("账号", "开户人", "银行", "异常类型", "异常发生时间", "状态", "命中原因", "涉及金额"),
report.getAbnormalAccounts().stream() report.getAbnormalAccounts().stream()
.map(item -> List.of( .map(item -> List.of(
maskAccount(item.getAccountNo()), maskAccount(item.getAccountNo()),
@@ -238,10 +238,12 @@ public class CcdiProjectOverviewReportPdfExporter {
safeText(item.getBankName()), safeText(item.getBankName()),
safeText(item.getAbnormalType()), safeText(item.getAbnormalType()),
safeText(item.getAbnormalTime()), safeText(item.getAbnormalTime()),
safeText(item.getStatus()) safeText(item.getStatus()),
safeText(item.getReasonDetail()),
formatMoney(item.getInvolvedAmount())
)) ))
.collect(Collectors.toList()), .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

@@ -86,6 +86,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
private static final String ACTION_LABEL = "查看详情"; 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_STATEMENT = "STATEMENT";
private static final String EXCLUSION_TYPE_OBJECT = "OBJECT"; private static final String EXCLUSION_TYPE_OBJECT = "OBJECT";
@@ -369,6 +375,14 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
) { ) {
ensureProjectExists(queryDTO.getProjectId()); ensureProjectExists(queryDTO.getProjectId());
normalizeSuspiciousTransactionQuery(queryDTO); normalizeSuspiciousTransactionQuery(queryDTO);
if (isSuspiciousTransactionScopeMismatch(queryDTO)) {
return emptySuspiciousTransactionPage();
}
prepareSuspiciousTransactionExternalBranch(queryDTO);
if (isExternalScopeWithoutSubject(queryDTO)) {
return emptySuspiciousTransactionPage();
}
Page<CcdiProjectSuspiciousTransactionItemVO> page = new Page<>( Page<CcdiProjectSuspiciousTransactionItemVO> page = new Page<>(
defaultPageNum(queryDTO.getPageNum()), defaultPageNum(queryDTO.getPageNum()),
@@ -376,9 +390,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
); );
Page<CcdiProjectSuspiciousTransactionItemVO> resultPage = Page<CcdiProjectSuspiciousTransactionItemVO> resultPage =
overviewMapper.selectSuspiciousTransactionPage(page, queryDTO); overviewMapper.selectSuspiciousTransactionPage(page, queryDTO);
List<CcdiProjectSuspiciousTransactionItemVO> rows =
defaultList(resultPage == null ? null : resultPage.getRecords());
attachSuspiciousTransactionHitTags(rows, queryDTO);
CcdiProjectSuspiciousTransactionPageVO result = new CcdiProjectSuspiciousTransactionPageVO(); CcdiProjectSuspiciousTransactionPageVO result = new CcdiProjectSuspiciousTransactionPageVO();
result.setRows(defaultList(resultPage == null ? null : resultPage.getRecords())); result.setRows(rows);
result.setTotal(resultPage == null ? 0L : resultPage.getTotal()); result.setTotal(resultPage == null ? 0L : resultPage.getTotal());
return result; return result;
} }
@@ -389,6 +406,14 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
) { ) {
ensureProjectExists(queryDTO.getProjectId()); ensureProjectExists(queryDTO.getProjectId());
normalizeSuspiciousTransactionQuery(queryDTO); normalizeSuspiciousTransactionQuery(queryDTO);
if (isSuspiciousTransactionScopeMismatch(queryDTO)) {
return List.of();
}
prepareSuspiciousTransactionExternalBranch(queryDTO);
if (isExternalScopeWithoutSubject(queryDTO)) {
return List.of();
}
return defaultList(overviewMapper.selectReportSuspiciousTransactionList(queryDTO)).stream() return defaultList(overviewMapper.selectReportSuspiciousTransactionList(queryDTO)).stream()
.map(this::buildSuspiciousTransactionExcelRow) .map(this::buildSuspiciousTransactionExcelRow)
@@ -453,9 +478,17 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
public void exportRiskDetails(HttpServletResponse response, Long projectId) { public void exportRiskDetails(HttpServletResponse response, Long projectId) {
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO(); CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
queryDTO.setProjectId(projectId); 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); List<CcdiProjectSuspiciousTransactionExcel> suspiciousRows = exportSuspiciousTransactions(queryDTO);
Long projectId = queryDTO.getProjectId();
List<CcdiProjectEmployeeCreditNegativeExcel> creditRows = exportEmployeeCreditNegative(projectId); List<CcdiProjectEmployeeCreditNegativeExcel> creditRows = exportEmployeeCreditNegative(projectId);
List<CcdiProjectAbnormalAccountExcel> abnormalRows = exportAbnormalAccountPeople(projectId); List<CcdiProjectAbnormalAccountExcel> abnormalRows = exportAbnormalAccountPeople(projectId);
try { try {
@@ -719,12 +752,58 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
queryDTO.setModelCode(null); queryDTO.setModelCode(null);
} }
if (queryDTO.getSuspiciousType() == null || queryDTO.getSuspiciousType().isBlank()) { if (queryDTO.getSuspiciousType() == null || queryDTO.getSuspiciousType().isBlank()) {
queryDTO.setSuspiciousType("ALL"); queryDTO.setSuspiciousType(SUSPICIOUS_TYPE_ALL);
return; return;
} }
queryDTO.setSuspiciousType(queryDTO.getSuspiciousType().trim().toUpperCase()); 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) { private CcdiProjectOverviewStatVO buildStat(String key, String label, Integer value) {
CcdiProjectOverviewStatVO stat = new CcdiProjectOverviewStatVO(); CcdiProjectOverviewStatVO stat = new CcdiProjectOverviewStatVO();
stat.setKey(key); stat.setKey(key);
@@ -826,6 +905,8 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
row.setAbnormalType(item.getAbnormalType()); row.setAbnormalType(item.getAbnormalType());
row.setAbnormalTime(item.getAbnormalTime()); row.setAbnormalTime(item.getAbnormalTime());
row.setStatus(item.getStatus()); row.setStatus(item.getStatus());
row.setReasonDetail(item.getReasonDetail());
row.setInvolvedAmount(item.getInvolvedAmount());
return row; return row;
} }
@@ -930,7 +1011,12 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return; return;
} }
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = defaultList( Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = defaultList(
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(projectId, bankStatementIds) bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
projectId,
bankStatementIds,
null,
null
)
).stream().filter(item -> item.getBankStatementId() != null) ).stream().filter(item -> item.getBankStatementId() != null)
.collect(Collectors.groupingBy( .collect(Collectors.groupingBy(
CcdiBankStatementHitTagVO::getBankStatementId, CcdiBankStatementHitTagVO::getBankStatementId,
@@ -942,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) { private void normalizeObjectRows(List<CcdiProjectPersonAnalysisObjectRecordVO> objectRows) {
objectRows.forEach(row -> { objectRows.forEach(row -> {
if (row.getRiskTags() == null) { if (row.getRiskTags() == null) {

View File

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

View File

@@ -227,6 +227,7 @@ public class CcdiProjectSpecialCheckServiceImpl implements ICcdiProjectSpecialCh
incomeDetail.setSelfIncome(BigDecimal.ZERO); incomeDetail.setSelfIncome(BigDecimal.ZERO);
incomeDetail.setSpouseIncome(BigDecimal.ZERO); incomeDetail.setSpouseIncome(BigDecimal.ZERO);
incomeDetail.setTotalIncome(BigDecimal.ZERO); incomeDetail.setTotalIncome(BigDecimal.ZERO);
incomeDetail.setExplainableIncome(BigDecimal.ZERO);
detail.setIncomeDetail(incomeDetail); detail.setIncomeDetail(incomeDetail);
} }

View File

@@ -1730,6 +1730,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and tx.txDate &lt; ai.invalid_date and tx.txDate &lt; ai.invalid_date
group by staff.id_card, ai.account_no, ai.invalid_date group by staff.id_card, ai.account_no, ai.invalid_date
) t ) t
where t.windowTotalAmount >= 500000
or t.windowMaxSingleAmount >= 100000
</select> </select>
<select id="selectDormantAccountLargeActivationObjects" resultMap="BankTagObjectHitResultMap"> <select id="selectDormantAccountLargeActivationObjects" resultMap="BankTagObjectHitResultMap">
@@ -1750,6 +1752,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
staff.id_card AS objectKey, staff.id_card AS objectKey,
ai.account_no AS accountNo, ai.account_no AS accountNo,
ai.effective_date AS effectiveDate, ai.effective_date AS effectiveDate,
project_window.projectStartDate AS projectStartDate,
min(tx.txDate) AS firstTxDate, min(tx.txDate) AS firstTxDate,
timestampdiff(MONTH, ai.effective_date, min(tx.txDate)) AS dormantMonths, timestampdiff(MONTH, ai.effective_date, min(tx.txDate)) AS dormantMonths,
round(sum(tx.tradeTotalAmount), 2) AS windowTotalAmount, round(sum(tx.tradeTotalAmount), 2) AS windowTotalAmount,
@@ -1757,6 +1760,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from ccdi_account_info ai from ccdi_account_info ai
inner join ccdi_base_staff staff inner join ccdi_base_staff staff
on staff.id_card = ai.owner_id 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 ( inner join (
select select
trim(bs.LE_ACCOUNT_NO) AS accountNo, trim(bs.LE_ACCOUNT_NO) AS accountNo,
@@ -1774,8 +1786,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where ai.owner_type = 'EMPLOYEE' where ai.owner_type = 'EMPLOYEE'
and ai.status = 1 and ai.status = 1
and ai.effective_date is not null 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) 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 ) t
where t.windowTotalAmount >= 500000 where t.windowTotalAmount >= 500000
or t.windowMaxSingleAmount >= 100000 or t.windowMaxSingleAmount >= 100000

View File

@@ -30,6 +30,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap> </resultMap>
<resultMap id="CcdiBankStatementHitTagVOResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO"> <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="ruleCode" column="rule_code"/>
<result property="ruleName" column="rule_name"/> <result property="ruleName" column="rule_name"/>
<result property="riskLevel" column="risk_level"/> <result property="riskLevel" column="risk_level"/>
@@ -47,6 +48,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectStatementTagsByProjectAndStatementIds" resultMap="CcdiBankStatementHitTagVOResultMap"> <select id="selectStatementTagsByProjectAndStatementIds" resultMap="CcdiBankStatementHitTagVOResultMap">
select select
model_code,
rule_code, rule_code,
rule_name, rule_name,
risk_level, risk_level,
@@ -67,6 +69,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<foreach collection="bankStatementIds" item="item" open="(" separator="," close=")"> <foreach collection="bankStatementIds" item="item" open="(" separator="," close=")">
#{item} #{item}
</foreach> </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 order by bank_statement_id asc, id asc
</select> </select>

View File

@@ -68,6 +68,10 @@
<resultMap id="SuspiciousTransactionItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO"> <resultMap id="SuspiciousTransactionItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO">
<id property="bankStatementId" column="bankStatementId"/> <id property="bankStatementId" column="bankStatementId"/>
<result property="trxDate" column="trxDate"/> <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="suspiciousPersonName" column="suspiciousPersonName"/>
<result property="relatedPersonName" column="relatedPersonName"/> <result property="relatedPersonName" column="relatedPersonName"/>
<result property="relatedStaffName" column="relatedStaffName"/> <result property="relatedStaffName" column="relatedStaffName"/>
@@ -88,6 +92,8 @@
<result property="abnormalType" column="abnormalType"/> <result property="abnormalType" column="abnormalType"/>
<result property="abnormalTime" column="abnormal_time"/> <result property="abnormalTime" column="abnormal_time"/>
<result property="status" column="status"/> <result property="status" column="status"/>
<result property="reasonDetail" column="reasonDetail"/>
<result property="involvedAmount" column="involved_amount"/>
</resultMap> </resultMap>
<resultMap id="ReportUploadSubjectResultMap" <resultMap id="ReportUploadSubjectResultMap"
@@ -811,6 +817,22 @@
and risk.cert_no = subject.cert_no and risk.cert_no = subject.cert_no
</select> </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"> <select id="selectExternalRiskModelCardsByProjectId" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO">
<bind name="externalProjectId" value="projectId"/> <bind name="externalProjectId" value="projectId"/>
select select
@@ -1008,9 +1030,21 @@
and ex.exclusion_type = 'STATEMENT' and ex.exclusion_type = 'STATEMENT'
and ex.bank_statement_id = tr.bank_statement_id and ex.bank_statement_id = tr.bank_statement_id
) )
<include refid="suspiciousTransactionModelScopeSql"/>
</sql>
<sql id="suspiciousTransactionModelScopeSql">
<if test="query.modelCode != null and query.modelCode != ''"> <if test="query.modelCode != null and query.modelCode != ''">
and tr.model_code = #{query.modelCode} and tr.model_code = #{query.modelCode}
</if> </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>
<sql id="suspiciousTransactionNameHitSql"> <sql id="suspiciousTransactionNameHitSql">
@@ -1161,6 +1195,7 @@
<include refid="suspiciousTransactionNameHitSql"/> <include refid="suspiciousTransactionNameHitSql"/>
) name_hits on name_hits.bankStatementId = base.bankStatementId ) name_hits on name_hits.bankStatementId = base.bankStatementId
<if test="query.includeExternalPerson == true">
union all union all
select select
@@ -1181,6 +1216,7 @@
from ( from (
<include refid="externalSuspiciousTransactionSql"/> <include refid="externalSuspiciousTransactionSql"/>
) external_hits ) external_hits
</if>
</sql> </sql>
<sql id="suspiciousTransactionAggregatedSql"> <sql id="suspiciousTransactionAggregatedSql">
@@ -1269,6 +1305,10 @@
select select
final_result.bankStatementId, final_result.bankStatementId,
final_result.trxDate, 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.suspiciousPersonName,
final_result.relatedPersonName, final_result.relatedPersonName,
final_result.relatedStaffName, final_result.relatedStaffName,
@@ -1283,6 +1323,8 @@
from ( from (
<include refid="suspiciousTransactionAggregatedSql"/> <include refid="suspiciousTransactionAggregatedSql"/>
) final_result ) final_result
inner join ccdi_bank_statement bs
on bs.bank_statement_id = final_result.bankStatementId
<include refid="suspiciousTransactionFilterSql"/> <include refid="suspiciousTransactionFilterSql"/>
order by final_result.trxDate desc, final_result.bankStatementId desc order by final_result.trxDate desc, final_result.bankStatementId desc
</select> </select>
@@ -1291,6 +1333,10 @@
select select
final_result.bankStatementId, final_result.bankStatementId,
final_result.trxDate, 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.suspiciousPersonName,
final_result.relatedPersonName, final_result.relatedPersonName,
final_result.relatedStaffName, final_result.relatedStaffName,
@@ -1305,6 +1351,8 @@
from ( from (
<include refid="suspiciousTransactionAggregatedSql"/> <include refid="suspiciousTransactionAggregatedSql"/>
) final_result ) final_result
inner join ccdi_bank_statement bs
on bs.bank_statement_id = final_result.bankStatementId
<include refid="suspiciousTransactionFilterSql"/> <include refid="suspiciousTransactionFilterSql"/>
order by final_result.trxDate desc, final_result.bankStatementId desc order by final_result.trxDate desc, final_result.bankStatementId desc
</select> </select>
@@ -1357,6 +1405,7 @@
and ex.exclusion_type = 'STATEMENT' and ex.exclusion_type = 'STATEMENT'
and ex.bank_statement_id = tr.bank_statement_id and ex.bank_statement_id = tr.bank_statement_id
) )
<include refid="suspiciousTransactionModelScopeSql"/>
group by tr.bank_statement_id group by tr.bank_statement_id
) tag_result on tag_result.bank_statement_id = final_result.bankStatementId ) tag_result on tag_result.bank_statement_id = final_result.bankStatementId
<include refid="suspiciousTransactionFilterSql"/> <include refid="suspiciousTransactionFilterSql"/>
@@ -1426,7 +1475,12 @@
when account.status = 1 then '正常' when account.status = 1 then '正常'
when account.status = 2 then '已销户' when account.status = 2 then '已销户'
else cast(account.status as char) 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 from ccdi_bank_statement_tag_result tr
inner join ccdi_account_info account inner join ccdi_account_info account
on account.owner_type = 'EMPLOYEE' on account.owner_type = 'EMPLOYEE'
@@ -1453,7 +1507,9 @@
abnormal.bankName, abnormal.bankName,
abnormal.abnormalType, abnormal.abnormalType,
abnormal.abnormal_time, abnormal.abnormal_time,
abnormal.status abnormal.status,
abnormal.reasonDetail,
abnormal.involved_amount
from ( from (
<include refid="abnormalAccountBaseSql"/> <include refid="abnormalAccountBaseSql"/>
where tr.project_id = #{query.projectId} where tr.project_id = #{query.projectId}
@@ -1476,7 +1532,9 @@
abnormal.bankName, abnormal.bankName,
abnormal.abnormalType, abnormal.abnormalType,
abnormal.abnormal_time, abnormal.abnormal_time,
abnormal.status abnormal.status,
abnormal.reasonDetail,
abnormal.involved_amount
from ( from (
<include refid="abnormalAccountBaseSql"/> <include refid="abnormalAccountBaseSql"/>
where tr.project_id = #{projectId} where tr.project_id = #{projectId}

View File

@@ -9,9 +9,14 @@
<result property="staffName" column="staff_name"/> <result property="staffName" column="staff_name"/>
<result property="deptName" column="dept_name"/> <result property="deptName" column="dept_name"/>
<result property="totalIncome" column="total_income"/> <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="totalAsset" column="total_asset"/>
<result property="totalDebt" column="total_debt"/> <result property="totalDebt" column="total_debt"/>
<result property="comparisonAmount" column="comparison_amount"/> <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="riskLevelCode" column="risk_level_code"/>
<result property="riskLevelName" column="risk_level_name"/> <result property="riskLevelName" column="risk_level_name"/>
</resultMap> </resultMap>
@@ -95,7 +100,9 @@
javaType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyIncomeDetailVO"> javaType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyIncomeDetailVO">
<result property="selfIncome" column="income_self_income"/> <result property="selfIncome" column="income_self_income"/>
<result property="spouseIncome" column="income_spouse_income"/> <result property="spouseIncome" column="income_spouse_income"/>
<result property="employmentYears" column="income_employment_years"/>
<result property="totalIncome" column="income_total_income"/> <result property="totalIncome" column="income_total_income"/>
<result property="explainableIncome" column="income_explainable_income"/>
</association> </association>
<association property="assetDetail" <association property="assetDetail"
javaType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetDetailVO"> javaType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectFamilyAssetDetailVO">
@@ -126,9 +133,14 @@
<result property="staffName" column="summary_staff_name"/> <result property="staffName" column="summary_staff_name"/>
<result property="deptName" column="summary_dept_name"/> <result property="deptName" column="summary_dept_name"/>
<result property="totalIncome" column="summary_total_income"/> <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="totalAsset" column="summary_total_asset"/>
<result property="totalDebt" column="summary_total_debt"/> <result property="totalDebt" column="summary_total_debt"/>
<result property="comparisonAmount" column="summary_comparison_amount"/> <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="riskLevelCode" column="summary_risk_level_code"/>
<result property="riskLevelName" column="summary_risk_level_name"/> <result property="riskLevelName" column="summary_risk_level_name"/>
</association> </association>
@@ -194,44 +206,58 @@
aggregated.staff_name, aggregated.staff_name,
aggregated.dept_name, aggregated.dept_name,
aggregated.total_income, aggregated.total_income,
aggregated.self_income,
aggregated.spouse_income,
aggregated.employment_years,
aggregated.total_asset, aggregated.total_asset,
aggregated.total_debt, aggregated.total_debt,
aggregated.comparison_amount, aggregated.comparison_amount,
aggregated.explainable_income,
aggregated.asset_income_ratio,
case case
when aggregated.missing_asset_info = 1 or aggregated.missing_debt_info = 1 then 'MISSING_INFO' when aggregated.missing_info = 1 then 'MISSING_INFO'
when comparison_amount &lt;= total_asset * 1.5 then 'NORMAL' when aggregated.asset_income_ratio &lt;= 1.5 then 'NORMAL'
when comparison_amount &gt; total_asset * 1.5 and comparison_amount &lt;= total_asset * 3 then 'RISK' when aggregated.asset_income_ratio &gt; 1.5 and aggregated.asset_income_ratio &lt;= 3 then 'RISK'
when comparison_amount &gt; total_asset * 3 then 'HIGH' when aggregated.asset_income_ratio &gt; 3 then 'HIGH'
else 'HIGH' else 'HIGH'
end as risk_level_code, end as risk_level_code,
case case
when aggregated.missing_asset_info = 1 or aggregated.missing_debt_info = 1 then '缺少信息' when aggregated.missing_info = 1 then '缺少信息'
when comparison_amount &lt;= total_asset * 1.5 then '正常' when aggregated.asset_income_ratio &lt;= 1.5 then '正常'
when comparison_amount &gt; total_asset * 1.5 and comparison_amount &lt;= total_asset * 3 then '存在风险' when aggregated.asset_income_ratio &gt; 1.5 and aggregated.asset_income_ratio &lt;= 3 then '关注'
when comparison_amount &gt; total_asset * 3 then '高风险' when aggregated.asset_income_ratio &gt; 3 then '高关注'
else '高风险' else '高关注'
end as risk_level_name end as risk_level_name
from ( from (
select select
source.*, source.*,
case case
when source.self_asset_record_count = 0 when source.self_income &lt;= 0
or source.spouse_staff_asset_record_count = 0 then 1 or source.employment_years is null
or source.total_asset &lt;= 0 then 1
else 0 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 case
when source.self_debt_record_count = 0 when source.self_income &lt;= 0
or source.spouse_staff_debt_record_count = 0 then 1 or source.employment_years is null
else 0 or source.total_asset &lt;= 0
end as missing_debt_info, 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 case
when source.self_asset_record_count = 0 when source.self_income &lt;= 0
or source.spouse_staff_asset_record_count = 0 or source.employment_years is null
or source.self_debt_record_count = 0 or source.total_asset &lt;= 0
or source.spouse_staff_debt_record_count = 0 then 4 or (source.self_income * source.employment_years + source.spouse_income + source.total_debt) &lt;= 0 then 0
when source.comparison_amount &lt;= source.total_asset * 1.5 then 1 when source.total_asset / (source.self_income * source.employment_years + source.spouse_income + source.total_debt) &lt;= 1.5 then 1
when source.comparison_amount &lt;= source.total_asset * 3 then 2 when source.total_asset / (source.self_income * source.employment_years + source.spouse_income + source.total_debt) &lt;= 3 then 2
when source.comparison_amount &gt; source.total_asset * 3 then 3 when source.total_asset / (source.self_income * source.employment_years + source.spouse_income + source.total_debt) &gt; 3 then 3
else 3 else 3
end as risk_level_sort end as risk_level_sort
from ( from (
@@ -240,6 +266,12 @@
scope.staff_code, scope.staff_code,
scope.staff_name, scope.staff_name,
scope.dept_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(base_staff.annual_income, 0) + coalesce(spouse.spouse_income, 0) as total_income,
coalesce(( coalesce((
select count(1) select count(1)
@@ -294,15 +326,7 @@
from ccdi_debts_info debt from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card) or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as total_debt, ), 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
from ( from (
<include refid="projectEmployeeScopeSql"/> <include refid="projectEmployeeScopeSql"/>
) scope ) scope
@@ -328,7 +352,9 @@
aggregated.dept_name, aggregated.dept_name,
aggregated.self_income as income_self_income, aggregated.self_income as income_self_income,
aggregated.spouse_income as income_spouse_income, aggregated.spouse_income as income_spouse_income,
aggregated.employment_years as income_employment_years,
aggregated.total_income as income_total_income, 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.missing_self_asset_info as asset_missing_self_asset_info,
aggregated.self_total_asset as asset_self_total_asset, aggregated.self_total_asset as asset_self_total_asset,
aggregated.spouse_total_asset as asset_spouse_total_asset, aggregated.spouse_total_asset as asset_spouse_total_asset,
@@ -342,36 +368,61 @@
aggregated.staff_name as summary_staff_name, aggregated.staff_name as summary_staff_name,
aggregated.dept_name as summary_dept_name, aggregated.dept_name as summary_dept_name,
aggregated.total_income as summary_total_income, 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_asset as summary_total_asset,
aggregated.total_debt as summary_total_debt, aggregated.total_debt as summary_total_debt,
aggregated.comparison_amount as summary_comparison_amount, aggregated.comparison_amount as summary_comparison_amount,
aggregated.explainable_income as summary_explainable_income,
aggregated.asset_income_ratio as summary_asset_income_ratio,
case case
when aggregated.missing_self_asset_info = 1 or aggregated.missing_self_debt_info = 1 then 'MISSING_INFO' when aggregated.missing_info = 1 then 'MISSING_INFO'
when comparison_amount &lt;= total_asset * 1.5 then 'NORMAL' when aggregated.asset_income_ratio &lt;= 1.5 then 'NORMAL'
when comparison_amount &gt; total_asset * 1.5 and comparison_amount &lt;= total_asset * 3 then 'RISK' when aggregated.asset_income_ratio &gt; 1.5 and aggregated.asset_income_ratio &lt;= 3 then 'RISK'
when comparison_amount &gt; total_asset * 3 then 'HIGH' when aggregated.asset_income_ratio &gt; 3 then 'HIGH'
else 'HIGH' else 'HIGH'
end as summary_risk_level_code, end as summary_risk_level_code,
case case
when aggregated.missing_self_asset_info = 1 or aggregated.missing_self_debt_info = 1 then '缺少信息' when aggregated.missing_info = 1 then '缺少信息'
when comparison_amount &lt;= total_asset * 1.5 then '正常' when aggregated.asset_income_ratio &lt;= 1.5 then '正常'
when comparison_amount &gt; total_asset * 1.5 and comparison_amount &lt;= total_asset * 3 then '存在风险' when aggregated.asset_income_ratio &gt; 1.5 and aggregated.asset_income_ratio &lt;= 3 then '关注'
when comparison_amount &gt; total_asset * 3 then '高风险' when aggregated.asset_income_ratio &gt; 3 then '高关注'
else '高风险' else '高关注'
end as summary_risk_level_name end as summary_risk_level_name
from ( from (
select select
source.*, source.*,
case case
when source.self_asset_record_count = 0 when source.self_income &lt;= 0
or source.spouse_staff_asset_record_count = 0 then 1 or source.employment_years is null
or source.total_asset &lt;= 0 then 1
else 0 else 0
end as missing_self_asset_info, end as missing_self_asset_info,
case case
when source.self_debt_record_count = 0 when source.self_debt_record_count = 0
or source.spouse_staff_debt_record_count = 0 then 1 or source.spouse_staff_debt_record_count = 0 then 1
else 0 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 ( from (
select select
#{projectId} as project_id, #{projectId} as project_id,
@@ -383,6 +434,10 @@
spouse.spouse_is_staff, spouse.spouse_is_staff,
coalesce(base_staff.annual_income, 0) as self_income, coalesce(base_staff.annual_income, 0) as self_income,
coalesce(spouse.spouse_income, 0) as spouse_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(base_staff.annual_income, 0) + coalesce(spouse.spouse_income, 0) as total_income,
coalesce(( coalesce((
select count(1) select count(1)
@@ -471,15 +526,7 @@
from ccdi_debts_info debt from ccdi_debts_info debt
where debt.person_id = scope.staff_id_card where debt.person_id = scope.staff_id_card
or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card) or (spouse.spouse_id_card is not null and debt.person_id = spouse.spouse_id_card)
), 0) as total_debt, ), 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
from ( from (
<include refid="projectEmployeeScopeSql"/> <include refid="projectEmployeeScopeSql"/>
) scope ) scope

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

View File

@@ -88,11 +88,12 @@ class CcdiFileUploadControllerTest {
CcdiPullBankInfoSubmitDTO dto = new CcdiPullBankInfoSubmitDTO(); CcdiPullBankInfoSubmitDTO dto = new CcdiPullBankInfoSubmitDTO();
dto.setProjectId(PROJECT_ID); dto.setProjectId(PROJECT_ID);
dto.setIdCards(List.of("110101199001018888")); dto.setIdCards(List.of("110101199001018888"));
dto.setDataChannelCode("ZJRCU");
dto.setStartDate("2026-03-01"); dto.setStartDate("2026-03-01");
dto.setEndDate("2026-03-10"); dto.setEndDate("2026-03-10");
setLoginUser(9527L, "admin"); setLoginUser(9527L, "admin");
when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "2026-03-01", "2026-03-10", 9527L, "admin")) when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "ZJRCU", "2026-03-01", "2026-03-10", 9527L, "admin"))
.thenReturn("batch-1"); .thenReturn("batch-1");
AjaxResult result = controller.pullBankInfo(dto); AjaxResult result = controller.pullBankInfo(dto);
@@ -100,6 +101,35 @@ class CcdiFileUploadControllerTest {
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 @Test
void deleteFile_shouldUseCurrentLoginUserId() { void deleteFile_shouldUseCurrentLoginUserId() {
setLoginUser(9527L, "admin"); setLoginUser(9527L, "admin");

View File

@@ -172,7 +172,7 @@ class CcdiProjectOverviewControllerContractTest {
Method method = controllerClass.getMethod( Method method = controllerClass.getMethod(
"exportRiskDetails", "exportRiskDetails",
HttpServletResponse.class, HttpServletResponse.class,
Long.class Class.forName("com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO")
); );
PostMapping postMapping = method.getAnnotation(PostMapping.class); PostMapping postMapping = method.getAnnotation(PostMapping.class);
Operation operation = method.getAnnotation(Operation.class); Operation operation = method.getAnnotation(Operation.class);
@@ -198,7 +198,7 @@ class CcdiProjectOverviewControllerContractTest {
assertEquals("/report/export", requestMapping.value()[0]); assertEquals("/report/export", requestMapping.value()[0]);
assertEquals(List.of(RequestMethod.GET, RequestMethod.POST), Arrays.asList(requestMapping.method())); assertEquals(List.of(RequestMethod.GET, RequestMethod.POST), Arrays.asList(requestMapping.method()));
assertNotNull(operation); assertNotNull(operation);
assertEquals("一键导出结果总览报告", operation.summary()); assertEquals("导出结果总览报告", operation.summary());
assertNotNull(preAuthorize); assertNotNull(preAuthorize);
assertEquals("@ss.hasPermi('ccdi:project:query')", preAuthorize.value()); assertEquals("@ss.hasPermi('ccdi:project:query')", preAuthorize.value());
} }
@@ -211,7 +211,10 @@ class CcdiProjectOverviewControllerContractTest {
.map(Field::getName) .map(Field::getName)
.collect(Collectors.toList()); .collect(Collectors.toList());
assertEquals(List.of("projectId", "suspiciousType", "pageNum", "pageSize"), fieldNames); assertEquals(
List.of("projectId", "modelCode", "suspiciousType", "pageNum", "pageSize", "includeExternalPerson"),
fieldNames
);
} }
@Test @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.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService; import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -52,6 +53,9 @@ class CcdiProjectOverviewControllerTest {
@Mock @Mock
private ICcdiProjectOverviewService overviewService; private ICcdiProjectOverviewService overviewService;
@Mock
private CcdiProjectAccessService projectAccessService;
@Test @Test
void shouldExposeDashboardEndpoint() throws Exception { void shouldExposeDashboardEndpoint() throws Exception {
when(overviewService.getDashboard(40L)).thenReturn(new CcdiProjectOverviewDashboardVO()); when(overviewService.getDashboard(40L)).thenReturn(new CcdiProjectOverviewDashboardVO());
@@ -308,15 +312,20 @@ class CcdiProjectOverviewControllerTest {
@Test @Test
void shouldExposeRiskDetailsExportEndpoint() throws Exception { void shouldExposeRiskDetailsExportEndpoint() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse(); 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( Method method = CcdiProjectOverviewController.class.getMethod(
"exportRiskDetails", "exportRiskDetails",
jakarta.servlet.http.HttpServletResponse.class, jakarta.servlet.http.HttpServletResponse.class,
Long.class CcdiProjectSuspiciousTransactionQueryDTO.class
); );
PostMapping postMapping = method.getAnnotation(PostMapping.class); PostMapping postMapping = method.getAnnotation(PostMapping.class);
Operation operation = method.getAnnotation(Operation.class); Operation operation = method.getAnnotation(Operation.class);

View File

@@ -72,6 +72,26 @@ class CcdiBankTagAnalysisMapperXmlTest {
assertTrue(xml.contains("AS logId")); 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 @Test
void houseOrCarExpenseRule_shouldJoinBankStatementAndReturnStatementHitFields() throws Exception { void houseOrCarExpenseRule_shouldJoinBankStatementAndReturnStatementHitFields() throws Exception {
String xml = readXml(RESOURCE); String xml = readXml(RESOURCE);

View File

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

@@ -91,12 +91,19 @@ class CcdiProjectOverviewMapperSqlTest {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml")); String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String suspiciousSql = extractSelect(xml, "selectSuspiciousTransactionPage"); String suspiciousSql = extractSelect(xml, "selectSuspiciousTransactionPage");
String modelHitSql = extractSqlFragment(xml, "suspiciousTransactionModelHitSql"); String modelHitSql = extractSqlFragment(xml, "suspiciousTransactionModelHitSql");
String modelScopeSql = extractSqlFragment(xml, "suspiciousTransactionModelScopeSql");
String mergedSql = extractSqlFragment(xml, "suspiciousTransactionMergedSql");
String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql"); String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql");
String externalSubjectExistsSql = extractSelect(xml, "selectExternalPersonSubjectExistsByProjectId");
assertTrue(modelHitSql.contains("from ccdi_bank_statement_tag_result tr"), modelHitSql); assertTrue(modelHitSql.contains("from ccdi_bank_statement_tag_result tr"), modelHitSql);
assertTrue(modelHitSql.contains("tr.bank_statement_id is not null"), modelHitSql); assertTrue(modelHitSql.contains("tr.bank_statement_id is not null"), modelHitSql);
assertFalse(modelHitSql.contains("rule_name like '%可疑%'"), modelHitSql); assertFalse(modelHitSql.contains("rule_name like '%可疑%'"), modelHitSql);
assertFalse(modelHitSql.contains("ABNORMAL_CUSTOMER_TRANSACTION"), 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_biz_intermediary"), suspiciousSql);
assertTrue(suspiciousSql.contains("ccdi_enterprise_base_info"), suspiciousSql); assertTrue(suspiciousSql.contains("ccdi_enterprise_base_info"), suspiciousSql);
assertTrue(suspiciousSql.contains("group by merged.bankStatementId"), suspiciousSql); assertTrue(suspiciousSql.contains("group by merged.bankStatementId"), suspiciousSql);
@@ -104,12 +111,21 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(suspiciousSql.contains("hasModelRuleHit"), suspiciousSql); assertTrue(suspiciousSql.contains("hasModelRuleHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("hasNameListHit"), suspiciousSql); assertTrue(suspiciousSql.contains("hasNameListHit"), suspiciousSql);
assertTrue(suspiciousSql.contains("final_result.nameListHitType"), 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"); String reportSuspiciousSql = extractSelect(xml, "selectReportSuspiciousTransactionList");
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '中介'"), reportSuspiciousSql); assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '中介'"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("疑似与中介往来"), reportSuspiciousSql); assertTrue(reportSuspiciousSql.contains("疑似与中介往来"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '信贷客户'"), reportSuspiciousSql); assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '信贷客户'"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("与信贷客户之间非正常资金往来"), reportSuspiciousSql); assertTrue(reportSuspiciousSql.contains("与信贷客户之间非正常资金往来"), reportSuspiciousSql);
assertTrue(reportSuspiciousSql.contains("suspiciousTransactionModelScopeSql"), reportSuspiciousSql);
} }
@Test @Test
@@ -195,6 +211,8 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(abnormalPageSql.contains("tr.bank_statement_id is null"), abnormalPageSql); assertTrue(abnormalPageSql.contains("tr.bank_statement_id is null"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("account.owner_type = 'EMPLOYEE'"), abnormalPageSql); assertTrue(abnormalPageSql.contains("account.owner_type = 'EMPLOYEE'"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("tr.reason_detail"), 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("instr(tr.reason_detail, account.account_no) > 0"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("when account.status = 1 then '正常'"), abnormalPageSql); assertTrue(abnormalPageSql.contains("when account.status = 1 then '正常'"), abnormalPageSql);
assertTrue(abnormalPageSql.contains("when account.status = 2 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("tr.bank_statement_id is null"), abnormalExportSql);
assertTrue(abnormalExportSql.contains("account.owner_type = 'EMPLOYEE'"), abnormalExportSql); assertTrue(abnormalExportSql.contains("account.owner_type = 'EMPLOYEE'"), abnormalExportSql);
assertTrue(abnormalExportSql.contains("tr.reason_detail"), abnormalExportSql); assertTrue(abnormalExportSql.contains("tr.reason_detail"), abnormalExportSql);
assertTrue(abnormalExportSql.contains("abnormal.reasonDetail"), abnormalExportSql);
assertTrue(abnormalExportSql.contains("abnormal.involved_amount"), abnormalExportSql);
assertTrue( assertTrue(
abnormalExportSql.contains("order by abnormal_time desc, account.account_no asc, tr.rule_code asc"), abnormalExportSql.contains("order by abnormal_time desc, account.account_no asc, tr.rule_code asc"),
abnormalExportSql abnormalExportSql
); );
assertTrue(xml.contains("tr.reason_detail as reasonDetail"), xml);
assertTrue(xml.contains("as involved_amount"), xml);
} }
private String extractSelect(String xml, String selectId) { private String extractSelect(String xml, String selectId) {

View File

@@ -129,7 +129,9 @@ class CcdiBankStatementServiceImplTest {
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO(); CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(51274L); hitTag.setBankStatementId(51274L);
hitTag.setRuleName("大额存现交易"); hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L))) when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
43L, List.of(51274L), null, null
))
.thenReturn(List.of(hitTag)); .thenReturn(List.of(hitTag));
List<CcdiBankStatementExcel> result = service.selectStatementListForExport(queryDTO); List<CcdiBankStatementExcel> result = service.selectStatementListForExport(queryDTO);
@@ -152,7 +154,9 @@ class CcdiBankStatementServiceImplTest {
hitTag.setBankStatementId(51274L); hitTag.setBankStatementId(51274L);
hitTag.setRuleCode("LARGE_CASH_DEPOSIT"); hitTag.setRuleCode("LARGE_CASH_DEPOSIT");
hitTag.setRuleName("大额存现交易"); hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L))) when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
43L, List.of(51274L), null, null
))
.thenReturn(List.of(hitTag)); .thenReturn(List.of(hitTag));
Page<CcdiBankStatementListVO> result = service.selectStatementPage(page, queryDTO); Page<CcdiBankStatementListVO> result = service.selectStatementPage(page, queryDTO);
@@ -170,7 +174,9 @@ class CcdiBankStatementServiceImplTest {
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO(); CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
hitTag.setBankStatementId(200L); hitTag.setBankStatementId(200L);
hitTag.setRuleName("大额存现交易"); hitTag.setRuleName("大额存现交易");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(200L))) when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
43L, List.of(200L), null, null
))
.thenReturn(List.of(hitTag)); .thenReturn(List.of(hitTag));
CcdiBankStatementDetailVO result = service.getStatementDetail(200L); CcdiBankStatementDetailVO result = service.getStatementDetail(200L);
@@ -179,4 +185,21 @@ class CcdiBankStatementServiceImplTest {
assertEquals(1, result.getHitTags().size()); assertEquals(1, result.getHitTags().size());
assertEquals("大额存现交易", result.getHitTags().get(0).getRuleName()); 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.alibaba.excel.EasyExcel;
import com.ruoyi.ccdi.project.domain.CcdiProject; import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.enums.TriggerType; 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.vo.CcdiFileUploadStatisticsVO;
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord; import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper; 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.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient; 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.request.GetBankStatementRequest;
import com.ruoyi.lsfx.domain.response.CheckParseStatusResponse; import com.ruoyi.lsfx.domain.response.CheckParseStatusResponse;
import com.ruoyi.lsfx.domain.response.DeleteFilesResponse; import com.ruoyi.lsfx.domain.response.DeleteFilesResponse;
@@ -143,6 +146,7 @@ class CcdiFileUploadServiceImplTest {
String batchId = service.submitPullBankInfo( String batchId = service.submitPullBankInfo(
PROJECT_ID, PROJECT_ID,
List.of("110101199001018888", "110101199001019999"), List.of("110101199001018888", "110101199001019999"),
LsfxConstants.DATA_CHANNEL_ZJRCU,
"2026-03-01", "2026-03-01",
"2026-03-10", "2026-03-10",
9527L, 9527L,
@@ -170,6 +174,7 @@ class CcdiFileUploadServiceImplTest {
() -> service.submitPullBankInfo( () -> service.submitPullBankInfo(
PROJECT_ID, PROJECT_ID,
List.of("3301"), List.of("3301"),
LsfxConstants.DATA_CHANNEL_ZJRCU,
"2026-01-01", "2026-01-01",
"2026-01-31", "2026-01-31",
1L, 1L,
@@ -437,6 +442,7 @@ class CcdiFileUploadServiceImplTest {
LSFX_PROJECT_ID, LSFX_PROJECT_ID,
record, record,
"110101199001018888", "110101199001018888",
LsfxConstants.DATA_CHANNEL_ZJRCU,
"2026-03-01", "2026-03-01",
"2026-03-10" "2026-03-10"
); );
@@ -449,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 @Test
void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException { void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException {
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx"))) when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
@@ -470,6 +504,44 @@ class CcdiFileUploadServiceImplTest {
), eq("原始流水.xlsx")); ), 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 @Test
void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException { void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException {
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
@@ -717,7 +789,8 @@ class CcdiFileUploadServiceImplTest {
"fetchAndSaveBankStatements", "fetchAndSaveBankStatements",
PROJECT_ID, PROJECT_ID,
LSFX_PROJECT_ID, LSFX_PROJECT_ID,
LOG_ID LOG_ID,
null
); );
assertTrue(Boolean.TRUE.equals(ReflectionTestUtils.getField(result, "success"))); assertTrue(Boolean.TRUE.equals(ReflectionTestUtils.getField(result, "success")));
@@ -929,6 +1002,27 @@ class CcdiFileUploadServiceImplTest {
return response; 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, private void invokeSubmitTasksAsync(List<String> tempFilePaths,
List<CcdiFileUploadRecord> records, List<CcdiFileUploadRecord> records,
String batchId) throws Exception { String batchId) throws Exception {

View File

@@ -163,6 +163,9 @@ class CcdiProjectOverviewReportPdfExporterTest {
private String resolveTestFontPath() { private String resolveTestFontPath() {
List<String> candidates = List.of( 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 Medium.ttc",
"/System/Library/Fonts/STHeiti Light.ttc", "/System/Library/Fonts/STHeiti Light.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc",
@@ -325,6 +328,8 @@ class CcdiProjectOverviewReportPdfExporterTest {
row.setAbnormalType("突然销户"); row.setAbnormalType("突然销户");
row.setAbnormalTime("2026-03-20"); row.setAbnormalTime("2026-03-20");
row.setStatus("已销户"); row.setStatus("已销户");
row.setReasonDetail("账户6222000000000003销户前存在异常交易");
row.setInvolvedAmount(new BigDecimal("120000.00"));
return row; 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.CcdiProjectOverviewEmployeeResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -65,6 +66,8 @@ class CcdiProjectOverviewServiceAbnormalAccountTest {
item.setAbnormalType("突然销户"); item.setAbnormalType("突然销户");
item.setAbnormalTime("2026-03-20"); item.setAbnormalTime("2026-03-20");
item.setStatus("已销户"); item.setStatus("已销户");
item.setReasonDetail("账户6222000000000001销户前存在异常交易");
item.setInvolvedAmount(new BigDecimal("120000.00"));
Page<CcdiProjectAbnormalAccountItemVO> resultPage = new Page<>(1, 5); Page<CcdiProjectAbnormalAccountItemVO> resultPage = new Page<>(1, 5);
resultPage.setRecords(List.of(item)); resultPage.setRecords(List.of(item));
@@ -78,6 +81,8 @@ class CcdiProjectOverviewServiceAbnormalAccountTest {
assertEquals(1L, result.getTotal()); assertEquals(1L, result.getTotal());
assertEquals("6222000000000001", result.getRows().getFirst().getAccountNo()); assertEquals("6222000000000001", result.getRows().getFirst().getAccountNo());
assertEquals("突然销户", result.getRows().getFirst().getAbnormalType()); assertEquals("突然销户", result.getRows().getFirst().getAbnormalType());
assertEquals("账户6222000000000001销户前存在异常交易", result.getRows().getFirst().getReasonDetail());
assertEquals(new BigDecimal("120000.00"), result.getRows().getFirst().getInvolvedAmount());
verify(overviewMapper).selectAbnormalAccountPage( verify(overviewMapper).selectAbnormalAccountPage(
argThat(page -> page.getCurrent() == 1L && page.getSize() == 5L), argThat(page -> page.getCurrent() == 1L && page.getSize() == 5L),
argThat(query -> query.getProjectId().equals(40L)) argThat(query -> query.getProjectId().equals(40L))
@@ -115,6 +120,8 @@ class CcdiProjectOverviewServiceAbnormalAccountTest {
item.setAbnormalType("休眠账户大额启用"); item.setAbnormalType("休眠账户大额启用");
item.setAbnormalTime("2025-08-01"); item.setAbnormalTime("2025-08-01");
item.setStatus("正常"); item.setStatus("正常");
item.setReasonDetail("账户6222000000000002休眠后大额启用");
item.setInvolvedAmount(new BigDecimal("500000.00"));
when(overviewMapper.selectAbnormalAccountList(40L)).thenReturn(List.of(item)); when(overviewMapper.selectAbnormalAccountList(40L)).thenReturn(List.of(item));
List<CcdiProjectAbnormalAccountExcel> rows = service.exportAbnormalAccountPeople(40L); List<CcdiProjectAbnormalAccountExcel> rows = service.exportAbnormalAccountPeople(40L);
@@ -126,6 +133,8 @@ class CcdiProjectOverviewServiceAbnormalAccountTest {
assertEquals("休眠账户大额启用", rows.getFirst().getAbnormalType()); assertEquals("休眠账户大额启用", rows.getFirst().getAbnormalType());
assertEquals("2025-08-01", rows.getFirst().getAbnormalTime()); assertEquals("2025-08-01", rows.getFirst().getAbnormalTime());
assertEquals("正常", rows.getFirst().getStatus()); assertEquals("正常", rows.getFirst().getStatus());
assertEquals("账户6222000000000002休眠后大额启用", rows.getFirst().getReasonDetail());
assertEquals(new BigDecimal("500000.00"), rows.getFirst().getInvolvedAmount());
verify(overviewMapper).selectAbnormalAccountList(40L); verify(overviewMapper).selectAbnormalAccountList(40L);
} }

View File

@@ -295,6 +295,8 @@ class CcdiProjectOverviewServiceImplTest {
abnormalItem.setAbnormalType("突然销户"); abnormalItem.setAbnormalType("突然销户");
abnormalItem.setAbnormalTime("2026-03-20"); abnormalItem.setAbnormalTime("2026-03-20");
abnormalItem.setStatus("已销户"); abnormalItem.setStatus("已销户");
abnormalItem.setReasonDetail("账户6222000000000001销户前存在异常交易");
abnormalItem.setInvolvedAmount(new BigDecimal("120000.00"));
when(overviewMapper.selectAbnormalAccountList(40L)).thenReturn(List.of(abnormalItem)); when(overviewMapper.selectAbnormalAccountList(40L)).thenReturn(List.of(abnormalItem));
MockHttpServletResponse response = new MockHttpServletResponse(); MockHttpServletResponse response = new MockHttpServletResponse();
@@ -316,7 +318,10 @@ class CcdiProjectOverviewServiceImplTest {
rows.size() == 1 && "李四".equals(rows.getFirst().getPersonName()) rows.size() == 1 && "李四".equals(rows.getFirst().getPersonName())
), ),
argThat((List<CcdiProjectAbnormalAccountExcel> rows) -> 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.setRuleCode("RULE_A");
hitTag.setRuleName("大额转账"); hitTag.setRuleName("大额转账");
hitTag.setRiskLevel("HIGH"); hitTag.setRiskLevel("HIGH");
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(40L, List.of(1L))) when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(
40L, List.of(1L), null, null
))
.thenReturn(List.of(hitTag)); .thenReturn(List.of(hitTag));
CcdiProjectPersonAnalysisObjectRecordVO objectRow = new CcdiProjectPersonAnalysisObjectRecordVO(); 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.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel; import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO; 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.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper; 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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; 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 @Test
void shouldExportSuspiciousTransactionsWithCurrentFilter() { void shouldExportSuspiciousTransactionsWithCurrentFilter() {
CcdiProject project = new CcdiProject(); CcdiProject project = new CcdiProject();

View File

@@ -47,6 +47,8 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
abnormalRow.setAbnormalType("突然销户"); abnormalRow.setAbnormalType("突然销户");
abnormalRow.setAbnormalTime("2026-03-20"); abnormalRow.setAbnormalTime("2026-03-20");
abnormalRow.setStatus("已销户"); abnormalRow.setStatus("已销户");
abnormalRow.setReasonDetail("账户6222000000000001销户前存在异常交易");
abnormalRow.setInvolvedAmount(new BigDecimal("120000.00"));
exporter.export(response, 40L, List.of(suspiciousRow), List.of(creditRow), List.of(abnormalRow)); 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(3).getStringCellValue());
assertEquals("异常发生时间", workbook.getSheetAt(2).getRow(0).getCell(4).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(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("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(1).getStringCellValue());
assertEquals("中国农业银行", workbook.getSheetAt(2).getRow(1).getCell(2).getStringCellValue()); assertEquals("中国农业银行", workbook.getSheetAt(2).getRow(1).getCell(2).getStringCellValue());
assertEquals("突然销户", workbook.getSheetAt(2).getRow(1).getCell(3).getStringCellValue()); assertEquals("突然销户", workbook.getSheetAt(2).getRow(1).getCell(3).getStringCellValue());
assertEquals("2026-03-20", workbook.getSheetAt(2).getRow(1).getCell(4).getStringCellValue()); assertEquals("2026-03-20", workbook.getSheetAt(2).getRow(1).getCell(4).getStringCellValue());
assertEquals("已销户", workbook.getSheetAt(2).getRow(1).getCell(5).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()); 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"); "2026-03-31-create-ccdi-account-info-and-abnormal-account-rules.sql");
assertAll( assertAll(
() -> assertTrue(migrationSql.contains("员工本人账户已销户且销户日前30天内存在交易记录"), () -> assertTrue(migrationSql.contains("员工本人账户已销户且销户日前30天内存在大额资金流动"),
"SUDDEN_ACCOUNT_CLOSURE 应使用设计文档中的业务口径"), "SUDDEN_ACCOUNT_CLOSURE 应使用设计文档中的业务口径"),
() -> assertTrue(migrationSql.contains("员工本人账户开户后长期未使用,首次启用后出现大额资金流动。"), () -> assertTrue(migrationSql.contains("员工本人账户开户后长期未使用,首次启用后出现大额资金流动。"),
"DORMANT_ACCOUNT_LARGE_ACTIVATION 应使用设计文档中的业务口径"), "DORMANT_ACCOUNT_LARGE_ACTIVATION 应使用设计文档中的业务口径"),
() -> assertTrue(migrationSql.contains("真实规则识别员工本人账户销户前30天内仍有交易的员工对象"), () -> assertTrue(migrationSql.contains("真实规则识别员工本人账户销户前30天内存在大额资金流动的员工对象"),
"SUDDEN_ACCOUNT_CLOSURE 应同步真实规则说明"), "SUDDEN_ACCOUNT_CLOSURE 应同步真实规则说明"),
() -> assertTrue(migrationSql.contains("真实规则:识别长期休眠后首次启用即出现大额资金流动的员工对象"), () -> assertTrue(migrationSql.contains("真实规则:识别长期休眠后首次启用即出现大额资金流动的员工对象"),
"DORMANT_ACCOUNT_LARGE_ACTIVATION 应同步真实规则说明") "DORMANT_ACCOUNT_LARGE_ACTIVATION 应同步真实规则说明")

View File

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

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,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`

View File

@@ -0,0 +1,25 @@
# 涉疑交易明细筛选一致性前端实施计划
## 目标
- 防止旧请求和父页面数据更新覆盖当前筛选结果。
- 列表直接使用后端返回的筛选后标签,不再逐条请求流水详情。
- 详情和风险明细导出携带当前筛选条件。
## 实施步骤
1.`sectionData` 监听改为只在项目 ID 变化时初始化筛选和首屏数据,父页面同项目更新不重置本地筛选状态。
2. 为模型和预警类型变化统一增加筛选状态重置:清空列表、总数、详情和筛选范围缓存,并使旧请求失效。
3. 增加涉疑交易请求序号API 返回、异常处理和加载状态只允许最新请求修改。
4. 删除列表 `hydrateSuspiciousRows` 的逐条详情补全,直接规范化后端返回的 `hitTags`
5. 流水详情请求携带当前 `modelCode``suspiciousType`,详情缓存键包含筛选范围。
6. 风险明细导出携带当前涉疑交易筛选条件。
## 测试
- 静态单测:筛选参数透传、列表不再逐条详情补全、请求序号保护、父数据不覆盖。
- 竞态测试:旧请求晚返回时保持最新结果。
- 页面测试:员工疑似赌博、员工大额交易、外部人员大额交易、全部模型来回切换。
- 网络检查:列表加载只发一条涉疑交易列表请求,不发当前页逐条详情请求。
- 详情与导出检查:标签与当前筛选一致。

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
# 拉取行内/金综流水前端实施计划
## 背景
原弹窗只体现“拉取本行信息”,用户无法选择流水平台来源。新需求是在同一入口中区分拉取行内流水和拉取金综流水。
## 实施范围
- 将上传数据工具栏入口文案调整为“拉取流水”。
- 将弹窗标题调整为“拉取行内/金综流水”。
- 在弹窗中增加“流水类型”单选:
- 默认选择 `ZJRCU` 行内流水。
- 可切换为 `JZL` 金综流水。
- 两种来源均沿用证件号码批量输入能力,多个证件号按列表提交给后端。
- 行内流水保留时间跨度选择与近一年校验。
- 金综流水隐藏时间跨度,提交时 `startDate/endDate` 传空,由后端固定转换为 `0/0`
- 类型备注按页面截图口径展示:
- 行内流水:`备注:仅支持获取近一年流水`
- 金综流水:`备注:自动获取最近30天内授权的流水文件`
- 提交 payload 增加 `dataChannelCode`
## 验证计划
- 前端构建检查。
- 使用真实项目页面打开弹窗,确认默认行内、切换金综后隐藏时间跨度。

View File

@@ -0,0 +1,50 @@
# 涉疑交易明细筛选一致性设计
## 背景
生产环境“涉疑交易明细”在员工模型与外部人员模型之间切换时,存在旧结果重新出现、列表标签与筛选模型不一致、刷新后仍可能恢复旧数据等问题。
## 根因
1. `RiskDetailSection` 对父组件 `sectionData` 使用深度监听。父页面静默刷新任意风险数据时,子组件会重置筛选条件并重新灌入首屏数据。
2. 涉疑交易查询和当前页流水详情补全并行执行,但没有请求序号控制,较早发出的请求可以在较晚完成后覆盖最新筛选结果。
3. 列表接口按 `modelCode` 筛选流水,前端随后逐条调用无模型参数的流水详情接口补标签,导致标签口径脱离列表筛选条件。
4. 后端 `MODEL_RULE` 查询仍启用外部人员分支,模型命中 SQL 也没有排除 `EXTERNAL_*` 模型,“员工规则命中”与“外部人员预警”边界不完整。
5. 涉疑交易导出的标签聚合没有按当前模型过滤,页面与导出可能不一致。
## 筛选口径
- 选择具体模型时,以 `modelCode` 精确筛选流水和标签。
- `ALL + 员工模型` 仅返回该员工模型。
- `ALL + 外部人员模型` 仅返回该外部人员模型。
- `MODEL_RULE + 全部模型` 仅返回非 `EXTERNAL_*` 员工模型。
- `EXTERNAL_PERSON + 全部模型` 仅返回 `EXTERNAL_*` 外部人员模型。
- `ALL + 全部模型` 返回全部模型。
- 列表、详情和风险明细导出的涉疑交易工作表使用同一口径。
## 数据流
1. 筛选条件变化时,前端清空旧列表、关闭详情并使正在执行的旧请求失效,不自动查询。
2. 用户点击“查询”时,前端固定本次 `projectId``suspiciousType``modelCode`、页码和每页数量,并生成请求序号。
3. 后端先完成涉疑流水分页查询,并直接返回列表展示所需的流水字段。
4. 后端仅对当前页流水 ID 批量查询符合当前筛选口径的标签并装配到行数据。
5. 前端仅允许最新请求序号更新列表、总数和加载状态。
6. 用户打开详情时,详情请求携带当前筛选条件;详情标签与当前列表行标签保持一致。
## 性能约束
- 浏览器加载列表由“1 次列表请求 + 当前页 N 次详情请求”降为 1 次列表请求。
- 后端列表处理固定为分页主查询和当前页标签批量查询,不按行查询数据库。
- `ALL + 员工具体模型` 不检查、也不拼接外部人员分支。
- 下拉框变化不自动请求,避免生产环境重复查询。
- 当前页标签查询先按项目和流水 ID 范围收敛,再应用模型范围过滤。
## 验收标准
1. 员工疑似赌博筛选只展示员工疑似赌博流水和标签。
2. 员工大额交易与外部人员大额交易严格区分。
3. 人为延迟旧请求后,旧响应不能覆盖最新筛选结果。
4. 父页面静默刷新后,当前筛选条件和列表不被首屏数据覆盖。
5. 列表、详情和风险明细导出的涉疑交易标签一致。
6. 每次列表查询不再产生当前页逐条详情请求。

View File

@@ -0,0 +1,21 @@
# 账号库管理页面布局对齐实施记录
## 修改内容
- 移除账号库管理页额外的 `.board` 卡片容器。
- 移除账号库管理页额外的灰色页面背景、圆角、边框和卡片内边距。
- 保留查询区、按钮区、表格、分页和弹窗表单的原有业务逻辑。
## 影响范围
- 前端页面:`ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
- 未修改接口、查询参数、表格字段、导入逻辑、权限配置和后端代码。
## 验证情况
- 已执行 `node tests/unit/account-info-layout-polish.test.js`,账号库列顺序和外层卡片移除断言通过。
- 已使用 Node 22.22.3 + npm 10.9.8 执行 `npm run build:prod`,构建通过;因本机 Node 14.21.3 缺少 npm未使用 `.nvmrc` 指向版本。
- 已通过真实前端页面 `http://127.0.0.1:8088/ccdiAccountInfo` 验证:
- 列表表头为“所属人类型、员工姓名、关系类型、证件号、账户姓名、账户号码、账户类型、账户范围”。
- 页面不再存在 `.account-page``.board` 外层卡片容器。
- 查询区标签均为单行显示,页面无横向溢出。

View File

@@ -0,0 +1,27 @@
# 实体统一社会信用代码查询区布局实施记录
## 修改内容
- 实体库管理查询区统一使用 136px 标签宽度,并设置查询区标签不换行。
- 实体库管理查询区保持每个字段 6 栅格,使上下两行输入框左边界和宽度对齐。
- 信贷客户实体关联查询区统一使用 136px 标签宽度,并设置查询区标签不换行。
- 信贷客户实体关联查询区保持每个字段 6 栅格,使同一行输入框左边界和宽度对齐。
## 影响范围
- `ruoyi-ui/src/views/ccdiEnterpriseBaseInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
- 本次仅调整查询区展示布局,不修改查询参数、接口、表格字段、导入逻辑和后端代码。
## 验证情况
- 已使用 Node 22.22.3 + npm 10.9.8 执行 `npm run build:prod`,构建通过;本机 `.nvmrc` 指向的 Node 14.21.3 缺少 npm未使用该不完整环境。
- 已通过真实页面 `http://127.0.0.1:8088/maintain/enterpriseBaseInfo` 验证:
- “统一社会信用代码”标签单行显示,标签高度 32px。
- 查询区上下两行输入框按列对齐,当前 1280px 视口下各列输入框左边界为 396/650/904/1158px。
- 页面无横向溢出。
- 已通过真实页面 `http://127.0.0.1:8088/maintain/custEnterpriseRelation` 验证:
- “统一社会信用代码”标签单行显示,标签高度 32px。
- 查询区输入框按列对齐,当前 1280px 视口下各列输入框左边界为 396/650/904/1158px。
- 页面无横向溢出。
- 浏览器控制台错误数为 0。

View File

@@ -0,0 +1,58 @@
# 前端页面布局小优化实施记录
## 修改背景
对近期涉及的前端页面进行风格检查后,发现部分后台表格和筛选区存在列顺序不顺、下拉框宽度偏窄、窄屏挤压和长文本撑开表格的问题。本次只做页面展示层小优化,不调整接口和业务逻辑。
## 修改内容
1. 流水明细查询
- 将“交易金额”列移动到“异常标签”列前面。
- 将“交易金额”列控制为 120px“异常标签”列控制为 220px避免列宽虚高。
- 长账号、长摘要和长名称允许在单元格内断行,避免撑开表格。
- 单个超长异常标签限制在列内省略展示,避免撑开表格。
2. 风险明细-涉疑交易明细
- 将“命中模型”和“预警类型”筛选框统一调整为 240px。
- 查询按钮设置最小宽度,避免筛选栏视觉抖动。
- 将“交易金额”列移动到“异常标签”列前面,与流水明细查询保持一致。
- 将“交易金额”列控制为 120px“异常标签”列控制为 220px避免列宽虚高。
- 长账号、长摘要和长名称允许在单元格内断行。
- 单个超长异常标签限制在列内省略展示。
3. 风险模型
- 保留“模型预警统计”“命中模型涉及人员”两个区块标题。
- “模型预警统计”说明文案缩短为“点击筛选”,“命中模型涉及人员”标题下不再展示说明文案。
4. 账户库
- 列表列顺序调整为“所属人类型”在前,“员工姓名”在后,便于先判断账户归属分类。
- 查询区改为响应式列宽:手机一列、平板两列、中等屏三列、大屏四列。
- 账户库编辑/详情弹窗改为 92% 自适应宽度,并限制最大宽度 1160px。
- 员工姓名、账户姓名、账户号码增加溢出提示,避免长文本撑开表格。
## 影响范围
- 影响项目详情中的“流水明细查询”展示。
- 影响项目详情中的“风险明细-涉疑交易明细”筛选区和表格文本展示。
- 影响项目详情中的“风险模型”区块标题展示。
- 影响账户库列表查询区、表格长文本展示和编辑/详情弹窗宽度。
- 不改接口、不改后端、不改数据结构。
## 验证情况
- 已补充前端源码结构单测,覆盖流水明细查询/涉疑交易明细金额列顺序、涉疑交易筛选框宽度、账户库列表列顺序和账户库布局优化。
- 已执行前端结构单测:
- `node tests/unit/detail-query-hit-tags-list.test.js`
- `node tests/unit/risk-detail-suspicious-transaction-layout.test.js`
- `node tests/unit/account-info-layout-polish.test.js`
- 已执行 `npm run build:prod`,构建通过;存在既有资源体积告警,且当前工作区未跟踪的 `0uS5znL.docx` 会被构建扫描到,不属于本次代码改动。
- 本次构建使用 Node 22.22.3 + npm 10.9.8;本机 `.nvmrc` 指向的 Node 14.21.3 缺少 npm未继续使用该不完整环境。
- 已复查流水明细查询和涉疑交易明细列顺序与列宽:交易金额列位于异常标签列前,金额列宽为 120px异常标签列宽为 220px摘要/交易类型列收窄并允许长文本断行,单个超长异常标签在列内省略,避免长文本遮挡后续列。
- 已启动真实后端和前端开发服务检查页面:
- 账户库列表、查询区、详情弹窗可正常渲染。
- 账户库列表列头已确认调整为“所属人类型”在“员工姓名”前。
- 账户库窄屏查询区无页面级横向溢出。
- 项目详情“流水明细查询”中“交易金额”列已在“异常标签”列前。
- 项目详情“结果总览-涉疑交易明细”中“交易金额”列已在“异常标签”列前。
- 项目详情“流水明细查询”和“结果总览-涉疑交易明细”均已临时注入超长金额、超长异常标签做极限宽度验收;交易金额列保持 120px异常标签列保持 220px页面无横向溢出。
- “模型预警统计”标题保留并仅显示“点击筛选”,“命中模型涉及人员”标题保留且不再显示“查看明细”。

View File

@@ -0,0 +1,26 @@
# 项目快捷入口模板预填实施记录
## 修改内容
- 将项目首页“创建季度初核”“创建新员工排查”“创建高风险专项”三个快捷入口改为模板化打开新建项目弹窗。
- 按当前日期自动生成项目名称与项目描述:
- 季度初核:生成当年当季度纪检初核项目。
- 新员工排查:生成当年当月新员工排查项目。
- 高风险专项:生成当年高风险专项排查项目。
- 高风险专项默认选择“自定义项目规则参数配置”,季度初核与新员工排查默认选择“全局默认模型参数配置”。
- 调整快捷卡片副标题,明确提示入口会预填对应模板。
## 影响范围
- 前端页面:`ruoyi-ui/src/views/ccdiProject/index.vue`
- 快捷入口组件:`ruoyi-ui/src/views/ccdiProject/components/QuickEntry.vue`
- 未新增后端字段、接口、数据库脚本或菜单权限。
## 验证情况
- 已使用 Node 22.22.3 + npm 10.9.8 执行 `npm run build:prod`,构建通过;因本机 Node 14.21.3 缺少 npm未使用 `.nvmrc` 指向版本。
- 已通过真实前端页面 `http://127.0.0.1:8088/ccdiProject` 验证:
- 项目列表“最近更新时间”表头正常显示。
- 快捷入口副标题显示为“预填本季度项目模板”等模板化文案。
- 点击“创建季度初核”后弹窗标题为“创建季度初核项目”项目名称预填为“2026年第三季度纪检初核项目”项目描述预填为“用于开展2026年第三季度纪检初核排查工作。”。
- 验证过程中未提交新增项目。

View File

@@ -0,0 +1,21 @@
# 员工亲属实体关联查询区布局优化实施记录
## 修改内容
- 将员工亲属实体关联页查询区的“统一社会信用代码”字段调整为局部宽标签,不扩大整页所有查询项标签宽度。
- 保持查询区两行均为 8/8/8 三列对齐,只缩短“统一社会信用代码”输入框宽度,避免长标签挤压或换行。
- 为查询表单标签增加不换行约束,避免长标签撑高搜索区行高。
## 影响范围
- 前端页面:`ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
- 未修改查询参数、接口、表格字段、导入逻辑、权限配置和后端代码。
## 验证情况
- 已执行前端结构检查,确认员工调动记录和图谱模块没有纳入本次改动。
- 已使用 Node 22.22.3 + npm 10.9.8 执行 `npm run build:prod`,构建通过;因本机 Node 14.21.3 缺少 npm未使用 `.nvmrc` 指向版本。
- 已通过真实前端页面 `http://127.0.0.1:8088/maintain/staffEnterpriseRelation` 验证:
- 两行查询项均保持三列对齐,列起点一致。
- “统一社会信用代码”标签单行显示,标签宽度 136px输入框宽度约 187px。
- 页面无横向溢出。

View File

@@ -0,0 +1,21 @@
# 员工亲属关系查询区布局优化实施记录
## 修改内容
- 将员工亲属关系维护页查询表单标签宽度调整为 128px避免“关系人身份证号”等长标签换行。
- 为查询表单标签增加不换行约束,避免单个字段撑高整行。
- 为员工身份证号、关系人身份证号列表列增加固定展示宽度并开启溢出提示。
## 影响范围
- 前端页面:`ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
- 未修改查询参数、接口、数据库脚本、导入逻辑和权限配置。
## 验证情况
- 已执行前端结构检查,确认员工调动记录和图谱模块没有纳入本次改动。
- 已使用 Node 22.22.3 + npm 10.9.8 执行 `npm run build:prod`,构建通过;因本机 Node 14.21.3 缺少 npm未使用 `.nvmrc` 指向版本。
- 已通过真实前端页面 `http://127.0.0.1:8088/maintain/staffFmyRelation` 验证:
- “关系人身份证号”标签单行显示,标签高度 32px。
- 第一行查询项顶部坐标一致,第二行“状态”未被前一行撑出异常空隙。
- 身份证号相关表格列未造成页面级横向溢出。

View File

@@ -0,0 +1,26 @@
# 用户管理与新增贷款查询布局调整实施记录
## 修改内容
- 新增贷款查询页将首个“柜员号”查询项的局部标签宽度调整为 72px使输入框整体向前靠拢不影响“审核人柜员号”等长标签字段。
- 系统管理-用户管理列表将“用户编号”列改为“编号”,并固定宽度为 80px。
- 用户管理新增/修改弹窗去掉页面上的“用户性别”“岗位”“备注”表单项。
- 用户管理新增/修改弹窗保留“状态”和“角色”,不调整接口、权限和提交方法。
## 影响范围
- `ruoyi-ui/src/views/ccdiProject/components/detail/IncreaseLendingSection.vue`
- `ruoyi-ui/src/views/system/user/index.vue`
- 本次仅修改前端展示层,不修改后端接口、数据库结构和权限配置。
## 验证情况
- 已使用 Node 22.22.3 + npm 10.9.8 执行 `npm run build:prod`,构建通过;本机 `.nvmrc` 指向的 Node 14.21.3 缺少 npm未使用该不完整环境。
- 已通过真实页面 `http://127.0.0.1:8088/system/user` 验证:
- 用户列表第一列显示为“编号”,宽度为 80px。
- 新增用户弹窗不再显示“用户性别”“岗位”“备注”,保留“状态”“角色”。
- 修改用户弹窗不再显示“用户性别”“岗位”“备注”,保留“状态”“角色”。
- 已通过真实页面 `http://127.0.0.1:8088/ccdiProject/detail/42?tab=special` 验证:
- 新增贷款查询“柜员号”标签宽度为 72px输入框较原 104px 标签宽度向前靠拢。
- 页面无横向溢出。
- 浏览器控制台错误数为 0。

View File

@@ -0,0 +1,31 @@
# 员工信息维护列表与详情展示调整实施记录
## 修改时间
2026-07-15
## 修改内容
1. 员工信息维护列表移除首列多选框。
2. 列表“创建时间”列调整为“入职时间”,展示 `hireDate`,空值显示 `-`
3. 列表默认排序由创建时间倒序改为入职时间倒序,同一天按柜员号倒序。
4. 列表“身份证号”和“所属部门”列增加最小宽度,避免 18 位身份证号和部门名称显示过窄。
5. 员工列表首列增加左侧留白,移除多选框后保持表格视觉间距;入职时间列适当收窄,为前部字段释放横向空间。
6. 列表“年收入”字段按万元展示,列名调整为“年收入(万元)”,空值显示 `-`,有效数值保留 2 位小数;详情仍按原字段值展示且不显示小数位。
7. 员工详情接口改为关联 `sys_dept` 查询部门名称,修复详情“所属部门”为空的问题。
## 影响范围
- 前端页面:`ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- 后端 Mapper`ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapper.java`
- 后端 SQL`ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml`
- 后端服务:`ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffServiceImpl.java`
## 验证情况
- 后端编译验证:`mvn -pl ccdi-info-collection -am compile -DskipTests` 通过。
- 后端打包验证:`mvn -pl ruoyi-admin -am package -DskipTests` 通过。
- 后端启动验证Windows PowerShell 环境缺少 `sh``lsof``pgrep`,未使用 `bin/restart_java_backend.sh`;改用 Windows 原生命令重启真实 `ruoyi-admin.jar``http://127.0.0.1:62318/swagger-ui.html` 返回 200。
- 前端环境验证:`.nvmrc` 为 Node 14.21.3,但本机该版本切换后当前会话无法找到 `node/npm`;按项目规则改用 Node 22.22.3 + npm 10.9.8。
- 前端构建验证:`npm run build:prod` 通过,存在既有资源体积 warning。
- 页面验证:打开真实前端 `http://127.0.0.1:8088/maintain/baseStaff`,确认列表无多选框、表头为“入职时间”、身份证号列完整展示、列表按入职时间倒序展示;点击第一条员工详情,确认“所属部门”显示为部门名称。

View File

@@ -0,0 +1,32 @@
# 专项排查资产收入匹配口径实施记录
## 保存路径确认
本文档保存于 `docs/reports/implementation/`,用于记录本次专项排查口径调整的实施内容、影响范围与验证情况。
## 修改内容
- 后端专项排查家庭资产负债查询新增本人年收入、配偶年收入、入职年限、可解释收入、资产收入倍数。
- 后端风险结论改为 `高关注``关注``正常``缺少信息`
- 无负债记录不再阻断结论判断,负债金额按 0 纳入可解释收入。
- 前端专项排查列表中家庭总年收入改为万元展示。
- 前端详查结果改为分行展示家庭总资产、可解释收入公式与资产收入倍数。
- 缺少信息结论按实际缺项展示收入、资产或入职时间,多项缺失时展示关键信息不完整。
- 隐藏家庭资产负债表格内置展开箭头,保留“查看详情”按钮作为展开入口,避免展开箭头挤在序号列前导致显示异常。
## 影响范围
- `ccdi-project` 专项排查家庭资产负债接口。
- `ruoyi-ui` 项目详情专项排查家庭资产负债列表与展开详情。
- 不涉及员工信息维护、资产导入、征信负债导入和数据库结构变更。
## 验证情况
- 已执行 `mvn -pl ccdi-project -am compile -DskipTests`,编译通过。执行过程中 Maven 提示 `ccdi-info-collection` 中存在既有重复依赖声明警告,本次未调整该无关问题。
- 已执行前端 Node 环境确认。`.nvmrc` 指向 `14.21.3`,但本机 PowerShell 环境下 `nvm use` 未能直接切换该版本;按仓库约定改用已验证可用的 `Node v22.22.3``npm 10.9.8`
- 已执行 `npm run build:prod`,生产构建通过。构建输出存在既有资源体积警告,本次未调整该无关问题。
- 已重新完整打包并启动真实后端 `ruoyi-admin.jar`,确认 62318 端口由本仓库 Java 进程监听,日志出现 `Started RuoYiApplication`
- 已在真实页面验证项目 42员工“模型二测试员工”有收入、有入职时间、有资产、无负债列表家庭总年收入展示为 `12.35 万元`,风险情况为 `正常`;详查结果展示 `本人年收入 12.35 万元 × 入职年限 7 年 + 配偶年收入 0.00 万元 + 家庭总负债 0.00 万元 = 86.42 万元`
- 已在真实页面验证项目 44同一项目内两名员工均可按新口径展示列表家庭总年收入分别展示为 `24.35 万元``12.35 万元`,风险情况为 `正常`
- 已在真实页面验证项目 90629002员工有收入和入职时间但无资产列表风险情况为 `缺少信息`,详查结果显示 `缺少资产,暂无法判断。`
- 已在真实页面验证家庭资产负债表格展开箭头隐藏生效,序号列前不再显示异常符号;继续通过“查看详情”展开明细。

View File

@@ -0,0 +1,23 @@
# 涉疑交易明细默认分页调整实施记录
## 修改内容
- 结果总览首次加载涉疑交易明细时,请求参数由 `pageSize=5` 调整为 `pageSize=10`
- 涉疑交易明细组件初始化和项目切换时,默认分页条数调整为 10。
- 员工征信负面和异常账户关联人员默认分页条数继续保持 5。
- 分页可选项继续保持 `5 / 10 / 20 / 50`
## 影响范围
- 仅影响结果总览中涉疑交易明细的默认展示条数。
- 不修改后端接口、数据库、筛选逻辑或其他风险列表分页行为。
## 验证情况
- 使用 Node 22.22.3、npm 10.9.8 执行 5 个相关前端测试,全部通过。
- 执行 `npm run build:prod`,生产构建通过;仅有仓库原有包体积警告。
- 当前会话未提供 `browser-use` 所需浏览器执行工具,改用终端 Playwright 连接真实前端和真实 Java 后端验证。
- 项目 90629002 首次涉疑交易请求携带 `pageSize=10`页面分页控件显示“10条/页”。
- 员工征信负面和异常账户请求仍携带 `pageSize=5`
- 浏览器控制台错误数为 0。
- 验证结束后已关闭测试浏览器、前端和后端进程80、62318 均无监听进程。

View File

@@ -0,0 +1,81 @@
# 涉疑交易明细筛选一致性实施记录
## 修改内容
### 后端
- 涉疑交易模型命中 SQL 按预警类型隔离模型范围:
- `MODEL_RULE` 排除 `EXTERNAL_*` 外部模型。
- `EXTERNAL_PERSON` 仅保留 `EXTERNAL_*` 外部模型。
- 选择具体 `modelCode` 时精确匹配模型。
- 优化外部人员分支判断:`MODEL_RULE``ALL + 员工具体模型` 不再检查或拼接外部人员分支。
- 涉疑交易分页结果直接返回本方账户、对方账户等列表展示字段。
- 分页完成后,仅针对当前页流水 ID 批量查询一次筛选范围内的标签,并返回标签 `modelCode`
- 流水详情接口支持 `modelCode``suspiciousType`,详情标签与列表筛选一致。
- 风险明细统一导出接口接收当前涉疑筛选条件,涉疑交易工作表与页面口径一致。
- 涉疑交易导出标签聚合应用相同模型范围。
### 前端
- 模型或预警类型变化时立即清空旧列表、总数、详情和缓存,但不自动发起查询。
- 增加涉疑交易请求序号,只有最后一次请求可以更新列表、总数和加载状态。
- 父组件同项目数据更新时不再重置本地筛选或覆盖当前列表;仅项目切换时重新初始化。
- 删除涉疑列表当前页逐条请求流水详情的补全逻辑,直接使用列表接口返回字段和标签。
- 详情缓存键增加预警类型与模型编码,详情请求透传当前筛选条件。
- 风险明细导出透传当前涉疑交易筛选条件。
## 性能变化
- 浏览器列表加载由“1 次涉疑列表请求 + 当前页 N 次流水详情请求”降为 1 次涉疑列表请求。
- 后端每次分页固定执行分页查询和当前页标签批量查询,不按流水逐条查询标签。
- 员工具体模型查询不再执行外部人员主体判断和外部人员复杂关联分支。
- 下拉框变化不请求后端,只有点击“查询”才执行一次查询。
## 自动化验证
### 后端
执行:
```bash
mvn -pl ccdi-project -am "-Dtest=CcdiProjectOverviewServiceSuspiciousTransactionTest,CcdiProjectOverviewMapperSqlTest,CcdiBankStatementServiceImplTest,CcdiBankTagResultMapperXmlTest,CcdiBankStatementControllerTest,CcdiProjectOverviewControllerTest,CcdiProjectOverviewControllerContractTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
结果:`Tests run: 55, Failures: 0, Errors: 0, Skipped: 0`
同时执行 `mvn -pl ruoyi-admin -am package -DskipTests`,后端打包通过。
### 前端
项目 `.nvmrc` 对应的 Node 14.21.3 本机安装缺少 npm因此按仓库规则使用已验证的 Node 22.22.3、npm 10.9.8。
执行以下定向脚本,全部通过:
```bash
node tests/unit/risk-detail-suspicious-filter-consistency.test.js
node tests/unit/risk-detail-suspicious-filter-linkage.test.js
node tests/unit/risk-detail-suspicious-transaction-detail-dialog.test.js
node tests/unit/preliminary-check-suspicious-transaction-load.test.js
node tests/unit/project-overview-api.test.js
```
执行 `npm run build:prod`,生产构建通过;仅保留仓库原有包体积警告。
## 真实页面验证
`browser-use` 所需的内置浏览器执行工具在当前会话不可用,按技能检查流程确认后,使用终端 Playwright 连接真实前端 `http://localhost:9528` 和真实 Java 后端 `http://localhost:62318` 验证,未使用原型或 Mock。
- 项目 42
- 切换模型时旧列表立即清空,未自动发起查询。
- `ALL + SUSPICIOUS_GAMBLING` 返回空结果,未保留上一轮大额交易列表。
- `ALL + LARGE_TRANSACTION` 返回标签全部为 `LARGE_TRANSACTION`
- 主动打开详情后,详情请求携带 `modelCode=LARGE_TRANSACTION&suspiciousType=ALL`
- 项目 90629002
- `MODEL_RULE + 全部模型` 仅返回 2 条员工流水,标签均为员工模型。
- `EXTERNAL_PERSON + 全部模型` 仅返回 3 条外部人员流水,标签均为 `EXTERNAL_*`
- `EXTERNAL_PERSON + EXTERNAL_LARGE_TRANSACTION` 仅返回外部人员大额交易标签。
- `EXTERNAL_PERSON + EXTERNAL_SUSPICIOUS_GAMBLING` 仅返回 1 条外部人员可疑赌博流水和对应标签,未混入外部人员大额交易标签。
- 列表查询期间未产生当前页逐条详情请求;仅主动点击“详情”后发起一条详情请求。
- 浏览器控制台错误数为 0。
测试结束后已关闭本轮启动的前端、后端和浏览器进程62318、9528 均无监听进程。

View File

@@ -0,0 +1,40 @@
# 涉疑交易无外部人员短路实施记录
## 修改内容
- 为涉疑交易查询 DTO 增加 `includeExternalPerson` 内部开关。
- 新增 `selectExternalPersonSubjectExistsByProjectId`,使用 `limit 1` 轻量判断项目是否存在外部人员主体。
- 将涉疑交易 SQL 中外部人员预警 `union all` 改为按 `includeExternalPerson` 条件拼接。
- 在涉疑交易分页和导出服务入口设置外部人员分支开关;`EXTERNAL_PERSON` 且无外部人员主体时直接返回空结果。
- 补充 Service 单测和 Mapper SQL 结构测试。
## 影响范围
- 影响接口:
- `GET /ccdi/project/overview/suspicious-transactions`
- 涉疑交易导出相关服务调用
- 不改变员工本人、员工亲属、模型规则、名单命中等涉疑交易原有查询口径。
- 不修改前端逻辑和数据库表结构。
## 验证情况
- 首次执行以下命令时,未带 `-am` 编译依赖模块,`ccdi-project` 使用本地旧版 `ccdi-lsfx` 依赖,导致编译阶段出现 LSFX 接口签名不一致报错,未进入测试阶段:
```bash
mvn -pl ccdi-project "-Dtest=CcdiProjectOverviewServiceSuspiciousTransactionTest,CcdiProjectOverviewMapperSqlTest" test
```
- 已使用 `-am` 重新构建依赖模块并执行定向单测:
```bash
mvn -pl ccdi-project -am "-Dtest=CcdiProjectOverviewServiceSuspiciousTransactionTest,CcdiProjectOverviewMapperSqlTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
- 结果:通过,`Tests run: 14, Failures: 0, Errors: 0, Skipped: 0`
- 覆盖测试:
- `CcdiProjectOverviewServiceSuspiciousTransactionTest`
- `CcdiProjectOverviewMapperSqlTest`
## 备注
本次修复针对无外部人员项目的性能短路,避免为了计算“没有外部人员”而执行外部人员预警复杂关联。

View File

@@ -0,0 +1,20 @@
# 个人基本信息联系方式校验调整实施记录
## 修改时间
2026-07-16
## 修改内容
1. 个人基本信息编辑中,手机号码和邮箱取消必填限制。
2. 手机号码和邮箱保留可选格式校验:为空时允许保存;填写后仍需符合对应格式。
## 影响范围
- 前端个人资料页:`ruoyi-ui/src/views/system/user/profile/userInfo.vue`
## 验证情况
- 前端环境验证:使用 Node 22.22.3 + npm 10.9.8。
- 前端构建验证:`npm run build:prod` 通过,存在既有资源体积 warning。
- 页面验证:打开真实前端 `http://127.0.0.1:8088/user/profile`,清空手机号码和邮箱输入框并触发失焦,未出现必填提示。本次页面验证未点击保存,未修改用户资料。

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
# 流水明细导出项目可读权限实施记录
## 修改内容
- 移除流水明细导出接口上的 `ccdi:project:export` 菜单权限前置校验。
- 导出接口继续执行 `projectAccessService.assertCanRead(projectId)`,普通用户仅能导出自己可读项目的流水明细。
## 影响范围
- 影响接口:`POST /ccdi/project/bank-statement/export`
- 流水列表、筛选项、详情接口权限口径不变。
- 已复核项目详情内其他导出入口,当前没有使用 `ccdi:project:export`;结果总览与专项核查导出仍按 `ccdi:project:query` 加项目可读校验执行。
## 验证记录
- 通过:`mvn -pl ccdi-project -am -Dtest=CcdiBankStatementControllerTest "-Dsurefire.failIfNoSpecifiedTests=false" test`

View File

@@ -0,0 +1,36 @@
# 上传流水身份证号本地回填实施记录
## 背景
上传行外流水文件时,文件名已经会校验必须包含 18 位身份证号,但流水平台返回明细中的 `cretNo` 可能为空。当前结果总览、流水打标和专项分析主要依赖 `ccdi_bank_statement.cret_no` 关联员工或关系人,空值会导致上传后有流水但结果出不来。
## 修改内容
- 仅调整后端上传流水文件链路,不修改前端、不修改拉取本行信息链路、不修改数据库结构。
- `CcdiFileUploadServiceImpl`
- 在上传记录文件名中复用现有身份证号识别规则提取 18 位身份证号。
- 保存流水平台返回的流水明细前,若 `CcdiBankStatement.cretNo` 为空,则使用上传记录文件名中的身份证号回填。
- 继续使用原有上传记录文件名转传流水平台,不新增 `身份证号-原文件名` 前缀改写。
- `CcdiFileUploadServiceImplTest`
- 保留“转传流水平台使用记录文件名”的测试。
- 新增平台返回 `cretNo` 为空时,本地入库实体回填上传文件名身份证号的测试。
## 影响范围
- 对平台正常返回 `cretNo` 的流水无影响,仍优先使用平台返回值。
- 对平台未返回 `cretNo`、但上传文件名包含身份证号的流水,入库时会补齐 `ccdi_bank_statement.cret_no`
- 对文件名不含身份证号的上传仍在上传前拦截,不进入落库阶段。
## 验证
```bash
mvn -pl ccdi-project -am "-Dtest=CcdiFileUploadServiceImplTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
- 结果BUILD SUCCESS
- 覆盖:上传链路文件名校验、上传平台文件名传递、解析完成后流水入库、`cretNo` 为空时本地回填。
## 备注
- PowerShell 下 Maven `-D` 参数需要加引号;未加引号时命令会被解析为错误生命周期阶段。
- 本次未启动前端或后端服务,因此无测试进程需要关闭。

View File

@@ -0,0 +1,44 @@
# 拉取行内/金综流水来源选择实施记录
## 修改内容
- 后端拉取流水提交 DTO 增加 `dataChannelCode`
- 后端 Controller 校验流水来源,仅允许 `ZJRCU``JZL`
- 后端 Service 透传来源到流水平台请求。
- 行内流水继续传页面日期;金综流水固定传 `dataStartDateId=0``dataEndDateId=0`
- 前端上传数据页入口改为“拉取流水”,弹窗增加行内/金综单选。
- 前端选择金综流水时隐藏时间跨度,提交 `dataChannelCode=JZL`
- 行内和金综均支持多个证件号码批量提交,沿用原 `idCards` 列表。
- 类型备注调整为截图口径,金综备注不再出现“时间参数”。
## 影响范围
- 项目详情页上传数据模块。
- `/ccdi/file-upload/pull-bank-info` 接口。
- 流水平台拉取行内/金综流水请求参数组装。
## 验证情况
- 后端单测通过:
- `mvn -pl ccdi-project -am test "-Dtest=CcdiFileUploadControllerTest,CcdiFileUploadServiceImplTest" "-Dsurefire.failIfNoSpecifiedTests=false"`
- 结果40 个测试通过。
- 前端构建通过:
- `.nvmrc` 指向 `14.21.3`,本机 `nvm use` 未读取版本参数;按本机已验证环境使用 Node `v22.22.3`、npm `10.9.8`
- `npm run build:prod`
- 结果:构建成功,仅存在既有资源体积告警。
- 真实页面验证通过:
- 启动真实 `ruoyi-admin.jar` 后端并访问 `http://127.0.0.1:62318/swagger-ui.html`,返回 200。
- 启动前端开发服务,实际访问地址为 `http://127.0.0.1:8089/`
- 在真实项目详情页 `90342` 打开“拉取流水”弹窗,默认选中“拉取行内流水”并显示时间跨度。
- 切换“拉取金综流水”后,时间跨度隐藏。
- 金综备注显示为“备注:自动获取最近30天内授权的流水文件”。
- 复查行内备注显示为“备注:仅支持获取近一年流水”,金综备注未出现“时间参数”。
- 未点击“确认拉取”,未提交真实拉取任务。
- 测试结束后已停止本轮启动的前端和后端进程,端口 `8088``8089``62318` 无本轮服务监听。
## 提交前复验
- 后端单测复验通过:`CcdiFileUploadControllerTest``CcdiFileUploadServiceImplTest` 共 40 个测试通过。
- 前端生产构建复验通过:继续使用 Node `v22.22.3`、npm `10.9.8`,构建成功,仅存在既有资源体积告警。
- 真实页面复验通过:访问 `http://127.0.0.1:8088/ccdiProject/detail/90342`,打开“拉取流水”弹窗后默认选中“拉取行内流水”;切换“拉取金综流水”后时间跨度隐藏;两条备注与截图一致,页面未出现“时间参数”。
- 复验未点击“确定”,未提交真实拉取任务;复验结束后已停止后端 `62318` 与前端 `8088` 监听进程。

View File

@@ -18,9 +18,10 @@ export function getBankStatementOptions(projectId) {
}); });
} }
export function getBankStatementDetail(bankStatementId) { export function getBankStatementDetail(bankStatementId, params) {
return request({ return request({
url: `/ccdi/project/bank-statement/detail/${bankStatementId}`, url: `/ccdi/project/bank-statement/detail/${bankStatementId}`,
method: "get", method: "get",
params,
}); });
} }

View File

@@ -56,7 +56,7 @@ export function executeQualityCheck(projectId) {
}) })
} }
// 拉取本行信息 // 解析证件号码文件
export function parseIdCardFile(file) { export function parseIdCardFile(file) {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
@@ -70,7 +70,7 @@ export function parseIdCardFile(file) {
}) })
} }
// 拉取本行信息 // 拉取行内/金综流水
export function pullBankInfo(data) { export function pullBankInfo(data) {
return request({ return request({
url: '/ccdi/file-upload/pull-bank-info', url: '/ccdi/file-upload/pull-bank-info',

View File

@@ -1,6 +1,5 @@
<template> <template>
<div class="app-container account-page"> <div class="app-container">
<div class="board">
<el-form <el-form
v-show="showSearch" v-show="showSearch"
ref="queryForm" ref="queryForm"
@@ -10,7 +9,7 @@
label-width="96px" label-width="96px"
> >
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="6"> <el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="所属人类型"> <el-form-item label="所属人类型">
<el-select v-model="queryParams.ownerType" placeholder="请选择所属人类型" clearable style="width: 100%"> <el-select v-model="queryParams.ownerType" placeholder="请选择所属人类型" clearable style="width: 100%">
<el-option label="员工" value="EMPLOYEE" /> <el-option label="员工" value="EMPLOYEE" />
@@ -20,12 +19,12 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="员工姓名"> <el-form-item label="员工姓名">
<el-input v-model="queryParams.staffName" placeholder="请输入员工姓名" clearable style="width: 100%" @keyup.enter.native="handleQuery" /> <el-input v-model="queryParams.staffName" placeholder="请输入员工姓名" clearable style="width: 100%" @keyup.enter.native="handleQuery" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="账户范围"> <el-form-item label="账户范围">
<el-select v-model="queryParams.bankScope" placeholder="请选择账户范围" clearable style="width: 100%"> <el-select v-model="queryParams.bankScope" placeholder="请选择账户范围" clearable style="width: 100%">
<el-option label="行内" value="INTERNAL" /> <el-option label="行内" value="INTERNAL" />
@@ -33,19 +32,24 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="关系类型"> <el-form-item label="关系类型">
<el-select v-model="queryParams.relationType" placeholder="请选择关系类型" clearable style="width: 100%"> <el-select v-model="queryParams.relationType" placeholder="请选择关系类型" clearable style="width: 100%">
<el-option v-for="item in relationTypeOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in relationTypeOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="账户姓名"> <el-form-item label="账户姓名">
<el-input v-model="queryParams.accountName" placeholder="请输入账户姓名" clearable style="width: 100%" @keyup.enter.native="handleQuery" /> <el-input v-model="queryParams.accountName" placeholder="请输入账户姓名" clearable style="width: 100%" @keyup.enter.native="handleQuery" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="账户号码">
<el-input v-model="queryParams.accountNo" placeholder="请输入账户号码" clearable style="width: 100%" @keyup.enter.native="handleQuery" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="账户类型"> <el-form-item label="账户类型">
<el-select v-model="queryParams.accountType" placeholder="请选择账户类型" clearable style="width: 100%"> <el-select v-model="queryParams.accountType" placeholder="请选择账户类型" clearable style="width: 100%">
<el-option label="银行账户" value="BANK" /> <el-option label="银行账户" value="BANK" />
@@ -55,7 +59,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="是否实控"> <el-form-item label="是否实控">
<el-select v-model="queryParams.isActualControl" placeholder="请选择是否实控" clearable style="width: 100%"> <el-select v-model="queryParams.isActualControl" placeholder="请选择是否实控" clearable style="width: 100%">
<el-option label="是" :value="1" /> <el-option label="是" :value="1" />
@@ -63,7 +67,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="风险等级"> <el-form-item label="风险等级">
<el-select v-model="queryParams.riskLevel" placeholder="请选择风险等级" clearable style="width: 100%"> <el-select v-model="queryParams.riskLevel" placeholder="请选择风险等级" clearable style="width: 100%">
<el-option label="LOW" value="LOW" /> <el-option label="LOW" value="LOW" />
@@ -72,7 +76,7 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :xs="24" :sm="12" :md="8" :lg="6" :xl="6">
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 100%"> <el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 100%">
<el-option label="正常" :value="1" /> <el-option label="正常" :value="1" />
@@ -114,9 +118,6 @@
</el-row> </el-row>
<el-table v-loading="loading" :data="rows" stripe border class="account-table"> <el-table v-loading="loading" :data="rows" stripe border class="account-table">
<el-table-column label="员工姓名" prop="staffName" min-width="120">
<template slot-scope="scope">{{ scope.row.staffName || '-' }}</template>
</el-table-column>
<el-table-column label="所属人类型" prop="ownerType" width="132" align="center"> <el-table-column label="所属人类型" prop="ownerType" width="132" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-tag :type="ownerTypeTagType(scope.row.ownerType)" size="mini" effect="plain"> <el-tag :type="ownerTypeTagType(scope.row.ownerType)" size="mini" effect="plain">
@@ -124,16 +125,19 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="员工姓名" prop="staffName" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.staffName || '-' }}</template>
</el-table-column>
<el-table-column label="关系类型" prop="relationType" width="90" align="center"> <el-table-column label="关系类型" prop="relationType" width="90" align="center">
<template slot-scope="scope">{{ scope.row.relationType || '-' }}</template> <template slot-scope="scope">{{ scope.row.relationType || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="证件号" prop="ownerId" min-width="170" show-overflow-tooltip> <el-table-column label="证件号" prop="ownerId" min-width="170" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.ownerId || scope.row.relationCertNo || '-' }}</template> <template slot-scope="scope">{{ scope.row.ownerId || scope.row.relationCertNo || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="账户姓名" prop="accountName" min-width="120"> <el-table-column label="账户姓名" prop="accountName" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.accountName || '-' }}</template> <template slot-scope="scope">{{ scope.row.accountName || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="账户号码" prop="accountNo" min-width="180" /> <el-table-column label="账户号码" prop="accountNo" min-width="180" show-overflow-tooltip />
<el-table-column label="账户类型" prop="accountType" width="110" align="center"> <el-table-column label="账户类型" prop="accountType" width="110" align="center">
<template slot-scope="scope">{{ accountTypeLabel(scope.row.accountType) }}</template> <template slot-scope="scope">{{ accountTypeLabel(scope.row.accountType) }}</template>
</el-table-column> </el-table-column>
@@ -196,9 +200,14 @@
</el-table> </el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" /> <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
</div>
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="1160px" append-to-body> <el-dialog
:title="dialogTitle"
:visible.sync="dialogVisible"
width="92%"
custom-class="account-info-dialog"
append-to-body
>
<el-form ref="dialogForm" :model="form" :rules="rules" label-width="120px"> <el-form ref="dialogForm" :model="form" :rules="rules" label-width="120px">
<div class="form-section"> <div class="form-section">
<div class="section-head"><span>归属信息</span></div> <div class="section-head"><span>归属信息</span></div>
@@ -528,6 +537,7 @@ export default {
bankScope: '', bankScope: '',
relationType: '', relationType: '',
accountName: '', accountName: '',
accountNo: '',
accountType: '', accountType: '',
isActualControl: undefined, isActualControl: undefined,
riskLevel: '', riskLevel: '',
@@ -658,6 +668,7 @@ export default {
bankScope: '', bankScope: '',
relationType: '', relationType: '',
accountName: '', accountName: '',
accountNo: '',
accountType: '', accountType: '',
isActualControl: undefined, isActualControl: undefined,
riskLevel: '', riskLevel: '',
@@ -887,23 +898,15 @@ export default {
margin-right: 0; margin-right: 0;
} }
.account-page {
min-height: calc(100vh - 84px);
background: #f5f7fa;
}
.board {
padding: 20px;
border-radius: 8px;
background: #fff;
border: 1px solid #ebeef5;
}
.account-table ::v-deep .el-table__header th { .account-table ::v-deep .el-table__header th {
background: #f8f8f9; background: #f8f8f9;
color: #515a6e; color: #515a6e;
} }
::v-deep .account-info-dialog {
max-width: 1160px;
}
.form-section { .form-section {
margin-bottom: 18px; margin-bottom: 18px;
padding: 18px 18px 4px; padding: 18px 18px 4px;
@@ -930,4 +933,15 @@ export default {
font-weight: 700; font-weight: 700;
} }
@media (max-width: 768px) {
::v-deep .account-info-dialog {
width: calc(100vw - 24px) !important;
margin-top: 8vh !important;
}
::v-deep .account-info-dialog .el-dialog__body {
padding: 18px 16px;
}
}
</style> </style>

View File

@@ -112,14 +112,17 @@
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row> </el-row>
<el-table v-loading="loading" :data="baseStaffList" @selection-change="handleSelectionChange"> <el-table v-loading="loading" :data="baseStaffList" class="base-staff-table">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="姓名" align="center" prop="name" :show-overflow-tooltip="true"/> <el-table-column label="姓名" align="center" prop="name" :show-overflow-tooltip="true"/>
<el-table-column label="柜员号" align="center" prop="staffId" :show-overflow-tooltip="true"/> <el-table-column label="柜员号" align="center" prop="staffId" :show-overflow-tooltip="true"/>
<el-table-column label="身份证号" align="center" prop="idCard" :show-overflow-tooltip="true"/> <el-table-column label="身份证号" align="center" prop="idCard" min-width="190" :show-overflow-tooltip="true"/>
<el-table-column label="所属部门" align="center" prop="deptName" :show-overflow-tooltip="true"/> <el-table-column label="所属部门" align="center" prop="deptName" min-width="180" :show-overflow-tooltip="true"/>
<el-table-column label="电话" align="center" prop="phone" width="120"/> <el-table-column label="电话" align="center" prop="phone" width="120"/>
<el-table-column label="年收入" align="center" prop="annualIncome" width="140"/> <el-table-column label="年收入(万元)" align="center" prop="annualIncome" width="120">
<template slot-scope="scope">
<span>{{ formatAnnualIncomeWan(scope.row.annualIncome) }}</span>
</template>
</el-table-column>
<el-table-column label="是否党员" align="center" prop="partyMember" width="100"> <el-table-column label="是否党员" align="center" prop="partyMember" width="100">
<template slot-scope="scope"> <template slot-scope="scope">
<span>{{ formatPartyMember(scope.row.partyMember) }}</span> <span>{{ formatPartyMember(scope.row.partyMember) }}</span>
@@ -131,9 +134,9 @@
<el-tag v-else type="danger">离职</el-tag> <el-tag v-else type="danger">离职</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="180"> <el-table-column label="入职时间" align="center" prop="hireDate" width="140">
<template slot-scope="scope"> <template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span> <span>{{ scope.row.hireDate ? parseTime(scope.row.hireDate, '{y}-{m}-{d}') : '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200"> <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200">
@@ -340,7 +343,7 @@
<el-descriptions-item label="身份证号">{{ employeeDetail.idCard || '-' }}</el-descriptions-item> <el-descriptions-item label="身份证号">{{ employeeDetail.idCard || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ employeeDetail.phone || '-' }}</el-descriptions-item> <el-descriptions-item label="电话">{{ employeeDetail.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="年收入"> <el-descriptions-item label="年收入">
{{ employeeDetail.annualIncome !== null && employeeDetail.annualIncome !== undefined && employeeDetail.annualIncome !== '' ? employeeDetail.annualIncome : '-' }} {{ formatAnnualIncome(employeeDetail.annualIncome) }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="入职时间"> <el-descriptions-item label="入职时间">
{{ employeeDetail.hireDate ? parseTime(employeeDetail.hireDate, '{y}-{m}-{d}') : '-' }} {{ employeeDetail.hireDate ? parseTime(employeeDetail.hireDate, '{y}-{m}-{d}') : '-' }}
@@ -967,6 +970,26 @@ export default {
maximumFractionDigits: 2 maximumFractionDigits: 2
})} 万元`; })} 万元`;
}, },
formatAnnualIncome(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
const amount = Number(value);
if (!Number.isFinite(amount)) {
return "-";
}
return amount.toFixed(0);
},
formatAnnualIncomeWan(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
const amount = Number(value);
if (!Number.isFinite(amount)) {
return "-";
}
return (amount / 10000).toFixed(2);
},
normalizeAnnualIncome(value) { normalizeAnnualIncome(value) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return null; return null;
@@ -1511,6 +1534,11 @@ export default {
margin-bottom: 10px; margin-bottom: 10px;
} }
.base-staff-table ::v-deep .el-table__header-wrapper th:first-child .cell,
.base-staff-table ::v-deep .el-table__body-wrapper td:first-child .cell {
padding-left: 24px;
}
/* 详情弹窗样式 */ /* 详情弹窗样式 */
.employee-detail-dialog .detail-container { .employee-detail-dialog .detail-container {
padding: 10px 0; padding: 10px 0;

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" v-show="showSearch" label-width="120px" class="query-form"> <el-form :model="queryParams" ref="queryForm" size="small" v-show="showSearch" label-width="136px" class="query-form">
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="6"> <el-col :span="6">
<el-form-item label="身份证号" prop="personId"> <el-form-item label="身份证号" prop="personId">
@@ -854,6 +854,10 @@ export default {
margin-right: 0; margin-right: 0;
} }
.query-form ::v-deep .el-form-item__label {
white-space: nowrap;
}
.detail-container { .detail-container {
padding: 0 20px; padding: 0 20px;
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<el-form ref="queryForm" :model="queryParams" size="small" v-show="showSearch" label-width="110px" class="query-form"> <el-form ref="queryForm" :model="queryParams" size="small" v-show="showSearch" label-width="136px" class="query-form">
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="6"> <el-col :span="6">
<el-form-item label="企业名称" prop="enterpriseName"> <el-form-item label="企业名称" prop="enterpriseName">
@@ -1002,6 +1002,10 @@ export default {
margin-right: 0; margin-right: 0;
} }
.query-form ::v-deep .el-form-item__label {
white-space: nowrap;
}
.el-upload__tip { .el-upload__tip {
line-height: 1.6; line-height: 1.6;
} }

View File

@@ -22,7 +22,7 @@
<el-table-column <el-table-column
prop="updateTime" prop="updateTime"
label="更新/创建时间" label="最近更新时间"
width="180" width="180"
align="center" align="center"
> >

View File

@@ -23,7 +23,7 @@
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="card-title">创建季度初核</div> <div class="card-title">创建季度初核</div>
<div class="card-desc">创建季度初核</div> <div class="card-desc">预填本季度项目模板</div>
</div> </div>
</div> </div>
</el-col> </el-col>
@@ -34,7 +34,7 @@
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="card-title">创建新员工排查</div> <div class="card-title">创建新员工排查</div>
<div class="card-desc">创建新员工排查</div> <div class="card-desc">预填本月排查模板</div>
</div> </div>
</div> </div>
</el-col> </el-col>
@@ -45,7 +45,7 @@
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="card-title">创建高风险专项</div> <div class="card-title">创建高风险专项</div>
<div class="card-desc">创建高风险专项</div> <div class="card-desc">预填专项排查模板</div>
</div> </div>
</div> </div>
</el-col> </el-col>

View File

@@ -226,7 +226,7 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="摘要 / 交易类型" min-width="240"> <el-table-column label="摘要 / 交易类型" min-width="200">
<template slot-scope="scope"> <template slot-scope="scope">
<div class="multi-line-cell"> <div class="multi-line-cell">
<div class="primary-text">{{ formatField(scope.row.userMemo) }}</div> <div class="primary-text">{{ formatField(scope.row.userMemo) }}</div>
@@ -234,6 +234,22 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column
label="交易金额"
prop="displayAmount"
width="120"
align="right"
sortable="custom"
>
<template slot-scope="scope">
<span
class="amount-text"
:class="scope.row.displayAmount >= 0 ? 'amount-in' : 'amount-out'"
>
{{ formatAmount(scope.row.displayAmount) }}
</span>
</template>
</el-table-column>
<el-table-column label="异常标签" min-width="220"> <el-table-column label="异常标签" min-width="220">
<template slot-scope="scope"> <template slot-scope="scope">
<div v-if="scope.row.hitTags && scope.row.hitTags.length" class="hit-tag-list"> <div v-if="scope.row.hitTags && scope.row.hitTags.length" class="hit-tag-list">
@@ -250,22 +266,6 @@
<span v-else class="empty-text">-</span> <span v-else class="empty-text">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column
label="交易金额"
prop="displayAmount"
min-width="140"
align="right"
sortable="custom"
>
<template slot-scope="scope">
<span
class="amount-text"
:class="scope.row.displayAmount >= 0 ? 'amount-in' : 'amount-out'"
>
{{ formatAmount(scope.row.displayAmount) }}
</span>
</template>
</el-table-column>
<el-table-column label="详情" width="100" fixed="right" align="center"> <el-table-column label="详情" width="100" fixed="right" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-button type="text" size="small" @click="handleViewDetail(scope.row)"> <el-button type="text" size="small" @click="handleViewDetail(scope.row)">
@@ -974,18 +974,31 @@ export default {
.primary-text { .primary-text {
color: #303133; color: #303133;
line-height: 20px; line-height: 20px;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
} }
.secondary-text { .secondary-text {
color: #909399; color: #909399;
font-size: 12px; font-size: 12px;
line-height: 18px; line-height: 18px;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
} }
.hit-tag-list { .hit-tag-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
min-width: 0;
}
.hit-tag-list ::v-deep .el-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
} }
.empty-text { .empty-text {

View File

@@ -4,16 +4,16 @@
<section class="detail-block"> <section class="detail-block">
<div class="block-header section-header"> <div class="block-header section-header">
<div class="block-title">总收入</div> <div class="block-title">总收入</div>
<div class="section-summary-value">{{ formatAmount(totalIncome) }}</div> <div class="section-summary-value">{{ formatWanAmount(totalIncome) }}</div>
</div> </div>
<div class="detail-metric-list"> <div class="detail-metric-list">
<div class="detail-metric-item"> <div class="detail-metric-item">
<span class="metric-label">本人收入</span> <span class="metric-label">本人收入</span>
<span class="metric-value">{{ formatAmount(incomeDetail.selfIncome) }}</span> <span class="metric-value">{{ formatWanAmount(incomeDetail.selfIncome) }}</span>
</div> </div>
<div class="detail-metric-item"> <div class="detail-metric-item">
<span class="metric-label">配偶收入</span> <span class="metric-label">配偶收入</span>
<span class="metric-value">{{ formatAmount(incomeDetail.spouseIncome) }}</span> <span class="metric-value">{{ formatWanAmount(incomeDetail.spouseIncome) }}</span>
</div> </div>
</div> </div>
</section> </section>
@@ -69,7 +69,7 @@
<span class="metric-card-value">{{ assetDebtRatio }}</span> <span class="metric-card-value">{{ assetDebtRatio }}</span>
</div> </div>
<div class="metric-card"> <div class="metric-card">
<span class="metric-card-label">资产/收入比</span> <span class="metric-card-label">资产收入倍数</span>
<span class="metric-card-value">{{ assetIncomeRatio }}</span> <span class="metric-card-value">{{ assetIncomeRatio }}</span>
</div> </div>
<div class="metric-card"> <div class="metric-card">
@@ -86,7 +86,9 @@
</div> </div>
<div class="result-card" :class="[`result-card--${riskResult.type}`]"> <div class="result-card" :class="[`result-card--${riskResult.type}`]">
<div class="result-card-label">结论</div> <div class="result-card-label">结论</div>
<div class="result-card-value">{{ riskResult.text }}</div> <div class="result-card-value">
<div v-for="line in riskResult.lines" :key="line" class="result-card-line">{{ line }}</div>
</div>
</div> </div>
</section> </section>
</div> </div>
@@ -115,7 +117,9 @@ export default {
: { : {
selfIncome: 0, selfIncome: 0,
spouseIncome: 0, spouseIncome: 0,
employmentYears: null,
totalIncome: 0, totalIncome: 0,
explainableIncome: 0,
}; };
}, },
summary() { summary() {
@@ -160,6 +164,15 @@ export default {
totalDebt() { totalDebt() {
return this.resolveAmount(this.summary.totalDebt, this.debtDetail.totalDebt); return this.resolveAmount(this.summary.totalDebt, this.debtDetail.totalDebt);
}, },
employmentYears() {
const target = this.summary.employmentYears !== undefined && this.summary.employmentYears !== null
? this.summary.employmentYears
: this.incomeDetail.employmentYears;
return target === undefined || target === null || target === "" ? null : Number(target);
},
explainableIncome() {
return this.resolveAmount(this.summary.explainableIncome, this.incomeDetail.explainableIncome);
},
assetItems() { assetItems() {
return Array.isArray(this.assetDetail.items) ? this.assetDetail.items : []; return Array.isArray(this.assetDetail.items) ? this.assetDetail.items : [];
}, },
@@ -184,12 +197,10 @@ export default {
isPercent: true, isPercent: true,
}); });
}, },
// 资产/收入 = totalAsset / totalIncome // 资产收入倍数 = totalAsset / explainableIncome
assetIncomeRatio() { assetIncomeRatio() {
return this.formatMetricValue(this.totalAsset / this.totalIncome, { const ratio = this.resolveRatio(this.summary.assetIncomeRatio, this.totalAsset, this.explainableIncome);
denominator: this.totalIncome, return this.formatRatio(ratio);
suffix: "倍",
});
}, },
// 负债/收入比 = totalDebt / totalIncome // 负债/收入比 = totalDebt / totalIncome
debtIncomeRatio() { debtIncomeRatio() {
@@ -202,22 +213,22 @@ export default {
const riskResultMap = { const riskResultMap = {
NORMAL: { NORMAL: {
name: this.summary.riskLevelName || "正常", name: this.summary.riskLevelName || "正常",
text: "结构基本合理", lines: this.buildResultLines(),
type: "success", type: "success",
}, },
RISK: { RISK: {
name: this.summary.riskLevelName || "存在风险", name: this.summary.riskLevelName || "关注",
text: "负债与收入压力偏高", lines: this.buildResultLines(),
type: "warning", type: "warning",
}, },
HIGH: { HIGH: {
name: this.summary.riskLevelName || "高风险", name: this.summary.riskLevelName || "高关注",
text: "资产负债结构明显异常", lines: this.buildResultLines(),
type: "danger", type: "danger",
}, },
MISSING_INFO: { MISSING_INFO: {
name: this.summary.riskLevelName || "缺少信息", name: this.summary.riskLevelName || "缺少信息",
text: "当前信息不完整", lines: [this.buildMissingInfoLine()],
type: "info", type: "info",
}, },
}; };
@@ -246,6 +257,46 @@ export default {
maximumFractionDigits: 2, maximumFractionDigits: 2,
})} 万元`; })} 万元`;
}, },
buildResultLines() {
return [
`家庭总资产:${this.formatWanAmount(this.totalAsset)}`,
`可解释收入:本人年收入 ${this.formatWanAmount(this.incomeDetail.selfIncome)} × 入职年限 ${this.employmentYears || "-"} 年 + 配偶年收入 ${this.formatWanAmount(this.incomeDetail.spouseIncome)} + 家庭总负债 ${this.formatWanAmount(this.totalDebt)} = ${this.formatWanAmount(this.explainableIncome)}`,
`资产收入倍数:${this.formatRatio(this.resolveRatio(this.summary.assetIncomeRatio, this.totalAsset, this.explainableIncome))}`,
];
},
buildMissingInfoLine() {
const missing = [];
if (this.resolveAmount(this.incomeDetail.selfIncome) <= 0) {
missing.push("收入");
}
if (this.resolveAmount(this.totalAsset) <= 0) {
missing.push("资产");
}
if (!this.employmentYears) {
missing.push("入职时间");
}
return missing.length === 1
? `缺少${missing[0]},暂无法判断。`
: "关键信息不完整,暂无法判断。";
},
resolveRatio(preferred, numerator, denominator) {
if (preferred !== undefined && preferred !== null && preferred !== "") {
const ratio = Number(preferred);
return Number.isFinite(ratio) ? ratio : null;
}
const divisor = Number(denominator || 0);
if (!divisor) {
return null;
}
const ratio = Number(numerator || 0) / divisor;
return Number.isFinite(ratio) ? ratio : null;
},
formatRatio(value) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) {
return "-";
}
return Number(value).toFixed(2);
},
buildAmountGroups(items, amountField, resolveLabel, totalAmount) { buildAmountGroups(items, amountField, resolveLabel, totalAmount) {
const groupMap = items.reduce((result, item, index) => { const groupMap = items.reduce((result, item, index) => {
const label = resolveLabel(item); const label = resolveLabel(item);
@@ -467,6 +518,11 @@ export default {
color: #111827; color: #111827;
} }
.result-card-line {
line-height: 1.7;
word-break: break-word;
}
.empty-hint { .empty-hint {
font-size: 13px; font-size: 13px;
color: #94a3b8; color: #94a3b8;

View File

@@ -19,7 +19,7 @@
<template slot="empty"> <template slot="empty">
<el-empty :image-size="80" description="暂无员工家庭资产负债核查数据" /> <el-empty :image-size="80" description="暂无员工家庭资产负债核查数据" />
</template> </template>
<el-table-column type="expand" width="1"> <el-table-column type="expand" width="1" class-name="family-expand-column">
<template slot-scope="scope"> <template slot-scope="scope">
<div class="family-detail-wrapper"> <div class="family-detail-wrapper">
<div class="family-detail-toolbar"> <div class="family-detail-toolbar">
@@ -47,7 +47,7 @@
<el-table-column prop="deptName" label="所属部门" min-width="140" /> <el-table-column prop="deptName" label="所属部门" min-width="140" />
<el-table-column label="家庭总年收入" min-width="140"> <el-table-column label="家庭总年收入" min-width="140">
<template slot-scope="scope"> <template slot-scope="scope">
<span>{{ formatAmount(scope.row.totalIncome) }}</span> <span>{{ formatWanAmount(scope.row.totalIncome) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="家庭总负债" min-width="140"> <el-table-column label="家庭总负债" min-width="140">
@@ -201,7 +201,7 @@ export default {
} }
const detail = this.detailCache[row.staffIdCard] || {}; const detail = this.detailCache[row.staffIdCard] || {};
const summary = detail.summary || {}; const summary = detail.summary || {};
const evidenceSummary = `${row.staffName}家庭资产负债核查:家庭总年收入${this.formatAmount(row.totalIncome)},家庭总负债${this.formatAmount(row.totalDebt)},家庭总资产${this.formatWanAmount(row.totalAsset)},风险情况${row.riskLevelName || "-" }`; const evidenceSummary = `${row.staffName}家庭资产负债核查:家庭总年收入${this.formatWanAmount(row.totalIncome)},家庭总负债${this.formatAmount(row.totalDebt)},家庭总资产${this.formatWanAmount(row.totalAsset)},风险情况${row.riskLevelName || "-" }`;
this.$emit("evidence-confirm", { this.$emit("evidence-confirm", {
evidenceType: "ASSET", evidenceType: "ASSET",
relatedPersonName: row.staffName || "关联人员", relatedPersonName: row.staffName || "关联人员",
@@ -291,4 +291,9 @@ export default {
background: #f8fafc; background: #f8fafc;
color: #64748b; color: #64748b;
} }
::v-deep .family-expand-column .cell,
::v-deep .family-expand-column .el-table__expand-icon {
display: none;
}
</style> </style>

View File

@@ -9,7 +9,7 @@
</div> </div>
<el-form :model="queryParams" size="small" :inline="true" label-width="104px" class="query-form"> <el-form :model="queryParams" size="small" :inline="true" label-width="104px" class="query-form">
<el-form-item label="柜员号"> <el-form-item label="柜员号" label-width="72px">
<el-input <el-input
v-model="queryParams.staffId" v-model="queryParams.staffId"
placeholder="请输入柜员号" placeholder="请输入柜员号"

View File

@@ -99,6 +99,8 @@ import RiskDetailSection from "./RiskDetailSection";
import ProjectAnalysisDialog from "./ProjectAnalysisDialog"; import ProjectAnalysisDialog from "./ProjectAnalysisDialog";
import ExternalPersonDetailDialog from "./ExternalPersonDetailDialog"; import ExternalPersonDetailDialog from "./ExternalPersonDetailDialog";
const DEFAULT_SUSPICIOUS_PAGE_SIZE = 10;
export default { export default {
name: "PreliminaryCheck", name: "PreliminaryCheck",
components: { components: {
@@ -429,7 +431,7 @@ export default {
projectId: this.projectId, projectId: this.projectId,
suspiciousType: "ALL", suspiciousType: "ALL",
pageNum: 1, pageNum: 1,
pageSize: 5, pageSize: DEFAULT_SUSPICIOUS_PAGE_SIZE,
}), }),
getOverviewEmployeeCreditNegative({ getOverviewEmployeeCreditNegative({
projectId: this.projectId, projectId: this.projectId,

View File

@@ -25,6 +25,7 @@
class="suspicious-filter-select" class="suspicious-filter-select"
:placeholder="allModelOptionLabel" :placeholder="allModelOptionLabel"
filterable filterable
@change="handleSuspiciousModelSelectChange"
> >
<el-option :label="allModelOptionLabel" value="" /> <el-option :label="allModelOptionLabel" value="" />
<el-option <el-option
@@ -93,7 +94,7 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="摘要 / 交易类型" min-width="240"> <el-table-column label="摘要 / 交易类型" min-width="200">
<template slot-scope="scope"> <template slot-scope="scope">
<div class="multi-line-cell"> <div class="multi-line-cell">
<div class="primary-text">{{ formatField(scope.row.userMemo) }}</div> <div class="primary-text">{{ formatField(scope.row.userMemo) }}</div>
@@ -101,6 +102,16 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="displayAmount" label="交易金额" width="120" align="right">
<template slot-scope="scope">
<span
class="amount-text"
:class="scope.row.displayAmount >= 0 ? 'amount-in' : 'amount-out'"
>
{{ formatAmount(scope.row.displayAmount) }}
</span>
</template>
</el-table-column>
<el-table-column label="异常标签" min-width="220"> <el-table-column label="异常标签" min-width="220">
<template slot-scope="scope"> <template slot-scope="scope">
<div v-if="scope.row.hitTags && scope.row.hitTags.length" class="hit-tag-list"> <div v-if="scope.row.hitTags && scope.row.hitTags.length" class="hit-tag-list">
@@ -117,16 +128,6 @@
<span v-else class="empty-text">-</span> <span v-else class="empty-text">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="displayAmount" label="交易金额" min-width="140" align="right">
<template slot-scope="scope">
<span
class="amount-text"
:class="scope.row.displayAmount >= 0 ? 'amount-in' : 'amount-out'"
>
{{ formatAmount(scope.row.displayAmount) }}
</span>
</template>
</el-table-column>
<el-table-column label="详情" width="100" fixed="right" align="center"> <el-table-column label="详情" width="100" fixed="right" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-button type="text" size="mini" @click="handleViewDetail(scope.row)"> <el-button type="text" size="mini" @click="handleViewDetail(scope.row)">
@@ -223,6 +224,10 @@
<el-table-column prop="abnormalType" label="异常类型" min-width="160" /> <el-table-column prop="abnormalType" label="异常类型" min-width="160" />
<el-table-column prop="abnormalTime" label="异常发生时间" min-width="160" /> <el-table-column prop="abnormalTime" label="异常发生时间" min-width="160" />
<el-table-column prop="status" label="状态" min-width="120" /> <el-table-column prop="status" label="状态" min-width="120" />
<el-table-column prop="reasonDetail" label="命中原因" min-width="260" show-overflow-tooltip />
<el-table-column prop="involvedAmount" label="涉及金额" min-width="130" align="right">
<template slot-scope="scope">{{ formatAmount(scope.row.involvedAmount) }}</template>
</el-table-column>
</el-table> </el-table>
<pagination <pagination
@@ -378,6 +383,7 @@ const EXTERNAL_RISK_MODEL_OPTIONS = [
{ value: "EXTERNAL_SUSPICIOUS_GAMBLING", label: "外部人员可疑赌博" }, { value: "EXTERNAL_SUSPICIOUS_GAMBLING", label: "外部人员可疑赌博" },
{ value: "EXTERNAL_SUSPICIOUS_RELATION", label: "外部人员可疑关系" }, { value: "EXTERNAL_SUSPICIOUS_RELATION", label: "外部人员可疑关系" },
]; ];
const DEFAULT_SUSPICIOUS_PAGE_SIZE = 10;
const DEFAULT_RISK_DETAIL_PAGE_SIZE = 5; const DEFAULT_RISK_DETAIL_PAGE_SIZE = 5;
const RISK_DETAIL_PAGE_SIZE_OPTIONS = [5, 10, 20, 50]; const RISK_DETAIL_PAGE_SIZE_OPTIONS = [5, 10, 20, 50];
@@ -513,6 +519,15 @@ const normalizeDetailData = (detail) => ({
hitTags: normalizeHitTags(detail && detail.hitTags), hitTags: normalizeHitTags(detail && detail.hitTags),
}); });
const normalizeSuspiciousRows = (rows) => (
Array.isArray(rows)
? rows.map((row) => ({
...row,
hitTags: normalizeHitTags(row && row.hitTags, row),
}))
: []
);
export default { export default {
name: "RiskDetailSection", name: "RiskDetailSection",
props: { props: {
@@ -530,7 +545,7 @@ export default {
currentSuspiciousType: "ALL", currentSuspiciousType: "ALL",
selectedModelCode: "", selectedModelCode: "",
suspiciousPageNum: 1, suspiciousPageNum: 1,
suspiciousPageSize: DEFAULT_RISK_DETAIL_PAGE_SIZE, suspiciousPageSize: DEFAULT_SUSPICIOUS_PAGE_SIZE,
suspiciousTotal: 0, suspiciousTotal: 0,
suspiciousTransactionList: [], suspiciousTransactionList: [],
riskModelOptions: [], riskModelOptions: [],
@@ -546,6 +561,8 @@ export default {
abnormalAccountList: [], abnormalAccountList: [],
projectId: null, projectId: null,
statementDetailCache: {}, statementDetailCache: {},
suspiciousRequestSeq: 0,
detailRequestSeq: 0,
}; };
}, },
computed: { computed: {
@@ -574,15 +591,11 @@ export default {
watch: { watch: {
sectionData: { sectionData: {
immediate: true, immediate: true,
deep: true,
handler(value) { handler(value) {
this.projectId = value && value.projectId ? value.projectId : null; const nextProjectId = value && value.projectId ? value.projectId : null;
this.currentSuspiciousType = normalizeSuspiciousType(value && value.suspiciousType); const projectChanged = String(nextProjectId || "") !== String(this.projectId || "");
this.selectedModelCode = ""; this.projectId = nextProjectId;
this.riskModelOptions = normalizeRiskModelOptions(value && value.riskModelOptions); this.riskModelOptions = normalizeRiskModelOptions(value && value.riskModelOptions);
this.suspiciousPageNum = 1;
this.suspiciousPageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE;
this.suspiciousTotal = Number(value && value.total) || 0;
this.employeeCreditNegativePageNum = 1; this.employeeCreditNegativePageNum = 1;
this.employeeCreditNegativePageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE; this.employeeCreditNegativePageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE;
this.employeeCreditNegativeTotal = Number(value && value.employeeCreditNegativeTotal) || 0; this.employeeCreditNegativeTotal = Number(value && value.employeeCreditNegativeTotal) || 0;
@@ -590,19 +603,33 @@ export default {
this.abnormalAccountPageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE; this.abnormalAccountPageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE;
this.abnormalAccountTotal = 0; this.abnormalAccountTotal = 0;
this.abnormalAccountList = []; this.abnormalAccountList = [];
const rows = Array.isArray(value && value.suspiciousTransactionList)
? value.suspiciousTransactionList
: [];
this.employeeCreditNegativeList = Array.isArray(value && value.employeeCreditNegativeList) this.employeeCreditNegativeList = Array.isArray(value && value.employeeCreditNegativeList)
? value.employeeCreditNegativeList ? value.employeeCreditNegativeList
: []; : [];
this.hydrateSuspiciousRows(rows); if (projectChanged) {
this.suspiciousRequestSeq += 1;
this.detailRequestSeq += 1;
this.currentSuspiciousType = normalizeSuspiciousType(value && value.suspiciousType);
this.selectedModelCode = "";
this.suspiciousPageNum = 1;
this.suspiciousPageSize = DEFAULT_SUSPICIOUS_PAGE_SIZE;
this.suspiciousTotal = Number(value && value.total) || 0;
this.suspiciousTransactionList = normalizeSuspiciousRows(
value && value.suspiciousTransactionList
);
this.suspiciousLoading = false;
this.statementDetailCache = {};
this.closeDetailDialog();
}
this.loadAbnormalAccountPeople(); this.loadAbnormalAccountPeople();
}, },
}, },
}, },
methods: { methods: {
buildFlowEvidenceFingerprint, buildFlowEvidenceFingerprint,
handleSuspiciousModelSelectChange() {
this.resetSuspiciousFilterResults();
},
handleSuspiciousTypeSelectChange(value) { handleSuspiciousTypeSelectChange(value) {
if (value === "EXTERNAL_PERSON") { if (value === "EXTERNAL_PERSON") {
const hasExternalModel = EXTERNAL_RISK_MODEL_OPTIONS const hasExternalModel = EXTERNAL_RISK_MODEL_OPTIONS
@@ -610,11 +637,20 @@ export default {
if (!hasExternalModel) { if (!hasExternalModel) {
this.selectedModelCode = ""; this.selectedModelCode = "";
} }
return; } else if (value === "MODEL_RULE" && isExternalModelCode(this.selectedModelCode)) {
}
if (value === "MODEL_RULE" && isExternalModelCode(this.selectedModelCode)) {
this.selectedModelCode = ""; this.selectedModelCode = "";
} }
this.resetSuspiciousFilterResults();
},
resetSuspiciousFilterResults() {
this.suspiciousRequestSeq += 1;
this.detailRequestSeq += 1;
this.suspiciousPageNum = 1;
this.suspiciousTotal = 0;
this.suspiciousTransactionList = [];
this.suspiciousLoading = false;
this.statementDetailCache = {};
this.closeDetailDialog();
}, },
async handleSuspiciousQuery() { async handleSuspiciousQuery() {
this.suspiciousPageNum = 1; this.suspiciousPageNum = 1;
@@ -663,6 +699,7 @@ export default {
await this.loadAbnormalAccountPeople(); await this.loadAbnormalAccountPeople();
}, },
async loadSuspiciousTransactions() { async loadSuspiciousTransactions() {
const requestSeq = ++this.suspiciousRequestSeq;
if (!this.projectId) { if (!this.projectId) {
this.suspiciousTransactionList = []; this.suspiciousTransactionList = [];
this.suspiciousTotal = 0; this.suspiciousTotal = 0;
@@ -670,25 +707,35 @@ export default {
return; return;
} }
this.suspiciousLoading = true; const query = {
try {
const response = await getOverviewSuspiciousTransactions({
projectId: this.projectId, projectId: this.projectId,
modelCode: this.selectedModelCode, modelCode: this.selectedModelCode,
suspiciousType: this.currentSuspiciousType, suspiciousType: this.currentSuspiciousType,
pageNum: this.suspiciousPageNum, pageNum: this.suspiciousPageNum,
pageSize: this.suspiciousPageSize, pageSize: this.suspiciousPageSize,
}); };
this.suspiciousLoading = true;
try {
const response = await getOverviewSuspiciousTransactions(query);
if (requestSeq !== this.suspiciousRequestSeq) {
return;
}
const data = (response && response.data) || {}; const data = (response && response.data) || {};
this.suspiciousTotal = Number(data.total) || 0; this.suspiciousTotal = Number(data.total) || 0;
await this.hydrateSuspiciousRows(Array.isArray(data.rows) ? data.rows : []); this.suspiciousTransactionList = normalizeSuspiciousRows(data.rows);
} catch (error) { } catch (error) {
if (requestSeq !== this.suspiciousRequestSeq) {
return;
}
this.suspiciousTransactionList = []; this.suspiciousTransactionList = [];
this.suspiciousTotal = 0; this.suspiciousTotal = 0;
this.$message.error("加载涉疑交易明细失败"); this.$message.error("加载涉疑交易明细失败");
console.error("加载涉疑交易明细失败", error); console.error("加载涉疑交易明细失败", error);
} finally {
if (requestSeq === this.suspiciousRequestSeq) {
this.suspiciousLoading = false; this.suspiciousLoading = false;
} }
}
}, },
async loadEmployeeCreditNegative() { async loadEmployeeCreditNegative() {
if (!this.projectId) { if (!this.projectId) {
@@ -744,57 +791,25 @@ export default {
this.abnormalAccountLoading = false; this.abnormalAccountLoading = false;
} }
}, },
async hydrateSuspiciousRows(rows) { async fetchStatementDetail(bankStatementId, silent, scope = {}) {
const safeRows = Array.isArray(rows) ? rows : [];
if (!safeRows.length) {
this.suspiciousTransactionList = [];
this.suspiciousLoading = false;
return;
}
this.suspiciousLoading = true;
try {
const enrichedRows = await Promise.all(
safeRows.map(async (row) => {
if (!row || !row.bankStatementId) {
return {
...row,
hitTags: [],
};
}
try {
const detail = await this.fetchStatementDetail(row.bankStatementId, true);
return {
...detail,
...row,
hitTags: normalizeHitTags(detail.hitTags, row),
};
} catch (error) {
return {
...row,
hitTags: [],
};
}
})
);
this.suspiciousTransactionList = enrichedRows;
} finally {
this.suspiciousLoading = false;
}
},
async fetchStatementDetail(bankStatementId, silent) {
if (!bankStatementId) { if (!bankStatementId) {
return createEmptyDetailData(); return createEmptyDetailData();
} }
if (this.statementDetailCache[bankStatementId]) { const modelCode = scope.modelCode !== undefined ? scope.modelCode : this.selectedModelCode;
return this.statementDetailCache[bankStatementId]; const suspiciousType = scope.suspiciousType || this.currentSuspiciousType;
const cacheKey = `${bankStatementId}|${suspiciousType || "ALL"}|${modelCode || "ALL"}`;
if (this.statementDetailCache[cacheKey]) {
return this.statementDetailCache[cacheKey];
} }
try { try {
const response = await getBankStatementDetail(bankStatementId); const response = await getBankStatementDetail(bankStatementId, {
modelCode,
suspiciousType,
});
const detail = normalizeDetailData(response && response.data); const detail = normalizeDetailData(response && response.data);
this.$set(this.statementDetailCache, bankStatementId, detail); this.$set(this.statementDetailCache, cacheKey, detail);
return detail; return detail;
} catch (error) { } catch (error) {
if (!silent) { if (!silent) {
@@ -808,10 +823,18 @@ export default {
return; return;
} }
const requestSeq = ++this.detailRequestSeq;
const scope = {
modelCode: this.selectedModelCode,
suspiciousType: this.currentSuspiciousType,
};
this.detailVisible = true; this.detailVisible = true;
this.detailLoading = true; this.detailLoading = true;
try { try {
const detail = await this.fetchStatementDetail(row.bankStatementId, false); const detail = await this.fetchStatementDetail(row.bankStatementId, false, scope);
if (requestSeq !== this.detailRequestSeq) {
return;
}
this.detailData = { this.detailData = {
...detail, ...detail,
...row, ...row,
@@ -820,12 +843,17 @@ export default {
: normalizeHitTags(detail.hitTags, row), : normalizeHitTags(detail.hitTags, row),
}; };
} catch (error) { } catch (error) {
if (requestSeq !== this.detailRequestSeq) {
return;
}
this.detailData = createEmptyDetailData(); this.detailData = createEmptyDetailData();
this.$message.error("加载流水详情失败"); this.$message.error("加载流水详情失败");
console.error("加载流水详情失败", error); console.error("加载流水详情失败", error);
} finally { } finally {
if (requestSeq === this.detailRequestSeq) {
this.detailLoading = false; this.detailLoading = false;
} }
}
}, },
closeDetailDialog() { closeDetailDialog() {
this.detailVisible = false; this.detailVisible = false;
@@ -865,6 +893,8 @@ export default {
"ccdi/project/overview/risk-details/export", "ccdi/project/overview/risk-details/export",
{ {
projectId: this.projectId, projectId: this.projectId,
modelCode: this.selectedModelCode,
suspiciousType: this.currentSuspiciousType,
}, },
`风险明细_${this.projectId}_${new Date().getTime()}.xlsx` `风险明细_${this.projectId}_${new Date().getTime()}.xlsx`
); );
@@ -1079,12 +1109,16 @@ export default {
font-weight: 500; font-weight: 500;
} }
.suspicious-filter-form :deep(.el-button) {
min-width: 88px;
}
.suspicious-filter-select { .suspicious-filter-select {
width: 220px; width: 240px;
} }
.suspicious-type-select { .suspicious-type-select {
width: 160px; width: 240px;
} }
.result-table { .result-table {
@@ -1100,18 +1134,31 @@ export default {
.primary-text { .primary-text {
color: #303133; color: #303133;
line-height: 20px; line-height: 20px;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
} }
.secondary-text { .secondary-text {
color: #909399; color: #909399;
font-size: 12px; font-size: 12px;
line-height: 18px; line-height: 18px;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
} }
.hit-tag-list { .hit-tag-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
min-width: 0;
}
.hit-tag-list ::v-deep .el-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
} }
.empty-text { .empty-text {

View File

@@ -9,11 +9,11 @@
</div> </div>
<div class="model-workbench"> <div class="model-workbench">
<section class="model-list-panel"> <section class="model-list-panel" aria-label="模型预警统计">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<div class="panel-title">模型预警统计</div> <div class="panel-title">模型预警统计</div>
<div class="panel-subtitle">点击模型筛选下方命中人员</div> <div class="panel-subtitle">点击筛选</div>
</div> </div>
</div> </div>
@@ -41,11 +41,10 @@
</div> </div>
</section> </section>
<section class="model-result-panel"> <section class="model-result-panel" aria-label="命中模型涉及人员">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<div class="panel-title">命中模型涉及人员</div> <div class="panel-title">命中模型涉及人员</div>
<div class="panel-subtitle">按模型人员信息和触发方式查看明细</div>
</div> </div>
</div> </div>
@@ -56,7 +55,7 @@
<el-tag <el-tag
v-for="item in selectedCards" v-for="item in selectedCards"
:key="`selected-${item.key}`" :key="`selected-${item.key}`"
size="mini" size="small"
closable closable
effect="plain" effect="plain"
@close="toggleModelSelection(item.key)" @close="toggleModelSelection(item.key)"
@@ -76,7 +75,7 @@
<span class="filter-label">{{ keywordLabel }}</span> <span class="filter-label">{{ keywordLabel }}</span>
<el-input <el-input
v-model.trim="keyword" v-model.trim="keyword"
size="mini" size="small"
clearable clearable
:placeholder="keywordPlaceholder" :placeholder="keywordPlaceholder"
@keyup.enter.native="handleQuery" @keyup.enter.native="handleQuery"
@@ -87,7 +86,7 @@
<span class="filter-label">部门</span> <span class="filter-label">部门</span>
<el-select <el-select
v-model="deptId" v-model="deptId"
size="mini" size="small"
clearable clearable
filterable filterable
:loading="deptLoading" :loading="deptLoading"
@@ -106,7 +105,7 @@
<span class="filter-label">模型关系</span> <span class="filter-label">模型关系</span>
<el-radio-group <el-radio-group
v-model="matchMode" v-model="matchMode"
size="mini" size="small"
@change="handleMatchModeChange" @change="handleMatchModeChange"
> >
<el-radio-button label="ANY">命中任一模型</el-radio-button> <el-radio-button label="ANY">命中任一模型</el-radio-button>
@@ -115,8 +114,8 @@
</div> </div>
<div class="filter-actions"> <div class="filter-actions">
<el-button size="mini" type="primary" @click="handleQuery">查询</el-button> <el-button size="small" type="primary" @click="handleQuery">查询</el-button>
<el-button size="mini" plain @click="resetQuery">重置</el-button> <el-button size="small" plain @click="resetQuery">重置</el-button>
</div> </div>
</div> </div>
@@ -594,9 +593,9 @@ export default {
.model-list-panel, .model-list-panel,
.model-result-panel { .model-result-panel {
border: 1px solid var(--ccdi-border); border: 0;
border-radius: 8px; border-radius: 0;
background: #fff; background: transparent;
overflow: hidden; overflow: hidden;
} }
@@ -609,10 +608,8 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
min-height: 54px; min-height: 38px;
padding: 12px 14px; padding: 0 2px 10px;
border-bottom: 1px solid #e7eef5;
background: #f8fbfe;
} }
.panel-title { .panel-title {
@@ -623,7 +620,7 @@ export default {
} }
.panel-subtitle { .panel-subtitle {
margin-top: 3px; margin-top: 2px;
font-size: 12px; font-size: 12px;
color: var(--ccdi-text-muted); color: var(--ccdi-text-muted);
} }
@@ -632,7 +629,7 @@ export default {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px; gap: 10px;
padding: 12px; padding: 0;
} }
.model-card { .model-card {
@@ -747,16 +744,16 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin: 12px 14px 0; margin: 14px 0 0;
padding: 8px 10px; padding: 0 0 10px;
border: 1px solid #e6edf5; border-bottom: 1px solid #edf2f7;
border-radius: 6px; background: transparent;
background: #fbfdff;
} }
.selected-label { .selected-label {
color: var(--ccdi-text-secondary); color: var(--ccdi-text-secondary);
font-size: 12px; font-size: 14px;
line-height: 22px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -771,19 +768,18 @@ export default {
.selected-placeholder { .selected-placeholder {
color: var(--ccdi-text-muted); color: var(--ccdi-text-muted);
font-size: 13px; font-size: 14px;
} }
.filter-bar { .filter-bar {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px 14px; gap: 12px 18px;
margin: 10px 14px 12px; margin: 12px 0 14px;
padding: 10px; padding: 0;
border: 1px solid #e6edf5; border: 0;
border-radius: 6px; background: transparent;
background: #fff;
} }
.filter-item { .filter-item {
@@ -808,7 +804,8 @@ export default {
.filter-label, .filter-label,
.summary-label { .summary-label {
font-size: 12px; font-size: 14px;
line-height: 32px;
color: var(--ccdi-text-secondary); color: var(--ccdi-text-secondary);
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
@@ -821,8 +818,8 @@ export default {
} }
.model-table { .model-table {
margin: 0 14px; margin: 0;
width: calc(100% - 28px); width: 100%;
border: 1px solid #e7eef5; border: 1px solid #e7eef5;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;

View File

@@ -25,7 +25,7 @@
:disabled="isWriteLocked" :disabled="isWriteLocked"
@click="handleFetchBankInfo" @click="handleFetchBankInfo"
> >
拉取本行信息 拉取流水
</el-button> </el-button>
<el-button <el-button
size="small" size="small"
@@ -110,7 +110,7 @@
</div> </div>
<el-dialog <el-dialog
title="拉取本行信息" title="拉取行内/金综流水"
:visible.sync="pullBankInfoDialogVisible" :visible.sync="pullBankInfoDialogVisible"
:close-on-click-modal="false" :close-on-click-modal="false"
width="640px" width="640px"
@@ -120,6 +120,24 @@
:model="pullBankInfoForm" :model="pullBankInfoForm"
label-width="100px" label-width="100px"
> >
<el-form-item label="流水类型">
<el-radio-group
v-model="pullBankInfoForm.dataChannelCode"
:disabled="isWriteLocked"
class="pull-bank-channel-group"
>
<el-radio label="ZJRCU">
<span class="pull-bank-channel-title">拉取行内流水</span>
<span class="pull-bank-channel-tip">备注:仅支持获取近一年流水</span>
</el-radio>
<el-radio label="JZL">
<span class="pull-bank-channel-title">拉取金综流水</span>
<span class="pull-bank-channel-tip">
备注:自动获取最近30天内授权的流水文件
</span>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="证件号码"> <el-form-item label="证件号码">
<el-input <el-input
v-model="pullBankInfoForm.idCardText" v-model="pullBankInfoForm.idCardText"
@@ -171,7 +189,7 @@
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="时间跨度"> <el-form-item v-if="isZjrcuPullBankInfo" label="时间跨度">
<el-date-picker <el-date-picker
class="pull-bank-range-picker" class="pull-bank-range-picker"
v-model="pullBankInfoForm.dateRange" v-model="pullBankInfoForm.dateRange"
@@ -309,6 +327,7 @@ export default {
parsingIdCardFile: false, parsingIdCardFile: false,
idCardFileList: [], idCardFileList: [],
pullBankInfoForm: { pullBankInfoForm: {
dataChannelCode: "ZJRCU",
idCardText: "", idCardText: "",
dateRange: [], dateRange: [],
}, },
@@ -365,6 +384,9 @@ export default {
isWriteLocked() { isWriteLocked() {
return !this.canOperate || this.isProjectTagging || this.isProjectArchived; return !this.canOperate || this.isProjectTagging || this.isProjectArchived;
}, },
isZjrcuPullBankInfo() {
return this.pullBankInfoForm.dataChannelCode === "ZJRCU";
},
writeLockedMessage() { writeLockedMessage() {
if (!this.canOperate) { if (!this.canOperate) {
return "当前项目仅可查看,暂不可上传或拉取数据。"; return "当前项目仅可查看,暂不可上传或拉取数据。";
@@ -440,6 +462,7 @@ export default {
}, },
resetPullBankInfoForm() { resetPullBankInfoForm() {
this.pullBankInfoForm = { this.pullBankInfoForm = {
dataChannelCode: "ZJRCU",
idCardText: "", idCardText: "",
dateRange: this.buildDefaultPullBankInfoDateRange(), dateRange: this.buildDefaultPullBankInfoDateRange(),
}; };
@@ -594,18 +617,22 @@ export default {
} }
const idCards = this.buildFinalIdCardList(); const idCards = this.buildFinalIdCardList();
const [startDate, endDate] = this.pullBankInfoForm.dateRange || []; const [startDate, endDate] = this.pullBankInfoForm.dateRange || [];
const dataChannelCode = this.pullBankInfoForm.dataChannelCode;
if (idCards.length === 0) { if (idCards.length === 0) {
this.$message.warning("请至少输入一个身份证号"); this.$message.warning("请至少输入一个身份证号");
return; return;
} }
if (!startDate || !endDate) { if (this.isZjrcuPullBankInfo && (!startDate || !endDate)) {
this.$message.warning("请选择完整的时间跨度"); this.$message.warning("请选择完整的时间跨度");
return; return;
} }
if (this.hasInvalidPullBankInfoDateRange([startDate, endDate])) { if (
this.isZjrcuPullBankInfo &&
this.hasInvalidPullBankInfoDateRange([startDate, endDate])
) {
this.$message.warning("时间跨度仅支持近一年内日期,且最晚只能选择到昨天"); this.$message.warning("时间跨度仅支持近一年内日期,且最晚只能选择到昨天");
return; return;
} }
@@ -616,8 +643,9 @@ export default {
const payload = { const payload = {
projectId: this.projectId, projectId: this.projectId,
idCards, idCards,
startDate, dataChannelCode,
endDate, startDate: this.isZjrcuPullBankInfo ? startDate : null,
endDate: this.isZjrcuPullBankInfo ? endDate : null,
}; };
const res = await pullBankInfo(payload); const res = await pullBankInfo(payload);
@@ -642,11 +670,11 @@ export default {
} catch (error) { } catch (error) {
this.pullBankInfoLoading = false; this.pullBankInfoLoading = false;
this.$message.error( this.$message.error(
"拉取本行信息失败:" + ((error && error.message) || "未知错误") "拉取流水失败:" + ((error && error.message) || "未知错误")
); );
} }
}, },
/** 拉取本行信息 */ /** 拉取流水 */
handleFetchBankInfo() { handleFetchBankInfo() {
if (this.isWriteLocked) { if (this.isWriteLocked) {
this.showWriteLockedMessage(); this.showWriteLockedMessage();
@@ -1171,6 +1199,37 @@ export default {
} }
.pull-bank-info-form { .pull-bank-info-form {
.pull-bank-channel-group {
display: flex;
flex-direction: column;
gap: 12px;
::v-deep .el-radio {
display: flex;
align-items: center;
margin-right: 0;
line-height: 24px;
}
::v-deep .el-radio__label {
display: inline-flex;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
padding-left: 10px;
}
}
.pull-bank-channel-title {
font-size: 15px;
color: #303133;
}
.pull-bank-channel-tip {
font-size: 13px;
color: #909399;
}
.pull-bank-field-tip { .pull-bank-field-tip {
margin-top: 8px; margin-top: 8px;
font-size: 12px; font-size: 12px;

View File

@@ -202,11 +202,36 @@ export default {
return { return {
projectId: null, projectId: null,
projectName: "", projectName: "",
projectDesc: "", description: "",
startDate: "", configType: "default",
endDate: "", };
targetCount: 0, },
targetPersons: [], getProjectTemplate(type) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const quarter = Math.floor(now.getMonth() / 3) + 1;
const quarterNames = ["一", "二", "三", "四"];
const templates = {
quarterly: {
projectName: `${year}年第${quarterNames[quarter - 1]}季度纪检初核项目`,
description: `用于开展${year}年第${quarterNames[quarter - 1]}季度纪检初核排查工作。`,
configType: "default",
},
employee: {
projectName: `${year}${month}月新员工排查项目`,
description: `用于开展${year}${month}月新入职员工排查工作。`,
configType: "default",
},
highRisk: {
projectName: `${year}年高风险专项排查项目`,
description: `用于开展${year}年高风险人员和异常线索专项排查工作。`,
configType: "custom",
},
};
return {
...this.getEmptyForm(),
...templates[type],
}; };
}, },
/** 关闭新增弹窗 */ /** 关闭新增弹窗 */
@@ -247,19 +272,19 @@ export default {
}, },
/** 创建季度初核 */ /** 创建季度初核 */
handleCreateQuarterly() { handleCreateQuarterly() {
this.projectForm = this.getEmptyForm(); this.projectForm = this.getProjectTemplate("quarterly");
this.addDialogTitle = "创建季度初核项目"; this.addDialogTitle = "创建季度初核项目";
this.addDialogVisible = true; this.addDialogVisible = true;
}, },
/** 创建新员工排查 */ /** 创建新员工排查 */
handleCreateEmployee() { handleCreateEmployee() {
this.projectForm = this.getEmptyForm(); this.projectForm = this.getProjectTemplate("employee");
this.addDialogTitle = "创建新员工排查项目"; this.addDialogTitle = "创建新员工排查项目";
this.addDialogVisible = true; this.addDialogVisible = true;
}, },
/** 创建高风险专项 */ /** 创建高风险专项 */
handleCreateHighRisk() { handleCreateHighRisk() {
this.projectForm = this.getEmptyForm(); this.projectForm = this.getProjectTemplate("highRisk");
this.addDialogTitle = "创建高风险专项项目"; this.addDialogTitle = "创建高风险专项项目";
this.addDialogVisible = true; this.addDialogVisible = true;
}, },

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