新增流水明细对手方汇总查询

This commit is contained in:
wjj
2026-07-20 14:29:23 +08:00
parent 7fec8e21be
commit 175a50bf96
13 changed files with 521 additions and 4 deletions

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel; 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.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;
@@ -55,6 +56,19 @@ public class CcdiBankStatementController extends BaseController {
return getDataTable(result.getRecords(), result.getTotal()); 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

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

View File

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

View File

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

View File

@@ -72,6 +72,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="displayAmount" column="displayAmount"/> <result property="displayAmount" column="displayAmount"/>
</resultMap> </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"> <resultMap id="CcdiBankStatementDetailVOResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO">
<id property="bankStatementId" column="bankStatementId"/> <id property="bankStatementId" column="bankStatementId"/>
<result property="projectId" column="projectId"/> <result property="projectId" column="projectId"/>
@@ -284,6 +298,45 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</choose> </choose>
</sql> </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 id="selectStatementPage" resultMap="CcdiBankStatementListVOResultMap">
SELECT SELECT
<include refid="statementListColumns"/> <include refid="statementListColumns"/>
@@ -294,6 +347,38 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<include refid="statementOrderBy"/> <include refid="statementOrderBy"/>
</select> </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 id="selectStatementListForExport" resultMap="CcdiBankStatementListVOResultMap">
SELECT SELECT
<include refid="statementListColumns"/> <include refid="statementListColumns"/>

View File

@@ -3,6 +3,7 @@ package com.ruoyi.ccdi.project.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel; 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.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;
@@ -80,6 +81,31 @@ class CcdiBankStatementControllerTest {
assertEquals(1, result.getRows().size()); 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 @Test
void detail_shouldReturnAjaxResultSuccess() { void detail_shouldReturnAjaxResultSuccess() {
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO(); CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();

View File

@@ -116,6 +116,27 @@ class CcdiBankStatementMapperXmlTest {
assertTrue(sql.contains("END ) <= ?"), sql); 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 @Test
void selectStatementDetailById_shouldJoinUploadRecordForOriginalFileMetadata() throws Exception { void selectStatementDetailById_shouldJoinUploadRecordForOriginalFileMetadata() throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) { try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {

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

@@ -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) { export function getBankStatementOptions(projectId) {
return request({ return request({
url: "/ccdi/project/bank-statement/options", url: "/ccdi/project/bank-statement/options",

View File

@@ -152,8 +152,18 @@
<div class="shell-header"> <div class="shell-header">
<div class="shell-title-group"> <div class="shell-title-group">
<span class="shell-title">流水明细查询</span> <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> </div>
<el-button <el-button
v-if="resultMode === 'statement'"
size="small" size="small"
type="primary" type="primary"
plain plain
@@ -164,7 +174,7 @@
</el-button> </el-button>
</div> </div>
<el-alert <el-alert
v-if="prefillTip" v-if="resultMode === 'statement' && prefillTip"
:closable="false" :closable="false"
class="prefill-alert" class="prefill-alert"
show-icon show-icon
@@ -180,15 +190,24 @@
<div class="result-card"> <div class="result-card">
<el-alert <el-alert
v-if="listError" v-if="resultMode === 'statement' && listError"
:closable="false" :closable="false"
class="result-alert" class="result-alert"
show-icon show-icon
title="流水明细加载失败,请稍后重试" title="流水明细加载失败,请稍后重试"
type="error" type="error"
/> />
<el-alert
v-if="resultMode === 'counterparty' && counterpartyError"
:closable="false"
class="result-alert"
show-icon
title="对手方加载失败,请稍后重试"
type="error"
/>
<el-table <el-table
v-if="resultMode === 'statement'"
v-loading="loading" v-loading="loading"
:data="list" :data="list"
class="result-table" class="result-table"
@@ -275,9 +294,82 @@
</el-table-column> </el-table-column>
</el-table> </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 <pagination
v-show="total > 0" v-show="currentTotal > 0"
:total="total" :total="currentTotal"
:page.sync="queryParams.pageNum" :page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize" :limit.sync="queryParams.pageSize"
@pagination="handlePageChange" @pagination="handlePageChange"
@@ -411,6 +503,7 @@
import { parseTime } from "@/utils/ruoyi"; import { parseTime } from "@/utils/ruoyi";
import { import {
listBankStatement, listBankStatement,
listBankStatementCounterparties,
getBankStatementOptions, getBankStatementOptions,
getBankStatementDetail, getBankStatementDetail,
} from "@/api/ccdiProjectBankStatement"; } from "@/api/ccdiProjectBankStatement";
@@ -427,6 +520,14 @@ const SORT_MAP = {
displayAmount: "amount", displayAmount: "amount",
}; };
const COUNTERPARTY_SORT_MAP = {
inAmount: "inAmount",
outAmount: "outAmount",
totalAmount: "totalAmount",
totalCount: "totalCount",
latestTrxDate: "latestTrxDate",
};
const createDefaultQueryParams = (projectId) => ({ const createDefaultQueryParams = (projectId) => ({
projectId, projectId,
pageNum: 1, pageNum: 1,
@@ -517,10 +618,14 @@ export default {
optionsLoading: false, optionsLoading: false,
detailLoading: false, detailLoading: false,
detailVisible: false, detailVisible: false,
resultMode: "statement",
activeTab: "all", activeTab: "all",
dateRange: [], dateRange: [],
list: [], list: [],
total: 0, total: 0,
counterpartyList: [],
counterpartyTotal: 0,
counterpartyError: "",
listError: "", listError: "",
prefillTip: "", prefillTip: "",
detailData: createEmptyDetailData(), detailData: createEmptyDetailData(),
@@ -532,6 +637,11 @@ export default {
this.getList(); this.getList();
this.getOptions(); this.getOptions();
}, },
computed: {
currentTotal() {
return this.resultMode === "counterparty" ? this.counterpartyTotal : this.total;
},
},
watch: { watch: {
dateRange(value) { dateRange(value) {
this.queryParams.transactionStartTime = value && value[0] ? value[0] : ""; this.queryParams.transactionStartTime = value && value[0] ? value[0] : "";
@@ -557,12 +667,19 @@ export default {
if (!this.queryParams.projectId) { if (!this.queryParams.projectId) {
this.list = []; this.list = [];
this.total = 0; this.total = 0;
this.counterpartyList = [];
this.counterpartyTotal = 0;
this.listError = ""; this.listError = "";
this.counterpartyError = "";
return; return;
} }
this.loading = true; this.loading = true;
try { try {
if (this.resultMode === "counterparty") {
await this.getCounterpartyList();
return;
}
const res = await listBankStatement(this.queryParams); const res = await listBankStatement(this.queryParams);
this.list = (res.rows || []).map((item) => ({ this.list = (res.rows || []).map((item) => ({
...item, ...item,
@@ -579,6 +696,19 @@ export default {
this.loading = false; 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() { async getOptions() {
this.syncProjectId(); this.syncProjectId();
if (!this.queryParams.projectId) { if (!this.queryParams.projectId) {
@@ -624,6 +754,7 @@ export default {
if (!value || !Array.isArray(value.ourCertNos) || !value.ourCertNos.length) { if (!value || !Array.isArray(value.ourCertNos) || !value.ourCertNos.length) {
return; return;
} }
this.resultMode = "statement";
this.activeTab = "all"; this.activeTab = "all";
this.dateRange = []; this.dateRange = [];
this.queryParams = { this.queryParams = {
@@ -634,6 +765,17 @@ export default {
this.syncProjectId(); this.syncProjectId();
this.getList(); 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) { handleTabChange(tab) {
const tabName = tab && tab.name ? tab.name : this.activeTab; const tabName = tab && tab.name ? tab.name : this.activeTab;
this.activeTab = tabName; this.activeTab = tabName;
@@ -650,6 +792,15 @@ export default {
} }
this.getList(); 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) { handlePageChange(pageInfo) {
if (typeof pageInfo === "number") { if (typeof pageInfo === "number") {
this.queryParams.pageNum = pageInfo; this.queryParams.pageNum = pageInfo;
@@ -743,6 +894,13 @@ export default {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
}, },
formatCount(value) {
if (value === null || value === undefined || value === "") {
return 0;
}
const count = Number(value);
return Number.isNaN(count) ? 0 : count;
},
formatSignedAmount(value) { formatSignedAmount(value) {
if (value === null || value === undefined || value === "") { if (value === null || value === undefined || value === "") {
return "-"; return "-";
@@ -766,6 +924,15 @@ export default {
} }
return this.formatField(detail.customerAccountName); 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) { formatOriginalFileName(detail) {
if (!detail) { if (!detail) {
return "暂无原始文件"; return "暂无原始文件";
@@ -876,6 +1043,14 @@ export default {
color: #909399; color: #909399;
} }
.result-mode-switch {
margin-top: 8px;
:deep(.el-radio-button__inner) {
min-width: 82px;
}
}
.shell-header { .shell-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -1011,6 +1186,15 @@ export default {
font-weight: 600; 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 { .amount-in {
color: #67c23a; color: #67c23a;
} }