5 Commits

37 changed files with 2066 additions and 25 deletions

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
@@ -55,6 +56,19 @@ public class CcdiBankStatementController extends BaseController {
return getDataTable(result.getRecords(), result.getTotal());
}
/**
* 分页查询对手方聚合列表
*/
@GetMapping("/counterparty/list")
@Operation(summary = "分页查询对手方聚合列表")
public TableDataInfo counterpartyList(CcdiBankStatementQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
PageDomain pageDomain = TableSupport.buildPageRequest();
Page<CcdiBankStatementCounterpartyVO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
Page<CcdiBankStatementCounterpartyVO> result = bankStatementService.selectCounterpartyPage(page, queryDTO);
return getDataTable(result.getRecords(), result.getTotal());
}
/**
* 查询项目级筛选项
*/

View File

@@ -9,10 +9,12 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskExclusionSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectUnsubmittedAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
@@ -24,6 +26,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountFlowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountVO;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.common.utils.poi.ExcelUtil;
@@ -226,6 +230,30 @@ public class CcdiProjectOverviewController extends BaseController {
return AjaxResult.success(pageVO);
}
/**
* 查询未提交账户
*/
@GetMapping("/unsubmitted-accounts")
@Operation(summary = "查询未提交账户")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getUnsubmittedAccounts(CcdiProjectUnsubmittedAccountQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
CcdiProjectUnsubmittedAccountVO result = overviewService.getUnsubmittedAccounts(queryDTO);
return AjaxResult.success(result);
}
/**
* 查询未提交账户流水
*/
@GetMapping("/unsubmitted-accounts/flows")
@Operation(summary = "查询未提交账户流水")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getUnsubmittedAccountFlows(CcdiProjectUnsubmittedAccountQueryDTO queryDTO) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiProjectUnsubmittedAccountFlowVO> rows = overviewService.getUnsubmittedAccountFlows(queryDTO);
return AjaxResult.success(rows);
}
/**
* 导出涉疑交易明细
*/
@@ -302,6 +330,23 @@ public class CcdiProjectOverviewController extends BaseController {
util.exportExcel(response, rows, "外部人员风险模型命中人员");
}
/**
* 导出未提交账户流水
*/
@PostMapping("/unsubmitted-accounts/export")
@Operation(summary = "导出未提交账户流水")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public void exportUnsubmittedAccountFlows(
HttpServletResponse response,
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
) {
projectAccessService.assertCanRead(queryDTO.getProjectId());
List<CcdiProjectUnsubmittedAccountFlowExcel> rows = overviewService.exportUnsubmittedAccountFlows(queryDTO);
ExcelUtil<CcdiProjectUnsubmittedAccountFlowExcel> util =
new ExcelUtil<>(CcdiProjectUnsubmittedAccountFlowExcel.class);
util.exportExcel(response, rows, "未提交账户流水");
}
/**
* 导出风险明细
*/

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
/**
* 未提交账户查询参数
*/
@Data
public class CcdiProjectUnsubmittedAccountQueryDTO {
/**
* 项目ID
*/
private Long projectId;
/**
* 主体名称
*/
private String subjectName;
/**
* 未提交账号
*/
private String accountNo;
}

View File

