Compare commits

...

17 Commits

Author SHA1 Message Date
wkc
0de248a039 修复流水明细时间金额筛选SQL问题」}{ 2026-03-11 10:06:17 +08:00
wkc
b69064b68d 调整流水明细筛选栏顺序并移除交易类型筛选 2026-03-11 09:30:17 +08:00
wkc
68325518d7 修复流水明细查询流入页签SQL拼接错误 2026-03-10 17:54:17 +08:00
wkc
120255fcd5 完成流水明细查询前端实现并移除旧跳转入口 2026-03-10 17:01:11 +08:00
wkc
40b7e5bb1b 补齐流水明细查询导出与状态反馈 2026-03-10 16:57:54 +08:00
wkc
df15307288 完成流水明细查询结果区与详情展示 2026-03-10 16:56:32 +08:00
wkc
879580ffe5 完成流水明细查询筛选栏布局 2026-03-10 16:49:52 +08:00
wkc
ab1c06e631 实现流水明细查询页面初始加载逻辑 2026-03-10 16:47:32 +08:00
wkc
d95de8a692 搭建流水明细查询前端页面骨架 2026-03-10 16:44:01 +08:00
wkc
a3a890a2f1 新增流水明细查询控制器接口 2026-03-10 16:21:48 +08:00
wkc
4384c7a4ff 实现流水明细导出模型与详情查询 2026-03-10 16:16:55 +08:00
wkc
1c607c0b2d 实现流水明细查询服务层规范化逻辑 2026-03-10 16:10:25 +08:00
wkc
cfc3545fc7 补充流水明细查询Mapper与动态SQL 2026-03-10 16:06:44 +08:00
wkc
b498137206 新增流水明细查询后端契约与测试骨架 2026-03-10 16:01:00 +08:00
wkc
80337e33b1 新增流水明细查询前后端实施计划 2026-03-10 15:39:36 +08:00
wkc
ebc2d2c3d2 新增项目详情流水明细查询设计文档 2026-03-10 15:31:42 +08:00
wkc
0921e76781 调整上传数据页分页与卡片状态 2026-03-10 14:28:38 +08:00
23 changed files with 3534 additions and 80 deletions

View File

@@ -0,0 +1,88 @@
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.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.service.ICcdiBankStatementService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.PageDomain;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.core.page.TableSupport;
import com.ruoyi.common.utils.poi.ExcelUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 流水明细查询Controller
*
* @author ruoyi
*/
@RestController
@RequestMapping("/ccdi/project/bank-statement")
@Tag(name = "流水明细查询")
public class CcdiBankStatementController extends BaseController {
@Resource
private ICcdiBankStatementService bankStatementService;
/**
* 分页查询流水明细
*/
@GetMapping("/list")
@Operation(summary = "分页查询流水明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public TableDataInfo list(CcdiBankStatementQueryDTO queryDTO) {
PageDomain pageDomain = TableSupport.buildPageRequest();
Page<CcdiBankStatementListVO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
Page<CcdiBankStatementListVO> result = bankStatementService.selectStatementPage(page, queryDTO);
return getDataTable(result.getRecords(), result.getTotal());
}
/**
* 查询项目级筛选项
*/
@GetMapping("/options")
@Operation(summary = "查询项目级筛选项")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getOptions(Long projectId) {
CcdiBankStatementFilterOptionsVO options = bankStatementService.getFilterOptions(projectId);
return AjaxResult.success(options);
}
/**
* 查询流水详情
*/
@GetMapping("/detail/{bankStatementId}")
@Operation(summary = "查询流水详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getDetail(@PathVariable Long bankStatementId) {
CcdiBankStatementDetailVO detail = bankStatementService.getStatementDetail(bankStatementId);
return AjaxResult.success(detail);
}
/**
* 导出流水明细
*/
@PostMapping("/export")
@Operation(summary = "导出流水明细")
@PreAuthorize("@ss.hasPermi('ccdi:project:export')")
public void export(HttpServletResponse response, CcdiBankStatementQueryDTO queryDTO) {
List<CcdiBankStatementExcel> list = bankStatementService.selectStatementListForExport(queryDTO);
ExcelUtil<CcdiBankStatementExcel> util = new ExcelUtil<>(CcdiBankStatementExcel.class);
util.exportExcel(response, list, "流水明细");
}
}

View File

@@ -0,0 +1,72 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 流水明细查询DTO
*
* @author ruoyi
*/
@Data
public class CcdiBankStatementQueryDTO {
/** 项目ID */
private Long projectId;
/** 页签类型 */
private String tabType;
/** 交易开始时间 */
private String transactionStartTime;
/** 交易结束时间 */
private String transactionEndTime;
/** 对方名称 */
private String counterpartyName;
/** 对方名称是否匹配空值 */
private Boolean counterpartyNameEmpty;
/** 摘要 */
private String userMemo;
/** 摘要是否匹配空值 */
private Boolean userMemoEmpty;
/** 本方主体 */
private List<String> ourSubjects;
/** 本方银行 */
private List<String> ourBanks;
/** 本方账户 */
private List<String> ourAccounts;
/** 最小金额 */
private BigDecimal amountMin;
/** 最大金额 */
private BigDecimal amountMax;
/** 对方账户 */
private String counterpartyAccount;
/** 对方账户是否匹配空值 */
private Boolean counterpartyAccountEmpty;
/** 交易类型 */
private String transactionType;
/** 交易类型是否匹配空值 */
private Boolean transactionTypeEmpty;
/** 排序字段 */
private String orderBy;
/** 排序方向 */
private String orderDirection;
}

View File

@@ -0,0 +1,47 @@
package com.ruoyi.ccdi.project.domain.excel;
import com.ruoyi.common.annotation.Excel;
import lombok.Data;
import java.math.BigDecimal;
/**
* 流水明细导出对象
*
* @author ruoyi
*/
@Data
public class CcdiBankStatementExcel {
/** 交易时间 */
@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 displayAmount;
}

View File

@@ -0,0 +1,93 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 流水明细详情VO
*
* @author ruoyi
*/
@Data
public class CcdiBankStatementDetailVO {
/** 流水ID */
private Long bankStatementId;
/** 项目ID */
private Long projectId;
/** 交易时间 */
private String trxDate;
/** 币种 */
private String currency;
/** 本方账户 */
private String leAccountNo;
/** 本方主体 */
private String leAccountName;
/** 对方名称 */
private String customerAccountName;
/** 对方账户 */
private String customerAccountNo;
/** 对方银行 */
private String customerBank;
/** 对方备注 */
private String customerReference;
/** 摘要 */
private String userMemo;
/** 银行摘要 */
private String bankComments;
/** 银行交易号 */
private String bankTrxNumber;
/** 本方银行 */
private String bank;
/** 交易类型 */
private String cashType;
/** 借方金额 */
private BigDecimal amountDr;
/** 贷方金额 */
private BigDecimal amountCr;
/** 余额 */
private BigDecimal amountBalance;
/** 页面展示金额 */
private BigDecimal displayAmount;
/** 交易标志 */
private String trxFlag;
/** 分类ID */
private Integer trxType;
/** 异常类型 */
private String exceptionType;
/** 是否内部交易 */
private Integer internalFlag;
/** 交易方式 */
private String paymentMethod;
/** 身份证号 */
private String cretNo;
/** 创建时间 */
private Date createDate;
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 流水明细筛选项集合VO
*
* @author ruoyi
*/
@Data
public class CcdiBankStatementFilterOptionsVO {
/** 本方主体选项 */
private List<CcdiBankStatementOptionVO> ourSubjectOptions = new ArrayList<>();
/** 本方银行选项 */
private List<CcdiBankStatementOptionVO> ourBankOptions = new ArrayList<>();
/** 本方账户选项 */
private List<CcdiBankStatementOptionVO> ourAccountOptions = new ArrayList<>();
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
/**
* 流水明细列表VO
*
* @author ruoyi
*/
@Data
public class CcdiBankStatementListVO {
/** 流水ID */
private Long bankStatementId;
/** 交易时间 */
private String trxDate;
/** 本方账户 */
private String leAccountNo;
/** 本方主体 */
private String leAccountName;
/** 对方名称 */
private String customerAccountName;
/** 对方账户 */
private String customerAccountNo;
/** 摘要 */
private String userMemo;
/** 交易类型 */
private String cashType;
/** 页面展示金额 */
private BigDecimal displayAmount;
}

View File

@@ -0,0 +1,22 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 流水明细筛选项VO
*
* @author ruoyi
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CcdiBankStatementOptionVO {
/** 展示文案 */
private String label;
/** 实际值 */
private String value;
}

View File

@@ -1,7 +1,12 @@
package com.ruoyi.ccdi.project.mapper;
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.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@@ -24,4 +29,13 @@ public interface CcdiBankStatementMapper extends BaseMapper<CcdiBankStatement> {
int deleteByProjectIdAndBatchId(@Param("projectId") Long projectId,
@Param("batchId") Integer batchId);
Page<CcdiBankStatementListVO> selectStatementPage(Page<CcdiBankStatementListVO> page,
@Param("query") CcdiBankStatementQueryDTO query);
List<CcdiBankStatementListVO> selectStatementListForExport(@Param("query") CcdiBankStatementQueryDTO query);
CcdiBankStatementDetailVO selectStatementDetailById(@Param("bankStatementId") Long bankStatementId);
CcdiBankStatementFilterOptionsVO selectFilterOptions(@Param("projectId") Long projectId);
}

View File

@@ -0,0 +1,52 @@
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.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import java.util.List;
/**
* 流水明细查询Service接口
*
* @author ruoyi
*/
public interface ICcdiBankStatementService {
/**
* 查询项目级筛选项
*
* @param projectId 项目ID
* @return 筛选项
*/
CcdiBankStatementFilterOptionsVO getFilterOptions(Long projectId);
/**
* 分页查询流水明细
*
* @param page 分页对象
* @param queryDTO 查询条件
* @return 分页结果
*/
Page<CcdiBankStatementListVO> selectStatementPage(Page<CcdiBankStatementListVO> page,
CcdiBankStatementQueryDTO queryDTO);
/**
* 查询导出列表
*
* @param queryDTO 查询条件
* @return 导出列表
*/
List<CcdiBankStatementExcel> selectStatementListForExport(CcdiBankStatementQueryDTO queryDTO);
/**
* 查询流水详情
*
* @param bankStatementId 流水ID
* @return 详情
*/
CcdiBankStatementDetailVO getStatementDetail(Long bankStatementId);
}

View File

@@ -0,0 +1,144 @@
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.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankStatementService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.Collections;
import java.util.stream.Collectors;
/**
* 流水明细查询Service实现
*
* @author ruoyi
*/
@Service
public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
private static final Set<String> ALLOWED_TAB_TYPES = Set.of("all", "in", "out");
private static final Set<String> ALLOWED_ORDER_DIRECTIONS = Set.of("asc", "desc");
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@Override
public CcdiBankStatementFilterOptionsVO getFilterOptions(Long projectId) {
CcdiBankStatementFilterOptionsVO options = bankStatementMapper.selectFilterOptions(projectId);
return options == null ? new CcdiBankStatementFilterOptionsVO() : options;
}
@Override
public Page<CcdiBankStatementListVO> selectStatementPage(Page<CcdiBankStatementListVO> page,
CcdiBankStatementQueryDTO queryDTO) {
CcdiBankStatementQueryDTO normalizedQuery = queryDTO == null ? new CcdiBankStatementQueryDTO() : queryDTO;
normalizeQuery(normalizedQuery);
return bankStatementMapper.selectStatementPage(page, normalizedQuery);
}
@Override
public List<CcdiBankStatementExcel> selectStatementListForExport(CcdiBankStatementQueryDTO queryDTO) {
CcdiBankStatementQueryDTO normalizedQuery = queryDTO == null ? new CcdiBankStatementQueryDTO() : queryDTO;
normalizeQuery(normalizedQuery);
List<CcdiBankStatementListVO> rows = bankStatementMapper.selectStatementListForExport(normalizedQuery);
if (rows == null || rows.isEmpty()) {
return Collections.emptyList();
}
return rows.stream().map(this::toExcel).collect(Collectors.toList());
}
@Override
public CcdiBankStatementDetailVO getStatementDetail(Long bankStatementId) {
return bankStatementMapper.selectStatementDetailById(bankStatementId);
}
private void normalizeQuery(CcdiBankStatementQueryDTO queryDTO) {
queryDTO.setTransactionStartTime(normalizeText(queryDTO.getTransactionStartTime()));
queryDTO.setTransactionEndTime(normalizeText(queryDTO.getTransactionEndTime()));
queryDTO.setCounterpartyName(normalizeText(queryDTO.getCounterpartyName()));
queryDTO.setUserMemo(normalizeText(queryDTO.getUserMemo()));
queryDTO.setCounterpartyAccount(normalizeText(queryDTO.getCounterpartyAccount()));
queryDTO.setTransactionType(normalizeText(queryDTO.getTransactionType()));
queryDTO.setOurSubjects(normalizeList(queryDTO.getOurSubjects()));
queryDTO.setOurBanks(normalizeList(queryDTO.getOurBanks()));
queryDTO.setOurAccounts(normalizeList(queryDTO.getOurAccounts()));
queryDTO.setCounterpartyNameEmpty(Boolean.TRUE.equals(queryDTO.getCounterpartyNameEmpty()));
queryDTO.setUserMemoEmpty(Boolean.TRUE.equals(queryDTO.getUserMemoEmpty()));
queryDTO.setCounterpartyAccountEmpty(Boolean.TRUE.equals(queryDTO.getCounterpartyAccountEmpty()));
queryDTO.setTransactionTypeEmpty(Boolean.TRUE.equals(queryDTO.getTransactionTypeEmpty()));
queryDTO.setTabType(normalizeTabType(queryDTO.getTabType()));
queryDTO.setOrderBy(normalizeOrderBy(queryDTO.getOrderBy()));
queryDTO.setOrderDirection(normalizeOrderDirection(queryDTO.getOrderDirection()));
}
private String normalizeTabType(String tabType) {
String normalized = normalizeLowerCase(tabType);
return normalized != null && ALLOWED_TAB_TYPES.contains(normalized) ? normalized : "all";
}
private String normalizeOrderBy(String orderBy) {
String normalized = normalizeText(orderBy);
if (normalized == null) {
return "trxDate";
}
if ("amount".equalsIgnoreCase(normalized)) {
return "amount";
}
if ("trxDate".equalsIgnoreCase(normalized)) {
return "trxDate";
}
return "trxDate";
}
private String normalizeOrderDirection(String orderDirection) {
String normalized = normalizeLowerCase(orderDirection);
return normalized != null && ALLOWED_ORDER_DIRECTIONS.contains(normalized) ? normalized : "desc";
}
private String normalizeLowerCase(String value) {
String normalized = normalizeText(value);
return normalized == null ? null : normalized.toLowerCase(Locale.ROOT);
}
private String normalizeText(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private List<String> normalizeList(List<String> values) {
if (values == null || values.isEmpty()) {
return null;
}
List<String> normalized = values.stream()
.map(this::normalizeText)
.filter(item -> item != null)
.distinct()
.collect(Collectors.toList());
return normalized.isEmpty() ? null : normalized;
}
private CcdiBankStatementExcel toExcel(CcdiBankStatementListVO row) {
CcdiBankStatementExcel excel = new CcdiBankStatementExcel();
excel.setTrxDate(row.getTrxDate());
excel.setLeAccountNo(row.getLeAccountNo());
excel.setLeAccountName(row.getLeAccountName());
excel.setCustomerAccountName(row.getCustomerAccountName());
excel.setCustomerAccountNo(row.getCustomerAccountNo());
excel.setUserMemo(row.getUserMemo());
excel.setCashType(row.getCashType());
excel.setDisplayAmount(row.getDisplayAmount());
return excel;
}
}

View File

@@ -59,6 +59,302 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
from ccdi_bank_statement
</sql>
<resultMap id="CcdiBankStatementListVOResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO">
<id property="bankStatementId" column="bankStatementId"/>
<result property="trxDate" column="trxDate"/>
<result property="leAccountNo" column="leAccountNo"/>
<result property="leAccountName" column="leAccountName"/>
<result property="customerAccountName" column="customerAccountName"/>
<result property="customerAccountNo" column="customerAccountNo"/>
<result property="userMemo" column="userMemo"/>
<result property="cashType" column="cashType"/>
<result property="displayAmount" column="displayAmount"/>
</resultMap>
<resultMap id="CcdiBankStatementDetailVOResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementDetailVO">
<id property="bankStatementId" column="bankStatementId"/>
<result property="projectId" column="projectId"/>
<result property="trxDate" column="trxDate"/>
<result property="currency" column="currency"/>
<result property="leAccountNo" column="leAccountNo"/>
<result property="leAccountName" column="leAccountName"/>
<result property="customerAccountName" column="customerAccountName"/>
<result property="customerAccountNo" column="customerAccountNo"/>
<result property="customerBank" column="customerBank"/>
<result property="customerReference" column="customerReference"/>
<result property="userMemo" column="userMemo"/>
<result property="bankComments" column="bankComments"/>
<result property="bankTrxNumber" column="bankTrxNumber"/>
<result property="bank" column="bank"/>
<result property="cashType" column="cashType"/>
<result property="amountDr" column="amountDr"/>
<result property="amountCr" column="amountCr"/>
<result property="amountBalance" column="amountBalance"/>
<result property="displayAmount" column="displayAmount"/>
<result property="trxFlag" column="trxFlag"/>
<result property="trxType" column="trxType"/>
<result property="exceptionType" column="exceptionType"/>
<result property="internalFlag" column="internalFlag"/>
<result property="paymentMethod" column="paymentMethod"/>
<result property="cretNo" column="cretNo"/>
<result property="createDate" column="createDate"/>
</resultMap>
<resultMap id="CcdiBankStatementFilterOptionsVOResultMap"
type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO">
<collection property="ourSubjectOptions"
column="{projectId=project_id}"
select="selectOurSubjectOptions"/>
<collection property="ourBankOptions"
column="{projectId=project_id}"
select="selectOurBankOptions"/>
<collection property="ourAccountOptions"
column="{projectId=project_id}"
select="selectOurAccountOptions"/>
</resultMap>
<sql id="parsedTrxDateExpr">
CASE
WHEN bs.TRX_DATE IS NULL OR TRIM(bs.TRX_DATE) = '' THEN NULL
WHEN LENGTH(TRIM(bs.TRX_DATE)) = 10 THEN STR_TO_DATE(CONCAT(TRIM(bs.TRX_DATE), ' 00:00:00'), '%Y-%m-%d %H:%i:%s')
ELSE STR_TO_DATE(TRIM(bs.TRX_DATE), '%Y-%m-%d %H:%i:%s')
END
</sql>
<sql id="displayAmountExpr">
CASE
WHEN IFNULL(bs.AMOUNT_CR, 0) > 0 THEN IFNULL(bs.AMOUNT_CR, 0)
ELSE 0 - IFNULL(bs.AMOUNT_DR, 0)
END
</sql>
<sql id="absoluteAmountExpr">
CASE
WHEN IFNULL(bs.AMOUNT_CR, 0) > 0 THEN IFNULL(bs.AMOUNT_CR, 0)
ELSE IFNULL(bs.AMOUNT_DR, 0)
END
</sql>
<sql id="statementListColumns">
bs.bank_statement_id AS bankStatementId,
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,
<include refid="displayAmountExpr"/> AS displayAmount
</sql>
<sql id="statementFilterWhere">
AND (bs.project_id = #{query.projectId})
<if test="query.tabType == 'in'">
AND IFNULL(bs.AMOUNT_CR, 0) > 0
</if>
<if test="query.tabType == 'out'">
AND IFNULL(bs.AMOUNT_DR, 0) > 0
</if>
<if test="query.transactionStartTime != null and query.transactionStartTime != ''">
AND (<include refid="parsedTrxDateExpr"/>) <![CDATA[ >= ]]>
CASE
WHEN LENGTH(TRIM(#{query.transactionStartTime})) = 10
THEN STR_TO_DATE(CONCAT(TRIM(#{query.transactionStartTime}), ' 00:00:00'), '%Y-%m-%d %H:%i:%s')
ELSE STR_TO_DATE(TRIM(#{query.transactionStartTime}), '%Y-%m-%d %H:%i:%s')
END
</if>
<if test="query.transactionEndTime != null and query.transactionEndTime != ''">
AND (<include refid="parsedTrxDateExpr"/>) <![CDATA[ <= ]]>
CASE
WHEN LENGTH(TRIM(#{query.transactionEndTime})) = 10
THEN STR_TO_DATE(CONCAT(TRIM(#{query.transactionEndTime}), ' 23:59:59'), '%Y-%m-%d %H:%i:%s')
ELSE STR_TO_DATE(TRIM(#{query.transactionEndTime}), '%Y-%m-%d %H:%i:%s')
END
</if>
<if test="(query.counterpartyName != null and query.counterpartyName != '') or query.counterpartyNameEmpty">
AND (
<if test="query.counterpartyName != null and query.counterpartyName != ''">
bs.CUSTOMER_ACCOUNT_NAME LIKE CONCAT('%', TRIM(#{query.counterpartyName}), '%')
</if>
<if test="query.counterpartyName != null and query.counterpartyName != '' and query.counterpartyNameEmpty">
OR
</if>
<if test="query.counterpartyNameEmpty">
bs.CUSTOMER_ACCOUNT_NAME IS NULL OR TRIM(bs.CUSTOMER_ACCOUNT_NAME) = ''
</if>
)
</if>
<if test="(query.userMemo != null and query.userMemo != '') or query.userMemoEmpty">
AND (
<if test="query.userMemo != null and query.userMemo != ''">
bs.USER_MEMO LIKE CONCAT('%', TRIM(#{query.userMemo}), '%')
</if>
<if test="query.userMemo != null and query.userMemo != '' and query.userMemoEmpty">
OR
</if>
<if test="query.userMemoEmpty">
bs.USER_MEMO IS NULL OR TRIM(bs.USER_MEMO) = ''
</if>
)
</if>
<if test="query.ourSubjects != null and query.ourSubjects.size() > 0">
AND bs.LE_ACCOUNT_NAME IN
<foreach collection="query.ourSubjects" item="item" open="(" separator="," close=")">
#{item}
</foreach>
</if>
<if test="query.ourBanks != null and query.ourBanks.size() > 0">
AND bs.BANK IN
<foreach collection="query.ourBanks" item="item" open="(" separator="," close=")">
#{item}
</foreach>
</if>
<if test="query.ourAccounts != null and query.ourAccounts.size() > 0">
AND bs.LE_ACCOUNT_NO IN
<foreach collection="query.ourAccounts" item="item" open="(" separator="," close=")">
#{item}
</foreach>
</if>
<if test="query.amountMin != null">
AND (<include refid="absoluteAmountExpr"/>) <![CDATA[ >= ]]> #{query.amountMin}
</if>
<if test="query.amountMax != null">
AND (<include refid="absoluteAmountExpr"/>) <![CDATA[ <= ]]> #{query.amountMax}
</if>
<if test="(query.counterpartyAccount != null and query.counterpartyAccount != '') or query.counterpartyAccountEmpty">
AND (
<if test="query.counterpartyAccount != null and query.counterpartyAccount != ''">
bs.CUSTOMER_ACCOUNT_NO LIKE CONCAT('%', TRIM(#{query.counterpartyAccount}), '%')
</if>
<if test="query.counterpartyAccount != null and query.counterpartyAccount != '' and query.counterpartyAccountEmpty">
OR
</if>
<if test="query.counterpartyAccountEmpty">
bs.CUSTOMER_ACCOUNT_NO IS NULL OR TRIM(bs.CUSTOMER_ACCOUNT_NO) = ''
</if>
)
</if>
<if test="(query.transactionType != null and query.transactionType != '') or query.transactionTypeEmpty">
AND (
<if test="query.transactionType != null and query.transactionType != ''">
bs.CASH_TYPE LIKE CONCAT('%', TRIM(#{query.transactionType}), '%')
</if>
<if test="query.transactionType != null and query.transactionType != '' and query.transactionTypeEmpty">
OR
</if>
<if test="query.transactionTypeEmpty">
bs.CASH_TYPE IS NULL OR TRIM(bs.CASH_TYPE) = ''
</if>
)
</if>
</sql>
<sql id="statementOrderBy">
<choose>
<when test="query.orderBy == 'amount' and query.orderDirection == 'asc'">
ORDER BY <include refid="absoluteAmountExpr"/> ASC, bs.bank_statement_id ASC
</when>
<when test="query.orderBy == 'amount' and query.orderDirection == 'desc'">
ORDER BY <include refid="absoluteAmountExpr"/> DESC, bs.bank_statement_id DESC
</when>
<when test="query.orderDirection == 'asc'">
ORDER BY <include refid="parsedTrxDateExpr"/> ASC, bs.bank_statement_id ASC
</when>
<otherwise>
ORDER BY <include refid="parsedTrxDateExpr"/> DESC, bs.bank_statement_id DESC
</otherwise>
</choose>
</sql>
<select id="selectStatementPage" resultMap="CcdiBankStatementListVOResultMap">
SELECT
<include refid="statementListColumns"/>
FROM ccdi_bank_statement bs
<where>
<include refid="statementFilterWhere"/>
</where>
<include refid="statementOrderBy"/>
</select>
<select id="selectStatementListForExport" resultMap="CcdiBankStatementListVOResultMap">
SELECT
<include refid="statementListColumns"/>
FROM ccdi_bank_statement bs
<where>
<include refid="statementFilterWhere"/>
</where>
<include refid="statementOrderBy"/>
</select>
<select id="selectStatementDetailById" resultMap="CcdiBankStatementDetailVOResultMap">
SELECT
bs.bank_statement_id AS bankStatementId,
bs.project_id AS projectId,
bs.TRX_DATE AS trxDate,
bs.CURRENCY AS currency,
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.customer_bank AS customerBank,
bs.customer_reference AS customerReference,
bs.USER_MEMO AS userMemo,
bs.BANK_COMMENTS AS bankComments,
bs.BANK_TRX_NUMBER AS bankTrxNumber,
bs.BANK AS bank,
bs.CASH_TYPE AS cashType,
bs.AMOUNT_DR AS amountDr,
bs.AMOUNT_CR AS amountCr,
bs.AMOUNT_BALANCE AS amountBalance,
<include refid="displayAmountExpr"/> AS displayAmount,
bs.TRX_FLAG AS trxFlag,
bs.TRX_TYPE AS trxType,
bs.EXCEPTION_TYPE AS exceptionType,
bs.internal_flag AS internalFlag,
bs.payment_method AS paymentMethod,
bs.cret_no AS cretNo,
bs.CREATE_DATE AS createDate
FROM ccdi_bank_statement bs
WHERE bs.bank_statement_id = #{bankStatementId}
</select>
<select id="selectFilterOptions" resultMap="CcdiBankStatementFilterOptionsVOResultMap">
SELECT #{projectId} AS project_id
</select>
<select id="selectOurSubjectOptions" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementOptionVO">
SELECT DISTINCT
TRIM(bs.LE_ACCOUNT_NAME) AS label,
TRIM(bs.LE_ACCOUNT_NAME) AS value
FROM ccdi_bank_statement bs
WHERE bs.project_id = #{projectId}
AND bs.LE_ACCOUNT_NAME IS NOT NULL
AND TRIM(bs.LE_ACCOUNT_NAME) != ''
ORDER BY TRIM(bs.LE_ACCOUNT_NAME)
</select>
<select id="selectOurBankOptions" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementOptionVO">
SELECT DISTINCT
TRIM(bs.BANK) AS label,
TRIM(bs.BANK) AS value
FROM ccdi_bank_statement bs
WHERE bs.project_id = #{projectId}
AND bs.BANK IS NOT NULL
AND TRIM(bs.BANK) != ''
ORDER BY TRIM(bs.BANK)
</select>
<select id="selectOurAccountOptions" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementOptionVO">
SELECT DISTINCT
TRIM(bs.LE_ACCOUNT_NO) AS label,
TRIM(bs.LE_ACCOUNT_NO) AS value
FROM ccdi_bank_statement bs
WHERE bs.project_id = #{projectId}
AND bs.LE_ACCOUNT_NO IS NOT NULL
AND TRIM(bs.LE_ACCOUNT_NO) != ''
ORDER BY TRIM(bs.LE_ACCOUNT_NO)
</select>
<insert id="insertBatch" parameterType="java.util.List">
insert into ccdi_bank_statement (
project_id, LE_ID, ACCOUNT_ID, group_id,

View File

@@ -0,0 +1,106 @@
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.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.service.ICcdiBankStatementService;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiBankStatementControllerTest {
@InjectMocks
private CcdiBankStatementController controller;
@Mock
private ICcdiBankStatementService bankStatementService;
@AfterEach
void tearDown() {
RequestContextHolder.resetRequestAttributes();
}
@Test
void options_shouldReturnAjaxResultSuccess() {
when(bankStatementService.getFilterOptions(100L)).thenReturn(new CcdiBankStatementFilterOptionsVO());
AjaxResult result = controller.getOptions(100L);
assertEquals(200, result.get("code"));
}
@Test
void list_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);
CcdiBankStatementListVO row = new CcdiBankStatementListVO();
row.setBankStatementId(1L);
Page<CcdiBankStatementListVO> page = new Page<>(1, 10);
page.setRecords(List.of(row));
page.setTotal(1);
when(bankStatementService.selectStatementPage(any(), same(queryDTO))).thenReturn(page);
TableDataInfo result = controller.list(queryDTO);
assertEquals(200, result.getCode());
assertEquals(1L, result.getTotal());
assertEquals(1, result.getRows().size());
}
@Test
void detail_shouldReturnAjaxResultSuccess() {
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();
detailVO.setBankStatementId(1000L);
when(bankStatementService.getStatementDetail(1000L)).thenReturn(detailVO);
AjaxResult result = controller.getDetail(1000L);
assertEquals(200, result.get("code"));
assertEquals(detailVO, result.get("data"));
}
@Test
void export_shouldWriteExcelResponse() {
MockHttpServletResponse response = new MockHttpServletResponse();
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
CcdiBankStatementExcel row = new CcdiBankStatementExcel();
row.setLeAccountNo("6222");
row.setDisplayAmount(new BigDecimal("10.00"));
when(bankStatementService.selectStatementListForExport(same(queryDTO))).thenReturn(List.of(row));
controller.export(response, queryDTO);
verify(bankStatementService).selectStatementListForExport(same(queryDTO));
assertTrue(response.getContentType().startsWith("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
assertTrue(response.getContentAsByteArray().length > 0);
}
}

View File

@@ -0,0 +1,189 @@
package com.ruoyi.ccdi.project.mapper;
import com.ruoyi.ccdi.project.domain.dto.CcdiBankStatementQueryDTO;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.apache.ibatis.type.TypeAliasRegistry;
import org.junit.jupiter.api.Test;
import javax.sql.DataSource;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiBankStatementMapperXmlTest {
private static final String RESOURCE = "mapper/ccdi/project/CcdiBankStatementMapper.xml";
@Test
void selectStatementPage_shouldKeepWhitespaceBeforeDynamicAndConditions() throws Exception {
Configuration configuration = new Configuration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource()));
registerTypeAliases(configuration.getTypeAliasRegistry());
configuration.getLanguageRegistry().register(XMLLanguageDriver.class);
configuration.addMapper(CcdiBankStatementMapper.class);
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
XMLMapperBuilder xmlMapperBuilder =
new XMLMapperBuilder(inputStream, configuration, RESOURCE, configuration.getSqlFragments());
xmlMapperBuilder.parse();
}
MappedStatement mappedStatement = configuration.getMappedStatement(
"com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper.selectStatementPage");
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(33L);
queryDTO.setTabType("in");
Map<String, Object> params = new HashMap<>();
params.put("query", queryDTO);
BoundSql boundSql = mappedStatement.getBoundSql(params);
String sql = boundSql.getSql().replaceAll("\\s+", " ").trim();
assertFalse(sql.contains("?AND"), sql);
assertTrue(sql.contains("(bs.project_id = ?) AND IFNULL"), sql);
}
@Test
void statementFilterWhere_shouldWrapBaseConditionToAvoidParameterAndCollision() throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
String projectIdLine = Arrays.stream(xml.split("\\R"))
.filter(line -> line.contains("bs.project_id = #{query.projectId}"))
.findFirst()
.orElse("");
assertTrue(xml.contains("AND (bs.project_id = #{query.projectId})"), xml);
assertTrue(projectIdLine.contains("AND (bs.project_id = #{query.projectId})"), projectIdLine);
}
}
@Test
void statementFilterWhere_shouldWrapIncludedCaseExpressionsToAvoidAndCaseCollision() throws Exception {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(xml.contains("AND (<include refid=\"parsedTrxDateExpr\"/>) <![CDATA[ >= ]]>"), xml);
assertTrue(xml.contains("AND (<include refid=\"parsedTrxDateExpr\"/>) <![CDATA[ <= ]]>"), xml);
assertTrue(xml.contains("AND (<include refid=\"absoluteAmountExpr\"/>) <![CDATA[ >= ]]>"), xml);
assertTrue(xml.contains("AND (<include refid=\"absoluteAmountExpr\"/>) <![CDATA[ <= ]]>"), xml);
}
}
@Test
void selectStatementPage_shouldKeepWhitespaceBeforeDateRangeExpressions() throws Exception {
MappedStatement mappedStatement = loadMappedStatement(
"com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper.selectStatementPage");
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(33L);
queryDTO.setTransactionStartTime("2024-01-01 00:00:00");
queryDTO.setTransactionEndTime("2024-01-31 23:59:59");
String sql = renderSql(mappedStatement, queryDTO);
assertFalse(sql.contains("ANDCASE"), sql);
assertTrue(sql.contains("AND ( CASE WHEN bs.TRX_DATE"), sql);
assertTrue(sql.contains("END ) >="), sql);
assertTrue(sql.contains("END ) <="), sql);
}
@Test
void selectStatementPage_shouldKeepWhitespaceBeforeAmountRangeExpressions() throws Exception {
MappedStatement mappedStatement = loadMappedStatement(
"com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper.selectStatementPage");
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(33L);
queryDTO.setAmountMin(java.math.BigDecimal.ONE);
queryDTO.setAmountMax(new java.math.BigDecimal("100"));
String sql = renderSql(mappedStatement, queryDTO);
assertFalse(sql.contains("ANDCASE"), sql);
assertTrue(sql.contains("AND ( CASE WHEN IFNULL(bs.AMOUNT_CR, 0) > 0"), sql);
assertTrue(sql.contains("END ) >= ?"), sql);
assertTrue(sql.contains("END ) <= ?"), sql);
}
private MappedStatement loadMappedStatement(String statementId) throws Exception {
Configuration configuration = new Configuration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource()));
registerTypeAliases(configuration.getTypeAliasRegistry());
configuration.getLanguageRegistry().register(XMLLanguageDriver.class);
configuration.addMapper(CcdiBankStatementMapper.class);
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
XMLMapperBuilder xmlMapperBuilder =
new XMLMapperBuilder(inputStream, configuration, RESOURCE, configuration.getSqlFragments());
xmlMapperBuilder.parse();
}
return configuration.getMappedStatement(statementId);
}
private String renderSql(MappedStatement mappedStatement, CcdiBankStatementQueryDTO queryDTO) {
Map<String, Object> params = new HashMap<>();
params.put("query", queryDTO);
BoundSql boundSql = mappedStatement.getBoundSql(params);
return boundSql.getSql().replaceAll("\\s+", " ").trim();
}
private void registerTypeAliases(TypeAliasRegistry typeAliasRegistry) {
typeAliasRegistry.registerAlias("map", Map.class);
}
private static class NoOpDataSource implements DataSource {
@Override
public java.sql.Connection getConnection() {
throw new UnsupportedOperationException("Not required for SQL rendering tests");
}
@Override
public java.sql.Connection getConnection(String username, String password) {
throw new UnsupportedOperationException("Not required for SQL rendering tests");
}
@Override
public java.io.PrintWriter getLogWriter() {
return null;
}
@Override
public void setLogWriter(java.io.PrintWriter out) {
}
@Override
public void setLoginTimeout(int seconds) {
}
@Override
public int getLoginTimeout() {
return 0;
}
@Override
public java.util.logging.Logger getParentLogger() {
return java.util.logging.Logger.getGlobal();
}
@Override
public <T> T unwrap(Class<T> iface) {
throw new UnsupportedOperationException("Not supported");
}
@Override
public boolean isWrapperFor(Class<?> iface) {
return false;
}
}
}

View File

@@ -0,0 +1,124 @@
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.CcdiBankStatementDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementFilterOptionsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementOptionVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiBankStatementServiceImplTest {
@InjectMocks
private CcdiBankStatementServiceImpl service;
@Mock
private CcdiBankStatementMapper bankStatementMapper;
@Test
void getFilterOptions_shouldReturnProjectWideOptions() {
CcdiBankStatementFilterOptionsVO options = new CcdiBankStatementFilterOptionsVO();
options.setOurSubjectOptions(List.of(new CcdiBankStatementOptionVO("主体A", "主体A")));
when(bankStatementMapper.selectFilterOptions(100L)).thenReturn(options);
CcdiBankStatementFilterOptionsVO result = service.getFilterOptions(100L);
assertEquals(1, result.getOurSubjectOptions().size());
}
@Test
void pageQuery_shouldNormalizeSortFieldAndDirection() {
Page<CcdiBankStatementListVO> page = new Page<>(1, 10);
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(100L);
queryDTO.setOrderBy("amount");
queryDTO.setOrderDirection("desc");
doReturn(page).when(bankStatementMapper).selectStatementPage(eq(page), same(queryDTO));
service.selectStatementPage(page, queryDTO);
verify(bankStatementMapper).selectStatementPage(eq(page), same(queryDTO));
}
@Test
void normalizeQuery_shouldFallbackToSafeDefaults() {
Page<CcdiBankStatementListVO> page = new Page<>(1, 10);
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(100L);
queryDTO.setOrderBy("drop table");
queryDTO.setOrderDirection("sideways");
doReturn(page).when(bankStatementMapper).selectStatementPage(eq(page), same(queryDTO));
service.selectStatementPage(page, queryDTO);
assertEquals("trxDate", queryDTO.getOrderBy());
assertEquals("desc", queryDTO.getOrderDirection());
assertEquals("all", queryDTO.getTabType());
}
@Test
void normalizeQuery_shouldTrimBlankStringsToNull() {
Page<CcdiBankStatementListVO> page = new Page<>(1, 10);
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(100L);
queryDTO.setCounterpartyName(" ");
queryDTO.setUserMemo(" ");
queryDTO.setCounterpartyAccount(" ");
queryDTO.setTransactionType(" ");
doReturn(page).when(bankStatementMapper).selectStatementPage(eq(page), same(queryDTO));
service.selectStatementPage(page, queryDTO);
assertNull(queryDTO.getCounterpartyName());
assertNull(queryDTO.getUserMemo());
assertNull(queryDTO.getCounterpartyAccount());
assertNull(queryDTO.getTransactionType());
}
@Test
void selectStatementListForExport_shouldMapDisplayColumns() {
CcdiBankStatementListVO row = new CcdiBankStatementListVO();
row.setTrxDate("2024-02-01 10:33:44");
row.setLeAccountNo("6222");
row.setLeAccountName("张三");
row.setDisplayAmount(new BigDecimal("-8.00"));
when(bankStatementMapper.selectStatementListForExport(any())).thenReturn(List.of(row));
List<CcdiBankStatementExcel> result = service.selectStatementListForExport(new CcdiBankStatementQueryDTO());
assertEquals(1, result.size());
assertEquals("6222", result.get(0).getLeAccountNo());
}
@Test
void getStatementDetail_shouldDelegateToMapper() {
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();
detailVO.setBankStatementId(200L);
when(bankStatementMapper.selectStatementDetailById(200L)).thenReturn(detailVO);
CcdiBankStatementDetailVO result = service.getStatementDetail(200L);
assertSame(detailVO, result);
}
}

View File

@@ -0,0 +1,413 @@
# Project Detail Transaction Query Backend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build the backend query, filter options, detail, and export endpoints for the project detail bank statement page using local `ccdi_bank_statement` data.
**Architecture:** Extend the existing `ccdi-project` module with a dedicated bank statement controller and service, while reusing the current `CcdiBankStatementMapper` plus XML dynamic SQL. Keep DTO/VO layering strict, compute direction and display amount in the query layer, and export through `ruoyi-common` `ExcelUtil` so the page and export share one query contract.
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, MyBatis XML, Mockito + JUnit 5, RuoYi `ExcelUtil`
---
### Task 1: Scaffold DTO/VO contracts and the first failing service test
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiBankStatementQueryDTO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementOptionVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementFilterOptionsVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementListVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementDetailVO.java`
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiBankStatementService.java`
- Create: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java`
**Step 1: Write the failing test**
```java
@ExtendWith(MockitoExtension.class)
class CcdiBankStatementServiceImplTest {
@InjectMocks
private CcdiBankStatementServiceImpl service;
@Mock
private CcdiBankStatementMapper bankStatementMapper;
@Test
void getFilterOptions_shouldReturnProjectWideOptions() {
CcdiBankStatementFilterOptionsVO options = new CcdiBankStatementFilterOptionsVO();
options.setOurSubjectOptions(List.of(new CcdiBankStatementOptionVO("主体A", "主体A")));
when(bankStatementMapper.selectFilterOptions(100L)).thenReturn(options);
CcdiBankStatementFilterOptionsVO result = service.getFilterOptions(100L);
assertEquals(1, result.getOurSubjectOptions().size());
}
}
```
**Step 2: Run test to verify it fails**
Run: `mvn test -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest`
Expected: FAIL with missing `CcdiBankStatementServiceImpl`, DTO, or mapper methods.
**Step 3: Write minimal implementation**
```java
public interface ICcdiBankStatementService {
CcdiBankStatementFilterOptionsVO getFilterOptions(Long projectId);
}
```
```java
@Data
public class CcdiBankStatementOptionVO {
private String label;
private String value;
public CcdiBankStatementOptionVO(String label, String value) {
this.label = label;
this.value = value;
}
}
```
Add the remaining DTO/VO classes with only the fields already confirmed in the design.
**Step 4: Run test to verify it still fails only on the missing service implementation**
Run: `mvn test -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest`
Expected: FAIL with `CcdiBankStatementServiceImpl` not found or method not implemented.
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiBankStatementQueryDTO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementOptionVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementFilterOptionsVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementListVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiBankStatementDetailVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiBankStatementService.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java
git commit -m "新增流水明细查询后端契约与测试骨架"
```
### Task 2: Implement mapper query methods for options, list, detail, and export
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapper.java`
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java`
**Step 1: Write the failing test**
Add tests that assert the service delegates to mapper methods with normalized arguments:
```java
@Test
void pageQuery_shouldNormalizeSortFieldAndDirection() {
Page<CcdiBankStatementListVO> page = new Page<>(1, 10);
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(100L);
queryDTO.setOrderBy("amount");
queryDTO.setOrderDirection("desc");
service.selectStatementPage(page, queryDTO);
verify(bankStatementMapper).selectStatementPage(eq(page), same(queryDTO));
}
```
**Step 2: Run test to verify it fails**
Run: `mvn test -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest`
Expected: FAIL because mapper methods `selectStatementPage`, `selectStatementListForExport`, `selectStatementDetailById`, `selectFilterOptions` are missing.
**Step 3: Write minimal implementation**
In `CcdiBankStatementMapper.java`, add:
```java
Page<CcdiBankStatementListVO> selectStatementPage(Page<CcdiBankStatementListVO> page,
@Param("query") CcdiBankStatementQueryDTO query);
List<CcdiBankStatementListVO> selectStatementListForExport(@Param("query") CcdiBankStatementQueryDTO query);
CcdiBankStatementDetailVO selectStatementDetailById(@Param("bankStatementId") Long bankStatementId);
CcdiBankStatementFilterOptionsVO selectFilterOptions(@Param("projectId") Long projectId);
```
In `CcdiBankStatementMapper.xml`, add:
- a reusable SQL fragment for common filters
- a reusable expression for parsed transaction time
- a reusable expression for signed display amount
- separate selects for page, export, detail, and distinct project options
**Step 4: Run test to verify it passes or fails only on service logic**
Run: `mvn test -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest`
Expected: mapper-signature errors gone; remaining failures only relate to unimplemented service methods.
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapper.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java
git commit -m "补充流水明细查询Mapper与动态SQL"
```
### Task 3: Implement service normalization, page query, and project-wide filter options
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImpl.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java`
**Step 1: Write the failing test**
Add focused service tests for:
- default `tabType=all`
- invalid `orderBy` falling back to `trxDate`
- invalid `orderDirection` falling back to `desc`
- blank string fields trimming to `null`
```java
@Test
void normalizeQuery_shouldFallbackToSafeDefaults() {
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
queryDTO.setProjectId(100L);
queryDTO.setOrderBy("drop table");
queryDTO.setOrderDirection("sideways");
service.selectStatementPage(new Page<>(1, 10), queryDTO);
assertEquals("trxDate", queryDTO.getOrderBy());
assertEquals("desc", queryDTO.getOrderDirection());
assertEquals("all", queryDTO.getTabType());
}
```
**Step 2: Run test to verify it fails**
Run: `mvn test -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest`
Expected: FAIL because service normalization logic is missing.
**Step 3: Write minimal implementation**
In `CcdiBankStatementServiceImpl.java`:
```java
@Service
public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
@Resource
private CcdiBankStatementMapper bankStatementMapper;
@Override
public Page<CcdiBankStatementListVO> selectStatementPage(Page<CcdiBankStatementListVO> page,
CcdiBankStatementQueryDTO queryDTO) {
normalizeQuery(queryDTO);
return bankStatementMapper.selectStatementPage(page, queryDTO);
}
}
```
Implement `normalizeQuery(queryDTO)` to enforce the white-listed sort fields and normalize blank strings and booleans before delegating to mapper.
**Step 4: Run test to verify it passes**
Run: `mvn test -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest`
Expected: PASS
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java
git commit -m "实现流水明细查询服务层规范化逻辑"
```
### Task 4: Add detail lookup and export model with a failing export test
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiBankStatementExcel.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiBankStatementService.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImpl.java`
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java`
**Step 1: Write the failing test**
Add a service test for export mapping:
```java
@Test
void selectStatementListForExport_shouldMapDisplayColumns() {
CcdiBankStatementListVO row = new CcdiBankStatementListVO();
row.setTrxDate("2024-02-01 10:33:44");
row.setLeAccountNo("6222");
row.setLeAccountName("张三");
row.setDisplayAmount(new BigDecimal("-8.00"));
when(bankStatementMapper.selectStatementListForExport(any())).thenReturn(List.of(row));
List<CcdiBankStatementExcel> result = service.selectStatementListForExport(new CcdiBankStatementQueryDTO());
assertEquals(1, result.size());
assertEquals("6222", result.get(0).getLeAccountNo());
}
```
**Step 2: Run test to verify it fails**
Run: `mvn test -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest`
Expected: FAIL because export model and export service method do not exist.
**Step 3: Write minimal implementation**
Create `CcdiBankStatementExcel.java` with `@Excel` annotations for:
- `trxDate`
- `leAccountNo`
- `leAccountName`
- `customerAccountName`
- `customerAccountNo`
- `userMemo`
- `cashType`
- `displayAmount`
Add service methods:
```java
List<CcdiBankStatementExcel> selectStatementListForExport(CcdiBankStatementQueryDTO queryDTO);
CcdiBankStatementDetailVO getStatementDetail(Long bankStatementId);
```
Map export rows from `CcdiBankStatementListVO` to `CcdiBankStatementExcel`.
**Step 4: Run test to verify it passes**
Run: `mvn test -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest`
Expected: PASS
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiBankStatementExcel.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiBankStatementService.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java
git commit -m "实现流水明细导出模型与详情查询"
```
### Task 5: Add controller endpoints and the first failing controller test
**Files:**
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankStatementController.java`
- Create: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiBankStatementControllerTest.java`
**Step 1: Write the failing test**
```java
@ExtendWith(MockitoExtension.class)
class CcdiBankStatementControllerTest {
@InjectMocks
private CcdiBankStatementController controller;
@Mock
private ICcdiBankStatementService bankStatementService;
@Test
void options_shouldReturnAjaxResultSuccess() {
when(bankStatementService.getFilterOptions(100L)).thenReturn(new CcdiBankStatementFilterOptionsVO());
AjaxResult result = controller.getOptions(100L);
assertEquals(200, result.get("code"));
}
}
```
**Step 2: Run test to verify it fails**
Run: `mvn test -pl ccdi-project -Dtest=CcdiBankStatementControllerTest`
Expected: FAIL because controller class and methods do not exist.
**Step 3: Write minimal implementation**
Add endpoints:
- `GET /ccdi/project/bank-statement/list`
- `GET /ccdi/project/bank-statement/options`
- `GET /ccdi/project/bank-statement/detail/{bankStatementId}`
- `POST /ccdi/project/bank-statement/export`
Use `TableSupport.buildPageRequest()` for paging and `ExcelUtil<CcdiBankStatementExcel>` for export:
```java
ExcelUtil<CcdiBankStatementExcel> util = new ExcelUtil<>(CcdiBankStatementExcel.class);
util.exportExcel(response, list, "流水明细");
```
Guard permissions with:
- query/list/detail/options: `ccdi:project:query`
- export: `ccdi:project:export`
**Step 4: Run test to verify it passes**
Run: `mvn test -pl ccdi-project -Dtest=CcdiBankStatementControllerTest`
Expected: PASS
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankStatementController.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiBankStatementControllerTest.java
git commit -m "新增流水明细查询控制器接口"
```
### Task 6: Verify the backend end-to-end inside the module
**Files:**
- Modify if needed after failures: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankStatementController.java`
- Modify if needed after failures: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImpl.java`
- Modify if needed after failures: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml`
**Step 1: Run the focused backend tests**
Run: `mvn test -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest,CcdiBankStatementControllerTest`
Expected: PASS
**Step 2: Run the module compile**
Run: `mvn clean compile -pl ccdi-project -am`
Expected: BUILD SUCCESS
**Step 3: Smoke-check the mapper XML and endpoint contracts**
Run:
```bash
mvn test -pl ccdi-project -Dtest=CcdiBankStatementServiceImplTest -q
```
Expected: PASS without XML binding errors or missing mapper statements.
**Step 4: Fix any compile or binding failures with the smallest possible patch**
Typical fixes:
- wrong `@Param("query")` names
- missing VO field aliases in SQL
- `Page<>` generic mismatch
- `ExcelUtil` export field annotation issues
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankStatementController.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImpl.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankStatementServiceImplTest.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiBankStatementControllerTest.java
git commit -m "完成流水明细查询后端实现与校验"
```

View File

@@ -0,0 +1,371 @@
# 项目详情流水明细查询设计
## 概述
本次设计面向项目详情页中的“流水明细查询”菜单,按原型图实现“查询 + 筛选 + 导出”能力,不包含旧需求中的“加入分析”“二次分析”等扩展功能。
页面默认查询当前项目下全部已入库流水,数据源仅使用本地表 `ccdi_bank_statement`,不再依赖上传记录的“查看流水”跳转。上传数据页同步移除上传记录表的操作列,后续不再从上传记录进入流水明细页。
## 已确认范围
- 页面入口保留在项目详情页的 `detail` 菜单
- 默认查询范围为当前 `projectId` 下全部流水
- 上传页移除“查看流水”入口
- “导出流水”需要实现真实功能
- 导出范围为当前页面筛选后的全部结果
- 本次只做“查询 + 筛选 + 导出”
- 摘要筛选仅使用 `userMemo`
- 筛选栏中的多选项需要基于整个项目维度单独查询
- 进入页面时即并行加载列表数据和项目级多选项
## 方案对比
### 方案一:本地库专用查询模块
-`ccdi-project` 模块新增银行流水查询 Controller、Service、Mapper 查询方法
- 页面直接查询本地 `ccdi_bank_statement`
- 导出使用本地查询条件复用
优点:
- 与现有“上传后落本地库”架构一致
- 查询与导出结果稳定,可复现
- 后续可继续扩展更多项目级分析能力
缺点:
- 需要补充 DTO、VO、Mapper SQL、导出模型
### 方案二:大而全聚合接口
- 用一个接口同时返回列表、多选项、统计信息
优点:
- 前端调用少
缺点:
- 接口职责混杂
- 后续增加筛选维度时维护成本高
- 导出仍需要单独处理
### 方案三:直接回源 LSFX 接口
- 页面查询与导出实时调用流水分析平台接口
优点:
- 本地查询层改动少
缺点:
- 与现有本地入库方案冲突
- 项目级全量查询稳定性差
- 页面与导出口径难统一
## 选型
采用方案一:本地库专用查询模块。
该方案与当前 `ccdi_bank_statement``CcdiFileUploadServiceImpl` 的入库逻辑一致,最符合“项目维度统一查询 + 当前筛选导出”的实现要求。
## 页面结构
页面仍由 `ruoyi-ui/src/views/ccdiProject/detail.vue` 动态加载 `DetailQuery.vue`
`DetailQuery.vue` 从占位组件升级为正式页面,整体布局分为左右两栏:
- 左侧:固定筛选栏
- 右侧:结果区域
右侧结果区域包含:
- 标题“流水明细查询”
- 顶部页签:`全部` / `流入` / `流出`
- 导出按钮:`导出流水`
- 列表表格
- 分页器
列表中的操作位保留只读详情能力,用于打开详情抽屉或弹窗查看单条流水完整字段。
## 筛选项设计
左侧筛选栏按原型提供以下条件:
1. 交易时间
- 对应字段:`trxDate`
- 支持起止范围查询
- 后端兼容 `yyyy-MM-dd HH:mm:ss``yyyy-MM-dd`
2. 对方名称 + 空值
- 对应字段:`customerAccountName`
- 输入框为模糊匹配
- 勾选空值时匹配 `null`、空串、全空白字符串
3. 摘要 + 空值
- 对应字段:`userMemo`
- 输入框为模糊匹配
- 勾选空值时匹配 `null`、空串、全空白字符串
4. 本方主体
- 对应字段:`leAccountName`
- 多选
5. 本方银行
- 对应字段:`bank`
- 多选
6. 本方账户
- 对应字段:`leAccountNo`
- 多选
7. 交易金额
- 使用绝对值范围筛选
- 统一适配 `全部 / 流入 / 流出` 三个页签
8. 对方账户 + 空值
- 对应字段:`customerAccountNo`
- 输入框为模糊匹配
9. 交易类型 + 空值
- 对应字段:`cashType`
- 输入框为模糊匹配
## 多选项加载规则
本方主体、本方银行、本方账户三类多选项不从当前列表结果派生,而是通过单独接口按整个项目维度去重查询。
进入页面时前端并行加载:
- 列表接口:默认查询当前项目全部流水
- 多选项接口:返回整个项目维度的全部主体、银行、账户选项
多选项接口返回值:
- `ourSubjectOptions`
- `ourBankOptions`
- `ourAccountOptions`
前端多选框内部搜索仅在已加载的项目级选项上做本地过滤,不再触发额外远程请求。
## 列表列设计
表格列与原型语义对齐:
1. 交易时间
- 字段:`trxDate`
2. 本行账户/主体
- 主行:`leAccountNo`
- 副行:`leAccountName`
3. 对方名称/账户
- 主行:`customerAccountName`
- 副行:`customerAccountNo`
4. 摘要/交易类型
- 主行:`userMemo`
- 副行:`cashType`
5. 交易金额
- 流入显示 `+amountCr`
- 流出显示 `-amountDr`
- 页面展示统一输出计算后的 `displayAmount`
6. 操作
- 只读详情
## 页签与排序规则
### 页签规则
- `全部`:不过滤金额方向
- `流入`:仅查询 `amount_cr > 0`
- `流出`:仅查询 `amount_dr > 0`
### 排序规则
支持两个排序字段:
- 交易时间
- 交易金额
排序方向支持:
- 升序
- 降序
交易金额排序按绝对值排序,和金额范围筛选保持一致。
排序字段必须由后端白名单控制,禁止前端任意传值直接拼接 SQL。
## 后端接口设计
建议新增专用 Controller`CcdiBankStatementController`
### 1. 列表分页查询
- 路径:`GET /ccdi/project/bank-statement/list`
- 入参:`CcdiBankStatementQueryDTO`
- 返回:`TableDataInfo`
入参字段包括:
- `projectId`
- `tabType`
- `transactionStartTime`
- `transactionEndTime`
- `counterpartyName`
- `counterpartyNameEmpty`
- `userMemo`
- `userMemoEmpty`
- `ourSubjects`
- `ourBanks`
- `ourAccounts`
- `amountMin`
- `amountMax`
- `counterpartyAccount`
- `counterpartyAccountEmpty`
- `transactionType`
- `transactionTypeEmpty`
- `orderBy`
- `orderDirection`
### 2. 多选项查询
- 路径:`GET /ccdi/project/bank-statement/options`
- 入参:`projectId`
- 返回:`CcdiBankStatementFilterOptionsVO`
### 3. 单条详情
- 路径:`GET /ccdi/project/bank-statement/detail/{bankStatementId}`
- 返回:`CcdiBankStatementDetailVO`
### 4. 导出
- 路径:`POST /ccdi/project/bank-statement/export`
- 入参:复用 `CcdiBankStatementQueryDTO`
- 返回Excel 文件流
## 服务层设计
建议新增:
- `ICcdiBankStatementService`
- `CcdiBankStatementServiceImpl`
职责拆分:
- 列表查询参数校验与标准化
- 排序字段白名单处理
- 时间范围解析与兼容
- 多选项去重查询
- 单条详情查询
- 导出列表查询与导出模型组装
## Mapper 设计
保留现有 `CcdiBankStatementMapper`,在接口和 XML 中新增查询方法,不新增独立 Mapper。
建议补充的方法:
- `selectStatementPage`
- `selectStatementListForExport`
- `selectFilterOptions`
- `selectStatementDetailById`
SQL 规则:
- 基于 `project_id` 做主过滤
- 使用动态 SQL 处理输入框、空值、多选数组
- 用统一表达式计算排序时间字段
- 用统一表达式计算展示金额与金额排序值
## 前端设计
建议新增 API 文件:
- `ruoyi-ui/src/api/ccdiProjectBankStatement.js`
建议改造文件:
- `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
页面初始化流程:
1. 读取 `projectId`
2. 并行调用:
- `list`
- `options`
3. 渲染左侧筛选和右侧列表
交互规则:
- 查询、页签切换、排序切换、分页切换时重置到第一页并刷新列表
- 重置时恢复默认筛选并回到 `全部`
- 导出时使用当前筛选条件导出全部结果
## 导出规则
- 导出范围:当前页面筛选后的全部结果
- 不受当前分页限制
- 导出文件名:`项目名称_流水明细_时间戳.xlsx`
- 导出列与页面列表保持一致:
- 交易时间
- 本方账户
- 本方主体
- 对方名称
- 对方账户
- 摘要
- 交易类型
- 交易金额
- 当前筛选结果为空时,不生成空文件,直接提示“当前条件下无可导出数据”
## 异常处理
- 多选项接口失败:列表仍可查询,筛选项置空并提示可刷新重试
- 列表接口失败:显示错误态并保留当前筛选值
- 详情接口失败:仅阻断详情展示,不影响主页面
- 导出失败:保留筛选条件并提示失败原因
- 项目下无流水数据:显示空态,导出按钮禁用
## 验收标准
### 功能验收
- 进入页面时并行加载列表与项目级多选项
- 默认展示当前项目下全部流水
- 三个页签切换结果正确
- 所有筛选项单独与组合查询均正确
- 空值筛选逻辑正确
- 交易时间兼容两种时间格式
- 金额范围按绝对值筛选正确
- 排序仅支持交易时间与交易金额
- 导出结果与页面筛选口径一致
- 上传页操作列已移除,不再支持查看流水跳转
### 技术验收
- 后端遵循项目 DTO / VO 分层规范
- Controller 使用 Swagger 注释
- 简单查询由 MyBatis Plus + XML 动态 SQL 实现
- 前端 API 独立封装到 `src/api`
- 页面在桌面端和移动端均可正常展示
## 风险与约束
1. `TRX_DATE` 为字符串字段
- 需要在 SQL 或服务层统一解析,保证筛选与排序稳定
2. 历史数据可能存在空字符串与空白字符串混用
- 空值筛选必须统一兼容
3. 项目维度多选项可能数量较大
- 首版先按项目维度一次性加载
- 若实际数据量过大,再考虑远程搜索优化
4. 当前页面不引入旧需求中的二次分析能力
- 后续若扩展,需要新增业务表与关联逻辑

View File

@@ -0,0 +1,411 @@
# Project Detail Transaction Query Frontend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the placeholder `DetailQuery.vue` with the full project detail bank statement query page and remove the obsolete upload-record jump entry.
**Architecture:** Keep the page inside the existing `ccdiProject/detail.vue` dynamic component system. Use one dedicated API module for list, options, detail, and export calls; preload project-wide select options on page entry; keep the entire interaction inside `DetailQuery.vue` plus a minimal cleanup in `UploadData.vue`.
**Tech Stack:** Vue 2.6, Element UI 2.15, Axios request wrapper, existing global `this.download`, `npm run build:prod` + manual smoke validation
---
### Task 1: Replace the placeholder with a typed page shell and make the build fail first
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
- Create: `ruoyi-ui/src/api/ccdiProjectBankStatement.js`
**Step 1: Write the failing verification**
In `DetailQuery.vue`, replace the placeholder text with the final top-level data structure and import the new API module before creating it:
```javascript
import {
listBankStatement,
getBankStatementOptions,
getBankStatementDetail
} from "@/api/ccdiProjectBankStatement";
```
Also add empty methods `getList`, `getOptions`, and `handleExport`.
**Step 2: Run build to verify it fails**
Run: `cd ruoyi-ui && npm run build:prod`
Expected: FAIL because `@/api/ccdiProjectBankStatement` does not exist yet.
**Step 3: Write minimal implementation**
Create `ruoyi-ui/src/api/ccdiProjectBankStatement.js` with:
```javascript
import request from "@/utils/request";
export function listBankStatement(query) {
return request({
url: "/ccdi/project/bank-statement/list",
method: "get",
params: query
});
}
```
Add stubs for:
- `getBankStatementOptions(projectId)`
- `getBankStatementDetail(bankStatementId)`
Do not add export wrapper here; use `this.download` directly in the component.
**Step 4: Run build to verify it passes**
Run: `cd ruoyi-ui && npm run build:prod`
Expected: PASS or fail only on unfinished component template bindings.
**Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue ruoyi-ui/src/api/ccdiProjectBankStatement.js
git commit -m "搭建流水明细查询前端页面骨架"
```
### Task 2: Implement page state, preload list and project-wide select options
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
**Step 1: Write the failing verification**
Add the real component state before wiring the template:
```javascript
data() {
return {
loading: false,
optionsLoading: false,
activeTab: "all",
queryParams: {
projectId: this.projectId,
pageNum: 1,
pageSize: 10,
tabType: "all",
transactionStartTime: "",
transactionEndTime: "",
counterpartyName: "",
counterpartyNameEmpty: false,
userMemo: "",
userMemoEmpty: false,
ourSubjects: [],
ourBanks: [],
ourAccounts: [],
amountMin: "",
amountMax: "",
counterpartyAccount: "",
counterpartyAccountEmpty: false,
transactionType: "",
transactionTypeEmpty: false,
orderBy: "trxDate",
orderDirection: "desc"
},
optionData: {
ourSubjectOptions: [],
ourBankOptions: [],
ourAccountOptions: []
}
};
}
```
Call both `getList()` and `getOptions()` in `created()`.
**Step 2: Run build to verify it fails**
Run: `cd ruoyi-ui && npm run build:prod`
Expected: FAIL because the template has not been updated to use the new reactive state yet, or methods are incomplete.
**Step 3: Write minimal implementation**
Implement:
- `getList()`
- `getOptions()`
- `syncProjectId()`
- `handleQuery()`
- `resetQuery()`
Ensure `created()` and the `projectId` watcher both call:
```javascript
this.queryParams.projectId = this.projectId;
this.getOptions();
this.getList();
```
**Step 4: Run build to verify it passes**
Run: `cd ruoyi-ui && npm run build:prod`
Expected: PASS
**Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue
git commit -m "实现流水明细查询页面初始加载逻辑"
```
### Task 3: Build the left filter panel and local-search multi-selects
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
**Step 1: Write the failing verification**
Replace the old placeholder template with the final left column filter markup using Element UI controls:
- `el-date-picker` for transaction time range
- `el-input` + `el-checkbox` for counterparty name, summary, counterparty account, transaction type
- `el-select multiple filterable collapse-tags` for subject, bank, account
- amount range inputs
Do not wire the search/reset buttons yet.
**Step 2: Run build to verify it fails**
Run: `cd ruoyi-ui && npm run build:prod`
Expected: FAIL because methods or bound state used in the template are incomplete.
**Step 3: Write minimal implementation**
Wire the filter controls to `queryParams` and `dateRange`:
```javascript
watch: {
dateRange(value) {
this.queryParams.transactionStartTime = value && value[0] ? value[0] : "";
this.queryParams.transactionEndTime = value && value[1] ? value[1] : "";
}
}
```
Add the left action buttons:
- `查询`
- `重置`
Add local filter support inside each `el-select` using Element UI `filterable`.
**Step 4: Run build to verify it passes**
Run: `cd ruoyi-ui && npm run build:prod`
Expected: PASS
**Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue
git commit -m "完成流水明细查询筛选栏布局"
```
### Task 4: Build the right result area, tabs, table, pagination, and detail drawer
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
**Step 1: Write the failing verification**
Add the right-side structure:
- top tabs `全部 / 流入 / 流出`
- export button
- `el-table`
- `pagination`
- detail drawer or dialog
Reference the final columns:
- `trxDate`
- `leAccountNo / leAccountName`
- `customerAccountName / customerAccountNo`
- `userMemo / cashType`
- `displayAmount`
- `详情`
**Step 2: Run build to verify it fails**
Run: `cd ruoyi-ui && npm run build:prod`
Expected: FAIL because event handlers like `handleTabChange`, `handleSortChange`, `handleViewDetail`, or `handlePageChange` are missing.
**Step 3: Write minimal implementation**
Implement:
- `handleTabChange(tab)`
- `handleSortChange({ prop, order })`
- `handlePageChange(pageNum)`
- `handleViewDetail(row)`
- `closeDetailDialog()`
Sort mapping:
```javascript
const sortMap = {
trxDate: "trxDate",
displayAmount: "amount"
};
```
Tab mapping:
```javascript
const tabMap = {
all: "all",
in: "in",
out: "out"
};
```
**Step 4: Run build to verify it passes**
Run: `cd ruoyi-ui && npm run build:prod`
Expected: PASS
**Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue
git commit -m "完成流水明细查询结果区与详情展示"
```
### Task 5: Wire export, empty/error states, and responsive styles
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
**Step 1: Write the failing verification**
Add the `导出流水` button click handler without implementation:
```javascript
handleExport() {
// TODO
}
```
Show the button in the UI but keep it clickable.
**Step 2: Run build to verify it fails functionally in manual smoke**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected: BUILD PASS, but manual smoke in browser should show export button with no behavior yet.
**Step 3: Write minimal implementation**
Implement export with the existing global helper:
```javascript
this.download(
"ccdi/project/bank-statement/export",
{ ...this.queryParams },
`${this.projectInfo.projectName || "项目"}_流水明细_${Date.now()}.xlsx`
);
```
Add:
- empty state when `list.length === 0`
- inline error tip when list load fails
- disabled export when total is `0`
- responsive layout styles for mobile widths
**Step 4: Run build and manual smoke verification**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected: BUILD PASS
Manual smoke:
1. Open project detail page
2. Switch to `流水明细查询`
3. Confirm left filters render
4. Confirm export button is disabled on empty data and enabled on non-empty data
**Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue
git commit -m "补齐流水明细查询导出与状态反馈"
```
### Task 6: Remove obsolete upload-page operation column and finish smoke verification
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- Modify if needed: `ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue`
**Step 1: Write the failing verification**
Delete only the old upload-table operation column usage in the template, but do not clean methods yet.
**Step 2: Run build to verify it fails**
Run: `cd ruoyi-ui && npm run build:prod`
Expected: FAIL because `handleViewFlow` or `handleViewError` remains unused or referenced.
**Step 3: Write minimal implementation**
In `UploadData.vue`:
- remove the upload-record operation column entirely
- remove methods:
- `handleViewFlow`
- `handleViewError`
- remove any event payload that tried to change menu to `detail`
Keep upload, polling, and record-list refresh behavior unchanged.
**Step 4: Run final verification**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected: BUILD PASS
Manual smoke:
1. Open one project detail page
2. Confirm `上传数据` 页不再出现“查看流水”入口
3. Confirm `流水明细查询` 可独立查询、筛选、分页、导出
4. Confirm手机宽度下左右布局能够折叠为上下布局
**Step 5: Commit**
```bash
git add ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue ruoyi-ui/src/views/ccdiProject/components/detail/DetailQuery.vue ruoyi-ui/src/api/ccdiProjectBankStatement.js
git commit -m "完成流水明细查询前端实现并移除旧跳转入口"
```

View File

@@ -0,0 +1,26 @@
import request from "@/utils/request";
export function listBankStatement(query) {
return request({
url: "/ccdi/project/bank-statement/list",
method: "get",
params: query,
});
}
export function getBankStatementOptions(projectId) {
return request({
url: "/ccdi/project/bank-statement/options",
method: "get",
params: {
projectId,
},
});
}
export function getBankStatementDetail(bankStatementId) {
return request({
url: `/ccdi/project/bank-statement/detail/${bankStatementId}`,
method: "get",
});
}

View File

@@ -1,13 +1,438 @@
<template>
<div class="detail-query-container">
<div class="placeholder-content">
<i class="el-icon-document"></i>
<p>流水明细查询功能开发中...</p>
<div class="query-page-shell">
<div class="shell-sidebar">
<div class="shell-panel-title">筛选条件</div>
<el-form label-position="top" class="filter-form">
<el-form-item label="交易时间">
<el-date-picker
v-model="dateRange"
class="filter-control"
type="datetimerange"
value-format="yyyy-MM-dd HH:mm:ss"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
unlink-panels
/>
</el-form-item>
<el-form-item label="本方主体">
<el-select
v-model="queryParams.ourSubjects"
class="filter-control"
multiple
filterable
collapse-tags
clearable
:loading="optionsLoading"
placeholder="请选择本方主体"
>
<el-option
v-for="item in optionData.ourSubjectOptions"
:key="`subject-${item.value}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="本方银行">
<el-select
v-model="queryParams.ourBanks"
class="filter-control"
multiple
filterable
collapse-tags
clearable
:loading="optionsLoading"
placeholder="请选择本方银行"
>
<el-option
v-for="item in optionData.ourBankOptions"
:key="`bank-${item.value}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="本方账号">
<el-select
v-model="queryParams.ourAccounts"
class="filter-control"
multiple
filterable
collapse-tags
clearable
:loading="optionsLoading"
placeholder="请选择本方账号"
>
<el-option
v-for="item in optionData.ourAccountOptions"
:key="`account-${item.value}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="金额区间">
<div class="amount-range">
<el-input
v-model="queryParams.amountMin"
placeholder="最小金额"
clearable
/>
<span class="amount-separator">-</span>
<el-input
v-model="queryParams.amountMax"
placeholder="最大金额"
clearable
/>
</div>
</el-form-item>
<el-form-item>
<div class="filter-item-header">
<span class="filter-item-label">对方名称</span>
<el-checkbox
v-model="queryParams.counterpartyNameEmpty"
class="empty-checkbox"
>
匹配空值
</el-checkbox>
</div>
<el-input
v-model="queryParams.counterpartyName"
placeholder="请输入对方名称"
clearable
/>
</el-form-item>
<el-form-item>
<div class="filter-item-header">
<span class="filter-item-label">对方账户</span>
<el-checkbox
v-model="queryParams.counterpartyAccountEmpty"
class="empty-checkbox"
>
匹配空值
</el-checkbox>
</div>
<el-input
v-model="queryParams.counterpartyAccount"
placeholder="请输入对方账户"
clearable
/>
</el-form-item>
<el-form-item>
<div class="filter-item-header">
<span class="filter-item-label">摘要</span>
<el-checkbox v-model="queryParams.userMemoEmpty" class="empty-checkbox">
匹配空值
</el-checkbox>
</div>
<el-input
v-model="queryParams.userMemo"
placeholder="请输入摘要关键字"
clearable
/>
</el-form-item>
<div class="filter-actions">
<el-button size="small" type="primary" @click="handleQuery">查询</el-button>
<el-button size="small" plain @click="resetQuery">重置</el-button>
</div>
</el-form>
</div>
<div class="shell-main">
<div class="shell-header">
<div class="shell-title-group">
<span class="shell-title">流水明细查询</span>
<span class="shell-subtitle">按项目范围查询交易明细并查看详情</span>
</div>
<el-button
size="small"
type="primary"
plain
:disabled="total === 0"
@click="handleExport"
>
导出流水
</el-button>
</div>
<el-tabs v-model="activeTab" class="result-tabs" @tab-click="handleTabChange">
<el-tab-pane label="全部" name="all" />
<el-tab-pane label="流入" name="in" />
<el-tab-pane label="流出" name="out" />
</el-tabs>
<div class="result-card">
<el-alert
v-if="listError"
:closable="false"
class="result-alert"
show-icon
title="流水明细加载失败,请稍后重试"
type="error"
/>
<el-table
v-loading="loading"
:data="list"
border
stripe
class="result-table"
@sort-change="handleSortChange"
>
<template slot="empty">
<el-empty
:image-size="96"
description="当前筛选条件下暂无流水明细"
/>
</template>
<el-table-column
label="交易时间"
prop="trxDate"
min-width="180"
sortable="custom"
/>
<el-table-column label="本方账户" min-width="220">
<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="220">
<template slot-scope="scope">
<div class="multi-line-cell">
<div class="primary-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="240">
<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="交易金额"
prop="displayAmount"
min-width="140"
align="right"
sortable="custom"
>
<template slot-scope="scope">
<span
class="amount-text"
:class="scope.row.displayAmount >= 0 ? 'amount-in' : 'amount-out'"
>
{{ formatAmount(scope.row.displayAmount) }}
</span>
</template>
</el-table-column>
<el-table-column label="详情" width="100" fixed="right" align="center">
<template slot-scope="scope">
<el-button type="text" size="small" @click="handleViewDetail(scope.row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="handlePageChange"
/>
</div>
</div>
</div>
<el-drawer
:visible.sync="detailVisible"
append-to-body
custom-class="detail-drawer"
size="520px"
title="流水详情"
@close="closeDetailDialog"
>
<div v-loading="detailLoading" class="detail-drawer-body">
<div class="detail-section">
<div class="detail-section-title">基础信息</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">流水ID</span>
<span class="detail-value">{{ formatField(detailData.bankStatementId) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">交易时间</span>
<span class="detail-value">{{ formatField(detailData.trxDate) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">币种</span>
<span class="detail-value">{{ formatField(detailData.currency) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">交易类型</span>
<span class="detail-value">{{ formatField(detailData.cashType) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">显示金额</span>
<span class="detail-value">{{ formatAmount(detailData.displayAmount) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">余额</span>
<span class="detail-value">{{ formatAmount(detailData.amountBalance) }}</span>
</div>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">账户信息</div>
<div class="detail-grid">
<div class="detail-item detail-item-full">
<span class="detail-label">本方账户</span>
<span class="detail-value">
{{ formatField(detailData.leAccountName) }} / {{ formatField(detailData.leAccountNo) }}
</span>
</div>
<div class="detail-item detail-item-full">
<span class="detail-label">对方账户</span>
<span class="detail-value">
{{ formatField(detailData.customerAccountName) }} /
{{ formatField(detailData.customerAccountNo) }}
</span>
</div>
<div class="detail-item">
<span class="detail-label">本方银行</span>
<span class="detail-value">{{ formatField(detailData.bank) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">对方银行</span>
<span class="detail-value">{{ formatField(detailData.customerBank) }}</span>
</div>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">补充信息</div>
<div class="detail-grid">
<div class="detail-item detail-item-full">
<span class="detail-label">摘要</span>
<span class="detail-value">{{ formatField(detailData.userMemo) }}</span>
</div>
<div class="detail-item detail-item-full">
<span class="detail-label">银行摘要</span>
<span class="detail-value">{{ formatField(detailData.bankComments) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">银行交易号</span>
<span class="detail-value">{{ formatField(detailData.bankTrxNumber) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">交易方式</span>
<span class="detail-value">{{ formatField(detailData.paymentMethod) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">异常类型</span>
<span class="detail-value">{{ formatField(detailData.exceptionType) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">创建时间</span>
<span class="detail-value">{{ formatDate(detailData.createDate) }}</span>
</div>
</div>
</div>
</div>
</el-drawer>
</div>
</template>
<script>
import { parseTime } from "@/utils/ruoyi";
import {
listBankStatement,
getBankStatementOptions,
getBankStatementDetail,
} from "@/api/ccdiProjectBankStatement";
const TAB_MAP = {
all: "all",
in: "in",
out: "out",
};
const SORT_MAP = {
trxDate: "trxDate",
displayAmount: "amount",
};
const createDefaultQueryParams = (projectId) => ({
projectId,
pageNum: 1,
pageSize: 10,
tabType: "all",
transactionStartTime: "",
transactionEndTime: "",
counterpartyName: "",
counterpartyNameEmpty: false,
userMemo: "",
userMemoEmpty: false,
ourSubjects: [],
ourBanks: [],
ourAccounts: [],
amountMin: "",
amountMax: "",
counterpartyAccount: "",
counterpartyAccountEmpty: false,
orderBy: "trxDate",
orderDirection: "desc",
});
const createEmptyOptionData = () => ({
ourSubjectOptions: [],
ourBankOptions: [],
ourAccountOptions: [],
});
const createEmptyDetailData = () => ({
bankStatementId: "",
trxDate: "",
currency: "",
leAccountNo: "",
leAccountName: "",
customerAccountName: "",
customerAccountNo: "",
customerBank: "",
userMemo: "",
bankComments: "",
bankTrxNumber: "",
bank: "",
cashType: "",
amountBalance: "",
displayAmount: "",
paymentMethod: "",
exceptionType: "",
createDate: "",
});
export default {
name: "DetailQuery",
props: {
@@ -24,28 +449,421 @@ export default {
}),
},
},
data() {
return {
loading: false,
optionsLoading: false,
detailLoading: false,
detailVisible: false,
activeTab: "all",
dateRange: [],
list: [],
total: 0,
listError: "",
detailData: createEmptyDetailData(),
queryParams: createDefaultQueryParams(this.projectId),
optionData: createEmptyOptionData(),
};
},
created() {
this.getList();
this.getOptions();
},
watch: {
dateRange(value) {
this.queryParams.transactionStartTime = value && value[0] ? value[0] : "";
this.queryParams.transactionEndTime = value && value[1] ? value[1] : "";
},
projectId() {
this.syncProjectId();
this.getOptions();
this.getList();
},
},
methods: {
async getList() {
this.syncProjectId();
if (!this.queryParams.projectId) {
this.list = [];
this.total = 0;
this.listError = "";
return;
}
this.loading = true;
try {
const res = await listBankStatement(this.queryParams);
this.list = res.rows || [];
this.total = res.total || 0;
this.listError = "";
} catch (error) {
this.list = [];
this.total = 0;
this.listError = "加载流水明细失败";
console.error("加载流水明细失败", error);
} finally {
this.loading = false;
}
},
async getOptions() {
this.syncProjectId();
if (!this.queryParams.projectId) {
this.optionData = createEmptyOptionData();
return;
}
this.optionsLoading = true;
try {
const res = await getBankStatementOptions(this.queryParams.projectId);
const data = res.data || {};
this.optionData = {
ourSubjectOptions: data.ourSubjectOptions || [],
ourBankOptions: data.ourBankOptions || [],
ourAccountOptions: data.ourAccountOptions || [],
};
} catch (error) {
this.optionData = createEmptyOptionData();
console.error("加载流水筛选项失败", error);
} finally {
this.optionsLoading = false;
}
},
syncProjectId() {
this.queryParams.projectId = this.projectId;
this.queryParams.tabType = TAB_MAP[this.activeTab] || "all";
},
handleQuery() {
this.queryParams.pageNum = 1;
this.syncProjectId();
this.getList();
},
resetQuery() {
this.activeTab = "all";
this.dateRange = [];
this.queryParams = createDefaultQueryParams(this.projectId);
this.syncProjectId();
this.getOptions();
this.getList();
},
handleTabChange(tab) {
const tabName = tab && tab.name ? tab.name : this.activeTab;
this.activeTab = tabName;
this.queryParams.pageNum = 1;
this.queryParams.tabType = TAB_MAP[tabName] || "all";
this.getList();
},
handleSortChange({ prop, order }) {
this.queryParams.orderBy = SORT_MAP[prop] || "trxDate";
this.queryParams.orderDirection = order === "ascending" ? "asc" : "desc";
if (!order) {
this.queryParams.orderBy = "trxDate";
this.queryParams.orderDirection = "desc";
}
this.getList();
},
handlePageChange(pageInfo) {
if (typeof pageInfo === "number") {
this.queryParams.pageNum = pageInfo;
} else {
this.queryParams.pageNum = pageInfo.page;
this.queryParams.pageSize = pageInfo.limit;
}
this.getList();
},
async handleViewDetail(row) {
if (!row || !row.bankStatementId) {
return;
}
this.detailVisible = true;
this.detailLoading = true;
try {
const res = await getBankStatementDetail(row.bankStatementId);
this.detailData = {
...createEmptyDetailData(),
...(res.data || {}),
};
} catch (error) {
this.detailData = createEmptyDetailData();
this.$message.error("加载流水详情失败");
console.error("加载流水详情失败", error);
} finally {
this.detailLoading = false;
}
},
closeDetailDialog() {
this.detailVisible = false;
this.detailData = createEmptyDetailData();
},
handleExport() {
if (this.total === 0) {
return;
}
this.download(
"ccdi/project/bank-statement/export",
{ ...this.queryParams },
`${this.projectInfo.projectName || "项目"}_流水明细_${Date.now()}.xlsx`
);
},
formatField(value) {
return value || "-";
},
formatDate(value) {
return value ? parseTime(value, "{y}-{m}-{d} {h}:{i}:{s}") : "-";
},
formatAmount(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return Number(value).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
},
},
};
</script>
<style lang="scss" scoped>
.detail-query-container {
padding: 40px 20px;
padding: 16px;
background: #fff;
min-height: 400px;
min-height: 480px;
}
.placeholder-content {
text-align: center;
color: #909399;
.query-page-shell {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 12px;
}
i {
font-size: 48px;
margin-bottom: 16px;
.shell-sidebar,
.shell-main {
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fff;
}
.shell-sidebar {
padding: 16px 14px;
}
.shell-main {
padding: 20px;
}
.shell-panel-title,
.shell-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.shell-title-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.shell-subtitle {
font-size: 13px;
color: #909399;
}
.shell-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
.filter-form {
margin-top: 12px;
:deep(.el-form-item) {
margin-bottom: 14px;
}
p {
font-size: 14px;
margin: 0;
:deep(.el-form-item__label) {
padding-bottom: 6px;
line-height: 20px;
font-size: 13px;
}
}
.filter-control {
width: 100%;
}
.filter-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.filter-item-label {
font-size: 13px;
line-height: 20px;
color: #606266;
}
.empty-checkbox {
margin-top: 0;
flex-shrink: 0;
}
:deep(.empty-checkbox .el-checkbox__label) {
padding-left: 4px;
font-size: 12px;
color: #909399;
}
.amount-range {
display: flex;
align-items: center;
gap: 6px;
}
.amount-separator {
color: #909399;
font-size: 13px;
}
.filter-actions {
display: flex;
gap: 8px;
.el-button {
flex: 1;
}
}
.result-tabs {
margin-bottom: 16px;
}
.result-card {
border: 1px solid #ebeef5;
border-radius: 4px;
overflow: hidden;
}
.result-alert {
margin: 16px 16px 0;
}
.result-table {
width: 100%;
}
.multi-line-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.primary-text {
color: #303133;
line-height: 20px;
}
.secondary-text {
color: #909399;
font-size: 12px;
line-height: 18px;
}
.amount-text {
font-weight: 600;
}
.amount-in {
color: #67c23a;
}
.amount-out {
color: #f56c6c;
}
.detail-drawer-body {
padding: 0 4px 24px;
}
.detail-section {
margin-bottom: 20px;
padding: 16px;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.detail-section-title {
margin-bottom: 14px;
font-size: 15px;
font-weight: 600;
color: #303133;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 16px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.detail-item-full {
grid-column: 1 / -1;
}
.detail-label {
font-size: 12px;
color: #909399;
}
.detail-value {
color: #303133;
line-height: 20px;
word-break: break-all;
}
@media (max-width: 992px) {
.query-page-shell {
grid-template-columns: 1fr;
}
.filter-actions {
flex-direction: column;
}
}
@media (max-width: 768px) {
.detail-query-container {
padding: 8px;
}
.shell-sidebar,
.shell-main {
padding: 16px;
}
.shell-header {
flex-direction: column;
}
.result-alert {
margin: 12px 12px 0;
}
.detail-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -27,7 +27,12 @@
<!-- 上传模块 -->
<div class="upload-section">
<div class="upload-cards">
<div v-for="card in uploadCards" :key="card.key" class="upload-card">
<div
v-for="card in uploadCards"
:key="card.key"
class="upload-card"
:class="{ 'is-disabled': card.disabled }"
>
<div class="card-icon">
<i :class="card.icon"></i>
</div>
@@ -38,6 +43,7 @@
:type="card.uploaded ? 'primary' : ''"
:icon="card.uploaded ? 'el-icon-view' : 'el-icon-upload2'"
:plain="!card.uploaded"
:disabled="card.disabled"
@click="handleUploadClick(card.key)"
>
{{ card.btnText }}
@@ -49,21 +55,6 @@
<!-- 文件上传记录列表 -->
<div class="file-list-section">
<div class="list-toolbar">
<div class="filter-group">
<el-select
v-model="queryParams.fileStatus"
placeholder="文件状态"
clearable
@change="loadFileList"
style="width: 150px"
>
<el-option label="上传中" value="uploading"></el-option>
<el-option label="解析中" value="parsing"></el-option>
<el-option label="解析成功" value="parsed_success"></el-option>
<el-option label="解析失败" value="parsed_failed"></el-option>
</el-select>
</div>
<el-button icon="el-icon-refresh" @click="handleManualRefresh">刷新</el-button>
</div>
@@ -92,29 +83,6 @@
</template>
</el-table-column>
<el-table-column prop="uploadUser" label="上传人" width="100"></el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template slot-scope="scope">
<el-button
v-if="scope.row.fileStatus === 'parsed_success'"
type="text"
size="small"
@click="handleViewFlow(scope.row)"
>
查看流水
</el-button>
<el-button
v-if="scope.row.fileStatus === 'parsed_failed'"
type="text"
size="small"
@click="handleViewError(scope.row)"
>
查看错误
</el-button>
<span v-if="scope.row.fileStatus === 'uploading' || scope.row.fileStatus === 'parsing'">
-
</span>
</template>
</el-table-column>
</el-table>
<el-pagination
@@ -365,6 +333,7 @@ export default {
icon: "el-icon-document",
btnText: "上传流水",
uploaded: false,
disabled: false,
},
{
key: "credit",
@@ -373,6 +342,7 @@ export default {
icon: "el-icon-s-data",
btnText: "上传征信",
uploaded: false,
disabled: true,
},
{
key: "namelist",
@@ -381,6 +351,7 @@ export default {
icon: "el-icon-s-order",
btnText: "选择名单",
uploaded: false,
disabled: true,
},
],
// 质量指标
@@ -422,9 +393,8 @@ export default {
listLoading: false,
queryParams: {
projectId: null,
fileStatus: null,
pageNum: 1,
pageSize: 20,
pageSize: 10,
},
total: 0,
@@ -544,7 +514,7 @@ export default {
/** 上传卡片点击 */
handleUploadClick(key) {
const card = this.uploadCards.find((c) => c.key === key);
if (!card) return;
if (!card || card.disabled) return;
if (key === "transaction") {
// 流水导入 - 打开批量上传弹窗
@@ -881,7 +851,6 @@ export default {
try {
const params = {
projectId: this.projectId,
fileStatus: this.queryParams.fileStatus,
pageNum: this.queryParams.pageNum,
pageSize: this.queryParams.pageSize,
};
@@ -955,23 +924,6 @@ export default {
this.loadFileList();
},
/** 查看流水 */
handleViewFlow(record) {
this.$emit("menu-change", {
key: "detail",
route: "detail",
params: { logId: record.logId },
});
},
/** 查看错误 */
handleViewError(record) {
this.$alert(record.errorMessage || "未知错误", "错误信息", {
confirmButtonText: "确定",
type: "error",
});
},
/** 状态文本映射 */
getStatusText(status) {
const map = {
@@ -1160,6 +1112,23 @@ export default {
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
}
&.is-disabled {
background-color: #fafafa;
border-color: #ebeef5;
opacity: 0.7;
&:hover {
border-color: #ebeef5;
box-shadow: none;
}
.card-icon,
.card-title,
.card-desc {
color: #c0c4cc;
}
}
.card-icon {
font-size: 32px;
color: #1890ff;
@@ -1291,14 +1260,9 @@ export default {
.list-toolbar {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
margin-bottom: 16px;
.filter-group {
display: flex;
gap: 12px;
}
}
}

View File

@@ -0,0 +1,78 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const componentPath = path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/DetailQuery.vue"
);
const source = fs.readFileSync(componentPath, "utf8");
[
["对方名称", "queryParams.counterpartyNameEmpty"],
["摘要", "queryParams.userMemoEmpty"],
["对方账户", "queryParams.counterpartyAccountEmpty"],
].forEach(([label, model]) => {
const inlineTogglePattern = new RegExp(
`<el-form-item>[\\s\\S]*?<div class="filter-item-header">[\\s\\S]*?<span class="filter-item-label">${label}</span>[\\s\\S]*?<el-checkbox[\\s\\S]*?v-model="${model}"[\\s\\S]*?class="empty-checkbox"[\\s\\S]*?>[\\s\\S]*?匹配空值[\\s\\S]*?</el-checkbox>[\\s\\S]*?</div>`,
"m"
);
assert(
inlineTogglePattern.test(source),
`${label}筛选项应将匹配空值放到标题同一行`
);
});
const filterOrder = [
'label="交易时间"',
'label="本方主体"',
'label="本方银行"',
'label="本方账号"',
'label="金额区间"',
'<span class="filter-item-label">对方名称</span>',
'<span class="filter-item-label">对方账户</span>',
'<span class="filter-item-label">摘要</span>',
];
let lastIndex = -1;
filterOrder.forEach((token) => {
const currentIndex = source.indexOf(token);
assert.notStrictEqual(currentIndex, -1, `未找到筛选项标识: ${token}`);
assert(
currentIndex > lastIndex,
`筛选项顺序不正确,${token} 应出现在前一项之后`
);
lastIndex = currentIndex;
});
assert(
!source.includes('placeholder="请输入交易类型"'),
"筛选栏不应再显示交易类型输入框"
);
assert(
!source.includes("queryParams.transactionType"),
"筛选逻辑不应再保留交易类型参数"
);
assert(
!source.includes("queryParams.transactionTypeEmpty"),
"筛选逻辑不应再保留交易类型空值匹配参数"
);
assert(
/\.filter-item-header\s*\{[\s\S]*display:\s*flex;[\s\S]*justify-content:\s*space-between;[\s\S]*margin-bottom:\s*6px;/m.test(
source
),
"筛选项标题行应使用紧凑的横向布局"
);
assert(
/\.filter-form\s*\{[\s\S]*margin-top:\s*12px;[\s\S]*:deep\(.el-form-item\)\s*\{[\s\S]*margin-bottom:\s*14px;/m.test(
source
),
"筛选表单应压缩顶部和表单项间距"
);
console.log("detail-query-filter-layout test passed");

View File

@@ -0,0 +1,35 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const componentPath = path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/UploadData.vue"
);
const source = fs.readFileSync(componentPath, "utf8");
assert(
/<el-button[\s\S]*?:disabled="card\.disabled"[\s\S]*?@click="handleUploadClick\(card\.key\)"/.test(
source
),
"上传卡片按钮应绑定禁用状态"
);
assert(
/key:\s*"credit"[\s\S]*?disabled:\s*true/.test(source),
"征信导入卡片应配置为禁用"
);
assert(
/key:\s*"namelist"[\s\S]*?disabled:\s*true/.test(source),
"名单库选择卡片应配置为禁用"
);
assert(
/handleUploadClick\(key\)\s*\{[\s\S]*?if\s*\(!card\s*\|\|\s*card\.disabled\)\s*return;/.test(
source
),
"禁用卡片点击后不应继续执行上传逻辑"
);
console.log("upload-data-disabled-cards test passed");

View File

@@ -0,0 +1,26 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const componentPath = path.resolve(
__dirname,
"../../src/views/ccdiProject/components/detail/UploadData.vue"
);
const source = fs.readFileSync(componentPath, "utf8");
const fileListSectionMatch = source.match(
/<div class="file-list-section">([\s\S]*?)<el-table/
);
assert(fileListSectionMatch, "未找到上传文件列表区域");
assert(
!/<el-select[\s\S]*?queryParams\.fileStatus/.test(fileListSectionMatch[1]),
"上传文件列表工具栏不应再显示上传状态筛选框"
);
assert(
/queryParams:\s*\{[\s\S]*?pageSize:\s*10\b/.test(source),
"上传文件列表分页默认每页应为 10 条"
);
console.log("upload-data-file-list-settings test passed");