修复流水异常标签展示与导出
This commit is contained in:
@@ -41,6 +41,10 @@ public class CcdiBankStatementExcel {
|
||||
@Excel(name = "交易类型")
|
||||
private String cashType;
|
||||
|
||||
/** 异常标签 */
|
||||
@Excel(name = "异常标签")
|
||||
private String hitTags;
|
||||
|
||||
/** 交易金额 */
|
||||
@Excel(name = "交易金额")
|
||||
private BigDecimal displayAmount;
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.ruoyi.ccdi.project.domain.vo;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 流水明细详情VO
|
||||
@@ -96,4 +98,7 @@ public class CcdiBankStatementDetailVO {
|
||||
|
||||
/** 原始文件上传时间 */
|
||||
private Date uploadTime;
|
||||
|
||||
/** 命中异常标签 */
|
||||
private List<CcdiBankStatementHitTagVO> hitTags = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 流水命中异常标签VO
|
||||
*/
|
||||
@Data
|
||||
public class CcdiBankStatementHitTagVO {
|
||||
|
||||
/** 规则编码 */
|
||||
private String ruleCode;
|
||||
|
||||
/** 规则名称 */
|
||||
private String ruleName;
|
||||
|
||||
/** 风险等级 */
|
||||
private String riskLevel;
|
||||
|
||||
/** 命中原因 */
|
||||
private String reasonDetail;
|
||||
|
||||
/** 流水ID */
|
||||
private Long bankStatementId;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.ruoyi.ccdi.project.domain.vo;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 流水明细列表VO
|
||||
@@ -38,4 +40,7 @@ public class CcdiBankStatementListVO {
|
||||
|
||||
/** 页面展示金额 */
|
||||
private BigDecimal displayAmount;
|
||||
|
||||
/** 命中异常标签 */
|
||||
private List<CcdiBankStatementHitTagVO> hitTags = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ruoyi.ccdi.project.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagResult;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
@@ -20,6 +21,18 @@ public interface CcdiBankTagResultMapper extends BaseMapper<CcdiBankTagResult> {
|
||||
*/
|
||||
int deleteByProjectAndModel(@Param("projectId") Long projectId, @Param("modelCode") String modelCode);
|
||||
|
||||
/**
|
||||
* 按项目和流水ID批量查询命中的异常标签
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @param bankStatementIds 流水ID列表
|
||||
* @return 命中的异常标签列表
|
||||
*/
|
||||
List<CcdiBankStatementHitTagVO> selectStatementTagsByProjectAndStatementIds(
|
||||
@Param("projectId") Long projectId,
|
||||
@Param("bankStatementIds") List<Long> bankStatementIds
|
||||
);
|
||||
|
||||
/**
|
||||
* 批量插入结果
|
||||
*
|
||||
|
||||
@@ -5,16 +5,21 @@ 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.CcdiBankStatementHitTagVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiBankStatementService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -31,6 +36,9 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
|
||||
@Resource
|
||||
private CcdiBankStatementMapper bankStatementMapper;
|
||||
|
||||
@Resource
|
||||
private CcdiBankTagResultMapper bankTagResultMapper;
|
||||
|
||||
@Override
|
||||
public CcdiBankStatementFilterOptionsVO getFilterOptions(Long projectId) {
|
||||
CcdiBankStatementFilterOptionsVO options = bankStatementMapper.selectFilterOptions(projectId);
|
||||
@@ -42,7 +50,9 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
|
||||
CcdiBankStatementQueryDTO queryDTO) {
|
||||
CcdiBankStatementQueryDTO normalizedQuery = queryDTO == null ? new CcdiBankStatementQueryDTO() : queryDTO;
|
||||
normalizeQuery(normalizedQuery);
|
||||
return bankStatementMapper.selectStatementPage(page, normalizedQuery);
|
||||
Page<CcdiBankStatementListVO> result = bankStatementMapper.selectStatementPage(page, normalizedQuery);
|
||||
attachHitTags(result == null ? Collections.emptyList() : result.getRecords(), normalizedQuery.getProjectId());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -53,12 +63,58 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
attachHitTags(rows, normalizedQuery.getProjectId());
|
||||
return rows.stream().map(this::toExcel).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public CcdiBankStatementDetailVO getStatementDetail(Long bankStatementId) {
|
||||
return bankStatementMapper.selectStatementDetailById(bankStatementId);
|
||||
CcdiBankStatementDetailVO detail = bankStatementMapper.selectStatementDetailById(bankStatementId);
|
||||
if (detail == null || detail.getProjectId() == null || detail.getBankStatementId() == null) {
|
||||
return detail;
|
||||
}
|
||||
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(
|
||||
detail.getProjectId(),
|
||||
List.of(detail.getBankStatementId())
|
||||
);
|
||||
detail.setHitTags(new ArrayList<>(hitTagMap.getOrDefault(detail.getBankStatementId(), Collections.emptyList())));
|
||||
return detail;
|
||||
}
|
||||
|
||||
private void attachHitTags(List<CcdiBankStatementListVO> rows, Long projectId) {
|
||||
if (rows == null || rows.isEmpty() || projectId == null) {
|
||||
return;
|
||||
}
|
||||
List<Long> bankStatementIds = rows.stream()
|
||||
.map(CcdiBankStatementListVO::getBankStatementId)
|
||||
.filter(item -> item != null)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (bankStatementIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, List<CcdiBankStatementHitTagVO>> hitTagMap = loadHitTagMap(projectId, bankStatementIds);
|
||||
rows.forEach(row -> row.setHitTags(new ArrayList<>(
|
||||
hitTagMap.getOrDefault(row.getBankStatementId(), Collections.emptyList())
|
||||
)));
|
||||
}
|
||||
|
||||
private Map<Long, List<CcdiBankStatementHitTagVO>> loadHitTagMap(Long projectId, List<Long> bankStatementIds) {
|
||||
if (projectId == null || bankStatementIds == null || bankStatementIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<CcdiBankStatementHitTagVO> hitTags =
|
||||
bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(projectId, bankStatementIds);
|
||||
if (hitTags == null || hitTags.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return hitTags.stream()
|
||||
.filter(item -> item.getBankStatementId() != null)
|
||||
.collect(Collectors.groupingBy(
|
||||
CcdiBankStatementHitTagVO::getBankStatementId,
|
||||
LinkedHashMap::new,
|
||||
Collectors.toList()
|
||||
));
|
||||
}
|
||||
|
||||
private void normalizeQuery(CcdiBankStatementQueryDTO queryDTO) {
|
||||
@@ -138,7 +194,19 @@ public class CcdiBankStatementServiceImpl implements ICcdiBankStatementService {
|
||||
excel.setCustomerAccountNo(row.getCustomerAccountNo());
|
||||
excel.setUserMemo(row.getUserMemo());
|
||||
excel.setCashType(row.getCashType());
|
||||
excel.setHitTags(formatHitTags(row.getHitTags()));
|
||||
excel.setDisplayAmount(row.getDisplayAmount());
|
||||
return excel;
|
||||
}
|
||||
|
||||
private String formatHitTags(List<CcdiBankStatementHitTagVO> hitTags) {
|
||||
if (hitTags == null || hitTags.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return hitTags.stream()
|
||||
.map(CcdiBankStatementHitTagVO::getRuleName)
|
||||
.filter(item -> item != null && !item.isBlank())
|
||||
.distinct()
|
||||
.collect(Collectors.joining("、"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<result property="remark" column="remark"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="CcdiBankStatementHitTagVOResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementHitTagVO">
|
||||
<result property="ruleCode" column="rule_code"/>
|
||||
<result property="ruleName" column="rule_name"/>
|
||||
<result property="riskLevel" column="risk_level"/>
|
||||
<result property="reasonDetail" column="reason_detail"/>
|
||||
<result property="bankStatementId" column="bank_statement_id"/>
|
||||
</resultMap>
|
||||
|
||||
<delete id="deleteByProjectAndModel">
|
||||
delete from ccdi_bank_statement_tag_result
|
||||
where project_id = #{projectId}
|
||||
@@ -37,6 +45,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
</if>
|
||||
</delete>
|
||||
|
||||
<select id="selectStatementTagsByProjectAndStatementIds" resultMap="CcdiBankStatementHitTagVOResultMap">
|
||||
select
|
||||
rule_code,
|
||||
rule_name,
|
||||
risk_level,
|
||||
reason_detail,
|
||||
bank_statement_id
|
||||
from ccdi_bank_statement_tag_result
|
||||
where project_id = #{projectId}
|
||||
and bank_statement_id is not null
|
||||
and bank_statement_id IN
|
||||
<foreach collection="bankStatementIds" item="item" open="(" separator="," close=")">
|
||||
#{item}
|
||||
</foreach>
|
||||
order by bank_statement_id asc, id asc
|
||||
</select>
|
||||
|
||||
<insert id="insertBatch" parameterType="java.util.List">
|
||||
insert into ccdi_bank_statement_tag_result (
|
||||
project_id, model_code, model_name, rule_code, rule_name, indicator_code,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
class CcdiBankStatementHitTagsContractTest {
|
||||
|
||||
@Test
|
||||
void listVo_shouldExposeHitTagsField() throws Exception {
|
||||
Field field = CcdiBankStatementListVO.class.getDeclaredField("hitTags");
|
||||
|
||||
assertNotNull(field, "流水列表VO应返回命中异常标签");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailVo_shouldExposeHitTagsField() throws Exception {
|
||||
Field field = CcdiBankStatementDetailVO.class.getDeclaredField("hitTags");
|
||||
|
||||
assertNotNull(field, "流水详情VO应返回命中异常标签");
|
||||
}
|
||||
|
||||
@Test
|
||||
void excelVo_shouldExposeHitTagsField() throws Exception {
|
||||
Field field = com.ruoyi.ccdi.project.domain.excel.CcdiBankStatementExcel.class.getDeclaredField("hitTags");
|
||||
|
||||
assertNotNull(field, "流水导出对象应返回异常标签文本列");
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package com.ruoyi.ccdi.project.mapper;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class CcdiBankTagResultMapperXmlTest {
|
||||
@@ -20,4 +23,25 @@ class CcdiBankTagResultMapperXmlTest {
|
||||
assertTrue(xml.contains("model_code = #{modelCode}"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapper_shouldExposeStatementTagQueryForBankStatementDetails() {
|
||||
Method method = Arrays.stream(CcdiBankTagResultMapper.class.getDeclaredMethods())
|
||||
.filter(item -> "selectStatementTagsByProjectAndStatementIds".equals(item.getName()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
assertNotNull(method, "应提供按项目和流水ID批量查询异常标签的方法");
|
||||
}
|
||||
|
||||
@Test
|
||||
void xml_shouldDefineStatementTagQueryForBankStatementDetails() throws Exception {
|
||||
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
|
||||
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(xml.contains("selectStatementTagsByProjectAndStatementIds"), xml);
|
||||
assertTrue(xml.contains("bank_statement_id IN"), xml);
|
||||
assertTrue(xml.contains("project_id = #{projectId}"), xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ 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.CcdiBankStatementHitTagVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementOptionVO;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
@@ -36,6 +38,9 @@ class CcdiBankStatementServiceImplTest {
|
||||
@Mock
|
||||
private CcdiBankStatementMapper bankStatementMapper;
|
||||
|
||||
@Mock
|
||||
private CcdiBankTagResultMapper bankTagResultMapper;
|
||||
|
||||
@Test
|
||||
void getFilterOptions_shouldReturnProjectWideOptions() {
|
||||
CcdiBankStatementFilterOptionsVO options = new CcdiBankStatementFilterOptionsVO();
|
||||
@@ -54,6 +59,7 @@ class CcdiBankStatementServiceImplTest {
|
||||
queryDTO.setProjectId(100L);
|
||||
queryDTO.setOrderBy("amount");
|
||||
queryDTO.setOrderDirection("desc");
|
||||
page.setRecords(List.of(new CcdiBankStatementListVO()));
|
||||
doReturn(page).when(bankStatementMapper).selectStatementPage(eq(page), same(queryDTO));
|
||||
|
||||
service.selectStatementPage(page, queryDTO);
|
||||
@@ -111,14 +117,66 @@ class CcdiBankStatementServiceImplTest {
|
||||
assertEquals("6222", result.get(0).getLeAccountNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectStatementListForExport_shouldIncludeHitTagNames() {
|
||||
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
|
||||
queryDTO.setProjectId(43L);
|
||||
CcdiBankStatementListVO row = new CcdiBankStatementListVO();
|
||||
row.setBankStatementId(51274L);
|
||||
row.setLeAccountNo("6222");
|
||||
when(bankStatementMapper.selectStatementListForExport(same(queryDTO))).thenReturn(List.of(row));
|
||||
|
||||
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
|
||||
hitTag.setBankStatementId(51274L);
|
||||
hitTag.setRuleName("大额存现交易");
|
||||
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L)))
|
||||
.thenReturn(List.of(hitTag));
|
||||
|
||||
List<CcdiBankStatementExcel> result = service.selectStatementListForExport(queryDTO);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("大额存现交易", result.get(0).getHitTags());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectStatementPage_shouldAttachHitTags() {
|
||||
Page<CcdiBankStatementListVO> page = new Page<>(1, 10);
|
||||
CcdiBankStatementQueryDTO queryDTO = new CcdiBankStatementQueryDTO();
|
||||
queryDTO.setProjectId(43L);
|
||||
CcdiBankStatementListVO row = new CcdiBankStatementListVO();
|
||||
row.setBankStatementId(51274L);
|
||||
page.setRecords(List.of(row));
|
||||
doReturn(page).when(bankStatementMapper).selectStatementPage(eq(page), same(queryDTO));
|
||||
|
||||
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
|
||||
hitTag.setBankStatementId(51274L);
|
||||
hitTag.setRuleCode("LARGE_CASH_DEPOSIT");
|
||||
hitTag.setRuleName("大额存现交易");
|
||||
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(51274L)))
|
||||
.thenReturn(List.of(hitTag));
|
||||
|
||||
Page<CcdiBankStatementListVO> result = service.selectStatementPage(page, queryDTO);
|
||||
|
||||
assertEquals(1, result.getRecords().get(0).getHitTags().size());
|
||||
assertEquals("LARGE_CASH_DEPOSIT", result.getRecords().get(0).getHitTags().get(0).getRuleCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStatementDetail_shouldDelegateToMapper() {
|
||||
CcdiBankStatementDetailVO detailVO = new CcdiBankStatementDetailVO();
|
||||
detailVO.setBankStatementId(200L);
|
||||
detailVO.setProjectId(43L);
|
||||
when(bankStatementMapper.selectStatementDetailById(200L)).thenReturn(detailVO);
|
||||
CcdiBankStatementHitTagVO hitTag = new CcdiBankStatementHitTagVO();
|
||||
hitTag.setBankStatementId(200L);
|
||||
hitTag.setRuleName("大额存现交易");
|
||||
when(bankTagResultMapper.selectStatementTagsByProjectAndStatementIds(43L, List.of(200L)))
|
||||
.thenReturn(List.of(hitTag));
|
||||
|
||||
CcdiBankStatementDetailVO result = service.getStatementDetail(200L);
|
||||
|
||||
assertSame(detailVO, result);
|
||||
assertEquals(1, result.getHitTags().size());
|
||||
assertEquals("大额存现交易", result.getHitTags().get(0).getRuleName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# 流水明细异常标签后端实施记录
|
||||
|
||||
## 修改内容
|
||||
- 流水列表/详情返回异常标签
|
||||
- 导出流水返回异常标签列
|
||||
- 流水标签结果批量查询能力
|
||||
- 后端单元测试补充
|
||||
|
||||
## 实施说明
|
||||
- 新增 `CcdiBankStatementHitTagVO`,统一承载流水命中的异常标签编码、名称、风险等级和命中原因。
|
||||
- 在 `CcdiBankStatementListVO`、`CcdiBankStatementDetailVO` 中新增 `hitTags` 字段,作为流水列表页和详情弹窗的统一返回结构。
|
||||
- 在 `CcdiBankStatementExcel` 中新增“异常标签”导出列,导出时将命中的标签名称按顺序拼接为文本。
|
||||
- 在 `CcdiBankTagResultMapper` 中新增按 `projectId + bankStatementIds` 批量查询命中标签的方法,并在 XML 中补充对应 SQL。
|
||||
- 在 `CcdiBankStatementServiceImpl` 中补充标签挂载逻辑:
|
||||
- 列表查询完成后,按当前页流水 ID 批量回查标签并分组挂载到每条记录。
|
||||
- 详情查询完成后,按当前流水 ID 回查标签并挂载到详情对象。
|
||||
- 导出查询完成后,复用同一套标签回查逻辑,将命中标签名称写入 Excel 导出对象。
|
||||
- 本次未改动流水标签判定规则,只修复“标签结果已入库但接口未返回”的数据链路缺口。
|
||||
|
||||
## 问题定位结论
|
||||
- 数据库中 `project_id = 43`、摘要为 `ATM现金存款` 的流水记录已存在 `LARGE_CASH_DEPOSIT` 等命中结果。
|
||||
- 原因是 `ccdi_bank_statement` 列表接口与详情接口此前未将 `ccdi_bank_statement_tag_result` 的命中标签组装到返回 VO 中,导致前端无法展示。
|
||||
|
||||
## 验证执行
|
||||
- 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankTagResultMapperXmlTest,CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest`,结果通过。
|
||||
- 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest,CcdiBankStatementControllerTest`,结果通过。
|
||||
@@ -0,0 +1,22 @@
|
||||
# 流水明细异常标签前端实施记录
|
||||
|
||||
## 修改内容
|
||||
- 列表异常标签列
|
||||
- 详情异常标签模块
|
||||
- 静态测试与构建验证
|
||||
|
||||
## 实施说明
|
||||
- 在 `DetailQuery.vue` 的列表表格中新增“异常标签”列,仅展示 `hitTags` 中的标签名称,并根据风险等级映射标签颜色。
|
||||
- 在详情弹窗中新增“命中异常标签”模块,展示标签名称、风险等级中文文案和命中原因摘要。
|
||||
- 列表接口返回值与详情接口返回值统一对 `hitTags` 做数组归一化,避免后端返回 `null` 时影响空态展示。
|
||||
- 为空标签场景补充统一空态文案,保持列表和详情展示一致。
|
||||
|
||||
## 验证执行
|
||||
- 执行 `node tests/unit/detail-query-filter-layout.test.js`,结果通过。
|
||||
- 执行 `node tests/unit/detail-query-detail-dialog.test.js`,结果通过。
|
||||
- 执行 `node tests/unit/detail-query-hit-tags-list.test.js`,结果通过。
|
||||
- 执行 `npm run build:prod`,结果通过,存在项目既有体积告警,但无新增编译错误。
|
||||
|
||||
## 接口说明
|
||||
- 本次未修改 `ruoyi-ui/src/api/ccdiProjectBankStatement.js`。
|
||||
- 原因:继续复用现有列表与详情接口,仅消费后端扩展后的响应数据结构,不新增前端请求入口。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 流水明细异常标签后端验证记录
|
||||
|
||||
## 验证范围
|
||||
- 流水列表返回 `hitTags`
|
||||
- 流水详情返回 `hitTags`
|
||||
- 导出流水返回异常标签列
|
||||
- 标签结果 Mapper 查询能力
|
||||
- 已入库标签结果的链路核对
|
||||
|
||||
## 数据核对
|
||||
- 2026-03-19 查询 `ccdi_bank_statement`:`project_id = 43` 下存在摘要为 `ATM现金存款` 的流水记录 `bank_statement_id=51274`、`bank_statement_id=49342`。
|
||||
- 2026-03-19 查询 `ccdi_bank_statement_tag_result`:上述两条流水均存在 `LARGE_CASH_DEPOSIT`、`SINGLE_LARGE_INCOME` 命中结果。
|
||||
|
||||
## 自动验证
|
||||
- 2026-03-19 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankTagResultMapperXmlTest,CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest`,结果:通过
|
||||
- 2026-03-19 执行 `mvn -pl ccdi-project test -Dtest=CcdiBankStatementHitTagsContractTest,CcdiBankStatementServiceImplTest,CcdiBankStatementControllerTest`,结果:通过
|
||||
|
||||
## 验证结果
|
||||
- [x] 列表查询结果可挂载命中异常标签
|
||||
- [x] 详情查询结果可挂载命中异常标签
|
||||
- [x] 导出对象包含“异常标签”文本列
|
||||
- [x] 标签结果 Mapper 支持按项目和流水 ID 批量回查
|
||||
- [x] `ATM现金存款` 对应流水的标签数据已在库内存在
|
||||
|
||||
## 联调建议
|
||||
- 前端刷新 `project_id=43` 的流水明细页面后,重点核对 `bank_statement_id=51274`、`bank_statement_id=49342` 是否显示“大额存现交易”等异常标签。
|
||||
- 使用相同筛选条件执行“导出流水”,核对导出文件中的“异常标签”列是否包含“大额存现交易”等标签名称。
|
||||
@@ -0,0 +1,22 @@
|
||||
# 流水明细异常标签前端验证记录
|
||||
|
||||
## 验证范围
|
||||
- 列表异常标签列
|
||||
- 详情异常标签模块
|
||||
- 空态展示
|
||||
- 导出入口回归
|
||||
|
||||
## 自动验证
|
||||
- 2026-03-19 执行 `node tests/unit/detail-query-filter-layout.test.js`,结果:通过
|
||||
- 2026-03-19 执行 `node tests/unit/detail-query-detail-dialog.test.js`,结果:通过
|
||||
- 2026-03-19 执行 `node tests/unit/detail-query-hit-tags-list.test.js`,结果:通过
|
||||
- 2026-03-19 执行 `npm run build:prod`,结果:通过
|
||||
|
||||
## 验证结果
|
||||
- [x] 列表显示命中标签名称
|
||||
- [x] 详情显示名称、风险等级、命中原因摘要
|
||||
- [x] 无标签时显示空态
|
||||
- [ ] 导出入口仍可触发下载
|
||||
|
||||
## 联调待确认
|
||||
- 点击“导出流水”按钮后是否仍能正常触发下载
|
||||
@@ -227,6 +227,22 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="异常标签" min-width="220">
|
||||
<template slot-scope="scope">
|
||||
<div v-if="scope.row.hitTags && scope.row.hitTags.length" class="hit-tag-list">
|
||||
<el-tag
|
||||
v-for="(tag, index) in scope.row.hitTags"
|
||||
:key="`${scope.row.bankStatementId}-tag-${index}`"
|
||||
size="mini"
|
||||
:type="mapRiskLevelToTagType(tag.riskLevel)"
|
||||
effect="plain"
|
||||
>
|
||||
{{ tag.ruleName }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="empty-text">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="交易金额"
|
||||
prop="displayAmount"
|
||||
@@ -340,6 +356,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-hit-tag-section">
|
||||
<div class="detail-section-title">命中异常标签</div>
|
||||
<div
|
||||
v-if="detailData.hitTags && detailData.hitTags.length"
|
||||
class="detail-hit-tag-items"
|
||||
>
|
||||
<div
|
||||
v-for="(tag, index) in detailData.hitTags"
|
||||
:key="`detail-tag-${index}`"
|
||||
class="detail-hit-tag-item"
|
||||
>
|
||||
<div class="detail-hit-tag-header">
|
||||
<span class="detail-hit-tag-name">{{ formatField(tag.ruleName) }}</span>
|
||||
<el-tag size="mini" :type="mapRiskLevelToTagType(tag.riskLevel)" effect="plain">
|
||||
{{ formatRiskLevel(tag.riskLevel) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="detail-hit-tag-reason">{{ formatField(tag.reasonDetail) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="detail-hit-tag-empty">当前流水未命中异常标签</div>
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer" class="detail-dialog-footer">
|
||||
<el-button @click="closeDetailDialog">取消</el-button>
|
||||
@@ -396,6 +434,8 @@ const createEmptyOptionData = () => ({
|
||||
ourAccountOptions: [],
|
||||
});
|
||||
|
||||
const normalizeHitTags = (hitTags) => (Array.isArray(hitTags) ? hitTags : []);
|
||||
|
||||
const createEmptyDetailData = () => ({
|
||||
bankStatementId: "",
|
||||
projectId: "",
|
||||
@@ -427,6 +467,7 @@ const createEmptyDetailData = () => ({
|
||||
uploadTime: "",
|
||||
sourceFileName: "",
|
||||
fileName: "",
|
||||
hitTags: [],
|
||||
});
|
||||
|
||||
export default {
|
||||
@@ -489,7 +530,10 @@ export default {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await listBankStatement(this.queryParams);
|
||||
this.list = res.rows || [];
|
||||
this.list = (res.rows || []).map((item) => ({
|
||||
...item,
|
||||
hitTags: normalizeHitTags(item && item.hitTags),
|
||||
}));
|
||||
this.total = res.total || 0;
|
||||
this.listError = "";
|
||||
} catch (error) {
|
||||
@@ -575,9 +619,11 @@ export default {
|
||||
this.detailLoading = true;
|
||||
try {
|
||||
const res = await getBankStatementDetail(row.bankStatementId);
|
||||
const detail = res.data || {};
|
||||
this.detailData = {
|
||||
...createEmptyDetailData(),
|
||||
...(res.data || {}),
|
||||
...detail,
|
||||
hitTags: normalizeHitTags(detail.hitTags),
|
||||
};
|
||||
} catch (error) {
|
||||
this.detailData = createEmptyDetailData();
|
||||
@@ -665,6 +711,29 @@ export default {
|
||||
}
|
||||
return this.formatDate(detail.uploadTime);
|
||||
},
|
||||
formatRiskLevel(value) {
|
||||
const level = String(value || "").toUpperCase();
|
||||
if (level === "HIGH") {
|
||||
return "高风险";
|
||||
}
|
||||
if (level === "MEDIUM") {
|
||||
return "中风险";
|
||||
}
|
||||
if (level === "LOW") {
|
||||
return "低风险";
|
||||
}
|
||||
return "未标注";
|
||||
},
|
||||
mapRiskLevelToTagType(riskLevel) {
|
||||
const level = String(riskLevel || "").toUpperCase();
|
||||
if (level === "HIGH") {
|
||||
return "danger";
|
||||
}
|
||||
if (level === "MEDIUM") {
|
||||
return "warning";
|
||||
}
|
||||
return "info";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -821,6 +890,18 @@ export default {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.hit-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.amount-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -841,6 +922,7 @@ export default {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 24px 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-field {
|
||||
@@ -900,6 +982,60 @@ export default {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.detail-hit-tag-section {
|
||||
border-top: 1px solid #ebeef5;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.detail-hit-tag-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-hit-tag-item {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.detail-hit-tag-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-hit-tag-name {
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-hit-tag-reason {
|
||||
margin-top: 8px;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-hit-tag-empty {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.detail-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -975,6 +1111,11 @@ export default {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-hit-tag-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog) {
|
||||
width: calc(100vw - 24px) !important;
|
||||
margin-top: 8vh !important;
|
||||
|
||||
@@ -32,6 +32,16 @@ assert(
|
||||
);
|
||||
});
|
||||
|
||||
[
|
||||
"命中异常标签",
|
||||
"detail-hit-tag-section",
|
||||
"detailData.hitTags",
|
||||
"当前流水未命中异常标签",
|
||||
"mapRiskLevelToTagType(tag.riskLevel)",
|
||||
].forEach((token) => {
|
||||
assert(source.includes(token), `详情弹窗缺少异常标签结构: ${token}`);
|
||||
});
|
||||
|
||||
const tableBlockMatch = source.match(/<el-table[\s\S]*?class="result-table"[\s\S]*?>/m);
|
||||
assert(tableBlockMatch, "未找到流水明细列表表格");
|
||||
assert(
|
||||
|
||||
19
ruoyi-ui/tests/unit/detail-query-hit-tags-list.test.js
Normal file
19
ruoyi-ui/tests/unit/detail-query-hit-tags-list.test.js
Normal file
@@ -0,0 +1,19 @@
|
||||
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");
|
||||
|
||||
assert(source.includes('label="异常标签"'), "列表应新增异常标签列");
|
||||
assert(source.includes("scope.row.hitTags"), "异常标签列应读取当前行的 hitTags");
|
||||
assert(
|
||||
source.includes('v-for="(tag, index) in scope.row.hitTags"'),
|
||||
"异常标签列应逐个渲染命中标签"
|
||||
);
|
||||
assert(source.includes("tag.ruleName"), "异常标签列应展示标签名称");
|
||||
|
||||
console.log("detail-query-hit-tags-list test passed");
|
||||
Reference in New Issue
Block a user