@@ -35,9 +35,9 @@ public class CcdiProjectSuspiciousTransactionExcel {
@Excel(name = "交易类型")
private String cashType;
@Excel(name = "异常标签")
private String hitTags;
@Excel(name = "交易金额")
private BigDecimal displayAmount;
@Excel(name = "异常标签")
private String hitTags;
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.ccdi.project.domain.excel;
import com.ruoyi.common.annotation.Excel;
import java.math.BigDecimal;
import lombok.Data;
/**
* 未提交账户流水导出对象
*/
@Data
public class CcdiProjectUnsubmittedAccountFlowExcel {
@Excel(name = "主体名称")
private String subjectName;
@Excel(name = "未提交账号")
private String accountNo;
@Excel(name = "交易时间")
private String trxDate;
@Excel(name = "本方账号")
private String leAccountNo;
@Excel(name = "本方主体")
private String leAccountName;
@Excel(name = "对方名称")
private String customerAccountName;
@Excel(name = "对方账号")
private String customerAccountNo;
@Excel(name = "摘要")
private String userMemo;
@Excel(name = "交易类型")
private String cashType;
@Excel(name = "流入金额")
private BigDecimal amountCr;
@Excel(name = "流出金额")
private BigDecimal amountDr;
}

View File

@@ -0,0 +1,44 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 流水对手方聚合VO
*
* @author ruoyi
*/
@Data
public class CcdiBankStatementCounterpartyVO {
/** 对方名称 */
private String customerAccountName;
/** 对方名称是否为空 */
private Boolean nameEmpty;
/** 关联账号数 */
private Integer accountCount;
/** 流入合计 */
private BigDecimal inAmount;
/** 流入笔数 */
private Long inCount;
/** 流出合计 */
private BigDecimal outAmount;
/** 流出笔数 */
private Long outCount;
/** 总交易金额 */
private BigDecimal totalAmount;
/** 总笔数 */
private Long totalCount;
/** 最近交易时间 */
private String latestTrxDate;
}

View File

@@ -4,6 +4,7 @@ import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
@@ -37,4 +38,6 @@ public class CcdiProjectOverviewReportVO {
private List<CcdiProjectEmployeeCreditNegativeExcel> illegalPeople = new ArrayList<>();
private List<CcdiProjectAbnormalAccountExcel> abnormalAccounts = new ArrayList<>();
private List<CcdiProjectUnsubmittedAccountFlowExcel> unsubmittedAccountFlows = new ArrayList<>();
}

View File

@@ -0,0 +1,35 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.math.BigDecimal;
import lombok.Data;
/**
* 未提交账户流水行
*/
@Data
public class CcdiProjectUnsubmittedAccountFlowVO {
private Long bankStatementId;
private String subjectName;
private String accountNo;
private String trxDate;
private String leAccountNo;
private String leAccountName;
private String customerAccountName;
private String customerAccountNo;
private String userMemo;
private String cashType;
private BigDecimal amountCr;
private BigDecimal amountDr;
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.math.BigDecimal;
import lombok.Data;
/**
* 未提交账户账号项
*/
@Data
public class CcdiProjectUnsubmittedAccountItemVO {
private String subjectName;
private String accountNo;
private BigDecimal totalAmountCr;
private BigDecimal totalAmountDr;
private BigDecimal netAmount;
private BigDecimal inflowRatio;
private BigDecimal outflowRatio;
private Boolean highRatio;
private Integer transactionCount;
private String startDate;
private String endDate;
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* 未提交账户主体分组
*/
@Data
public class CcdiProjectUnsubmittedAccountSubjectVO {
private String subjectName;
private Integer accountCount = 0;
private BigDecimal totalAmountCr = BigDecimal.ZERO;
private BigDecimal totalAmountDr = BigDecimal.ZERO;
private BigDecimal netAmount = BigDecimal.ZERO;
private List<CcdiProjectUnsubmittedAccountItemVO> accounts = new ArrayList<>();
}

View File

@@ -0,0 +1,23 @@
package com.ruoyi.ccdi.project.domain.vo;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
/**
* 未提交账户查询结果
*/
@Data
public class CcdiProjectUnsubmittedAccountVO {
private Integer accountCount = 0;
private BigDecimal totalAmountCr = BigDecimal.ZERO;
private BigDecimal totalAmountDr = BigDecimal.ZERO;
private BigDecimal netAmount = BigDecimal.ZERO;
private List<CcdiProjectUnsubmittedAccountSubjectVO> subjects = new ArrayList<>();
}

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankStatement;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
@@ -38,6 +39,9 @@ public interface CcdiBankStatementMapper extends BaseMapper<CcdiBankStatement> {
Page<CcdiBankStatementListVO> selectStatementPage(Page<CcdiBankStatementListVO> page,
@Param("query") CcdiBankStatementQueryDTO query);
Page<CcdiBankStatementCounterpartyVO> selectCounterpartyPage(Page<CcdiBankStatementCounterpartyVO> page,
@Param("query") CcdiBankStatementQueryDTO query);
List<CcdiBankStatementListVO> selectStatementListForExport(@Param("query") CcdiBankStatementQueryDTO query);
CcdiBankStatementDetailVO selectStatementDetailById(@Param("bankStatementId") Long bankStatementId);

View File

@@ -9,6 +9,7 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQuery
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectUnsubmittedAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
@@ -24,6 +25,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountFlowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountItemVO;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Mapper;
@@ -259,6 +262,26 @@ public interface CcdiProjectOverviewMapper {
*/
List<CcdiProjectAbnormalAccountItemVO> selectAbnormalAccountList(@Param("projectId") Long projectId);
/**
* 查询未提交账户列表
*
* @param query 查询条件
* @return 未提交账户列表
*/
List<CcdiProjectUnsubmittedAccountItemVO> selectUnsubmittedAccountList(
@Param("query") CcdiProjectUnsubmittedAccountQueryDTO query
);
/**
* 查询未提交账户流水
*
* @param query 查询条件
* @return 未提交账户流水
*/
List<CcdiProjectUnsubmittedAccountFlowVO> selectUnsubmittedAccountFlowList(
@Param("query") CcdiProjectUnsubmittedAccountQueryDTO query
);
/**
* 查询项目员工负面征信导出列表
*

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
@@ -34,6 +35,16 @@ public interface ICcdiBankStatementService {
Page<CcdiBankStatementListVO> selectStatementPage(Page<CcdiBankStatementListVO> page,
CcdiBankStatementQueryDTO queryDTO);
/**
* 分页查询对手方聚合列表
*
* @param page 分页对象
* @param queryDTO 查询条件
* @return 分页结果
*/
Page<CcdiBankStatementCounterpartyVO> selectCounterpartyPage(Page<CcdiBankStatementCounterpartyVO> page,
CcdiBankStatementQueryDTO queryDTO);
/**
* 查询导出列表
*

View File

@@ -9,12 +9,14 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskExclusionSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectUnsubmittedAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
@@ -26,6 +28,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountFlowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountVO;
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;
@@ -278,6 +282,52 @@ public interface ICcdiProjectOverviewService {
return List.of();
}
/**
* 查询未提交账户
*
* @param queryDTO 查询条件
* @return 未提交账户结果
*/
default CcdiProjectUnsubmittedAccountVO getUnsubmittedAccounts(
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
) {
return new CcdiProjectUnsubmittedAccountVO();
}
/**
* 查询未提交账户流水
*
* @param queryDTO 查询条件
* @return 未提交账户流水
*/
default List<CcdiProjectUnsubmittedAccountFlowVO> getUnsubmittedAccountFlows(
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
) {
return List.of();
}
/**
* 导出未提交账户流水
*
* @param projectId 项目ID
* @return 导出列表
*/
default List<CcdiProjectUnsubmittedAccountFlowExcel> exportUnsubmittedAccountFlows(Long projectId) {
return List.of();
}
/**
* 导出未提交账户流水
*
* @param queryDTO 查询条件
* @return 导出列表
*/
default List<CcdiProjectUnsubmittedAccountFlowExcel> exportUnsubmittedAccountFlows(
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
) {
return List.of();
}
/**
* 重算结果总览员工结果并同步项目风险人数
*

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
@@ -57,6 +58,16 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
return result;
}
@Override
public Page<CcdiBankStatementCounterpartyVO> selectCounterpartyPage(
Page<CcdiBankStatementCounterpartyVO> page,
CcdiBankStatementQueryDTO queryDTO
) {
CcdiBankStatementQueryDTO normalizedQuery = queryDTO == null ? new CcdiBankStatementQueryDTO() : queryDTO;
normalizeQuery(normalizedQuery);
return bankStatementMapper.selectCounterpartyPage(page, normalizedQuery);
}
@Override
public List<CcdiBankStatementExcel> selectStatementListForExport(CcdiBankStatementQueryDTO queryDTO) {
CcdiBankStatementQueryDTO normalizedQuery = queryDTO == null ? new CcdiBankStatementQueryDTO() : queryDTO;
@@ -153,6 +164,7 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
queryDTO.setCounterpartyAccount(normalizeText(queryDTO.getCounterpartyAccount()));
queryDTO.setTransactionType(normalizeText(queryDTO.getTransactionType()));
queryDTO.setOurSubjects(normalizeList(queryDTO.getOurSubjects()));
queryDTO.setOurCertNos(normalizeList(queryDTO.getOurCertNos()));
queryDTO.setOurBanks(normalizeList(queryDTO.getOurBanks()));
queryDTO.setOurAccounts(normalizeList(queryDTO.getOurAccounts()));
queryDTO.setCounterpartyNameEmpty(Boolean.TRUE.equals(queryDTO.getCounterpartyNameEmpty()));
@@ -180,6 +192,21 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
if ("trxDate".equalsIgnoreCase(normalized)) {
return "trxDate";
}
if ("latestTrxDate".equalsIgnoreCase(normalized)) {
return "latestTrxDate";
}
if ("inAmount".equalsIgnoreCase(normalized)) {
return "inAmount";
}
if ("outAmount".equalsIgnoreCase(normalized)) {
return "outAmount";
}
if ("totalAmount".equalsIgnoreCase(normalized)) {
return "totalAmount";
}
if ("totalCount".equalsIgnoreCase(normalized)) {
return "totalCount";
}
return "trxDate";
}

View File

@@ -246,6 +246,25 @@ public class CcdiProjectOverviewReportPdfExporter {
new float[] { 0.12F, 0.1F, 0.14F, 0.14F, 0.11F, 0.08F, 0.22F, 0.09F },
"暂无异常账户信息"
);
writer.subsection("4. 未提交账户流水表(共" + report.getUnsubmittedAccountFlows().size() + "条)");
writer.table(
List.of("主体", "未提交账号", "交易时间", "本方账号", "对方名称", "摘要/交易类型", "流入", "流出"),
report.getUnsubmittedAccountFlows().stream()
.map(item -> List.of(
safeText(item.getSubjectName()),
maskAccount(item.getAccountNo()),
safeText(item.getTrxDate()),
maskAccount(item.getLeAccountNo()),
safeText(item.getCustomerAccountName()),
formatSummaryAndCashType(item.getUserMemo(), item.getCashType()),
formatMoney(item.getAmountCr()),
formatMoney(item.getAmountDr())
))
.collect(Collectors.toList()),
new float[] { 0.14F, 0.13F, 0.12F, 0.13F, 0.14F, 0.17F, 0.08F, 0.09F },
"暂无未提交账户流水"
);
}
private LoadedChineseFont loadChineseFont(PDDocument document) throws IOException {

View File

@@ -12,12 +12,14 @@ import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskExclusionSaveDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectUnsubmittedAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectRiskExclusion;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
@@ -53,6 +55,10 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountFlowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountSubjectVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountVO;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
@@ -67,6 +73,7 @@ import com.ruoyi.common.utils.SecurityUtils;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.annotation.Resource;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
@@ -474,6 +481,57 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
.toList();
}
@Override
public CcdiProjectUnsubmittedAccountVO getUnsubmittedAccounts(CcdiProjectUnsubmittedAccountQueryDTO queryDTO) {
ensureProjectExists(queryDTO.getProjectId());
List<CcdiProjectUnsubmittedAccountItemVO> rows =
defaultList(overviewMapper.selectUnsubmittedAccountList(queryDTO));
CcdiProjectUnsubmittedAccountVO result = new CcdiProjectUnsubmittedAccountVO();
result.setAccountCount(rows.size());
result.setTotalAmountCr(sumUnsubmittedAmount(rows, true));
result.setTotalAmountDr(sumUnsubmittedAmount(rows, false));
result.setNetAmount(result.getTotalAmountCr().subtract(result.getTotalAmountDr()));
Map<String, List<CcdiProjectUnsubmittedAccountItemVO>> groupedRows = rows.stream()
.collect(Collectors.groupingBy(
item -> item.getSubjectName() == null ? "" : item.getSubjectName(),
LinkedHashMap::new,
Collectors.toList()
));
result.setSubjects(groupedRows.entrySet().stream()
.map(entry -> buildUnsubmittedAccountSubject(entry.getKey(), entry.getValue()))
.toList());
return result;
}
@Override
public List<CcdiProjectUnsubmittedAccountFlowVO> getUnsubmittedAccountFlows(
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
) {
ensureProjectExists(queryDTO.getProjectId());
return defaultList(overviewMapper.selectUnsubmittedAccountFlowList(queryDTO));
}
@Override
public List<CcdiProjectUnsubmittedAccountFlowExcel> exportUnsubmittedAccountFlows(Long projectId) {
CcdiProjectUnsubmittedAccountQueryDTO queryDTO = new CcdiProjectUnsubmittedAccountQueryDTO();
queryDTO.setProjectId(projectId);
return exportUnsubmittedAccountFlows(queryDTO);
}
@Override
public List<CcdiProjectUnsubmittedAccountFlowExcel> exportUnsubmittedAccountFlows(
CcdiProjectUnsubmittedAccountQueryDTO queryDTO
) {
ensureProjectExists(queryDTO.getProjectId());
return defaultList(overviewMapper.selectUnsubmittedAccountFlowList(queryDTO)).stream()
.map(this::buildUnsubmittedAccountFlowExcelRow)
.toList();
}
@Override
public void exportRiskDetails(HttpServletResponse response, Long projectId) {
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
@@ -491,8 +549,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
Long projectId = queryDTO.getProjectId();
List<CcdiProjectEmployeeCreditNegativeExcel> creditRows = exportEmployeeCreditNegative(projectId);
List<CcdiProjectAbnormalAccountExcel> abnormalRows = exportAbnormalAccountPeople(projectId);
List<CcdiProjectUnsubmittedAccountFlowExcel> unsubmittedRows = exportUnsubmittedAccountFlows(projectId);
try {
workbookExporter.export(response, projectId, suspiciousRows, creditRows, abnormalRows);
workbookExporter.export(response, projectId, suspiciousRows, creditRows, abnormalRows, unsubmittedRows);
} catch (IOException e) {
throw new ServiceException("导出风险明细失败");
}
@@ -525,6 +584,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
));
report.setIllegalPeople(exportEmployeeCreditNegative(projectId));
report.setAbnormalAccounts(exportAbnormalAccountPeople(projectId));
report.setUnsubmittedAccountFlows(exportUnsubmittedAccountFlows(projectId));
try {
reportPdfExporter.export(response, report);
@@ -910,6 +970,45 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return row;
}
private CcdiProjectUnsubmittedAccountSubjectVO buildUnsubmittedAccountSubject(
String subjectName,
List<CcdiProjectUnsubmittedAccountItemVO> rows
) {
CcdiProjectUnsubmittedAccountSubjectVO subject = new CcdiProjectUnsubmittedAccountSubjectVO();
subject.setSubjectName(subjectName);
subject.setAccountCount(rows.size());
subject.setTotalAmountCr(sumUnsubmittedAmount(rows, true));
subject.setTotalAmountDr(sumUnsubmittedAmount(rows, false));
subject.setNetAmount(subject.getTotalAmountCr().subtract(subject.getTotalAmountDr()));
subject.setAccounts(rows);
return subject;
}
private BigDecimal sumUnsubmittedAmount(List<CcdiProjectUnsubmittedAccountItemVO> rows, boolean inflow) {
return rows.stream()
.map(item -> inflow ? item.getTotalAmountCr() : item.getTotalAmountDr())
.filter(value -> value != null)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
private CcdiProjectUnsubmittedAccountFlowExcel buildUnsubmittedAccountFlowExcelRow(
CcdiProjectUnsubmittedAccountFlowVO item
) {
CcdiProjectUnsubmittedAccountFlowExcel row = new CcdiProjectUnsubmittedAccountFlowExcel();
row.setSubjectName(item.getSubjectName());
row.setAccountNo(item.getAccountNo());
row.setTrxDate(item.getTrxDate());
row.setLeAccountNo(item.getLeAccountNo());
row.setLeAccountName(item.getLeAccountName());
row.setCustomerAccountName(item.getCustomerAccountName());
row.setCustomerAccountNo(item.getCustomerAccountNo());
row.setUserMemo(item.getUserMemo());
row.setCashType(item.getCashType());
row.setAmountCr(item.getAmountCr());
row.setAmountDr(item.getAmountDr());
return row;
}
private List<CcdiProjectOverviewReportParamVO> buildReportParams(CcdiProject project) {
Long effectiveProjectId = "default".equals(project.getConfigType()) ? 0L : project.getProjectId();
return defaultList(modelParamMapper.selectByProjectId(effectiveProjectId)).stream()

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
import com.ruoyi.common.utils.file.FileUtils;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.poi.ss.usermodel.Row;
@@ -29,7 +30,8 @@ public class CcdiProjectRiskDetailWorkbookExporter {
Long projectId,
List<CcdiProjectSuspiciousTransactionExcel> suspiciousRows,
List<CcdiProjectEmployeeCreditNegativeExcel> creditRows,
List<CcdiProjectAbnormalAccountExcel> abnormalRows
List<CcdiProjectAbnormalAccountExcel> abnormalRows,
List<CcdiProjectUnsubmittedAccountFlowExcel> unsubmittedRows
) throws IOException {
response.setContentType(CONTENT_TYPE);
FileUtils.setAttachmentResponseHeader(response, "风险明细_" + projectId + ".xlsx");
@@ -38,6 +40,7 @@ public class CcdiProjectRiskDetailWorkbookExporter {
writeSuspiciousSheet(workbook.createSheet("涉疑交易明细"), suspiciousRows);
writeCreditSheet(workbook.createSheet("员工负面征信信息"), creditRows);
writeAbnormalAccountSheet(workbook.createSheet("异常账户人员信息"), abnormalRows);
writeUnsubmittedAccountSheet(workbook.createSheet("未提交账户流水"), unsubmittedRows);
workbook.write(response.getOutputStream());
}
}
@@ -53,8 +56,8 @@ public class CcdiProjectRiskDetailWorkbookExporter {
"关联员工",
"摘要",
"交易类型",
"异常标签",
"交易金额"
"交易金额",
"异常标签"
};
writeHeader(header, headers);
@@ -69,8 +72,8 @@ public class CcdiProjectRiskDetailWorkbookExporter {
row.createCell(5).setCellValue(safeText(item.getRelatedStaffDisplay()));
row.createCell(6).setCellValue(safeText(item.getUserMemo()));
row.createCell(7).setCellValue(safeText(item.getCashType()));
row.createCell(8).setCellValue(safeText(item.getHitTags()));
row.createCell(9).setCellValue(safeNumber(item.getDisplayAmount()));
row.createCell(8).setCellValue(safeNumber(item.getDisplayAmount()));
row.createCell(9).setCellValue(safeText(item.getHitTags()));
}
}
@@ -123,6 +126,40 @@ public class CcdiProjectRiskDetailWorkbookExporter {
}
}
private void writeUnsubmittedAccountSheet(Sheet sheet, List<CcdiProjectUnsubmittedAccountFlowExcel> rows) {
Row header = sheet.createRow(0);
String[] headers = {
"主体名称",
"未提交账号",
"交易时间",
"本方账号",
"本方主体",
"对方名称",
"对方账号",
"摘要",
"交易类型",
"流入金额",
"流出金额"
};
writeHeader(header, headers);
for (int i = 0; i < rows.size(); i++) {
CcdiProjectUnsubmittedAccountFlowExcel item = rows.get(i);
Row row = sheet.createRow(i + 1);
row.createCell(0).setCellValue(safeText(item.getSubjectName()));
row.createCell(1).setCellValue(safeText(item.getAccountNo()));
row.createCell(2).setCellValue(safeText(item.getTrxDate()));
row.createCell(3).setCellValue(safeText(item.getLeAccountNo()));
row.createCell(4).setCellValue(safeText(item.getLeAccountName()));
row.createCell(5).setCellValue(safeText(item.getCustomerAccountName()));
row.createCell(6).setCellValue(safeText(item.getCustomerAccountNo()));
row.createCell(7).setCellValue(safeText(item.getUserMemo()));
row.createCell(8).setCellValue(safeText(item.getCashType()));
row.createCell(9).setCellValue(safeNumber(item.getAmountCr()));
row.createCell(10).setCellValue(safeNumber(item.getAmountDr()));
}
}
private void writeHeader(Row row, String[] headers) {
for (int i = 0; i < headers.length; i++) {
row.createCell(i).setCellValue(headers[i]);

View File

@@ -72,6 +72,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="displayAmount" column="displayAmount"/>
</resultMap>
<resultMap id="CcdiBankStatementCounterpartyVOResultMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO">
<result property="customerAccountName" column="customerAccountName"/>
<result property="nameEmpty" column="nameEmpty"/>
<result property="accountCount" column="accountCount"/>
<result property="inAmount" column="inAmount"/>
<result property="inCount" column="inCount"/>
<result property="outAmount" column="outAmount"/>
<result property="outCount" column="outCount"/>
<result property="totalAmount" column="totalAmount"/>
<result property="totalCount" column="totalCount"/>
<result property="latestTrxDate" column="latestTrxDate"/>
</resultMap>
<resultMap id="CcdiBankStatementDetailVOResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO">
<id property="bankStatementId" column="bankStatementId"/>
<result property="projectId" column="projectId"/>
@@ -284,6 +298,45 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</choose>
</sql>
<sql id="counterpartyNameExpr">
TRIM(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''))
</sql>
<sql id="counterpartyOrderBy">
<choose>
<when test="query.orderBy == 'inAmount' and query.orderDirection == 'asc'">
ORDER BY inAmount ASC, totalCount DESC, customerAccountName ASC
</when>
<when test="query.orderBy == 'inAmount' and query.orderDirection == 'desc'">
ORDER BY inAmount DESC, totalCount DESC, customerAccountName ASC
</when>
<when test="query.orderBy == 'outAmount' and query.orderDirection == 'asc'">
ORDER BY outAmount ASC, totalCount DESC, customerAccountName ASC
</when>
<when test="query.orderBy == 'outAmount' and query.orderDirection == 'desc'">
ORDER BY outAmount DESC, totalCount DESC, customerAccountName ASC
</when>
<when test="query.orderBy == 'totalCount' and query.orderDirection == 'asc'">
ORDER BY totalCount ASC, totalAmount DESC, customerAccountName ASC
</when>
<when test="query.orderBy == 'totalCount' and query.orderDirection == 'desc'">
ORDER BY totalCount DESC, totalAmount DESC, customerAccountName ASC
</when>
<when test="query.orderBy == 'latestTrxDate' and query.orderDirection == 'asc'">
ORDER BY latestTrxDate ASC, totalAmount DESC, customerAccountName ASC
</when>
<when test="query.orderBy == 'latestTrxDate' and query.orderDirection == 'desc'">
ORDER BY latestTrxDate DESC, totalAmount DESC, customerAccountName ASC
</when>
<when test="query.orderBy == 'totalAmount' and query.orderDirection == 'asc'">
ORDER BY totalAmount ASC, totalCount DESC, customerAccountName ASC
</when>
<otherwise>
ORDER BY totalAmount DESC, totalCount DESC, customerAccountName ASC
</otherwise>
</choose>
</sql>
<select id="selectStatementPage" resultMap="CcdiBankStatementListVOResultMap">
SELECT
<include refid="statementListColumns"/>
@@ -294,6 +347,38 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<include refid="statementOrderBy"/>
</select>
<select id="selectCounterpartyPage" resultMap="CcdiBankStatementCounterpartyVOResultMap">
SELECT
grouped.customerAccountName,
CASE WHEN grouped.customerAccountName = '' THEN 1 ELSE 0 END AS nameEmpty,
grouped.accountCount,
grouped.inAmount,
grouped.inCount,
grouped.outAmount,
grouped.outCount,
grouped.totalAmount,
grouped.totalCount,
grouped.latestTrxDate
FROM (
SELECT
<include refid="counterpartyNameExpr"/> AS customerAccountName,
COUNT(DISTINCT NULLIF(TRIM(IFNULL(bs.CUSTOMER_ACCOUNT_NO, '')), '')) AS accountCount,
SUM(IFNULL(bs.AMOUNT_CR, 0)) AS inAmount,
SUM(CASE WHEN IFNULL(bs.AMOUNT_CR, 0) > 0 THEN 1 ELSE 0 END) AS inCount,
SUM(IFNULL(bs.AMOUNT_DR, 0)) AS outAmount,
SUM(CASE WHEN IFNULL(bs.AMOUNT_DR, 0) > 0 THEN 1 ELSE 0 END) AS outCount,
SUM(IFNULL(bs.AMOUNT_CR, 0) + IFNULL(bs.AMOUNT_DR, 0)) AS totalAmount,
COUNT(1) AS totalCount,
DATE_FORMAT(MAX(<include refid="parsedTrxDateExpr"/>), '%Y-%m-%d %H:%i:%s') AS latestTrxDate
FROM ccdi_bank_statement bs
<where>
<include refid="statementFilterWhere"/>
</where>
GROUP BY <include refid="counterpartyNameExpr"/>
) grouped
<include refid="counterpartyOrderBy"/>
</select>
<select id="selectStatementListForExport" resultMap="CcdiBankStatementListVOResultMap">
SELECT
<include refid="statementListColumns"/>

View File

@@ -96,6 +96,35 @@
<result property="involvedAmount" column="involved_amount"/>
</resultMap>
<resultMap id="UnsubmittedAccountItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountItemVO">
<result property="subjectName" column="subjectName"/>
<result property="accountNo" column="accountNo"/>
<result property="totalAmountCr" column="totalAmountCr"/>
<result property="totalAmountDr" column="totalAmountDr"/>
<result property="netAmount" column="netAmount"/>
<result property="inflowRatio" column="inflowRatio"/>
<result property="outflowRatio" column="outflowRatio"/>
<result property="highRatio" column="highRatio"/>
<result property="transactionCount" column="transactionCount"/>
<result property="startDate" column="startDate"/>
<result property="endDate" column="endDate"/>
</resultMap>
<resultMap id="UnsubmittedAccountFlowResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectUnsubmittedAccountFlowVO">
<result property="bankStatementId" column="bankStatementId"/>
<result property="subjectName" column="subjectName"/>
<result property="accountNo" column="accountNo"/>
<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="userMemo" column="userMemo"/>
<result property="cashType" column="cashType"/>
<result property="amountCr" column="amountCr"/>
<result property="amountDr" column="amountDr"/>
</resultMap>
<resultMap id="ReportUploadSubjectResultMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO">
<result property="subjectName" column="subjectName"/>
@@ -1545,6 +1574,196 @@
order by abnormal.abnormal_time desc, abnormal.account_no asc, abnormal.rule_code asc
</select>
<sql id="unsubmittedAccountLoanConditionSql">
(
bs.USER_MEMO like '%还贷%'
or bs.USER_MEMO like '%还贷款%'
or bs.USER_MEMO like '%贷款还款%'
or bs.USER_MEMO like '%归还贷款%'
or bs.USER_MEMO like '%贷款归还%'
or bs.USER_MEMO like '%还本%'
or bs.USER_MEMO like '%还息%'
or bs.USER_MEMO like '%付息%'
or bs.USER_MEMO like '%贷息%'
or bs.USER_MEMO like '%贷款利息%'
or bs.USER_MEMO like '%贷款发放%'
or bs.USER_MEMO like '%普贷放款%'
or bs.USER_MEMO like '%放款%'
or bs.USER_MEMO like '%放贷%'
or bs.USER_MEMO like '%银团贷%'
or bs.USER_MEMO like '%流贷%'
or bs.USER_MEMO like '%续贷%资金%'
or bs.CASH_TYPE like '%贷款本息%'
or bs.CASH_TYPE like '%贷款扣款%'
or bs.CASH_TYPE like '%贷款回收%'
or bs.CASH_TYPE like '%还贷款%'
or bs.CASH_TYPE like '%归还本金%'
or bs.CASH_TYPE like '%归还本息%'
or bs.CASH_TYPE like '%归还利息%'
or bs.CASH_TYPE like '%归还逾期利息%'
or bs.CASH_TYPE like '%贷息%'
or bs.CASH_TYPE like '%贷款放%'
or bs.CASH_TYPE like '%放贷%'
or bs.CASH_TYPE like '%信贷系统业务对公资产%'
or bs.CUSTOMER_ACCOUNT_NAME like '%个贷系统平账专户%'
or bs.CUSTOMER_ACCOUNT_NAME like '%企业贷%'
or bs.CUSTOMER_ACCOUNT_NAME like '%微粒贷%'
or bs.CUSTOMER_ACCOUNT_NAME like '%贷款还款户%'
or bs.CUSTOMER_ACCOUNT_NAME like '%贷款归还户%'
or bs.CUSTOMER_ACCOUNT_NAME like '%融资利息收入%'
or bs.CUSTOMER_ACCOUNT_NAME like '%短期贷款%'
or bs.CUSTOMER_ACCOUNT_NAME like '%科创贷%'
or bs.CUSTOMER_ACCOUNT_NAME like '%企金贷%'
or bs.CUSTOMER_ACCOUNT_NAME like '%网贷通%'
or bs.CUSTOMER_ACCOUNT_NAME like '%联合贷%'
or bs.CUSTOMER_ACCOUNT_NAME like '%数字信用凭据融资%'
or bs.CUSTOMER_ACCOUNT_NAME like '%消费金融%'
)
</sql>
<sql id="unsubmittedAccountCandidateRowSql">
select
bs.bank_statement_id as bankStatementId,
trim(bs.LE_ACCOUNT_NAME) as subjectName,
replace(replace(replace(trim(bs.CUSTOMER_ACCOUNT_NO), '-', ''), char(9), ''), ' ', '') as accountNo,
bs.TRX_DATE as 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,
bs.USER_MEMO as userMemo,
bs.CASH_TYPE as cashType,
ifnull(bs.AMOUNT_CR, 0) as amountCr,
ifnull(bs.AMOUNT_DR, 0) as amountDr,
case
when <include refid="unsubmittedAccountLoanConditionSql"/> then 1
else 0
end as loanFlag
from ccdi_bank_statement bs
where bs.project_id = #{unsubmittedProjectId}
and bs.LE_ACCOUNT_NAME is not null
and trim(bs.LE_ACCOUNT_NAME) != ''
and bs.CUSTOMER_ACCOUNT_NAME is not null
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
and replace(replace(trim(bs.CUSTOMER_ACCOUNT_NAME), '', '('), '', ')')
= replace(replace(trim(bs.LE_ACCOUNT_NAME), '', '('), '', ')')
and bs.CUSTOMER_ACCOUNT_NO is not null
and replace(replace(replace(trim(bs.CUSTOMER_ACCOUNT_NO), '-', ''), char(9), ''), ' ', '') != ''
and replace(replace(replace(trim(bs.CUSTOMER_ACCOUNT_NO), '-', ''), char(9), ''), ' ', '')
!= replace(replace(replace(trim(ifnull(bs.LE_ACCOUNT_NO, '')), '-', ''), char(9), ''), ' ', '')
and not exists (
select 1
from ccdi_bank_statement submitted
where submitted.project_id = bs.project_id
and submitted.LE_ACCOUNT_NO is not null
and replace(replace(replace(trim(submitted.LE_ACCOUNT_NO), '-', ''), char(9), ''), ' ', '')
= replace(replace(replace(trim(bs.CUSTOMER_ACCOUNT_NO), '-', ''), char(9), ''), ' ', '')
)
</sql>
<sql id="unsubmittedAccountAggregateSql">
select
candidate.subjectName,
candidate.accountNo,
sum(candidate.amountCr) as totalAmountCr,
sum(candidate.amountDr) as totalAmountDr,
sum(candidate.amountCr) - sum(candidate.amountDr) as netAmount,
count(*) as transactionCount,
min(candidate.trxDate) as startDate,
max(candidate.trxDate) as endDate
from (
<include refid="unsubmittedAccountCandidateRowSql"/>
) candidate
group by candidate.subjectName, candidate.accountNo
having sum(candidate.loanFlag) &lt; count(*)
</sql>
<sql id="unsubmittedAccountSubjectTotalSql">
select
trim(bs.LE_ACCOUNT_NAME) as subjectName,
sum(ifnull(bs.AMOUNT_CR, 0)) as subjectAmountCr,
sum(ifnull(bs.AMOUNT_DR, 0)) as subjectAmountDr
from ccdi_bank_statement bs
where bs.project_id = #{unsubmittedProjectId}
and bs.LE_ACCOUNT_NAME is not null
and trim(bs.LE_ACCOUNT_NAME) != ''
group by trim(bs.LE_ACCOUNT_NAME)
</sql>
<select id="selectUnsubmittedAccountList" resultMap="UnsubmittedAccountItemResultMap">
<bind name="unsubmittedProjectId" value="query.projectId"/>
select
agg.subjectName,
agg.accountNo,
agg.totalAmountCr,
agg.totalAmountDr,
agg.netAmount,
case
when ifnull(subject_total.subjectAmountCr, 0) &gt; 0
then round(agg.totalAmountCr / subject_total.subjectAmountCr, 4)
else 0
end as inflowRatio,
case
when ifnull(subject_total.subjectAmountDr, 0) &gt; 0
then round(agg.totalAmountDr / subject_total.subjectAmountDr, 4)
else 0
end as outflowRatio,
case
when ifnull(subject_total.subjectAmountCr, 0) &gt; 0
and agg.totalAmountCr / subject_total.subjectAmountCr &gt;= 0.1
then 1
when ifnull(subject_total.subjectAmountDr, 0) &gt; 0
and agg.totalAmountDr / subject_total.subjectAmountDr &gt;= 0.1
then 1
else 0
end as highRatio,
agg.transactionCount,
agg.startDate,
agg.endDate
from (
<include refid="unsubmittedAccountAggregateSql"/>
) agg
left join (
<include refid="unsubmittedAccountSubjectTotalSql"/>
) subject_total on subject_total.subjectName = agg.subjectName
order by agg.totalAmountCr + agg.totalAmountDr desc, agg.subjectName asc, agg.accountNo asc
</select>
<select id="selectUnsubmittedAccountFlowList" resultMap="UnsubmittedAccountFlowResultMap">
<bind name="unsubmittedProjectId" value="query.projectId"/>
select
candidate.bankStatementId,
candidate.subjectName,
candidate.accountNo,
candidate.trxDate,
candidate.leAccountNo,
candidate.leAccountName,
candidate.customerAccountName,
candidate.customerAccountNo,
candidate.userMemo,
candidate.cashType,
candidate.amountCr,
candidate.amountDr
from (
<include refid="unsubmittedAccountCandidateRowSql"/>
) candidate
inner join (
<include refid="unsubmittedAccountAggregateSql"/>
) agg on agg.subjectName = candidate.subjectName
and agg.accountNo = candidate.accountNo
<where>
<if test="query.subjectName != null and query.subjectName != ''">
and replace(replace(trim(candidate.subjectName), '', '('), '', ')')
= replace(replace(trim(#{query.subjectName}), '', '('), '', ')')
</if>
<if test="query.accountNo != null and query.accountNo != ''">
and candidate.accountNo
= replace(replace(replace(trim(#{query.accountNo}), '-', ''), char(9), ''), ' ', '')
</if>
</where>
order by candidate.subjectName asc, candidate.accountNo asc, candidate.trxDate asc, candidate.bankStatementId asc
</select>
<select id="selectRiskModelNamesByScope" resultType="java.lang.String">
select
json_unquote(json_extract(result.model_hit_summary_json, concat('$[', idx.idx, '].modelName'))) as model_name

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementCounterpartyVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
@@ -80,6 +81,31 @@ class CcdiBankStatementControllerTest {
assertEquals(1, result.getRows().size());
}
@Test
void counterpartyList_shouldReturnTableData() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setParameter("pageNum", "1");
request.setParameter("pageSize", "10");
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(100L);
CcdiBankStatementCounterpartyVO row = new CcdiBankStatementCounterpartyVO();
row.setCustomerAccountName("测试对手方");
row.setTotalCount(2L);
Page<CcdiBankStatementCounterpartyVO> page = new Page<>(1, 10);
page.setRecords(List.of(row));
page.setTotal(1);
when(bankStatementService.selectCounterpartyPage(any(), same(queryDTO))).thenReturn(page);
TableDataInfo result = controller.counterpartyList(queryDTO);
assertEquals(200, result.getCode());
assertEquals(1L, result.getTotal());
assertEquals(1, result.getRows().size());
verify(projectAccessService).assertCanRead(100L);
}
@Test
void detail_shouldReturnAjaxResultSuccess() {
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();

View File

@@ -116,6 +116,27 @@ class CcdiBankStatementMapperXmlTest {
assertTrue(sql.contains("END ) <= ?"), sql);
}
@Test
void selectCounterpartyPage_shouldGroupByTrimmedCounterpartyNameAndReuseFilters() throws Exception {
MappedStatement mappedStatement = loadMappedStatement(
"com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper.selectCounterpartyPage");
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(33L);
queryDTO.setTabType("out");
queryDTO.setCounterpartyName("杭州");
queryDTO.setOrderBy("totalAmount");
queryDTO.setOrderDirection("desc");
String sql = renderSql(mappedStatement, queryDTO);
assertTrue(sql.contains("TRIM(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) AS customerAccountName"), sql);
assertTrue(sql.contains("GROUP BY TRIM(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''))"), sql);
assertTrue(sql.contains("(bs.project_id = ?)"), sql);
assertTrue(sql.contains("IFNULL(bs.AMOUNT_DR, 0) > 0"), sql);
assertTrue(sql.contains("bs.CUSTOMER_ACCOUNT_NAME LIKE CONCAT('%', TRIM(?), '%')"), sql);
assertTrue(sql.contains("ORDER BY totalAmount DESC, totalCount DESC, customerAccountName ASC"), sql);
}
@Test
void selectStatementDetailById_shouldJoinUploadRecordForOriginalFileMetadata() throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {

View File

@@ -238,6 +238,49 @@ class CcdiProjectOverviewMapperSqlTest {
assertTrue(xml.contains("as involved_amount"), xml);
}
@Test
void shouldExposeUnsubmittedAccountQueriesWithLoanExclusion() throws Exception {
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
String accountSql = extractSelect(xml, "selectUnsubmittedAccountList");
String flowSql = extractSelect(xml, "selectUnsubmittedAccountFlowList");
String candidateSql = extractSqlFragment(xml, "unsubmittedAccountCandidateRowSql");
String loanSql = extractSqlFragment(xml, "unsubmittedAccountLoanConditionSql");
String aggregateSql = extractSqlFragment(xml, "unsubmittedAccountAggregateSql");
assertTrue(accountSql.contains("unsubmittedAccountAggregateSql"), accountSql);
assertTrue(accountSql.contains("unsubmittedAccountSubjectTotalSql"), accountSql);
assertTrue(flowSql.contains("unsubmittedAccountCandidateRowSql"), flowSql);
assertTrue(flowSql.contains("unsubmittedAccountAggregateSql"), flowSql);
assertTrue(flowSql.contains("query.projectId"), flowSql);
assertTrue(flowSql.contains("query.subjectName"), flowSql);
assertTrue(flowSql.contains("query.accountNo"), flowSql);
assertTrue(flowSql.contains("candidate.subjectName"), flowSql);
assertTrue(flowSql.contains("candidate.accountNo"), flowSql);
assertTrue(candidateSql.contains("bs.project_id = #{unsubmittedProjectId}"), candidateSql);
assertTrue(candidateSql.contains("bs.CUSTOMER_ACCOUNT_NAME"), candidateSql);
assertTrue(candidateSql.contains("bs.LE_ACCOUNT_NAME"), candidateSql);
assertTrue(candidateSql.contains("not exists"), candidateSql);
assertTrue(candidateSql.contains("submitted.LE_ACCOUNT_NO"), candidateSql);
assertFalse(candidateSql.contains("TRX_TYPE"), candidateSql);
assertTrue(loanSql.contains("还贷"), loanSql);
assertTrue(loanSql.contains("贷款还款"), loanSql);
assertTrue(loanSql.contains("归还贷款"), loanSql);
assertTrue(loanSql.contains("贷款发放"), loanSql);
assertTrue(loanSql.contains("普贷放款"), loanSql);
assertTrue(loanSql.contains("信贷系统业务对公资产"), loanSql);
assertTrue(loanSql.contains("个贷系统平账专户"), loanSql);
assertTrue(loanSql.contains("企业贷"), loanSql);
assertTrue(loanSql.contains("消费金融"), loanSql);
assertFalse(loanSql.contains("民间借贷"), loanSql);
assertFalse(loanSql.contains("资金拆借"), loanSql);
assertFalse(loanSql.contains("调拨"), loanSql);
assertFalse(loanSql.contains("划拨"), loanSql);
assertFalse(loanSql.contains("TRX_TYPE"), loanSql);
assertTrue(aggregateSql.contains("having sum(candidate.loanFlag) &lt; count(*)"), aggregateSql);
}
private String extractSelect(String xml, String selectId) {
String start = "<select id=\"" + selectId + "\"";
int startIndex = xml.indexOf(start);

View File

@@ -10,6 +10,7 @@ import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
@@ -322,6 +323,9 @@ class CcdiProjectOverviewServiceImplTest {
&& "6222000000000001".equals(rows.getFirst().getAccountNo())
&& "账户6222000000000001销户前存在异常交易".equals(rows.getFirst().getReasonDetail())
&& new BigDecimal("120000.00").equals(rows.getFirst().getInvolvedAmount())
),
argThat((List<CcdiProjectUnsubmittedAccountFlowExcel> rows) ->
rows != null && rows.isEmpty()
)
);
}

View File

@@ -3,11 +3,13 @@ package com.ruoyi.ccdi.project.service.impl;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectUnsubmittedAccountFlowExcel;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.ByteArrayInputStream;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.List;
@@ -17,7 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectRiskDetailWorkbookExporterTest {
@Test
void shouldExportWorkbookWithThreeOrderedSheets() throws Exception {
void shouldExportWorkbookWithFourOrderedSheets() throws Exception {
CcdiProjectRiskDetailWorkbookExporter exporter = new CcdiProjectRiskDetailWorkbookExporter();
MockHttpServletResponse response = new MockHttpServletResponse();
@@ -50,17 +52,38 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
abnormalRow.setReasonDetail("账户6222000000000001销户前存在异常交易");
abnormalRow.setInvolvedAmount(new BigDecimal("120000.00"));
exporter.export(response, 40L, List.of(suspiciousRow), List.of(creditRow), List.of(abnormalRow));
CcdiProjectUnsubmittedAccountFlowExcel unsubmittedRow = new CcdiProjectUnsubmittedAccountFlowExcel();
unsubmittedRow.setSubjectName("测试主体");
unsubmittedRow.setAccountNo("330001000000000001");
unsubmittedRow.setTrxDate("2026-03-21");
unsubmittedRow.setLeAccountNo("330001000000000002");
unsubmittedRow.setLeAccountName("测试主体");
unsubmittedRow.setCustomerAccountName("测试主体");
unsubmittedRow.setCustomerAccountNo("330001000000000001");
unsubmittedRow.setUserMemo("往来款");
unsubmittedRow.setCashType("转账");
unsubmittedRow.setAmountCr(new BigDecimal("30000.00"));
unsubmittedRow.setAmountDr(BigDecimal.ZERO);
exporter.export(
response,
40L,
List.of(suspiciousRow),
List.of(creditRow),
List.of(abnormalRow),
List.of(unsubmittedRow)
);
assertTrue(response.getContentType().startsWith(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
));
try (var workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) {
assertEquals(3, workbook.getNumberOfSheets());
assertEquals(4, workbook.getNumberOfSheets());
assertEquals("涉疑交易明细", workbook.getSheetAt(0).getSheetName());
assertEquals("员工负面征信信息", workbook.getSheetAt(1).getSheetName());
assertEquals("异常账户人员信息", workbook.getSheetAt(2).getSheetName());
assertEquals("未提交账户流水", workbook.getSheetAt(3).getSheetName());
assertEquals("交易时间", workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue());
assertEquals("本方账户", workbook.getSheetAt(0).getRow(0).getCell(1).getStringCellValue());
assertEquals("本方主体", workbook.getSheetAt(0).getRow(0).getCell(2).getStringCellValue());
@@ -69,11 +92,12 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
assertEquals("关联员工", workbook.getSheetAt(0).getRow(0).getCell(5).getStringCellValue());
assertEquals("摘要", workbook.getSheetAt(0).getRow(0).getCell(6).getStringCellValue());
assertEquals("交易类型", workbook.getSheetAt(0).getRow(0).getCell(7).getStringCellValue());
assertEquals("异常标签", workbook.getSheetAt(0).getRow(0).getCell(8).getStringCellValue());
assertEquals("交易金额", workbook.getSheetAt(0).getRow(0).getCell(9).getStringCellValue());
assertEquals("交易金额", workbook.getSheetAt(0).getRow(0).getCell(8).getStringCellValue());
assertEquals("异常标签", workbook.getSheetAt(0).getRow(0).getCell(9).getStringCellValue());
assertEquals("测试对手方", workbook.getSheetAt(0).getRow(1).getCell(3).getStringCellValue());
assertEquals("6222000000000001", workbook.getSheetAt(0).getRow(1).getCell(4).getStringCellValue());
assertEquals("异常标签", workbook.getSheetAt(0).getRow(1).getCell(8).getStringCellValue());
assertEquals(100D, workbook.getSheetAt(0).getRow(1).getCell(8).getNumericCellValue());
assertEquals("异常标签", workbook.getSheetAt(0).getRow(1).getCell(9).getStringCellValue());
assertEquals("账号", workbook.getSheetAt(2).getRow(0).getCell(0).getStringCellValue());
assertEquals("开户人", workbook.getSheetAt(2).getRow(0).getCell(1).getStringCellValue());
assertEquals("银行", workbook.getSheetAt(2).getRow(0).getCell(2).getStringCellValue());
@@ -91,6 +115,34 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
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("主体名称", workbook.getSheetAt(3).getRow(0).getCell(0).getStringCellValue());
assertEquals("未提交账号", workbook.getSheetAt(3).getRow(0).getCell(1).getStringCellValue());
assertEquals("交易时间", workbook.getSheetAt(3).getRow(0).getCell(2).getStringCellValue());
assertEquals("流入金额", workbook.getSheetAt(3).getRow(0).getCell(9).getStringCellValue());
assertEquals("测试主体", workbook.getSheetAt(3).getRow(1).getCell(0).getStringCellValue());
assertEquals("330001000000000001", workbook.getSheetAt(3).getRow(1).getCell(1).getStringCellValue());
assertEquals(30000D, workbook.getSheetAt(3).getRow(1).getCell(9).getNumericCellValue());
}
}
@Test
void shouldKeepSuspiciousTransactionExcelAmountBeforeHitTags() {
Field[] fields = CcdiProjectSuspiciousTransactionExcel.class.getDeclaredFields();
int amountIndex = findFieldIndex(fields, "displayAmount");
int hitTagsIndex = findFieldIndex(fields, "hitTags");
assertTrue(amountIndex >= 0);
assertTrue(hitTagsIndex >= 0);
assertTrue(amountIndex < hitTagsIndex);
}
private int findFieldIndex(Field[] fields, String fieldName) {
for (int i = 0; i < fields.length; i++) {
if (fieldName.equals(fields[i].getName())) {
return i;
}
}
return -1;
}
}

View File

@@ -0,0 +1,29 @@
# 流水明细对手方聚合查询后端实施计划
## 背景
明细查询需要增加类似 lsfx 对手方页签的聚合视图,按流水 `CUSTOMER_ACCOUNT_NAME` 归集同一项目、同一筛选条件下的交易数据,供前端展示对手方汇总。
## 实施范围
- `ccdi-project` 后端模块。
- 新增对手方聚合分页接口,不新增业务表,不修改已有流水明细查询入参结构。
- 复用 `CcdiBankStatementQueryDTO` 的项目、交易方向、交易时间、本方主体、本方银行、本方账号、金额区间、对方名称、对方账户、摘要和空值匹配筛选。
## 实施步骤
1. 新增 `CcdiBankStatementCounterpartyVO`,返回对手方名称、空值标识、关联账号数、流入/流出金额与笔数、总交易额、总笔数、最近交易时间。
2.`CcdiBankStatementController` 增加 `GET /ccdi/project/bank-statement/counterparty/list`,沿用项目读权限校验和 RuoYi 分页返回结构。
3.`ICcdiBankStatementService``CcdiBankStatementServiceImpl` 增加聚合查询服务方法,统一复用明细查询参数归一化和排序白名单。
4.`CcdiBankStatementMapper` 与 XML 中新增聚合 SQL
-`CUSTOMER_ACCOUNT_NAME``TRIM(IFNULL(..., ''))` 后分组。
-`CASE WHEN AMOUNT >= 0` 统计流入,用 `< 0` 统计流出。
-`ABS(AMOUNT)` 计算流出合计和总交易额,便于前端展示正数合计。
- 最近交易时间复用既有交易时间解析表达式。
5. 补充 Controller 与 Mapper XML 单元测试覆盖接口分页返回、SQL 聚合字段、筛选条件和排序。
## 验证计划
- 运行后端定向单测:
`mvn -pl ccdi-project -am "-Dtest=CcdiBankStatementMapperXmlTest,CcdiBankStatementControllerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`
- 打包并启动真实后端后,用真实页面请求新接口,确认 `counterparty/list` 返回 200。

View File

@@ -0,0 +1,27 @@
# 未提交账户后端实施计划
## 目标
在结果总览风险明细中新增“未提交账户”能力,参考 lsfx 未提交账户口径识别本项目已上传流水中疑似未提交分析的同名账户,并按已确认范围排除银行贷款/信贷类账号。
## 实施范围
- 新增未提交账户查询接口,返回汇总、主体分组和账号卡片数据。
- 新增未提交账户导出接口,导出未提交账户流水。
- 风险明细统一导出增加“未提交账户流水” Sheet。
- 结果总览报告增加未提交账户明细。
- 不新增数据库表,不新增菜单与权限。
## 关键口径
1. 候选账号来自 `ccdi_bank_statement` 当前项目流水。
2. 对手方户名等于本方主体名,对手方账号非空,且清洗后不等于本方账号。
3. 对手方账号不在本项目已提交账号集合中,已提交账号集合来自当前项目已上传流水的本方账号 `LE_ACCOUNT_NO`
4. 贷款排除只使用银行贷款/信贷高确定性关键词,不使用 `TRX_TYPE`
5. 只有候选账号关联流水全部命中贷款/信贷排除条件时,才排除该账号。
## 验证
- Mapper XML 断言覆盖未提交账户 SQL、贷款排除关键词与“不使用 TRX_TYPE”。
- 风险明细工作簿测试覆盖新增 Sheet。
- 后端编译或相关测试通过后记录实施结果。

View File

@@ -0,0 +1,32 @@
# 流水明细对手方聚合查询前端实施计划
## 背景
用户希望在项目详情的“流水明细查询”中增加“对手方”视图,效果参考 lsfx 对手方列表:在同一筛选条件下按对手方汇总,查看流入、流出和总交易规模。
## 实施范围
- `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- `ruoyi-ui/src/api/ccdiProjectBankStatement.js`
## 实施步骤
1. 在流水明细结果区标题旁增加 `el-radio-group`,提供“流水明细 / 对手方”切换。
2. 新增 `listBankStatementCounterparties` API 方法,请求后端 `/ccdi/project/bank-statement/counterparty/list`
3. 对手方模式下复用左侧筛选区和分页组件,隐藏“导出流水”按钮。
4. 对手方表格展示对手方名称、流入合计、流出合计、总交易额和最近交易时间。
5. 对手方模式默认按总交易额倒序;表格列排序映射到后端白名单字段。
## 验证计划
- 前端构建:
`npm run build:prod`
- 真实页面验证:
1. 打开项目详情 `http://127.0.0.1:8081/ccdiProject/detail/42?tab=detail`
2. 进入“流水明细查询”,切换到“对手方”。
3. 确认列表展示对手方名称、流入/流出/总交易额与分页总数。
4. 确认对手方表格不展示“操作”列和“查看流水”按钮。
## 本机 Node 说明
`.nvmrc` 指向 `14.21.3`,但本机该版本环境不可稳定提供 npm。按仓库约定本次使用已验证可用的 Node `22.22.3` 与 npm `10.9.8` 完成构建和页面验证。

View File

@@ -0,0 +1,26 @@
# 未提交账户前端实施计划
## 目标
在结果总览风险明细页面新增“未提交账户”展示区,形态参考 lsfx 截图:顶部汇总、主体分组、账号卡片和导出流水入口。
## 实施范围
- `projectOverview.js` 增加未提交账户查询 API。
- `RiskDetailSection.vue` 增加未提交账户区块。
- 展示未提交账户数、流入总额、流出总额、流入/流出差额。
- 按主体展示账号卡片,卡片显示账号、流入金额、流出金额和交易占比较高标记。
- 导出按钮调用后端未提交账户流水导出接口。
## 交互规则
- 当前项目为空时显示空态。
- 查询失败时展示错误提示并清空未提交账户数据。
- 账号显示使用中间省略,金额按万元展示。
- 交易占比较高按后端返回的标记展示。
## 验证
- 前端单元静态断言覆盖标题、汇总字段、导出入口和卡片字段。
- 按项目要求使用可用 Node/npm 版本完成构建或测试。
- 页面开发完成后使用浏览器实际验证。

View File

@@ -0,0 +1,32 @@
# 流水明细对手方聚合查询实施记录
## 修改内容
- 后端新增 `GET /ccdi/project/bank-statement/counterparty/list`,按对方名称聚合流水,返回流入合计、流出合计、总交易额、笔数和最近交易时间。
- 后端新增 `CcdiBankStatementCounterpartyVO`,并在 Mapper XML 中复用明细查询筛选条件和排序白名单。
- 前端在项目详情“流水明细查询”中增加“流水明细 / 对手方”切换。
- 前端新增对手方聚合表格,支持按聚合指标排序;按用户反馈移除“查看流水”入口,避免从汇总视图跳到逐笔流水后缺少明确返回路径。
## 影响范围
- 涉及 `ccdi-project` 的银行流水查询 Controller、Service、Mapper 与 XML。
- 涉及 `ruoyi-ui` 的流水明细查询组件与银行流水 API 文件。
- 不涉及数据库表结构、菜单、权限标识或初始化数据变更。
## 验证情况
- 后端单测通过:
`mvn -pl ccdi-project -am "-Dtest=CcdiBankStatementMapperXmlTest,CcdiBankStatementControllerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`
- 前端构建通过:
`npm run build:prod`
- 真实页面验证通过:
- 打开真实项目详情页 `http://127.0.0.1:8081/ccdiProject/detail/42?tab=detail`
- 切换“对手方”后,接口 `/ccdi/project/bank-statement/counterparty/list` 返回 200页面展示“美团、微信支付、银行转账”等聚合行和 `共 8 条`
- 对手方表格已移除“操作”列和“查看流水”按钮,只保留汇总信息展示与表格排序。
- 浏览器验证说明:`browser-use` 对应的 Chrome DevTools MCP 被本机 profile 锁占用,本次按 Playwright 技能要求使用 `@playwright/cli` 在真实页面完成验证。
## 环境说明
- `.nvmrc``14.21.3`,但本机 nvm 的 Node 14 环境无法稳定提供 npm。
- 本次前端构建与页面验证使用 Node `22.22.3`、npm `10.9.8`;部分 PowerShell 会话中 nvm 切换后 PATH 不刷新,因此构建使用已确认存在的 `C:\Users\20696\AppData\Local\nvm-nodejs\npm.cmd`
- 前端构建存在项目既有体积告警,包含 `login-background``chunk-elementUI``chunk-libs``0uS5znL.docx` 等资产超限;未发现由本次代码变更引入的构建失败。

View File

@@ -0,0 +1,63 @@
# 未提交账户实施记录
## 修改内容
- 新增结果总览“未提交账户”后端查询与导出能力。
- 新增前端风险明细未提交账户卡片展示。
- 新增未提交账户卡片点击后的本地“流水明细”弹窗,按 `LE_ACCOUNT_NAME + CUSTOMER_ACCOUNT_NO` 当前卡片范围查询不跳转“流水明细查询”Tab。
- 新增未提交账户流水查询接口 `GET /ccdi/project/overview/unsubmitted-accounts/flows`;原未提交账户导出接口支持可选 `subjectName/accountNo` 过滤,用于弹窗内导出当前账号流水。
- 风险明细统一导出与结果总览报告接入未提交账户流水。
- 贷款排除仅使用银行贷款/信贷高确定性关键词,未启用内部杂项账号排除。
- 本地项目 `90342test拉取行内流水` 补充未提交账户演示数据,统一流水号标记为 `UNSUBMITTED_ACCOUNT_SEED_90342_20260720%`
## 影响范围
- 后端:`ccdi-project` 结果总览 Controller、Service、Mapper、DTO、VO、Excel 与导出器。
- 前端:`ruoyi-ui` 结果总览风险明细组件与 API未提交账户卡片新增本地明细弹窗与当前账号导出。
- 文档:后端计划、前端计划、实施记录。
## 验证情况
- 后端增量测试首次执行通过:`mvn -pl ccdi-project "-Dtest=CcdiProjectOverviewMapperSqlTest,CcdiProjectRiskDetailWorkbookExporterTest,CcdiProjectOverviewServiceImplTest" test`,共 25 个测试通过。
- 执行 `mvn -pl ruoyi-admin -am clean package -DskipTests`,后端全链路依赖模块打包成功。
- Mapper XML well-formed 检查通过。
- 前端使用 Node `v22.22.3`、npm `10.9.8` 执行 `node tests/unit/risk-detail-abnormal-account-layout.test.js`,静态断言通过。
- 前端使用 Node `v22.22.3`、npm `10.9.8` 启动 `npm run dev -- --host 127.0.0.1 --port 8088`,编译成功。
- 本地补数验证:`output/sql/unsubmitted_account_seed_90342.sql` 插入 8 条流水,其中 4 个同名未提交账户聚合展示、1 个同名贷款账户通过“归还贷款/贷款还款”口径被排除;清理脚本为 `output/sql/unsubmitted_account_cleanup_90342.sql`
- 浏览器验证:已打开 `http://127.0.0.1:8088/ccdiProject/detail/90342?tab=overview`页面显示“未提交账户”4 个、流入总额 100.90 万元、流出总额 -1.16 万元、差额 99.74 万元。
## 2026-07-20 追加:未提交账户流水弹窗
- 后端复用未提交账户候选流水 SQL 与贷款排除口径,增加按主体名称和未提交账号过滤的流水查询能力。
- 前端未提交账户卡片支持点击/回车打开本地弹窗,弹窗标题展示日期范围、主体和账号,并提供全部/流入/流出切换。
- 弹窗内“导出流水”只导出当前主体当前未提交账号流水;页面区块右上角“导出流水”仍导出当前项目全部未提交账户流水。
- 后端验证:`mvn -pl ccdi-project -am "-Dtest=CcdiProjectOverviewMapperSqlTest,CcdiProjectRiskDetailWorkbookExporterTest,CcdiProjectOverviewServiceImplTest" "-Dsurefire.failIfNoSpecifiedTests=false" test` 通过25 个测试通过。
- 后端打包:先执行 `mvn -pl ruoyi-admin -am clean package -DskipTests` 时因旧后端进程锁定 `ruoyi-admin.jar` 导致 clean 失败;确认 62318 旧进程结束后执行 `mvn -pl ruoyi-admin -am package -DskipTests` 成功,并重启真实 Java 后端PID 30896端口 62318 已监听。
- 前端验证:`.nvmrc` 指向 Node 14.21.3,但该环境缺少 npm按项目约定使用 Node `v22.22.3`、npm `10.9.8` 执行 `node tests/unit/risk-detail-abnormal-account-layout.test.js` 通过,并启动 `npm run dev -- --host 127.0.0.1 --port 8088` 编译成功。
- 浏览器验证:打开 `http://127.0.0.1:8088/ccdiProject/detail/90342?tab=overview`,未提交账户显示 4 个;点击首个账号卡片后弹出本地“流水明细”弹窗,接口 `GET /dev-api/ccdi/project/overview/unsubmitted-accounts/flows?projectId=90342&subjectName=测试主体&accountNo=33001061559364` 返回 200弹窗显示全部 2、流入 1、流出 1控制台无错误。
## 2026-07-20 追加:多主体展示优化
- 前端未提交账户统计栏新增“涉及主体数”,从 `subjects.length` 计算,不改后端接口与 SQL 口径。
- 主体分组标题继续只展示主体名称,不增加“主体:”前缀;多主体时按主体名称分组列出各自账号卡片。
- 未提交账户区块右侧导出按钮调整为与本页其他明细导出一致的文字按钮“导出”;弹窗内当前账号导出仍保留“导出流水”。
- 前端验证Node `v22.22.3`、npm `10.9.8` 下执行 `node tests/unit/risk-detail-abnormal-account-layout.test.js` 通过。
- 浏览器验证:打开 `http://127.0.0.1:8088/ccdiProject/detail/90342?tab=overview`,未提交账户区块显示“涉及主体数 1”、主体分组标题显示“测试主体”区块右侧按钮显示“导出”控制台无错误。
## 2026-07-20 追加:涉疑交易导出位置调整
- 移除“风险明细”区块标题右侧的综合导出按钮。
- 将导出按钮移动到“涉疑交易明细”查询条件行右侧。
- 导出接口改为 `POST /ccdi/project/overview/suspicious-transactions/export`,只导出涉疑交易明细单独 Excel不再包含负面征信、异常账户和未提交账户等综合风险明细内容。
- “交易占比较高”仍使用后端 `highRatio` 字段,判定逻辑保持为未提交账号流入或流出占对应主体总流入/总流出达到 10%。
- 前端验证Node `v22.22.3`、npm `10.9.8` 下执行 `node tests/unit/risk-detail-abnormal-account-layout.test.js` 通过。
- 浏览器验证:打开 `http://127.0.0.1:8088/ccdiProject/detail/90342?tab=overview`,确认“风险明细”标题右侧无导出按钮,“涉疑交易明细”筛选行右侧显示“导出”;点击后请求 `POST /dev-api/ccdi/project/overview/suspicious-transactions/export` 返回 200控制台无错误。
## 2026-07-20 追加:文案与涉疑交易导出列顺序调整
- 未提交账户说明文案调整为“被调查主体未提交分析的同名账户”,不再在页面说明中展示“已排除银行贷款类账号”。
- 涉疑交易明细单独 Excel 导出列顺序调整为“交易金额”在“异常标签”左侧。
- 综合风险明细工作簿中的“涉疑交易明细”Sheet 同步调整列顺序,保持同类导出字段顺序一致。
- 后端验证:执行 `mvn -pl ccdi-project -am "-Dtest=CcdiProjectRiskDetailWorkbookExporterTest" "-Dsurefire.failIfNoSpecifiedTests=false" test` 通过,覆盖综合风险明细工作簿列顺序与涉疑交易单独 Excel 字段顺序。
- 前端验证Node `v22.22.3`、npm `10.9.8` 下执行 `node tests/unit/risk-detail-abnormal-account-layout.test.js` 通过。
- 浏览器验证:刷新 `http://127.0.0.1:8088/ccdiProject/detail/90342?tab=overview`,页面显示新说明文案,未出现“已排除银行贷款类账号”;涉疑交易表格列顺序为“交易金额”在“异常标签”左侧,控制台无错误。

View File

@@ -144,6 +144,26 @@ export function getOverviewAbnormalAccountPeople(params) {
})
}
export function getOverviewUnsubmittedAccounts(projectId) {
return request({
url: '/ccdi/project/overview/unsubmitted-accounts',
method: 'get',
params: { projectId }
})
}
export function getOverviewUnsubmittedAccountFlows(params) {
return request({
url: '/ccdi/project/overview/unsubmitted-accounts/flows',
method: 'get',
params: {
projectId: params.projectId,
subjectName: params.subjectName,
accountNo: params.accountNo
}
})
}
export function exportOverviewReport(projectId) {
return request({
url: '/ccdi/project/overview/report/export',

View File

@@ -8,6 +8,14 @@ export function listBankStatement(query) {
});
}
export function listBankStatementCounterparties(query) {
return request({
url: "/ccdi/project/bank-statement/counterparty/list",
method: "get",
params: query,
});
}
export function getBankStatementOptions(projectId) {
return request({
url: "/ccdi/project/bank-statement/options",

View File

@@ -152,8 +152,18 @@
<div class="shell-header">
<div class="shell-title-group">
<span class="shell-title">流水明细查询</span>
<el-radio-group
v-model="resultMode"
class="result-mode-switch"
size="small"
@change="handleResultModeChange"
>
<el-radio-button label="statement">流水明细</el-radio-button>
<el-radio-button label="counterparty">对手方</el-radio-button>
</el-radio-group>
</div>
<el-button
v-if="resultMode === 'statement'"
size="small"
type="primary"
plain
@@ -164,7 +174,7 @@
</el-button>
</div>
<el-alert
v-if="prefillTip"
v-if="resultMode === 'statement' && prefillTip"
:closable="false"
class="prefill-alert"
show-icon
@@ -180,15 +190,24 @@
<div class="result-card">
<el-alert
v-if="listError"
v-if="resultMode === 'statement' && listError"
:closable="false"
class="result-alert"
show-icon
title="流水明细加载失败,请稍后重试"
type="error"
/>
<el-alert
v-if="resultMode === 'counterparty' && counterpartyError"
:closable="false"
class="result-alert"
show-icon
title="对手方加载失败,请稍后重试"
type="error"
/>
<el-table
v-if="resultMode === 'statement'"
v-loading="loading"
:data="list"
class="result-table"
@@ -275,9 +294,82 @@
</el-table-column>
</el-table>
<el-table
v-else
v-loading="loading"
:data="counterpartyList"
class="result-table counterparty-table"
@sort-change="handleCounterpartySortChange"
>
<template slot="empty">
<el-empty
:image-size="96"
description="当前筛选条件下暂无对手方"
/>
</template>
<el-table-column
label="对手方名称"
prop="customerAccountName"
min-width="220"
:formatter="formatCounterpartyNameColumn"
/>
<el-table-column
label="流入合计"
prop="inAmount"
width="150"
align="right"
sortable="custom"
>
<template slot-scope="scope">
<div class="amount-stack">
<span class="amount-text amount-in">{{ formatAmount(scope.row.inAmount) }}</span>
<span class="secondary-text">{{ formatCount(scope.row.inCount) }} </span>
</div>
</template>
</el-table-column>
<el-table-column
label="流出合计"
prop="outAmount"
width="150"
align="right"
sortable="custom"
>
<template slot-scope="scope">
<div class="amount-stack">
<span class="amount-text amount-out">{{ formatAmount(scope.row.outAmount) }}</span>
<span class="secondary-text">{{ formatCount(scope.row.outCount) }} </span>
</div>
</template>
</el-table-column>
<el-table-column
label="总交易额"
prop="totalAmount"
width="150"
align="right"
sortable="custom"
>
<template slot-scope="scope">
<div class="amount-stack">
<span class="amount-text">{{ formatAmount(scope.row.totalAmount) }}</span>
<span class="secondary-text">{{ formatCount(scope.row.totalCount) }} </span>
</div>
</template>
</el-table-column>
<el-table-column
label="最近交易时间"
prop="latestTrxDate"
min-width="170"
sortable="custom"
>
<template slot-scope="scope">
{{ formatField(scope.row.latestTrxDate) }}
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-show="currentTotal > 0"
:total="currentTotal"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="handlePageChange"
@@ -411,6 +503,7 @@
import { parseTime } from "@/utils/ruoyi";
import {
listBankStatement,
listBankStatementCounterparties,
getBankStatementOptions,
getBankStatementDetail,
} from "@/api/ccdiProjectBankStatement";
@@ -427,6 +520,14 @@ const SORT_MAP = {
displayAmount: "amount",
};
const COUNTERPARTY_SORT_MAP = {
inAmount: "inAmount",
outAmount: "outAmount",
totalAmount: "totalAmount",
totalCount: "totalCount",
latestTrxDate: "latestTrxDate",
};
const createDefaultQueryParams = (projectId) => ({
projectId,
pageNum: 1,
@@ -517,10 +618,14 @@ export default {
optionsLoading: false,
detailLoading: false,
detailVisible: false,
resultMode: "statement",
activeTab: "all",
dateRange: [],
list: [],
total: 0,
counterpartyList: [],
counterpartyTotal: 0,
counterpartyError: "",
listError: "",
prefillTip: "",
detailData: createEmptyDetailData(),
@@ -532,6 +637,11 @@ export default {
this.getList();
this.getOptions();
},
computed: {
currentTotal() {
return this.resultMode === "counterparty" ? this.counterpartyTotal : this.total;
},
},
watch: {
dateRange(value) {
this.queryParams.transactionStartTime = value && value[0] ? value[0] : "";
@@ -557,12 +667,19 @@ export default {
if (!this.queryParams.projectId) {
this.list = [];
this.total = 0;
this.counterpartyList = [];
this.counterpartyTotal = 0;
this.listError = "";
this.counterpartyError = "";
return;
}
this.loading = true;
try {
if (this.resultMode === "counterparty") {
await this.getCounterpartyList();
return;
}
const res = await listBankStatement(this.queryParams);
this.list = (res.rows || []).map((item) => ({
...item,
@@ -579,6 +696,19 @@ export default {
this.loading = false;
}
},
async getCounterpartyList() {
try {
const res = await listBankStatementCounterparties(this.queryParams);
this.counterpartyList = res.rows || [];
this.counterpartyTotal = res.total || 0;
this.counterpartyError = "";
} catch (error) {
this.counterpartyList = [];
this.counterpartyTotal = 0;
this.counterpartyError = "加载对手方失败";
console.error("加载对手方失败", error);
}
},
async getOptions() {
this.syncProjectId();
if (!this.queryParams.projectId) {
@@ -624,6 +754,7 @@ export default {
if (!value || !Array.isArray(value.ourCertNos) || !value.ourCertNos.length) {
return;
}
this.resultMode = "statement";
this.activeTab = "all";
this.dateRange = [];
this.queryParams = {
@@ -634,6 +765,17 @@ export default {
this.syncProjectId();
this.getList();
},
handleResultModeChange(mode) {
this.queryParams.pageNum = 1;
if (mode === "counterparty") {
this.queryParams.orderBy = "totalAmount";
this.queryParams.orderDirection = "desc";
} else {
this.queryParams.orderBy = "trxDate";
this.queryParams.orderDirection = "desc";
}
this.getList();
},
handleTabChange(tab) {
const tabName = tab && tab.name ? tab.name : this.activeTab;
this.activeTab = tabName;
@@ -650,6 +792,15 @@ export default {
}
this.getList();
},
handleCounterpartySortChange({ prop, order }) {
this.queryParams.orderBy = COUNTERPARTY_SORT_MAP[prop] || "totalAmount";
this.queryParams.orderDirection = order === "ascending" ? "asc" : "desc";
if (!order) {
this.queryParams.orderBy = "totalAmount";
this.queryParams.orderDirection = "desc";
}
this.getList();
},
handlePageChange(pageInfo) {
if (typeof pageInfo === "number") {
this.queryParams.pageNum = pageInfo;
@@ -743,6 +894,13 @@ export default {
maximumFractionDigits: 2,
});
},
formatCount(value) {
if (value === null || value === undefined || value === "") {
return 0;
}
const count = Number(value);
return Number.isNaN(count) ? 0 : count;
},
formatSignedAmount(value) {
if (value === null || value === undefined || value === "") {
return "-";
@@ -766,6 +924,15 @@ export default {
}
return this.formatField(detail.customerAccountName);
},
formatCounterpartyRowName(row) {
if (!row || row.nameEmpty || !row.customerAccountName) {
return "为空";
}
return this.formatField(row.customerAccountName);
},
formatCounterpartyNameColumn(row) {
return this.formatCounterpartyRowName(row);
},
formatOriginalFileName(detail) {
if (!detail) {
return "暂无原始文件";
@@ -876,6 +1043,14 @@ export default {
color: #909399;
}
.result-mode-switch {
margin-top: 8px;
:deep(.el-radio-button__inner) {
min-width: 82px;
}
}
.shell-header {
display: flex;
align-items: flex-start;
@@ -1011,6 +1186,15 @@ export default {
font-weight: 600;
}
.amount-stack {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
min-width: 0;
line-height: 20px;
}
.amount-in {
color: #67c23a;
}

View File

@@ -6,9 +6,100 @@
<div class="section-title">风险明细</div>
<div class="section-subtitle">展示涉疑交易与异常账户关联人员信息</div>
</div>
<el-button size="mini" type="text" :disabled="!projectId" @click="handleRiskDetailExport">
导出
</el-button>
</div>
<div class="block">
<div class="block-header">
<div>
<div class="block-title">未提交账户</div>
<div class="block-subtitle">被调查主体未提交分析的同名账户</div>
</div>
<el-button
size="mini"
type="text"
:disabled="!projectId || unsubmittedAccountTotal <= 0"
@click="handleUnsubmittedAccountExport"
>
导出
</el-button>
</div>
<div v-loading="unsubmittedAccountLoading" class="unsubmitted-panel">
<template v-if="unsubmittedAccountTotal > 0">
<div class="unsubmitted-metrics">
<div class="unsubmitted-metric">
<div class="metric-label">涉及主体数</div>
<div class="metric-value">{{ unsubmittedSubjectTotal }}</div>
</div>
<div class="unsubmitted-metric">
<div class="metric-label">未提交账户数</div>
<div class="metric-value">{{ unsubmittedAccountTotal }}</div>
</div>
<div class="unsubmitted-metric">
<div class="metric-label">流入总额</div>
<div class="metric-value amount-in">{{ formatWanAmount(unsubmittedAccountSummary.totalAmountCr) }}</div>
</div>
<div class="unsubmitted-metric">
<div class="metric-label">流出总额</div>
<div class="metric-value amount-out">-{{ formatWanAmount(unsubmittedAccountSummary.totalAmountDr) }}</div>
</div>
<div class="unsubmitted-metric">
<div class="metric-label">流入/流出差额</div>
<div
class="metric-value"
:class="Number(unsubmittedAccountSummary.netAmount || 0) >= 0 ? 'amount-in' : 'amount-out'"
>
{{ formatWanAmount(unsubmittedAccountSummary.netAmount) }}
</div>
</div>
</div>
<div
v-for="subject in unsubmittedAccountSubjects"
:key="subject.subjectName"
class="unsubmitted-subject"
>
<div class="unsubmitted-subject-name">{{ formatField(subject.subjectName) }}</div>
<div class="unsubmitted-account-grid">
<div
v-for="account in subject.accounts"
:key="`${subject.subjectName}-${account.accountNo}`"
class="unsubmitted-account-card"
role="button"
tabindex="0"
@click="handleUnsubmittedAccountCardClick(subject, account)"
@keyup.enter="handleUnsubmittedAccountCardClick(subject, account)"
>
<div class="unsubmitted-account-head">
<div class="unsubmitted-account-no">
<i class="el-icon-bank-card"></i>
<span>{{ maskAccountNo(account.accountNo) }}</span>
</div>
<el-tag
v-if="account.highRatio"
size="mini"
type="danger"
effect="plain"
>
交易占比较高
</el-tag>
</div>
<div class="unsubmitted-card-metrics">
<div>
<div class="card-metric-label">流入金额</div>
<div class="card-metric-value amount-in">{{ formatWanAmount(account.totalAmountCr) }}</div>
</div>
<div>
<div class="card-metric-label">流出金额</div>
<div class="card-metric-value amount-out">-{{ formatWanAmount(account.totalAmountDr) }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<el-empty v-else :image-size="96" description="当前项目暂无未提交账户" />
</div>
</div>
<div class="block">
@@ -56,6 +147,11 @@
查询
</el-button>
</el-form-item>
<el-form-item class="suspicious-export-action">
<el-button size="mini" type="text" :disabled="!projectId" @click="handleSuspiciousTransactionExport">
导出
</el-button>
</el-form-item>
</el-form>
<el-table
@@ -242,6 +338,96 @@
</div>
</div>
<el-dialog
:visible.sync="unsubmittedFlowVisible"
append-to-body
custom-class="unsubmitted-flow-dialog"
width="88%"
top="4vh"
@close="closeUnsubmittedFlowDialog"
>
<template slot="title">
<div class="unsubmitted-flow-title">
<span>流水明细</span>
<div v-if="selectedUnsubmittedAccount" class="unsubmitted-flow-title-tags">
<span>{{ formatUnsubmittedFlowPeriod }}</span>
<span>{{ formatField(selectedUnsubmittedAccount.subjectName) }}</span>
<span>{{ formatField(selectedUnsubmittedAccount.accountNo) }}</span>
</div>
</div>
</template>
<div class="unsubmitted-flow-toolbar">
<el-radio-group v-model="unsubmittedFlowType" size="small">
<el-radio-button label="ALL">全部 · {{ unsubmittedFlowAllCount }}</el-radio-button>
<el-radio-button label="INFLOW">流入 · {{ unsubmittedFlowInCount }}</el-radio-button>
<el-radio-button label="OUTFLOW">流出 · {{ unsubmittedFlowOutCount }}</el-radio-button>
</el-radio-group>
<el-button
size="small"
type="text"
icon="el-icon-download"
:disabled="!selectedUnsubmittedAccount || unsubmittedFlowAllCount <= 0"
@click="handleUnsubmittedFlowExport"
>
导出流水
</el-button>
</div>
<el-table
v-loading="unsubmittedFlowLoading"
:data="unsubmittedFlowFilteredList"
class="result-table unsubmitted-flow-table"
height="640"
>
<template slot="empty">
<el-empty :image-size="96" description="当前账号暂无流水明细" />
</template>
<el-table-column label="交易时间" min-width="150">
<template slot-scope="scope">
<div class="multi-line-cell">
<div class="primary-text">{{ formatDatePart(scope.row.trxDate) }}</div>
<div class="secondary-text">{{ formatTimePart(scope.row.trxDate) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="本方账户/主体" min-width="240">
<template slot-scope="scope">
<div class="multi-line-cell">
<div class="primary-text">{{ formatField(scope.row.leAccountNo) }}</div>
<div class="secondary-text">{{ formatField(scope.row.leAccountName) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="对方名称/账户" min-width="260">
<template slot-scope="scope">
<div class="multi-line-cell">
<div class="primary-text link-text">{{ formatField(scope.row.customerAccountName) }}</div>
<div class="secondary-text">{{ formatField(scope.row.customerAccountNo) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="摘要/交易类型" min-width="260" show-overflow-tooltip>
<template slot-scope="scope">
<div class="multi-line-cell">
<div class="primary-text">{{ formatField(scope.row.userMemo) }}</div>
<div class="secondary-text">{{ formatField(scope.row.cashType) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="交易金额" min-width="170" align="right">
<template slot-scope="scope">
<span class="flow-amount" :class="getUnsubmittedFlowAmountClass(scope.row)">
{{ formatUnsubmittedFlowAmount(scope.row) }}
</span>
</template>
</el-table-column>
<el-table-column label="分类" min-width="130">
<template>
<span>未提交账户</span>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog
:visible.sync="detailVisible"
append-to-body
@@ -369,6 +555,8 @@ import {
getOverviewAbnormalAccountPeople,
getOverviewEmployeeCreditNegative,
getOverviewSuspiciousTransactions,
getOverviewUnsubmittedAccountFlows,
getOverviewUnsubmittedAccounts,
} from "@/api/ccdi/projectOverview";
import { getBankStatementDetail } from "@/api/ccdiProjectBankStatement";
import { buildFlowEvidenceFingerprint, buildFlowEvidenceSnapshot } from "@/utils/ccdiEvidence";
@@ -559,6 +747,19 @@ export default {
abnormalAccountPageSize: DEFAULT_RISK_DETAIL_PAGE_SIZE,
abnormalAccountTotal: 0,
abnormalAccountList: [],
unsubmittedAccountLoading: false,
unsubmittedAccountSummary: {
totalAmountCr: 0,
totalAmountDr: 0,
netAmount: 0,
},
unsubmittedAccountTotal: 0,
unsubmittedAccountSubjects: [],
unsubmittedFlowVisible: false,
unsubmittedFlowLoading: false,
unsubmittedFlowType: "ALL",
selectedUnsubmittedAccount: null,
unsubmittedFlowList: [],
projectId: null,
statementDetailCache: {},
suspiciousRequestSeq: 0,
@@ -587,6 +788,33 @@ export default {
allModelOptionLabel() {
return this.isExternalSuspiciousType ? "全部外部模型" : "全部模型";
},
unsubmittedFlowFilteredList() {
if (this.unsubmittedFlowType === "INFLOW") {
return this.unsubmittedFlowList.filter((row) => this.isUnsubmittedFlowIn(row));
}
if (this.unsubmittedFlowType === "OUTFLOW") {
return this.unsubmittedFlowList.filter((row) => this.isUnsubmittedFlowOut(row));
}
return this.unsubmittedFlowList;
},
unsubmittedFlowAllCount() {
return this.unsubmittedFlowList.length;
},
unsubmittedSubjectTotal() {
return this.unsubmittedAccountSubjects.length;
},
unsubmittedFlowInCount() {
return this.unsubmittedFlowList.filter((row) => this.isUnsubmittedFlowIn(row)).length;
},
unsubmittedFlowOutCount() {
return this.unsubmittedFlowList.filter((row) => this.isUnsubmittedFlowOut(row)).length;
},
formatUnsubmittedFlowPeriod() {
if (!this.selectedUnsubmittedAccount) {
return "-";
}
return `${this.formatDatePart(this.selectedUnsubmittedAccount.startDate)} ~ ${this.formatDatePart(this.selectedUnsubmittedAccount.endDate)}`;
},
},
watch: {
sectionData: {
@@ -603,6 +831,8 @@ export default {
this.abnormalAccountPageSize = DEFAULT_RISK_DETAIL_PAGE_SIZE;
this.abnormalAccountTotal = 0;
this.abnormalAccountList = [];
this.unsubmittedAccountTotal = 0;
this.unsubmittedAccountSubjects = [];
this.employeeCreditNegativeList = Array.isArray(value && value.employeeCreditNegativeList)
? value.employeeCreditNegativeList
: [];
@@ -620,8 +850,10 @@ export default {
this.suspiciousLoading = false;
this.statementDetailCache = {};
this.closeDetailDialog();
this.closeUnsubmittedFlowDialog();
}
this.loadAbnormalAccountPeople();
this.loadUnsubmittedAccounts();
},
},
},
@@ -791,6 +1023,80 @@ export default {
this.abnormalAccountLoading = false;
}
},
async loadUnsubmittedAccounts() {
if (!this.projectId) {
this.unsubmittedAccountSummary = {
totalAmountCr: 0,
totalAmountDr: 0,
netAmount: 0,
};
this.unsubmittedAccountTotal = 0;
this.unsubmittedAccountSubjects = [];
this.unsubmittedAccountLoading = false;
return;
}
this.unsubmittedAccountLoading = true;
try {
const response = await getOverviewUnsubmittedAccounts(this.projectId);
const data = (response && response.data) || {};
this.unsubmittedAccountSummary = {
totalAmountCr: Number(data.totalAmountCr) || 0,
totalAmountDr: Number(data.totalAmountDr) || 0,
netAmount: Number(data.netAmount) || 0,
};
this.unsubmittedAccountTotal = Number(data.accountCount) || 0;
this.unsubmittedAccountSubjects = Array.isArray(data.subjects) ? data.subjects : [];
} catch (error) {
this.unsubmittedAccountSummary = {
totalAmountCr: 0,
totalAmountDr: 0,
netAmount: 0,
};
this.unsubmittedAccountTotal = 0;
this.unsubmittedAccountSubjects = [];
this.$message.error("加载未提交账户失败");
console.error("加载未提交账户失败", error);
} finally {
this.unsubmittedAccountLoading = false;
}
},
async handleUnsubmittedAccountCardClick(subject, account) {
if (!this.projectId || !subject || !account || !account.accountNo) {
return;
}
const selectedAccount = {
...account,
subjectName: subject.subjectName,
};
this.selectedUnsubmittedAccount = selectedAccount;
this.unsubmittedFlowType = "ALL";
this.unsubmittedFlowList = [];
this.unsubmittedFlowVisible = true;
this.unsubmittedFlowLoading = true;
try {
const response = await getOverviewUnsubmittedAccountFlows({
projectId: this.projectId,
subjectName: selectedAccount.subjectName,
accountNo: selectedAccount.accountNo,
});
this.unsubmittedFlowList = Array.isArray(response && response.data) ? response.data : [];
} catch (error) {
this.unsubmittedFlowList = [];
this.$message.error("加载未提交账户流水失败");
console.error("加载未提交账户流水失败", error);
} finally {
this.unsubmittedFlowLoading = false;
}
},
closeUnsubmittedFlowDialog() {
this.unsubmittedFlowVisible = false;
this.unsubmittedFlowLoading = false;
this.unsubmittedFlowType = "ALL";
this.selectedUnsubmittedAccount = null;
this.unsubmittedFlowList = [];
},
async fetchStatementDetail(bankStatementId, silent, scope = {}) {
if (!bankStatementId) {
return createEmptyDetailData();
@@ -885,18 +1191,42 @@ export default {
const value = this.formatField(detail.leAccountName);
return value === "-" ? "关联人员" : value;
},
handleRiskDetailExport() {
handleSuspiciousTransactionExport() {
if (!this.projectId) {
return;
}
this.download(
"ccdi/project/overview/risk-details/export",
"ccdi/project/overview/suspicious-transactions/export",
{
projectId: this.projectId,
modelCode: this.selectedModelCode,
suspiciousType: this.currentSuspiciousType,
},
`风险明细_${this.projectId}_${new Date().getTime()}.xlsx`
`涉疑交易明细_${this.projectId}_${new Date().getTime()}.xlsx`
);
},
handleUnsubmittedAccountExport() {
if (!this.projectId || this.unsubmittedAccountTotal <= 0) {
return;
}
this.download(
"ccdi/project/overview/unsubmitted-accounts/export",
{ projectId: this.projectId },
`未提交账户流水_${this.projectId}_${new Date().getTime()}.xlsx`
);
},
handleUnsubmittedFlowExport() {
if (!this.projectId || !this.selectedUnsubmittedAccount) {
return;
}
this.download(
"ccdi/project/overview/unsubmitted-accounts/export",
{
projectId: this.projectId,
subjectName: this.selectedUnsubmittedAccount.subjectName,
accountNo: this.selectedUnsubmittedAccount.accountNo,
},
`未提交账户流水_${this.projectId}_${this.selectedUnsubmittedAccount.accountNo}_${new Date().getTime()}.xlsx`
);
},
formatRelatedStaff(row) {
@@ -943,6 +1273,65 @@ export default {
maximumFractionDigits: 2,
});
},
formatWanAmount(value) {
if (value === null || value === undefined || value === "") {
return "0.00 万元";
}
const amount = Number(value);
if (Number.isNaN(amount)) {
return "0.00 万元";
}
return `${(amount / 10000).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} 万元`;
},
maskAccountNo(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
const text = String(value);
if (text.length <= 10) {
return text;
}
return `${text.slice(0, 8)} ... ${text.slice(-6)}`;
},
formatDatePart(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return String(value).split(" ")[0] || "-";
},
formatTimePart(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
const parts = String(value).split(" ");
return parts.length > 1 && parts[1] ? parts[1] : "-";
},
isUnsubmittedFlowIn(row) {
return Number(row && row.amountCr) > 0;
},
isUnsubmittedFlowOut(row) {
return Number(row && row.amountDr) > 0;
},
getUnsubmittedFlowAmountValue(row) {
if (this.isUnsubmittedFlowIn(row)) {
return Number(row.amountCr) || 0;
}
if (this.isUnsubmittedFlowOut(row)) {
return -Number(row.amountDr || 0);
}
return 0;
},
formatUnsubmittedFlowAmount(row) {
const amount = this.getUnsubmittedFlowAmountValue(row);
const prefix = amount < 0 ? "-¥" : "¥";
return `${prefix}${this.formatAmount(Math.abs(amount))}`;
},
getUnsubmittedFlowAmountClass(row) {
return this.getUnsubmittedFlowAmountValue(row) >= 0 ? "amount-in" : "amount-out";
},
formatSignedAmount(value) {
if (value === null || value === undefined || value === "") {
return "-";
@@ -1113,6 +1502,10 @@ export default {
min-width: 88px;
}
.suspicious-export-action {
margin-left: auto;
}
.suspicious-filter-select {
width: 240px;
}
@@ -1121,6 +1514,176 @@ export default {
width: 240px;
}
.unsubmitted-panel {
min-height: 120px;
}
.unsubmitted-metrics {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 18px;
margin-bottom: 24px;
}
.unsubmitted-metric {
min-width: 0;
}
.metric-label {
color: #64748b;
font-size: 13px;
line-height: 20px;
}
.metric-value {
margin-top: 6px;
color: #0f172a;
font-size: 24px;
line-height: 32px;
font-weight: 700;
overflow-wrap: anywhere;
}
.unsubmitted-subject + .unsubmitted-subject {
margin-top: 22px;
}
.unsubmitted-subject-name {
margin-bottom: 12px;
color: #111827;
font-size: 15px;
line-height: 22px;
font-weight: 700;
}
.unsubmitted-account-grid {
display: grid;
grid-template-columns: repeat(4, minmax(220px, 1fr));
gap: 16px;
}
.unsubmitted-account-card {
min-width: 0;
min-height: 132px;
padding: 18px 20px;
border: 1px solid #e5e7eb;
background: #fff;
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.unsubmitted-account-card:hover,
.unsubmitted-account-card:focus {
border-color: #93c5fd;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.1);
outline: none;
transform: translateY(-1px);
}
.unsubmitted-account-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
min-height: 28px;
}
.unsubmitted-account-no {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
color: #111827;
font-size: 14px;
line-height: 22px;
font-weight: 600;
}
.unsubmitted-account-no i {
flex: 0 0 auto;
color: #9ca3af;
}
.unsubmitted-account-no span {
min-width: 0;
overflow-wrap: anywhere;
}
.unsubmitted-card-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 24px;
}
.card-metric-label {
color: #64748b;
font-size: 13px;
line-height: 20px;
}
.card-metric-value {
margin-top: 4px;
font-size: 16px;
line-height: 24px;
font-weight: 700;
overflow-wrap: anywhere;
}
.unsubmitted-flow-title {
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
}
.unsubmitted-flow-title > span {
flex: 0 0 auto;
color: #111827;
font-size: 18px;
font-weight: 700;
}
.unsubmitted-flow-title-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
min-width: 0;
}
.unsubmitted-flow-title-tags span {
max-width: 260px;
padding: 4px 10px;
color: #409eff;
background: #ecf5ff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.unsubmitted-flow-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.unsubmitted-flow-table :deep(.el-table__header th) {
background: #fff;
color: #606266;
font-size: 14px;
}
.flow-amount {
font-size: 16px;
font-weight: 600;
}
.link-text {
color: #409eff;
}
.result-table {
width: 100%;
}
@@ -1355,6 +1918,14 @@ export default {
}
@media (max-width: 992px) {
.unsubmitted-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.unsubmitted-account-grid {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.detail-overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px 24px;
@@ -1362,6 +1933,12 @@ export default {
}
@media (max-width: 768px) {
.unsubmitted-metrics,
.unsubmitted-account-grid,
.unsubmitted-card-metrics {
grid-template-columns: 1fr;
}
.suspicious-filter-form {
align-items: stretch;
}

View File

@@ -11,6 +11,27 @@ const source = fs.readFileSync(
);
[
"未提交账户",
"被调查主体未提交分析的同名账户",
"涉及主体数",
"未提交账户数",
"流入总额",
"流出总额",
"流入/流出差额",
"交易占比较高",
"导出流水",
"handleUnsubmittedAccountCardClick",
"getOverviewUnsubmittedAccountFlows",
"unsubmittedFlowVisible",
"unsubmittedSubjectTotal",
"流水明细",
"本方账户/主体",
"对方名称/账户",
"摘要/交易类型",
"handleSuspiciousTransactionExport",
"ccdi/project/overview/suspicious-transactions/export",
"涉疑交易明细_",
"当前项目暂无未提交账户",
"异常账户人员信息",
"账号",
"开户人",
@@ -22,3 +43,13 @@ const source = fs.readFileSync(
"涉及金额",
"当前项目暂无异常账户人员信息",
].forEach((token) => assert(source.includes(token), token));
assert(
!source.includes("ccdi/project/overview/risk-details/export"),
"风险明细区块不应再调用综合风险明细导出"
);
assert(
!source.includes("已排除银行贷款类账号"),
"未提交账户说明不应展示贷款排除文案"
);