Compare commits
18 Commits
dev-ui
...
9a60371a8f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a60371a8f | |||
| 380f9b4e7a | |||
| 928f65dfca | |||
| c64146ac40 | |||
| 0541ce0ac6 | |||
| 26c639134e | |||
| 0f7b57e824 | |||
| 104e8697fe | |||
| bbc6a2050b | |||
| bf7a4c0538 | |||
| b2e177dd24 | |||
| 2071d04c08 | |||
| 4988ab5944 | |||
| c00d5475e6 | |||
| 0b64532959 | |||
| 9f0ad4ce87 | |||
| 75b5989774 | |||
| d8c069a836 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -84,6 +84,8 @@ logs/
|
||||
|
||||
ruoyi-ui/vue.config.js
|
||||
|
||||
ruoyi-ui/dist.zip
|
||||
|
||||
*/src/test/
|
||||
|
||||
.pytest_cache/
|
||||
|
||||
@@ -2,19 +2,36 @@ package com.ruoyi.info.collection.utils;
|
||||
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.alibaba.excel.ExcelWriter;
|
||||
import com.alibaba.excel.annotation.ExcelProperty;
|
||||
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
|
||||
import com.alibaba.excel.write.metadata.WriteSheet;
|
||||
import com.alibaba.excel.write.handler.WriteHandler;
|
||||
import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
|
||||
import com.ruoyi.common.annotation.DictDropdown;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.info.collection.handler.DictDropdownWriteHandler;
|
||||
import com.ruoyi.info.collection.handler.RequiredFieldWriteHandler;
|
||||
import com.ruoyi.info.collection.handler.TextFormatWriteHandler;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.DataValidation;
|
||||
import org.apache.poi.ss.usermodel.DataValidationConstraint;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -77,8 +94,10 @@ public class EasyExcelUtil {
|
||||
* @return 数据列表
|
||||
*/
|
||||
public static <T> List<T> importExcel(String fileName, Class<T> clazz) {
|
||||
try {
|
||||
return EasyExcel.read(fileName).head(clazz).sheet().doReadSync();
|
||||
try (InputStream inputStream = java.nio.file.Files.newInputStream(java.nio.file.Path.of(fileName))) {
|
||||
return importExcel(inputStream, clazz);
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("导入Excel失败", e);
|
||||
}
|
||||
@@ -94,7 +113,11 @@ public class EasyExcelUtil {
|
||||
*/
|
||||
public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz) {
|
||||
try {
|
||||
return EasyExcel.read(inputStream).head(clazz).sheet().doReadSync();
|
||||
byte[] bytes = inputStream.readAllBytes();
|
||||
validateDictDropdownTemplate(bytes, clazz, null);
|
||||
return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet().doReadSync();
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("导入Excel失败", e);
|
||||
}
|
||||
@@ -111,7 +134,11 @@ public class EasyExcelUtil {
|
||||
*/
|
||||
public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz, String sheetName) {
|
||||
try {
|
||||
return EasyExcel.read(inputStream).head(clazz).sheet(sheetName).doReadSync();
|
||||
byte[] bytes = inputStream.readAllBytes();
|
||||
validateDictDropdownTemplate(bytes, clazz, sheetName);
|
||||
return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet(sheetName).doReadSync();
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("导入Excel失败", e);
|
||||
}
|
||||
@@ -128,7 +155,7 @@ public class EasyExcelUtil {
|
||||
public static <T> void importTemplateExcel(HttpServletResponse response, Class<T> clazz, String sheetName) {
|
||||
try {
|
||||
setResponseHeader(response, sheetName + "模板");
|
||||
EasyExcel.write(response.getOutputStream(), clazz)
|
||||
templateWriter(response, clazz)
|
||||
.sheet(sheetName)
|
||||
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
|
||||
.doWrite(List.of());
|
||||
@@ -151,7 +178,7 @@ public class EasyExcelUtil {
|
||||
WriteHandler... handlers) {
|
||||
try {
|
||||
setResponseHeader(response, sheetName + "模板");
|
||||
var writerBuilder = EasyExcel.write(response.getOutputStream(), clazz)
|
||||
var writerBuilder = templateWriter(response, clazz)
|
||||
.sheet(sheetName)
|
||||
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy());
|
||||
// 注册所有自定义处理器
|
||||
@@ -190,7 +217,7 @@ public class EasyExcelUtil {
|
||||
public static <T> void importTemplateWithDictDropdown(HttpServletResponse response, Class<T> clazz, String sheetName) {
|
||||
try {
|
||||
setResponseHeader(response, sheetName + "模板");
|
||||
EasyExcel.write(response.getOutputStream(), clazz)
|
||||
templateWriter(response, clazz)
|
||||
.sheet(sheetName)
|
||||
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
|
||||
.registerWriteHandler(new DictDropdownWriteHandler(clazz))
|
||||
@@ -217,7 +244,7 @@ public class EasyExcelUtil {
|
||||
String sheetName, String fileName) {
|
||||
try {
|
||||
setResponseHeader(response, fileName);
|
||||
EasyExcel.write(response.getOutputStream(), clazz)
|
||||
templateWriter(response, clazz)
|
||||
.sheet(sheetName)
|
||||
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
|
||||
.registerWriteHandler(new DictDropdownWriteHandler(clazz))
|
||||
@@ -250,7 +277,7 @@ public class EasyExcelUtil {
|
||||
String fileName
|
||||
) {
|
||||
setResponseHeader(response, fileName);
|
||||
try (ExcelWriter writer = EasyExcel.write(response.getOutputStream()).build()) {
|
||||
try (ExcelWriter writer = templateWriter(response).build()) {
|
||||
writer.write(List.of(), buildTemplateSheet(0, firstClazz, firstSheetName));
|
||||
writer.write(List.of(), buildTemplateSheet(1, secondClazz, secondSheetName));
|
||||
} catch (IOException e) {
|
||||
@@ -322,4 +349,137 @@ public class EasyExcelUtil {
|
||||
throw new RuntimeException("导出带字典下拉框的Excel失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateDictDropdownTemplate(byte[] bytes, Class<?> clazz, String sheetName) {
|
||||
List<DropdownColumn> dropdownColumns = resolveDropdownColumns(clazz);
|
||||
if (dropdownColumns.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) {
|
||||
Sheet sheet = sheetName == null ? workbook.getSheetAt(0) : workbook.getSheet(sheetName);
|
||||
if (sheet == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int lastDataRowIndex = findLastDataRowIndex(sheet);
|
||||
if (lastDataRowIndex < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> missingColumnTitles = new ArrayList<>();
|
||||
for (DropdownColumn column : dropdownColumns) {
|
||||
if (!isListValidationCovered(sheet, column.index(), lastDataRowIndex)) {
|
||||
missingColumnTitles.add(column.title());
|
||||
}
|
||||
}
|
||||
if (!missingColumnTitles.isEmpty()) {
|
||||
throw new ServiceException(sheet.getSheetName() + " Sheet 的 "
|
||||
+ String.join("、", missingColumnTitles)
|
||||
+ " 列缺少下拉框,请下载最新导入模板填写后重新导入");
|
||||
}
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("导入Excel失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<DropdownColumn> resolveDropdownColumns(Class<?> clazz) {
|
||||
List<DropdownColumn> columns = new ArrayList<>();
|
||||
Class<?> current = clazz;
|
||||
while (current != null && current != Object.class) {
|
||||
for (Field field : current.getDeclaredFields()) {
|
||||
if (field.getAnnotation(DictDropdown.class) == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
|
||||
if (excelProperty == null || excelProperty.index() < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
columns.add(new DropdownColumn(excelProperty.index(), resolveColumnTitle(field, excelProperty)));
|
||||
}
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
columns.sort(Comparator.comparingInt(DropdownColumn::index));
|
||||
return columns;
|
||||
}
|
||||
|
||||
private static String resolveColumnTitle(Field field, ExcelProperty excelProperty) {
|
||||
if (excelProperty.value().length > 0 && excelProperty.value()[0] != null
|
||||
&& !excelProperty.value()[0].isBlank()) {
|
||||
return excelProperty.value()[0].replace("*", "");
|
||||
}
|
||||
return field.getName();
|
||||
}
|
||||
|
||||
private static int findLastDataRowIndex(Sheet sheet) {
|
||||
int lastDataRowIndex = -1;
|
||||
for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
if (hasData(row)) {
|
||||
lastDataRowIndex = rowIndex;
|
||||
}
|
||||
}
|
||||
return lastDataRowIndex;
|
||||
}
|
||||
|
||||
private static boolean hasData(Row row) {
|
||||
if (row == null || row.getLastCellNum() < 0) {
|
||||
return false;
|
||||
}
|
||||
for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) {
|
||||
if (cellIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
Cell cell = row.getCell(cellIndex);
|
||||
if (cell != null && cell.toString() != null && !cell.toString().isBlank()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isListValidationCovered(Sheet sheet, int columnIndex, int lastDataRowIndex) {
|
||||
boolean[] coveredRows = new boolean[lastDataRowIndex + 1];
|
||||
for (DataValidation validation : sheet.getDataValidations()) {
|
||||
DataValidationConstraint constraint = validation.getValidationConstraint();
|
||||
if (constraint == null || constraint.getValidationType() != DataValidationConstraint.ValidationType.LIST) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (CellRangeAddress address : validation.getRegions().getCellRangeAddresses()) {
|
||||
if (address.getFirstColumn() > columnIndex || address.getLastColumn() < columnIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int firstRow = Math.max(1, address.getFirstRow());
|
||||
int lastRow = Math.min(lastDataRowIndex, address.getLastRow());
|
||||
for (int rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) {
|
||||
coveredRows[rowIndex] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int rowIndex = 1; rowIndex <= lastDataRowIndex; rowIndex++) {
|
||||
if (hasData(sheet.getRow(rowIndex)) && !coveredRows[rowIndex]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private record DropdownColumn(int index, String title) {}
|
||||
|
||||
private static <T> ExcelWriterBuilder templateWriter(HttpServletResponse response, Class<T> clazz)
|
||||
throws IOException {
|
||||
// 模板为空且体量小,使用内存工作簿避免 SXSSF 在无字体环境初始化 Fontconfig。
|
||||
return EasyExcel.write(response.getOutputStream(), clazz).inMemory(Boolean.TRUE);
|
||||
}
|
||||
|
||||
private static ExcelWriterBuilder templateWriter(HttpServletResponse response) throws IOException {
|
||||
return EasyExcel.write(response.getOutputStream()).inMemory(Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
AND e.status = #{query.status}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY e.create_time DESC
|
||||
ORDER BY e.create_time DESC, e.staff_id DESC
|
||||
</select>
|
||||
|
||||
<!-- 批量插入或更新员工信息(只更新非null字段) -->
|
||||
|
||||
@@ -21,4 +21,15 @@ class CcdiBaseStaffMapperTest {
|
||||
assertTrue(xml.contains("#{item.partyMember}"), xml);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapperXml_shouldUseStableOrderForBaseStaffPagination() throws Exception {
|
||||
try (InputStream inputStream = getClass().getClassLoader()
|
||||
.getResourceAsStream("mapper/info/collection/CcdiBaseStaffMapper.xml")) {
|
||||
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8)
|
||||
.replaceAll("\\s+", " ");
|
||||
|
||||
assertTrue(xml.contains("ORDER BY e.create_time DESC, e.staff_id DESC"), xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
@@ -110,7 +111,15 @@ public class LsfxAnalysisClient {
|
||||
* 上传文件
|
||||
*/
|
||||
public UploadFileResponse uploadFile(Integer groupId, File file) {
|
||||
log.info("【流水分析】上传文件请求: groupId={}, fileName={}", groupId, file.getName());
|
||||
return uploadFile(groupId, file, file.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
public UploadFileResponse uploadFile(Integer groupId, File file, String uploadFileName) {
|
||||
String multipartFileName = StringUtils.hasText(uploadFileName) ? uploadFileName : file.getName();
|
||||
log.info("【流水分析】上传文件请求: groupId={}, fileName={}", groupId, multipartFileName);
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
@@ -118,7 +127,7 @@ public class LsfxAnalysisClient {
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("groupId", groupId);
|
||||
params.put("files", file);
|
||||
params.put("files", HttpUtil.namedFileResource(file, multipartFileName));
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId);
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
@@ -31,6 +32,24 @@ public class HttpUtil {
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
public static org.springframework.core.io.Resource namedFileResource(File file, String filename) {
|
||||
return new NamedFileSystemResource(file, filename);
|
||||
}
|
||||
|
||||
private static class NamedFileSystemResource extends FileSystemResource {
|
||||
private final String filename;
|
||||
|
||||
NamedFileSystemResource(File file, String filename) {
|
||||
super(file);
|
||||
this.filename = StringUtils.hasText(filename) ? filename : file.getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送GET请求(带查询参数和请求头)
|
||||
* @param url 请求URL
|
||||
@@ -207,6 +226,8 @@ public class HttpUtil {
|
||||
if (value instanceof File) {
|
||||
File file = (File) value;
|
||||
body.add(key, new FileSystemResource(file));
|
||||
} else if (value instanceof org.springframework.core.io.Resource) {
|
||||
body.add(key, value);
|
||||
} else {
|
||||
body.add(key, value);
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiFundGraphService;
|
||||
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 io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 资金流图谱Controller
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/ccdi/project/fund-graph")
|
||||
@Tag(name = "资金流图谱")
|
||||
public class CcdiFundGraphController extends BaseController {
|
||||
|
||||
@Resource
|
||||
private ICcdiFundGraphService fundGraphService;
|
||||
|
||||
@GetMapping("/search")
|
||||
@Operation(summary = "查询资金流图谱主体")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult searchSubjects(CcdiFundGraphQueryDTO queryDTO) {
|
||||
List<CcdiFundGraphNodeVO> subjects = fundGraphService.searchSubjects(queryDTO);
|
||||
return AjaxResult.success(subjects);
|
||||
}
|
||||
|
||||
@GetMapping("/graph")
|
||||
@Operation(summary = "查询一层资金流图谱")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult getGraph(CcdiFundGraphQueryDTO queryDTO) {
|
||||
CcdiFundGraphVO graph = fundGraphService.getFundGraph(queryDTO);
|
||||
return AjaxResult.success(graph);
|
||||
}
|
||||
|
||||
@GetMapping("/edge-detail")
|
||||
@Operation(summary = "查询资金边流水明细")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public TableDataInfo getEdgeDetail(CcdiFundGraphEdgeDetailQueryDTO queryDTO) {
|
||||
PageDomain pageDomain = TableSupport.buildPageRequest();
|
||||
Page<CcdiFundGraphStatementVO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
|
||||
Page<CcdiFundGraphStatementVO> result = fundGraphService.getEdgeDetails(page, queryDTO);
|
||||
return getDataTable(result.getRecords(), result.getTotal());
|
||||
}
|
||||
|
||||
@PostMapping("/manual-edge")
|
||||
@Operation(summary = "新增手工资金流向")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult saveManualEdge(@RequestBody CcdiFundGraphManualEdgeSaveDTO saveDTO) {
|
||||
try {
|
||||
CcdiFundGraphEdgeVO edge = fundGraphService.saveManualEdge(saveDTO, SecurityUtils.getUsername());
|
||||
return AjaxResult.success(edge);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return AjaxResult.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,14 @@ package com.ruoyi.ccdi.project.controller;
|
||||
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalPersonQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
|
||||
@@ -73,28 +67,6 @@ public class CcdiProjectOverviewController extends BaseController {
|
||||
return AjaxResult.success(overview);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询外部人员预警
|
||||
*/
|
||||
@GetMapping("/external-persons")
|
||||
@Operation(summary = "查询外部人员预警")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult getExternalPersons(CcdiProjectExternalPersonQueryDTO queryDTO) {
|
||||
CcdiProjectExternalPersonWarningVO warnings = overviewService.getExternalPersonWarnings(queryDTO);
|
||||
return AjaxResult.success(warnings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询外部人员风险汇总
|
||||
*/
|
||||
@GetMapping("/external-persons/summary")
|
||||
@Operation(summary = "查询外部人员风险汇总")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult getExternalRiskSummary(Long projectId) {
|
||||
CcdiProjectExternalRiskSummaryVO summary = overviewService.getExternalRiskSummary(projectId);
|
||||
return AjaxResult.success(summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询中高风险人员TOP10
|
||||
*/
|
||||
@@ -128,28 +100,6 @@ public class CcdiProjectOverviewController extends BaseController {
|
||||
return AjaxResult.success(people);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询外部人员风险模型卡片
|
||||
*/
|
||||
@GetMapping("/external-risk-models/cards")
|
||||
@Operation(summary = "查询外部人员风险模型卡片")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult getExternalRiskModelCards(Long projectId) {
|
||||
CcdiProjectRiskModelCardsVO cards = overviewService.getExternalRiskModelCards(projectId);
|
||||
return AjaxResult.success(cards);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询外部人员风险模型命中人员
|
||||
*/
|
||||
@GetMapping("/external-risk-models/people")
|
||||
@Operation(summary = "查询外部人员风险模型命中人员")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult getExternalRiskModelPeople(CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO) {
|
||||
CcdiProjectRiskModelPeopleVO people = overviewService.getExternalRiskModelPeople(queryDTO);
|
||||
return AjaxResult.success(people);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询项目分析详情
|
||||
*/
|
||||
@@ -223,48 +173,6 @@ public class CcdiProjectOverviewController extends BaseController {
|
||||
util.exportExcel(response, rows, "风险人员总览");
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出外部人员预警
|
||||
*/
|
||||
@PostMapping("/external-persons/export")
|
||||
@Operation(summary = "导出外部人员预警")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public void exportExternalPersons(HttpServletResponse response, Long projectId) {
|
||||
List<CcdiProjectExternalPersonWarningExcel> rows = overviewService.exportExternalPersonWarnings(projectId);
|
||||
ExcelUtil<CcdiProjectExternalPersonWarningExcel> util =
|
||||
new ExcelUtil<>(CcdiProjectExternalPersonWarningExcel.class);
|
||||
util.exportExcel(response, rows, "外部人员预警");
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出风险模型命中人员
|
||||
*/
|
||||
@PostMapping("/risk-models/people/export")
|
||||
@Operation(summary = "导出风险模型命中人员")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public void exportRiskModelPeople(HttpServletResponse response, CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
|
||||
List<CcdiProjectRiskModelPeopleExcel> rows = overviewService.exportRiskModelPeople(queryDTO);
|
||||
ExcelUtil<CcdiProjectRiskModelPeopleExcel> util =
|
||||
new ExcelUtil<>(CcdiProjectRiskModelPeopleExcel.class);
|
||||
util.exportExcel(response, rows, "风险模型命中人员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出外部人员风险模型命中人员
|
||||
*/
|
||||
@PostMapping("/external-risk-models/people/export")
|
||||
@Operation(summary = "导出外部人员风险模型命中人员")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public void exportExternalRiskModelPeople(
|
||||
HttpServletResponse response,
|
||||
CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO
|
||||
) {
|
||||
List<CcdiProjectRiskModelPeopleExcel> rows = overviewService.exportExternalRiskModelPeople(queryDTO);
|
||||
ExcelUtil<CcdiProjectRiskModelPeopleExcel> util =
|
||||
new ExcelUtil<>(CcdiProjectRiskModelPeopleExcel.class);
|
||||
util.exportExcel(response, rows, "外部人员风险模型命中人员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出风险明细
|
||||
*/
|
||||
@@ -276,10 +184,10 @@ public class CcdiProjectOverviewController extends BaseController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出结果总览报告
|
||||
* 一键导出结果总览报告
|
||||
*/
|
||||
@RequestMapping(value = "/report/export", method = { RequestMethod.GET, RequestMethod.POST })
|
||||
@Operation(summary = "导出结果总览报告")
|
||||
@Operation(summary = "一键导出结果总览报告")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public void exportOverviewReport(HttpServletResponse response, Long projectId) {
|
||||
overviewService.exportOverviewReport(response, projectId);
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.controller;
|
||||
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiRelationGraphService;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 关系图谱Controller
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/ccdi/project/relation-graph")
|
||||
@Tag(name = "关系图谱")
|
||||
public class CcdiRelationGraphController extends BaseController {
|
||||
|
||||
@Resource
|
||||
private ICcdiRelationGraphService relationGraphService;
|
||||
|
||||
@GetMapping("/search")
|
||||
@Operation(summary = "查询关系图谱主体")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult searchSubjects(CcdiRelationGraphQueryDTO queryDTO) {
|
||||
List<CcdiRelationGraphNodeVO> subjects = relationGraphService.searchSubjects(queryDTO);
|
||||
return AjaxResult.success(subjects);
|
||||
}
|
||||
|
||||
@GetMapping("/graph")
|
||||
@Operation(summary = "查询一层关系图谱")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult getGraph(CcdiRelationGraphQueryDTO queryDTO) {
|
||||
CcdiRelationGraphVO graph = relationGraphService.getRelationGraph(queryDTO);
|
||||
return AjaxResult.success(graph);
|
||||
}
|
||||
|
||||
@GetMapping("/suspected-enterprises")
|
||||
@Operation(summary = "查询关系图谱疑似同名企业")
|
||||
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
|
||||
public AjaxResult getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
|
||||
CcdiRelationGraphSuspectedEnterpriseVO result = relationGraphService.getSuspectedEnterprises(queryDTO);
|
||||
return AjaxResult.success(result);
|
||||
}
|
||||
}
|
||||
@@ -40,9 +40,6 @@ public class CcdiBankStatementQueryDTO {
|
||||
/** 本方主体 */
|
||||
private List<String> ourSubjects;
|
||||
|
||||
/** 本方证件号 */
|
||||
private List<String> ourCertNos;
|
||||
|
||||
/** 本方银行 */
|
||||
private List<String> ourBanks;
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 资金流图谱边明细查询条件
|
||||
*/
|
||||
@Data
|
||||
public class CcdiFundGraphEdgeDetailQueryDTO {
|
||||
|
||||
/** 项目ID:历史字段,资金流图谱不按项目过滤 */
|
||||
private Long projectId;
|
||||
|
||||
/** 身份证号、员工姓名或本方户名 */
|
||||
private String keyword;
|
||||
|
||||
/** 主体节点object_key;复用图谱公共SQL片段时兼容条件判断 */
|
||||
private String objectKey;
|
||||
|
||||
/** 边起点 */
|
||||
private String fromKey;
|
||||
|
||||
/** 边终点 */
|
||||
private String toKey;
|
||||
|
||||
/** 方向:1支出,2收入 */
|
||||
private String direction;
|
||||
|
||||
/** 交易开始时间 */
|
||||
private String transactionStartTime;
|
||||
|
||||
/** 交易结束时间 */
|
||||
private String transactionEndTime;
|
||||
|
||||
/** 最小金额 */
|
||||
private BigDecimal amountMin;
|
||||
|
||||
/** 最大金额 */
|
||||
private BigDecimal amountMax;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 手工资金流向保存参数。
|
||||
*/
|
||||
@Data
|
||||
public class CcdiFundGraphManualEdgeSaveDTO {
|
||||
|
||||
/** 起点主体object_key;为空时默认使用当前查询中心 */
|
||||
private String fromObjectKey;
|
||||
|
||||
/** 起点主体名称 */
|
||||
private String fromName;
|
||||
|
||||
/** 终点主体object_key;已有节点时传入 */
|
||||
private String toObjectKey;
|
||||
|
||||
/** 终点主体名称;新建主体时必填 */
|
||||
private String toName;
|
||||
|
||||
/** 终点主体身份证号/证件号;有值时按md5(trim(idNo))复用主体 */
|
||||
private String toIdNo;
|
||||
|
||||
/** 手工录入汇总金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 手工录入笔数 */
|
||||
private Integer transactionCount;
|
||||
|
||||
/** 方向:1支出,2收入 */
|
||||
private String direction;
|
||||
|
||||
/** 资金流向关系说明 */
|
||||
private String relationDesc;
|
||||
|
||||
/** 来源说明 */
|
||||
private String sourceDesc;
|
||||
|
||||
/** 分析备注 */
|
||||
private String remark;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 资金流图谱查询条件
|
||||
*/
|
||||
@Data
|
||||
public class CcdiFundGraphQueryDTO {
|
||||
|
||||
/** 项目ID:历史字段,资金流图谱不按项目过滤 */
|
||||
private Long projectId;
|
||||
|
||||
/** 身份证号、员工姓名或本方户名 */
|
||||
private String keyword;
|
||||
|
||||
/** 主体节点object_key;节点穿透时直接使用 */
|
||||
private String objectKey;
|
||||
|
||||
/** 交易开始时间 */
|
||||
private String transactionStartTime;
|
||||
|
||||
/** 交易结束时间 */
|
||||
private String transactionEndTime;
|
||||
|
||||
/** 最小金额 */
|
||||
private BigDecimal amountMin;
|
||||
|
||||
/** 最大金额 */
|
||||
private BigDecimal amountMax;
|
||||
|
||||
/** 最小汇总金额,默认1000 */
|
||||
private BigDecimal minTotalAmount;
|
||||
|
||||
/** 方向:1支出,2收入 */
|
||||
private String direction;
|
||||
|
||||
/** 返回边数量上限 */
|
||||
private Integer limit;
|
||||
|
||||
/** 预留追溯层级,一期固定按一层处理 */
|
||||
private Integer depth;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 外部人员预警查询DTO
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectExternalPersonQueryDTO {
|
||||
|
||||
/** 项目ID */
|
||||
private Long projectId;
|
||||
|
||||
/** 页码 */
|
||||
private Integer pageNum;
|
||||
|
||||
/** 每页数量 */
|
||||
private Integer pageSize;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 外部人员模型命中人员查询DTO
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectExternalRiskModelPeopleQueryDTO {
|
||||
|
||||
/** 项目ID */
|
||||
private Long projectId;
|
||||
|
||||
/** 模型编码 */
|
||||
private List<String> modelCodes;
|
||||
|
||||
/** 匹配方式 */
|
||||
private String matchMode;
|
||||
|
||||
/** 关键字 */
|
||||
private String keyword;
|
||||
|
||||
/** 页码 */
|
||||
private Integer pageNum;
|
||||
|
||||
/** 每页数量 */
|
||||
private Integer pageSize;
|
||||
|
||||
public String getModelCodesCsv() {
|
||||
if (modelCodes == null || modelCodes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return modelCodes.stream()
|
||||
.filter(item -> item != null && !item.isBlank())
|
||||
.map(String::trim)
|
||||
.distinct()
|
||||
.collect(Collectors.joining(","));
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 关系图谱查询条件
|
||||
*/
|
||||
@Data
|
||||
public class CcdiRelationGraphQueryDTO {
|
||||
|
||||
/** 项目ID:历史字段,关系图谱不按项目过滤 */
|
||||
private Long projectId;
|
||||
|
||||
/** 身份证号、姓名、统一社会信用代码或节点object_key */
|
||||
private String keyword;
|
||||
|
||||
/** 节点object_key;节点穿透时直接使用 */
|
||||
private String objectKey;
|
||||
|
||||
/** 返回边数量上限 */
|
||||
private Integer limit;
|
||||
|
||||
/** 预留追溯层级,一期固定按一层处理 */
|
||||
private Integer depth;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 关系图谱疑似企业查询条件
|
||||
*/
|
||||
@Data
|
||||
public class CcdiRelationGraphSuspectedEnterpriseQueryDTO {
|
||||
|
||||
/** 姓名 */
|
||||
private String personName;
|
||||
|
||||
/** 证件号 */
|
||||
private String certNo;
|
||||
|
||||
/** 出生日期,yyyy-MM-dd */
|
||||
private String birthDate;
|
||||
|
||||
/** 返回数量上限 */
|
||||
private Integer limit;
|
||||
}
|
||||
@@ -207,7 +207,6 @@ public class CcdiBankStatement implements Serializable {
|
||||
entity.setBatchSequence(item.getUploadSequnceNumber());
|
||||
entity.setCustomerCertNo(item.getCustomerCertNo());
|
||||
entity.setCustomerSocialCreditCode(item.getCustomerSocialCreditCode());
|
||||
entity.setCretNo(normalizeCertNo(item.getCretNo()));
|
||||
|
||||
// 5. 特殊字段处理
|
||||
entity.setMetaJson(null); // 根据文档要求强制设为 null
|
||||
@@ -220,19 +219,4 @@ public class CcdiBankStatement implements Serializable {
|
||||
throw new RuntimeException("流水数据转换失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeCertNo(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = value.trim()
|
||||
.replace('-', '-')
|
||||
.replace('—', '-')
|
||||
.replace('–', '-');
|
||||
int separatorIndex = normalized.indexOf('-');
|
||||
if (separatorIndex >= 0) {
|
||||
normalized = normalized.substring(0, separatorIndex).trim();
|
||||
}
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.excel;
|
||||
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 外部人员预警导出对象
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectExternalPersonWarningExcel {
|
||||
|
||||
@Excel(name = "姓名")
|
||||
private String name;
|
||||
|
||||
@Excel(name = "证件号")
|
||||
private String idNo;
|
||||
|
||||
@Excel(name = "主体类型")
|
||||
private String subjectType;
|
||||
|
||||
@Excel(name = "风险等级")
|
||||
private String riskLevel;
|
||||
|
||||
@Excel(name = "命中模型数")
|
||||
private Integer modelCount;
|
||||
|
||||
@Excel(name = "核心异常点")
|
||||
private String riskPoint;
|
||||
|
||||
@Excel(name = "涉及对象")
|
||||
private String relatedObject;
|
||||
|
||||
@Excel(name = "最近交易时间")
|
||||
private String latestTradeTime;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.excel;
|
||||
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 风险模型命中人员导出对象
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectRiskModelPeopleExcel {
|
||||
|
||||
@Excel(name = "风险主体")
|
||||
private String personName;
|
||||
|
||||
@Excel(name = "主体类型")
|
||||
private String subjectType;
|
||||
|
||||
@Excel(name = "证件号")
|
||||
private String idNo;
|
||||
|
||||
@Excel(name = "部门/涉及对象")
|
||||
private String scopeName;
|
||||
|
||||
@Excel(name = "命中模型")
|
||||
private String modelNames;
|
||||
|
||||
@Excel(name = "异常标签")
|
||||
private String hitTags;
|
||||
}
|
||||
@@ -14,29 +14,20 @@ public class CcdiProjectSuspiciousTransactionExcel {
|
||||
@Excel(name = "交易时间")
|
||||
private String trxDate;
|
||||
|
||||
@Excel(name = "本方账户")
|
||||
private String leAccountNo;
|
||||
@Excel(name = "可疑人员")
|
||||
private String suspiciousPersonName;
|
||||
|
||||
@Excel(name = "本方主体")
|
||||
private String leAccountName;
|
||||
|
||||
@Excel(name = "对方名称")
|
||||
private String customerAccountName;
|
||||
|
||||
@Excel(name = "对方账户")
|
||||
private String customerAccountNo;
|
||||
@Excel(name = "关联人")
|
||||
private String relatedPersonName;
|
||||
|
||||
@Excel(name = "关联员工")
|
||||
private String relatedStaffDisplay;
|
||||
|
||||
@Excel(name = "摘要")
|
||||
private String userMemo;
|
||||
@Excel(name = "关系")
|
||||
private String relationType;
|
||||
|
||||
@Excel(name = "交易类型")
|
||||
private String cashType;
|
||||
|
||||
@Excel(name = "异常标签")
|
||||
private String hitTags;
|
||||
@Excel(name = "摘要/交易类型")
|
||||
private String summaryAndCashType;
|
||||
|
||||
@Excel(name = "交易金额")
|
||||
private BigDecimal displayAmount;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 资金流图谱汇总边
|
||||
*/
|
||||
@Data
|
||||
public class CcdiFundGraphEdgeVO {
|
||||
|
||||
private String edgeKey;
|
||||
|
||||
private String fromKey;
|
||||
|
||||
private String toKey;
|
||||
|
||||
private String fromObjectKey;
|
||||
|
||||
private String toObjectKey;
|
||||
|
||||
private String fromName;
|
||||
|
||||
private String toName;
|
||||
|
||||
private BigDecimal totalAmount;
|
||||
|
||||
private Long transactionCount;
|
||||
|
||||
private String firstTrxDate;
|
||||
|
||||
private String lastTrxDate;
|
||||
|
||||
private String direction;
|
||||
|
||||
private String familyRelationType;
|
||||
|
||||
private String sourceType;
|
||||
|
||||
private String relationDesc;
|
||||
|
||||
private String sourceDesc;
|
||||
|
||||
private String remark;
|
||||
|
||||
private Integer depth;
|
||||
|
||||
private Boolean canTrace;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 资金流图谱节点
|
||||
*/
|
||||
@Data
|
||||
public class CcdiFundGraphNodeVO {
|
||||
|
||||
private String nodeKey;
|
||||
|
||||
private String objectKey;
|
||||
|
||||
private String nodeName;
|
||||
|
||||
private String idNo;
|
||||
|
||||
private String cinocsno;
|
||||
|
||||
private String idnoType;
|
||||
|
||||
private String staffId;
|
||||
|
||||
private String sourceType;
|
||||
|
||||
private String nodeType;
|
||||
|
||||
private String identityType;
|
||||
|
||||
private String relationType;
|
||||
|
||||
private Long accountCount;
|
||||
|
||||
private String createdTime;
|
||||
|
||||
private String updatedTime;
|
||||
|
||||
private Boolean canExpand;
|
||||
|
||||
private Integer depth;
|
||||
|
||||
private BigDecimal totalAmount;
|
||||
|
||||
private Long transactionCount;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 资金流图谱边对应流水明细
|
||||
*/
|
||||
@Data
|
||||
public class CcdiFundGraphStatementVO {
|
||||
|
||||
private Long bankStatementId;
|
||||
|
||||
private String trxDate;
|
||||
|
||||
private String leAccountNo;
|
||||
|
||||
private String leAccountName;
|
||||
|
||||
private String customerAccountName;
|
||||
|
||||
private String customerAccountNo;
|
||||
|
||||
private String cashType;
|
||||
|
||||
private String userMemo;
|
||||
|
||||
private BigDecimal amount;
|
||||
|
||||
private String direction;
|
||||
|
||||
private String familyRelationType;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 资金流图谱结果
|
||||
*/
|
||||
@Data
|
||||
public class CcdiFundGraphVO {
|
||||
|
||||
private CcdiFundGraphNodeVO centerNode;
|
||||
|
||||
private List<CcdiFundGraphNodeVO> nodes = new ArrayList<>();
|
||||
|
||||
private List<CcdiFundGraphEdgeVO> edges = new ArrayList<>();
|
||||
|
||||
private BigDecimal totalAmount = BigDecimal.ZERO;
|
||||
|
||||
private Long transactionCount = 0L;
|
||||
|
||||
private Integer maxDepth = 1;
|
||||
|
||||
private Boolean traceReserved = true;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 外部人员预警项
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectExternalPersonWarningItemVO {
|
||||
|
||||
private String name;
|
||||
|
||||
private String idNo;
|
||||
|
||||
private String subjectType;
|
||||
|
||||
private String riskLevel;
|
||||
|
||||
private String riskLevelType;
|
||||
|
||||
private Integer riskCount;
|
||||
|
||||
private Integer modelCount;
|
||||
|
||||
private String riskPoint;
|
||||
|
||||
private String relatedObject;
|
||||
|
||||
private String latestTradeTime;
|
||||
|
||||
private List<CcdiProjectRiskHitTagVO> riskPointTagList;
|
||||
|
||||
private String actionLabel;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 外部人员预警分页
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectExternalPersonWarningVO {
|
||||
|
||||
private List<CcdiProjectExternalPersonWarningItemVO> rows;
|
||||
|
||||
private Long total;
|
||||
|
||||
private Long pageNum;
|
||||
|
||||
private Long pageSize;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 外部人员风险等级汇总
|
||||
*/
|
||||
@Data
|
||||
public class CcdiProjectExternalRiskSummaryVO {
|
||||
|
||||
private Integer total;
|
||||
|
||||
private Integer high;
|
||||
|
||||
private Integer medium;
|
||||
|
||||
private Integer low;
|
||||
|
||||
private Integer noRisk;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.ruoyi.ccdi.project.domain.vo;
|
||||
import com.ruoyi.ccdi.project.domain.CcdiProject;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
@@ -22,16 +21,10 @@ public class CcdiProjectOverviewReportVO {
|
||||
|
||||
private CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO();
|
||||
|
||||
private CcdiProjectExternalRiskSummaryVO externalRiskSummary = new CcdiProjectExternalRiskSummaryVO();
|
||||
|
||||
private List<CcdiProjectOverviewReportModelSummaryVO> modelSummaries = new ArrayList<>();
|
||||
|
||||
private List<CcdiProjectOverviewReportModelSummaryVO> externalModelSummaries = new ArrayList<>();
|
||||
|
||||
private List<CcdiProjectRiskModelPeopleItemVO> riskPeople = new ArrayList<>();
|
||||
|
||||
private List<CcdiProjectExternalPersonWarningExcel> externalPersonWarnings = new ArrayList<>();
|
||||
|
||||
private List<CcdiProjectOverviewReportSuspiciousTransactionVO> suspiciousTransactions = new ArrayList<>();
|
||||
|
||||
private List<CcdiProjectEmployeeCreditNegativeExcel> illegalPeople = new ArrayList<>();
|
||||
|
||||
@@ -13,8 +13,4 @@ public class CcdiProjectOverviewStatVO {
|
||||
private String label;
|
||||
|
||||
private Integer value;
|
||||
|
||||
private Integer employeeValue;
|
||||
|
||||
private Integer externalValue;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,4 @@ public class CcdiProjectSuspiciousTransactionItemVO {
|
||||
private Boolean hasModelRuleHit;
|
||||
|
||||
private Boolean hasNameListHit;
|
||||
|
||||
private String nameListHitType;
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 关系图谱边
|
||||
*/
|
||||
@Data
|
||||
public class CcdiRelationGraphEdgeVO {
|
||||
|
||||
private String objectKey;
|
||||
|
||||
private String fromKey;
|
||||
|
||||
private String toKey;
|
||||
|
||||
private String fromObjectKey;
|
||||
|
||||
private String toObjectKey;
|
||||
|
||||
private String fromName;
|
||||
|
||||
private String toName;
|
||||
|
||||
private String edgeTable;
|
||||
|
||||
private String relationType;
|
||||
|
||||
private String companyName;
|
||||
|
||||
private String stockName;
|
||||
|
||||
private String stockType;
|
||||
|
||||
private String stockPercent;
|
||||
|
||||
private String shouldCapi;
|
||||
|
||||
private String shouldCapiValue;
|
||||
|
||||
private String shouldCapiUnit;
|
||||
|
||||
private String shoudDate;
|
||||
|
||||
private String pKeyNo;
|
||||
|
||||
private String operName;
|
||||
|
||||
private String operKeyNo;
|
||||
|
||||
private String personId;
|
||||
|
||||
private String relationName;
|
||||
|
||||
private String relationCertNo;
|
||||
|
||||
private String gender;
|
||||
|
||||
private String birthDate;
|
||||
|
||||
private String relationCertType;
|
||||
|
||||
private String mobilePhone1;
|
||||
|
||||
private String mobilePhone2;
|
||||
|
||||
private String wechatNo1;
|
||||
|
||||
private String wechatNo2;
|
||||
|
||||
private String wechatNo3;
|
||||
|
||||
private String contactAddress;
|
||||
|
||||
private BigDecimal annualIncome;
|
||||
|
||||
private String relationDesc;
|
||||
|
||||
private String status;
|
||||
|
||||
private String effectiveDate;
|
||||
|
||||
private String invalidDate;
|
||||
|
||||
private String remark;
|
||||
|
||||
private String dataSource;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 关系图谱节点
|
||||
*/
|
||||
@Data
|
||||
public class CcdiRelationGraphNodeVO {
|
||||
|
||||
private String objectKey;
|
||||
|
||||
private String nodeKey;
|
||||
|
||||
private String nodeName;
|
||||
|
||||
private String idNumber;
|
||||
|
||||
private String subjectType;
|
||||
|
||||
private String sourceType;
|
||||
|
||||
private String detailRefType;
|
||||
|
||||
private String detailRefKey;
|
||||
|
||||
private String createdTime;
|
||||
|
||||
private String updatedTime;
|
||||
|
||||
private Boolean canExpand;
|
||||
|
||||
private Integer depth;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 关系图谱疑似企业明细
|
||||
*/
|
||||
@Data
|
||||
public class CcdiRelationGraphSuspectedEnterpriseItemVO {
|
||||
|
||||
private String candidateKeyNo;
|
||||
|
||||
private String personName;
|
||||
|
||||
private String companyId;
|
||||
|
||||
private String companyName;
|
||||
|
||||
private String creditCode;
|
||||
|
||||
private String enterpriseStatus;
|
||||
|
||||
private String industryName;
|
||||
|
||||
private String relationType;
|
||||
|
||||
private String stockPercent;
|
||||
|
||||
/** 企业成立日期或当前可用的工商关系日期 */
|
||||
private String establishDate;
|
||||
|
||||
private Integer ageAtEstablish;
|
||||
|
||||
private String matchReason;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 关系图谱疑似企业结果
|
||||
*/
|
||||
@Data
|
||||
public class CcdiRelationGraphSuspectedEnterpriseVO {
|
||||
|
||||
/** 是否因同名候选过多被拦截 */
|
||||
private Boolean blocked = false;
|
||||
|
||||
/** 拦截或空结果说明 */
|
||||
private String message;
|
||||
|
||||
/** 同名工商keyno数量 */
|
||||
private Integer sameNameKeyNoCount = 0;
|
||||
|
||||
/** 表格明细 */
|
||||
private List<CcdiRelationGraphSuspectedEnterpriseItemVO> rows = new ArrayList<>();
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 关系图谱结果
|
||||
*/
|
||||
@Data
|
||||
public class CcdiRelationGraphVO {
|
||||
|
||||
private CcdiRelationGraphNodeVO centerNode;
|
||||
|
||||
private List<CcdiRelationGraphNodeVO> nodes = new ArrayList<>();
|
||||
|
||||
private List<CcdiRelationGraphEdgeVO> edges = new ArrayList<>();
|
||||
|
||||
private Long edgeCount = 0L;
|
||||
|
||||
private Integer maxDepth = 1;
|
||||
}
|
||||
@@ -90,36 +90,6 @@ public interface CcdiBankTagAnalysisMapper {
|
||||
List<BankTagStatementHitVO> selectLargeTransferStatements(@Param("projectId") Long projectId,
|
||||
@Param("threshold") BigDecimal threshold);
|
||||
|
||||
/**
|
||||
* 外部人员单笔大额交易
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @param threshold 单笔大额阈值
|
||||
* @return 流水命中结果
|
||||
*/
|
||||
List<BankTagStatementHitVO> selectExternalSingleLargeAmountStatements(@Param("projectId") Long projectId,
|
||||
@Param("threshold") BigDecimal threshold);
|
||||
|
||||
/**
|
||||
* 外部人员累计交易超限
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @param threshold 累计交易阈值
|
||||
* @return 对象命中结果
|
||||
*/
|
||||
List<BankTagObjectHitVO> selectExternalCumulativeTransactionAmountObjects(@Param("projectId") Long projectId,
|
||||
@Param("threshold") BigDecimal threshold);
|
||||
|
||||
/**
|
||||
* 外部人员年流水超限
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @param threshold 年流水阈值
|
||||
* @return 对象命中结果
|
||||
*/
|
||||
List<BankTagObjectHitVO> selectExternalAnnualTurnoverObjects(@Param("projectId") Long projectId,
|
||||
@Param("threshold") BigDecimal threshold);
|
||||
|
||||
/**
|
||||
* 与客户之间非正常资金往来
|
||||
*
|
||||
@@ -156,26 +126,6 @@ public interface CcdiBankTagAnalysisMapper {
|
||||
*/
|
||||
List<BankTagStatementHitVO> selectGamblingSensitiveKeywordStatements(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 外部人员疑似赌博摘要
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @return 流水命中结果
|
||||
*/
|
||||
List<BankTagStatementHitVO> selectExternalGamblingMemoStatements(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 外部人员同日多对手方疑似赌博交易
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @param amountMinThreshold 可疑金额下限
|
||||
* @param amountMaxThreshold 可疑金额上限
|
||||
* @return 对象命中结果
|
||||
*/
|
||||
List<BankTagObjectHitVO> selectExternalMultiPartyGamblingTransferObjects(@Param("projectId") Long projectId,
|
||||
@Param("amountMinThreshold") BigDecimal amountMinThreshold,
|
||||
@Param("amountMaxThreshold") BigDecimal amountMaxThreshold);
|
||||
|
||||
/**
|
||||
* 特殊金额交易
|
||||
*
|
||||
@@ -184,22 +134,6 @@ public interface CcdiBankTagAnalysisMapper {
|
||||
*/
|
||||
List<BankTagStatementHitVO> selectSpecialAmountTransactionStatements(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 外部人员与员工或员工亲属交易
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @return 流水命中结果
|
||||
*/
|
||||
List<BankTagStatementHitVO> selectExternalToStaffOrFamilyTransactionStatements(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 外部人员夜间交易
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @return 流水命中结果
|
||||
*/
|
||||
List<BankTagStatementHitVO> selectExternalNightTransactionStatements(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 月度固定收入疑似兼职
|
||||
*
|
||||
@@ -338,11 +272,9 @@ public interface CcdiBankTagAnalysisMapper {
|
||||
* 微信支付宝提现超额
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @param amountThreshold 提现金额阈值
|
||||
* @return 对象命中结果
|
||||
*/
|
||||
List<BankTagObjectHitVO> selectWithdrawAmtObjects(@Param("projectId") Long projectId,
|
||||
@Param("amountThreshold") BigDecimal amountThreshold);
|
||||
List<BankTagObjectHitVO> selectWithdrawAmtObjects(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 工资快速转出
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 资金流图谱Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface CcdiFundGraphMapper {
|
||||
|
||||
List<CcdiFundGraphNodeVO> selectFundGraphSubjects(@Param("query") CcdiFundGraphQueryDTO query);
|
||||
|
||||
List<CcdiFundGraphEdgeVO> selectFundGraphEdges(@Param("query") CcdiFundGraphQueryDTO query);
|
||||
|
||||
List<CcdiFundGraphEdgeVO> selectFundGraphManualEdges(@Param("query") CcdiFundGraphQueryDTO query);
|
||||
|
||||
int countSubjectByObjectKey(@Param("objectKey") String objectKey);
|
||||
|
||||
int insertManualSubject(
|
||||
@Param("objectKey") String objectKey,
|
||||
@Param("idNo") String idNo,
|
||||
@Param("name") String name
|
||||
);
|
||||
|
||||
int insertManualEdge(
|
||||
@Param("objectKey") String objectKey,
|
||||
@Param("dto") CcdiFundGraphManualEdgeSaveDTO dto,
|
||||
@Param("operator") String operator
|
||||
);
|
||||
|
||||
Page<CcdiFundGraphStatementVO> selectFundGraphEdgeDetails(
|
||||
Page<CcdiFundGraphStatementVO> page,
|
||||
@Param("query") CcdiFundGraphEdgeDetailQueryDTO query
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.CcdiProject;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalPersonQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
|
||||
@@ -13,8 +11,6 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativeItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
|
||||
@@ -125,88 +121,6 @@ public interface CcdiProjectOverviewMapper {
|
||||
@Param("query") CcdiProjectRiskModelPeopleQueryDTO query
|
||||
);
|
||||
|
||||
/**
|
||||
* 查询风险模型命中人员导出列表
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 命中人员列表
|
||||
*/
|
||||
List<CcdiProjectRiskModelPeopleItemVO> selectRiskModelPeopleList(
|
||||
@Param("query") CcdiProjectRiskModelPeopleQueryDTO query
|
||||
);
|
||||
|
||||
/**
|
||||
* 分页查询外部人员预警
|
||||
*
|
||||
* @param page 分页参数
|
||||
* @param query 查询条件
|
||||
* @return 外部人员预警分页
|
||||
*/
|
||||
Page<CcdiProjectExternalPersonWarningItemVO> selectExternalPersonWarningPage(
|
||||
Page<CcdiProjectExternalPersonWarningItemVO> page,
|
||||
@Param("query") CcdiProjectExternalPersonQueryDTO query
|
||||
);
|
||||
|
||||
/**
|
||||
* 查询外部人员预警导出列表
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @return 外部人员预警列表
|
||||
*/
|
||||
List<CcdiProjectExternalPersonWarningItemVO> selectExternalPersonWarningList(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 查询外部人员风险等级汇总
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @return 外部人员风险等级汇总
|
||||
*/
|
||||
CcdiProjectExternalRiskSummaryVO selectExternalRiskSummaryByProjectId(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 查询外部人员预警模型卡片
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @return 模型卡片
|
||||
*/
|
||||
List<CcdiProjectRiskModelCardVO> selectExternalRiskModelCardsByProjectId(@Param("projectId") Long projectId);
|
||||
|
||||
/**
|
||||
* 分页查询外部人员模型命中人员
|
||||
*
|
||||
* @param page 分页参数
|
||||
* @param query 查询条件
|
||||
* @return 命中人员分页
|
||||
*/
|
||||
Page<CcdiProjectRiskModelPeopleItemVO> selectExternalRiskModelPeoplePage(
|
||||
Page<CcdiProjectRiskModelPeopleItemVO> page,
|
||||
@Param("query") CcdiProjectExternalRiskModelPeopleQueryDTO query
|
||||
);
|
||||
|
||||
/**
|
||||
* 查询外部人员模型命中人员导出列表
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 命中人员列表
|
||||
*/
|
||||
List<CcdiProjectRiskModelPeopleItemVO> selectExternalRiskModelPeopleList(
|
||||
@Param("query") CcdiProjectExternalRiskModelPeopleQueryDTO query
|
||||
);
|
||||
|
||||
/**
|
||||
* 查询外部人员命中标签
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @param certNo 外部人员证件号
|
||||
* @param selectedModelCodes 已选模型编码CSV,可为空
|
||||
* @return 命中标签列表
|
||||
*/
|
||||
List<CcdiProjectRiskHitTagVO> selectExternalRiskHitTagsByScope(
|
||||
@Param("projectId") Long projectId,
|
||||
@Param("certNo") String certNo,
|
||||
@Param("selectedModelCodes") String selectedModelCodes
|
||||
);
|
||||
|
||||
/**
|
||||
* 分页查询涉疑交易明细
|
||||
*
|
||||
@@ -326,5 +240,4 @@ public interface CcdiProjectOverviewMapper {
|
||||
* @return 风险人数汇总
|
||||
*/
|
||||
Map<String, Object> selectRiskCountSummaryByProjectId(@Param("projectId") Long projectId);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.mapper;
|
||||
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 关系图谱Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface CcdiRelationGraphMapper {
|
||||
|
||||
List<CcdiRelationGraphNodeVO> selectRelationGraphSubjects(@Param("query") CcdiRelationGraphQueryDTO query);
|
||||
|
||||
List<CcdiRelationGraphNodeVO> selectRelationGraphNodesByKeys(@Param("objectKeys") List<String> objectKeys);
|
||||
|
||||
List<CcdiRelationGraphEdgeVO> selectRelationGraphEdges(@Param("query") CcdiRelationGraphQueryDTO query);
|
||||
|
||||
int countSuspectedEnterpriseKeyNos(@Param("personName") String personName);
|
||||
|
||||
List<CcdiRelationGraphSuspectedEnterpriseItemVO> selectSuspectedEnterprises(@Param("personName") String personName,
|
||||
@Param("limit") Integer limit);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 资金流图谱Service接口
|
||||
*/
|
||||
public interface ICcdiFundGraphService {
|
||||
|
||||
List<CcdiFundGraphNodeVO> searchSubjects(CcdiFundGraphQueryDTO queryDTO);
|
||||
|
||||
CcdiFundGraphVO getFundGraph(CcdiFundGraphQueryDTO queryDTO);
|
||||
|
||||
Page<CcdiFundGraphStatementVO> getEdgeDetails(
|
||||
Page<CcdiFundGraphStatementVO> page,
|
||||
CcdiFundGraphEdgeDetailQueryDTO queryDTO
|
||||
);
|
||||
|
||||
CcdiFundGraphEdgeVO saveManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO, String operator);
|
||||
}
|
||||
@@ -2,22 +2,16 @@ package com.ruoyi.ccdi.project.service;
|
||||
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalPersonQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountPageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeCreditNegativePageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
|
||||
@@ -88,80 +82,6 @@ public interface ICcdiProjectOverviewService {
|
||||
return new CcdiProjectRiskModelPeopleVO();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出风险模型命中人员
|
||||
*
|
||||
* @param queryDTO 查询条件
|
||||
* @return 导出列表
|
||||
*/
|
||||
default List<CcdiProjectRiskModelPeopleExcel> exportRiskModelPeople(CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询外部人员预警
|
||||
*
|
||||
* @param queryDTO 查询条件
|
||||
* @return 外部人员预警
|
||||
*/
|
||||
default CcdiProjectExternalPersonWarningVO getExternalPersonWarnings(CcdiProjectExternalPersonQueryDTO queryDTO) {
|
||||
return new CcdiProjectExternalPersonWarningVO();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询外部人员风险等级汇总
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @return 外部人员风险等级汇总
|
||||
*/
|
||||
default CcdiProjectExternalRiskSummaryVO getExternalRiskSummary(Long projectId) {
|
||||
return new CcdiProjectExternalRiskSummaryVO();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出外部人员预警
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @return 导出列表
|
||||
*/
|
||||
default List<CcdiProjectExternalPersonWarningExcel> exportExternalPersonWarnings(Long projectId) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询外部人员风险模型卡片
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @return 风险模型卡片
|
||||
*/
|
||||
default CcdiProjectRiskModelCardsVO getExternalRiskModelCards(Long projectId) {
|
||||
return new CcdiProjectRiskModelCardsVO();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询外部人员风险模型命中人员
|
||||
*
|
||||
* @param queryDTO 查询条件
|
||||
* @return 命中人员
|
||||
*/
|
||||
default CcdiProjectRiskModelPeopleVO getExternalRiskModelPeople(
|
||||
CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO
|
||||
) {
|
||||
return new CcdiProjectRiskModelPeopleVO();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出外部人员风险模型命中人员
|
||||
*
|
||||
* @param queryDTO 查询条件
|
||||
* @return 导出列表
|
||||
*/
|
||||
default List<CcdiProjectRiskModelPeopleExcel> exportExternalRiskModelPeople(
|
||||
CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO
|
||||
) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询涉疑交易明细
|
||||
*
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.service;
|
||||
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 关系图谱Service接口
|
||||
*/
|
||||
public interface ICcdiRelationGraphService {
|
||||
|
||||
List<CcdiRelationGraphNodeVO> searchSubjects(CcdiRelationGraphQueryDTO queryDTO);
|
||||
|
||||
CcdiRelationGraphVO getRelationGraph(CcdiRelationGraphQueryDTO queryDTO);
|
||||
|
||||
CcdiRelationGraphSuspectedEnterpriseVO getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO);
|
||||
}
|
||||
@@ -27,32 +27,20 @@ public class BankTagRuleConfigResolver {
|
||||
private static final Map<String, Set<String>> RULE_PARAM_MAPPING = Map.ofEntries(
|
||||
Map.entry("SINGLE_LARGE_INCOME", Set.of("SINGLE_TRANSACTION_AMOUNT")),
|
||||
Map.entry("CUMULATIVE_INCOME", Set.of("CUMULATIVE_TRANSACTION_AMOUNT")),
|
||||
Map.entry("EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT", Set.of("CUMULATIVE_TRANSACTION_AMOUNT")),
|
||||
Map.entry("ANNUAL_TURNOVER", Set.of("ANNUAL_TURNOVER")),
|
||||
Map.entry("EXTERNAL_ANNUAL_TURNOVER", Set.of("ANNUAL_TURNOVER")),
|
||||
Map.entry("LARGE_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT")),
|
||||
Map.entry("FREQUENT_CASH_DEPOSIT", Set.of("LARGE_CASH_DEPOSIT", "FREQUENT_CASH_DEPOSIT")),
|
||||
Map.entry("LARGE_TRANSFER", Set.of("FREQUENT_TRANSFER")),
|
||||
Map.entry("EXTERNAL_SINGLE_LARGE_AMOUNT", Set.of("FREQUENT_TRANSFER")),
|
||||
Map.entry("FOREX_BUY_AMT", Set.of("SINGLE_PURCHASE_AMOUNT")),
|
||||
Map.entry("FOREX_SELL_AMT", Set.of("SINGLE_SETTLEMENT_AMOUNT")),
|
||||
Map.entry("WITHDRAW_CNT", Set.of("WITHDRAW_CNT")),
|
||||
Map.entry("WITHDRAW_AMT", Set.of("WITHDRAW_AMT")),
|
||||
Map.entry("STOCK_TFR_LARGE", Set.of("STOCK_TFR_LARGE")),
|
||||
Map.entry("LARGE_STOCK_TRADING", Set.of("STOCK_TFR_LARGE")),
|
||||
Map.entry("MULTI_PARTY_GAMBLING_TRANSFER", Set.of("MULTI_PARTY_AMT_MIN", "MULTI_PARTY_AMT_MAX")),
|
||||
Map.entry("EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER", Set.of("MULTI_PARTY_AMT_MIN", "MULTI_PARTY_AMT_MAX")),
|
||||
Map.entry("MONTHLY_FIXED_INCOME", Set.of("MONTHLY_FIXED_INCOME")),
|
||||
Map.entry("FIXED_COUNTERPARTY_TRANSFER", Set.of("FIXED_COUNTERPARTY_TRANSFER_MIN", "FIXED_COUNTERPARTY_TRANSFER_MAX"))
|
||||
);
|
||||
|
||||
private static final Map<String, String> RULE_PARAM_MODEL_MAPPING = Map.of(
|
||||
"EXTERNAL_SINGLE_LARGE_AMOUNT", "LARGE_TRANSACTION",
|
||||
"EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT", "LARGE_TRANSACTION",
|
||||
"EXTERNAL_ANNUAL_TURNOVER", "LARGE_TRANSACTION",
|
||||
"EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER", "SUSPICIOUS_GAMBLING"
|
||||
);
|
||||
|
||||
@Resource
|
||||
private CcdiProjectMapper projectMapper;
|
||||
|
||||
@@ -80,13 +68,12 @@ public class BankTagRuleConfigResolver {
|
||||
}
|
||||
|
||||
Long effectiveProjectId = "default".equals(project.getConfigType()) ? 0L : projectId;
|
||||
String paramModelCode = RULE_PARAM_MODEL_MAPPING.getOrDefault(ruleMeta.getRuleCode(), ruleMeta.getModelCode());
|
||||
List<CcdiModelParam> params = modelParamMapper.selectByProjectAndModel(effectiveProjectId, paramModelCode);
|
||||
List<CcdiModelParam> params = modelParamMapper.selectByProjectAndModel(effectiveProjectId, ruleMeta.getModelCode());
|
||||
|
||||
Map<String, String> thresholdValues = new LinkedHashMap<>();
|
||||
Set<String> requiredParamCodes = RULE_PARAM_MAPPING.getOrDefault(ruleMeta.getRuleCode(), Set.of());
|
||||
log.info("【流水标签】解析规则参数: projectId={}, effectiveProjectId={}, ruleCode={}, paramModelCode={}, requiredParams={}",
|
||||
projectId, effectiveProjectId, ruleMeta.getRuleCode(), paramModelCode, requiredParamCodes);
|
||||
log.info("【流水标签】解析规则参数: projectId={}, effectiveProjectId={}, ruleCode={}, requiredParams={}",
|
||||
projectId, effectiveProjectId, ruleMeta.getRuleCode(), requiredParamCodes);
|
||||
for (CcdiModelParam param : params) {
|
||||
if (requiredParamCodes.contains(param.getParamCode())) {
|
||||
thresholdValues.put(param.getParamCode(), param.getParamValue());
|
||||
|
||||
@@ -225,15 +225,9 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
|
||||
case "LARGE_TRANSFER" -> analysisMapper.selectLargeTransferStatements(
|
||||
projectId, toBigDecimal(config.getThresholdValue("FREQUENT_TRANSFER"))
|
||||
);
|
||||
case "EXTERNAL_SINGLE_LARGE_AMOUNT" -> analysisMapper.selectExternalSingleLargeAmountStatements(
|
||||
projectId, toBigDecimal(config.getThresholdValue("FREQUENT_TRANSFER"))
|
||||
);
|
||||
case "ABNORMAL_CUSTOMER_TRANSACTION" -> analysisMapper.selectAbnormalCustomerTransactionStatements(projectId);
|
||||
case "EXTERNAL_NIGHT_TRANSACTION" -> analysisMapper.selectExternalNightTransactionStatements(projectId);
|
||||
case "GAMBLING_SENSITIVE_KEYWORD" -> analysisMapper.selectGamblingSensitiveKeywordStatements(projectId);
|
||||
case "EXTERNAL_GAMBLING_MEMO" -> analysisMapper.selectExternalGamblingMemoStatements(projectId);
|
||||
case "SPECIAL_AMOUNT_TRANSACTION" -> analysisMapper.selectSpecialAmountTransactionStatements(projectId);
|
||||
case "EXTERNAL_TO_STAFF_FAMILY_TRANSACTION" -> analysisMapper.selectExternalToStaffOrFamilyTransactionStatements(projectId);
|
||||
case "SUSPICIOUS_INCOME_KEYWORD" -> analysisMapper.selectSuspiciousIncomeKeywordStatements(projectId);
|
||||
case "HOUSE_REGISTRATION_MISMATCH" -> analysisMapper.selectHouseRegistrationMismatchStatements(projectId);
|
||||
case "PROPERTY_FEE_REGISTRATION_MISMATCH" -> analysisMapper.selectPropertyFeeRegistrationMismatchStatements(projectId);
|
||||
@@ -264,15 +258,9 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
|
||||
case "CUMULATIVE_INCOME" -> analysisMapper.selectCumulativeIncomeObjects(
|
||||
projectId, toBigDecimal(config.getThresholdValue("CUMULATIVE_TRANSACTION_AMOUNT"))
|
||||
);
|
||||
case "EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT" -> analysisMapper.selectExternalCumulativeTransactionAmountObjects(
|
||||
projectId, toBigDecimal(config.getThresholdValue("CUMULATIVE_TRANSACTION_AMOUNT"))
|
||||
);
|
||||
case "ANNUAL_TURNOVER" -> analysisMapper.selectAnnualTurnoverObjects(
|
||||
projectId, toBigDecimal(config.getThresholdValue("ANNUAL_TURNOVER"))
|
||||
);
|
||||
case "EXTERNAL_ANNUAL_TURNOVER" -> analysisMapper.selectExternalAnnualTurnoverObjects(
|
||||
projectId, toBigDecimal(config.getThresholdValue("ANNUAL_TURNOVER"))
|
||||
);
|
||||
case "FREQUENT_CASH_DEPOSIT" -> analysisMapper.selectFrequentCashDepositObjects(
|
||||
projectId,
|
||||
toBigDecimal(config.getThresholdValue("LARGE_CASH_DEPOSIT")),
|
||||
@@ -284,11 +272,6 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
|
||||
toBigDecimal(config.getThresholdValue("MULTI_PARTY_AMT_MIN")),
|
||||
toBigDecimal(config.getThresholdValue("MULTI_PARTY_AMT_MAX"))
|
||||
);
|
||||
case "EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER" -> analysisMapper.selectExternalMultiPartyGamblingTransferObjects(
|
||||
projectId,
|
||||
toBigDecimal(config.getThresholdValue("MULTI_PARTY_AMT_MIN")),
|
||||
toBigDecimal(config.getThresholdValue("MULTI_PARTY_AMT_MAX"))
|
||||
);
|
||||
case "MONTHLY_FIXED_INCOME" -> analysisMapper.selectMonthlyFixedIncomeObjects(
|
||||
projectId, toBigDecimal(config.getThresholdValue("MONTHLY_FIXED_INCOME"))
|
||||
);
|
||||
@@ -302,9 +285,7 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
|
||||
case "WITHDRAW_CNT" -> analysisMapper.selectWithdrawCntObjects(
|
||||
projectId, toInteger(config.getThresholdValue("WITHDRAW_CNT"))
|
||||
);
|
||||
case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(
|
||||
projectId, toBigDecimal(config.getThresholdValue("WITHDRAW_AMT"))
|
||||
);
|
||||
case "WITHDRAW_AMT" -> analysisMapper.selectWithdrawAmtObjects(projectId);
|
||||
case "SALARY_QUICK_TRANSFER" -> analysisMapper.selectSalaryQuickTransferObjects(projectId);
|
||||
case "SALARY_UNUSED" -> analysisMapper.selectSalaryUnusedObjects(projectId);
|
||||
case "SUDDEN_ACCOUNT_CLOSURE" -> analysisMapper.selectSuddenAccountClosureObjects(projectId);
|
||||
|
||||
@@ -659,7 +659,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
throw new RuntimeException("临时文件不存在: " + tempFilePath);
|
||||
}
|
||||
|
||||
UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file);
|
||||
UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file, record.getFileName());
|
||||
if (uploadResponse == null || uploadResponse.getData() == null
|
||||
|| uploadResponse.getData().getUploadLogList() == null
|
||||
|| uploadResponse.getData().getUploadLogList().isEmpty()) {
|
||||
@@ -673,7 +673,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
}
|
||||
|
||||
log.info("【文件上传】文件上传成功: logId={}", logId);
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId);
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, true);
|
||||
log.info("【文件上传】处理完成: fileName={}", record.getFileName());
|
||||
return true;
|
||||
|
||||
@@ -710,6 +710,14 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
Integer lsfxProjectId,
|
||||
CcdiFileUploadRecord record,
|
||||
Integer logId) {
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false);
|
||||
}
|
||||
|
||||
private void processRecordAfterLogIdReady(Long projectId,
|
||||
Integer lsfxProjectId,
|
||||
CcdiFileUploadRecord record,
|
||||
Integer logId,
|
||||
boolean preserveRecordFileName) {
|
||||
log.info("【文件上传】步骤3: 更新状态为解析中, logId={}", logId);
|
||||
record.setLogId(logId);
|
||||
record.setFileStatus("parsing");
|
||||
@@ -736,11 +744,13 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0);
|
||||
Integer status = logItem.getStatus();
|
||||
String uploadStatusDesc = logItem.getUploadStatusDesc();
|
||||
String fileName = StringUtils.hasText(logItem.getUploadFileName())
|
||||
? logItem.getUploadFileName()
|
||||
: logItem.getDownloadFileName();
|
||||
if (StringUtils.hasText(fileName)) {
|
||||
record.setFileName(fileName);
|
||||
if (!preserveRecordFileName) {
|
||||
String fileName = StringUtils.hasText(logItem.getUploadFileName())
|
||||
? logItem.getUploadFileName()
|
||||
: logItem.getDownloadFileName();
|
||||
if (StringUtils.hasText(fileName)) {
|
||||
record.setFileName(fileName);
|
||||
}
|
||||
}
|
||||
if (logItem.getFileSize() != null) {
|
||||
record.setFileSize(logItem.getFileSize());
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphEdgeDetailQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphManualEdgeSaveDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFundGraphQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphVO;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiFundGraphMapper;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiFundGraphService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.DigestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 资金流图谱Service实现
|
||||
*/
|
||||
@Service
|
||||
public class CcdiFundGraphServiceImpl implements ICcdiFundGraphService {
|
||||
|
||||
private static final int DEFAULT_LIMIT = 20;
|
||||
private static final int MAX_LIMIT = 100;
|
||||
private static final BigDecimal DEFAULT_MIN_TOTAL_AMOUNT = new BigDecimal("1000");
|
||||
private static final Comparator<CcdiFundGraphEdgeVO> EDGE_COMPARATOR = Comparator
|
||||
.comparing(CcdiFundGraphServiceImpl::safeAmount, Comparator.reverseOrder())
|
||||
.thenComparing(CcdiFundGraphServiceImpl::safeTransactionCount, Comparator.reverseOrder())
|
||||
.thenComparing(CcdiFundGraphServiceImpl::safeDateText, Comparator.reverseOrder())
|
||||
.thenComparing(edge -> normalizeSortText(edge == null ? null : edge.getEdgeKey()));
|
||||
|
||||
@Resource
|
||||
private CcdiFundGraphMapper fundGraphMapper;
|
||||
|
||||
@Override
|
||||
public List<CcdiFundGraphNodeVO> searchSubjects(CcdiFundGraphQueryDTO queryDTO) {
|
||||
CcdiFundGraphQueryDTO query = normalizeGraphQuery(queryDTO);
|
||||
if (isBlank(query.getKeyword()) && isBlank(query.getObjectKey())) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return fundGraphMapper.selectFundGraphSubjects(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CcdiFundGraphVO getFundGraph(CcdiFundGraphQueryDTO queryDTO) {
|
||||
CcdiFundGraphQueryDTO query = normalizeGraphQuery(queryDTO);
|
||||
CcdiFundGraphNodeVO centerNode = resolveCenterNode(query);
|
||||
if (centerNode == null || isBlank(centerNode.getObjectKey())) {
|
||||
return new CcdiFundGraphVO();
|
||||
}
|
||||
|
||||
query.setObjectKey(centerNode.getObjectKey());
|
||||
List<CcdiFundGraphEdgeVO> edges = new ArrayList<>();
|
||||
List<CcdiFundGraphEdgeVO> realEdges = fundGraphMapper.selectFundGraphEdges(query);
|
||||
if (realEdges != null) {
|
||||
edges.addAll(realEdges);
|
||||
}
|
||||
List<CcdiFundGraphEdgeVO> manualEdges = fundGraphMapper.selectFundGraphManualEdges(query);
|
||||
if (manualEdges != null) {
|
||||
edges.addAll(manualEdges);
|
||||
}
|
||||
edges = sortAndLimitEdges(edges, query.getLimit());
|
||||
CcdiFundGraphVO graph = new CcdiFundGraphVO();
|
||||
graph.setCenterNode(centerNode);
|
||||
graph.setEdges(edges);
|
||||
graph.setNodes(buildNodes(centerNode, edges));
|
||||
graph.setTransactionCount(edges.stream()
|
||||
.map(CcdiFundGraphEdgeVO::getTransactionCount)
|
||||
.filter(item -> item != null)
|
||||
.reduce(0L, Long::sum));
|
||||
graph.setTotalAmount(edges.stream()
|
||||
.map(CcdiFundGraphEdgeVO::getTotalAmount)
|
||||
.filter(item -> item != null)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
graph.setMaxDepth(1);
|
||||
graph.setTraceReserved(true);
|
||||
return graph;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<CcdiFundGraphStatementVO> getEdgeDetails(
|
||||
Page<CcdiFundGraphStatementVO> page,
|
||||
CcdiFundGraphEdgeDetailQueryDTO queryDTO
|
||||
) {
|
||||
CcdiFundGraphEdgeDetailQueryDTO query = normalizeDetailQuery(queryDTO);
|
||||
if (isBlank(query.getFromKey()) || isBlank(query.getToKey())) {
|
||||
return page;
|
||||
}
|
||||
return fundGraphMapper.selectFundGraphEdgeDetails(page, query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CcdiFundGraphEdgeVO saveManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO, String operator) {
|
||||
CcdiFundGraphManualEdgeSaveDTO dto = normalizeManualEdge(saveDTO);
|
||||
String fromObjectKey = dto.getFromObjectKey();
|
||||
String toObjectKey = resolveManualToObjectKey(dto);
|
||||
dto.setToObjectKey(toObjectKey);
|
||||
|
||||
String edgeObjectKey = md5("MANUAL_EDGE|" + fromObjectKey + "|" + toObjectKey + "|" + dto.getDirection()
|
||||
+ "|" + UUID.randomUUID());
|
||||
fundGraphMapper.insertManualEdge(edgeObjectKey, dto, normalizeText(operator));
|
||||
|
||||
CcdiFundGraphEdgeVO edge = new CcdiFundGraphEdgeVO();
|
||||
edge.setEdgeKey(edgeObjectKey);
|
||||
edge.setFromKey(toSubjectKey(fromObjectKey));
|
||||
edge.setToKey(toSubjectKey(toObjectKey));
|
||||
edge.setFromObjectKey(fromObjectKey);
|
||||
edge.setToObjectKey(toObjectKey);
|
||||
edge.setFromName(dto.getFromName());
|
||||
edge.setToName(dto.getToName());
|
||||
edge.setTotalAmount(dto.getAmount());
|
||||
edge.setTransactionCount(dto.getTransactionCount() == null ? 1L : dto.getTransactionCount().longValue());
|
||||
edge.setDirection(dto.getDirection());
|
||||
edge.setRelationDesc(dto.getRelationDesc());
|
||||
edge.setSourceDesc(dto.getSourceDesc());
|
||||
edge.setRemark(dto.getRemark());
|
||||
edge.setSourceType("MANUAL");
|
||||
edge.setDepth(1);
|
||||
edge.setCanTrace(false);
|
||||
return edge;
|
||||
}
|
||||
|
||||
private CcdiFundGraphNodeVO resolveCenterNode(CcdiFundGraphQueryDTO query) {
|
||||
if (isBlank(query.getObjectKey()) && isBlank(query.getKeyword())) {
|
||||
return null;
|
||||
}
|
||||
List<CcdiFundGraphNodeVO> subjects = fundGraphMapper.selectFundGraphSubjects(query);
|
||||
if (subjects == null || subjects.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return subjects.get(0);
|
||||
}
|
||||
|
||||
private List<CcdiFundGraphNodeVO> buildNodes(CcdiFundGraphNodeVO centerNode, List<CcdiFundGraphEdgeVO> edges) {
|
||||
Map<String, CcdiFundGraphNodeVO> nodeMap = new LinkedHashMap<>();
|
||||
Map<String, CcdiFundGraphNodeVO> subjectCache = new LinkedHashMap<>();
|
||||
subjectCache.put(centerNode.getObjectKey(), centerNode);
|
||||
addNode(nodeMap, centerNode, centerNode.getNodeKey(), centerNode.getObjectKey(), centerNode.getNodeName(),
|
||||
centerNode.getRelationType(), centerNode.getCanExpand(), BigDecimal.ZERO, 0L);
|
||||
|
||||
for (CcdiFundGraphEdgeVO edge : edges) {
|
||||
String centerObjectKey = centerNode.getObjectKey();
|
||||
String fromRelationType = centerObjectKey != null && centerObjectKey.equals(edge.getFromObjectKey())
|
||||
? null
|
||||
: edge.getFamilyRelationType();
|
||||
String toRelationType = centerObjectKey != null && centerObjectKey.equals(edge.getToObjectKey())
|
||||
? null
|
||||
: edge.getFamilyRelationType();
|
||||
addNode(nodeMap, lookupSubject(edge.getFromObjectKey(), subjectCache), edge.getFromKey(),
|
||||
edge.getFromObjectKey(), edge.getFromName(), fromRelationType, true, edge.getTotalAmount(),
|
||||
edge.getTransactionCount());
|
||||
addNode(nodeMap, lookupSubject(edge.getToObjectKey(), subjectCache), edge.getToKey(),
|
||||
edge.getToObjectKey(), edge.getToName(), toRelationType, edge.getCanTrace(),
|
||||
edge.getTotalAmount(), edge.getTransactionCount());
|
||||
}
|
||||
return List.copyOf(nodeMap.values());
|
||||
}
|
||||
|
||||
private CcdiFundGraphNodeVO lookupSubject(String objectKey, Map<String, CcdiFundGraphNodeVO> subjectCache) {
|
||||
if (isBlank(objectKey)) {
|
||||
return null;
|
||||
}
|
||||
if (subjectCache.containsKey(objectKey)) {
|
||||
return subjectCache.get(objectKey);
|
||||
}
|
||||
CcdiFundGraphQueryDTO query = new CcdiFundGraphQueryDTO();
|
||||
query.setObjectKey(objectKey);
|
||||
query.setLimit(DEFAULT_LIMIT);
|
||||
List<CcdiFundGraphNodeVO> subjects = fundGraphMapper.selectFundGraphSubjects(query);
|
||||
CcdiFundGraphNodeVO subject = subjects == null || subjects.isEmpty() ? null : subjects.get(0);
|
||||
subjectCache.put(objectKey, subject);
|
||||
return subject;
|
||||
}
|
||||
|
||||
private String resolveManualToObjectKey(CcdiFundGraphManualEdgeSaveDTO dto) {
|
||||
if (!isBlank(dto.getToObjectKey())) {
|
||||
ensureManualSubject(dto.getToObjectKey(), dto.getToIdNo(), dto.getToName());
|
||||
return dto.getToObjectKey();
|
||||
}
|
||||
String objectKey = !isBlank(dto.getToIdNo())
|
||||
? md5(dto.getToIdNo())
|
||||
: md5("MANUAL_NODE|" + dto.getToName() + "|" + UUID.randomUUID());
|
||||
dto.setToObjectKey(objectKey);
|
||||
ensureManualSubject(objectKey, dto.getToIdNo(), dto.getToName());
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
private void ensureManualSubject(String objectKey, String idNo, String name) {
|
||||
if (fundGraphMapper.countSubjectByObjectKey(objectKey) > 0) {
|
||||
return;
|
||||
}
|
||||
fundGraphMapper.insertManualSubject(objectKey, normalizeText(idNo), normalizeText(name));
|
||||
}
|
||||
|
||||
private CcdiFundGraphManualEdgeSaveDTO normalizeManualEdge(CcdiFundGraphManualEdgeSaveDTO saveDTO) {
|
||||
CcdiFundGraphManualEdgeSaveDTO dto = saveDTO == null ? new CcdiFundGraphManualEdgeSaveDTO() : saveDTO;
|
||||
dto.setFromObjectKey(normalizeText(dto.getFromObjectKey()));
|
||||
dto.setFromName(normalizeText(dto.getFromName()));
|
||||
dto.setToObjectKey(normalizeText(dto.getToObjectKey()));
|
||||
dto.setToName(normalizeText(dto.getToName()));
|
||||
dto.setToIdNo(normalizeText(dto.getToIdNo()));
|
||||
dto.setDirection(normalizeText(dto.getDirection()));
|
||||
dto.setRelationDesc(normalizeText(dto.getRelationDesc()));
|
||||
dto.setSourceDesc(normalizeText(dto.getSourceDesc()));
|
||||
dto.setRemark(normalizeText(dto.getRemark()));
|
||||
if (isBlank(dto.getFromObjectKey())) {
|
||||
throw new IllegalArgumentException("起点主体不能为空");
|
||||
}
|
||||
if (isBlank(dto.getToObjectKey()) && isBlank(dto.getToName())) {
|
||||
throw new IllegalArgumentException("终点主体不能为空");
|
||||
}
|
||||
if (isBlank(dto.getDirection())) {
|
||||
dto.setDirection("1");
|
||||
}
|
||||
if (dto.getAmount() == null) {
|
||||
dto.setAmount(BigDecimal.ZERO);
|
||||
}
|
||||
if (dto.getTransactionCount() == null || dto.getTransactionCount() <= 0) {
|
||||
dto.setTransactionCount(1);
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private void addNode(
|
||||
Map<String, CcdiFundGraphNodeVO> nodeMap,
|
||||
CcdiFundGraphNodeVO subject,
|
||||
String nodeKey,
|
||||
String objectKey,
|
||||
String nodeName,
|
||||
String relationType,
|
||||
Boolean canExpand,
|
||||
BigDecimal edgeAmount,
|
||||
Long edgeCount
|
||||
) {
|
||||
if (isBlank(nodeKey)) {
|
||||
return;
|
||||
}
|
||||
CcdiFundGraphNodeVO node = nodeMap.computeIfAbsent(nodeKey, key -> {
|
||||
CcdiFundGraphNodeVO item = new CcdiFundGraphNodeVO();
|
||||
item.setNodeKey(key);
|
||||
item.setObjectKey(objectKey);
|
||||
item.setNodeName(subject != null && !isBlank(subject.getNodeName())
|
||||
? subject.getNodeName()
|
||||
: (isBlank(nodeName) ? "未知主体" : nodeName));
|
||||
item.setIdNo(subject == null ? null : subject.getIdNo());
|
||||
item.setCinocsno(subject == null ? null : subject.getCinocsno());
|
||||
item.setIdnoType(subject == null ? null : subject.getIdnoType());
|
||||
item.setStaffId(subject == null ? null : subject.getStaffId());
|
||||
item.setSourceType(subject == null ? null : subject.getSourceType());
|
||||
item.setNodeType(subject != null && !isBlank(subject.getNodeType()) ? subject.getNodeType() : "PERSON");
|
||||
item.setIdentityType(subject != null && !isBlank(subject.getIdentityType()) ? subject.getIdentityType() : "IDNO");
|
||||
item.setRelationType(relationType);
|
||||
item.setAccountCount(subject == null ? 0L : subject.getAccountCount());
|
||||
item.setCreatedTime(subject == null ? null : subject.getCreatedTime());
|
||||
item.setUpdatedTime(subject == null ? null : subject.getUpdatedTime());
|
||||
item.setCanExpand(Boolean.TRUE.equals(canExpand));
|
||||
item.setDepth(1);
|
||||
item.setTotalAmount(BigDecimal.ZERO);
|
||||
item.setTransactionCount(0L);
|
||||
return item;
|
||||
});
|
||||
if (isBlank(node.getObjectKey())) {
|
||||
node.setObjectKey(objectKey);
|
||||
}
|
||||
if (isBlank(node.getRelationType())) {
|
||||
node.setRelationType(relationType);
|
||||
}
|
||||
if (Boolean.TRUE.equals(canExpand)) {
|
||||
node.setCanExpand(true);
|
||||
}
|
||||
node.setTotalAmount(node.getTotalAmount().add(edgeAmount == null ? BigDecimal.ZERO : edgeAmount));
|
||||
node.setTransactionCount(node.getTransactionCount() + (edgeCount == null ? 0L : edgeCount));
|
||||
}
|
||||
|
||||
private CcdiFundGraphQueryDTO normalizeGraphQuery(CcdiFundGraphQueryDTO queryDTO) {
|
||||
CcdiFundGraphQueryDTO query = queryDTO == null ? new CcdiFundGraphQueryDTO() : queryDTO;
|
||||
query.setKeyword(normalizeText(query.getKeyword()));
|
||||
query.setObjectKey(normalizeText(query.getObjectKey()));
|
||||
query.setTransactionStartTime(normalizeText(query.getTransactionStartTime()));
|
||||
query.setTransactionEndTime(normalizeText(query.getTransactionEndTime()));
|
||||
query.setDirection(normalizeText(query.getDirection()));
|
||||
if (query.getMinTotalAmount() == null) {
|
||||
query.setMinTotalAmount(DEFAULT_MIN_TOTAL_AMOUNT);
|
||||
}
|
||||
query.setLimit(normalizeLimit(query.getLimit()));
|
||||
query.setDepth(1);
|
||||
return query;
|
||||
}
|
||||
|
||||
private CcdiFundGraphEdgeDetailQueryDTO normalizeDetailQuery(CcdiFundGraphEdgeDetailQueryDTO queryDTO) {
|
||||
CcdiFundGraphEdgeDetailQueryDTO query = queryDTO == null ? new CcdiFundGraphEdgeDetailQueryDTO() : queryDTO;
|
||||
query.setKeyword(normalizeText(query.getKeyword()));
|
||||
query.setFromKey(normalizeText(query.getFromKey()));
|
||||
query.setToKey(normalizeText(query.getToKey()));
|
||||
query.setDirection(normalizeText(query.getDirection()));
|
||||
query.setTransactionStartTime(normalizeText(query.getTransactionStartTime()));
|
||||
query.setTransactionEndTime(normalizeText(query.getTransactionEndTime()));
|
||||
return query;
|
||||
}
|
||||
|
||||
private Integer normalizeLimit(Integer limit) {
|
||||
if (limit == null || limit <= 0) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
return Math.min(limit, MAX_LIMIT);
|
||||
}
|
||||
|
||||
private List<CcdiFundGraphEdgeVO> sortAndLimitEdges(List<CcdiFundGraphEdgeVO> edges, Integer limit) {
|
||||
if (edges == null || edges.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<CcdiFundGraphEdgeVO> sorted = new ArrayList<>(edges);
|
||||
sorted.sort(EDGE_COMPARATOR);
|
||||
int finalLimit = normalizeLimit(limit);
|
||||
if (sorted.size() > finalLimit) {
|
||||
return List.copyOf(sorted.subList(0, finalLimit));
|
||||
}
|
||||
return List.copyOf(sorted);
|
||||
}
|
||||
|
||||
private String normalizeText(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private String toSubjectKey(String objectKey) {
|
||||
return "idno_node/" + objectKey;
|
||||
}
|
||||
|
||||
private String md5(String value) {
|
||||
return DigestUtils.md5DigestAsHex(value.trim().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static BigDecimal safeAmount(CcdiFundGraphEdgeVO edge) {
|
||||
return edge == null || edge.getTotalAmount() == null ? BigDecimal.ZERO : edge.getTotalAmount();
|
||||
}
|
||||
|
||||
private static Long safeTransactionCount(CcdiFundGraphEdgeVO edge) {
|
||||
return edge == null || edge.getTransactionCount() == null ? 0L : edge.getTransactionCount();
|
||||
}
|
||||
|
||||
private static String safeDateText(CcdiFundGraphEdgeVO edge) {
|
||||
return normalizeSortText(edge == null ? null : edge.getLastTrxDate());
|
||||
}
|
||||
|
||||
private static String normalizeSortText(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import com.ruoyi.ccdi.project.service.ICcdiProjectService;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -54,7 +53,6 @@ public class CcdiModelParamServiceImpl implements ICcdiModelParamService {
|
||||
private ICcdiProjectService projectService;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private ICcdiBankTagService bankTagService;
|
||||
|
||||
@Override
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.ruoyi.ccdi.project.service.impl;
|
||||
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
|
||||
@@ -59,7 +58,7 @@ public class CcdiProjectOverviewReportPdfExporter {
|
||||
writeCover(writer, report);
|
||||
writeUploadSubjects(writer, report.getUploadSubjects());
|
||||
writeParams(writer, report.getParams());
|
||||
writeRiskOverview(writer, report);
|
||||
writeRiskModels(writer, report);
|
||||
writeRiskDetails(writer, report);
|
||||
writer.close();
|
||||
document.save(response.getOutputStream());
|
||||
@@ -112,9 +111,9 @@ public class CcdiProjectOverviewReportPdfExporter {
|
||||
);
|
||||
}
|
||||
|
||||
private void writeRiskOverview(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
|
||||
writer.section("三、风险总览");
|
||||
writer.metrics(buildOverallRiskMetrics(report));
|
||||
private void writeRiskModels(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
|
||||
writer.section("三、风险模型");
|
||||
writer.metrics(report.getDashboard().getStats());
|
||||
writer.subsection("风险模型汇总");
|
||||
writer.table(
|
||||
List.of("模型名称", "预警数量", "涉及人员"),
|
||||
@@ -145,40 +144,6 @@ public class CcdiProjectOverviewReportPdfExporter {
|
||||
new float[] { 0.1F, 0.11F, 0.16F, 0.14F, 0.24F, 0.25F },
|
||||
"暂无风险人员与异常点数据"
|
||||
);
|
||||
|
||||
if (hasExternalRisk(report)) {
|
||||
writer.subsection("外部人员预警");
|
||||
writer.metrics(buildExternalMetrics(report));
|
||||
writer.table(
|
||||
List.of("外部模型", "预警数量", "涉及人数"),
|
||||
report.getExternalModelSummaries().stream()
|
||||
.map(item -> List.of(
|
||||
safeText(item.getModelName()),
|
||||
String.valueOf(defaultZero(item.getWarningCount())),
|
||||
formatCount(item.getPeopleCount(), "人")
|
||||
))
|
||||
.collect(Collectors.toList()),
|
||||
new float[] { 0.5F, 0.2F, 0.3F },
|
||||
"暂无外部人员模型汇总数据"
|
||||
);
|
||||
writer.table(
|
||||
List.of("姓名", "证件号", "主体类型", "风险等级", "命中模型数", "核心异常点", "涉及对象", "最近交易时间"),
|
||||
report.getExternalPersonWarnings().stream()
|
||||
.map(item -> List.of(
|
||||
safeText(item.getName()),
|
||||
maskIdCard(item.getIdNo()),
|
||||
safeText(item.getSubjectType()),
|
||||
safeText(item.getRiskLevel()),
|
||||
String.valueOf(defaultZero(item.getModelCount())),
|
||||
safeText(item.getRiskPoint()),
|
||||
safeText(item.getRelatedObject()),
|
||||
safeText(item.getLatestTradeTime())
|
||||
))
|
||||
.collect(Collectors.toList()),
|
||||
new float[] { 0.09F, 0.15F, 0.1F, 0.09F, 0.1F, 0.25F, 0.12F, 0.1F },
|
||||
"暂无外部人员预警数据"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeRiskDetails(PdfPageWriter writer, CcdiProjectOverviewReportVO report) throws IOException {
|
||||
@@ -287,69 +252,6 @@ public class CcdiProjectOverviewReportPdfExporter {
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<CcdiProjectOverviewStatVO> buildOverallRiskMetrics(CcdiProjectOverviewReportVO report) {
|
||||
List<CcdiProjectOverviewStatVO> employeeStats = report.getDashboard().getStats();
|
||||
if (employeeStats == null || employeeStats.isEmpty()) {
|
||||
return List.of(
|
||||
buildMetric("总人数", report.getExternalRiskSummary().getTotal()),
|
||||
buildMetric("高风险", report.getExternalRiskSummary().getHigh()),
|
||||
buildMetric("中风险", report.getExternalRiskSummary().getMedium()),
|
||||
buildMetric("低风险", report.getExternalRiskSummary().getLow()),
|
||||
buildMetric("无风险", report.getExternalRiskSummary().getNoRisk())
|
||||
);
|
||||
}
|
||||
int employeeTotal = metricValue(employeeStats, "people");
|
||||
int high = metricValue(employeeStats, "riskPeople");
|
||||
int medium = metricValue(employeeStats, "medium");
|
||||
int low = metricValue(employeeStats, "low");
|
||||
int noRisk = metricValue(employeeStats, "count");
|
||||
int externalTotal = defaultZero(report.getExternalRiskSummary().getTotal());
|
||||
int externalHigh = defaultZero(report.getExternalRiskSummary().getHigh());
|
||||
int externalMedium = defaultZero(report.getExternalRiskSummary().getMedium());
|
||||
int externalLow = defaultZero(report.getExternalRiskSummary().getLow());
|
||||
int externalNoRisk = defaultZero(report.getExternalRiskSummary().getNoRisk());
|
||||
return List.of(
|
||||
buildMetric("总人数", employeeTotal + externalTotal),
|
||||
buildMetric("高风险", high + externalHigh),
|
||||
buildMetric("中风险", medium + externalMedium),
|
||||
buildMetric("低风险", low + externalLow),
|
||||
buildMetric("无风险", noRisk + externalNoRisk)
|
||||
);
|
||||
}
|
||||
|
||||
private Integer metricValue(List<CcdiProjectOverviewStatVO> stats, String key) {
|
||||
return defaultZero(stats.stream()
|
||||
.filter(stat -> key.equals(stat.getKey()))
|
||||
.findFirst()
|
||||
.map(CcdiProjectOverviewStatVO::getValue)
|
||||
.orElse(0));
|
||||
}
|
||||
|
||||
private boolean hasExternalRisk(CcdiProjectOverviewReportVO report) {
|
||||
return defaultZero(report.getExternalRiskSummary().getHigh()) > 0
|
||||
|| defaultZero(report.getExternalRiskSummary().getMedium()) > 0
|
||||
|| defaultZero(report.getExternalRiskSummary().getLow()) > 0
|
||||
|| !report.getExternalModelSummaries().isEmpty()
|
||||
|| !report.getExternalPersonWarnings().isEmpty();
|
||||
}
|
||||
|
||||
private List<CcdiProjectOverviewStatVO> buildExternalMetrics(CcdiProjectOverviewReportVO report) {
|
||||
return List.of(
|
||||
buildMetric("外部人员", report.getExternalRiskSummary().getTotal()),
|
||||
buildMetric("高风险", report.getExternalRiskSummary().getHigh()),
|
||||
buildMetric("中风险", report.getExternalRiskSummary().getMedium()),
|
||||
buildMetric("低风险", report.getExternalRiskSummary().getLow()),
|
||||
buildMetric("无风险人员", report.getExternalRiskSummary().getNoRisk())
|
||||
);
|
||||
}
|
||||
|
||||
private CcdiProjectOverviewStatVO buildMetric(String label, Integer value) {
|
||||
CcdiProjectOverviewStatVO stat = new CcdiProjectOverviewStatVO();
|
||||
stat.setLabel(label);
|
||||
stat.setValue(defaultZero(value));
|
||||
return stat;
|
||||
}
|
||||
|
||||
private String formatPeopleSummary(CcdiProjectOverviewReportModelSummaryVO item) {
|
||||
String names = safeText(item.getPeopleNames());
|
||||
if ("-".equals(names)) {
|
||||
@@ -491,7 +393,6 @@ public class CcdiProjectOverviewReportPdfExporter {
|
||||
private static final float SUBSECTION_FONT_SIZE = 12F;
|
||||
private static final float LINE_HEIGHT = 12F;
|
||||
private static final float CELL_PADDING = 5F;
|
||||
private static final float TABLE_AFTER_GAP = 32F;
|
||||
|
||||
private final PDDocument document;
|
||||
private final PDType0Font font;
|
||||
@@ -519,7 +420,7 @@ public class CcdiProjectOverviewReportPdfExporter {
|
||||
}
|
||||
|
||||
void title(String text) throws IOException {
|
||||
writeLine(text, TITLE_FONT_SIZE, new Color(18, 56, 93), 0F, 28F, true);
|
||||
writeLine(text, TITLE_FONT_SIZE, new Color(18, 56, 93), 0F, 28F);
|
||||
}
|
||||
|
||||
void text(String text, float fontSize, Color color) throws IOException {
|
||||
@@ -528,12 +429,12 @@ public class CcdiProjectOverviewReportPdfExporter {
|
||||
|
||||
void section(String text) throws IOException {
|
||||
ensureSpace(32F);
|
||||
writeLine(text, SECTION_FONT_SIZE, new Color(18, 56, 93), 0F, 26F, true);
|
||||
writeLine(text, SECTION_FONT_SIZE, new Color(18, 56, 93), 0F, 26F);
|
||||
}
|
||||
|
||||
void subsection(String text) throws IOException {
|
||||
ensureSpace(26F);
|
||||
writeLine(text, SUBSECTION_FONT_SIZE, new Color(51, 65, 85), 0F, 22F, true);
|
||||
writeLine(text, SUBSECTION_FONT_SIZE, new Color(51, 65, 85), 0F, 22F);
|
||||
}
|
||||
|
||||
void separator() throws IOException {
|
||||
@@ -580,7 +481,7 @@ public class CcdiProjectOverviewReportPdfExporter {
|
||||
for (List<String> row : safeRows) {
|
||||
drawRow(row, widths, false);
|
||||
}
|
||||
y -= TABLE_AFTER_GAP;
|
||||
y -= 8F;
|
||||
}
|
||||
|
||||
private float[] calculateWidths(float[] ratios) {
|
||||
@@ -655,17 +556,6 @@ public class CcdiProjectOverviewReportPdfExporter {
|
||||
}
|
||||
|
||||
private void writeLine(String text, float fontSize, Color color, float indent, float advance) throws IOException {
|
||||
writeLine(text, fontSize, color, indent, advance, false);
|
||||
}
|
||||
|
||||
private void writeLine(
|
||||
String text,
|
||||
float fontSize,
|
||||
Color color,
|
||||
float indent,
|
||||
float advance,
|
||||
boolean bold
|
||||
) throws IOException {
|
||||
ensureSpace(advance);
|
||||
content.beginText();
|
||||
content.setNonStrokingColor(color);
|
||||
@@ -673,14 +563,6 @@ public class CcdiProjectOverviewReportPdfExporter {
|
||||
content.newLineAtOffset(MARGIN + indent, y);
|
||||
content.showText(text);
|
||||
content.endText();
|
||||
if (bold) {
|
||||
content.beginText();
|
||||
content.setNonStrokingColor(color);
|
||||
content.setFont(font, fontSize);
|
||||
content.newLineAtOffset(MARGIN + indent + 0.25F, y);
|
||||
content.showText(text);
|
||||
content.endText();
|
||||
}
|
||||
y -= advance;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
package com.ruoyi.ccdi.project.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.CcdiModelParam;
|
||||
import com.ruoyi.ccdi.project.domain.CcdiProject;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalPersonQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectExternalRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskModelPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskModelPeopleExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectRiskPeopleOverviewExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
|
||||
import com.ruoyi.ccdi.project.domain.entity.CcdiProjectOverviewEmployeeResult;
|
||||
@@ -29,33 +26,25 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisAbnormalGroupVO
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardsVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiModelParamService;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -68,7 +57,6 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -86,6 +74,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
@Resource
|
||||
private CcdiProjectMapper projectMapper;
|
||||
|
||||
@Resource
|
||||
private CcdiModelParamMapper modelParamMapper;
|
||||
|
||||
@Resource
|
||||
private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper;
|
||||
|
||||
@@ -101,10 +92,6 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
@Resource
|
||||
private CcdiProjectOverviewReportPdfExporter reportPdfExporter;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private ICcdiModelParamService modelParamService;
|
||||
|
||||
@Override
|
||||
public CcdiProjectOverviewDashboardVO getDashboard(Long projectId) {
|
||||
CcdiProject project = overviewMapper.selectDashboardBaseByProjectId(projectId);
|
||||
@@ -225,108 +212,6 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
return people;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CcdiProjectRiskModelPeopleExcel> exportRiskModelPeople(CcdiProjectRiskModelPeopleQueryDTO queryDTO) {
|
||||
ensureProjectExists(queryDTO.getProjectId());
|
||||
normalizeRiskModelPeopleQuery(queryDTO);
|
||||
|
||||
return defaultList(overviewMapper.selectRiskModelPeopleList(queryDTO)).stream()
|
||||
.map(item -> buildRiskModelPeopleExcelRow(item, "员工"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CcdiProjectExternalPersonWarningVO getExternalPersonWarnings(CcdiProjectExternalPersonQueryDTO queryDTO) {
|
||||
ensureProjectExists(queryDTO.getProjectId());
|
||||
|
||||
Page<CcdiProjectExternalPersonWarningItemVO> page = new Page<>(
|
||||
defaultRiskPeoplePageNum(queryDTO.getPageNum()),
|
||||
defaultRiskPeoplePageSize(queryDTO.getPageSize())
|
||||
);
|
||||
Page<CcdiProjectExternalPersonWarningItemVO> resultPage =
|
||||
overviewMapper.selectExternalPersonWarningPage(page, queryDTO);
|
||||
|
||||
List<CcdiProjectExternalPersonWarningItemVO> rows =
|
||||
defaultList(resultPage == null ? null : resultPage.getRecords()).stream()
|
||||
.peek(item -> item.setActionLabel(ACTION_LABEL))
|
||||
.toList();
|
||||
|
||||
CcdiProjectExternalPersonWarningVO warnings = new CcdiProjectExternalPersonWarningVO();
|
||||
warnings.setRows(rows);
|
||||
warnings.setTotal(resultPage == null ? 0L : resultPage.getTotal());
|
||||
warnings.setPageNum(page.getCurrent());
|
||||
warnings.setPageSize(page.getSize());
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CcdiProjectExternalRiskSummaryVO getExternalRiskSummary(Long projectId) {
|
||||
ensureProjectExists(projectId);
|
||||
CcdiProjectExternalRiskSummaryVO summary = overviewMapper.selectExternalRiskSummaryByProjectId(projectId);
|
||||
if (summary == null) {
|
||||
return new CcdiProjectExternalRiskSummaryVO();
|
||||
}
|
||||
summary.setTotal(defaultZero(summary.getTotal()));
|
||||
summary.setHigh(defaultZero(summary.getHigh()));
|
||||
summary.setMedium(defaultZero(summary.getMedium()));
|
||||
summary.setLow(defaultZero(summary.getLow()));
|
||||
summary.setNoRisk(defaultZero(summary.getNoRisk()));
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CcdiProjectExternalPersonWarningExcel> exportExternalPersonWarnings(Long projectId) {
|
||||
ensureProjectExists(projectId);
|
||||
|
||||
return defaultList(overviewMapper.selectExternalPersonWarningList(projectId)).stream()
|
||||
.map(this::buildExternalPersonWarningExcelRow)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CcdiProjectRiskModelCardsVO getExternalRiskModelCards(Long projectId) {
|
||||
ensureProjectExists(projectId);
|
||||
|
||||
CcdiProjectRiskModelCardsVO cards = new CcdiProjectRiskModelCardsVO();
|
||||
cards.setCardList(defaultList(overviewMapper.selectExternalRiskModelCardsByProjectId(projectId)));
|
||||
return cards;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CcdiProjectRiskModelPeopleVO getExternalRiskModelPeople(CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO) {
|
||||
ensureProjectExists(queryDTO.getProjectId());
|
||||
normalizeExternalRiskModelPeopleQuery(queryDTO);
|
||||
|
||||
Page<CcdiProjectRiskModelPeopleItemVO> page = new Page<>(
|
||||
defaultPageNum(queryDTO.getPageNum()),
|
||||
defaultPageSize(queryDTO.getPageSize())
|
||||
);
|
||||
Page<CcdiProjectRiskModelPeopleItemVO> resultPage =
|
||||
overviewMapper.selectExternalRiskModelPeoplePage(page, queryDTO);
|
||||
|
||||
List<CcdiProjectRiskModelPeopleItemVO> rows = defaultList(resultPage == null ? null : resultPage.getRecords())
|
||||
.stream()
|
||||
.peek(item -> item.setActionLabel(ACTION_LABEL))
|
||||
.toList();
|
||||
|
||||
CcdiProjectRiskModelPeopleVO people = new CcdiProjectRiskModelPeopleVO();
|
||||
people.setRows(rows);
|
||||
people.setTotal(resultPage == null ? 0L : resultPage.getTotal());
|
||||
return people;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CcdiProjectRiskModelPeopleExcel> exportExternalRiskModelPeople(
|
||||
CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO
|
||||
) {
|
||||
ensureProjectExists(queryDTO.getProjectId());
|
||||
normalizeExternalRiskModelPeopleQuery(queryDTO);
|
||||
|
||||
return defaultList(overviewMapper.selectExternalRiskModelPeopleList(queryDTO)).stream()
|
||||
.map(item -> buildRiskModelPeopleExcelRow(item, item.getStaffCode()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CcdiProjectSuspiciousTransactionPageVO getSuspiciousTransactions(
|
||||
CcdiProjectSuspiciousTransactionQueryDTO queryDTO
|
||||
@@ -354,7 +239,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
ensureProjectExists(queryDTO.getProjectId());
|
||||
normalizeSuspiciousTransactionQuery(queryDTO);
|
||||
|
||||
return defaultList(overviewMapper.selectReportSuspiciousTransactionList(queryDTO)).stream()
|
||||
return defaultList(overviewMapper.selectSuspiciousTransactionList(queryDTO)).stream()
|
||||
.map(this::buildSuspiciousTransactionExcelRow)
|
||||
.toList();
|
||||
}
|
||||
@@ -443,14 +328,11 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
report.setUploadSubjects(defaultList(overviewMapper.selectReportUploadSubjects(projectId)).stream()
|
||||
.peek(item -> item.setDataPeriod(formatDataPeriod(item.getMinTrxDate(), item.getMaxTrxDate())))
|
||||
.toList());
|
||||
report.setParams(buildReportParams(projectId));
|
||||
report.setParams(buildReportParams(project));
|
||||
report.setModelSummaries(defaultList(overviewMapper.selectReportRiskModelSummaries(projectId)));
|
||||
report.setExternalRiskSummary(getExternalRiskSummary(projectId));
|
||||
report.setExternalModelSummaries(buildExternalReportModelSummaries(projectId));
|
||||
report.setRiskPeople(defaultList(overviewMapper.selectReportRiskPeople(projectId)).stream()
|
||||
.peek(item -> item.setActionLabel(ACTION_LABEL))
|
||||
.toList());
|
||||
report.setExternalPersonWarnings(exportExternalPersonWarnings(projectId));
|
||||
report.setSuspiciousTransactions(defaultList(
|
||||
overviewMapper.selectReportSuspiciousTransactionList(suspiciousQuery)
|
||||
));
|
||||
@@ -551,51 +433,6 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
return row;
|
||||
}
|
||||
|
||||
private CcdiProjectExternalPersonWarningExcel buildExternalPersonWarningExcelRow(
|
||||
CcdiProjectExternalPersonWarningItemVO item
|
||||
) {
|
||||
CcdiProjectExternalPersonWarningExcel row = new CcdiProjectExternalPersonWarningExcel();
|
||||
row.setName(item.getName());
|
||||
row.setIdNo(item.getIdNo());
|
||||
row.setSubjectType(item.getSubjectType());
|
||||
row.setRiskLevel(item.getRiskLevel());
|
||||
row.setModelCount(item.getModelCount());
|
||||
row.setRiskPoint(item.getRiskPoint());
|
||||
row.setRelatedObject(item.getRelatedObject());
|
||||
row.setLatestTradeTime(item.getLatestTradeTime());
|
||||
return row;
|
||||
}
|
||||
|
||||
private List<CcdiProjectOverviewReportModelSummaryVO> buildExternalReportModelSummaries(Long projectId) {
|
||||
return defaultList(overviewMapper.selectExternalRiskModelCardsByProjectId(projectId)).stream()
|
||||
.map(this::buildExternalReportModelSummary)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private CcdiProjectOverviewReportModelSummaryVO buildExternalReportModelSummary(CcdiProjectRiskModelCardVO card) {
|
||||
CcdiProjectOverviewReportModelSummaryVO row = new CcdiProjectOverviewReportModelSummaryVO();
|
||||
row.setModelCode(card.getModelCode());
|
||||
row.setModelName(card.getModelName());
|
||||
row.setWarningCount(card.getWarningCount());
|
||||
row.setPeopleCount(card.getPeopleCount());
|
||||
row.setPeopleNames("-");
|
||||
return row;
|
||||
}
|
||||
|
||||
private CcdiProjectRiskModelPeopleExcel buildRiskModelPeopleExcelRow(
|
||||
CcdiProjectRiskModelPeopleItemVO item,
|
||||
String subjectType
|
||||
) {
|
||||
CcdiProjectRiskModelPeopleExcel row = new CcdiProjectRiskModelPeopleExcel();
|
||||
row.setPersonName(item.getStaffName());
|
||||
row.setSubjectType(subjectType);
|
||||
row.setIdNo(item.getIdNo());
|
||||
row.setScopeName(item.getDepartment());
|
||||
row.setModelNames(joinModelNames(item.getModelNames()));
|
||||
row.setHitTags(joinHitTagNames(item.getHitTagList()));
|
||||
return row;
|
||||
}
|
||||
|
||||
private void ensureProjectExists(Long projectId) {
|
||||
getRequiredProject(projectId);
|
||||
}
|
||||
@@ -608,14 +445,6 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
queryDTO.setMatchMode(queryDTO.getMatchMode().trim().toUpperCase());
|
||||
}
|
||||
|
||||
private void normalizeExternalRiskModelPeopleQuery(CcdiProjectExternalRiskModelPeopleQueryDTO queryDTO) {
|
||||
if (queryDTO.getMatchMode() == null || queryDTO.getMatchMode().isBlank()) {
|
||||
queryDTO.setMatchMode("ANY");
|
||||
return;
|
||||
}
|
||||
queryDTO.setMatchMode(queryDTO.getMatchMode().trim().toUpperCase());
|
||||
}
|
||||
|
||||
private void normalizeSuspiciousTransactionQuery(CcdiProjectSuspiciousTransactionQueryDTO queryDTO) {
|
||||
if (queryDTO.getSuspiciousType() == null || queryDTO.getSuspiciousType().isBlank()) {
|
||||
queryDTO.setSuspiciousType("ALL");
|
||||
@@ -685,18 +514,15 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
}
|
||||
|
||||
private CcdiProjectSuspiciousTransactionExcel buildSuspiciousTransactionExcelRow(
|
||||
CcdiProjectOverviewReportSuspiciousTransactionVO item
|
||||
CcdiProjectSuspiciousTransactionItemVO item
|
||||
) {
|
||||
CcdiProjectSuspiciousTransactionExcel row = new CcdiProjectSuspiciousTransactionExcel();
|
||||
row.setTrxDate(item.getTrxDate());
|
||||
row.setLeAccountNo(item.getLeAccountNo());
|
||||
row.setLeAccountName(item.getLeAccountName());
|
||||
row.setCustomerAccountName(item.getCustomerAccountName());
|
||||
row.setCustomerAccountNo(item.getCustomerAccountNo());
|
||||
row.setSuspiciousPersonName(item.getSuspiciousPersonName());
|
||||
row.setRelatedPersonName(item.getRelatedPersonName());
|
||||
row.setRelatedStaffDisplay(formatRelatedStaff(item.getRelatedStaffName(), item.getRelatedStaffCode()));
|
||||
row.setUserMemo(item.getUserMemo());
|
||||
row.setCashType(item.getCashType());
|
||||
row.setHitTags(item.getHitTags());
|
||||
row.setRelationType(item.getRelationType());
|
||||
row.setSummaryAndCashType(formatSummaryAndCashType(item.getUserMemo(), item.getCashType()));
|
||||
row.setDisplayAmount(item.getDisplayAmount());
|
||||
return row;
|
||||
}
|
||||
@@ -728,21 +554,23 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
return row;
|
||||
}
|
||||
|
||||
private List<CcdiProjectOverviewReportParamVO> buildReportParams(Long projectId) {
|
||||
ModelParamAllVO response = modelParamService.selectAllParams(projectId);
|
||||
return defaultList(response == null ? null : response.getModels()).stream()
|
||||
.flatMap(model -> defaultList(model.getParams()).stream().map(param -> {
|
||||
CcdiProjectOverviewReportParamVO row = new CcdiProjectOverviewReportParamVO();
|
||||
row.setModelName(model.getModelName());
|
||||
row.setParamName(param.getParamName());
|
||||
row.setParamValue(param.getParamValue());
|
||||
row.setParamUnit(param.getParamUnit());
|
||||
row.setParamDesc(param.getParamDesc());
|
||||
return row;
|
||||
}))
|
||||
private List<CcdiProjectOverviewReportParamVO> buildReportParams(CcdiProject project) {
|
||||
Long effectiveProjectId = "default".equals(project.getConfigType()) ? 0L : project.getProjectId();
|
||||
return defaultList(modelParamMapper.selectByProjectId(effectiveProjectId)).stream()
|
||||
.map(this::buildReportParamRow)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private CcdiProjectOverviewReportParamVO buildReportParamRow(CcdiModelParam param) {
|
||||
CcdiProjectOverviewReportParamVO row = new CcdiProjectOverviewReportParamVO();
|
||||
row.setModelName(param.getModelName());
|
||||
row.setParamName(param.getParamName());
|
||||
row.setParamValue(param.getParamValue());
|
||||
row.setParamUnit(param.getParamUnit());
|
||||
row.setParamDesc(param.getParamDesc());
|
||||
return row;
|
||||
}
|
||||
|
||||
private String formatDataPeriod(String minTrxDate, String maxTrxDate) {
|
||||
if (minTrxDate == null || minTrxDate.isBlank() || maxTrxDate == null || maxTrxDate.isBlank()) {
|
||||
return "-";
|
||||
@@ -769,21 +597,6 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
|
||||
return safeMemo + "/" + safeCashType;
|
||||
}
|
||||
|
||||
private String joinModelNames(List<String> modelNames) {
|
||||
return defaultList(modelNames).stream()
|
||||
.filter(item -> item != null && !item.isBlank())
|
||||
.distinct()
|
||||
.collect(Collectors.joining("、"));
|
||||
}
|
||||
|
||||
private String joinHitTagNames(List<CcdiProjectRiskHitTagVO> hitTags) {
|
||||
return defaultList(hitTags).stream()
|
||||
.map(CcdiProjectRiskHitTagVO::getRuleName)
|
||||
.filter(item -> item != null && !item.isBlank())
|
||||
.distinct()
|
||||
.collect(Collectors.joining("、"));
|
||||
}
|
||||
|
||||
private CcdiProjectPersonAnalysisAbnormalDetailVO buildAbnormalDetail(
|
||||
List<CcdiBankStatementListVO> statementRows,
|
||||
List<CcdiProjectPersonAnalysisObjectRecordVO> objectRows
|
||||
|
||||
@@ -44,33 +44,19 @@ public class CcdiProjectRiskDetailWorkbookExporter {
|
||||
|
||||
private void writeSuspiciousSheet(Sheet sheet, List<CcdiProjectSuspiciousTransactionExcel> rows) {
|
||||
Row header = sheet.createRow(0);
|
||||
String[] headers = {
|
||||
"交易时间",
|
||||
"本方账户",
|
||||
"本方主体",
|
||||
"对方名称",
|
||||
"对方账户",
|
||||
"关联员工",
|
||||
"摘要",
|
||||
"交易类型",
|
||||
"异常标签",
|
||||
"交易金额"
|
||||
};
|
||||
String[] headers = { "交易时间", "可疑人员", "关联人", "关联员工", "关系", "摘要/交易类型", "交易金额" };
|
||||
writeHeader(header, headers);
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
CcdiProjectSuspiciousTransactionExcel item = rows.get(i);
|
||||
Row row = sheet.createRow(i + 1);
|
||||
row.createCell(0).setCellValue(safeText(item.getTrxDate()));
|
||||
row.createCell(1).setCellValue(safeText(item.getLeAccountNo()));
|
||||
row.createCell(2).setCellValue(safeText(item.getLeAccountName()));
|
||||
row.createCell(3).setCellValue(safeText(item.getCustomerAccountName()));
|
||||
row.createCell(4).setCellValue(safeText(item.getCustomerAccountNo()));
|
||||
row.createCell(5).setCellValue(safeText(item.getRelatedStaffDisplay()));
|
||||
row.createCell(6).setCellValue(safeText(item.getUserMemo()));
|
||||
row.createCell(7).setCellValue(safeText(item.getCashType()));
|
||||
row.createCell(8).setCellValue(safeText(item.getHitTags()));
|
||||
row.createCell(9).setCellValue(safeNumber(item.getDisplayAmount()));
|
||||
row.createCell(1).setCellValue(safeText(item.getSuspiciousPersonName()));
|
||||
row.createCell(2).setCellValue(safeText(item.getRelatedPersonName()));
|
||||
row.createCell(3).setCellValue(safeText(item.getRelatedStaffDisplay()));
|
||||
row.createCell(4).setCellValue(safeText(item.getRelationType()));
|
||||
row.createCell(5).setCellValue(safeText(item.getSummaryAndCashType()));
|
||||
row.createCell(6).setCellValue(safeNumber(item.getDisplayAmount()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
package com.ruoyi.ccdi.project.service.impl;
|
||||
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiRelationGraphSuspectedEnterpriseQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphVO;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiRelationGraphMapper;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiRelationGraphService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 关系图谱Service实现
|
||||
*/
|
||||
@Service
|
||||
public class CcdiRelationGraphServiceImpl implements ICcdiRelationGraphService {
|
||||
|
||||
private static final int DEFAULT_LIMIT = 80;
|
||||
private static final int MAX_LIMIT = 200;
|
||||
private static final int DEFAULT_SUSPECTED_LIMIT = 10;
|
||||
private static final int MAX_SUSPECTED_LIMIT = 20;
|
||||
private static final int SAME_NAME_BLOCK_THRESHOLD = 20;
|
||||
private static final String NODE_PREFIX = "rel_node/";
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
@Resource
|
||||
private CcdiRelationGraphMapper relationGraphMapper;
|
||||
|
||||
@Override
|
||||
public List<CcdiRelationGraphNodeVO> searchSubjects(CcdiRelationGraphQueryDTO queryDTO) {
|
||||
CcdiRelationGraphQueryDTO query = normalizeGraphQuery(queryDTO);
|
||||
if (isBlank(query.getKeyword()) && isBlank(query.getObjectKey())) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return relationGraphMapper.selectRelationGraphSubjects(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CcdiRelationGraphVO getRelationGraph(CcdiRelationGraphQueryDTO queryDTO) {
|
||||
CcdiRelationGraphQueryDTO query = normalizeGraphQuery(queryDTO);
|
||||
CcdiRelationGraphNodeVO centerNode = resolveCenterNode(query);
|
||||
if (centerNode == null || isBlank(centerNode.getObjectKey())) {
|
||||
return new CcdiRelationGraphVO();
|
||||
}
|
||||
|
||||
query.setObjectKey(centerNode.getObjectKey());
|
||||
List<CcdiRelationGraphEdgeVO> edges = relationGraphMapper.selectRelationGraphEdges(query);
|
||||
if (edges == null) {
|
||||
edges = Collections.emptyList();
|
||||
}
|
||||
|
||||
List<CcdiRelationGraphNodeVO> nodes = buildNodes(centerNode, edges);
|
||||
CcdiRelationGraphVO graph = new CcdiRelationGraphVO();
|
||||
graph.setCenterNode(centerNode);
|
||||
graph.setNodes(nodes);
|
||||
graph.setEdges(edges);
|
||||
graph.setEdgeCount((long) edges.size());
|
||||
graph.setMaxDepth(1);
|
||||
return graph;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CcdiRelationGraphSuspectedEnterpriseVO getSuspectedEnterprises(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
|
||||
CcdiRelationGraphSuspectedEnterpriseVO result = new CcdiRelationGraphSuspectedEnterpriseVO();
|
||||
CcdiRelationGraphSuspectedEnterpriseQueryDTO query = normalizeSuspectedEnterpriseQuery(queryDTO);
|
||||
if (isBlank(query.getPersonName())) {
|
||||
result.setMessage("缺少人员姓名,无法按工商同名主体召回");
|
||||
return result;
|
||||
}
|
||||
|
||||
int sameNameKeyNoCount = relationGraphMapper.countSuspectedEnterpriseKeyNos(query.getPersonName());
|
||||
result.setSameNameKeyNoCount(sameNameKeyNoCount);
|
||||
if (sameNameKeyNoCount > SAME_NAME_BLOCK_THRESHOLD) {
|
||||
result.setBlocked(true);
|
||||
result.setMessage("同名工商主体过多,请结合交易对手企业名称或其他线索进一步筛选");
|
||||
return result;
|
||||
}
|
||||
|
||||
LocalDate birthDate = resolveBirthDate(query);
|
||||
List<CcdiRelationGraphSuspectedEnterpriseItemVO> rows =
|
||||
relationGraphMapper.selectSuspectedEnterprises(query.getPersonName(), MAX_SUSPECTED_LIMIT);
|
||||
List<CcdiRelationGraphSuspectedEnterpriseItemVO> filteredRows = new ArrayList<>();
|
||||
if (rows != null) {
|
||||
for (CcdiRelationGraphSuspectedEnterpriseItemVO row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
row.setPersonName(query.getPersonName());
|
||||
if (applyAgeRule(row, birthDate)) {
|
||||
filteredRows.add(row);
|
||||
if (filteredRows.size() >= query.getLimit()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.setRows(filteredRows);
|
||||
if (filteredRows.isEmpty()) {
|
||||
result.setMessage("未发现可展示的疑似同名企业");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private CcdiRelationGraphNodeVO resolveCenterNode(CcdiRelationGraphQueryDTO query) {
|
||||
List<CcdiRelationGraphNodeVO> subjects = relationGraphMapper.selectRelationGraphSubjects(query);
|
||||
if (subjects == null || subjects.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return subjects.get(0);
|
||||
}
|
||||
|
||||
private List<CcdiRelationGraphNodeVO> buildNodes(CcdiRelationGraphNodeVO centerNode, List<CcdiRelationGraphEdgeVO> edges) {
|
||||
Set<String> objectKeys = new LinkedHashSet<>();
|
||||
objectKeys.add(centerNode.getObjectKey());
|
||||
for (CcdiRelationGraphEdgeVO edge : edges) {
|
||||
edge.setFromObjectKey(toObjectKey(edge.getFromKey()));
|
||||
edge.setToObjectKey(toObjectKey(edge.getToKey()));
|
||||
if (!isBlank(edge.getFromObjectKey())) {
|
||||
objectKeys.add(edge.getFromObjectKey());
|
||||
}
|
||||
if (!isBlank(edge.getToObjectKey())) {
|
||||
objectKeys.add(edge.getToObjectKey());
|
||||
}
|
||||
}
|
||||
|
||||
List<CcdiRelationGraphNodeVO> rawNodes = relationGraphMapper.selectRelationGraphNodesByKeys(new ArrayList<>(objectKeys));
|
||||
Map<String, CcdiRelationGraphNodeVO> nodeMap = new LinkedHashMap<>();
|
||||
if (rawNodes != null) {
|
||||
for (CcdiRelationGraphNodeVO node : rawNodes) {
|
||||
enrichNode(node, centerNode);
|
||||
nodeMap.put(node.getObjectKey(), node);
|
||||
}
|
||||
}
|
||||
if (!nodeMap.containsKey(centerNode.getObjectKey())) {
|
||||
enrichNode(centerNode, centerNode);
|
||||
nodeMap.put(centerNode.getObjectKey(), centerNode);
|
||||
}
|
||||
|
||||
for (CcdiRelationGraphEdgeVO edge : edges) {
|
||||
CcdiRelationGraphNodeVO fromNode = nodeMap.get(edge.getFromObjectKey());
|
||||
CcdiRelationGraphNodeVO toNode = nodeMap.get(edge.getToObjectKey());
|
||||
if (fromNode != null) {
|
||||
edge.setFromName(fromNode.getNodeName());
|
||||
}
|
||||
if (toNode != null) {
|
||||
edge.setToName(toNode.getNodeName());
|
||||
}
|
||||
}
|
||||
return List.copyOf(nodeMap.values());
|
||||
}
|
||||
|
||||
private void enrichNode(CcdiRelationGraphNodeVO node, CcdiRelationGraphNodeVO centerNode) {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
node.setNodeKey(NODE_PREFIX + node.getObjectKey());
|
||||
node.setCanExpand(true);
|
||||
node.setDepth(node.getObjectKey() != null && node.getObjectKey().equals(centerNode.getObjectKey()) ? 0 : 1);
|
||||
}
|
||||
|
||||
private CcdiRelationGraphQueryDTO normalizeGraphQuery(CcdiRelationGraphQueryDTO queryDTO) {
|
||||
CcdiRelationGraphQueryDTO query = queryDTO == null ? new CcdiRelationGraphQueryDTO() : queryDTO;
|
||||
query.setKeyword(normalizeText(query.getKeyword()));
|
||||
query.setObjectKey(normalizeText(query.getObjectKey()));
|
||||
query.setLimit(normalizeLimit(query.getLimit()));
|
||||
query.setDepth(1);
|
||||
return query;
|
||||
}
|
||||
|
||||
private CcdiRelationGraphSuspectedEnterpriseQueryDTO normalizeSuspectedEnterpriseQuery(CcdiRelationGraphSuspectedEnterpriseQueryDTO queryDTO) {
|
||||
CcdiRelationGraphSuspectedEnterpriseQueryDTO query =
|
||||
queryDTO == null ? new CcdiRelationGraphSuspectedEnterpriseQueryDTO() : queryDTO;
|
||||
query.setPersonName(normalizeText(query.getPersonName()));
|
||||
query.setCertNo(normalizeText(query.getCertNo()));
|
||||
query.setBirthDate(normalizeText(query.getBirthDate()));
|
||||
query.setLimit(normalizeSuspectedLimit(query.getLimit()));
|
||||
return query;
|
||||
}
|
||||
|
||||
private Integer normalizeSuspectedLimit(Integer limit) {
|
||||
if (limit == null || limit <= 0) {
|
||||
return DEFAULT_SUSPECTED_LIMIT;
|
||||
}
|
||||
return Math.min(limit, MAX_SUSPECTED_LIMIT);
|
||||
}
|
||||
|
||||
private LocalDate resolveBirthDate(CcdiRelationGraphSuspectedEnterpriseQueryDTO query) {
|
||||
LocalDate explicitBirthDate = parseDate(query.getBirthDate());
|
||||
if (explicitBirthDate != null) {
|
||||
return explicitBirthDate;
|
||||
}
|
||||
return parseBirthDateFromCertNo(query.getCertNo());
|
||||
}
|
||||
|
||||
private boolean applyAgeRule(CcdiRelationGraphSuspectedEnterpriseItemVO row, LocalDate birthDate) {
|
||||
LocalDate establishDate = parseDate(row.getEstablishDate());
|
||||
if (birthDate == null || establishDate == null) {
|
||||
row.setMatchReason("姓名一致;企业成立日期或出生日期缺失,年龄无法判断");
|
||||
return true;
|
||||
}
|
||||
int age = Period.between(birthDate, establishDate).getYears();
|
||||
row.setAgeAtEstablish(age);
|
||||
if (age < 18) {
|
||||
return false;
|
||||
}
|
||||
row.setMatchReason("姓名一致;成立时年龄" + age + "岁");
|
||||
return true;
|
||||
}
|
||||
|
||||
private LocalDate parseBirthDateFromCertNo(String certNo) {
|
||||
if (isBlank(certNo)) {
|
||||
return null;
|
||||
}
|
||||
String value = certNo.trim();
|
||||
if (value.matches("^\\d{17}[0-9Xx]$")) {
|
||||
return parseCompactDate(value.substring(6, 14));
|
||||
}
|
||||
if (value.matches("^\\d{15}$")) {
|
||||
return parseCompactDate("19" + value.substring(6, 12));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LocalDate parseCompactDate(String value) {
|
||||
try {
|
||||
return LocalDate.parse(value, DateTimeFormatter.BASIC_ISO_DATE);
|
||||
} catch (DateTimeParseException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDate parseDate(String value) {
|
||||
if (isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value.trim(), DATE_FORMATTER);
|
||||
} catch (DateTimeParseException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Integer normalizeLimit(Integer limit) {
|
||||
if (limit == null || limit <= 0) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
return Math.min(limit, MAX_LIMIT);
|
||||
}
|
||||
|
||||
private String toObjectKey(String nodeKey) {
|
||||
if (isBlank(nodeKey)) {
|
||||
return null;
|
||||
}
|
||||
return nodeKey.startsWith(NODE_PREFIX) ? nodeKey.substring(NODE_PREFIX.length()) : nodeKey;
|
||||
}
|
||||
|
||||
private String normalizeText(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -215,12 +215,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
#{item}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="query.ourCertNos != null and query.ourCertNos.size() > 0">
|
||||
AND bs.cret_no IN
|
||||
<foreach collection="query.ourCertNos" 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=")">
|
||||
|
||||
@@ -41,23 +41,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
and trim(bs.cret_no) != ''
|
||||
</sql>
|
||||
|
||||
<sql id="externalPersonPredicateSql">
|
||||
bs.cret_no is not null
|
||||
and trim(bs.cret_no) != ''
|
||||
and not exists (
|
||||
select 1
|
||||
from ccdi_base_staff staff
|
||||
where staff.id_card = bs.cret_no
|
||||
)
|
||||
and not exists (
|
||||
select 1
|
||||
from ccdi_staff_fmy_relation relation
|
||||
where relation.status = 1
|
||||
and relation.relation_cert_no = bs.cret_no
|
||||
)
|
||||
and trim(IFNULL(bs.LE_ACCOUNT_NAME, '')) <> trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''))
|
||||
</sql>
|
||||
|
||||
<sql id="cashDepositPredicate">
|
||||
(
|
||||
(
|
||||
@@ -122,123 +105,36 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
|
||||
<sql id="salaryExclusionPredicate">
|
||||
not (
|
||||
(
|
||||
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
|
||||
and (
|
||||
IFNULL(bs.USER_MEMO, '') LIKE '%代发%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%工资%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%奖金%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%薪酬%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%薪金%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%补贴%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%薪%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%年终奖%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%年金%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%加班费%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务费%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务外包%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%提成%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务派遣%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%绩效%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%酬劳%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%PAYROLL%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%SALA%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%CPF%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%directors%fees%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
|
||||
or IFNULL(bs.CASH_TYPE, '') LIKE '%代发%'
|
||||
or IFNULL(bs.CASH_TYPE, '') LIKE '%工资%'
|
||||
or IFNULL(bs.CASH_TYPE, '') LIKE '%劳务费%'
|
||||
)
|
||||
)
|
||||
or (
|
||||
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%公积金中心%'
|
||||
and (
|
||||
IFNULL(bs.USER_MEMO, '') LIKE '%公积金%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
|
||||
or IFNULL(bs.CASH_TYPE, '') LIKE '%公积金%'
|
||||
or IFNULL(bs.CASH_TYPE, '') LIKE '%批量代付%'
|
||||
)
|
||||
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
|
||||
and (
|
||||
IFNULL(bs.USER_MEMO, '') LIKE '%代发%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%工资%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%奖金%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%薪酬%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%薪金%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%补贴%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%薪%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%年终奖%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%年金%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%加班费%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务费%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务外包%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%提成%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%劳务派遣%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%绩效%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%酬劳%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%PAYROLL%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%SALA%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%CPF%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%directors%fees%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
|
||||
or IFNULL(bs.CASH_TYPE, '') LIKE '%代发%'
|
||||
or IFNULL(bs.CASH_TYPE, '') LIKE '%工资%'
|
||||
or IFNULL(bs.CASH_TYPE, '') LIKE '%劳务费%'
|
||||
)
|
||||
)
|
||||
</sql>
|
||||
|
||||
<sql id="financialProductExclusionPredicate">
|
||||
not (
|
||||
(
|
||||
(
|
||||
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '理财|理财产品|结构.*存款|结构性存款|理财.*托管|余额宝|朝朝宝|朝朝盈|现金宝|金添利|定存宝'
|
||||
or IFNULL(bs.USER_MEMO, '') REGEXP '理财|理财产品|结构.*存款|结构性存款|本金划出|本金返还|余额宝|朝朝宝|朝朝盈|现金宝|金添利|定存宝|整存整取|智能存款|通知存款'
|
||||
or IFNULL(bs.CASH_TYPE, '') REGEXP '受托理财|表内理财|购买理财|理财购买|理财扣款|理财申购|理财认购|结构性存款|存款产品|朝朝宝'
|
||||
or (
|
||||
IFNULL(bs.USER_MEMO, '') REGEXP '申购|认购|赎回'
|
||||
and IFNULL(bs.USER_MEMO, '') REGEXP '理财|产品|存款|本金|余额宝|朝朝宝|朝朝盈'
|
||||
)
|
||||
)
|
||||
and IFNULL(bs.USER_MEMO, '') NOT REGEXP '财务|经理|代理财税'
|
||||
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') NOT LIKE '%代理财政%'
|
||||
)
|
||||
)
|
||||
</sql>
|
||||
|
||||
<sql id="thirdPartyWithdrawIncomePredicate">
|
||||
(
|
||||
(
|
||||
bs.BANK in ('ALIPAY', 'WECHAT')
|
||||
and (
|
||||
IFNULL(bs.CASH_TYPE, '') LIKE '%提现%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%提现%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%转出到%银行%'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现%'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%转出到%银行%'
|
||||
)
|
||||
)
|
||||
or (
|
||||
(
|
||||
bs.BANK is null
|
||||
or bs.BANK = ''
|
||||
or bs.BANK not in ('ALIPAY', 'WECHAT')
|
||||
)
|
||||
and (
|
||||
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
|
||||
or IFNULL(bs.USER_MEMO, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
|
||||
or IFNULL(bs.CASH_TYPE, '') REGEXP '支付宝|Alipay|财付通|Tenpay|微信|wechat|WeChat|微信零钱'
|
||||
)
|
||||
and (
|
||||
IFNULL(bs.CASH_TYPE, '') LIKE '%提现%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%提现%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%转出到%银行%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%提现到账%'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现%'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%转出到%银行%'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%提现到账%'
|
||||
)
|
||||
)
|
||||
)
|
||||
</sql>
|
||||
|
||||
<sql id="abnormalCustomerTransactionSubjectSql">
|
||||
select
|
||||
staff.id_card as subjectCertNo,
|
||||
staff.name as subjectName,
|
||||
'本人' as subjectType
|
||||
from ccdi_base_staff staff
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
relation.relation_cert_no as subjectCertNo,
|
||||
relation.relation_name as subjectName,
|
||||
case
|
||||
when relation.relation_type is not null and trim(relation.relation_type) != '' then relation.relation_type
|
||||
else '关系人'
|
||||
end as subjectType
|
||||
from ccdi_staff_fmy_relation relation
|
||||
where relation.status = 1
|
||||
and relation.relation_cert_no is not null
|
||||
and trim(relation.relation_cert_no) != ''
|
||||
</sql>
|
||||
|
||||
<sql id="salaryIncomePredicate">
|
||||
bs.CUSTOMER_ACCOUNT_NAME = '浙江兰溪农村商业银行股份有限公司'
|
||||
and (
|
||||
@@ -271,7 +167,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
IFNULL(bs.USER_MEMO, '') REGEXP '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局'
|
||||
)
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
and (
|
||||
exists (
|
||||
select 1
|
||||
@@ -303,7 +198,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
IFNULL(bs.USER_MEMO, '') REGEXP '税务|缴税|税款'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '税务|税务局|国库|国家金库|财政'
|
||||
)
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
and (
|
||||
exists (
|
||||
select 1
|
||||
@@ -341,7 +235,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
and IFNULL(bs.LE_ACCOUNT_NAME, '') <> IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
|
||||
and relation.person_id is null
|
||||
and <include refid="salaryExclusionPredicate"/>
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
</select>
|
||||
|
||||
<select id="selectCumulativeIncomeObjects" resultMap="BankTagObjectHitResultMap">
|
||||
@@ -369,7 +262,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
and IFNULL(bs.LE_ACCOUNT_NAME, '') <> IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
|
||||
and relation.person_id is null
|
||||
and <include refid="salaryExclusionPredicate"/>
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
group by staff.id_card, bs.CUSTOMER_ACCOUNT_NAME
|
||||
having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
|
||||
) t
|
||||
@@ -391,7 +283,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
|
||||
where bs.project_id = #{projectId}
|
||||
and IFNULL(bs.LE_ACCOUNT_NAME, '') <> IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
|
||||
group by staff.id_card
|
||||
having SUM(IFNULL(bs.AMOUNT_DR, 0) + IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
|
||||
@@ -411,11 +302,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
where bs.project_id = #{projectId}
|
||||
and IFNULL(bs.AMOUNT_CR, 0) > #{threshold}
|
||||
and <include refid="cashDepositPredicate"/>
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
and exists (
|
||||
select 1
|
||||
from ccdi_base_staff staff
|
||||
where staff.id_card = bs.cret_no
|
||||
and (
|
||||
exists (
|
||||
select 1
|
||||
from ccdi_base_staff staff
|
||||
where staff.id_card = bs.cret_no
|
||||
)
|
||||
or exists (
|
||||
select 1
|
||||
from ccdi_staff_fmy_relation relation
|
||||
where relation.relation_cert_no = bs.cret_no
|
||||
and relation.status = 1
|
||||
)
|
||||
)
|
||||
</select>
|
||||
|
||||
@@ -442,7 +340,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
where bs.project_id = #{projectId}
|
||||
and IFNULL(bs.AMOUNT_CR, 0) > #{amountThreshold}
|
||||
and <include refid="cashDepositPredicate"/>
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
union all
|
||||
select
|
||||
relation.person_id AS object_key,
|
||||
LEFT(TRIM(bs.TRX_DATE), 10) AS cash_date
|
||||
from ccdi_bank_statement bs
|
||||
inner join ccdi_staff_fmy_relation relation on relation.relation_cert_no = bs.cret_no
|
||||
where bs.project_id = #{projectId}
|
||||
and relation.status = 1
|
||||
and IFNULL(bs.AMOUNT_CR, 0) > #{amountThreshold}
|
||||
and <include refid="cashDepositPredicate"/>
|
||||
) source
|
||||
group by source.object_key, source.cash_date
|
||||
having COUNT(1) > #{frequencyThreshold}
|
||||
@@ -466,8 +373,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
or IFNULL(bs.USER_MEMO, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入'
|
||||
or IFNULL(bs.CASH_TYPE, '') REGEXP '转帐|转账|汇入|转存|红包|汇款|网转|转入'
|
||||
)
|
||||
and IFNULL(bs.USER_MEMO, '') NOT LIKE '%款%'
|
||||
and IFNULL(bs.LE_ACCOUNT_NAME, '') <> IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
and (
|
||||
exists (
|
||||
select 1
|
||||
@@ -483,200 +390,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
)
|
||||
</select>
|
||||
|
||||
<select id="selectExternalSingleLargeAmountStatements" resultMap="BankTagStatementHitResultMap">
|
||||
<select id="selectAbnormalCustomerTransactionStatements" resultMap="BankTagStatementHitResultMap">
|
||||
select
|
||||
bs.bank_statement_id AS bankStatementId,
|
||||
bs.group_id AS groupId,
|
||||
bs.batch_id AS logId,
|
||||
CONCAT(
|
||||
'外部人员“', IFNULL(bs.LE_ACCOUNT_NAME, ''),
|
||||
'”单笔交易金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
|
||||
' 元,超过阈值 ', CAST(#{threshold} AS CHAR),
|
||||
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
|
||||
) AS reasonDetail
|
||||
'占位SQL,待补充真实规则' AS reasonDetail
|
||||
from ccdi_bank_statement bs
|
||||
where bs.project_id = #{projectId}
|
||||
and <include refid="externalPersonPredicateSql"/>
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > #{threshold}
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
</select>
|
||||
|
||||
<select id="selectExternalCumulativeTransactionAmountObjects" resultMap="BankTagObjectHitResultMap">
|
||||
select
|
||||
'EXTERNAL_CERT_NO' AS objectType,
|
||||
t.certNo AS objectKey,
|
||||
CONCAT(
|
||||
'外部人员“', IFNULL(t.personName, ''),
|
||||
'”累计交易金额 ', CAST(t.totalAmount AS CHAR),
|
||||
' 元,超过阈值 ', CAST(#{threshold} AS CHAR), ' 元'
|
||||
) AS reasonDetail
|
||||
from (
|
||||
select
|
||||
bs.cret_no AS certNo,
|
||||
max(IFNULL(bs.LE_ACCOUNT_NAME, '')) AS personName,
|
||||
ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) AS totalAmount
|
||||
from ccdi_bank_statement bs
|
||||
where bs.project_id = #{projectId}
|
||||
and <include refid="externalPersonPredicateSql"/>
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
group by bs.cret_no
|
||||
having ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) > #{threshold}
|
||||
) t
|
||||
</select>
|
||||
|
||||
<select id="selectExternalAnnualTurnoverObjects" resultMap="BankTagObjectHitResultMap">
|
||||
select
|
||||
'EXTERNAL_CERT_NO' AS objectType,
|
||||
t.certNo AS objectKey,
|
||||
CONCAT(
|
||||
'外部人员“', IFNULL(t.personName, ''),
|
||||
'”近一年流水交易额 ', CAST(t.annualAmount AS CHAR),
|
||||
' 元,超过阈值 ', CAST(#{threshold} AS CHAR), ' 元'
|
||||
) AS reasonDetail
|
||||
from (
|
||||
select
|
||||
bs.cret_no AS certNo,
|
||||
max(IFNULL(bs.LE_ACCOUNT_NAME, '')) AS personName,
|
||||
ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) AS annualAmount
|
||||
from ccdi_bank_statement bs
|
||||
where bs.project_id = #{projectId}
|
||||
and <include refid="externalPersonPredicateSql"/>
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
and STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 10), '%Y-%m-%d') >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
|
||||
group by bs.cret_no
|
||||
having ROUND(SUM(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0))), 2) > #{threshold}
|
||||
) t
|
||||
</select>
|
||||
|
||||
<select id="selectAbnormalCustomerTransactionStatements" resultMap="BankTagStatementHitResultMap">
|
||||
select
|
||||
hit.bankStatementId AS bankStatementId,
|
||||
max(hit.groupId) AS groupId,
|
||||
max(hit.logId) AS logId,
|
||||
substring_index(
|
||||
min(concat(lpad(hit.matchPriority, 2, '0'), '|', hit.reasonDetail)),
|
||||
'|',
|
||||
-1
|
||||
) AS reasonDetail
|
||||
from (
|
||||
select
|
||||
bs.bank_statement_id AS bankStatementId,
|
||||
bs.group_id AS groupId,
|
||||
bs.batch_id AS logId,
|
||||
1 AS matchPriority,
|
||||
CONCAT(
|
||||
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与信贷客户账号发生交易,',
|
||||
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
|
||||
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
|
||||
) AS reasonDetail
|
||||
from ccdi_bank_statement bs
|
||||
inner join (
|
||||
<include refid="abnormalCustomerTransactionSubjectSql"/>
|
||||
) subject on subject.subjectCertNo = bs.cret_no
|
||||
inner join ccdi_account_info account
|
||||
on trim(IFNULL(bs.customer_account_no, '')) != ''
|
||||
and account.owner_type = 'CREDIT_CUSTOMER'
|
||||
and account.account_no = bs.customer_account_no
|
||||
where bs.project_id = #{projectId}
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
bs.bank_statement_id AS bankStatementId,
|
||||
bs.group_id AS groupId,
|
||||
bs.batch_id AS logId,
|
||||
2 AS matchPriority,
|
||||
CONCAT(
|
||||
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介账号发生交易,',
|
||||
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
|
||||
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
|
||||
) AS reasonDetail
|
||||
from ccdi_bank_statement bs
|
||||
inner join (
|
||||
<include refid="abnormalCustomerTransactionSubjectSql"/>
|
||||
) subject on subject.subjectCertNo = bs.cret_no
|
||||
inner join ccdi_account_info account
|
||||
on trim(IFNULL(bs.customer_account_no, '')) != ''
|
||||
and account.owner_type = 'INTERMEDIARY'
|
||||
and account.account_no = bs.customer_account_no
|
||||
where bs.project_id = #{projectId}
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
bs.bank_statement_id AS bankStatementId,
|
||||
bs.group_id AS groupId,
|
||||
bs.batch_id AS logId,
|
||||
3 AS matchPriority,
|
||||
CONCAT(
|
||||
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介关联企业发生交易,',
|
||||
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
|
||||
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
|
||||
) AS reasonDetail
|
||||
from ccdi_bank_statement bs
|
||||
inner join (
|
||||
<include refid="abnormalCustomerTransactionSubjectSql"/>
|
||||
) subject on subject.subjectCertNo = bs.cret_no
|
||||
inner join ccdi_enterprise_base_info enterprise
|
||||
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
|
||||
and enterprise.enterprise_name = bs.CUSTOMER_ACCOUNT_NAME
|
||||
and enterprise.ent_source = 'INTERMEDIARY'
|
||||
where bs.project_id = #{projectId}
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
bs.bank_statement_id AS bankStatementId,
|
||||
bs.group_id AS groupId,
|
||||
bs.batch_id AS logId,
|
||||
4 AS matchPriority,
|
||||
CONCAT(
|
||||
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介库人员发生微信/支付宝交易,',
|
||||
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
|
||||
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
|
||||
) AS reasonDetail
|
||||
from ccdi_bank_statement bs
|
||||
inner join (
|
||||
<include refid="abnormalCustomerTransactionSubjectSql"/>
|
||||
) subject on subject.subjectCertNo = bs.cret_no
|
||||
inner join ccdi_biz_intermediary intermediary
|
||||
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
|
||||
and trim(IFNULL(intermediary.name, '')) != ''
|
||||
and bs.CUSTOMER_ACCOUNT_NAME like concat('%', intermediary.name, '%')
|
||||
where bs.project_id = #{projectId}
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
|
||||
and bs.bank in ('ALIPAY', 'WECHAT')
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
bs.bank_statement_id AS bankStatementId,
|
||||
bs.group_id AS groupId,
|
||||
bs.batch_id AS logId,
|
||||
5 AS matchPriority,
|
||||
CONCAT(
|
||||
subject.subjectType, '“', IFNULL(subject.subjectName, ''), '”与中介库人员发生名称精确匹配交易,',
|
||||
'金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR),
|
||||
' 元,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''), '”'
|
||||
) AS reasonDetail
|
||||
from ccdi_bank_statement bs
|
||||
inner join (
|
||||
<include refid="abnormalCustomerTransactionSubjectSql"/>
|
||||
) subject on subject.subjectCertNo = bs.cret_no
|
||||
inner join ccdi_biz_intermediary intermediary
|
||||
on trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '')) != ''
|
||||
and intermediary.name = bs.CUSTOMER_ACCOUNT_NAME
|
||||
where bs.project_id = #{projectId}
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
|
||||
|
||||
) hit
|
||||
group by hit.bankStatementId
|
||||
where 1 = 0
|
||||
</select>
|
||||
|
||||
<select id="selectLowIncomeRelativeLargeTransactionObjects" resultMap="BankTagObjectHitResultMap">
|
||||
@@ -695,9 +416,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
from ccdi_staff_fmy_relation relation
|
||||
inner join ccdi_bank_statement bs on relation.relation_cert_no = bs.cret_no
|
||||
where relation.status = 1
|
||||
and relation.annual_income is not null
|
||||
and (
|
||||
relation.annual_income = 0
|
||||
relation.annual_income is null
|
||||
or relation.annual_income = 0
|
||||
or relation.annual_income / 12 < 3000
|
||||
)
|
||||
and bs.project_id = #{projectId}
|
||||
@@ -782,77 +503,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
where bs.project_id = #{projectId}
|
||||
and IFNULL(bs.AMOUNT_DR, 0) > 0
|
||||
and (
|
||||
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|VIP666|USDT下注'
|
||||
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注'
|
||||
)
|
||||
</select>
|
||||
|
||||
<select id="selectExternalGamblingMemoStatements" resultMap="BankTagStatementHitResultMap">
|
||||
select
|
||||
bs.bank_statement_id AS bankStatementId,
|
||||
bs.group_id AS groupId,
|
||||
bs.batch_id AS logId,
|
||||
CONCAT(
|
||||
'外部人员“', IFNULL(bs.LE_ACCOUNT_NAME, ''),
|
||||
'”摘要/对手方命中疑似赌博关键词,摘要“', IFNULL(bs.USER_MEMO, ''),
|
||||
'”,对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
|
||||
'”,交易金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR), ' 元'
|
||||
) AS reasonDetail
|
||||
from ccdi_bank_statement bs
|
||||
where bs.project_id = #{projectId}
|
||||
and <include refid="externalPersonPredicateSql"/>
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
|
||||
and (
|
||||
IFNULL(bs.USER_MEMO, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|牌局|捕鱼|电子游艺|VIP666|USDT下注'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|牌局|捕鱼|电子游艺|VIP666|USDT下注'
|
||||
or IFNULL(bs.CASH_TYPE, '') REGEXP '游戏|抖币|体彩|福彩|彩票|赌博|赌球|下注|投注|球赛投注|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|牌局|捕鱼|电子游艺|VIP666|USDT下注'
|
||||
)
|
||||
</select>
|
||||
|
||||
<select id="selectExternalMultiPartyGamblingTransferObjects" resultMap="BankTagObjectHitResultMap">
|
||||
select
|
||||
'EXTERNAL_CERT_NO' AS objectType,
|
||||
t.certNo AS objectKey,
|
||||
CONCAT(
|
||||
'外部人员“', IFNULL(MAX(t.personName), ''),
|
||||
'”交易日 ', MAX(t.tradeDate),
|
||||
' 发生 ', CAST(MAX(t.hitCount) AS CHAR),
|
||||
' 笔疑似赌博交易,涉及 ', CAST(MAX(t.partyCount) AS CHAR),
|
||||
' 个对手方,金额合计 ', CAST(MAX(t.totalAmount) AS CHAR), ' 元'
|
||||
) AS reasonDetail
|
||||
from (
|
||||
select
|
||||
source.certNo AS certNo,
|
||||
max(source.personName) AS personName,
|
||||
source.tradeDate AS tradeDate,
|
||||
COUNT(1) AS hitCount,
|
||||
COUNT(DISTINCT source.customerAccountName) AS partyCount,
|
||||
ROUND(SUM(source.tradeAmount), 2) AS totalAmount
|
||||
from (
|
||||
select
|
||||
bs.cret_no AS certNo,
|
||||
IFNULL(bs.LE_ACCOUNT_NAME, '') AS personName,
|
||||
LEFT(TRIM(bs.TRX_DATE), 10) AS tradeDate,
|
||||
bs.CUSTOMER_ACCOUNT_NAME AS customerAccountName,
|
||||
GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS tradeAmount
|
||||
from ccdi_bank_statement bs
|
||||
where bs.project_id = #{projectId}
|
||||
and <include refid="externalPersonPredicateSql"/>
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) between #{amountMinThreshold} and #{amountMaxThreshold}
|
||||
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') <> ''
|
||||
and (
|
||||
IFNULL(bs.USER_MEMO, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay|转账|红包|牌局|赌'
|
||||
or IFNULL(bs.CASH_TYPE, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay|转账|红包|牌局|赌'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '微信|wechat|WeChat|财付通|Tenpay|支付宝|Alipay'
|
||||
)
|
||||
) source
|
||||
group by source.certNo, source.tradeDate
|
||||
having COUNT(1) > 2
|
||||
and COUNT(DISTINCT source.customerAccountName) >= 2
|
||||
) t
|
||||
group by t.certNo
|
||||
</select>
|
||||
|
||||
<select id="selectSpecialAmountTransactionStatements" resultMap="BankTagStatementHitResultMap">
|
||||
select
|
||||
bs.bank_statement_id AS bankStatementId,
|
||||
@@ -879,74 +534,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
)
|
||||
</select>
|
||||
|
||||
<select id="selectExternalToStaffOrFamilyTransactionStatements" resultMap="BankTagStatementHitResultMap">
|
||||
select distinct
|
||||
bs.bank_statement_id AS bankStatementId,
|
||||
bs.group_id AS groupId,
|
||||
bs.batch_id AS logId,
|
||||
CONCAT(
|
||||
'外部人员“', IFNULL(bs.LE_ACCOUNT_NAME, ''),
|
||||
'”与', CASE
|
||||
WHEN counter_account.owner_type = 'EMPLOYEE' THEN '员工'
|
||||
WHEN counter_account.owner_type = 'RELATION' THEN '员工亲属'
|
||||
WHEN counter_staff.id_card is not null THEN '员工'
|
||||
WHEN counter_relation.relation_cert_no is not null THEN '员工亲属'
|
||||
ELSE '员工/员工亲属'
|
||||
END,
|
||||
'“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
|
||||
'”发生资金往来,交易金额 ',
|
||||
CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR), ' 元'
|
||||
) AS reasonDetail
|
||||
from ccdi_bank_statement bs
|
||||
left join ccdi_account_info counter_account
|
||||
on trim(bs.CUSTOMER_ACCOUNT_NO) != ''
|
||||
and counter_account.account_no = trim(bs.CUSTOMER_ACCOUNT_NO)
|
||||
and counter_account.owner_type in ('EMPLOYEE', 'RELATION', 'INTERMEDIARY', 'CREDIT_CUSTOMER')
|
||||
left join ccdi_base_staff counter_staff
|
||||
on counter_account.account_no is null
|
||||
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
|
||||
and counter_staff.name = trim(bs.CUSTOMER_ACCOUNT_NAME)
|
||||
left join ccdi_staff_fmy_relation counter_relation
|
||||
on counter_account.account_no is null
|
||||
and counter_relation.status = 1
|
||||
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
|
||||
and counter_relation.relation_name = trim(bs.CUSTOMER_ACCOUNT_NAME)
|
||||
where bs.project_id = #{projectId}
|
||||
and <include refid="externalPersonPredicateSql"/>
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
|
||||
and (
|
||||
counter_account.owner_type in ('EMPLOYEE', 'RELATION')
|
||||
or (
|
||||
counter_account.account_no is null
|
||||
and (
|
||||
counter_staff.id_card is not null
|
||||
or counter_relation.relation_cert_no is not null
|
||||
)
|
||||
)
|
||||
)
|
||||
</select>
|
||||
|
||||
<select id="selectExternalNightTransactionStatements" resultMap="BankTagStatementHitResultMap">
|
||||
select
|
||||
bs.bank_statement_id AS bankStatementId,
|
||||
bs.group_id AS groupId,
|
||||
bs.batch_id AS logId,
|
||||
CONCAT(
|
||||
'外部人员“', IFNULL(bs.LE_ACCOUNT_NAME, ''),
|
||||
'”夜间交易,交易时间 ', IFNULL(bs.TRX_DATE, ''),
|
||||
',对手方“', IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''),
|
||||
'”,交易金额 ', CAST(GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) AS CHAR), ' 元'
|
||||
) AS reasonDetail
|
||||
from ccdi_bank_statement bs
|
||||
where bs.project_id = #{projectId}
|
||||
and <include refid="externalPersonPredicateSql"/>
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 0
|
||||
and (
|
||||
HOUR(STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s')) >= 22
|
||||
or HOUR(STR_TO_DATE(LEFT(TRIM(bs.TRX_DATE), 19), '%Y-%m-%d %H:%i:%s')) < 6
|
||||
)
|
||||
</select>
|
||||
|
||||
<select id="selectMonthlyFixedIncomeObjects" resultMap="BankTagObjectHitResultMap">
|
||||
select
|
||||
'STAFF_ID_CARD' AS objectType,
|
||||
@@ -1071,15 +658,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
where bs.project_id = #{projectId}
|
||||
and IFNULL(bs.AMOUNT_CR, 0) > 0
|
||||
and IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') <> '浙江兰溪农村商业银行股份有限公司'
|
||||
and not (
|
||||
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') LIKE '%公积金中心%'
|
||||
and (
|
||||
IFNULL(bs.USER_MEMO, '') LIKE '%公积金%'
|
||||
or IFNULL(bs.USER_MEMO, '') LIKE '%批量代付%'
|
||||
or IFNULL(bs.CASH_TYPE, '') LIKE '%公积金%'
|
||||
or IFNULL(bs.CASH_TYPE, '') LIKE '%批量代付%'
|
||||
)
|
||||
)
|
||||
and (
|
||||
IFNULL(bs.USER_MEMO, '') REGEXP '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees'
|
||||
or IFNULL(bs.CASH_TYPE, '') REGEXP '代发|工资|劳务费'
|
||||
@@ -1477,7 +1055,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
|
||||
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
|
||||
)
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
</select>
|
||||
|
||||
<select id="selectWithdrawCntObjects" resultMap="BankTagObjectHitResultMap">
|
||||
@@ -1497,8 +1074,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
from ccdi_bank_statement bs
|
||||
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
|
||||
where bs.project_id = #{projectId}
|
||||
and IFNULL(bs.AMOUNT_CR, 0) > 0
|
||||
and <include refid="thirdPartyWithdrawIncomePredicate"/>
|
||||
and IFNULL(bs.AMOUNT_CR, 0) >= 0
|
||||
and (
|
||||
IFNULL(bs.USER_MEMO, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
|
||||
or IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
|
||||
)
|
||||
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10)
|
||||
having COUNT(1) > #{frequencyThreshold}
|
||||
) t
|
||||
@@ -1507,25 +1087,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<select id="selectWithdrawAmtObjects" resultMap="BankTagObjectHitResultMap">
|
||||
select
|
||||
'STAFF_ID_CARD' AS objectType,
|
||||
t.objectKey AS objectKey,
|
||||
CONCAT(
|
||||
'单日微信/支付宝提现到账金额 ', CAST(t.withdrawAmount AS CHAR),
|
||||
' 元,超过阈值 ', CAST(#{amountThreshold} AS CHAR),
|
||||
' 元,交易日:', t.transDate
|
||||
) AS reasonDetail
|
||||
from (
|
||||
select
|
||||
staff.id_card AS objectKey,
|
||||
LEFT(TRIM(bs.TRX_DATE), 10) AS transDate,
|
||||
ROUND(SUM(IFNULL(bs.AMOUNT_CR, 0)), 2) AS withdrawAmount
|
||||
from ccdi_bank_statement bs
|
||||
inner join ccdi_base_staff staff on staff.id_card = bs.cret_no
|
||||
where bs.project_id = #{projectId}
|
||||
and IFNULL(bs.AMOUNT_CR, 0) > 0
|
||||
and <include refid="thirdPartyWithdrawIncomePredicate"/>
|
||||
group by staff.id_card, LEFT(TRIM(bs.TRX_DATE), 10)
|
||||
having SUM(IFNULL(bs.AMOUNT_CR, 0)) > #{amountThreshold}
|
||||
) t
|
||||
'' AS objectKey,
|
||||
'占位SQL,待补充真实规则' AS reasonDetail
|
||||
from ccdi_bank_statement bs
|
||||
where 1 = 0
|
||||
</select>
|
||||
|
||||
<select id="selectSalaryQuickTransferObjects" resultMap="BankTagObjectHitResultMap">
|
||||
@@ -1757,11 +1322,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
where bs.project_id = #{projectId}
|
||||
and IFNULL(bs.AMOUNT_DR, 0) > #{threshold}
|
||||
and (
|
||||
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管'
|
||||
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
|
||||
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|资金存管|第三方存管|银证转账|银证|证转银|银转证'
|
||||
IFNULL(bs.CUSTOMER_ACCOUNT_NAME, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
|
||||
or IFNULL(bs.USER_MEMO, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
|
||||
or IFNULL(bs.CASH_TYPE, '') REGEXP '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财|资金存管|第三方存管'
|
||||
)
|
||||
and <include refid="financialProductExclusionPredicate"/>
|
||||
</select>
|
||||
|
||||
<select id="selectProxyAccountOperationObjects" resultMap="BankTagObjectHitResultMap">
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiFundGraphMapper">
|
||||
|
||||
<resultMap id="FundGraphNodeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphNodeVO">
|
||||
<id property="objectKey" column="objectKey"/>
|
||||
<result property="nodeKey" column="nodeKey"/>
|
||||
<result property="nodeName" column="nodeName"/>
|
||||
<result property="idNo" column="idNo"/>
|
||||
<result property="cinocsno" column="cinocsno"/>
|
||||
<result property="idnoType" column="idnoType"/>
|
||||
<result property="staffId" column="staffId"/>
|
||||
<result property="sourceType" column="sourceType"/>
|
||||
<result property="nodeType" column="nodeType"/>
|
||||
<result property="identityType" column="identityType"/>
|
||||
<result property="relationType" column="relationType"/>
|
||||
<result property="accountCount" column="accountCount"/>
|
||||
<result property="createdTime" column="createdTime"/>
|
||||
<result property="updatedTime" column="updatedTime"/>
|
||||
<result property="canExpand" column="canExpand"/>
|
||||
<result property="depth" column="depth"/>
|
||||
<result property="totalAmount" column="totalAmount"/>
|
||||
<result property="transactionCount" column="transactionCount"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="FundGraphEdgeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphEdgeVO">
|
||||
<id property="edgeKey" column="edgeKey"/>
|
||||
<result property="fromKey" column="fromKey"/>
|
||||
<result property="toKey" column="toKey"/>
|
||||
<result property="fromObjectKey" column="fromObjectKey"/>
|
||||
<result property="toObjectKey" column="toObjectKey"/>
|
||||
<result property="fromName" column="fromName"/>
|
||||
<result property="toName" column="toName"/>
|
||||
<result property="totalAmount" column="totalAmount"/>
|
||||
<result property="transactionCount" column="transactionCount"/>
|
||||
<result property="firstTrxDate" column="firstTrxDate"/>
|
||||
<result property="lastTrxDate" column="lastTrxDate"/>
|
||||
<result property="direction" column="direction"/>
|
||||
<result property="familyRelationType" column="familyRelationType"/>
|
||||
<result property="sourceType" column="sourceType"/>
|
||||
<result property="relationDesc" column="relationDesc"/>
|
||||
<result property="sourceDesc" column="sourceDesc"/>
|
||||
<result property="remark" column="remark"/>
|
||||
<result property="depth" column="depth"/>
|
||||
<result property="canTrace" column="canTrace"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="FundGraphStatementResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiFundGraphStatementVO">
|
||||
<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="cashType" column="cashType"/>
|
||||
<result property="userMemo" column="userMemo"/>
|
||||
<result property="amount" column="amount"/>
|
||||
<result property="direction" column="direction"/>
|
||||
<result property="familyRelationType" column="familyRelationType"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="detailFilter">
|
||||
<if test="query.transactionStartTime != null and query.transactionStartTime != ''">
|
||||
AND d.trx_date <![CDATA[ >= ]]> (#{query.transactionStartTime} COLLATE utf8mb4_general_ci)
|
||||
</if>
|
||||
<if test="query.transactionEndTime != null and query.transactionEndTime != ''">
|
||||
AND d.trx_date <![CDATA[ <= ]]>
|
||||
(CASE
|
||||
WHEN LENGTH(TRIM(#{query.transactionEndTime})) = 10
|
||||
THEN CONCAT(TRIM(#{query.transactionEndTime}), ' 23:59:59')
|
||||
ELSE TRIM(#{query.transactionEndTime})
|
||||
END COLLATE utf8mb4_general_ci)
|
||||
</if>
|
||||
<if test="query.amountMin != null">
|
||||
AND d.amount <![CDATA[ >= ]]> #{query.amountMin}
|
||||
</if>
|
||||
<if test="query.amountMax != null">
|
||||
AND d.amount <![CDATA[ <= ]]> #{query.amountMax}
|
||||
</if>
|
||||
<if test="query.direction != null and query.direction != ''">
|
||||
AND d.flag = (#{query.direction} COLLATE utf8mb4_general_ci)
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<sql id="subjectJoinRows">
|
||||
SELECT
|
||||
d.object_key AS detailObjectKey,
|
||||
d.bank_statement_id AS bankStatementId,
|
||||
d.trx_date AS trxDate,
|
||||
d.le_account_no AS leAccountNo,
|
||||
d.le_account_name AS leAccountName,
|
||||
d.customer_account_name AS customerAccountName,
|
||||
d.customer_account_no AS customerAccountNo,
|
||||
d.cash_type AS cashType,
|
||||
d.user_memo AS userMemo,
|
||||
d.amount,
|
||||
d.flag AS direction,
|
||||
d.family_relation_type AS familyRelationType,
|
||||
from_subject.object_key AS fromObjectKey,
|
||||
to_subject.object_key AS toObjectKey,
|
||||
CONCAT('idno_node/', from_subject.object_key) AS fromKey,
|
||||
CONCAT('idno_node/', to_subject.object_key) AS toKey,
|
||||
from_subject.name AS fromName,
|
||||
to_subject.name AS toName,
|
||||
from_subject.idnocfno AS fromIdNo,
|
||||
to_subject.idnocfno AS toIdNo
|
||||
FROM lx_fund_flow_detail_edge d
|
||||
INNER JOIN lx_fund_flow_own_account_edge from_own
|
||||
ON from_own.to_key = d.from_key
|
||||
INNER JOIN lx_fund_flow_subject_node from_subject
|
||||
ON CONCAT('idno_node/', from_subject.object_key) = from_own.from_key
|
||||
INNER JOIN lx_fund_flow_own_account_edge to_own
|
||||
ON to_own.to_key = d.to_key
|
||||
INNER JOIN lx_fund_flow_subject_node to_subject
|
||||
ON CONCAT('idno_node/', to_subject.object_key) = to_own.from_key
|
||||
WHERE 1 = 1
|
||||
<if test="query.objectKey != null and query.objectKey != ''">
|
||||
AND (
|
||||
from_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
|
||||
OR to_subject.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
|
||||
)
|
||||
</if>
|
||||
<include refid="detailFilter"/>
|
||||
</sql>
|
||||
|
||||
<select id="selectFundGraphSubjects" resultMap="FundGraphNodeResultMap">
|
||||
SELECT
|
||||
n.object_key AS objectKey,
|
||||
CONCAT('idno_node/', n.object_key) AS nodeKey,
|
||||
n.name AS nodeName,
|
||||
n.idnocfno AS idNo,
|
||||
n.cinocsno AS cinocsno,
|
||||
n.idno_type AS idnoType,
|
||||
n.staff_id AS staffId,
|
||||
n.source_type AS sourceType,
|
||||
CASE
|
||||
WHEN n.idno_type = 'NAME_PROXY' OR n.source_type LIKE '%COUNTERPARTY%' THEN 'PROXY'
|
||||
ELSE 'PERSON'
|
||||
END AS nodeType,
|
||||
CASE
|
||||
WHEN n.idnocfno IS NOT NULL AND TRIM(n.idnocfno) != '' THEN 'IDNO'
|
||||
ELSE 'NAME'
|
||||
END AS identityType,
|
||||
CASE
|
||||
WHEN n.source_type LIKE 'GRAPH_TEST_FAMILY_%' THEN REPLACE(n.source_type, 'GRAPH_TEST_FAMILY_', '')
|
||||
ELSE NULL
|
||||
END AS relationType,
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM lx_fund_flow_own_account_edge own
|
||||
WHERE own.from_key = CONCAT('idno_node/', n.object_key)
|
||||
) THEN 1
|
||||
ELSE 0
|
||||
END AS canExpand,
|
||||
(
|
||||
SELECT COUNT(1)
|
||||
FROM lx_fund_flow_own_account_edge own_count
|
||||
WHERE own_count.from_key = CONCAT('idno_node/', n.object_key)
|
||||
) AS accountCount,
|
||||
DATE_FORMAT(n.created_time, '%Y-%m-%d %H:%i:%s') AS createdTime,
|
||||
DATE_FORMAT(n.updated_time, '%Y-%m-%d %H:%i:%s') AS updatedTime,
|
||||
0 AS depth,
|
||||
0 AS totalAmount,
|
||||
0 AS transactionCount
|
||||
FROM lx_fund_flow_subject_node n
|
||||
WHERE 1 = 1
|
||||
<if test="query.objectKey != null and query.objectKey != ''">
|
||||
AND n.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
|
||||
</if>
|
||||
<if test="query.objectKey == null or query.objectKey == ''">
|
||||
<if test="query.keyword != null and query.keyword != ''">
|
||||
AND (
|
||||
n.idnocfno = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
|
||||
OR n.name LIKE (CONCAT('%', TRIM(#{query.keyword}), '%') COLLATE utf8mb4_general_ci)
|
||||
OR n.object_key = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
|
||||
)
|
||||
</if>
|
||||
</if>
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN n.idnocfno = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 0
|
||||
WHEN n.object_key = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 1
|
||||
WHEN n.staff_id IS NOT NULL AND TRIM(n.staff_id) != '' THEN 2
|
||||
WHEN UPPER(IFNULL(n.source_type, '')) LIKE '%EMPLOYEE%' THEN 2
|
||||
WHEN n.source_type LIKE '%员工%' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
n.name
|
||||
LIMIT
|
||||
<choose>
|
||||
<when test="query.limit != null and query.limit > 0">
|
||||
#{query.limit}
|
||||
</when>
|
||||
<otherwise>
|
||||
20
|
||||
</otherwise>
|
||||
</choose>
|
||||
</select>
|
||||
|
||||
<select id="selectFundGraphEdges" resultMap="FundGraphEdgeResultMap">
|
||||
SELECT
|
||||
MD5(CONCAT(graph_rows.fromKey, '|', graph_rows.toKey, '|', graph_rows.direction, '|', IFNULL(graph_rows.familyRelationType, ''))) AS edgeKey,
|
||||
graph_rows.fromKey,
|
||||
graph_rows.toKey,
|
||||
graph_rows.fromObjectKey,
|
||||
graph_rows.toObjectKey,
|
||||
MAX(graph_rows.fromName) AS fromName,
|
||||
MAX(graph_rows.toName) AS toName,
|
||||
SUM(graph_rows.amount) AS totalAmount,
|
||||
COUNT(1) AS transactionCount,
|
||||
MIN(graph_rows.trxDate) AS firstTrxDate,
|
||||
MAX(graph_rows.trxDate) AS lastTrxDate,
|
||||
graph_rows.direction,
|
||||
graph_rows.familyRelationType,
|
||||
'BANK' AS sourceType,
|
||||
NULL AS relationDesc,
|
||||
NULL AS sourceDesc,
|
||||
NULL AS remark,
|
||||
1 AS depth,
|
||||
CASE
|
||||
WHEN MAX(
|
||||
CASE
|
||||
WHEN graph_rows.fromObjectKey = #{query.objectKey} THEN
|
||||
CASE
|
||||
WHEN graph_rows.toIdNo IS NOT NULL AND TRIM(graph_rows.toIdNo) != '' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
ELSE
|
||||
CASE
|
||||
WHEN graph_rows.fromIdNo IS NOT NULL AND TRIM(graph_rows.fromIdNo) != '' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
END
|
||||
) = 1 THEN 1
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM lx_fund_flow_own_account_edge own
|
||||
WHERE own.from_key = CASE
|
||||
WHEN graph_rows.fromObjectKey = #{query.objectKey}
|
||||
THEN graph_rows.toKey
|
||||
ELSE graph_rows.fromKey
|
||||
END
|
||||
) THEN 1
|
||||
ELSE 0
|
||||
END AS canTrace
|
||||
FROM (
|
||||
<include refid="subjectJoinRows"/>
|
||||
) graph_rows
|
||||
WHERE 1 = 1
|
||||
GROUP BY
|
||||
graph_rows.fromKey,
|
||||
graph_rows.toKey,
|
||||
graph_rows.fromObjectKey,
|
||||
graph_rows.toObjectKey,
|
||||
graph_rows.direction,
|
||||
graph_rows.familyRelationType
|
||||
<if test="query.minTotalAmount != null">
|
||||
HAVING SUM(graph_rows.amount) <![CDATA[ >= ]]> #{query.minTotalAmount}
|
||||
</if>
|
||||
ORDER BY totalAmount DESC, transactionCount DESC
|
||||
LIMIT #{query.limit}
|
||||
</select>
|
||||
|
||||
<select id="selectFundGraphManualEdges" resultMap="FundGraphEdgeResultMap">
|
||||
SELECT
|
||||
m.object_key AS edgeKey,
|
||||
CONCAT('idno_node/', m.from_object_key) AS fromKey,
|
||||
CONCAT('idno_node/', m.to_object_key) AS toKey,
|
||||
m.from_object_key AS fromObjectKey,
|
||||
m.to_object_key AS toObjectKey,
|
||||
COALESCE(from_subject.name, m.from_name) AS fromName,
|
||||
COALESCE(to_subject.name, m.to_name) AS toName,
|
||||
m.amount AS totalAmount,
|
||||
m.transaction_count AS transactionCount,
|
||||
DATE_FORMAT(m.created_time, '%Y-%m-%d %H:%i:%s') AS firstTrxDate,
|
||||
DATE_FORMAT(m.created_time, '%Y-%m-%d %H:%i:%s') AS lastTrxDate,
|
||||
m.direction,
|
||||
NULL AS familyRelationType,
|
||||
m.source_type AS sourceType,
|
||||
m.relation_desc AS relationDesc,
|
||||
m.source_desc AS sourceDesc,
|
||||
m.remark,
|
||||
1 AS depth,
|
||||
0 AS canTrace
|
||||
FROM lx_fund_flow_manual_edge m
|
||||
LEFT JOIN lx_fund_flow_subject_node from_subject
|
||||
ON from_subject.object_key = m.from_object_key
|
||||
LEFT JOIN lx_fund_flow_subject_node to_subject
|
||||
ON to_subject.object_key = m.to_object_key
|
||||
WHERE m.source_type = 'MANUAL'
|
||||
AND (
|
||||
m.from_object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
|
||||
OR m.to_object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
|
||||
)
|
||||
ORDER BY m.updated_time DESC
|
||||
LIMIT #{query.limit}
|
||||
</select>
|
||||
|
||||
<select id="selectFundGraphEdgeDetails" resultMap="FundGraphStatementResultMap">
|
||||
SELECT
|
||||
graph_rows.bankStatementId,
|
||||
graph_rows.trxDate,
|
||||
graph_rows.leAccountNo,
|
||||
graph_rows.leAccountName,
|
||||
graph_rows.customerAccountName,
|
||||
graph_rows.customerAccountNo,
|
||||
graph_rows.cashType,
|
||||
graph_rows.userMemo,
|
||||
graph_rows.amount,
|
||||
graph_rows.direction,
|
||||
graph_rows.familyRelationType
|
||||
FROM (
|
||||
<include refid="subjectJoinRows"/>
|
||||
) graph_rows
|
||||
WHERE graph_rows.fromKey = (#{query.fromKey} COLLATE utf8mb4_general_ci)
|
||||
AND graph_rows.toKey = (#{query.toKey} COLLATE utf8mb4_general_ci)
|
||||
<if test="query.direction != null and query.direction != ''">
|
||||
AND graph_rows.direction = (#{query.direction} COLLATE utf8mb4_general_ci)
|
||||
</if>
|
||||
ORDER BY graph_rows.trxDate DESC, graph_rows.bankStatementId DESC
|
||||
</select>
|
||||
|
||||
<select id="countSubjectByObjectKey" resultType="int">
|
||||
SELECT COUNT(1)
|
||||
FROM lx_fund_flow_subject_node
|
||||
WHERE object_key = (#{objectKey} COLLATE utf8mb4_general_ci)
|
||||
</select>
|
||||
|
||||
<insert id="insertManualSubject">
|
||||
INSERT IGNORE INTO lx_fund_flow_subject_node (
|
||||
object_key,
|
||||
idnocfno,
|
||||
name,
|
||||
idno_type,
|
||||
source_type
|
||||
) VALUES (
|
||||
#{objectKey},
|
||||
#{idNo},
|
||||
#{name},
|
||||
CASE
|
||||
WHEN #{idNo} IS NULL OR TRIM(#{idNo}) = '' THEN 'NAME_PROXY'
|
||||
ELSE 'PERSON'
|
||||
END,
|
||||
'MANUAL'
|
||||
)
|
||||
</insert>
|
||||
|
||||
<insert id="insertManualEdge">
|
||||
INSERT INTO lx_fund_flow_manual_edge (
|
||||
object_key,
|
||||
from_object_key,
|
||||
to_object_key,
|
||||
from_name,
|
||||
to_name,
|
||||
amount,
|
||||
transaction_count,
|
||||
direction,
|
||||
relation_desc,
|
||||
source_desc,
|
||||
remark,
|
||||
source_type,
|
||||
created_by,
|
||||
updated_by
|
||||
) VALUES (
|
||||
#{objectKey},
|
||||
#{dto.fromObjectKey},
|
||||
#{dto.toObjectKey},
|
||||
#{dto.fromName},
|
||||
#{dto.toName},
|
||||
#{dto.amount},
|
||||
#{dto.transactionCount},
|
||||
#{dto.direction},
|
||||
#{dto.relationDesc},
|
||||
#{dto.sourceDesc},
|
||||
#{dto.remark},
|
||||
'MANUAL',
|
||||
#{operator},
|
||||
#{operator}
|
||||
)
|
||||
</insert>
|
||||
</mapper>
|
||||
@@ -33,38 +33,6 @@
|
||||
select="selectRiskHitTagsByScope"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="ExternalRiskModelPeopleItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO">
|
||||
<id property="idNo" column="cert_no"/>
|
||||
<result property="staffName" column="person_name"/>
|
||||
<result property="staffCode" column="subject_type"/>
|
||||
<result property="department" column="related_object"/>
|
||||
<collection property="modelNames"
|
||||
column="{projectId=project_id,certNo=cert_no,selectedModelCodes=selected_model_codes}"
|
||||
ofType="java.lang.String"
|
||||
select="selectExternalRiskModelNamesByScope"/>
|
||||
<collection property="hitTagList"
|
||||
column="{projectId=project_id,certNo=cert_no,selectedModelCodes=selected_model_codes}"
|
||||
ofType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO"
|
||||
select="selectExternalRiskHitTagsByScope"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="ExternalPersonWarningItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalPersonWarningItemVO">
|
||||
<result property="name" column="person_name"/>
|
||||
<result property="idNo" column="cert_no"/>
|
||||
<result property="subjectType" column="subject_type"/>
|
||||
<result property="riskLevel" column="risk_level_name"/>
|
||||
<result property="riskLevelType" column="risk_level_type"/>
|
||||
<result property="riskCount" column="risk_count"/>
|
||||
<result property="modelCount" column="model_count"/>
|
||||
<result property="riskPoint" column="risk_point"/>
|
||||
<result property="relatedObject" column="related_object"/>
|
||||
<result property="latestTradeTime" column="latest_trade_time"/>
|
||||
<collection property="riskPointTagList"
|
||||
column="{projectId=project_id,certNo=cert_no,selectedModelCodes=selected_model_codes}"
|
||||
ofType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO"
|
||||
select="selectExternalRiskHitTagsByScope"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="SuspiciousTransactionItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO">
|
||||
<id property="bankStatementId" column="bankStatementId"/>
|
||||
<result property="trxDate" column="trxDate"/>
|
||||
@@ -78,7 +46,6 @@
|
||||
<result property="displayAmount" column="displayAmount"/>
|
||||
<result property="hasModelRuleHit" column="hasModelRuleHit"/>
|
||||
<result property="hasNameListHit" column="hasNameListHit"/>
|
||||
<result property="nameListHitType" column="nameListHitType"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="AbnormalAccountItemResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiProjectAbnormalAccountItemVO">
|
||||
@@ -147,13 +114,6 @@
|
||||
) tens
|
||||
</sql>
|
||||
|
||||
<sql id="externalModelCodeFilterSql">
|
||||
('EXTERNAL_LARGE_TRANSACTION',
|
||||
'EXTERNAL_ABNORMAL_TRANSACTION',
|
||||
'EXTERNAL_SUSPICIOUS_GAMBLING',
|
||||
'EXTERNAL_SUSPICIOUS_RELATION')
|
||||
</sql>
|
||||
|
||||
<sql id="resolvedEmployeeRiskBaseSql">
|
||||
select distinct
|
||||
tr.id,
|
||||
@@ -533,401 +493,6 @@
|
||||
order by result.staff_name asc, result.staff_id_card asc
|
||||
</select>
|
||||
|
||||
<select id="selectRiskModelPeopleList" resultMap="RiskModelPeopleItemResultMap">
|
||||
<bind name="projectId" value="query.projectId"/>
|
||||
select
|
||||
result.project_id,
|
||||
result.staff_id_card,
|
||||
result.staff_name,
|
||||
result.staff_code,
|
||||
result.dept_name as department,
|
||||
#{query.modelCodesCsv} as selected_model_codes
|
||||
from ccdi_project_overview_employee_result result
|
||||
where 1 = 1
|
||||
and result.project_id = #{query.projectId}
|
||||
<if test="query.modelCodes != null and query.modelCodes.size() > 0">
|
||||
<choose>
|
||||
<when test="query.matchMode == 'ALL'">
|
||||
<foreach collection="query.modelCodes" item="modelCode">
|
||||
and find_in_set(#{modelCode}, result.model_codes_csv)
|
||||
</foreach>
|
||||
</when>
|
||||
<otherwise>
|
||||
and (
|
||||
<foreach collection="query.modelCodes" item="modelCode" separator=" or ">
|
||||
find_in_set(#{modelCode}, result.model_codes_csv)
|
||||
</foreach>
|
||||
)
|
||||
</otherwise>
|
||||
</choose>
|
||||
</if>
|
||||
<if test="query.keyword != null and query.keyword != ''">
|
||||
and (
|
||||
result.staff_name like concat('%', trim(#{query.keyword}), '%')
|
||||
or result.staff_code like concat('%', trim(#{query.keyword}), '%')
|
||||
)
|
||||
</if>
|
||||
<if test="query.deptId != null">
|
||||
and result.dept_id = #{query.deptId}
|
||||
</if>
|
||||
order by result.staff_name asc, result.staff_id_card asc
|
||||
</select>
|
||||
|
||||
<sql id="externalPersonSubjectSql">
|
||||
select
|
||||
bs.project_id,
|
||||
bs.cret_no as cert_no,
|
||||
coalesce(
|
||||
max(intermediary.name),
|
||||
max(customer.name),
|
||||
max(nullif(trim(bs.LE_ACCOUNT_NAME), '')),
|
||||
'外部人员'
|
||||
) as person_name,
|
||||
case
|
||||
when max(case when intermediary.person_id is not null then 1 else 0 end) > 0 then '中介'
|
||||
when max(case when customer.person_id is not null then 1 else 0 end) > 0 then '客户'
|
||||
else '外部人员'
|
||||
end as subject_type
|
||||
from ccdi_bank_statement bs
|
||||
left join ccdi_base_staff staff
|
||||
on staff.id_card = bs.cret_no
|
||||
left join ccdi_staff_fmy_relation relation
|
||||
on relation.status = 1
|
||||
and relation.relation_cert_no = bs.cret_no
|
||||
left join ccdi_biz_intermediary intermediary
|
||||
on intermediary.person_sub_type = '本人'
|
||||
and intermediary.person_id = bs.cret_no
|
||||
left join ccdi_credit_customer_base customer
|
||||
on customer.person_id = bs.cret_no
|
||||
where bs.project_id = #{externalProjectId}
|
||||
and bs.cret_no is not null
|
||||
and trim(bs.cret_no) != ''
|
||||
and staff.id_card is null
|
||||
and relation.relation_cert_no is null
|
||||
group by bs.project_id, bs.cret_no
|
||||
</sql>
|
||||
|
||||
<sql id="externalPersonSourceSql">
|
||||
select
|
||||
subject.project_id,
|
||||
subject.cert_no,
|
||||
subject.person_name,
|
||||
subject.subject_type,
|
||||
bs.bank_statement_id,
|
||||
bs.TRX_DATE as trx_date,
|
||||
bs.CUSTOMER_ACCOUNT_NAME as customer_account_name,
|
||||
bs.customer_cert_no,
|
||||
case
|
||||
when counter_account.owner_type = 'EMPLOYEE' then '员工'
|
||||
when counter_account.owner_type = 'RELATION' then '员工亲属'
|
||||
when counter_account.owner_type = 'CREDIT_CUSTOMER' then '信贷客户'
|
||||
when counter_account.owner_type = 'INTERMEDIARY' then '中介库人员'
|
||||
when counter_staff.id_card is not null then '员工'
|
||||
when counter_relation.relation_cert_no is not null then '员工亲属'
|
||||
when counter_intermediary.person_id is not null then '中介库人员'
|
||||
else null
|
||||
end as related_object,
|
||||
tr.model_code,
|
||||
tr.model_name,
|
||||
tr.rule_code,
|
||||
tr.rule_name,
|
||||
tr.risk_level
|
||||
from (
|
||||
<include refid="externalPersonSubjectSql"/>
|
||||
) subject
|
||||
inner join ccdi_bank_statement bs
|
||||
on bs.project_id = subject.project_id
|
||||
and bs.cret_no = subject.cert_no
|
||||
inner join ccdi_bank_statement_tag_result tr
|
||||
on tr.project_id = bs.project_id
|
||||
and tr.bank_statement_id = bs.bank_statement_id
|
||||
and tr.model_code in <include refid="externalModelCodeFilterSql"/>
|
||||
left join ccdi_account_info counter_account
|
||||
on trim(bs.CUSTOMER_ACCOUNT_NO) != ''
|
||||
and counter_account.account_no = trim(bs.CUSTOMER_ACCOUNT_NO)
|
||||
and counter_account.owner_type in ('EMPLOYEE', 'RELATION', 'INTERMEDIARY', 'CREDIT_CUSTOMER')
|
||||
left join ccdi_base_staff counter_staff
|
||||
on counter_account.account_no is null
|
||||
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
|
||||
and counter_staff.name = trim(bs.CUSTOMER_ACCOUNT_NAME)
|
||||
left join ccdi_staff_fmy_relation counter_relation
|
||||
on counter_account.account_no is null
|
||||
and counter_relation.status = 1
|
||||
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
|
||||
and counter_relation.relation_name = trim(bs.CUSTOMER_ACCOUNT_NAME)
|
||||
left join ccdi_biz_intermediary counter_intermediary
|
||||
on counter_account.account_no is null
|
||||
and counter_intermediary.person_sub_type = '本人'
|
||||
and trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
|
||||
and counter_intermediary.name = trim(bs.CUSTOMER_ACCOUNT_NAME)
|
||||
where trim(ifnull(bs.LE_ACCOUNT_NAME, '')) != trim(ifnull(bs.CUSTOMER_ACCOUNT_NAME, ''))
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
subject.project_id,
|
||||
subject.cert_no,
|
||||
subject.person_name,
|
||||
subject.subject_type,
|
||||
null as bank_statement_id,
|
||||
null as trx_date,
|
||||
null as customer_account_name,
|
||||
null as customer_cert_no,
|
||||
'资金' as related_object,
|
||||
tr.model_code,
|
||||
tr.model_name,
|
||||
tr.rule_code,
|
||||
tr.rule_name,
|
||||
tr.risk_level
|
||||
from (
|
||||
<include refid="externalPersonSubjectSql"/>
|
||||
) subject
|
||||
inner join ccdi_bank_statement_tag_result tr
|
||||
on tr.project_id = subject.project_id
|
||||
and tr.object_type = 'EXTERNAL_CERT_NO'
|
||||
and tr.object_key = subject.cert_no
|
||||
and tr.model_code in <include refid="externalModelCodeFilterSql"/>
|
||||
</sql>
|
||||
|
||||
<sql id="externalPersonAggregateSql">
|
||||
select
|
||||
source.project_id,
|
||||
source.cert_no,
|
||||
max(source.person_name) as person_name,
|
||||
max(source.subject_type) as subject_type,
|
||||
count(*) as risk_count,
|
||||
count(distinct source.model_code) as model_count,
|
||||
group_concat(distinct source.rule_name order by source.rule_name separator '、') as risk_point,
|
||||
group_concat(distinct source.related_object order by source.related_object separator '、') as related_object,
|
||||
max(source.trx_date) as latest_trade_time,
|
||||
case
|
||||
when sum(case when source.risk_level = 'HIGH' then 1 else 0 end) > 0 then 'HIGH'
|
||||
when sum(case when source.risk_level = 'MEDIUM' then 1 else 0 end) > 0 then 'MEDIUM'
|
||||
else 'LOW'
|
||||
end as risk_level_code,
|
||||
null as selected_model_codes
|
||||
from (
|
||||
<include refid="externalPersonSourceSql"/>
|
||||
) source
|
||||
group by source.project_id, source.cert_no
|
||||
</sql>
|
||||
|
||||
<sql id="externalPersonWarningSelectSql">
|
||||
select
|
||||
agg.project_id,
|
||||
agg.cert_no,
|
||||
agg.person_name,
|
||||
agg.subject_type,
|
||||
agg.risk_count,
|
||||
agg.model_count,
|
||||
agg.risk_point,
|
||||
coalesce(agg.related_object, '-') as related_object,
|
||||
agg.latest_trade_time,
|
||||
agg.risk_level_code,
|
||||
case
|
||||
when agg.risk_level_code = 'HIGH' then '高风险'
|
||||
when agg.risk_level_code = 'MEDIUM' then '中风险'
|
||||
else '低风险'
|
||||
end as risk_level_name,
|
||||
case
|
||||
when agg.risk_level_code = 'HIGH' then 'danger'
|
||||
when agg.risk_level_code = 'MEDIUM' then 'warning'
|
||||
else 'info'
|
||||
end as risk_level_type,
|
||||
case
|
||||
when agg.risk_level_code = 'HIGH' then 1
|
||||
when agg.risk_level_code = 'MEDIUM' then 2
|
||||
else 3
|
||||
end as risk_level_sort,
|
||||
agg.selected_model_codes
|
||||
from (
|
||||
<include refid="externalPersonAggregateSql"/>
|
||||
) agg
|
||||
</sql>
|
||||
|
||||
<select id="selectExternalPersonWarningPage" resultMap="ExternalPersonWarningItemResultMap">
|
||||
<bind name="externalProjectId" value="query.projectId"/>
|
||||
select *
|
||||
from (
|
||||
<include refid="externalPersonWarningSelectSql"/>
|
||||
) warning
|
||||
order by warning.risk_level_sort asc, warning.model_count desc, warning.risk_count desc, warning.latest_trade_time desc
|
||||
</select>
|
||||
|
||||
<select id="selectExternalPersonWarningList" resultMap="ExternalPersonWarningItemResultMap">
|
||||
<bind name="externalProjectId" value="projectId"/>
|
||||
select *
|
||||
from (
|
||||
<include refid="externalPersonWarningSelectSql"/>
|
||||
) warning
|
||||
order by warning.risk_level_sort asc, warning.model_count desc, warning.risk_count desc, warning.latest_trade_time desc
|
||||
</select>
|
||||
|
||||
<select id="selectExternalRiskSummaryByProjectId" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO">
|
||||
<bind name="externalProjectId" value="projectId"/>
|
||||
select
|
||||
count(*) as total,
|
||||
coalesce(sum(case when risk.risk_level_code = 'HIGH' then 1 else 0 end), 0) as high,
|
||||
coalesce(sum(case when risk.risk_level_code = 'MEDIUM' then 1 else 0 end), 0) as medium,
|
||||
coalesce(sum(case when risk.risk_level_code = 'LOW' then 1 else 0 end), 0) as low,
|
||||
coalesce(sum(case when risk.risk_level_code is null then 1 else 0 end), 0) as noRisk
|
||||
from (
|
||||
<include refid="externalPersonSubjectSql"/>
|
||||
) subject
|
||||
left join (
|
||||
<include refid="externalPersonAggregateSql"/>
|
||||
) risk
|
||||
on risk.project_id = subject.project_id
|
||||
and risk.cert_no = subject.cert_no
|
||||
</select>
|
||||
|
||||
<select id="selectExternalRiskModelCardsByProjectId" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelCardVO">
|
||||
<bind name="externalProjectId" value="projectId"/>
|
||||
select
|
||||
model_scope.model_code,
|
||||
max(model_scope.model_name) as model_name,
|
||||
count(*) as warning_count,
|
||||
count(distinct model_scope.cert_no) as people_count
|
||||
from (
|
||||
<include refid="externalPersonSourceSql"/>
|
||||
) model_scope
|
||||
group by model_scope.model_code
|
||||
order by warning_count desc, model_scope.model_code asc
|
||||
</select>
|
||||
|
||||
<select id="selectExternalRiskModelPeoplePage" resultMap="ExternalRiskModelPeopleItemResultMap">
|
||||
<bind name="externalProjectId" value="query.projectId"/>
|
||||
select
|
||||
warning.project_id,
|
||||
warning.cert_no,
|
||||
warning.person_name,
|
||||
warning.subject_type,
|
||||
warning.related_object,
|
||||
#{query.modelCodesCsv} as selected_model_codes
|
||||
from (
|
||||
<include refid="externalPersonWarningSelectSql"/>
|
||||
) warning
|
||||
where 1 = 1
|
||||
<if test="query.modelCodes != null and query.modelCodes.size() > 0">
|
||||
<choose>
|
||||
<when test="query.matchMode == 'ALL'">
|
||||
<foreach collection="query.modelCodes" item="modelCode">
|
||||
and exists (
|
||||
select 1
|
||||
from (
|
||||
<include refid="externalPersonSourceSql"/>
|
||||
) source
|
||||
where source.cert_no = warning.cert_no
|
||||
and source.model_code = #{modelCode}
|
||||
)
|
||||
</foreach>
|
||||
</when>
|
||||
<otherwise>
|
||||
and exists (
|
||||
select 1
|
||||
from (
|
||||
<include refid="externalPersonSourceSql"/>
|
||||
) source
|
||||
where source.cert_no = warning.cert_no
|
||||
and source.model_code in
|
||||
<foreach collection="query.modelCodes" item="modelCode" open="(" separator="," close=")">
|
||||
#{modelCode}
|
||||
</foreach>
|
||||
)
|
||||
</otherwise>
|
||||
</choose>
|
||||
</if>
|
||||
<if test="query.keyword != null and query.keyword != ''">
|
||||
and (
|
||||
warning.person_name like concat('%', trim(#{query.keyword}), '%')
|
||||
or warning.cert_no like concat('%', trim(#{query.keyword}), '%')
|
||||
)
|
||||
</if>
|
||||
order by warning.person_name asc, warning.cert_no asc
|
||||
</select>
|
||||
|
||||
<select id="selectExternalRiskModelPeopleList" resultMap="ExternalRiskModelPeopleItemResultMap">
|
||||
<bind name="externalProjectId" value="query.projectId"/>
|
||||
select
|
||||
warning.project_id,
|
||||
warning.cert_no,
|
||||
warning.person_name,
|
||||
warning.subject_type,
|
||||
warning.related_object,
|
||||
#{query.modelCodesCsv} as selected_model_codes
|
||||
from (
|
||||
<include refid="externalPersonWarningSelectSql"/>
|
||||
) warning
|
||||
where 1 = 1
|
||||
<if test="query.modelCodes != null and query.modelCodes.size() > 0">
|
||||
<choose>
|
||||
<when test="query.matchMode == 'ALL'">
|
||||
<foreach collection="query.modelCodes" item="modelCode">
|
||||
and exists (
|
||||
select 1
|
||||
from (
|
||||
<include refid="externalPersonSourceSql"/>
|
||||
) source
|
||||
where source.cert_no = warning.cert_no
|
||||
and source.model_code = #{modelCode}
|
||||
)
|
||||
</foreach>
|
||||
</when>
|
||||
<otherwise>
|
||||
and exists (
|
||||
select 1
|
||||
from (
|
||||
<include refid="externalPersonSourceSql"/>
|
||||
) source
|
||||
where source.cert_no = warning.cert_no
|
||||
and source.model_code in
|
||||
<foreach collection="query.modelCodes" item="modelCode" open="(" separator="," close=")">
|
||||
#{modelCode}
|
||||
</foreach>
|
||||
)
|
||||
</otherwise>
|
||||
</choose>
|
||||
</if>
|
||||
<if test="query.keyword != null and query.keyword != ''">
|
||||
and (
|
||||
warning.person_name like concat('%', trim(#{query.keyword}), '%')
|
||||
or warning.cert_no like concat('%', trim(#{query.keyword}), '%')
|
||||
)
|
||||
</if>
|
||||
order by warning.person_name asc, warning.cert_no asc
|
||||
</select>
|
||||
|
||||
<select id="selectExternalRiskModelNamesByScope" resultType="java.lang.String">
|
||||
<bind name="externalProjectId" value="projectId"/>
|
||||
select distinct source.model_name
|
||||
from (
|
||||
<include refid="externalPersonSourceSql"/>
|
||||
) source
|
||||
where source.cert_no = #{certNo}
|
||||
<if test="selectedModelCodes != null and selectedModelCodes != ''">
|
||||
and find_in_set(source.model_code, #{selectedModelCodes})
|
||||
</if>
|
||||
order by source.model_name asc
|
||||
</select>
|
||||
|
||||
<select id="selectExternalRiskHitTagsByScope" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO">
|
||||
<bind name="externalProjectId" value="projectId"/>
|
||||
select distinct
|
||||
source.model_code as modelCode,
|
||||
source.model_name as modelName,
|
||||
source.rule_code as ruleCode,
|
||||
source.rule_name as ruleName,
|
||||
source.risk_level as riskLevel
|
||||
from (
|
||||
<include refid="externalPersonSourceSql"/>
|
||||
) source
|
||||
where source.cert_no = #{certNo}
|
||||
<if test="selectedModelCodes != null and selectedModelCodes != ''">
|
||||
and find_in_set(source.model_code, #{selectedModelCodes})
|
||||
</if>
|
||||
order by source.model_code asc, source.rule_code asc
|
||||
</select>
|
||||
|
||||
<sql id="suspiciousTransactionBaseSql">
|
||||
select
|
||||
bs.bank_statement_id as bankStatementId,
|
||||
@@ -965,70 +530,57 @@
|
||||
from ccdi_bank_statement_tag_result tr
|
||||
where tr.project_id = #{query.projectId}
|
||||
and tr.bank_statement_id is not null
|
||||
and tr.rule_name like '%可疑%'
|
||||
</sql>
|
||||
|
||||
<sql id="suspiciousTransactionNameHitSql">
|
||||
select
|
||||
hits.bankStatementId,
|
||||
hits.suspiciousPersonName,
|
||||
hits.matchPriority,
|
||||
hits.nameListHitType
|
||||
hits.matchPriority
|
||||
from (
|
||||
select
|
||||
bs.bank_statement_id as bankStatementId,
|
||||
coalesce(credit_customer.name, account.account_name, '信贷客户账号') as suspiciousPersonName,
|
||||
1 as matchPriority,
|
||||
'信贷客户' as nameListHitType
|
||||
from ccdi_bank_statement bs
|
||||
inner join ccdi_account_info account
|
||||
on trim(bs.customer_account_no) != ''
|
||||
and account.owner_type = 'CREDIT_CUSTOMER'
|
||||
and account.account_no = bs.customer_account_no
|
||||
left join ccdi_credit_customer_base credit_customer
|
||||
on credit_customer.person_id = account.owner_id
|
||||
where bs.project_id = #{query.projectId}
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
bs.bank_statement_id as bankStatementId,
|
||||
coalesce(intermediary.name, enterprise.enterprise_name, account.account_name, '中介账号') as suspiciousPersonName,
|
||||
2 as matchPriority,
|
||||
'中介' as nameListHitType
|
||||
from ccdi_bank_statement bs
|
||||
inner join ccdi_account_info account
|
||||
on trim(bs.customer_account_no) != ''
|
||||
and account.owner_type = 'INTERMEDIARY'
|
||||
and account.account_no = bs.customer_account_no
|
||||
left join ccdi_biz_intermediary intermediary
|
||||
on intermediary.person_id = account.owner_id
|
||||
left join ccdi_enterprise_base_info enterprise
|
||||
on enterprise.social_credit_code = account.owner_id
|
||||
where bs.project_id = #{query.projectId}
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
bs.bank_statement_id as bankStatementId,
|
||||
intermediary.name as suspiciousPersonName,
|
||||
3 as matchPriority,
|
||||
'中介' as nameListHitType
|
||||
1 as matchPriority
|
||||
from ccdi_bank_statement bs
|
||||
inner join ccdi_biz_intermediary intermediary
|
||||
on trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
|
||||
and intermediary.name = bs.CUSTOMER_ACCOUNT_NAME
|
||||
on trim(bs.customer_cert_no) != ''
|
||||
and intermediary.person_id = bs.customer_cert_no
|
||||
where bs.project_id = #{query.projectId}
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
bs.bank_statement_id as bankStatementId,
|
||||
enterprise.enterprise_name as suspiciousPersonName,
|
||||
4 as matchPriority,
|
||||
'中介' as nameListHitType
|
||||
2 as matchPriority
|
||||
from ccdi_bank_statement bs
|
||||
inner join ccdi_enterprise_base_info enterprise
|
||||
on trim(bs.customer_social_credit_code) != ''
|
||||
and enterprise.social_credit_code = bs.customer_social_credit_code
|
||||
and enterprise.risk_level = '1'
|
||||
and enterprise.ent_source = 'INTERMEDIARY'
|
||||
where bs.project_id = #{query.projectId}
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
bs.bank_statement_id as bankStatementId,
|
||||
intermediary.name as suspiciousPersonName,
|
||||
3 as matchPriority
|
||||
from ccdi_bank_statement bs
|
||||
inner join ccdi_biz_intermediary intermediary
|
||||
on trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
|
||||
and intermediary.name = bs.CUSTOMER_ACCOUNT_NAME
|
||||
where bs.project_id = #{query.projectId}
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
bs.bank_statement_id as bankStatementId,
|
||||
enterprise.enterprise_name as suspiciousPersonName,
|
||||
3 as matchPriority
|
||||
from ccdi_bank_statement bs
|
||||
inner join ccdi_enterprise_base_info enterprise
|
||||
on trim(bs.CUSTOMER_ACCOUNT_NAME) != ''
|
||||
@@ -1036,38 +588,9 @@
|
||||
and enterprise.risk_level = '1'
|
||||
and enterprise.ent_source = 'INTERMEDIARY'
|
||||
where bs.project_id = #{query.projectId}
|
||||
and GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000
|
||||
) hits
|
||||
</sql>
|
||||
|
||||
<sql id="externalSuspiciousTransactionSql">
|
||||
select
|
||||
bs.bank_statement_id as bankStatementId,
|
||||
bs.TRX_DATE as trxDate,
|
||||
source.person_name as relatedPersonName,
|
||||
null as relatedStaffName,
|
||||
null as relatedStaffCode,
|
||||
source.subject_type as relationType,
|
||||
bs.USER_MEMO as userMemo,
|
||||
bs.CASH_TYPE as cashType,
|
||||
case
|
||||
when ifnull(bs.AMOUNT_CR, 0) > 0 then bs.AMOUNT_CR
|
||||
when ifnull(bs.AMOUNT_DR, 0) > 0 then -bs.AMOUNT_DR
|
||||
else 0
|
||||
end as displayAmount,
|
||||
1 as hasModelRuleHit,
|
||||
0 as hasNameListHit,
|
||||
source.person_name as suspiciousPersonName,
|
||||
9 as matchPriority,
|
||||
'外部人员预警' as nameListHitType
|
||||
from (
|
||||
<bind name="externalProjectId" value="query.projectId"/>
|
||||
<include refid="externalPersonSourceSql"/>
|
||||
) source
|
||||
inner join ccdi_bank_statement bs
|
||||
on bs.bank_statement_id = source.bank_statement_id
|
||||
</sql>
|
||||
|
||||
<sql id="suspiciousTransactionMergedSql">
|
||||
select
|
||||
base.bankStatementId,
|
||||
@@ -1082,8 +605,7 @@
|
||||
1 as hasModelRuleHit,
|
||||
0 as hasNameListHit,
|
||||
null as suspiciousPersonName,
|
||||
null as matchPriority,
|
||||
null as nameListHitType
|
||||
null as matchPriority
|
||||
from (
|
||||
<include refid="suspiciousTransactionBaseSql"/>
|
||||
) base
|
||||
@@ -1106,35 +628,13 @@
|
||||
0 as hasModelRuleHit,
|
||||
1 as hasNameListHit,
|
||||
name_hits.suspiciousPersonName,
|
||||
name_hits.matchPriority,
|
||||
name_hits.nameListHitType
|
||||
name_hits.matchPriority
|
||||
from (
|
||||
<include refid="suspiciousTransactionBaseSql"/>
|
||||
) base
|
||||
inner join (
|
||||
<include refid="suspiciousTransactionNameHitSql"/>
|
||||
) name_hits on name_hits.bankStatementId = base.bankStatementId
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
external_hits.bankStatementId,
|
||||
external_hits.trxDate,
|
||||
external_hits.relatedPersonName,
|
||||
external_hits.relatedStaffName,
|
||||
external_hits.relatedStaffCode,
|
||||
external_hits.relationType,
|
||||
external_hits.userMemo,
|
||||
external_hits.cashType,
|
||||
external_hits.displayAmount,
|
||||
external_hits.hasModelRuleHit,
|
||||
external_hits.hasNameListHit,
|
||||
external_hits.suspiciousPersonName,
|
||||
external_hits.matchPriority,
|
||||
external_hits.nameListHitType
|
||||
from (
|
||||
<include refid="externalSuspiciousTransactionSql"/>
|
||||
) external_hits
|
||||
</sql>
|
||||
|
||||
<sql id="suspiciousTransactionAggregatedSql">
|
||||
@@ -1163,18 +663,7 @@
|
||||
max(merged.cashType) as cashType,
|
||||
max(merged.displayAmount) as displayAmount,
|
||||
max(merged.hasModelRuleHit) as hasModelRuleHit,
|
||||
max(merged.hasNameListHit) as hasNameListHit,
|
||||
substring_index(
|
||||
min(
|
||||
case
|
||||
when merged.nameListHitType is not null and merged.nameListHitType != ''
|
||||
then concat(lpad(merged.matchPriority, 2, '0'), '|', merged.nameListHitType)
|
||||
else null
|
||||
end
|
||||
),
|
||||
'|',
|
||||
-1
|
||||
) as nameListHitType
|
||||
max(merged.hasNameListHit) as hasNameListHit
|
||||
from (
|
||||
<include refid="suspiciousTransactionMergedSql"/>
|
||||
) merged
|
||||
@@ -1189,9 +678,6 @@
|
||||
<when test="query.suspiciousType == 'MODEL_RULE'">
|
||||
where final_result.hasModelRuleHit = 1
|
||||
</when>
|
||||
<when test="query.suspiciousType == 'EXTERNAL_PERSON'">
|
||||
where final_result.nameListHitType = '外部人员预警'
|
||||
</when>
|
||||
<otherwise>
|
||||
where final_result.hasModelRuleHit = 1 or final_result.hasNameListHit = 1
|
||||
</otherwise>
|
||||
@@ -1199,7 +685,7 @@
|
||||
</sql>
|
||||
|
||||
<select id="selectSuspiciousTransactionPage" resultMap="SuspiciousTransactionItemResultMap">
|
||||
<!-- ccdi_bank_statement_tag_result -->
|
||||
<!-- rule_name like '%可疑%' -->
|
||||
<!-- ccdi_biz_intermediary -->
|
||||
<!-- ccdi_enterprise_base_info -->
|
||||
<!-- group by merged.bankStatementId -->
|
||||
@@ -1215,8 +701,7 @@
|
||||
final_result.cashType,
|
||||
final_result.displayAmount,
|
||||
final_result.hasModelRuleHit,
|
||||
final_result.hasNameListHit,
|
||||
final_result.nameListHitType
|
||||
final_result.hasNameListHit
|
||||
from (
|
||||
<include refid="suspiciousTransactionAggregatedSql"/>
|
||||
) final_result
|
||||
@@ -1237,8 +722,7 @@
|
||||
final_result.cashType,
|
||||
final_result.displayAmount,
|
||||
final_result.hasModelRuleHit,
|
||||
final_result.hasNameListHit,
|
||||
final_result.nameListHitType
|
||||
final_result.hasNameListHit
|
||||
from (
|
||||
<include refid="suspiciousTransactionAggregatedSql"/>
|
||||
) final_result
|
||||
@@ -1258,21 +742,7 @@
|
||||
final_result.relatedStaffCode,
|
||||
final_result.userMemo,
|
||||
final_result.cashType,
|
||||
case
|
||||
when final_result.nameListHitType = '中介' then
|
||||
replace(
|
||||
replace(ifnull(tag_result.hitTags, ''), '与客户之间非正常资金往来', '疑似与中介往来'),
|
||||
'异常交易',
|
||||
'疑似与中介往来'
|
||||
)
|
||||
when final_result.nameListHitType = '信贷客户' then
|
||||
replace(
|
||||
replace(ifnull(tag_result.hitTags, ''), '与客户之间非正常资金往来', '与信贷客户之间非正常资金往来'),
|
||||
'异常交易',
|
||||
'与信贷客户之间非正常资金往来'
|
||||
)
|
||||
else tag_result.hitTags
|
||||
end as hitTags,
|
||||
tag_result.hitTags,
|
||||
final_result.displayAmount
|
||||
from (
|
||||
<include refid="suspiciousTransactionAggregatedSql"/>
|
||||
@@ -1559,5 +1029,4 @@
|
||||
group by base.staff_id_card
|
||||
) agg
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -114,18 +114,32 @@
|
||||
|
||||
<sql id="projectEmployeeScopeSql">
|
||||
select distinct
|
||||
statement_staff.id_card as staff_id_card,
|
||||
cast(statement_staff.staff_id as char) as staff_code,
|
||||
statement_staff.name as staff_name,
|
||||
coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card) as staff_id_card,
|
||||
cast(coalesce(direct_staff.staff_id, statement_staff.staff_id, family_staff.staff_id) as char) as staff_code,
|
||||
coalesce(direct_staff.name, statement_staff.name, family_staff.name) as staff_name,
|
||||
dept.dept_name
|
||||
from ccdi_bank_statement bs
|
||||
inner join ccdi_base_staff statement_staff
|
||||
on statement_staff.id_card = trim(bs.cret_no)
|
||||
from ccdi_bank_statement_tag_result tr
|
||||
left join ccdi_base_staff direct_staff
|
||||
on tr.object_type = 'STAFF_ID_CARD'
|
||||
and tr.object_key = direct_staff.id_card
|
||||
left join ccdi_bank_statement bs
|
||||
on tr.bank_statement_id = bs.bank_statement_id
|
||||
left join ccdi_base_staff statement_staff
|
||||
on (tr.object_key is null or tr.object_key = '')
|
||||
and bs.cret_no = statement_staff.id_card
|
||||
left join ccdi_staff_fmy_relation relation
|
||||
on relation.status = 1
|
||||
and (
|
||||
((tr.object_key is null or tr.object_key = '') and bs.cret_no = relation.relation_cert_no)
|
||||
or ((tr.object_key is not null and tr.object_key != '') and tr.object_type != 'STAFF_ID_CARD'
|
||||
and tr.object_key = relation.relation_cert_no)
|
||||
)
|
||||
left join ccdi_base_staff family_staff
|
||||
on relation.person_id = family_staff.id_card
|
||||
left join sys_dept dept
|
||||
on dept.dept_id = statement_staff.dept_id
|
||||
where bs.project_id = #{projectId}
|
||||
and bs.cret_no is not null
|
||||
and trim(bs.cret_no) != ''
|
||||
on dept.dept_id = coalesce(direct_staff.dept_id, statement_staff.dept_id, family_staff.dept_id)
|
||||
where tr.project_id = #{projectId}
|
||||
and coalesce(direct_staff.id_card, statement_staff.id_card, family_staff.id_card) is not null
|
||||
</sql>
|
||||
|
||||
<sql id="spouseRelationSql">
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.ccdi.project.mapper.CcdiRelationGraphMapper">
|
||||
|
||||
<resultMap id="RelationGraphNodeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphNodeVO">
|
||||
<id property="objectKey" column="objectKey"/>
|
||||
<result property="nodeKey" column="nodeKey"/>
|
||||
<result property="nodeName" column="nodeName"/>
|
||||
<result property="idNumber" column="idNumber"/>
|
||||
<result property="subjectType" column="subjectType"/>
|
||||
<result property="sourceType" column="sourceType"/>
|
||||
<result property="createdTime" column="createdTime"/>
|
||||
<result property="updatedTime" column="updatedTime"/>
|
||||
<result property="canExpand" column="canExpand"/>
|
||||
<result property="depth" column="depth"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="RelationGraphEdgeResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphEdgeVO">
|
||||
<id property="objectKey" column="objectKey"/>
|
||||
<result property="fromKey" column="fromKey"/>
|
||||
<result property="toKey" column="toKey"/>
|
||||
<result property="edgeTable" column="edgeTable"/>
|
||||
<result property="relationType" column="relationType"/>
|
||||
<result property="companyName" column="companyName"/>
|
||||
<result property="stockName" column="stockName"/>
|
||||
<result property="stockType" column="stockType"/>
|
||||
<result property="stockPercent" column="stockPercent"/>
|
||||
<result property="shouldCapi" column="shouldCapi"/>
|
||||
<result property="shouldCapiValue" column="shouldCapiValue"/>
|
||||
<result property="shouldCapiUnit" column="shouldCapiUnit"/>
|
||||
<result property="shoudDate" column="shoudDate"/>
|
||||
<result property="pKeyNo" column="pKeyNo"/>
|
||||
<result property="operName" column="operName"/>
|
||||
<result property="operKeyNo" column="operKeyNo"/>
|
||||
<result property="personId" column="personId"/>
|
||||
<result property="relationName" column="relationName"/>
|
||||
<result property="relationCertNo" column="relationCertNo"/>
|
||||
<result property="gender" column="gender"/>
|
||||
<result property="birthDate" column="birthDate"/>
|
||||
<result property="relationCertType" column="relationCertType"/>
|
||||
<result property="mobilePhone1" column="mobilePhone1"/>
|
||||
<result property="mobilePhone2" column="mobilePhone2"/>
|
||||
<result property="wechatNo1" column="wechatNo1"/>
|
||||
<result property="wechatNo2" column="wechatNo2"/>
|
||||
<result property="wechatNo3" column="wechatNo3"/>
|
||||
<result property="contactAddress" column="contactAddress"/>
|
||||
<result property="annualIncome" column="annualIncome"/>
|
||||
<result property="relationDesc" column="relationDesc"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="effectiveDate" column="effectiveDate"/>
|
||||
<result property="invalidDate" column="invalidDate"/>
|
||||
<result property="remark" column="remark"/>
|
||||
<result property="dataSource" column="dataSource"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="SuspectedEnterpriseResultMap" type="com.ruoyi.ccdi.project.domain.vo.CcdiRelationGraphSuspectedEnterpriseItemVO">
|
||||
<result property="candidateKeyNo" column="candidateKeyNo"/>
|
||||
<result property="personName" column="personName"/>
|
||||
<result property="companyId" column="companyId"/>
|
||||
<result property="companyName" column="companyName"/>
|
||||
<result property="creditCode" column="creditCode"/>
|
||||
<result property="enterpriseStatus" column="enterpriseStatus"/>
|
||||
<result property="industryName" column="industryName"/>
|
||||
<result property="relationType" column="relationType"/>
|
||||
<result property="stockPercent" column="stockPercent"/>
|
||||
<result property="establishDate" column="establishDate"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="nodeColumns">
|
||||
n.object_key AS objectKey,
|
||||
CONCAT('rel_node/', n.object_key) AS nodeKey,
|
||||
n.node_name AS nodeName,
|
||||
n.id_number AS idNumber,
|
||||
n.subject_type AS subjectType,
|
||||
n.source_type AS sourceType,
|
||||
DATE_FORMAT(n.created_time, '%Y-%m-%d %H:%i:%s') AS createdTime,
|
||||
DATE_FORMAT(n.updated_time, '%Y-%m-%d %H:%i:%s') AS updatedTime,
|
||||
1 AS canExpand,
|
||||
0 AS depth
|
||||
</sql>
|
||||
|
||||
<select id="selectRelationGraphSubjects" resultMap="RelationGraphNodeResultMap">
|
||||
SELECT
|
||||
<include refid="nodeColumns"/>
|
||||
FROM lx_rel_node n
|
||||
WHERE 1 = 1
|
||||
<if test="query.objectKey != null and query.objectKey != ''">
|
||||
AND n.object_key = (#{query.objectKey} COLLATE utf8mb4_general_ci)
|
||||
</if>
|
||||
<if test="query.objectKey == null or query.objectKey == ''">
|
||||
<if test="query.keyword != null and query.keyword != ''">
|
||||
AND (
|
||||
n.object_key = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
|
||||
OR n.id_number = (TRIM(#{query.keyword}) COLLATE utf8mb4_general_ci)
|
||||
OR n.node_name LIKE (CONCAT('%', TRIM(#{query.keyword}), '%') COLLATE utf8mb4_general_ci)
|
||||
)
|
||||
</if>
|
||||
</if>
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN n.object_key = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 0
|
||||
WHEN n.id_number = (TRIM(IFNULL(#{query.keyword}, '')) COLLATE utf8mb4_general_ci) THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
n.node_name
|
||||
LIMIT 20
|
||||
</select>
|
||||
|
||||
<select id="selectRelationGraphNodesByKeys" resultMap="RelationGraphNodeResultMap">
|
||||
SELECT
|
||||
<include refid="nodeColumns"/>
|
||||
FROM lx_rel_node n
|
||||
WHERE n.object_key IN
|
||||
<foreach collection="objectKeys" item="objectKey" open="(" separator="," close=")">
|
||||
#{objectKey}
|
||||
</foreach>
|
||||
</select>
|
||||
|
||||
<select id="selectRelationGraphEdges" resultMap="RelationGraphEdgeResultMap">
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
e.object_key AS objectKey,
|
||||
e.from_key AS fromKey,
|
||||
e.to_key AS toKey,
|
||||
'lx_rel_family_edge' AS edgeTable,
|
||||
e.relation_type AS relationType,
|
||||
NULL AS companyName,
|
||||
NULL AS stockName,
|
||||
NULL AS stockType,
|
||||
NULL AS stockPercent,
|
||||
NULL AS shouldCapi,
|
||||
NULL AS shouldCapiValue,
|
||||
NULL AS shouldCapiUnit,
|
||||
NULL AS shoudDate,
|
||||
NULL AS pKeyNo,
|
||||
NULL AS operName,
|
||||
NULL AS operKeyNo,
|
||||
e.person_id AS personId,
|
||||
e.relation_name AS relationName,
|
||||
e.relation_cert_no AS relationCertNo,
|
||||
NULL AS gender,
|
||||
NULL AS birthDate,
|
||||
NULL AS relationCertType,
|
||||
NULL AS mobilePhone1,
|
||||
NULL AS mobilePhone2,
|
||||
NULL AS wechatNo1,
|
||||
NULL AS wechatNo2,
|
||||
NULL AS wechatNo3,
|
||||
NULL AS contactAddress,
|
||||
NULL AS annualIncome,
|
||||
e.relation_desc AS relationDesc,
|
||||
NULL AS status,
|
||||
NULL AS effectiveDate,
|
||||
NULL AS invalidDate,
|
||||
NULL AS remark,
|
||||
NULL AS dataSource
|
||||
FROM lx_rel_family_edge e
|
||||
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
|
||||
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
e.object_key AS objectKey,
|
||||
e.from_key AS fromKey,
|
||||
e.to_key AS toKey,
|
||||
'lx_rel_stock_edge' AS edgeTable,
|
||||
e.stock_type AS relationType,
|
||||
e.company_name AS companyName,
|
||||
e.stock_name AS stockName,
|
||||
e.stock_type AS stockType,
|
||||
e.stock_percent AS stockPercent,
|
||||
e.should_capi AS shouldCapi,
|
||||
e.should_capi_value AS shouldCapiValue,
|
||||
e.should_capi_unit AS shouldCapiUnit,
|
||||
e.shoud_date AS shoudDate,
|
||||
e.p_key_no AS pKeyNo,
|
||||
NULL AS operName,
|
||||
NULL AS operKeyNo,
|
||||
NULL AS personId,
|
||||
NULL AS relationName,
|
||||
NULL AS relationCertNo,
|
||||
NULL AS gender,
|
||||
NULL AS birthDate,
|
||||
NULL AS relationCertType,
|
||||
NULL AS mobilePhone1,
|
||||
NULL AS mobilePhone2,
|
||||
NULL AS wechatNo1,
|
||||
NULL AS wechatNo2,
|
||||
NULL AS wechatNo3,
|
||||
NULL AS contactAddress,
|
||||
NULL AS annualIncome,
|
||||
NULL AS relationDesc,
|
||||
NULL AS status,
|
||||
NULL AS effectiveDate,
|
||||
NULL AS invalidDate,
|
||||
NULL AS remark,
|
||||
NULL AS dataSource
|
||||
FROM lx_rel_stock_edge e
|
||||
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
|
||||
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
e.object_key AS objectKey,
|
||||
e.from_key AS fromKey,
|
||||
e.to_key AS toKey,
|
||||
'lx_rel_represent_edge' AS edgeTable,
|
||||
'法定代表人' AS relationType,
|
||||
NULL AS companyName,
|
||||
NULL AS stockName,
|
||||
NULL AS stockType,
|
||||
NULL AS stockPercent,
|
||||
NULL AS shouldCapi,
|
||||
NULL AS shouldCapiValue,
|
||||
NULL AS shouldCapiUnit,
|
||||
NULL AS shoudDate,
|
||||
NULL AS pKeyNo,
|
||||
e.oper_name AS operName,
|
||||
e.oper_key_no AS operKeyNo,
|
||||
NULL AS personId,
|
||||
NULL AS relationName,
|
||||
NULL AS relationCertNo,
|
||||
NULL AS gender,
|
||||
NULL AS birthDate,
|
||||
NULL AS relationCertType,
|
||||
NULL AS mobilePhone1,
|
||||
NULL AS mobilePhone2,
|
||||
NULL AS wechatNo1,
|
||||
NULL AS wechatNo2,
|
||||
NULL AS wechatNo3,
|
||||
NULL AS contactAddress,
|
||||
NULL AS annualIncome,
|
||||
NULL AS relationDesc,
|
||||
NULL AS status,
|
||||
NULL AS effectiveDate,
|
||||
NULL AS invalidDate,
|
||||
NULL AS remark,
|
||||
NULL AS dataSource
|
||||
FROM lx_rel_represent_edge e
|
||||
WHERE e.from_key = CONCAT('rel_node/', #{query.objectKey})
|
||||
OR e.to_key = CONCAT('rel_node/', #{query.objectKey})
|
||||
) graph_edges
|
||||
ORDER BY
|
||||
CASE edgeTable
|
||||
WHEN 'lx_rel_family_edge' THEN 0
|
||||
WHEN 'lx_rel_stock_edge' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
relationType,
|
||||
objectKey
|
||||
LIMIT #{query.limit}
|
||||
</select>
|
||||
|
||||
<select id="countSuspectedEnterpriseKeyNos" resultType="int">
|
||||
SELECT COUNT(DISTINCT candidate_key_no)
|
||||
FROM (
|
||||
SELECT e.oper_key_no AS candidate_key_no
|
||||
FROM lx_rel_represent_edge e
|
||||
WHERE e.oper_name = (#{personName} COLLATE utf8mb4_general_ci)
|
||||
AND e.oper_key_no IS NOT NULL
|
||||
AND e.oper_key_no != ''
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT e.p_key_no AS candidate_key_no
|
||||
FROM lx_rel_stock_edge e
|
||||
WHERE e.stock_name = (#{personName} COLLATE utf8mb4_general_ci)
|
||||
AND e.stock_type = '自然人股东'
|
||||
AND e.p_key_no IS NOT NULL
|
||||
AND e.p_key_no != ''
|
||||
) same_name_candidates
|
||||
</select>
|
||||
|
||||
<select id="selectSuspectedEnterprises" resultMap="SuspectedEnterpriseResultMap">
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
e.oper_key_no AS candidateKeyNo,
|
||||
e.oper_name AS personName,
|
||||
REPLACE(e.to_key, 'rel_node/', '') AS companyId,
|
||||
COALESCE(ent.enterprise_name, company_node.node_name) AS companyName,
|
||||
COALESCE(ent.social_credit_code, company_node.id_number) AS creditCode,
|
||||
ent.status AS enterpriseStatus,
|
||||
ent.industry_name AS industryName,
|
||||
'法定代表人' AS relationType,
|
||||
NULL AS stockPercent,
|
||||
DATE_FORMAT(ent.establish_date, '%Y-%m-%d') AS establishDate,
|
||||
0 AS relationSort
|
||||
FROM lx_rel_represent_edge e
|
||||
LEFT JOIN lx_rel_node company_node
|
||||
ON company_node.object_key = REPLACE(e.to_key, 'rel_node/', '')
|
||||
LEFT JOIN ccdi_enterprise_base_info ent
|
||||
ON ent.social_credit_code = company_node.id_number
|
||||
WHERE e.oper_name = (#{personName} COLLATE utf8mb4_general_ci)
|
||||
AND e.oper_key_no IS NOT NULL
|
||||
AND e.oper_key_no != ''
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
e.p_key_no AS candidateKeyNo,
|
||||
e.stock_name AS personName,
|
||||
REPLACE(e.to_key, 'rel_node/', '') AS companyId,
|
||||
COALESCE(ent.enterprise_name, e.company_name, company_node.node_name) AS companyName,
|
||||
COALESCE(ent.social_credit_code, company_node.id_number) AS creditCode,
|
||||
ent.status AS enterpriseStatus,
|
||||
ent.industry_name AS industryName,
|
||||
e.stock_type AS relationType,
|
||||
e.stock_percent AS stockPercent,
|
||||
COALESCE(DATE_FORMAT(ent.establish_date, '%Y-%m-%d'), e.shoud_date) AS establishDate,
|
||||
1 AS relationSort
|
||||
FROM lx_rel_stock_edge e
|
||||
LEFT JOIN lx_rel_node company_node
|
||||
ON company_node.object_key = REPLACE(e.to_key, 'rel_node/', '')
|
||||
LEFT JOIN ccdi_enterprise_base_info ent
|
||||
ON ent.social_credit_code = company_node.id_number
|
||||
WHERE e.stock_name = (#{personName} COLLATE utf8mb4_general_ci)
|
||||
AND e.stock_type = '自然人股东'
|
||||
AND e.p_key_no IS NOT NULL
|
||||
AND e.p_key_no != ''
|
||||
) suspected_enterprises
|
||||
ORDER BY relationSort, companyName, companyId
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -281,8 +281,7 @@ class CcdiProjectOverviewControllerTest {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
|
||||
CcdiProjectSuspiciousTransactionExcel row = new CcdiProjectSuspiciousTransactionExcel();
|
||||
row.setLeAccountName("张三");
|
||||
row.setCustomerAccountName("测试对手方");
|
||||
row.setSuspiciousPersonName("张三");
|
||||
row.setDisplayAmount(new java.math.BigDecimal("10.00"));
|
||||
when(overviewService.exportSuspiciousTransactions(same(queryDTO))).thenReturn(List.of(row));
|
||||
|
||||
|
||||
@@ -104,26 +104,4 @@ class CcdiBankStatementTest {
|
||||
assertEquals("330101199001011234", entity.getCustomerCertNo());
|
||||
assertEquals("91330100123456789X", entity.getCustomerSocialCreditCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFromResponse_ShouldNormalizeCretNoBeforeDash() {
|
||||
BankStatementItem item = new BankStatementItem();
|
||||
item.setCretNo(" 330100198801010033 - 测试人员 ");
|
||||
|
||||
CcdiBankStatement entity = CcdiBankStatement.fromResponse(item);
|
||||
|
||||
assertNotNull(entity);
|
||||
assertEquals("330100198801010033", entity.getCretNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFromResponse_ShouldNormalizeCretNoWithChineseDash() {
|
||||
BankStatementItem item = new BankStatementItem();
|
||||
item.setCretNo("330100198801010033-测试人员");
|
||||
|
||||
CcdiBankStatement entity = CcdiBankStatement.fromResponse(item);
|
||||
|
||||
assertNotNull(entity);
|
||||
assertEquals("330100198801010033", entity.getCretNo());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class CcdiBankTagAnalysisMapperXmlTest {
|
||||
@@ -27,18 +26,11 @@ class CcdiBankTagAnalysisMapperXmlTest {
|
||||
"selectForexSellAmtStatements",
|
||||
"selectLargePurchaseTransactionStatements",
|
||||
"selectStockTfrLargeStatements",
|
||||
"selectLargeStockTradingStatements",
|
||||
"selectExternalSingleLargeAmountStatements",
|
||||
"selectExternalNightTransactionStatements",
|
||||
"selectExternalGamblingMemoStatements",
|
||||
"selectExternalToStaffOrFamilyTransactionStatements"
|
||||
"selectLargeStockTradingStatements"
|
||||
);
|
||||
private static final List<String> PHASE_TWO_OBJECT_SELECT_IDS = List.of(
|
||||
"selectLowIncomeRelativeLargeTransactionObjects",
|
||||
"selectMultiPartyGamblingTransferObjects",
|
||||
"selectExternalCumulativeTransactionAmountObjects",
|
||||
"selectExternalAnnualTurnoverObjects",
|
||||
"selectExternalMultiPartyGamblingTransferObjects",
|
||||
"selectMonthlyFixedIncomeObjects",
|
||||
"selectFixedCounterpartyTransferObjects",
|
||||
"selectSupplierConcentrationObjects",
|
||||
@@ -108,10 +100,7 @@ class CcdiBankTagAnalysisMapperXmlTest {
|
||||
void placeholderRules_shouldUseEmptyResultSqlTemplate() throws Exception {
|
||||
String xml = readXml(RESOURCE);
|
||||
assertTrue(xml.contains("占位SQL,待补充真实规则"));
|
||||
assertEquals(
|
||||
countMatches(xml, "占位SQL,待补充真实规则"),
|
||||
countMatches(xml, "where 1 = 0")
|
||||
);
|
||||
assertEquals(6, countMatches(xml, "where 1 = 0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -127,31 +116,6 @@ class CcdiBankTagAnalysisMapperXmlTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void lowIncomeRelativeRule_shouldIgnoreNullAnnualIncome() throws Exception {
|
||||
String xml = readXml(RESOURCE);
|
||||
String selectSql = extractSelectSql(xml, "selectLowIncomeRelativeLargeTransactionObjects");
|
||||
assertTrue(selectSql.contains("relation.annual_income is not null"));
|
||||
assertTrue(!selectSql.contains("relation.annual_income is null"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void abnormalCustomerTransactionRule_shouldUseCreditCustomerAndIntermediaryAccountRules() throws Exception {
|
||||
String xml = readXml(RESOURCE);
|
||||
String selectSql = extractSelectSql(xml, "selectAbnormalCustomerTransactionStatements");
|
||||
|
||||
assertTrue(selectSql.contains("account.owner_type = 'CREDIT_CUSTOMER'"));
|
||||
assertTrue(selectSql.contains("account.owner_type = 'INTERMEDIARY'"));
|
||||
assertTrue(selectSql.contains("account.account_no = bs.customer_account_no"));
|
||||
assertTrue(selectSql.contains("enterprise.ent_source = 'INTERMEDIARY'"));
|
||||
assertTrue(selectSql.contains("intermediary.name = bs.CUSTOMER_ACCOUNT_NAME"));
|
||||
assertTrue(selectSql.contains("bs.CUSTOMER_ACCOUNT_NAME like concat('%', intermediary.name, '%')"));
|
||||
assertTrue(selectSql.contains("bs.bank in ('ALIPAY', 'WECHAT')"));
|
||||
assertEquals(5, countMatches(selectSql, "GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000"));
|
||||
assertTrue(!selectSql.contains("customer_cert_no"));
|
||||
assertTrue(!selectSql.contains("social_credit_code = bs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawCntObjectRule_shouldUseRealSqlAndKeepObjectHitFields() throws Exception {
|
||||
String xml = readXml(RESOURCE);
|
||||
@@ -167,11 +131,7 @@ class CcdiBankTagAnalysisMapperXmlTest {
|
||||
String xml = readXml(RESOURCE);
|
||||
for (String selectId : PHASE_TWO_OBJECT_SELECT_IDS) {
|
||||
String selectSql = extractSelectSql(xml, selectId);
|
||||
assertTrue(
|
||||
selectSql.contains("'STAFF_ID_CARD' AS objectType")
|
||||
|| selectSql.contains("'EXTERNAL_CERT_NO' AS objectType"),
|
||||
() -> selectId + " 缺少 objectType"
|
||||
);
|
||||
assertTrue(selectSql.contains("'STAFF_ID_CARD' AS objectType"), () -> selectId + " 缺少 objectType");
|
||||
assertTrue(selectSql.contains("AS objectKey"), () -> selectId + " 缺少 objectKey");
|
||||
assertTrue(selectSql.contains("reasonDetail"), () -> selectId + " 缺少 reasonDetail");
|
||||
assertTrue(!selectSql.contains("where 1 = 0"), () -> selectId + " 仍是占位 SQL");
|
||||
@@ -236,31 +196,6 @@ class CcdiBankTagAnalysisMapperXmlTest {
|
||||
factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void externalPersonRules_shouldUseExternalSubjectScopeAndCounterpartyEmployeeMatching() throws Exception {
|
||||
String xml = readXml(RESOURCE);
|
||||
String scopeSql = extractSqlFragment(xml, "externalPersonPredicateSql");
|
||||
String relationSql = extractSelectSql(xml, "selectExternalToStaffOrFamilyTransactionStatements");
|
||||
String annualSql = extractSelectSql(xml, "selectExternalAnnualTurnoverObjects");
|
||||
String gamblingSql = extractSelectSql(xml, "selectExternalMultiPartyGamblingTransferObjects");
|
||||
|
||||
assertTrue(scopeSql.contains("bs.cret_no is not null"));
|
||||
assertTrue(scopeSql.contains("staff.id_card = bs.cret_no"));
|
||||
assertTrue(scopeSql.contains("relation.relation_cert_no = bs.cret_no"));
|
||||
assertTrue(scopeSql.contains("trim(IFNULL(bs.LE_ACCOUNT_NAME, '')) <> trim(IFNULL(bs.CUSTOMER_ACCOUNT_NAME, ''))"));
|
||||
assertFalse(scopeSql.contains("LE_ACCOUNT_NO"));
|
||||
assertTrue(annualSql.contains("'EXTERNAL_CERT_NO' AS objectType"));
|
||||
assertTrue(annualSql.contains("bs.cret_no AS certNo"));
|
||||
assertTrue(gamblingSql.contains("'EXTERNAL_CERT_NO' AS objectType"));
|
||||
assertTrue(gamblingSql.contains("having COUNT(1) > 2"));
|
||||
assertTrue(relationSql.contains("counter_account.owner_type in ('EMPLOYEE', 'RELATION', 'INTERMEDIARY', 'CREDIT_CUSTOMER')"));
|
||||
assertTrue(relationSql.contains("on counter_account.account_no is null"));
|
||||
assertTrue(relationSql.contains("counter_staff.name = trim(bs.CUSTOMER_ACCOUNT_NAME)"));
|
||||
assertTrue(relationSql.contains("counter_relation.relation_name = trim(bs.CUSTOMER_ACCOUNT_NAME)"));
|
||||
assertTrue(relationSql.contains("counter_account.owner_type in ('EMPLOYEE', 'RELATION')"));
|
||||
assertFalse(relationSql.contains("customer_cert_no"));
|
||||
}
|
||||
|
||||
private String readXml(String resource) throws Exception {
|
||||
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(resource)) {
|
||||
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
@@ -90,77 +90,13 @@ class CcdiProjectOverviewMapperSqlTest {
|
||||
void shouldExposeSuspiciousTransactionAggregationQuery() throws Exception {
|
||||
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
|
||||
String suspiciousSql = extractSelect(xml, "selectSuspiciousTransactionPage");
|
||||
String modelHitSql = extractSqlFragment(xml, "suspiciousTransactionModelHitSql");
|
||||
String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql");
|
||||
|
||||
assertTrue(modelHitSql.contains("from ccdi_bank_statement_tag_result tr"), modelHitSql);
|
||||
assertTrue(modelHitSql.contains("tr.bank_statement_id is not null"), modelHitSql);
|
||||
assertFalse(modelHitSql.contains("rule_name like '%可疑%'"), modelHitSql);
|
||||
assertFalse(modelHitSql.contains("ABNORMAL_CUSTOMER_TRANSACTION"), modelHitSql);
|
||||
assertTrue(suspiciousSql.contains("rule_name like '%可疑%'"), suspiciousSql);
|
||||
assertTrue(suspiciousSql.contains("ccdi_biz_intermediary"), suspiciousSql);
|
||||
assertTrue(suspiciousSql.contains("ccdi_enterprise_base_info"), suspiciousSql);
|
||||
assertTrue(suspiciousSql.contains("group by merged.bankStatementId"), suspiciousSql);
|
||||
assertTrue(aggregatedSql.contains("lpad(merged.matchPriority, 2, '0')"), aggregatedSql);
|
||||
assertTrue(suspiciousSql.contains("hasModelRuleHit"), suspiciousSql);
|
||||
assertTrue(suspiciousSql.contains("hasNameListHit"), suspiciousSql);
|
||||
assertTrue(suspiciousSql.contains("final_result.nameListHitType"), suspiciousSql);
|
||||
|
||||
String reportSuspiciousSql = extractSelect(xml, "selectReportSuspiciousTransactionList");
|
||||
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '中介'"), reportSuspiciousSql);
|
||||
assertTrue(reportSuspiciousSql.contains("疑似与中介往来"), reportSuspiciousSql);
|
||||
assertTrue(reportSuspiciousSql.contains("final_result.nameListHitType = '信贷客户'"), reportSuspiciousSql);
|
||||
assertTrue(reportSuspiciousSql.contains("与信贷客户之间非正常资金往来"), reportSuspiciousSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void externalPersonSourceSql_shouldIncludeStatementAndObjectHits() throws Exception {
|
||||
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
|
||||
String externalSourceSql = extractSqlFragment(xml, "externalPersonSourceSql");
|
||||
String normalizedExternalSourceSql = externalSourceSql.replace("\r\n", "\n");
|
||||
|
||||
assertTrue(externalSourceSql.contains("tr.bank_statement_id = bs.bank_statement_id"), externalSourceSql);
|
||||
assertTrue(externalSourceSql.contains("union all"), externalSourceSql);
|
||||
assertTrue(externalSourceSql.contains("tr.object_type = 'EXTERNAL_CERT_NO'"), externalSourceSql);
|
||||
assertTrue(externalSourceSql.contains("tr.object_key = subject.cert_no"), externalSourceSql);
|
||||
assertTrue(externalSourceSql.contains("'资金' as related_object"), externalSourceSql);
|
||||
assertFalse(externalSourceSql.contains("counter_staff.id_card = bs.customer_cert_no"), externalSourceSql);
|
||||
assertFalse(externalSourceSql.contains("counter_relation.relation_cert_no = bs.customer_cert_no"), externalSourceSql);
|
||||
assertFalse(externalSourceSql.contains("counter_intermediary.person_id = bs.customer_cert_no"), externalSourceSql);
|
||||
assertInOrder(
|
||||
externalSourceSql,
|
||||
"when counter_account.owner_type = 'EMPLOYEE' then '员工'",
|
||||
"when counter_account.owner_type = 'RELATION' then '员工亲属'",
|
||||
"when counter_account.owner_type = 'CREDIT_CUSTOMER' then '信贷客户'",
|
||||
"when counter_account.owner_type = 'INTERMEDIARY' then '中介库人员'",
|
||||
"when counter_staff.id_card is not null then '员工'",
|
||||
"when counter_relation.relation_cert_no is not null then '员工亲属'"
|
||||
);
|
||||
assertTrue(normalizedExternalSourceSql.contains("left join ccdi_base_staff counter_staff\n on counter_account.account_no is null"), externalSourceSql);
|
||||
assertTrue(normalizedExternalSourceSql.contains("left join ccdi_staff_fmy_relation counter_relation\n on counter_account.account_no is null"), externalSourceSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void suspiciousTransactionNameListSql_shouldKeepCreditCustomerAndIntermediaryRulesScopedByAmount() throws Exception {
|
||||
String xml = Files.readString(Path.of("src/main/resources/mapper/ccdi/project/CcdiProjectOverviewMapper.xml"));
|
||||
String nameHitSql = extractSqlFragment(xml, "suspiciousTransactionNameHitSql");
|
||||
String aggregatedSql = extractSqlFragment(xml, "suspiciousTransactionAggregatedSql");
|
||||
|
||||
assertTrue(nameHitSql.contains("account.owner_type = 'CREDIT_CUSTOMER'"), nameHitSql);
|
||||
assertTrue(nameHitSql.contains("account.owner_type = 'INTERMEDIARY'"), nameHitSql);
|
||||
assertTrue(nameHitSql.contains("account.account_no = bs.customer_account_no"), nameHitSql);
|
||||
assertTrue(nameHitSql.contains("'信贷客户' as nameListHitType"), nameHitSql);
|
||||
assertTrue(nameHitSql.contains("'中介' as nameListHitType"), nameHitSql);
|
||||
assertTrue(nameHitSql.contains("intermediary.name = bs.CUSTOMER_ACCOUNT_NAME"), nameHitSql);
|
||||
assertTrue(nameHitSql.contains("enterprise.ent_source = 'INTERMEDIARY'"), nameHitSql);
|
||||
assertTrue(
|
||||
nameHitSql.contains("GREATEST(IFNULL(bs.AMOUNT_DR, 0), IFNULL(bs.AMOUNT_CR, 0)) > 1000"),
|
||||
nameHitSql
|
||||
);
|
||||
assertFalse(nameHitSql.contains("customer_cert_no"), nameHitSql);
|
||||
assertFalse(nameHitSql.contains("social_credit_code = bs"), nameHitSql);
|
||||
assertTrue(aggregatedSql.contains("group by merged.bankStatementId"), aggregatedSql);
|
||||
assertTrue(aggregatedSql.contains("max(merged.hasModelRuleHit) as hasModelRuleHit"), aggregatedSql);
|
||||
assertTrue(aggregatedSql.contains("max(merged.hasNameListHit) as hasNameListHit"), aggregatedSql);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -223,22 +159,4 @@ class CcdiProjectOverviewMapperSqlTest {
|
||||
assertTrue(endIndex >= 0, "missing closing select tag: " + selectId);
|
||||
return xml.substring(startIndex, endIndex);
|
||||
}
|
||||
|
||||
private String extractSqlFragment(String xml, String sqlId) {
|
||||
String start = "<sql id=\"" + sqlId + "\"";
|
||||
int startIndex = xml.indexOf(start);
|
||||
assertTrue(startIndex >= 0, "missing sql fragment: " + sqlId);
|
||||
int endIndex = xml.indexOf("</sql>", startIndex);
|
||||
assertTrue(endIndex >= 0, "missing closing sql tag: " + sqlId);
|
||||
return xml.substring(startIndex, endIndex);
|
||||
}
|
||||
|
||||
private void assertInOrder(String sql, String... fragments) {
|
||||
int previousIndex = -1;
|
||||
for (String fragment : fragments) {
|
||||
int currentIndex = sql.indexOf(fragment);
|
||||
assertTrue(currentIndex > previousIndex, () -> "fragment order mismatch: " + fragment + "\n" + sql);
|
||||
previousIndex = currentIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ class CcdiProjectSpecialCheckMapperListSqlTest {
|
||||
String listSql = extractSelect(xml, "selectFamilyAssetLiabilityList");
|
||||
|
||||
assertTrue(listSql.contains("order by risk_level_sort desc, comparison_amount desc, staff_name asc"));
|
||||
assertTrue(xml.contains("from ccdi_bank_statement bs"));
|
||||
assertTrue(xml.contains("statement_staff.id_card = trim(bs.cret_no)"));
|
||||
assertTrue(xml.contains("bs.project_id = #{projectId}"));
|
||||
assertTrue(xml.contains("from ccdi_bank_statement_tag_result"));
|
||||
assertTrue(xml.contains("ccdi_base_staff"));
|
||||
assertTrue(xml.contains("ccdi_staff_fmy_relation"));
|
||||
assertTrue(xml.contains("relation_type = '配偶'"));
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.ruoyi.ccdi.project.service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
class CcdiProjectOverviewServiceStructureTest {
|
||||
@@ -35,4 +37,15 @@ class CcdiProjectOverviewServiceStructureTest {
|
||||
assertNotNull(clazz.getMethod("refreshOverviewEmployeeResults", Long.class, String.class));
|
||||
assertNotNull(clazz.getMethod("refreshProjectRiskCounts", Long.class, String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overviewServiceImplShouldNotDependOnModelParamService() throws Exception {
|
||||
Class<?> clazz = Class.forName(
|
||||
"com.ruoyi.ccdi.project.service.impl.CcdiProjectOverviewServiceImpl"
|
||||
);
|
||||
|
||||
assertFalse(Arrays.stream(clazz.getDeclaredFields()).anyMatch(field ->
|
||||
"com.ruoyi.ccdi.project.service.ICcdiModelParamService".equals(field.getType().getName())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,49 +132,6 @@ class BankTagRuleConfigResolverTest {
|
||||
assertEquals("8888", config.getThresholdValue("ANNUAL_TURNOVER"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_shouldUseLargeTransactionParamsForExternalLargeTransactionRule() {
|
||||
CcdiProject project = new CcdiProject();
|
||||
project.setProjectId(40L);
|
||||
project.setConfigType("default");
|
||||
when(projectMapper.selectById(40L)).thenReturn(project);
|
||||
when(modelParamMapper.selectByProjectAndModel(0L, "LARGE_TRANSACTION")).thenReturn(List.of(
|
||||
buildParam("LARGE_TRANSACTION", "FREQUENT_TRANSFER", "100000")
|
||||
));
|
||||
|
||||
CcdiBankTagRule ruleMeta = new CcdiBankTagRule();
|
||||
ruleMeta.setModelCode("EXTERNAL_LARGE_TRANSACTION");
|
||||
ruleMeta.setRuleCode("EXTERNAL_SINGLE_LARGE_AMOUNT");
|
||||
ruleMeta.setIndicatorCode("FREQUENT_TRANSFER");
|
||||
|
||||
BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta);
|
||||
|
||||
assertEquals("100000", config.getThresholdValue("FREQUENT_TRANSFER"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_shouldUseOriginalModelParamsForExternalObjectRules() {
|
||||
CcdiProject project = new CcdiProject();
|
||||
project.setProjectId(40L);
|
||||
project.setConfigType("default");
|
||||
when(projectMapper.selectById(40L)).thenReturn(project);
|
||||
when(modelParamMapper.selectByProjectAndModel(0L, "LARGE_TRANSACTION")).thenReturn(List.of(
|
||||
buildParam("LARGE_TRANSACTION", "CUMULATIVE_TRANSACTION_AMOUNT", "500000"),
|
||||
buildParam("LARGE_TRANSACTION", "ANNUAL_TURNOVER", "800000")
|
||||
));
|
||||
when(modelParamMapper.selectByProjectAndModel(0L, "SUSPICIOUS_GAMBLING")).thenReturn(List.of(
|
||||
buildParam("SUSPICIOUS_GAMBLING", "MULTI_PARTY_AMT_MIN", "500"),
|
||||
buildParam("SUSPICIOUS_GAMBLING", "MULTI_PARTY_AMT_MAX", "5000")
|
||||
));
|
||||
|
||||
assertRuleThresholds("EXTERNAL_LARGE_TRANSACTION", "EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT",
|
||||
Map.of("CUMULATIVE_TRANSACTION_AMOUNT", "500000"));
|
||||
assertRuleThresholds("EXTERNAL_LARGE_TRANSACTION", "EXTERNAL_ANNUAL_TURNOVER",
|
||||
Map.of("ANNUAL_TURNOVER", "800000"));
|
||||
assertRuleThresholds("EXTERNAL_SUSPICIOUS_GAMBLING", "EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER",
|
||||
Map.of("MULTI_PARTY_AMT_MIN", "500", "MULTI_PARTY_AMT_MAX", "5000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_shouldMapPhaseOneThresholdRulesToUppercaseParamCodes() {
|
||||
CcdiProject project = new CcdiProject();
|
||||
|
||||
@@ -496,95 +496,6 @@ class CcdiBankTagServiceImplTest {
|
||||
)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rebuildProject_shouldDispatchExternalPersonStatementRules() {
|
||||
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
|
||||
|
||||
CcdiBankTagRule largeRule = buildRule("EXTERNAL_LARGE_TRANSACTION", "外部人员大额交易",
|
||||
"EXTERNAL_SINGLE_LARGE_AMOUNT", "外部人员单笔大额交易", "STATEMENT");
|
||||
CcdiBankTagRule nightRule = buildRule("EXTERNAL_ABNORMAL_TRANSACTION", "外部人员异常交易",
|
||||
"EXTERNAL_NIGHT_TRANSACTION", "外部人员夜间集中交易", "STATEMENT");
|
||||
CcdiBankTagRule gamblingRule = buildRule("EXTERNAL_SUSPICIOUS_GAMBLING", "外部人员可疑赌博",
|
||||
"EXTERNAL_GAMBLING_MEMO", "外部人员疑似赌博摘要", "STATEMENT");
|
||||
CcdiBankTagRule relationRule = buildRule("EXTERNAL_SUSPICIOUS_RELATION", "外部人员可疑关系",
|
||||
"EXTERNAL_TO_STAFF_FAMILY_TRANSACTION", "外部人员与员工或员工亲属交易", "STATEMENT");
|
||||
BankTagRuleExecutionConfig largeConfig = buildConfig(40L, largeRule);
|
||||
largeConfig.setThresholdValues(Map.of("FREQUENT_TRANSFER", "100000"));
|
||||
|
||||
BankTagStatementHitVO hit = new BankTagStatementHitVO();
|
||||
hit.setBankStatementId(100L);
|
||||
hit.setGroupId(40);
|
||||
hit.setLogId(40001);
|
||||
hit.setReasonDetail("外部人员命中");
|
||||
|
||||
when(ruleMapper.selectEnabledRules(null)).thenReturn(List.of(largeRule, nightRule, gamblingRule, relationRule));
|
||||
when(configResolver.resolve(40L, largeRule)).thenReturn(largeConfig);
|
||||
when(configResolver.resolve(40L, nightRule)).thenReturn(buildConfig(40L, nightRule));
|
||||
when(configResolver.resolve(40L, gamblingRule)).thenReturn(buildConfig(40L, gamblingRule));
|
||||
when(configResolver.resolve(40L, relationRule)).thenReturn(buildConfig(40L, relationRule));
|
||||
when(analysisMapper.selectExternalSingleLargeAmountStatements(40L, new BigDecimal("100000"))).thenReturn(List.of(hit));
|
||||
when(analysisMapper.selectExternalNightTransactionStatements(40L)).thenReturn(List.of());
|
||||
when(analysisMapper.selectExternalGamblingMemoStatements(40L)).thenReturn(List.of());
|
||||
when(analysisMapper.selectExternalToStaffOrFamilyTransactionStatements(40L)).thenReturn(List.of());
|
||||
|
||||
service.rebuildProject(40L, null, "admin", TriggerType.MANUAL);
|
||||
|
||||
verify(analysisMapper).selectExternalSingleLargeAmountStatements(40L, new BigDecimal("100000"));
|
||||
verify(analysisMapper).selectExternalNightTransactionStatements(40L);
|
||||
verify(analysisMapper).selectExternalGamblingMemoStatements(40L);
|
||||
verify(analysisMapper).selectExternalToStaffOrFamilyTransactionStatements(40L);
|
||||
verify(resultMapper).insertBatch(argThat(results -> results.stream().anyMatch(item ->
|
||||
"EXTERNAL_LARGE_TRANSACTION".equals(item.getModelCode())
|
||||
&& "EXTERNAL_SINGLE_LARGE_AMOUNT".equals(item.getRuleCode())
|
||||
&& "STATEMENT".equals(item.getResultType())
|
||||
&& Long.valueOf(100L).equals(item.getBankStatementId())
|
||||
)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rebuildProject_shouldDispatchExternalPersonObjectRules() {
|
||||
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
|
||||
|
||||
CcdiBankTagRule cumulativeRule = buildRule("EXTERNAL_LARGE_TRANSACTION", "外部人员大额交易",
|
||||
"EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT", "外部人员累计交易超限", "OBJECT");
|
||||
CcdiBankTagRule annualRule = buildRule("EXTERNAL_LARGE_TRANSACTION", "外部人员大额交易",
|
||||
"EXTERNAL_ANNUAL_TURNOVER", "外部人员年流水交易额超限", "OBJECT");
|
||||
CcdiBankTagRule gamblingRule = buildRule("EXTERNAL_SUSPICIOUS_GAMBLING", "外部人员可疑赌博",
|
||||
"EXTERNAL_MULTI_PARTY_GAMBLING_TRANSFER", "外部人员同日多对手方疑似赌博交易", "OBJECT");
|
||||
BankTagRuleExecutionConfig cumulativeConfig = buildConfig(40L, cumulativeRule);
|
||||
cumulativeConfig.setThresholdValues(Map.of("CUMULATIVE_TRANSACTION_AMOUNT", "500000"));
|
||||
BankTagRuleExecutionConfig annualConfig = buildConfig(40L, annualRule);
|
||||
annualConfig.setThresholdValues(Map.of("ANNUAL_TURNOVER", "800000"));
|
||||
BankTagRuleExecutionConfig gamblingConfig = buildConfig(40L, gamblingRule);
|
||||
gamblingConfig.setThresholdValues(Map.of("MULTI_PARTY_AMT_MIN", "500", "MULTI_PARTY_AMT_MAX", "5000"));
|
||||
|
||||
BankTagObjectHitVO hit = new BankTagObjectHitVO();
|
||||
hit.setObjectType("EXTERNAL_CERT_NO");
|
||||
hit.setObjectKey("330100198801010033");
|
||||
hit.setReasonDetail("外部人员累计交易超限");
|
||||
|
||||
when(ruleMapper.selectEnabledRules(null)).thenReturn(List.of(cumulativeRule, annualRule, gamblingRule));
|
||||
when(configResolver.resolve(40L, cumulativeRule)).thenReturn(cumulativeConfig);
|
||||
when(configResolver.resolve(40L, annualRule)).thenReturn(annualConfig);
|
||||
when(configResolver.resolve(40L, gamblingRule)).thenReturn(gamblingConfig);
|
||||
when(analysisMapper.selectExternalCumulativeTransactionAmountObjects(40L, new BigDecimal("500000"))).thenReturn(List.of(hit));
|
||||
when(analysisMapper.selectExternalAnnualTurnoverObjects(40L, new BigDecimal("800000"))).thenReturn(List.of());
|
||||
when(analysisMapper.selectExternalMultiPartyGamblingTransferObjects(40L, new BigDecimal("500"), new BigDecimal("5000"))).thenReturn(List.of());
|
||||
|
||||
service.rebuildProject(40L, null, "admin", TriggerType.MANUAL);
|
||||
|
||||
verify(analysisMapper).selectExternalCumulativeTransactionAmountObjects(40L, new BigDecimal("500000"));
|
||||
verify(analysisMapper).selectExternalAnnualTurnoverObjects(40L, new BigDecimal("800000"));
|
||||
verify(analysisMapper).selectExternalMultiPartyGamblingTransferObjects(40L, new BigDecimal("500"), new BigDecimal("5000"));
|
||||
verify(resultMapper).insertBatch(argThat(results -> results.stream().anyMatch(item ->
|
||||
"EXTERNAL_LARGE_TRANSACTION".equals(item.getModelCode())
|
||||
&& "EXTERNAL_CUMULATIVE_TRANSACTION_AMOUNT".equals(item.getRuleCode())
|
||||
&& "OBJECT".equals(item.getResultType())
|
||||
&& "EXTERNAL_CERT_NO".equals(item.getObjectType())
|
||||
&& "330100198801010033".equals(item.getObjectKey())
|
||||
)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildSafeTaskErrorMessage_shouldKeepLongMessageForLongTextColumn() throws Exception {
|
||||
Method method = CcdiBankTagServiceImpl.class.getDeclaredMethod(
|
||||
|
||||
@@ -265,7 +265,8 @@ class CcdiFileUploadServiceImplTest {
|
||||
AtomicInteger sequence = new AtomicInteger();
|
||||
captureRecordStatus(events, sequence);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
@@ -291,7 +292,8 @@ class CcdiFileUploadServiceImplTest {
|
||||
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
|
||||
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
@@ -317,7 +319,8 @@ class CcdiFileUploadServiceImplTest {
|
||||
|
||||
@Test
|
||||
void processFileAsync_shouldCleanupInsertedStatementsWhenFetchFails() throws IOException {
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
@@ -364,6 +367,77 @@ class CcdiFileUploadServiceImplTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException {
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildEmptyBankStatementResponse());
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
record.setFileName("原始流水.xlsx");
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
|
||||
verify(lsfxClient).uploadFile(eq(LSFX_PROJECT_ID), argThat(file ->
|
||||
file.getName().startsWith("upload-") && file.getName().endsWith(".xlsx")
|
||||
), eq("原始流水.xlsx"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException {
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any()))
|
||||
.thenReturn(buildParsedSuccessStatusResponse("平台返回文件名.xlsx"));
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildEmptyBankStatementResponse());
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
record.setFileName("原始流水.xlsx");
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
|
||||
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
|
||||
org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
|
||||
"parsed_success".equals(item.getFileStatus())
|
||||
&& "原始流水.xlsx".equals(item.getFileName()))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processFileAsync_shouldKeepOriginalFileNameWhenParseStatusFails() throws IOException {
|
||||
GetFileUploadStatusResponse statusResponse = buildParsedSuccessStatusResponse("平台失败文件名.xlsx");
|
||||
GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0);
|
||||
logItem.setStatus(-1);
|
||||
logItem.setUploadStatusDesc("parse.failed");
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse);
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
record.setFileName("原始流水.xlsx");
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
|
||||
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
|
||||
org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
|
||||
"parsed_failed".equals(item.getFileStatus())
|
||||
&& "原始流水.xlsx".equals(item.getFileName()))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted() {
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
@@ -468,7 +542,8 @@ class CcdiFileUploadServiceImplTest {
|
||||
AtomicInteger sequence = new AtomicInteger();
|
||||
captureRecordStatus(events, sequence);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
@@ -491,7 +566,8 @@ class CcdiFileUploadServiceImplTest {
|
||||
List<CcdiFileUploadRecord> updates = new ArrayList<>();
|
||||
captureUpdatedRecords(updates);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
@@ -512,7 +588,7 @@ class CcdiFileUploadServiceImplTest {
|
||||
List<CcdiFileUploadRecord> updates = new ArrayList<>();
|
||||
captureUpdatedRecords(updates);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any()))
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenThrow(new RuntimeException("upload failed:" + "x".repeat(3000)));
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
@@ -526,7 +602,8 @@ class CcdiFileUploadServiceImplTest {
|
||||
|
||||
@Test
|
||||
void fetchAndSaveBankStatements_shouldTrimLeAccountNoBeforeInsert() throws IOException {
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
@@ -597,7 +674,8 @@ class CcdiFileUploadServiceImplTest {
|
||||
AtomicInteger sequence = new AtomicInteger();
|
||||
captureRecordStatus(events, sequence);
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.ruoyi.ccdi.project.service.impl;
|
||||
import com.ruoyi.ccdi.project.domain.CcdiProject;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectAbnormalAccountExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectEmployeeCreditNegativeExcel;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectExternalPersonWarningExcel;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportModelSummaryVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportParamVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
|
||||
@@ -11,10 +10,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportUploadSubjectVO
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectExternalRiskSummaryVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
|
||||
import java.lang.reflect.Method;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
@@ -22,7 +19,6 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class CcdiProjectOverviewReportPdfExporterTest {
|
||||
@@ -40,103 +36,6 @@ class CcdiProjectOverviewReportPdfExporterTest {
|
||||
assertTrue(response.getContentAsByteArray().length > 1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseOverallRiskSummaryInReportMetrics() throws Exception {
|
||||
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter();
|
||||
CcdiProjectOverviewReportVO report = buildReport();
|
||||
report.setExternalRiskSummary(buildExternalSummary());
|
||||
|
||||
List<CcdiProjectOverviewStatVO> stats = invokeOverallRiskMetrics(exporter, report);
|
||||
|
||||
assertEquals(12, stats.get(0).getValue());
|
||||
assertEquals(3, stats.get(1).getValue());
|
||||
assertEquals(4, stats.get(2).getValue());
|
||||
assertEquals(1, stats.get(3).getValue());
|
||||
assertEquals("无风险", stats.get(4).getLabel());
|
||||
assertEquals(4, stats.get(4).getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHideExternalSectionWhenReportHasOnlyEmployees() throws Exception {
|
||||
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter();
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
CcdiProjectOverviewReportVO report = buildReport();
|
||||
|
||||
exporter.export(response, report);
|
||||
|
||||
assertEquals("application/pdf", response.getContentType());
|
||||
assertFalse(invokeHasExternalRisk(exporter, report));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldShowExternalSectionWhenExternalRiskExists() throws Exception {
|
||||
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter();
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
CcdiProjectOverviewReportVO report = buildReport();
|
||||
report.setExternalRiskSummary(buildExternalSummary());
|
||||
report.setExternalModelSummaries(List.of(buildExternalModelSummary()));
|
||||
report.setExternalPersonWarnings(List.of(buildExternalPersonWarning()));
|
||||
|
||||
exporter.export(response, report);
|
||||
List<CcdiProjectOverviewStatVO> externalMetrics = invokeExternalMetrics(exporter, report);
|
||||
|
||||
assertEquals("application/pdf", response.getContentType());
|
||||
assertTrue(invokeHasExternalRisk(exporter, report));
|
||||
assertEquals("外部人员", externalMetrics.get(0).getLabel());
|
||||
assertEquals(2, externalMetrics.get(0).getValue());
|
||||
assertEquals("高风险", externalMetrics.get(1).getLabel());
|
||||
assertEquals(1, externalMetrics.get(1).getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tableGap_shouldLeaveEnoughSpaceForNextSectionTitle() throws Exception {
|
||||
Class<?> writerClass = Class.forName(
|
||||
"com.ruoyi.ccdi.project.service.impl.CcdiProjectOverviewReportPdfExporter$PdfPageWriter"
|
||||
);
|
||||
float tableAfterGap = readPrivateFloat(writerClass, "TABLE_AFTER_GAP");
|
||||
float sectionFontSize = readPrivateFloat(writerClass, "SECTION_FONT_SIZE");
|
||||
|
||||
assertTrue(tableAfterGap > sectionFontSize);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<CcdiProjectOverviewStatVO> invokeOverallRiskMetrics(
|
||||
CcdiProjectOverviewReportPdfExporter exporter,
|
||||
CcdiProjectOverviewReportVO report
|
||||
) throws Exception {
|
||||
Method method = CcdiProjectOverviewReportPdfExporter.class.getDeclaredMethod(
|
||||
"buildOverallRiskMetrics",
|
||||
CcdiProjectOverviewReportVO.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
return (List<CcdiProjectOverviewStatVO>) method.invoke(exporter, report);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<CcdiProjectOverviewStatVO> invokeExternalMetrics(
|
||||
CcdiProjectOverviewReportPdfExporter exporter,
|
||||
CcdiProjectOverviewReportVO report
|
||||
) throws Exception {
|
||||
Method method = CcdiProjectOverviewReportPdfExporter.class.getDeclaredMethod(
|
||||
"buildExternalMetrics",
|
||||
CcdiProjectOverviewReportVO.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
return (List<CcdiProjectOverviewStatVO>) method.invoke(exporter, report);
|
||||
}
|
||||
|
||||
private boolean invokeHasExternalRisk(
|
||||
CcdiProjectOverviewReportPdfExporter exporter,
|
||||
CcdiProjectOverviewReportVO report
|
||||
) throws Exception {
|
||||
Method method = CcdiProjectOverviewReportPdfExporter.class.getDeclaredMethod(
|
||||
"hasExternalRisk",
|
||||
CcdiProjectOverviewReportVO.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
return (Boolean) method.invoke(exporter, report);
|
||||
}
|
||||
|
||||
private CcdiProjectOverviewReportVO buildReport() {
|
||||
CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO();
|
||||
CcdiProject project = new CcdiProject();
|
||||
@@ -154,42 +53,25 @@ class CcdiProjectOverviewReportPdfExporterTest {
|
||||
return report;
|
||||
}
|
||||
|
||||
private float readPrivateFloat(Class<?> clazz, String fieldName) throws Exception {
|
||||
java.lang.reflect.Field field = clazz.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
return field.getFloat(null);
|
||||
}
|
||||
|
||||
private CcdiProjectOverviewDashboardVO buildDashboard() {
|
||||
CcdiProjectOverviewDashboardVO dashboard = new CcdiProjectOverviewDashboardVO();
|
||||
dashboard.setStats(List.of(
|
||||
buildStat("people", "总人数", 10),
|
||||
buildStat("riskPeople", "高风险", 2),
|
||||
buildStat("medium", "中风险", 3),
|
||||
buildStat("low", "低风险", 1),
|
||||
buildStat("count", "无风险人员", 4)
|
||||
buildStat("总人数", 10),
|
||||
buildStat("高风险", 2),
|
||||
buildStat("中风险", 3),
|
||||
buildStat("低风险", 1),
|
||||
buildStat("无风险", 4)
|
||||
));
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
private CcdiProjectOverviewStatVO buildStat(String key, String label, Integer value) {
|
||||
private CcdiProjectOverviewStatVO buildStat(String label, Integer value) {
|
||||
CcdiProjectOverviewStatVO stat = new CcdiProjectOverviewStatVO();
|
||||
stat.setKey(key);
|
||||
stat.setLabel(label);
|
||||
stat.setValue(value);
|
||||
return stat;
|
||||
}
|
||||
|
||||
private CcdiProjectExternalRiskSummaryVO buildExternalSummary() {
|
||||
CcdiProjectExternalRiskSummaryVO summary = new CcdiProjectExternalRiskSummaryVO();
|
||||
summary.setTotal(2);
|
||||
summary.setHigh(1);
|
||||
summary.setMedium(1);
|
||||
summary.setLow(0);
|
||||
summary.setNoRisk(0);
|
||||
return summary;
|
||||
}
|
||||
|
||||
private CcdiProjectOverviewReportUploadSubjectVO buildUploadSubject() {
|
||||
CcdiProjectOverviewReportUploadSubjectVO row = new CcdiProjectOverviewReportUploadSubjectVO();
|
||||
row.setSubjectName("测试主体");
|
||||
@@ -218,15 +100,6 @@ class CcdiProjectOverviewReportPdfExporterTest {
|
||||
return row;
|
||||
}
|
||||
|
||||
private CcdiProjectOverviewReportModelSummaryVO buildExternalModelSummary() {
|
||||
CcdiProjectOverviewReportModelSummaryVO row = new CcdiProjectOverviewReportModelSummaryVO();
|
||||
row.setModelName("外部人员异常交易");
|
||||
row.setWarningCount(2);
|
||||
row.setPeopleCount(2);
|
||||
row.setPeopleNames("-");
|
||||
return row;
|
||||
}
|
||||
|
||||
private CcdiProjectRiskModelPeopleItemVO buildRiskPeople() {
|
||||
CcdiProjectRiskModelPeopleItemVO row = new CcdiProjectRiskModelPeopleItemVO();
|
||||
row.setStaffName("张三");
|
||||
@@ -240,19 +113,6 @@ class CcdiProjectOverviewReportPdfExporterTest {
|
||||
return row;
|
||||
}
|
||||
|
||||
private CcdiProjectExternalPersonWarningExcel buildExternalPersonWarning() {
|
||||
CcdiProjectExternalPersonWarningExcel row = new CcdiProjectExternalPersonWarningExcel();
|
||||
row.setName("外部人员甲");
|
||||
row.setIdNo("330000000000000003");
|
||||
row.setSubjectType("外部人员");
|
||||
row.setRiskLevel("高风险");
|
||||
row.setModelCount(1);
|
||||
row.setRiskPoint("疑似异常往来");
|
||||
row.setRelatedObject("张三");
|
||||
row.setLatestTradeTime("2026-06-25 10:00:00");
|
||||
return row;
|
||||
}
|
||||
|
||||
private CcdiProjectOverviewReportSuspiciousTransactionVO buildSuspiciousTransaction() {
|
||||
CcdiProjectOverviewReportSuspiciousTransactionVO row = new CcdiProjectOverviewReportSuspiciousTransactionVO();
|
||||
row.setTrxDate("2026-03-20 10:00:00");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ruoyi.ccdi.project.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.CcdiModelParam;
|
||||
import com.ruoyi.ccdi.project.domain.CcdiProject;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
|
||||
@@ -16,7 +17,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO;
|
||||
@@ -28,6 +29,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
|
||||
@@ -39,6 +41,7 @@ import java.util.List;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
@@ -66,6 +69,9 @@ class CcdiProjectOverviewServiceImplTest {
|
||||
@Mock
|
||||
private CcdiProjectMapper projectMapper;
|
||||
|
||||
@Mock
|
||||
private CcdiModelParamMapper modelParamMapper;
|
||||
|
||||
@Mock
|
||||
private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper;
|
||||
|
||||
@@ -78,6 +84,9 @@ class CcdiProjectOverviewServiceImplTest {
|
||||
@Mock
|
||||
private CcdiProjectRiskDetailWorkbookExporter workbookExporter;
|
||||
|
||||
@Mock
|
||||
private CcdiProjectOverviewReportPdfExporter reportPdfExporter;
|
||||
|
||||
@Test
|
||||
void shouldBuildDashboardWithNoRiskCount() {
|
||||
CcdiProject project = new CcdiProject();
|
||||
@@ -251,20 +260,17 @@ class CcdiProjectOverviewServiceImplTest {
|
||||
project.setProjectId(40L);
|
||||
when(projectMapper.selectById(40L)).thenReturn(project);
|
||||
|
||||
CcdiProjectOverviewReportSuspiciousTransactionVO suspiciousItem =
|
||||
new CcdiProjectOverviewReportSuspiciousTransactionVO();
|
||||
CcdiProjectSuspiciousTransactionItemVO suspiciousItem = new CcdiProjectSuspiciousTransactionItemVO();
|
||||
suspiciousItem.setTrxDate("2026-03-20 10:00:00");
|
||||
suspiciousItem.setLeAccountNo("6222000000000000");
|
||||
suspiciousItem.setLeAccountName("张三");
|
||||
suspiciousItem.setCustomerAccountName("测试对手方");
|
||||
suspiciousItem.setCustomerAccountNo("6222000000000001");
|
||||
suspiciousItem.setSuspiciousPersonName("张三");
|
||||
suspiciousItem.setRelatedPersonName("张三");
|
||||
suspiciousItem.setRelatedStaffName("张三");
|
||||
suspiciousItem.setRelatedStaffCode("1001");
|
||||
suspiciousItem.setRelationType("本人");
|
||||
suspiciousItem.setUserMemo("转账");
|
||||
suspiciousItem.setCashType("转账");
|
||||
suspiciousItem.setHitTags("异常标签");
|
||||
suspiciousItem.setDisplayAmount(new BigDecimal("100.00"));
|
||||
when(overviewMapper.selectReportSuspiciousTransactionList(any())).thenReturn(List.of(suspiciousItem));
|
||||
when(overviewMapper.selectSuspiciousTransactionList(any())).thenReturn(List.of(suspiciousItem));
|
||||
|
||||
CcdiProjectEmployeeCreditNegativeItemVO creditItem = new CcdiProjectEmployeeCreditNegativeItemVO();
|
||||
creditItem.setPersonName("李四");
|
||||
@@ -286,17 +292,14 @@ class CcdiProjectOverviewServiceImplTest {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
service.exportRiskDetails(response, 40L);
|
||||
|
||||
verify(overviewMapper).selectReportSuspiciousTransactionList(argThat(query ->
|
||||
verify(overviewMapper).selectSuspiciousTransactionList(argThat(query ->
|
||||
query.getProjectId().equals(40L) && "ALL".equals(query.getSuspiciousType())
|
||||
));
|
||||
verify(workbookExporter).export(
|
||||
eq(response),
|
||||
eq(40L),
|
||||
argThat((List<CcdiProjectSuspiciousTransactionExcel> rows) ->
|
||||
rows.size() == 1
|
||||
&& "张三".equals(rows.getFirst().getLeAccountName())
|
||||
&& "测试对手方".equals(rows.getFirst().getCustomerAccountName())
|
||||
&& "异常标签".equals(rows.getFirst().getHitTags())
|
||||
rows.size() == 1 && "张三".equals(rows.getFirst().getSuspiciousPersonName())
|
||||
),
|
||||
argThat((List<CcdiProjectEmployeeCreditNegativeExcel> rows) ->
|
||||
rows.size() == 1 && "李四".equals(rows.getFirst().getPersonName())
|
||||
@@ -307,6 +310,37 @@ class CcdiProjectOverviewServiceImplTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExportOverviewReportParamsFromDefaultProjectConfig() throws Exception {
|
||||
CcdiProject project = new CcdiProject();
|
||||
project.setProjectId(40L);
|
||||
project.setProjectName("测试项目");
|
||||
project.setConfigType("default");
|
||||
when(projectMapper.selectById(40L)).thenReturn(project);
|
||||
|
||||
CcdiProject dashboardProject = new CcdiProject();
|
||||
dashboardProject.setProjectId(40L);
|
||||
dashboardProject.setTargetCount(10);
|
||||
dashboardProject.setHighRiskCount(1);
|
||||
dashboardProject.setMediumRiskCount(2);
|
||||
dashboardProject.setLowRiskCount(3);
|
||||
when(overviewMapper.selectDashboardBaseByProjectId(40L)).thenReturn(dashboardProject);
|
||||
when(modelParamMapper.selectByProjectId(0L)).thenReturn(List.of(
|
||||
buildModelParam("LARGE_TRANSACTION", "大额交易模型", "单笔金额", "1000", "元", "单笔金额阈值")
|
||||
));
|
||||
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
service.exportOverviewReport(response, 40L);
|
||||
|
||||
ArgumentCaptor<CcdiProjectOverviewReportVO> captor =
|
||||
ArgumentCaptor.forClass(CcdiProjectOverviewReportVO.class);
|
||||
verify(modelParamMapper).selectByProjectId(0L);
|
||||
verify(reportPdfExporter).export(eq(response), captor.capture());
|
||||
assertEquals(1, captor.getValue().getParams().size());
|
||||
assertEquals("大额交易模型", captor.getValue().getParams().getFirst().getModelName());
|
||||
assertEquals("单笔金额", captor.getValue().getParams().getFirst().getParamName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnPersonAnalysisDetailWithBasicInfoAndGroupedAbnormalDetail() {
|
||||
CcdiProject project = new CcdiProject();
|
||||
@@ -546,6 +580,24 @@ class CcdiProjectOverviewServiceImplTest {
|
||||
return result;
|
||||
}
|
||||
|
||||
private CcdiModelParam buildModelParam(
|
||||
String modelCode,
|
||||
String modelName,
|
||||
String paramName,
|
||||
String paramValue,
|
||||
String paramUnit,
|
||||
String paramDesc
|
||||
) {
|
||||
CcdiModelParam param = new CcdiModelParam();
|
||||
param.setModelCode(modelCode);
|
||||
param.setModelName(modelName);
|
||||
param.setParamName(paramName);
|
||||
param.setParamValue(paramValue);
|
||||
param.setParamUnit(paramUnit);
|
||||
param.setParamDesc(paramDesc);
|
||||
return param;
|
||||
}
|
||||
|
||||
private CcdiProjectRiskHitTagVO buildHitTag(
|
||||
String modelCode,
|
||||
String modelName,
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.CcdiProject;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSuspiciousTransactionQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiProjectSuspiciousTransactionExcel;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportSuspiciousTransactionVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
|
||||
@@ -99,20 +98,17 @@ class CcdiProjectOverviewServiceSuspiciousTransactionTest {
|
||||
project.setProjectId(40L);
|
||||
when(projectMapper.selectById(40L)).thenReturn(project);
|
||||
|
||||
CcdiProjectOverviewReportSuspiciousTransactionVO item =
|
||||
new CcdiProjectOverviewReportSuspiciousTransactionVO();
|
||||
CcdiProjectSuspiciousTransactionItemVO item = new CcdiProjectSuspiciousTransactionItemVO();
|
||||
item.setTrxDate("2024-01-15 10:00:00");
|
||||
item.setLeAccountNo("6222000000000000");
|
||||
item.setLeAccountName("孙七");
|
||||
item.setCustomerAccountName("测试对手方");
|
||||
item.setCustomerAccountNo("6222000000000001");
|
||||
item.setSuspiciousPersonName("孙七");
|
||||
item.setRelatedPersonName("孙七");
|
||||
item.setRelatedStaffName("孙七");
|
||||
item.setRelatedStaffCode("809901");
|
||||
item.setRelationType("本人");
|
||||
item.setUserMemo("");
|
||||
item.setCashType("转账");
|
||||
item.setHitTags("大额交易");
|
||||
item.setDisplayAmount(new BigDecimal("500000.00"));
|
||||
when(overviewMapper.selectReportSuspiciousTransactionList(any(CcdiProjectSuspiciousTransactionQueryDTO.class)))
|
||||
when(overviewMapper.selectSuspiciousTransactionList(any(CcdiProjectSuspiciousTransactionQueryDTO.class)))
|
||||
.thenReturn(List.of(item));
|
||||
|
||||
CcdiProjectSuspiciousTransactionQueryDTO queryDTO = new CcdiProjectSuspiciousTransactionQueryDTO();
|
||||
@@ -121,14 +117,8 @@ class CcdiProjectOverviewServiceSuspiciousTransactionTest {
|
||||
List<CcdiProjectSuspiciousTransactionExcel> rows = service.exportSuspiciousTransactions(queryDTO);
|
||||
|
||||
assertEquals(1, rows.size());
|
||||
assertEquals("6222000000000000", rows.getFirst().getLeAccountNo());
|
||||
assertEquals("孙七", rows.getFirst().getLeAccountName());
|
||||
assertEquals("测试对手方", rows.getFirst().getCustomerAccountName());
|
||||
assertEquals("6222000000000001", rows.getFirst().getCustomerAccountNo());
|
||||
assertEquals("孙七(809901)", rows.getFirst().getRelatedStaffDisplay());
|
||||
assertEquals("", rows.getFirst().getUserMemo());
|
||||
assertEquals("转账", rows.getFirst().getCashType());
|
||||
assertEquals("大额交易", rows.getFirst().getHitTags());
|
||||
assertEquals("/转账", rows.getFirst().getSummaryAndCashType());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -23,14 +23,11 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
|
||||
|
||||
CcdiProjectSuspiciousTransactionExcel suspiciousRow = new CcdiProjectSuspiciousTransactionExcel();
|
||||
suspiciousRow.setTrxDate("2026-03-20 10:00:00");
|
||||
suspiciousRow.setLeAccountNo("6222000000000000");
|
||||
suspiciousRow.setLeAccountName("张三");
|
||||
suspiciousRow.setCustomerAccountName("测试对手方");
|
||||
suspiciousRow.setCustomerAccountNo("6222000000000001");
|
||||
suspiciousRow.setSuspiciousPersonName("张三");
|
||||
suspiciousRow.setRelatedPersonName("张三");
|
||||
suspiciousRow.setRelatedStaffDisplay("张三(1001)");
|
||||
suspiciousRow.setUserMemo("转账");
|
||||
suspiciousRow.setCashType("转账");
|
||||
suspiciousRow.setHitTags("异常标签");
|
||||
suspiciousRow.setRelationType("本人");
|
||||
suspiciousRow.setSummaryAndCashType("转账/转账");
|
||||
suspiciousRow.setDisplayAmount(new BigDecimal("100.00"));
|
||||
|
||||
CcdiProjectEmployeeCreditNegativeExcel creditRow = new CcdiProjectEmployeeCreditNegativeExcel();
|
||||
@@ -59,19 +56,6 @@ class CcdiProjectRiskDetailWorkbookExporterTest {
|
||||
assertEquals("涉疑交易明细", workbook.getSheetAt(0).getSheetName());
|
||||
assertEquals("员工负面征信信息", workbook.getSheetAt(1).getSheetName());
|
||||
assertEquals("异常账户人员信息", workbook.getSheetAt(2).getSheetName());
|
||||
assertEquals("交易时间", workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue());
|
||||
assertEquals("本方账户", workbook.getSheetAt(0).getRow(0).getCell(1).getStringCellValue());
|
||||
assertEquals("本方主体", workbook.getSheetAt(0).getRow(0).getCell(2).getStringCellValue());
|
||||
assertEquals("对方名称", workbook.getSheetAt(0).getRow(0).getCell(3).getStringCellValue());
|
||||
assertEquals("对方账户", workbook.getSheetAt(0).getRow(0).getCell(4).getStringCellValue());
|
||||
assertEquals("关联员工", workbook.getSheetAt(0).getRow(0).getCell(5).getStringCellValue());
|
||||
assertEquals("摘要", workbook.getSheetAt(0).getRow(0).getCell(6).getStringCellValue());
|
||||
assertEquals("交易类型", workbook.getSheetAt(0).getRow(0).getCell(7).getStringCellValue());
|
||||
assertEquals("异常标签", workbook.getSheetAt(0).getRow(0).getCell(8).getStringCellValue());
|
||||
assertEquals("交易金额", workbook.getSheetAt(0).getRow(0).getCell(9).getStringCellValue());
|
||||
assertEquals("测试对手方", workbook.getSheetAt(0).getRow(1).getCell(3).getStringCellValue());
|
||||
assertEquals("6222000000000001", workbook.getSheetAt(0).getRow(1).getCell(4).getStringCellValue());
|
||||
assertEquals("异常标签", workbook.getSheetAt(0).getRow(1).getCell(8).getStringCellValue());
|
||||
assertEquals("账号", workbook.getSheetAt(2).getRow(0).getCell(0).getStringCellValue());
|
||||
assertEquals("开户人", workbook.getSheetAt(2).getRow(0).getCell(1).getStringCellValue());
|
||||
assertEquals("银行", workbook.getSheetAt(2).getRow(0).getCell(2).getStringCellValue());
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
# 员工资产导入与实体库自动补入修复设计
|
||||
|
||||
## 1. 背景
|
||||
|
||||
本设计用于修复以下后端问题:
|
||||
|
||||
1. 【员工信息维护】双 Sheet 导入时,`员工资产信息` Sheet 只能通过数据库已有员工反查归属,不能识别同一个导入文件中刚成功导入的员工。
|
||||
2. 【员工亲属关系维护】双 Sheet 导入时,`亲属资产信息` Sheet 只能通过数据库已有亲属关系反查归属,不能识别同一个导入文件中刚成功导入的亲属关系。
|
||||
3. 关联业务自动补入实体库能力在当前主工作区未完整接回,员工亲属、信贷客户、中介、供应商四类业务成功关联后,缺失企业需要统一补入 `ccdi_enterprise_base_info`。
|
||||
|
||||
本次设计只涉及后端。不调整前端页面结构、上传入口、模板样式和前端轮询字段。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 双 Sheet 导入时,主 Sheet 与资产 Sheet 在后端按业务依赖顺序执行。
|
||||
- 资产 Sheet 可关联数据库已有主数据,也可关联同一文件中本轮主 Sheet 成功导入的数据。
|
||||
- 主 Sheet 失败行不能作为资产归属依据。
|
||||
- 继续返回当前前端已支持的两个任务 ID:
|
||||
- 员工信息维护:`staffTaskId`、`assetTaskId`
|
||||
- 员工亲属关系维护:`relationTaskId`、`assetTaskId`
|
||||
- 恢复统一 `EnterpriseAutoFillService`,并接入员工亲属、信贷客户、中介、供应商四类业务。
|
||||
- 实体库自动补入只插入缺失企业,不更新已存在实体。
|
||||
|
||||
## 3. 不在本次范围
|
||||
|
||||
- 不改前端上传入口、轮询逻辑、失败记录展示和模板下载样式。
|
||||
- 不合并双 Sheet 导入任务 ID。
|
||||
- 不改变员工、亲属关系、资产、实体关联现有字段校验规则。
|
||||
- 不改变实体库手工新增、编辑、导入的既有业务规则。
|
||||
- 不增加兜底来源、降级来源或兼容性分支。
|
||||
|
||||
## 4. 总体架构
|
||||
|
||||
本次后端设计分为两条链路。
|
||||
|
||||
### 4.1 双 Sheet 导入编排
|
||||
|
||||
`/ccdi/baseStaff/importData` 和 `/ccdi/staffFmyRelation/importData` 保持原接口、原模板、原返回字段。
|
||||
|
||||
Controller 继续负责读取两个 Sheet,但不再分别提交两个彼此独立的异步任务。服务层新增提交编排方法:
|
||||
|
||||
- 员工信息维护提交方法接收 `List<CcdiBaseStaffExcel>` 和 `List<CcdiBaseStaffAssetInfoExcel>`。
|
||||
- 员工亲属关系维护提交方法接收 `List<CcdiStaffFmyRelationExcel>` 和 `List<CcdiAssetInfoExcel>`。
|
||||
|
||||
提交方法按实际存在的 Sheet 初始化对应任务状态,并启动一个后台编排任务。编排任务内部按顺序执行:
|
||||
|
||||
1. 主 Sheet 校验与插入。
|
||||
2. 收集本轮主 Sheet 成功上下文。
|
||||
3. 资产 Sheet 校验与插入。
|
||||
|
||||
### 4.2 实体库自动补入
|
||||
|
||||
新增或恢复统一内部服务:
|
||||
|
||||
`ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java`
|
||||
|
||||
该服务是关联业务补入实体库的唯一后端入口。四类业务在业务校验通过、业务记录落库前调用:
|
||||
|
||||
- 员工亲属实体关联:`EMP_RELATION`
|
||||
- 信贷客户实体关联:`CREDIT_CUSTOMER`
|
||||
- 中介实体关联:`INTERMEDIARY`
|
||||
- 招投标供应商:`SUPPLIER`
|
||||
|
||||
实体库缺失时最小插入;已存在时不更新。
|
||||
|
||||
供应商来源需要同步新增后端枚举 `EnterpriseSource.SUPPLIER("SUPPLIER", "供应商")`,并补充 `/ccdi/enum/enterpriseSource` 枚举接口测试。前端继续通过现有枚举接口展示来源,不需要调整页面结构。
|
||||
|
||||
## 5. 员工信息维护双 Sheet 导入设计
|
||||
|
||||
### 5.1 当前问题
|
||||
|
||||
当前 `/ccdi/baseStaff/importData` 分别读取 `员工信息` 和 `员工资产信息`,然后分别调用:
|
||||
|
||||
- `baseStaffService.importBaseStaff(staffList)`
|
||||
- `baseStaffAssetImportService.importAssetInfo(assetList)`
|
||||
|
||||
两个任务互相独立。员工资产导入只通过数据库 `ccdi_base_staff.id_card` 查找归属,无法稳定看到同一文件中刚导入成功的员工。
|
||||
|
||||
### 5.2 设计方案
|
||||
|
||||
新增员工信息维护双 Sheet 后端编排能力。
|
||||
|
||||
提交阶段:
|
||||
|
||||
1. Controller 读取两个 Sheet。
|
||||
2. 若两个 Sheet 都无数据,仍返回“至少需要一条数据”。
|
||||
3. 若存在员工 Sheet,生成并初始化 `staffTaskId`。
|
||||
4. 若存在员工资产 Sheet,生成并初始化 `assetTaskId`。
|
||||
5. 返回当前前端兼容的 `BaseStaffImportSubmitResultVO`。
|
||||
6. 后台启动一个编排任务。
|
||||
|
||||
编排阶段:
|
||||
|
||||
1. 先执行员工主数据导入。
|
||||
2. 沿用现有员工必填、身份证号、部门、员工 ID、身份证号重复校验。
|
||||
3. 批量插入员工成功记录。
|
||||
4. 收集本轮成功员工的 `idCard`。
|
||||
5. 更新员工任务状态和失败记录。
|
||||
6. 再执行员工资产导入。
|
||||
7. 员工资产归属候选来源为:
|
||||
- 数据库已有 `ccdi_base_staff.id_card`
|
||||
- 本轮员工 Sheet 成功导入的 `idCard`
|
||||
8. 员工资产落库继续保持:
|
||||
- `family_id = 员工身份证号`
|
||||
- `person_id = 员工身份证号`
|
||||
9. 员工资产继续保持重复校验:`personId + assetMainType + assetSubType + assetName`。
|
||||
|
||||
### 5.3 任务状态
|
||||
|
||||
- 只填员工 Sheet:只返回 `staffTaskId`。
|
||||
- 只填员工资产 Sheet:只返回 `assetTaskId`,只按数据库已有员工校验。
|
||||
- 只填员工资产 Sheet 是正常导入场景,不因 `员工信息` Sheet 为空或未填写而拦截。
|
||||
- 两个 Sheet 都填:返回 `staffTaskId` 和 `assetTaskId`,后台保证员工先处理、资产后处理。
|
||||
- 员工主 Sheet 部分成功时,员工资产只能使用成功员工上下文。
|
||||
- 员工主 Sheet 全部失败时,员工资产仍执行,但只能命中数据库已有员工。
|
||||
|
||||
## 6. 员工亲属关系维护双 Sheet 导入设计
|
||||
|
||||
### 6.1 当前问题
|
||||
|
||||
当前 `/ccdi/staffFmyRelation/importData` 分别读取 `员工亲属关系信息` 和 `亲属资产信息`,然后分别调用:
|
||||
|
||||
- `relationService.importRelation(relationList)`
|
||||
- `assetInfoImportService.importAssetInfo(assetList)`
|
||||
|
||||
两个任务互相独立。亲属资产导入只通过数据库 `ccdi_staff_fmy_relation.relation_cert_no` 查找归属,无法稳定看到同一文件中刚导入成功的亲属关系。
|
||||
|
||||
### 6.2 设计方案
|
||||
|
||||
新增员工亲属关系维护双 Sheet 后端编排能力。
|
||||
|
||||
提交阶段:
|
||||
|
||||
1. Controller 读取两个 Sheet。
|
||||
2. 若两个 Sheet 都无数据,仍返回“至少需要一条数据”。
|
||||
3. 若存在亲属关系 Sheet,生成并初始化 `relationTaskId`。
|
||||
4. 若存在亲属资产 Sheet,生成并初始化 `assetTaskId`。
|
||||
5. 返回当前前端兼容的 `StaffFmyRelationImportSubmitResultVO`。
|
||||
6. 后台启动一个编排任务。
|
||||
|
||||
编排阶段:
|
||||
|
||||
1. 先执行亲属关系导入。
|
||||
2. 沿用现有亲属关系必填、员工身份证号存在性、关系人证件号、重复组合校验。
|
||||
3. 批量插入亲属关系成功记录。
|
||||
4. 收集本轮成功亲属关系映射:
|
||||
- `relationCertNo` 作为资产 Sheet 的 `personId`
|
||||
- `personId` 作为资产落库的 `familyId`
|
||||
5. 更新亲属关系任务状态和失败记录。
|
||||
6. 再执行亲属资产导入。
|
||||
7. 亲属资产归属候选来源为:
|
||||
- 数据库已有员工亲属关系,沿用现有 owner 查询条件
|
||||
- 本轮亲属关系 Sheet 成功导入的员工亲属关系
|
||||
8. 亲属资产落库继续保持:
|
||||
- `family_id = 员工身份证号`
|
||||
- `person_id = 亲属身份证号`
|
||||
|
||||
### 6.3 任务状态
|
||||
|
||||
- 只填亲属关系 Sheet:只返回 `relationTaskId`。
|
||||
- 只填亲属资产 Sheet:只返回 `assetTaskId`,只按数据库已有员工亲属关系校验,并沿用现有 owner 查询条件。
|
||||
- 只填亲属资产 Sheet 是正常导入场景,不因 `员工亲属关系信息` Sheet 为空或未填写而拦截。
|
||||
- 两个 Sheet 都填:返回 `relationTaskId` 和 `assetTaskId`,后台保证亲属关系先处理、资产后处理。
|
||||
- 亲属关系主 Sheet 部分成功时,亲属资产只能使用成功亲属关系上下文。
|
||||
- 亲属关系主 Sheet 全部失败时,亲属资产仍执行,但只能命中数据库已有员工亲属关系。
|
||||
|
||||
## 7. 实体库自动补入设计
|
||||
|
||||
### 7.1 服务职责
|
||||
|
||||
`EnterpriseAutoFillService` 只负责一件事:对关联业务成功记录中的企业,确保实体库存在对应统一社会信用代码。
|
||||
|
||||
服务接口:
|
||||
|
||||
- `ensureExists(EnterpriseFillItem item)`
|
||||
- `ensureExistsBatch(List<EnterpriseFillItem> items)`
|
||||
|
||||
`EnterpriseFillItem` 字段:
|
||||
|
||||
- `socialCreditCode`
|
||||
- `enterpriseName`
|
||||
- `entSource`
|
||||
- `dataSource`
|
||||
- `userName`
|
||||
|
||||
### 7.2 插入规则
|
||||
|
||||
1. 按 `socialCreditCode` 去重,同批次同一统一社会信用代码只处理一次。
|
||||
2. 批量查询 `ccdi_enterprise_base_info` 已存在记录。
|
||||
3. 已存在实体不更新。
|
||||
4. 缺失实体最小插入:
|
||||
- `social_credit_code = 统一社会信用代码`
|
||||
- `enterprise_name = 企业名称;中介实体关联缺失实体时允许为 NULL`
|
||||
- `ent_source = 来源`
|
||||
- `data_source = MANUAL 或 IMPORT`
|
||||
- `risk_level = 来源规则值`
|
||||
- `created_by/updated_by = 当前用户`
|
||||
5. 中介实体关联缺失实体时不要求提供机构名称,补入实体的 `enterprise_name` 可以为 `NULL`。
|
||||
6. 中介来源 `INTERMEDIARY` 写 `risk_level = 1`。
|
||||
7. 员工亲属、信贷客户、供应商来源写 `risk_level = NULL`。
|
||||
8. 并发导致主键重复时,按“已存在实体”处理。
|
||||
9. 其他数据库异常抛出,让当前业务事务失败。
|
||||
|
||||
### 7.3 四类业务接入点
|
||||
|
||||
员工亲属实体关联:
|
||||
|
||||
- 手工新增:校验有效亲属和组合不重复后、插入关联表前补入。
|
||||
- 导入:只对校验成功并即将插入的记录批量补入。
|
||||
- 来源:`EMP_RELATION`
|
||||
- 数据来源:手工新增为 `MANUAL`,导入为 `IMPORT`
|
||||
- 风险等级:`NULL`
|
||||
|
||||
信贷客户实体关联:
|
||||
|
||||
- 手工新增:校验通过后、插入关联表前补入。
|
||||
- 导入:只对校验成功并即将插入的记录批量补入。
|
||||
- 来源:`CREDIT_CUSTOMER`
|
||||
- 数据来源:手工新增为 `MANUAL`,导入为 `IMPORT`
|
||||
- 风险等级:`NULL`
|
||||
|
||||
中介实体关联:
|
||||
|
||||
- 手工新增:字段校验和重复关系校验通过后,取消或替换“实体库必须已存在”校验,先补入实体库,再插入关联关系。
|
||||
- 导入:字段校验和重复关系校验通过后,取消或替换“实体库必须已存在”失败条件,只对即将插入的成功记录批量补入。
|
||||
- 来源:`INTERMEDIARY`
|
||||
- 数据来源:手工新增为 `MANUAL`,导入为 `IMPORT`
|
||||
- 风险等级:`1`
|
||||
|
||||
招投标供应商:
|
||||
|
||||
- 手工新增或保存招投标主信息时,只对 `supplierUscc` 非空且通过现有格式校验的供应商补入。
|
||||
- 导入:只对成功采购事项中 `supplierUscc` 非空且通过现有格式校验的供应商批量补入,失败采购事项不补入。
|
||||
- 导入中 `supplierUscc` 为空的供应商保持现有保存规则,但不补入实体库。
|
||||
- 来源:`SUPPLIER`
|
||||
- 数据来源:手工新增为 `MANUAL`,导入为 `IMPORT`
|
||||
- 风险等级:`NULL`
|
||||
|
||||
## 8. 错误处理与边界
|
||||
|
||||
### 8.1 双 Sheet 导入边界
|
||||
|
||||
- 主 Sheet 失败行不能进入资产归属候选。
|
||||
- 主 Sheet 文件内重复行不能进入资产归属候选。
|
||||
- 主 Sheet 数据库重复行不能进入资产归属候选。
|
||||
- 主 Sheet 为空但资产 Sheet 有数据时,资产 Sheet 必须按现有独立资产导入规则正常执行。
|
||||
- 资产 Sheet 仍按自身失败记录任务记录 `sheetName`、`rowNum`、`errorMessage`。
|
||||
- 员工资产找不到员工时继续报“员工资产导入仅支持员工本人证件号”。
|
||||
- 亲属资产找不到归属时继续报“未找到亲属资产归属员工”。
|
||||
- 亲属资产命中多个归属时继续报“亲属资产归属员工不唯一”。
|
||||
- 亲属资产数据库归属查询条件沿用当前实现,不因本次编排新增额外状态过滤。
|
||||
|
||||
### 8.2 自动补入边界
|
||||
|
||||
- 只处理业务校验成功、即将落库的记录。
|
||||
- 失败业务行不能产生实体库记录。
|
||||
- 已存在实体不更新,避免覆盖人工维护数据。
|
||||
- 不增加默认企业名称、默认来源、默认风险等级等兜底逻辑。
|
||||
- 不改变实体库导入的严格新增规则。
|
||||
- 中介实体关联的“实体库必须已存在”校验需要被自动补入替换,否则缺失实体无法进入补入链路。
|
||||
- 中介实体关联不新增机构名称入参;缺失实体补入时允许 `enterprise_name = NULL`。
|
||||
- 供应商自动补入只处理非空统一社会信用代码,空统一社会信用代码不改变原导入保存规则。
|
||||
|
||||
## 9. 测试设计
|
||||
|
||||
### 9.1 单元测试
|
||||
|
||||
员工信息维护双 Sheet:
|
||||
|
||||
- 同一模板中新员工和员工资产同时导入,资产引用新员工身份证号,资产成功。
|
||||
- 员工 Sheet 行校验失败,资产引用该身份证号且数据库不存在,资产失败。
|
||||
- 员工信息 Sheet 为空或未填写,员工资产 Sheet 引用数据库已有员工时,资产成功导入。
|
||||
- 只导员工资产,数据库已有员工时成功。
|
||||
- 只导员工资产,数据库无员工时失败。
|
||||
- 资产重复命中数据库或当前文件重复时失败。
|
||||
|
||||
员工亲属关系维护双 Sheet:
|
||||
|
||||
- 同一模板中新亲属关系和亲属资产同时导入,资产引用新亲属证件号,资产成功。
|
||||
- 亲属关系 Sheet 行校验失败,资产引用该亲属证件号且数据库不存在,资产失败。
|
||||
- 员工亲属关系信息 Sheet 为空或未填写,亲属资产 Sheet 引用数据库已有员工亲属关系时,资产成功导入。
|
||||
- 只导亲属资产,数据库已有唯一员工亲属关系时成功。
|
||||
- 只导亲属资产,数据库不存在亲属关系时失败。
|
||||
- 同一亲属证件号命中多个员工归属时失败。
|
||||
|
||||
实体库自动补入:
|
||||
|
||||
- 已存在实体不插入、不更新。
|
||||
- 缺失实体插入最小记录。
|
||||
- 同批多个成功行引用同一统一社会信用代码,只补入一次。
|
||||
- 中介来源写 `riskLevel=1`。
|
||||
- 员工亲属、信贷客户、供应商来源写 `riskLevel=null`。
|
||||
- 后端枚举接口返回 `SUPPLIER/供应商`。
|
||||
- 并发重复主键按已存在处理。
|
||||
|
||||
四类业务接入:
|
||||
|
||||
- 手工新增成功时调用自动补入。
|
||||
- 导入成功行进入自动补入集合。
|
||||
- 导入失败行不进入自动补入集合。
|
||||
|
||||
### 9.2 接口验证
|
||||
|
||||
1. 下载真实 `/ccdi/baseStaff/importTemplate` 模板,构造 `员工信息` + `员工资产信息` 同文件导入,上传 `/ccdi/baseStaff/importData`,轮询两个任务并查询员工、资产落库。
|
||||
2. 下载真实 `/ccdi/staffFmyRelation/importTemplate` 模板,构造 `员工亲属关系信息` + `亲属资产信息` 同文件导入,上传 `/ccdi/staffFmyRelation/importData`,轮询两个任务并查询亲属关系、资产落库。
|
||||
3. 分别调用员工亲属、信贷客户、中介、供应商新增或导入接口,验证实体库自动补入来源、数据来源和风险等级。
|
||||
4. 涉及中文清理 SQL 或验证 SQL 时使用 `bin/mysql_utf8_exec.sh`。
|
||||
|
||||
### 9.3 页面验证
|
||||
|
||||
完成后必须进入真实业务页面验证,不打开 prototype:
|
||||
|
||||
1. 【员工信息维护】下载模板,填写员工和员工资产,上传后检查任务状态、失败记录、列表和详情资产。
|
||||
2. 【员工亲属关系维护】下载模板,填写亲属关系和亲属资产,上传后检查任务状态、失败记录、详情资产。
|
||||
3. 四类实体自动补入完成后,进入【实体库管理】按统一社会信用代码查询。
|
||||
4. 测试结束后清理本轮新增员工、亲属关系、资产、实体关联和自动补入实体库数据。
|
||||
5. 测试结束后关闭测试过程中启动的前后端进程。
|
||||
|
||||
## 10. 实施顺序
|
||||
|
||||
1. 提炼员工主数据导入执行方法,返回成功员工上下文和失败记录。
|
||||
2. 提炼员工资产导入执行方法,支持额外员工归属上下文。
|
||||
3. 新增员工信息维护双 Sheet 编排方法并接入 Controller。
|
||||
4. 提炼亲属关系导入执行方法,返回成功亲属关系上下文和失败记录。
|
||||
5. 提炼亲属资产导入执行方法,支持额外亲属关系归属上下文。
|
||||
6. 新增员工亲属关系维护双 Sheet 编排方法并接入 Controller。
|
||||
7. 恢复或新增 `EnterpriseAutoFillService`。
|
||||
8. 接入员工亲属、信贷客户、中介、供应商四类自动补入。
|
||||
9. 补充单元测试。
|
||||
10. 执行接口验证和真实页面验证。
|
||||
11. 新增实施记录到 `docs/reports/implementation/`。
|
||||
|
||||
## 11. 风险与控制
|
||||
|
||||
- 不能继续由 Controller 分别提交两个独立异步任务,否则资产任务执行顺序仍不确定。
|
||||
- 不能把主 Sheet 失败行放入资产归属上下文,否则会产生脏资产。
|
||||
- 不能更新已存在实体库记录,否则会覆盖人工维护的企业名称、风险等级和来源。
|
||||
- 自动补入必须在业务记录落库前执行,并与业务事务保持一致。
|
||||
- 四类自动补入必须只处理成功业务行,失败行不能污染实体库。
|
||||
@@ -0,0 +1,128 @@
|
||||
# 导入模板下拉框结构校验后端设计
|
||||
|
||||
## 背景
|
||||
|
||||
本次问题来自员工信息维护批量导入文件:
|
||||
|
||||
- 文件路径:`/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx`
|
||||
- 问题表现:`员工信息` Sheet 的 `状态` 列没有 Excel 下拉框,但当前导入链路会继续读取数据并进入业务导入。
|
||||
|
||||
用户已确认本次范围为:所有 Excel 导入中,凡是导入对象字段标注了 `@DictDropdown`,上传文件对应 Sheet 的对应列都必须保留模板下拉框;缺失下拉框时导入应立即报错。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 统一拦截缺少下拉框的数据文件,避免模板结构被破坏后仍被导入。
|
||||
2. 覆盖所有使用 `EasyExcelUtil.importExcel(...)` 读取、且导入对象含 `@DictDropdown` 字段的导入接口。
|
||||
3. 双 Sheet 导入按 Sheet 分别校验,任一应有下拉框的列缺失时立即失败。
|
||||
4. 错误提示明确到 Sheet 和列,便于用户重新下载模板处理。
|
||||
|
||||
## 非目标
|
||||
|
||||
1. 不改造无 `@DictDropdown` 字段的导入,例如当前未使用字典下拉模板的导入不强行纳入。
|
||||
2. 不改变现有业务字段校验、重复校验、异步导入、失败记录展示逻辑。
|
||||
3. 不新增兼容旧模板或降级导入逻辑;缺少下拉框即按模板不合规处理。
|
||||
|
||||
## 推荐方案
|
||||
|
||||
采用工具层统一校验:
|
||||
|
||||
1. 在 `EasyExcelUtil.importExcel(InputStream, Class<T>)` 和 `EasyExcelUtil.importExcel(InputStream, Class<T>, String)` 内部先读取上传文件字节。
|
||||
2. 使用 POI 打开工作簿,根据导入类上的 `@DictDropdown` 和 `@ExcelProperty(index)` 解析应校验的列。
|
||||
3. 校验对应 Sheet 中是否存在覆盖该列数据区的数据验证规则。
|
||||
4. 校验通过后,再使用 EasyExcel 按现有方式读取数据。
|
||||
|
||||
该方案集中在公共导入工具,能随现有导入调用链自然覆盖员工信息、员工资产、亲属关系、招聘、调动、招投标、实体库、中介等使用字典下拉模板的导入。
|
||||
|
||||
## 校验规则
|
||||
|
||||
### 字段解析
|
||||
|
||||
- 扫描导入类及父类字段。
|
||||
- 仅处理同时具备 `@DictDropdown` 和带明确 `index` 的 `@ExcelProperty` 字段。
|
||||
- 列标题优先取 `@ExcelProperty.value()` 的第一个值。
|
||||
|
||||
### Sheet 定位
|
||||
|
||||
- 指定 Sheet 名读取时,校验该 Sheet。
|
||||
- 未指定 Sheet 名读取时,校验第一个 Sheet。
|
||||
- 若 Sheet 不存在,保持现有读取失败语义,不额外设计兜底。
|
||||
|
||||
### 下拉框判断
|
||||
|
||||
- 读取 Sheet 的 `DataValidation` 列表。
|
||||
- 只认可 `DataValidationConstraint.ValidationType.LIST` 类型的数据验证;数字、日期、自定义公式等其他校验类型不能视为模板下拉框。
|
||||
- 数据区定义为:从第 2 行开始,到本次上传文件中该 Sheet 的最后一行有效数据。
|
||||
- 对每个实际数据行,目标列单元格都必须被 `LIST` 数据验证区域覆盖;只覆盖表头、只覆盖单个样例行、或只覆盖部分数据行,都视为该列下拉框缺失。
|
||||
- 校验目标为模板结构是否保留,不判断用户是否逐单元格从下拉框选择。
|
||||
|
||||
### 失败行为
|
||||
|
||||
任一应有下拉框的列缺失数据验证时,导入立即失败,不进入异步任务,不写 Redis 状态,不产生部分成功。
|
||||
|
||||
建议错误文案:
|
||||
|
||||
```text
|
||||
员工信息 Sheet 的 状态 列缺少下拉框,请下载最新导入模板填写后重新导入
|
||||
```
|
||||
|
||||
双 Sheet 场景示例:
|
||||
|
||||
- `员工信息` Sheet 的 `是否党员` 或 `状态` 缺失下拉框:失败。
|
||||
- `员工资产信息` Sheet 的 `资产状态` 缺失下拉框:失败。
|
||||
- 两个 Sheet 都保留下拉框:继续现有导入读取和业务校验。
|
||||
|
||||
## 影响范围
|
||||
|
||||
后端主要影响:
|
||||
|
||||
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`
|
||||
- 使用 `EasyExcelUtil.importExcel(...)` 的导入 Controller。
|
||||
|
||||
前端不需要改动。后端抛出的错误沿现有上传失败链路返回,页面展示现有错误提示即可。
|
||||
|
||||
## 测试设计
|
||||
|
||||
### 单元测试
|
||||
|
||||
新增工具层测试覆盖:
|
||||
|
||||
1. 带 `@DictDropdown` 且保留下拉数据验证的模板读取通过。
|
||||
2. 员工信息 `状态` 列缺少下拉数据验证时报错。
|
||||
3. 非 `LIST` 类型数据验证不能替代下拉框。
|
||||
4. 只覆盖部分实际数据行的下拉框应报错。
|
||||
5. 双 Sheet 中任一 Sheet 的字典下拉列缺失时报错。
|
||||
6. 无 `@DictDropdown` 字段的导入对象不触发结构校验。
|
||||
|
||||
### 样例文件验证
|
||||
|
||||
使用用户提供文件验证:
|
||||
|
||||
```text
|
||||
/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx
|
||||
```
|
||||
|
||||
预期:导入员工信息 Sheet 时提示 `状态` 列缺少下拉框。
|
||||
|
||||
### 真实页面验证
|
||||
|
||||
实现完成后按仓库规则使用 `browser-use` 打开真实员工信息维护页面验证:
|
||||
|
||||
1. 下载页面当前真实导入模板。
|
||||
2. 上传缺少下拉框的批量测试文件,确认页面提示导入失败。
|
||||
3. 上传保留下拉框的测试文件,确认能进入现有正常导入链路。
|
||||
4. 测试结束关闭本轮启动的前后端进程。
|
||||
|
||||
## 文档与实施记录
|
||||
|
||||
实现完成后新增实施记录:
|
||||
|
||||
```text
|
||||
docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md
|
||||
```
|
||||
|
||||
实施记录需包含:
|
||||
|
||||
- 修改内容。
|
||||
- 影响范围。
|
||||
- 测试命令与真实页面验证结果。
|
||||
- 用户提供缺下拉框文件的验证结果。
|
||||
@@ -0,0 +1,730 @@
|
||||
# Import Dropdown Validation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 上传 Excel 导入文件时,所有 `@DictDropdown` 字段对应列必须保留模板下拉框;缺失下拉框立即报错并阻止导入。
|
||||
|
||||
**Architecture:** 在 `EasyExcelUtil` 的公共读取入口统一增加模板结构校验,先用 POI 检查上传文件中对应 Sheet 的 `LIST` 数据验证覆盖情况,再交给 EasyExcel 执行现有数据读取。业务 Controller、异步导入、Redis 失败记录逻辑保持不变。
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 3, EasyExcel, Apache POI 4.1.2, JUnit 5, Mockito.
|
||||
|
||||
---
|
||||
|
||||
## Project Notes
|
||||
|
||||
- 当前工作区已有未提交改动,包含 `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`。实施时必须先阅读当前 diff,保留已有 `templateWriter(...).inMemory(Boolean.TRUE)` 改动,不要回滚用户或其他任务留下的内容。
|
||||
- `.DS_Store` 忽略,不纳入任何暂存或提交。
|
||||
- 本计划只涉及后端;不新增前端代码。
|
||||
- 实现完成后必须新增实施记录:`docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md`。
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`
|
||||
- 统一读取上传文件字节。
|
||||
- 解析 `@DictDropdown` 字段。
|
||||
- 使用 POI 校验 `LIST` 数据验证是否覆盖每个实际数据行。
|
||||
- 校验通过后继续调用 EasyExcel 读取数据。
|
||||
- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilImportDropdownValidationTest.java`
|
||||
- 覆盖缺失下拉框、非 LIST 验证、部分行覆盖、无字典字段绕过等工具层规则。
|
||||
- Create: `docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md`
|
||||
- 记录本次实现、影响范围、测试命令、用户文件验证和真实页面验证结果。
|
||||
- Reference only: `docs/plans/backend/2026-04-30-import-dropdown-validation-backend-design.md`
|
||||
- 已审查通过的设计文档。
|
||||
|
||||
## Task 1: Add Failing Utility Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilImportDropdownValidationTest.java`
|
||||
|
||||
- [ ] **Step 1: Create test class skeleton**
|
||||
|
||||
Add this file with package/imports and helper methods:
|
||||
|
||||
```java
|
||||
package com.ruoyi.info.collection.utils;
|
||||
|
||||
import com.alibaba.excel.annotation.ExcelProperty;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel;
|
||||
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.ss.usermodel.DataValidation;
|
||||
import org.apache.poi.ss.usermodel.DataValidationConstraint;
|
||||
import org.apache.poi.ss.usermodel.DataValidationHelper;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.util.CellRangeAddressList;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
```
|
||||
|
||||
Helper methods:
|
||||
|
||||
```java
|
||||
private byte[] baseStaffWorkbook(boolean partyDropdown, boolean statusDropdown, boolean statusAsList, int statusLastRow) throws Exception {
|
||||
try (Workbook workbook = new XSSFWorkbook();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
Sheet sheet = workbook.createSheet("员工信息");
|
||||
Row header = sheet.createRow(0);
|
||||
String[] headers = {"姓名", "员工ID", "所属部门ID", "身份证号", "电话", "年收入(元/年)", "入职时间", "是否党员", "状态"};
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
header.createCell(i).setCellValue(headers[i]);
|
||||
}
|
||||
createBaseStaffRow(sheet, 1, "张三", 9020001L, "33010619850202101X", "0", "1");
|
||||
createBaseStaffRow(sheet, 2, "李四", 9020002L, "330106198603031022", "1", "1");
|
||||
|
||||
if (partyDropdown) {
|
||||
addListValidation(sheet, 7, 1, 2, "0", "1");
|
||||
}
|
||||
if (statusDropdown) {
|
||||
if (statusAsList) {
|
||||
addListValidation(sheet, 8, 1, statusLastRow, "0", "1");
|
||||
} else {
|
||||
addIntegerValidation(sheet, 8, 1, 2);
|
||||
}
|
||||
}
|
||||
|
||||
workbook.write(outputStream);
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] baseStaffDualSheetWorkbookWithMissingAssetStatusDropdown() throws Exception {
|
||||
try (Workbook workbook = new XSSFWorkbook();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
Sheet staffSheet = workbook.createSheet("员工信息");
|
||||
Row staffHeader = staffSheet.createRow(0);
|
||||
String[] staffHeaders = {"姓名", "员工ID", "所属部门ID", "身份证号", "电话", "年收入(元/年)", "入职时间", "是否党员", "状态"};
|
||||
for (int i = 0; i < staffHeaders.length; i++) {
|
||||
staffHeader.createCell(i).setCellValue(staffHeaders[i]);
|
||||
}
|
||||
createBaseStaffRow(staffSheet, 1, "张三", 9020001L, "33010619850202101X", "0", "1");
|
||||
addListValidation(staffSheet, 7, 1, 1, "0", "1");
|
||||
addListValidation(staffSheet, 8, 1, 1, "0", "1");
|
||||
|
||||
Sheet assetSheet = workbook.createSheet("员工资产信息");
|
||||
Row assetHeader = assetSheet.createRow(0);
|
||||
String[] assetHeaders = {"员工身份证号*", "资产大类*", "资产小类*", "资产名称*", "产权占比", "购买/评估日期", "资产原值", "当前估值*", "估值截止日期", "资产状态*", "备注"};
|
||||
for (int i = 0; i < assetHeaders.length; i++) {
|
||||
assetHeader.createCell(i).setCellValue(assetHeaders[i]);
|
||||
}
|
||||
Row assetRow = assetSheet.createRow(1);
|
||||
assetRow.createCell(0).setCellValue("33010619850202101X");
|
||||
assetRow.createCell(1).setCellValue("房产");
|
||||
assetRow.createCell(2).setCellValue("住宅");
|
||||
assetRow.createCell(3).setCellValue("测试住宅");
|
||||
assetRow.createCell(7).setCellValue(1000000D);
|
||||
assetRow.createCell(9).setCellValue("正常");
|
||||
|
||||
workbook.write(outputStream);
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void createBaseStaffRow(Sheet sheet, int rowIndex, String name, long staffId, String idCard, String partyMember, String status) {
|
||||
Row row = sheet.createRow(rowIndex);
|
||||
row.createCell(0).setCellValue(name);
|
||||
row.createCell(1).setCellValue(staffId);
|
||||
row.createCell(2).setCellValue(103L);
|
||||
row.createCell(3, CellType.STRING).setCellValue(idCard);
|
||||
row.createCell(4, CellType.STRING).setCellValue("13370000001");
|
||||
row.createCell(5).setCellValue(new BigDecimal("180000").doubleValue());
|
||||
row.createCell(6).setCellValue("2026-04-30");
|
||||
row.createCell(7, CellType.STRING).setCellValue(partyMember);
|
||||
row.createCell(8, CellType.STRING).setCellValue(status);
|
||||
}
|
||||
|
||||
private void addListValidation(Sheet sheet, int columnIndex, int firstRow, int lastRow, String... options) {
|
||||
DataValidationHelper helper = sheet.getDataValidationHelper();
|
||||
DataValidationConstraint constraint = helper.createExplicitListConstraint(options);
|
||||
DataValidation validation = helper.createValidation(
|
||||
constraint,
|
||||
new CellRangeAddressList(firstRow, lastRow, columnIndex, columnIndex)
|
||||
);
|
||||
sheet.addValidationData(validation);
|
||||
}
|
||||
|
||||
private void addIntegerValidation(Sheet sheet, int columnIndex, int firstRow, int lastRow) {
|
||||
DataValidationHelper helper = sheet.getDataValidationHelper();
|
||||
DataValidationConstraint constraint = helper.createIntegerConstraint(
|
||||
DataValidationConstraint.OperatorType.BETWEEN,
|
||||
"0",
|
||||
"1"
|
||||
);
|
||||
DataValidation validation = helper.createValidation(
|
||||
constraint,
|
||||
new CellRangeAddressList(firstRow, lastRow, columnIndex, columnIndex)
|
||||
);
|
||||
sheet.addValidationData(validation);
|
||||
}
|
||||
|
||||
private byte[] plainWorkbookWithoutDropdown() throws Exception {
|
||||
try (Workbook workbook = new XSSFWorkbook();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
Sheet sheet = workbook.createSheet("普通信息");
|
||||
Row header = sheet.createRow(0);
|
||||
header.createCell(0).setCellValue("名称");
|
||||
Row row = sheet.createRow(1);
|
||||
row.createCell(0).setCellValue("张三");
|
||||
workbook.write(outputStream);
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static class PlainExcel {
|
||||
@ExcelProperty(value = "名称", index = 0)
|
||||
private String name;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add tests for accepted and rejected workbooks**
|
||||
|
||||
Add these tests:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void importExcel_shouldPassWhenAllDictDropdownColumnsKeepListValidation() throws Exception {
|
||||
byte[] bytes = baseStaffWorkbook(true, true, true, 2);
|
||||
|
||||
List<CcdiBaseStaffExcel> rows = EasyExcelUtil.importExcel(
|
||||
new ByteArrayInputStream(bytes),
|
||||
CcdiBaseStaffExcel.class,
|
||||
"员工信息"
|
||||
);
|
||||
|
||||
assertEquals(2, rows.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void importExcel_shouldFailWhenPartyMemberDropdownIsMissing() throws Exception {
|
||||
byte[] bytes = baseStaffWorkbook(false, true, true, 2);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () ->
|
||||
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("是否党员 列缺少下拉框"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void importExcel_shouldFailWhenStatusDropdownIsMissing() throws Exception {
|
||||
byte[] bytes = baseStaffWorkbook(true, false, true, 2);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () ->
|
||||
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
|
||||
);
|
||||
|
||||
assertEquals("员工信息 Sheet 的 状态 列缺少下拉框,请下载最新导入模板填写后重新导入", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void importExcel_shouldFailWhenValidationIsNotListType() throws Exception {
|
||||
byte[] bytes = baseStaffWorkbook(true, true, false, 2);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () ->
|
||||
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("状态 列缺少下拉框"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void importExcel_shouldFailWhenListValidationDoesNotCoverEveryActualDataRow() throws Exception {
|
||||
byte[] bytes = baseStaffWorkbook(true, true, true, 1);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () ->
|
||||
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("状态 列缺少下拉框"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void importExcel_shouldFailWhenSecondSheetDropdownIsMissing() throws Exception {
|
||||
byte[] bytes = baseStaffDualSheetWorkbookWithMissingAssetStatusDropdown();
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () ->
|
||||
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffAssetInfoExcel.class, "员工资产信息")
|
||||
);
|
||||
|
||||
assertEquals("员工资产信息 Sheet 的 资产状态 列缺少下拉框,请下载最新导入模板填写后重新导入", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void importExcel_shouldSkipDropdownStructureValidationWhenClassHasNoDictDropdownFields() throws Exception {
|
||||
byte[] bytes = plainWorkbookWithoutDropdown();
|
||||
|
||||
List<PlainExcel> rows = EasyExcelUtil.importExcel(
|
||||
new ByteArrayInputStream(bytes),
|
||||
PlainExcel.class,
|
||||
"普通信息"
|
||||
);
|
||||
|
||||
assertEquals(1, rows.size());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the new tests and confirm they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest test
|
||||
```
|
||||
|
||||
Expected before implementation:
|
||||
|
||||
- Tests for missing dropdown, non-LIST validation, and partial coverage fail because no structure validation exists.
|
||||
- The passing workbook test may pass already.
|
||||
|
||||
## Task 2: Implement Dropdown Structure Validation in EasyExcelUtil
|
||||
|
||||
**Files:**
|
||||
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`
|
||||
|
||||
- [ ] **Step 1: Add imports**
|
||||
|
||||
Add imports needed by the new helper methods:
|
||||
|
||||
```java
|
||||
import com.alibaba.excel.annotation.ExcelProperty;
|
||||
import com.ruoyi.common.annotation.DictDropdown;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.DataValidation;
|
||||
import org.apache.poi.ss.usermodel.DataValidationConstraint;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
```
|
||||
|
||||
Keep existing imports that are still used, including the current `ExcelWriterBuilder` import from the existing working tree.
|
||||
|
||||
- [ ] **Step 2: Update importExcel(String fileName, Class<T>)**
|
||||
|
||||
Change the file-name overload to use a stream so the same validation path is used:
|
||||
|
||||
```java
|
||||
public static <T> List<T> importExcel(String fileName, Class<T> clazz) {
|
||||
try (InputStream inputStream = java.nio.file.Files.newInputStream(java.nio.file.Path.of(fileName))) {
|
||||
return importExcel(inputStream, clazz);
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("导入Excel失败", e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update InputStream import overloads**
|
||||
|
||||
Make both stream overloads read bytes once, validate, then pass a fresh `ByteArrayInputStream` to EasyExcel:
|
||||
|
||||
```java
|
||||
public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz) {
|
||||
try {
|
||||
byte[] bytes = inputStream.readAllBytes();
|
||||
validateDictDropdownTemplate(bytes, clazz, null);
|
||||
return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet().doReadSync();
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("导入Excel失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz, String sheetName) {
|
||||
try {
|
||||
byte[] bytes = inputStream.readAllBytes();
|
||||
validateDictDropdownTemplate(bytes, clazz, sheetName);
|
||||
return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet(sheetName).doReadSync();
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("导入Excel失败", e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add validation helpers**
|
||||
|
||||
Add these private helpers near the bottom of `EasyExcelUtil`, before `templateWriter(...)`:
|
||||
|
||||
```java
|
||||
private static void validateDictDropdownTemplate(byte[] bytes, Class<?> clazz, String sheetName) {
|
||||
List<DropdownColumn> dropdownColumns = resolveDropdownColumns(clazz);
|
||||
if (dropdownColumns.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) {
|
||||
Sheet sheet = sheetName == null ? workbook.getSheetAt(0) : workbook.getSheet(sheetName);
|
||||
if (sheet == null) {
|
||||
return;
|
||||
}
|
||||
int lastDataRowIndex = findLastDataRowIndex(sheet);
|
||||
if (lastDataRowIndex < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (DropdownColumn column : dropdownColumns) {
|
||||
if (!isListValidationCovered(sheet, column.index(), lastDataRowIndex)) {
|
||||
throw new ServiceException(sheet.getSheetName() + " Sheet 的 " + column.title()
|
||||
+ " 列缺少下拉框,请下载最新导入模板填写后重新导入");
|
||||
}
|
||||
}
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("导入Excel失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<DropdownColumn> resolveDropdownColumns(Class<?> clazz) {
|
||||
List<DropdownColumn> columns = new ArrayList<>();
|
||||
Class<?> current = clazz;
|
||||
while (current != null && current != Object.class) {
|
||||
for (Field field : current.getDeclaredFields()) {
|
||||
if (field.getAnnotation(DictDropdown.class) == null) {
|
||||
continue;
|
||||
}
|
||||
ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
|
||||
if (excelProperty == null || excelProperty.index() < 0) {
|
||||
continue;
|
||||
}
|
||||
columns.add(new DropdownColumn(excelProperty.index(), resolveColumnTitle(field, excelProperty)));
|
||||
}
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
columns.sort(Comparator.comparingInt(DropdownColumn::index));
|
||||
return columns;
|
||||
}
|
||||
|
||||
private static String resolveColumnTitle(Field field, ExcelProperty excelProperty) {
|
||||
if (excelProperty.value().length > 0 && excelProperty.value()[0] != null && !excelProperty.value()[0].isBlank()) {
|
||||
return excelProperty.value()[0].replace("*", "");
|
||||
}
|
||||
return field.getName();
|
||||
}
|
||||
|
||||
private static int findLastDataRowIndex(Sheet sheet) {
|
||||
int lastDataRowIndex = -1;
|
||||
for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
if (hasData(row)) {
|
||||
lastDataRowIndex = rowIndex;
|
||||
}
|
||||
}
|
||||
return lastDataRowIndex;
|
||||
}
|
||||
|
||||
private static boolean hasData(Row row) {
|
||||
if (row == null || row.getLastCellNum() < 0) {
|
||||
return false;
|
||||
}
|
||||
for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) {
|
||||
if (cellIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
Cell cell = row.getCell(cellIndex);
|
||||
if (cell != null && cell.toString() != null && !cell.toString().isBlank()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isListValidationCovered(Sheet sheet, int columnIndex, int lastDataRowIndex) {
|
||||
boolean[] coveredRows = new boolean[lastDataRowIndex + 1];
|
||||
for (DataValidation validation : sheet.getDataValidations()) {
|
||||
DataValidationConstraint constraint = validation.getValidationConstraint();
|
||||
if (constraint == null || constraint.getValidationType() != DataValidationConstraint.ValidationType.LIST) {
|
||||
continue;
|
||||
}
|
||||
for (CellRangeAddress address : validation.getRegions().getCellRangeAddresses()) {
|
||||
if (address.getFirstColumn() > columnIndex || address.getLastColumn() < columnIndex) {
|
||||
continue;
|
||||
}
|
||||
int firstRow = Math.max(1, address.getFirstRow());
|
||||
int lastRow = Math.min(lastDataRowIndex, address.getLastRow());
|
||||
for (int rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) {
|
||||
coveredRows[rowIndex] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int rowIndex = 1; rowIndex <= lastDataRowIndex; rowIndex++) {
|
||||
if (hasData(sheet.getRow(rowIndex)) && !coveredRows[rowIndex]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private record DropdownColumn(int index, String title) {}
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Do not add dictionary-value checks; this task only checks template dropdown structure.
|
||||
- Do not make Controller-specific changes.
|
||||
- Do not change existing async import status behavior.
|
||||
- If `sheet == null`, preserve current EasyExcel failure path rather than inventing a new fallback.
|
||||
|
||||
- [ ] **Step 5: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest test
|
||||
```
|
||||
|
||||
Expected: all tests in the new class pass.
|
||||
|
||||
## Task 3: Regression Tests for Existing Template Generation
|
||||
|
||||
**Files:**
|
||||
- Modify only if needed: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java`
|
||||
|
||||
- [ ] **Step 1: Run existing template tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilTemplateTest test
|
||||
```
|
||||
|
||||
Expected: existing template-generation tests pass. This confirms the validation change did not break dropdown generation and preserves the existing `inMemory(Boolean.TRUE)` template writer fix.
|
||||
|
||||
- [ ] **Step 2: Run controller tests that mock EasyExcelUtil**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-info-collection -am -Dtest=CcdiBaseStaffControllerTest,CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest,CcdiBaseStaffAssetImportControllerTest test
|
||||
```
|
||||
|
||||
Expected: controller tests pass. This confirms method signatures and import entry points did not change.
|
||||
|
||||
## Task 4: Verify User-Provided Broken Workbook
|
||||
|
||||
**Files:**
|
||||
- No committed file changes.
|
||||
|
||||
- [ ] **Step 1: Confirm workbook structure manually**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
from openpyxl import load_workbook
|
||||
path = '/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx'
|
||||
wb = load_workbook(path)
|
||||
for ws in wb.worksheets:
|
||||
print(ws.title, len(ws.data_validations.dataValidation))
|
||||
PY
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
员工信息 0
|
||||
员工资产信息 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validate through backend code path**
|
||||
|
||||
Use the real page upload in Task 6 as the authoritative backend-path validation. The expected page/API error is:
|
||||
|
||||
```text
|
||||
员工信息 Sheet 的 状态 列缺少下拉框,请下载最新导入模板填写后重新导入
|
||||
```
|
||||
|
||||
Do not commit this user-provided workbook or any generated upload files.
|
||||
|
||||
## Task 5: Add Implementation Report
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md`
|
||||
|
||||
- [ ] **Step 1: Write implementation report**
|
||||
|
||||
Create the report with this structure:
|
||||
|
||||
```markdown
|
||||
# 导入模板下拉框结构校验实施记录
|
||||
|
||||
## 修改内容
|
||||
|
||||
- 在 `EasyExcelUtil.importExcel(...)` 公共入口增加 `@DictDropdown` 列下拉框结构校验。
|
||||
- 上传文件中对应 Sheet 的对应列必须由 `LIST` 类型数据验证覆盖每个实际数据行。
|
||||
- 缺失下拉框时导入立即失败,不进入异步导入任务。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 影响所有使用 `EasyExcelUtil.importExcel(...)` 且导入对象含 `@DictDropdown` 字段的 Excel 导入。
|
||||
- 不影响无 `@DictDropdown` 字段的导入。
|
||||
- 不修改前端页面、业务字段校验、异步导入状态和失败记录逻辑。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest test`
|
||||
- `mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilTemplateTest test`
|
||||
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiBaseStaffControllerTest,CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest,CcdiBaseStaffAssetImportControllerTest test`
|
||||
|
||||
## 用户文件验证
|
||||
|
||||
- 文件:`/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx`
|
||||
- 结果:上传后提示 `员工信息 Sheet 的 状态 列缺少下拉框,请下载最新导入模板填写后重新导入`
|
||||
|
||||
## 真实页面验证
|
||||
|
||||
- 页面:员工信息维护真实业务页面。
|
||||
- 结果:记录下载真实模板、上传缺下拉文件、上传保留下拉测试文件的页面验证结论。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fill actual verification output after tests**
|
||||
|
||||
Replace the verification bullets with actual pass/fail results after running commands and browser validation.
|
||||
|
||||
## Task 6: Real Page Validation With browser-use
|
||||
|
||||
**Files:**
|
||||
- No source file changes unless verification uncovers a bug.
|
||||
- Generated test files must stay under ignored output paths such as `output/browser-use/` or `output/spreadsheet/` and must not be committed.
|
||||
|
||||
- [ ] **Step 1: Use browser-use skill**
|
||||
|
||||
Before browser work, open `/Users/wkc/.codex/plugins/cache/openai-bundled/browser-use/0.1.0-alpha1/skills/browser/SKILL.md` and follow it.
|
||||
|
||||
- [ ] **Step 2: Start backend using project script if needed**
|
||||
|
||||
If no backend is running or code changes require restart, run:
|
||||
|
||||
```bash
|
||||
sh bin/restart_java_backend.sh
|
||||
```
|
||||
|
||||
Expected: backend available at `http://localhost:62318`.
|
||||
|
||||
- [ ] **Step 3: Start frontend with nvm if needed**
|
||||
|
||||
If no frontend is running, run:
|
||||
|
||||
```bash
|
||||
cd ruoyi-ui
|
||||
nvm use
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Expected: frontend dev server URL printed by Vite/Vue CLI. Keep the process id/session so it can be stopped after testing.
|
||||
|
||||
- [ ] **Step 4: Test broken workbook on real page**
|
||||
|
||||
In the real employee information maintenance page:
|
||||
|
||||
1. Log in through the real app or `/login/test` shortcut if already used by the project.
|
||||
2. Open employee information maintenance.
|
||||
3. Upload `/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx`.
|
||||
4. Confirm the page displays the backend error mentioning `状态 列缺少下拉框`.
|
||||
|
||||
Expected: no async import task is created for this upload.
|
||||
|
||||
- [ ] **Step 5: Test current real template still works**
|
||||
|
||||
1. Download the current import template from the real page.
|
||||
2. Fill a small test workbook while preserving dropdown validations.
|
||||
3. Upload it and confirm it enters the existing normal import chain.
|
||||
4. Clean up any successfully imported test data.
|
||||
|
||||
Expected: dropdown validation does not block a valid template.
|
||||
|
||||
- [ ] **Step 6: Stop test processes**
|
||||
|
||||
Stop any backend/frontend process started during this task. Do not stop unrelated user-owned processes.
|
||||
|
||||
## Task 7: Final Verification and Commit Hygiene
|
||||
|
||||
**Files:**
|
||||
- Modify: files from previous tasks only.
|
||||
|
||||
- [ ] **Step 1: Check worktree and staged state**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --cached --name-status
|
||||
```
|
||||
|
||||
Expected: no staged unrelated files. `.DS_Store` remains ignored/uncommitted.
|
||||
|
||||
- [ ] **Step 2: Run final focused verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest,EasyExcelUtilTemplateTest,CcdiBaseStaffControllerTest,CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest,CcdiBaseStaffAssetImportControllerTest test
|
||||
```
|
||||
|
||||
Expected: all selected tests pass.
|
||||
|
||||
- [ ] **Step 3: Stage only this task's files**
|
||||
|
||||
Stage only these files:
|
||||
|
||||
```bash
|
||||
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java
|
||||
git add ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilImportDropdownValidationTest.java
|
||||
git add docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md
|
||||
```
|
||||
|
||||
If `EasyExcelUtil.java` still contains unrelated pre-existing edits that should not be committed, use partial staging or stop and ask the user before committing.
|
||||
|
||||
- [ ] **Step 4: Review staged diff**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --cached --name-status
|
||||
git diff --cached --stat
|
||||
```
|
||||
|
||||
Expected: only the implementation files and implementation report are staged.
|
||||
|
||||
- [ ] **Step 5: Commit if requested**
|
||||
|
||||
If the user wants a commit, use a Chinese message:
|
||||
|
||||
```bash
|
||||
git commit -m "新增导入模板下拉框校验"
|
||||
```
|
||||
|
||||
Expected: commit succeeds without `.DS_Store` or unrelated docs/files.
|
||||
@@ -0,0 +1,671 @@
|
||||
# Bank Upload Original Filename Implementation Plan
|
||||
|
||||
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Follow the CCDI project rule: unless the user explicitly declares `using-superpowers` for implementation, execute this plan through the ordinary workflow and do not enable subagents by default.
|
||||
|
||||
**Goal:** 上传本地流水文件后,页面记录名和转传流水分析平台 multipart 文件名都保持用户初始上传文件名。
|
||||
|
||||
**Architecture:** 后端继续使用唯一临时文件名保存文件,避免同名和并发冲突;上传流水分析平台时额外传入原始文件名,并用可覆盖 `Resource#getFilename()` 的资源对象构造 multipart 文件 part。上传记录状态后处理增加“是否保留当前记录文件名”的来源参数,本地上传链路保留初始文件名,拉取本行信息链路保持现状。
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 3, RestTemplate, MyBatis Plus, JUnit 5, Mockito, Vue 2 页面真实验证。
|
||||
|
||||
---
|
||||
|
||||
## Project Notes
|
||||
|
||||
- 设计文档:[docs/superpowers/specs/2026-05-06-bank-upload-original-filename-design.md](/Users/wkc/Desktop/ccdi/ccdi/docs/superpowers/specs/2026-05-06-bank-upload-original-filename-design.md)
|
||||
- 项目路径规则覆盖 `writing-plans` 默认目录:本次只涉及后端源码,因此实施计划放在 `docs/plans/backend/`。
|
||||
- 本次不新增前端源码,不新增数据库字段,不回改历史上传记录。
|
||||
- `.DS_Store` 忽略,不纳入暂存或提交。
|
||||
- 完成实现后必须新增实施记录:`docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md`。
|
||||
- 真实页面验证使用 @browser-use:browser 打开实际业务页面,不打开 prototype 页面。
|
||||
- 如果启动了后端、前端或 `lsfx-mock-server`,测试结束后必须关闭本轮启动的进程。
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java`
|
||||
- 新增可指定 multipart filename 的 `NamedFileSystemResource`。
|
||||
- `uploadFile` 继续支持 `File` 参数;当参数已经是 `Resource` 时直接加入 multipart body。
|
||||
- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java`
|
||||
- 新增 `uploadFile(Integer groupId, File file, String uploadFileName)`。
|
||||
- 现有 `uploadFile(Integer groupId, File file)` 委托到新方法并使用 `file.getName()`。
|
||||
- 项目上传链路使用新方法传入初始上传文件名。
|
||||
- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java`
|
||||
- 验证 multipart body 中 `files` 资源的 `filename` 可被显式指定。
|
||||
- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java`
|
||||
- 验证流水分析客户端上传时把指定原始文件名写入 `files` 参数。
|
||||
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
|
||||
- `processFileAsync` 调用 `lsfxClient.uploadFile(lsfxProjectId, file, record.getFileName())`。
|
||||
- `processRecordAfterLogIdReady` 增加来源控制参数,本地上传不覆盖 `record.fileName`,拉取本行信息保持现有覆盖行为。
|
||||
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
|
||||
- 更新现有 `processFileAsync` 上传 stubbing 到三参数签名。
|
||||
- 新增本地上传使用原始文件名转传、平台返回名不覆盖上传记录、拉取本行信息保持现状的回归测试。
|
||||
- Create: `docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md`
|
||||
- 记录修改内容、影响范围、测试命令、真实页面验证和进程清理情况。
|
||||
|
||||
## Task 1: Add Failing Tests For Multipart Filename
|
||||
|
||||
**Files:**
|
||||
- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java`
|
||||
- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java`
|
||||
|
||||
- [ ] **Step 1: Create `HttpUtilTest` with a failing filename assertion**
|
||||
|
||||
Add:
|
||||
|
||||
```java
|
||||
package com.ruoyi.lsfx.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HttpUtilTest {
|
||||
|
||||
@Mock
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void uploadFile_shouldUseExplicitResourceFilename() throws Exception {
|
||||
HttpUtil httpUtil = new HttpUtil();
|
||||
ReflectionTestUtils.setField(httpUtil, "restTemplate", restTemplate);
|
||||
|
||||
Path tempFile = tempDir.resolve("batch_0_123456.xlsx");
|
||||
Files.writeString(tempFile, "content");
|
||||
|
||||
ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
|
||||
when(restTemplate.postForEntity(eq("http://lsfx/upload"), captor.capture(), eq(String.class)))
|
||||
.thenReturn(ResponseEntity.ok("ok"));
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("groupId", 200);
|
||||
params.put("files", HttpUtil.namedFileResource(tempFile.toFile(), "银行流水A.xlsx"));
|
||||
|
||||
String result = httpUtil.uploadFile("http://lsfx/upload", params, null, String.class);
|
||||
|
||||
assertEquals("ok", result);
|
||||
MultiValueMap<String, Object> body = (MultiValueMap<String, Object>) captor.getValue().getBody();
|
||||
Object filePart = body.getFirst("files");
|
||||
Resource resource = assertInstanceOf(Resource.class, filePart);
|
||||
assertEquals("银行流水A.xlsx", resource.getFilename());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `LsfxAnalysisClientTest` with a failing client assertion**
|
||||
|
||||
Add:
|
||||
|
||||
```java
|
||||
package com.ruoyi.lsfx.client;
|
||||
|
||||
import com.ruoyi.lsfx.constants.LsfxConstants;
|
||||
import com.ruoyi.lsfx.domain.response.UploadFileResponse;
|
||||
import com.ruoyi.lsfx.util.HttpUtil;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LsfxAnalysisClientTest {
|
||||
|
||||
@Mock
|
||||
private HttpUtil httpUtil;
|
||||
|
||||
@InjectMocks
|
||||
private LsfxAnalysisClient client;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void uploadFile_shouldPassOriginalFilenameToMultipartResource() throws Exception {
|
||||
ReflectionTestUtils.setField(client, "baseUrl", "http://lsfx");
|
||||
ReflectionTestUtils.setField(client, "uploadFileEndpoint", "/upload");
|
||||
ReflectionTestUtils.setField(client, "clientId", "client-1");
|
||||
|
||||
Path tempFile = tempDir.resolve("batch_0_123456.xlsx");
|
||||
Files.writeString(tempFile, "content");
|
||||
|
||||
UploadFileResponse response = new UploadFileResponse();
|
||||
response.setData(new UploadFileResponse.UploadData());
|
||||
|
||||
ArgumentCaptor<Map<String, Object>> paramsCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
ArgumentCaptor<Map<String, String>> headersCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
when(httpUtil.uploadFile(eq("http://lsfx/upload"), paramsCaptor.capture(), headersCaptor.capture(), eq(UploadFileResponse.class)))
|
||||
.thenReturn(response);
|
||||
|
||||
client.uploadFile(200, tempFile.toFile(), "银行流水A.xlsx");
|
||||
|
||||
assertEquals(200, paramsCaptor.getValue().get("groupId"));
|
||||
Resource filePart = assertInstanceOf(Resource.class, paramsCaptor.getValue().get("files"));
|
||||
assertEquals("银行流水A.xlsx", filePart.getFilename());
|
||||
assertEquals("client-1", headersCaptor.getValue().get(LsfxConstants.HEADER_CLIENT_ID));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests and confirm they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest -Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected: FAIL because `HttpUtil.namedFileResource(...)` and `LsfxAnalysisClient.uploadFile(Integer, File, String)` do not exist yet.
|
||||
|
||||
## Task 2: Implement Multipart Filename Support
|
||||
|
||||
**Files:**
|
||||
- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java`
|
||||
- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java`
|
||||
|
||||
- [ ] **Step 1: Add named file resource to `HttpUtil`**
|
||||
|
||||
In `HttpUtil.java`, add only this import:
|
||||
|
||||
```java
|
||||
import org.springframework.util.StringUtils;
|
||||
```
|
||||
|
||||
Do not import `org.springframework.core.io.Resource` in this file because it conflicts with the existing `jakarta.annotation.Resource` annotation import. Use the Spring Resource type by fully qualified name in implementation snippets.
|
||||
|
||||
Add this nested class and factory method inside `HttpUtil`:
|
||||
|
||||
```java
|
||||
public static org.springframework.core.io.Resource namedFileResource(File file, String filename) {
|
||||
return new NamedFileSystemResource(file, filename);
|
||||
}
|
||||
|
||||
private static class NamedFileSystemResource extends FileSystemResource {
|
||||
private final String filename;
|
||||
|
||||
NamedFileSystemResource(File file, String filename) {
|
||||
super(file);
|
||||
this.filename = StringUtils.hasText(filename) ? filename : file.getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Let `HttpUtil.uploadFile` accept existing `Resource` values**
|
||||
|
||||
Replace the file branch in `uploadFile(...)` with:
|
||||
|
||||
```java
|
||||
if (value instanceof File) {
|
||||
File file = (File) value;
|
||||
body.add(key, new FileSystemResource(file));
|
||||
} else if (value instanceof org.springframework.core.io.Resource) {
|
||||
body.add(key, value);
|
||||
} else {
|
||||
body.add(key, value);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add explicit filename upload overload to `LsfxAnalysisClient`**
|
||||
|
||||
Replace the current upload method body with a delegating overload:
|
||||
|
||||
```java
|
||||
public UploadFileResponse uploadFile(Integer groupId, File file) {
|
||||
return uploadFile(groupId, file, file.getName());
|
||||
}
|
||||
|
||||
public UploadFileResponse uploadFile(Integer groupId, File file, String uploadFileName) {
|
||||
String multipartFileName = org.springframework.util.StringUtils.hasText(uploadFileName)
|
||||
? uploadFileName
|
||||
: file.getName();
|
||||
log.info("【流水分析】上传文件请求: groupId={}, fileName={}", groupId, multipartFileName);
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
try {
|
||||
String url = baseUrl + uploadFileEndpoint;
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("groupId", groupId);
|
||||
params.put("files", HttpUtil.namedFileResource(file, multipartFileName));
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId);
|
||||
|
||||
UploadFileResponse response = httpUtil.uploadFile(url, params, headers, UploadFileResponse.class);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - startTime;
|
||||
if (response != null && response.getData() != null) {
|
||||
log.info("【流水分析】上传文件成功: uploadStatus={}, 耗时={}ms",
|
||||
response.getData().getUploadStatus(), elapsed);
|
||||
} else {
|
||||
log.warn("【流水分析】上传文件响应异常: 耗时={}ms", elapsed);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (LsfxApiException e) {
|
||||
log.error("【流水分析】上传文件失败: groupId={}, error={}", groupId, e.getMessage(), e);
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("【流水分析】上传文件未知异常: groupId={}", groupId, e);
|
||||
throw new LsfxApiException("上传文件失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run `ccdi-lsfx` tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest,CreditParseControllerTest -Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit lsfx client changes**
|
||||
|
||||
Check the worktree, stage only task files, then verify staged scope:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git add ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java \
|
||||
ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java \
|
||||
ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java \
|
||||
ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java
|
||||
git diff --cached --name-status
|
||||
```
|
||||
|
||||
Expected staged files: only the four `ccdi-lsfx` files in this task.
|
||||
|
||||
```bash
|
||||
git commit -m "修复流水分析上传文件名传递"
|
||||
```
|
||||
|
||||
## Task 3: Add Failing Project Upload Service Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
|
||||
|
||||
- [ ] **Step 1: Update existing `processFileAsync` stubs to the new signature**
|
||||
|
||||
In `CcdiFileUploadServiceImplTest.java`, replace local file upload stubs:
|
||||
|
||||
```java
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse());
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```java
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
```
|
||||
|
||||
Only update tests that exercise `processFileAsync`. Do not change `processPullBankInfoAsync` tests to upload files; that chain uses `fetchInnerFlow`.
|
||||
|
||||
- [ ] **Step 2: Add test that local upload calls LSFX with original record filename**
|
||||
|
||||
Add:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException {
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildEmptyBankStatementResponse());
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
record.setFileName("原始流水.xlsx");
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
|
||||
verify(lsfxClient).uploadFile(eq(LSFX_PROJECT_ID), argThat(file ->
|
||||
file.getName().startsWith("upload-") && file.getName().endsWith(".xlsx")
|
||||
), eq("原始流水.xlsx"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add test that platform filename does not overwrite local upload record filename**
|
||||
|
||||
Add:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException {
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any()))
|
||||
.thenReturn(buildParsedSuccessStatusResponse("平台返回文件名.xlsx"));
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildEmptyBankStatementResponse());
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
record.setFileName("原始流水.xlsx");
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
|
||||
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
|
||||
org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
|
||||
"parsed_success".equals(item.getFileStatus())
|
||||
&& "原始流水.xlsx".equals(item.getFileName()))
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Keep pull-bank-info regression explicit**
|
||||
|
||||
Keep the existing test `processPullBankInfoAsync_shouldUpdateFileSizeFromStatusResponse` asserting:
|
||||
|
||||
```java
|
||||
"XX身份证.xlsx".equals(item.getFileName())
|
||||
```
|
||||
|
||||
This verifies the shared status method still allows platform filename overwrite for the “拉取本行信息” chain.
|
||||
|
||||
- [ ] **Step 5: Add failure-state filename regression**
|
||||
|
||||
Add:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void processFileAsync_shouldKeepOriginalFileNameWhenParseStatusFails() throws IOException {
|
||||
GetFileUploadStatusResponse statusResponse = buildParsedSuccessStatusResponse("平台失败文件名.xlsx");
|
||||
GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0);
|
||||
logItem.setStatus(-1);
|
||||
logItem.setUploadStatusDesc("parse.failed");
|
||||
|
||||
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(buildUploadResponse());
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse);
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
record.setFileName("原始流水.xlsx");
|
||||
Path tempFile = createTempFile();
|
||||
|
||||
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
|
||||
|
||||
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
|
||||
org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
|
||||
"parsed_failed".equals(item.getFileStatus())
|
||||
&& "原始流水.xlsx".equals(item.getFileName()))
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run service tests and confirm expected failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected: FAIL because `processFileAsync` still calls the old two-argument upload method and status handling still overwrites `record.fileName`.
|
||||
|
||||
## Task 4: Implement Project Upload Filename Isolation
|
||||
|
||||
**Files:**
|
||||
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
|
||||
|
||||
- [ ] **Step 1: Use original record filename when forwarding local uploaded files**
|
||||
|
||||
In `processFileAsync`, replace:
|
||||
|
||||
```java
|
||||
UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```java
|
||||
UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file, record.getFileName());
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add source control to status post-processing**
|
||||
|
||||
Add an overload:
|
||||
|
||||
```java
|
||||
private void processRecordAfterLogIdReady(Long projectId, Integer lsfxProjectId,
|
||||
CcdiFileUploadRecord record, Integer logId) {
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false);
|
||||
}
|
||||
```
|
||||
|
||||
Change the current method signature to:
|
||||
|
||||
```java
|
||||
private void processRecordAfterLogIdReady(Long projectId, Integer lsfxProjectId,
|
||||
CcdiFileUploadRecord record, Integer logId,
|
||||
boolean preserveRecordFileName) {
|
||||
```
|
||||
|
||||
Then replace the filename update block with:
|
||||
|
||||
```java
|
||||
if (!preserveRecordFileName) {
|
||||
String fileName = StringUtils.hasText(logItem.getUploadFileName())
|
||||
? logItem.getUploadFileName()
|
||||
: logItem.getDownloadFileName();
|
||||
if (StringUtils.hasText(fileName)) {
|
||||
record.setFileName(fileName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Call status post-processing with preservation from local upload**
|
||||
|
||||
In `processFileAsync`, replace:
|
||||
|
||||
```java
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```java
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, true);
|
||||
```
|
||||
|
||||
Do not change `processPullBankInfoAsync`; it should continue to call the four-argument overload and preserve the current pull-bank-info behavior.
|
||||
|
||||
- [ ] **Step 4: Run service tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit project upload service changes**
|
||||
|
||||
Check the worktree, stage only task files, then verify staged scope:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java \
|
||||
ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
|
||||
git diff --cached --name-status
|
||||
```
|
||||
|
||||
Expected staged files: only `CcdiFileUploadServiceImpl.java` and `CcdiFileUploadServiceImplTest.java`.
|
||||
|
||||
```bash
|
||||
git commit -m "修复本地上传流水记录文件名覆盖"
|
||||
```
|
||||
|
||||
## Task 5: Verification, Real Page Check, And Implementation Record
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md`
|
||||
- Reference: `ruoyi-admin/src/main/resources/application-dev.yml`
|
||||
- Reference: `lsfx-mock-server/routers/api.py`
|
||||
- Reference: `lsfx-mock-server/services/file_service.py`
|
||||
|
||||
- [ ] **Step 1: Run focused backend regression tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx,ccdi-project -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest,CcdiFileUploadServiceImplTest,CcdiFileUploadControllerTest -Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run compile check for affected modules**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ccdi-lsfx,ccdi-project -am -DskipTests compile
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 3: Start verification services**
|
||||
|
||||
Use the project backend restart script:
|
||||
|
||||
```bash
|
||||
sh bin/restart_java_backend.sh
|
||||
```
|
||||
|
||||
For the mock service, use local dev defaults:
|
||||
|
||||
```bash
|
||||
cd lsfx-mock-server
|
||||
python3 main.py --rule-hit-mode subset
|
||||
```
|
||||
|
||||
If the frontend is not already running, start it with the project Node version:
|
||||
|
||||
```bash
|
||||
cd ruoyi-ui
|
||||
nvm use
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Perform real page upload check with @browser-use:browser**
|
||||
|
||||
In the actual business page:
|
||||
|
||||
1. Login through the real application.
|
||||
2. Open project detail -> 上传数据.
|
||||
3. Upload a test file named with a distinctive original name, for example `原始文件名验证-20260506.xlsx`.
|
||||
4. Confirm the upload record table displays `原始文件名验证-20260506.xlsx`.
|
||||
5. Confirm the mock LSFX upload response or service log records the same filename. The mock service receives the filename through FastAPI `UploadFile.filename`, and `FileService.upload_file` writes it into `file_record.file_name`.
|
||||
|
||||
Expected: page table filename and mock LSFX received filename both match the original uploaded filename.
|
||||
|
||||
- [ ] **Step 5: Clean test data and stop started processes**
|
||||
|
||||
Remove the uploaded test record through the existing page delete action if it reached parsed success. If test data was created but cannot be removed from the page, clean only the test row by its unique filename or upload record id after confirming the scope.
|
||||
|
||||
Stop only the backend, frontend, or mock-service processes started in Step 3.
|
||||
|
||||
- [ ] **Step 6: Write implementation record**
|
||||
|
||||
Create `docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md` with:
|
||||
|
||||
```markdown
|
||||
# 上传流水文件原始文件名保持实施记录
|
||||
|
||||
## 修改内容
|
||||
|
||||
- `ccdi-lsfx` 支持 multipart 文件 part 显式指定 filename。
|
||||
- `ccdi-project` 本地上传流水文件链路转传流水分析平台时使用初始上传文件名。
|
||||
- 本地上传链路查询状态后不再使用平台返回文件名覆盖上传记录文件名。
|
||||
- 拉取本行信息链路保持原有文件名处理行为。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 影响项目详情“上传数据”中的本地流水文件上传。
|
||||
- 不影响历史上传记录。
|
||||
- 不影响拉取本行信息。
|
||||
- 不涉及前端源码和数据库结构变更。
|
||||
|
||||
## 验证情况
|
||||
|
||||
- `mvn -pl ccdi-lsfx,ccdi-project -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest,CcdiFileUploadServiceImplTest,CcdiFileUploadControllerTest -Dsurefire.failIfNoSpecifiedTests=false test`
|
||||
- `mvn -pl ccdi-lsfx,ccdi-project -am -DskipTests compile`
|
||||
- 真实页面验证:记录页面文件名、mock LSFX 接收 filename、测试数据清理和进程关闭结果。
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit final verification record**
|
||||
|
||||
Check the worktree, stage only the implementation record, then verify staged scope:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git add docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md
|
||||
git diff --cached --name-status
|
||||
```
|
||||
|
||||
Expected staged file: only the implementation record.
|
||||
|
||||
```bash
|
||||
git commit -m "文档: 记录上传流水文件名修复验证"
|
||||
```
|
||||
|
||||
## Final Review Checklist
|
||||
|
||||
- [ ] 本地上传流水文件转传 LSFX 时 multipart `files` part 的 filename 是初始上传文件名。
|
||||
- [ ] 本地上传记录 `file_name` 在解析成功或失败后仍是初始上传文件名。
|
||||
- [ ] 拉取本行信息链路仍按平台返回文件名更新记录,不被本次改动影响。
|
||||
- [ ] 无数据库结构变更。
|
||||
- [ ] 无前端源码变更。
|
||||
- [ ] 实施记录已包含测试、真实页面验证和进程清理结果。
|
||||
- [ ] 提交前 `git status --short` 无 `.DS_Store` 或无关文件。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
# 员工资产导入与员工亲属实体自动补入后端解决方案
|
||||
|
||||
## 背景
|
||||
|
||||
本方案针对以下两个已验证问题:
|
||||
|
||||
1. 【员工信息维护】和【员工亲属关系维护】使用双 Sheet 模板导入时,第二个 Sheet 的资产信息只按数据库已有主数据反查归属,不能识别同一个导入文件中刚导入的员工或亲属关系,导致提示“未找到资产归属员工”等错误。
|
||||
2. 【员工亲属实体关联】新增或导入关系人与企业关联后,只写入员工亲属实体关联表,没有同步生成实体库数据,导致企业名称和统一社会信用代码不能自动落入【实体库管理】。
|
||||
|
||||
本次解决方案只涉及后端,不调整前端页面结构、上传入口和模板样式。现有前端已经能接收员工任务 ID 与资产任务 ID,并分别轮询失败记录,因此后端需要保持现有返回字段兼容。
|
||||
|
||||
## 目标
|
||||
|
||||
- 双 Sheet 导入时,主 Sheet 和资产 Sheet 按同一个文件内的业务依赖顺序处理。
|
||||
- 资产 Sheet 可以关联到数据库已存在的员工或亲属主数据,也可以关联到同一文件中本轮成功导入的员工或亲属主数据。
|
||||
- 主 Sheet 校验失败的数据不能作为资产归属依据。
|
||||
- 员工亲属实体关联新增和导入成功后,缺失的企业自动补入实体库。
|
||||
- 实体库自动补入只插入缺失企业,不更新已存在实体。
|
||||
|
||||
## 方案一:员工信息维护双 Sheet 导入
|
||||
|
||||
### 当前问题链路
|
||||
|
||||
- `/ccdi/baseStaff/importData` 当前分别读取 `员工信息` 与 `员工资产信息` 两个 Sheet。
|
||||
- 有员工数据时调用 `baseStaffService.importBaseStaff(staffList)`,有资产数据时调用 `baseStaffAssetImportService.importAssetInfo(assetList)`。
|
||||
- 两个任务独立异步执行,员工资产任务只通过 `ccdi_base_staff.id_card` 查询数据库已有员工。
|
||||
- 当模板中员工和资产同时首次导入时,资产任务无法稳定看到本轮员工导入结果。
|
||||
|
||||
### 改造策略
|
||||
|
||||
保留接口路径、模板名称、返回结构和前端轮询模型,新增后端双 Sheet 编排能力。
|
||||
|
||||
1. 在员工导入服务中增加双 Sheet 提交方法:
|
||||
- 输入:`List<CcdiBaseStaffExcel> staffList`、`List<CcdiBaseStaffAssetInfoExcel> assetList`
|
||||
- 输出:`BaseStaffImportSubmitResultVO`
|
||||
- 有员工 Sheet 时生成 `staffTaskId`
|
||||
- 有资产 Sheet 时生成 `assetTaskId`
|
||||
|
||||
2. 新增一个统一异步编排方法,按顺序执行:
|
||||
- 初始化员工导入任务状态
|
||||
- 执行员工主数据校验和批量插入
|
||||
- 收集本轮员工导入成功的 `idCard`
|
||||
- 更新员工任务状态和失败记录
|
||||
- 初始化员工资产导入任务状态
|
||||
- 执行员工资产导入
|
||||
- 员工资产归属候选来源为:
|
||||
- 数据库已有 `ccdi_base_staff.id_card`
|
||||
- 本轮员工 Sheet 成功导入的 `idCard`
|
||||
- 更新员工资产任务状态和失败记录
|
||||
|
||||
3. 员工资产导入逻辑调整:
|
||||
- 保留“员工资产只允许员工本人身份证号”的业务规则。
|
||||
- 保留重复校验规则:`personId + assetMainType + assetSubType + assetName`。
|
||||
- `personId` 既可以命中数据库已有员工,也可以命中本轮成功导入员工。
|
||||
- 只命中员工 Sheet 失败行、且数据库中也不存在该身份证号时,资产行进入失败记录。
|
||||
|
||||
4. `/ccdi/baseStaff/importData` 改为调用新的双 Sheet 提交方法,不再由 Controller 分别提交两个互相独立的异步任务。
|
||||
|
||||
### 结果状态
|
||||
|
||||
- 只填员工 Sheet:只返回 `staffTaskId`,行为保持不变。
|
||||
- 只填员工资产 Sheet:只返回 `assetTaskId`,按数据库已有员工校验。
|
||||
- 两个 Sheet 都填写:返回 `staffTaskId` 与 `assetTaskId`,但后端在同一个编排任务中先处理员工再处理资产。
|
||||
|
||||
## 方案二:员工亲属关系维护双 Sheet 导入
|
||||
|
||||
### 当前问题链路
|
||||
|
||||
- `/ccdi/staffFmyRelation/importData` 当前分别读取 `员工亲属关系信息` 与 `亲属资产信息` 两个 Sheet。
|
||||
- 有亲属关系数据时调用 `relationService.importRelation(relationList)`,有亲属资产数据时调用 `assetInfoImportService.importAssetInfo(assetList)`。
|
||||
- 亲属资产导入只通过 `ccdi_staff_fmy_relation.relation_cert_no` 查询数据库已有亲属关系。
|
||||
- 当模板中亲属关系和亲属资产同时首次导入时,资产任务无法稳定看到本轮亲属关系导入结果。
|
||||
|
||||
### 改造策略
|
||||
|
||||
保留接口路径、模板名称、返回结构和前端轮询模型,新增员工亲属关系双 Sheet 编排能力。
|
||||
|
||||
1. 在员工亲属关系导入服务中增加双 Sheet 提交方法:
|
||||
- 输入:`List<CcdiStaffFmyRelationExcel> relationList`、`List<CcdiAssetInfoExcel> assetList`
|
||||
- 输出:`StaffFmyRelationImportSubmitResultVO`
|
||||
- 有亲属关系 Sheet 时生成 `relationTaskId`
|
||||
- 有亲属资产 Sheet 时生成 `assetTaskId`
|
||||
|
||||
2. 新增一个统一异步编排方法,按顺序执行:
|
||||
- 初始化亲属关系导入任务状态
|
||||
- 执行亲属关系校验和批量插入
|
||||
- 收集本轮成功导入且有效的亲属关系映射:
|
||||
- `relationCertNo` 作为资产 Sheet 的 `personId`
|
||||
- `personId` 作为资产落库的 `familyId`
|
||||
- 更新亲属关系任务状态和失败记录
|
||||
- 初始化亲属资产导入任务状态
|
||||
- 执行亲属资产导入
|
||||
- 亲属资产归属候选来源为:
|
||||
- 数据库已有有效员工亲属关系
|
||||
- 本轮亲属关系 Sheet 成功导入的有效员工亲属关系
|
||||
- 更新亲属资产任务状态和失败记录
|
||||
|
||||
3. 亲属资产导入逻辑调整:
|
||||
- 保留 `family_id = 员工身份证号`、`person_id = 亲属身份证号` 的落库规则。
|
||||
- `personId` 命中唯一亲属关系时导入成功。
|
||||
- `personId` 未命中数据库和本轮成功亲属关系时,失败原因为“未找到亲属资产归属员工”。
|
||||
- `personId` 命中多个员工归属时,失败原因为“亲属资产归属员工不唯一”。
|
||||
- 只命中亲属关系 Sheet 失败行、且数据库中也不存在有效亲属关系时,资产行进入失败记录。
|
||||
|
||||
4. `/ccdi/staffFmyRelation/importData` 改为调用新的双 Sheet 提交方法,不再由 Controller 分别提交两个互相独立的异步任务。
|
||||
|
||||
### 结果状态
|
||||
|
||||
- 只填亲属关系 Sheet:只返回 `relationTaskId`,行为保持不变。
|
||||
- 只填亲属资产 Sheet:只返回 `assetTaskId`,按数据库已有亲属关系校验。
|
||||
- 两个 Sheet 都填写:返回 `relationTaskId` 与 `assetTaskId`,但后端在同一个编排任务中先处理亲属关系再处理亲属资产。
|
||||
|
||||
## 方案三:员工亲属实体关联自动补入实体库
|
||||
|
||||
### 当前问题链路
|
||||
|
||||
- 员工亲属实体关联新增只写入 `ccdi_staff_enterprise_relation`。
|
||||
- 员工亲属实体关联导入成功行也只写入 `ccdi_staff_enterprise_relation`。
|
||||
- 当前源码中没有可复用的实体库自动补入服务实现。
|
||||
|
||||
### 改造策略
|
||||
|
||||
新增后端内部服务 `EnterpriseAutoFillService`,统一处理缺失企业的最小插入。
|
||||
|
||||
1. 新增服务文件:
|
||||
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java`
|
||||
|
||||
2. 服务方法:
|
||||
- `ensureExists(EnterpriseFillItem item)`
|
||||
- `ensureExistsBatch(List<EnterpriseFillItem> items)`
|
||||
|
||||
3. `EnterpriseFillItem` 字段:
|
||||
- `socialCreditCode`
|
||||
- `enterpriseName`
|
||||
- `entSource`
|
||||
- `dataSource`
|
||||
- `userName`
|
||||
|
||||
4. 插入规则:
|
||||
- 按 `socialCreditCode` 去重。
|
||||
- 先批量查询 `ccdi_enterprise_base_info` 已存在记录。
|
||||
- 已存在实体不更新。
|
||||
- 缺失实体最小插入:
|
||||
- `social_credit_code = 统一社会信用代码`
|
||||
- `enterprise_name = 企业名称`
|
||||
- `ent_source = EMP_RELATION`
|
||||
- `data_source = MANUAL` 或 `IMPORT`
|
||||
- `risk_level = NULL`
|
||||
- `create_by/update_by = 当前用户`
|
||||
- 不引入额外兜底字段,不改变实体库手工新增规则。
|
||||
|
||||
5. 接入员工亲属实体关联新增:
|
||||
- 在 `CcdiStaffEnterpriseRelationServiceImpl#insertRelation` 中,亲属有效性校验和组合查重通过后,写关联表前调用:
|
||||
- `entSource = EnterpriseSource.EMP_RELATION.getCode()`
|
||||
- `dataSource = DataSource.MANUAL.getCode()`
|
||||
|
||||
6. 接入员工亲属实体关联导入:
|
||||
- 在 `CcdiStaffEnterpriseRelationImportServiceImpl#importRelationAsync` 中,只对校验成功并即将插入的 `newRecords` 组装补入列表。
|
||||
- 批量写关联表前调用 `ensureExistsBatch`。
|
||||
- 失败行不进入实体库补入集合。
|
||||
- `dataSource = DataSource.IMPORT.getCode()`。
|
||||
|
||||
## 测试方案
|
||||
|
||||
### 单元测试
|
||||
|
||||
1. 员工信息维护双 Sheet:
|
||||
- 同一模板中员工 Sheet 新增员工,员工资产 Sheet 使用该员工身份证号,资产应导入成功。
|
||||
- 员工 Sheet 行校验失败,资产 Sheet 使用该失败员工身份证号且数据库不存在,资产应失败。
|
||||
- 只导资产 Sheet,数据库已有员工时资产成功。
|
||||
- 只导资产 Sheet,数据库无员工时资产失败。
|
||||
- 资产重复命中数据库或当前文件重复时失败。
|
||||
|
||||
2. 员工亲属关系维护双 Sheet:
|
||||
- 同一模板中亲属关系 Sheet 新增亲属,亲属资产 Sheet 使用该亲属证件号,资产应导入成功,并落库 `family_id = 员工身份证号`。
|
||||
- 亲属关系 Sheet 行校验失败,亲属资产 Sheet 使用该失败亲属证件号且数据库不存在,资产应失败。
|
||||
- 只导亲属资产 Sheet,数据库已有唯一有效亲属关系时资产成功。
|
||||
- 只导亲属资产 Sheet,数据库不存在亲属关系时资产失败。
|
||||
- 同一亲属证件号命中多个员工归属时资产失败。
|
||||
|
||||
3. 员工亲属实体关联自动补入:
|
||||
- 手工新增员工亲属实体关联成功时,缺失企业自动插入实体库,来源为 `EMP_RELATION`,数据来源为 `MANUAL`。
|
||||
- 手工新增时实体库已存在该统一社会信用代码,不更新实体库旧记录。
|
||||
- 导入成功行自动插入实体库,来源为 `EMP_RELATION`,数据来源为 `IMPORT`。
|
||||
- 导入失败行不插入实体库。
|
||||
- 同一批多个成功行引用同一统一社会信用代码,只补入一次。
|
||||
|
||||
### 接口验证
|
||||
|
||||
1. 调用 `/ccdi/baseStaff/importTemplate` 下载真实模板,构造:
|
||||
- `员工信息` 新员工
|
||||
- `员工资产信息` 引用该员工身份证号
|
||||
- 上传 `/ccdi/baseStaff/importData`
|
||||
- 轮询员工任务与资产任务,确认都成功
|
||||
- 查询 `ccdi_base_staff` 与 `ccdi_asset_info`,确认员工和资产落库
|
||||
|
||||
2. 调用 `/ccdi/staffFmyRelation/importTemplate` 下载真实模板,构造:
|
||||
- `员工亲属关系信息` 新亲属
|
||||
- `亲属资产信息` 引用该亲属证件号
|
||||
- 上传 `/ccdi/staffFmyRelation/importData`
|
||||
- 轮询亲属关系任务与亲属资产任务,确认都成功
|
||||
- 查询 `ccdi_staff_fmy_relation` 与 `ccdi_asset_info`,确认亲属关系和资产落库
|
||||
|
||||
3. 调用员工亲属实体关联新增接口:
|
||||
- 新增前确认 `ccdi_enterprise_base_info` 不存在该统一社会信用代码
|
||||
- 新增关联成功后查询实体库,确认自动生成企业记录
|
||||
- 校验 `ent_source = EMP_RELATION`
|
||||
|
||||
### 页面验证
|
||||
|
||||
完成后需要使用真实业务页面验证:
|
||||
|
||||
1. 【员工信息维护】页面下载模板,按模板填写员工和员工资产,上传后检查任务状态、失败记录和列表详情。
|
||||
2. 【员工亲属关系维护】页面下载模板,按模板填写亲属关系和亲属资产,上传后检查任务状态、失败记录和详情资产列表。
|
||||
3. 【员工亲属实体关联】页面新增关系人与企业关联后,进入【实体库管理】查询该统一社会信用代码,确认企业已自动生成且来源显示为员工关系人。
|
||||
|
||||
测试结束后清理本轮新增员工、亲属关系、资产、员工亲属实体关联和自动补入实体库数据,并关闭测试过程中启动的前后端进程。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 抽取员工导入和亲属关系导入的可复用执行方法,返回成功主数据上下文和失败记录。
|
||||
2. 新增员工信息维护双 Sheet 后端编排方法,接入 Controller。
|
||||
3. 新增员工亲属关系维护双 Sheet 后端编排方法,接入 Controller。
|
||||
4. 新增 `EnterpriseAutoFillService`。
|
||||
5. 员工亲属实体关联新增链路接入实体库自动补入。
|
||||
6. 员工亲属实体关联导入链路接入实体库自动补入。
|
||||
7. 补充单元测试。
|
||||
8. 执行接口验证和真实页面验证。
|
||||
9. 新增实施记录到 `docs/reports/implementation/`。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 双 Sheet 导入不能继续使用两个互相独立的异步任务,否则仍然存在主数据与资产任务执行顺序不确定的问题。
|
||||
- 资产归属上下文必须只使用“数据库已有数据”和“本轮主 Sheet 成功数据”,不能把失败主数据作为资产归属。
|
||||
- 实体库自动补入不能更新已存在企业,避免覆盖人工维护的企业名称、风险等级和来源信息。
|
||||
- 员工亲属实体关联自动补入必须只处理成功行,失败行不能产生实体库记录。
|
||||
@@ -1,26 +0,0 @@
|
||||
# Fund Graph Backend Implementation Plan
|
||||
|
||||
**目标:** 基于图谱结果表和手工补录边提供一期资金流图谱后端能力,支持按身份证号或员工姓名查询一层资金往来,并支持点击图谱边后分页查看该边每一笔流水。
|
||||
|
||||
**一期范围:**
|
||||
- 搜索条件:`projectId` 保留为历史字段但不参与过滤,`keyword` 支持身份证号精确匹配、员工姓名匹配。
|
||||
- 图谱范围:仅返回当前人员或姓名命中的直接对手方资金往来,不自动追溯二、三层。
|
||||
- 边明细:按图谱边的 `fromKey/toKey` 查询每笔流水,保留交易时间、本方、对手方、摘要、金额、方向。
|
||||
- 预留扩展:DTO/VO 保留 `depth`、`canTrace`、`canExpand` 字段,后续可以扩展节点点击追溯。
|
||||
|
||||
**实现内容:**
|
||||
- 新增 `CcdiFundGraphController`,暴露 `/ccdi/project/fund-graph/search`、`/ccdi/project/fund-graph/graph`、`/ccdi/project/fund-graph/edge-detail` 和 `/ccdi/project/fund-graph/manual-edge`。
|
||||
- 新增 `ICcdiFundGraphService` 与 `CcdiFundGraphServiceImpl`,负责查询参数归一化、TopN 限制、节点构建和追溯字段赋值。
|
||||
- 新增 `CcdiFundGraphMapper` 与 XML SQL,基于 `lx_fund_flow_*` 图谱结果表查询主体、汇总边、逐笔流水,并支持手工边落库。
|
||||
- 新增 DTO/VO:`CcdiFundGraphQueryDTO`、`CcdiFundGraphEdgeDetailQueryDTO`、`CcdiFundGraphVO`、`CcdiFundGraphNodeVO`、`CcdiFundGraphEdgeVO`、`CcdiFundGraphStatementVO`。
|
||||
|
||||
**数据口径:**
|
||||
- 一期查询不按 `projectId` 过滤,统一按全局图谱关系查询。
|
||||
- 资金边来自 `lx_fund_flow_detail_edge` 归并后的主体层汇总边,并叠加 `lx_fund_flow_manual_edge` 手工边。
|
||||
- 图谱边按 `fromKey/toKey/direction/familyRelationType` 聚合,统计累计金额、交易笔数、首末交易时间。
|
||||
- 手工边与真实边统一排序后再按 `limit` 截断,避免结果条数和排序口径不一致。
|
||||
|
||||
**后续追溯口子:**
|
||||
- 当前接口接收 `depth` 但服务层固定为一期一层。
|
||||
- 节点返回 `canExpand`,边返回 `canTrace`,后续可新增 `rootKey/currentNodeKey/depth` 参数做按节点展开。
|
||||
- 一期 SQL 已把人员、企业、账号、名称代理做成统一节点键,后续多层追溯可沿用同一套节点键。
|
||||
@@ -1,46 +0,0 @@
|
||||
# 外部人员预警后端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
在不改变结果总览员工主口径的前提下,新增外部人员预警查询与导出能力,使中介、客户、其他外部人员作为本方流水导入后,也能在结果总览中形成预警结果。
|
||||
|
||||
## 实施范围
|
||||
|
||||
1. 新增外部人员预警分页接口。
|
||||
2. 新增外部人员预警导出接口。
|
||||
3. 新增外部人员模型统计接口。
|
||||
4. 新增外部人员模型命中人员分页接口。
|
||||
5. 扩展涉疑交易明细筛选,支持外部人员相关流水。
|
||||
6. 补充外部人员预警测试数据 SQL。
|
||||
|
||||
## 业务口径
|
||||
|
||||
1. 本方 `cret_no` 命中员工身份证号时,归入员工。
|
||||
2. 本方 `cret_no` 命中员工亲属证件号时,归入员工亲属。
|
||||
3. 本方 `cret_no` 未命中员工和员工亲属时,归入外部人员。
|
||||
4. 外部人员 `cret_no` 命中中介库本人证件号时,主体类型为中介。
|
||||
5. 外部人员 `cret_no` 命中信贷客户证件号时,主体类型为客户。
|
||||
6. 其他外部人员统一显示为外部人员。
|
||||
7. 外部人员只跑交易和关系类模型,不套用员工资产、负面征信、岗位部门类模型。
|
||||
|
||||
## 模型范围
|
||||
|
||||
1. `EXTERNAL_LARGE_TRANSACTION`:外部人员大额交易。
|
||||
2. `EXTERNAL_ABNORMAL_TRANSACTION`:外部人员异常交易。
|
||||
3. `EXTERNAL_SUSPICIOUS_GAMBLING`:外部人员可疑赌博。
|
||||
4. `EXTERNAL_SUSPICIOUS_RELATION`:外部人员可疑关系。
|
||||
|
||||
## 数据实现
|
||||
|
||||
本轮采用直接聚合现有流水和标签结果的最短路径,不新增结果快照表:
|
||||
|
||||
1. 外部人员主体来自 `ccdi_bank_statement.cret_no`。
|
||||
2. 命中模型来自 `ccdi_bank_statement_tag_result` 中外部人员模型编码。
|
||||
3. 与员工或员工亲属的关系来自交易对手证件号、交易对手姓名、账户库命中结果。
|
||||
4. 中介识别优先使用中介库 `person_id`,不使用姓名作为主体识别依据。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 执行新增测试数据 SQL。
|
||||
2. 运行 `mvn test -pl ccdi-project` 相关测试或至少编译 `ccdi-project`。
|
||||
3. 启动后端后验证新增接口返回外部人员预警列表、模型统计和导出接口。
|
||||
@@ -1,29 +0,0 @@
|
||||
# 专项分析项目员工范围后端实施计划
|
||||
|
||||
## 背景
|
||||
|
||||
专项分析原先通过项目打标命中结果解析员工范围。未命中风险规则的项目员工不会展示在专项分析中,无法满足“项目内员工即使未命中也进行专项排查”的业务要求。
|
||||
|
||||
## 实施范围
|
||||
|
||||
- 修改专项分析公共员工范围 SQL。
|
||||
- 范围口径调整为:项目已入库银行流水 `ccdi_bank_statement` 中 `cret_no` 能匹配员工主数据 `ccdi_base_staff.id_card` 的员工。
|
||||
- 不新增前端入口、不新增人工纳入表、不伪造打标命中结果。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 调整 `CcdiProjectSpecialCheckMapper.xml` 中 `projectEmployeeScopeSql`。
|
||||
2. 使用 `ccdi_bank_statement.project_id` 和 `cret_no` 获取项目员工范围。
|
||||
3. 关联 `ccdi_base_staff` 获取员工姓名、柜员号、部门。
|
||||
4. 保持资产负债专项核查、采购拓展、招聘拓展、调动拓展共用同一员工范围。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 员工家庭资产负债专项核查列表与详情。
|
||||
- 专项分析下采购拓展、招聘拓展、调动拓展查询。
|
||||
- 结果总览风险人员统计不在本次调整范围内,仍按打标结果口径。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 执行 Maven 编译,确认 Mapper XML 与 Java 工程可编译。
|
||||
- 检查 SQL 引用字段存在且口径与项目目标人数回填逻辑一致。
|
||||
@@ -1,34 +0,0 @@
|
||||
# 员工信息维护与招聘信息管理正式化外壳前端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
- 基于当前本地最新代码,为 `ccdiBaseStaff` 与 `ccdiStaffRecruitment` 试套正式化外壳样式。
|
||||
- 仅调整查询区、工具条、表格区、分页区与弹窗壳层视觉。
|
||||
- 不改字段顺序、不改按钮位置、不改功能块结构。
|
||||
|
||||
## 范围
|
||||
|
||||
- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
|
||||
- `ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js`
|
||||
- `ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js`
|
||||
|
||||
## 方案
|
||||
|
||||
- 复用现有 `app-container`、`query-form`、`mb8`、弹窗 class,只补最少样式。
|
||||
- 给列表区新增最小表格外壳,保证分页和表格归一。
|
||||
- 通过边框、浅底、留白和表头背景统一正式化视觉。
|
||||
|
||||
## 验证
|
||||
|
||||
- `node tests/unit/base-staff-formal-shell-layout.test.js`
|
||||
- `node tests/unit/staff-recruitment-formal-shell-layout.test.js`
|
||||
- `node tests/unit/employee-asset-maintenance-layout.test.js`
|
||||
- `node tests/unit/staff-recruitment-import-toolbar.test.js`
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 两个页面外壳样式统一
|
||||
- 按钮顺序和功能入口保持不变
|
||||
- 单测通过
|
||||
- 浏览器实测通过
|
||||
@@ -1,65 +0,0 @@
|
||||
# 2026-04-29 批量正式化外壳样式实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
- 基于当前本地最新前端代码,批量推进信息维护相关页面的正式化外壳样式。
|
||||
- 严格保持“只改样式、不改内容和功能”的边界。
|
||||
- 复用已经在详情弹窗、员工信息维护页、招聘信息管理页验证过的正式化样式骨架。
|
||||
|
||||
## 范围
|
||||
|
||||
- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
|
||||
- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
|
||||
|
||||
## 实施策略
|
||||
|
||||
### 1. 查询区统一正式化
|
||||
|
||||
- 保留原有查询字段、排布逻辑和按钮位置。
|
||||
- 为查询区补统一白色面板、边框、克制圆角和更稳重的标签文字。
|
||||
- 收紧表单项底部留白,统一输入框、下拉框、日期控件的边框和圆角。
|
||||
|
||||
### 2. 工具条统一正式化
|
||||
|
||||
- 保留搜索、重置、新增、导入、失败记录入口及其相对位置。
|
||||
- 统一工具条外层白色承载区。
|
||||
- 按钮仅调整圆角、边框与视觉重量,不改变语义和行为。
|
||||
|
||||
### 3. 表格承载区统一正式化
|
||||
|
||||
- 新增或复用 `formal-table-shell` 包裹列表表格与分页区。
|
||||
- 收紧表头和行高,提升单屏信息密度。
|
||||
- 主体文本尽量左对齐,保留选择列和操作列居中。
|
||||
|
||||
### 4. 弹窗与详情区统一正式化
|
||||
|
||||
- 统一弹窗圆角、头部下边线、正文浅底。
|
||||
- 详情区、导入弹窗、编辑弹窗使用更克制的信息面板样式。
|
||||
- 不重排现有字段,不新增删减交互块。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 复用现有样式契约单测,确保已完成页面没有回退。
|
||||
- 使用浏览器打开真实业务路由进行验证,禁止使用 prototype 页面替代。
|
||||
- 核对关键页面是否保持:
|
||||
- 查询区与工具条仍在原位置
|
||||
- 新增、导入、失败记录按钮仍按原顺序出现
|
||||
- 表格列和弹窗内容结构不变
|
||||
|
||||
## 风险控制
|
||||
|
||||
- 不使用旧 patch 中的结构改法,只借用可复用的正式化视觉参数。
|
||||
- 每个页面只处理最外层承载和控件外观,不触碰业务字段、接口、校验、按钮逻辑。
|
||||
- 若真实页面路由可访问,则以真实页面结果为准;若不可访问,保留源码级验证说明。
|
||||
@@ -1,56 +0,0 @@
|
||||
# 结果总览项目分析详情正式化外壳前端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
- 基于 `output/mockups/project-analysis-formal-soft-preview.html` 的静态预览稿,恢复“项目分析详情”弹窗的正式化、去卡片化外壳样式。
|
||||
- 本次仅调整详情弹窗整体框架、标题区、左侧人物档案区、右侧主承载区与页签外层视觉。
|
||||
- 不修改“异常明细”页签内部业务结构、分页、按钮、接口与数据逻辑。
|
||||
|
||||
## 范围
|
||||
|
||||
- 修改 `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
|
||||
- 修改 `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
|
||||
- 修改 `ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js`
|
||||
- 修改 `ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js`
|
||||
|
||||
## 实施方案
|
||||
|
||||
### 1. 弹窗外壳正式化
|
||||
|
||||
- 将当前偏渐变、大圆角的详情弹窗外壳改为更平直的正式化工作台样式。
|
||||
- 顶部保留“结果总览 / 项目分析详情”的信息层级,但改成浅边线、弱装饰、明确留白的标题区。
|
||||
- 调整整体布局间距,让左侧档案区和右侧主区以纵向分隔线形成清晰结构。
|
||||
|
||||
### 2. 左侧档案区映射静态稿
|
||||
|
||||
- 保留当前姓名、风险等级、工号、部门、所属项目、命中模型数、核心异常标签的数据字段。
|
||||
- 通过信息头、字段列表、摘要区三段式样式,映射静态稿的人物档案视觉。
|
||||
- 不新增额外字段、不新增辅助业务区块。
|
||||
|
||||
### 3. 右侧主区外层收口
|
||||
|
||||
- 保持 `el-tabs`、错误提示、加载逻辑、默认页签逻辑不变。
|
||||
- 只调整页签外层、内容承载区、主区边界与留白,不进入各 tab 内部重做内容样式。
|
||||
|
||||
## 验证计划
|
||||
|
||||
### 代码校验
|
||||
|
||||
- 在 `ruoyi-ui` 目录执行:
|
||||
- `node tests/unit/project-analysis-dialog-layout.test.js`
|
||||
- `node tests/unit/project-analysis-dialog-sidebar.test.js`
|
||||
- `node tests/unit/project-analysis-dialog-empty-field.test.js`
|
||||
|
||||
### 浏览器验证
|
||||
|
||||
- 先通过 `nvm use` 确认前端 Node 版本。
|
||||
- 启动真实前端页面后,使用 `browser-use` 在系统真实页面打开“项目分析详情”弹窗。
|
||||
- 重点核对:
|
||||
- 标题区是否为正式化平直样式
|
||||
- 左侧档案区是否按预览稿形成清晰三段层次
|
||||
- 右侧主区是否只改外层、不影响“异常明细”内部内容与交互
|
||||
|
||||
## 风险控制
|
||||
|
||||
- 不改接口、不改 mock 数据、不改异常明细内部组件,避免把外壳样式改动扩大成业务结构调整。
|
||||
- 单测只更新与外层视觉契约直接相关的断言,避免引入无关回归。
|
||||
@@ -1,24 +0,0 @@
|
||||
# 2026-05-06 项目分析个人详情页样式对齐前端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
- 将项目分析个人详情页样式对齐到用户提供的参考图。
|
||||
- 本次只调整前端样式表现,不改接口、字段、交互逻辑和业务内容。
|
||||
|
||||
## 范围
|
||||
|
||||
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
|
||||
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
|
||||
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisAbnormalTab.vue`
|
||||
|
||||
## 实施要点
|
||||
|
||||
- 调整详情弹窗头部、左右分栏比例、页签尺寸和间距。
|
||||
- 调整左侧人物档案与命中模型摘要区块的标题、信息行、风险徽标和标签样式。
|
||||
- 调整右侧异常明细内容区的区块标题、表格头部、单元格留白、异常对象摘要卡片和快照块样式。
|
||||
- 保持现有数据绑定、页签切换、证据库按钮和分页逻辑不变。
|
||||
|
||||
## 验证
|
||||
|
||||
- 在真实业务页面打开项目总览详情弹窗,检查个人详情页视觉是否与参考图一致。
|
||||
- 确认异常明细、对象摘要、加入证据库按钮和分页仍可正常显示。
|
||||
@@ -1,29 +0,0 @@
|
||||
# Fund Graph Frontend Implementation Plan
|
||||
|
||||
**目标:** 在专项排查页签落地完整版图谱工作台,在项目分析弹窗内落地简版图谱展示,支持查看一层资金流和关系图谱,并通过点击资金边查看代表性流水。
|
||||
|
||||
**一期范围:**
|
||||
- 专项排查版保留搜索栏:身份证号/员工姓名、交易时间范围、最小汇总金额。
|
||||
- 项目分析弹窗版不提供搜索栏与手工新增入口,使用当前人员自动定位图谱。
|
||||
- 图谱区:用 ECharts force graph 展示人员、企业、账号代理、名称代理节点和有向资金边。
|
||||
- 汇总区:展示节点数、资金边数、交易笔数、汇总金额。
|
||||
- 明细区:点击任意边后展示累计金额、交易笔数、最近交易和关系标签。
|
||||
- 专项排查版保留分页逐笔流水;项目分析弹窗版仅展示最近 5 条代表性流水。
|
||||
|
||||
**实现内容:**
|
||||
- 新增 `src/api/ccdi/graph/fundGraph.js` 与 `src/api/ccdi/graph/relationGraph.js`,封装图谱接口。
|
||||
- 新增 `ProjectAnalysisFundFlowTab.vue`,承接项目分析弹窗内的简版图谱展示。
|
||||
- 新增 `graph/FundGraphSection.vue`,统一承载完整版和弹窗简版两种模式。
|
||||
- 修改 `ProjectAnalysisDialog.vue` 与 `SpecialCheck.vue`,分别接入简版与完整版图谱组件。
|
||||
|
||||
**交互口径:**
|
||||
- 打开页签时优先使用模型摘要或人员对象中的身份证号/姓名自动查询。
|
||||
- 专项排查版允许手工输入身份证号或员工姓名重新查询,并支持手工新增资金流向。
|
||||
- 项目分析弹窗版保留图、基础节点详情、边汇总和轻量明细,不保留搜索、手工新增、疑似企业弹层和复杂操作。
|
||||
- 默认展示 Top 20 资金边,避免一次渲染过多边影响交互。
|
||||
- 一期不自动展开追溯层级,节点“一层展开”通过追加一圈节点和边 merge 回现有图谱。
|
||||
|
||||
**后续追溯口子:**
|
||||
- 当前图谱节点已保留原始 `nodeKey` 和 `canExpand`。
|
||||
- 未来可在节点点击事件中调用后端追溯接口,把新增节点和边合并进现有图谱。
|
||||
- 组件已按一层查询和边明细查询拆分,后续追溯不会影响“点边看流水”的核心链路。
|
||||
@@ -1,21 +0,0 @@
|
||||
# 结果总览弹窗资金流向逐笔流水展示前端实施计划
|
||||
|
||||
## 需求范围
|
||||
|
||||
- 修改结果总览“查看详情”弹窗中的“资金流向”页签。
|
||||
- 去掉资金边详情中的“弹窗速览”提示文案。
|
||||
- 在弹窗资金边详情中展示逐笔流水明细,并保持分页加载。
|
||||
- 不修改专项排查页的图谱入口和完整图谱展示逻辑。
|
||||
|
||||
## 实施方案
|
||||
|
||||
1. 调整 `ProjectAnalysisFundFlowTab.vue` 中传给 `FundGraphSection` 的参数,开启资金边逐笔流水表格。
|
||||
2. 保持 `FundGraphSection` 现有边明细接口调用逻辑不变,继续使用分页查询。
|
||||
3. 在弹窗包装组件内改为上方图谱、下方逐笔流水布局,收敛表格和分页样式,避免逐笔流水表撑高或挤压图谱画布。
|
||||
4. 不新增接口、不修改后端、不改变专项排查页完整下钻能力。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 前端构建前按项目规则执行 `nvm use` 并确认 Node 版本。
|
||||
- 执行前端构建或聚焦测试,确认组件编译通过。
|
||||
- 使用真实页面打开结果总览“查看详情”弹窗,切换到“资金流向”,点击资金边确认下方显示逐笔流水和分页。
|
||||
@@ -1,36 +0,0 @@
|
||||
# 结果总览弹窗资金流向可用性优化前端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
- 优化结果总览“查看详情”弹窗内“资金流向”页签的图谱展示空间。
|
||||
- 降低多条资金边金额标签、节点名称在小画布中重叠的问题。
|
||||
- 在查看单个对手方资金边明细后,通过点击图谱画布空白区域恢复全量图谱状态。
|
||||
|
||||
## 实施范围
|
||||
|
||||
- 仅调整结果总览“查看详情”弹窗中的资金流向图谱。
|
||||
- 不调整专项排查页资金图谱的默认尺寸和业务逻辑。
|
||||
- 不修改后端接口、数据库和资金流水分页接口。
|
||||
|
||||
## 实施方案
|
||||
|
||||
1. 在 `ProjectAnalysisFundFlowTab` 中扩大资金流向工作区尺寸:
|
||||
- 提高弹窗内图谱卡片高度。
|
||||
- 改为上方图谱、下方逐笔流水布局,给图谱保留更大画布空间。
|
||||
- 下方逐笔流水表格保持固定高度和分页展示,避免撑高弹窗。
|
||||
|
||||
2. 在 `FundGraphSection` 中增加弹窗可配置能力:
|
||||
- 增加边标签紧凑展示开关,金额使用“万/亿”等短格式展示。
|
||||
- 支持隐藏资金边汇总卡片,只保留逐笔流水明细。
|
||||
- 点击图谱画布空白区域时清空选中节点、选中边和逐笔流水明细,并重新渲染图谱。
|
||||
|
||||
3. 优化选中状态表达:
|
||||
- 图谱中当前边和两端节点保持高亮,其他边降低透明度。
|
||||
- 点空白区域后恢复初始全量图谱状态,不额外增加按钮。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- 执行前端构建,确认无编译错误。
|
||||
- 在真实页面进入结果总览“查看详情”弹窗,切换到“资金流向”。
|
||||
- 选择包含多笔交易金额标签的资金边,验证节点名称和金额标签不再严重叠加,逐笔流水显示在图谱下方。
|
||||
- 点击图谱画布空白区域,验证逐笔流水清空,图谱恢复全量状态。
|
||||
@@ -1,47 +0,0 @@
|
||||
# 外部人员预警前端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
在结果总览页面保持员工为主的展示结构,新增同风格、靠后的“外部人员预警”入口,并补齐列表、详情、模型联动和导出交互。
|
||||
|
||||
## 页面结构
|
||||
|
||||
1. 风险总览卡片结构不变。
|
||||
2. 风险人员区域新增弱 Tab:
|
||||
- 员工风险人员
|
||||
- 外部人员预警
|
||||
3. 默认选中员工风险人员。
|
||||
4. 外部人员预警表格不展示工号、部门、资产分析、负面征信等员工专属字段。
|
||||
5. 外部人员操作按钮显示“查看交易”。
|
||||
|
||||
## 外部人员表格字段
|
||||
|
||||
1. 姓名。
|
||||
2. 证件号。
|
||||
3. 主体类型:中介、客户、外部人员。
|
||||
4. 风险等级。
|
||||
5. 命中模型数。
|
||||
6. 核心异常点。
|
||||
7. 涉及对象。
|
||||
8. 最近交易时间。
|
||||
9. 操作。
|
||||
|
||||
## 风险模型
|
||||
|
||||
1. 员工风险模型保持现有展示。
|
||||
2. 在模型区域后置展示外部人员预警模型。
|
||||
3. 选择外部人员模型时,下方命中人员列表切换为外部人员字段。
|
||||
4. 导出按钮按当前选中的模型范围导出命中明细。
|
||||
|
||||
## 导出
|
||||
|
||||
1. 风险人员区域导出按当前 Tab 决定导出员工或外部人员。
|
||||
2. 外部人员导出文件名为 `风险人员总览_外部人员预警_<项目ID>_<时间>.xlsx`。
|
||||
3. 风险模型导出导出当前筛选模型命中明细。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 使用真实页面验证员工 Tab 默认展示不变。
|
||||
2. 切换外部人员预警 Tab,确认字段和分页正常。
|
||||
3. 选择外部人员模型,确认模型命中人员列表切换正常。
|
||||
4. 点击导出,确认请求触发和文件下载。
|
||||
@@ -1,27 +0,0 @@
|
||||
# 外部人员详情加载态修正前端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
修正结果总览外部人员“查看详情”弹窗打开时先显示前端拼装的笼统对象异常、再刷新为流水异常明细的问题。
|
||||
|
||||
## 实施范围
|
||||
|
||||
- 页面:项目详情 > 结果总览 > 外部人员预警 > 查看详情。
|
||||
- 文件:`ruoyi-ui/src/views/ccdiProject/components/detail/ExternalPersonDetailDialog.vue`。
|
||||
- 不调整后端接口。
|
||||
|
||||
## 实施内容
|
||||
|
||||
1. 打开弹窗并加载流水异常明细时,先清空上一轮 `statementRows`。
|
||||
2. `detailLoading` 为 `true` 时不渲染 `ProjectAnalysisAbnormalTab`。
|
||||
3. 加载期间仅展示独立加载区域。
|
||||
4. 流水接口返回后再渲染异常明细内容。
|
||||
5. 对于未匹配到单笔流水的对象级规则,加载完成后保留“对象异常明细”分组展示。
|
||||
|
||||
## 验证要点
|
||||
|
||||
1. 进入真实结果总览外部人员预警列表。
|
||||
2. 点击“查看详情”。
|
||||
3. 确认加载期间不出现发白的“对象异常明细”内容。
|
||||
4. 确认加载完成后展示流水异常明细。
|
||||
5. 确认总流水超限等对象级规则在加载完成后仍可显示为“对象异常明细”。
|
||||
@@ -1,26 +0,0 @@
|
||||
# 外部人员预警列表字段语义修正前端实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
修正结果总览“外部人员预警”列表字段展示语义,避免将模型、规则和涉及对象混用。
|
||||
|
||||
## 实施范围
|
||||
|
||||
- 页面:项目详情 > 结果总览 > 外部人员预警。
|
||||
- 文件:`ruoyi-ui/src/views/ccdiProject/components/detail/RiskPeopleSection.vue`。
|
||||
- 不新增接口,不修改后端查询口径。
|
||||
|
||||
## 实施内容
|
||||
|
||||
1. 移除外部人员列表中的“命中模型数 / 命中模型”列。
|
||||
2. “核心异常点”继续展示 `riskPointTagList[].ruleName`,即规则名称。
|
||||
3. “涉及对象”继续展示后端 `relatedObject`,仅表示交易对手方对象类型。
|
||||
4. 员工风险人员列表不调整。
|
||||
|
||||
## 验证要点
|
||||
|
||||
1. 进入真实项目详情结果总览。
|
||||
2. 切换到“外部人员预警”Tab。
|
||||
3. 确认外部人员列表不再显示“命中模型数 / 命中模型”列。
|
||||
4. 确认“核心异常点”显示规则名称。
|
||||
5. 确认“涉及对象”不再承担模型或规则展示语义。
|
||||
@@ -1,609 +0,0 @@
|
||||
# 图谱开发决策记录
|
||||
|
||||
记录当前已确认的资金流图谱和关系图谱开发口径,作为后续开发、验收和跨对话延续的依据。
|
||||
|
||||
## 1. 页面嵌入位置
|
||||
|
||||
- 图谱功能先嵌入项目详情页的“专项排查”页签。
|
||||
- 现有前端入口为 `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`。
|
||||
- 页面在项目详情内承载,但资金流图谱本身不按 `project_id` 过滤。
|
||||
- 查询入口以全局身份证号 `cret_no` 或员工姓名为准。
|
||||
|
||||
## 2. 图谱表结构原则
|
||||
|
||||
- 建表逻辑尽量保持图谱平台已验证过的 SQL 逻辑。
|
||||
- 不重新设计统一点表/边表。
|
||||
- 表名保留图谱平台 SQL 中的五张结果表:
|
||||
- `lx_fund_flow_subject_node`
|
||||
- `lx_fund_flow_account_node`
|
||||
- `lx_fund_flow_own_account_edge`
|
||||
- `lx_fund_flow_detail_edge`
|
||||
- `lx_fund_flow_sum_edge`
|
||||
- 不在五张图谱表中增加 `project_id` 作为查询过滤口径。
|
||||
- 一条流水可能存在于多个项目中,资金流图谱按全局资金关系构建,避免项目维度导致重复建点或重复算边。
|
||||
|
||||
## 3. 五张表职责
|
||||
|
||||
| 表名 | 作用 |
|
||||
| --- | --- |
|
||||
| `lx_fund_flow_subject_node` | 主体点,表示人员、企业、名称代理主体 |
|
||||
| `lx_fund_flow_account_node` | 账户点,表示具体账号或名称代理账户 |
|
||||
| `lx_fund_flow_own_account_edge` | 主体到账户的持有关系 |
|
||||
| `lx_fund_flow_detail_edge` | 账户层逐笔资金交易边 |
|
||||
| `lx_fund_flow_sum_edge` | 主体层资金汇总边,前端默认展示 |
|
||||
|
||||
关键点:
|
||||
|
||||
- 一个人可能有多个账号,所以必须保留主体点、账户点、持有边三层结构。
|
||||
- 前端默认展示主体层汇总边,不默认展示全部账户层明细边,避免节点过多。
|
||||
- 点击汇总边后,再查询账户层逐笔流水。
|
||||
|
||||
## 4. 构建逻辑
|
||||
|
||||
构建逻辑以 `tupu/资金流图谱代码/lanxi_liushui_no_relation_simplified.sql` 为主。
|
||||
|
||||
重要口径:
|
||||
|
||||
- 项目内 SQL 尽量和图谱平台原 SQL 保持一致。
|
||||
- 先导入一部分“所有员工流水明细”作为图谱基座。
|
||||
- 这部分基座数据视为已验证、绝对正确,不应被后续构建流程随意清空或覆盖。
|
||||
- 后续从 `ccdi_bank_statement` 拉取新增流水时,需要先和图谱基座做一致性判断。
|
||||
- 如果 `ccdi_bank_statement` 中的流水已经能在图谱基座中匹配到一致流水,则不同步进图谱,避免重复计入。
|
||||
- 如果没有匹配到一致流水,则按原图谱 SQL 逻辑增量插入图谱。
|
||||
|
||||
保留的核心口径:
|
||||
|
||||
- 本方证件号 `cret_no` 必须存在。
|
||||
- 对手方名称必须存在,空值、空串、`0` 过滤。
|
||||
- 金额必须有效,支出和收入统一成 `amount` 与 `flag`。
|
||||
- 支出 `flag = 1`,收入 `flag = 2`。
|
||||
- 明细去重,避免重复流水导致金额和笔数翻倍。
|
||||
- 同名归并只作用于主体层,不改变账户节点和账户明细边。
|
||||
- 无账号但有名称的对手方,按原 SQL 逻辑生成名称代理账户和名称代理主体。
|
||||
|
||||
一期先不展开追溯能力。需要为后续追溯预留字段和逻辑口子:
|
||||
|
||||
- `source_table`
|
||||
- `penetrate_level`
|
||||
- 后续可扩展 `FIRST`、`LEVEL1` 等来源。
|
||||
|
||||
## 4.1 基座与增量同步口径
|
||||
|
||||
图谱表不是“清空重建”的临时结果表,而是承载已验证图谱基座和后续增量的正式结果表。
|
||||
|
||||
基座数据:
|
||||
|
||||
- 来源为先导入的所有员工流水明细。
|
||||
- 按原图谱平台 SQL 生成五张 `lx_*` 表。
|
||||
- 基座数据作为可信结果保留。
|
||||
|
||||
增量数据:
|
||||
|
||||
- 来源为后续 `ccdi_bank_statement`。
|
||||
- 拉取后先按原 SQL 的流水标准化和去重口径生成候选流水。
|
||||
- 候选流水和既有 `lx_fund_flow_detail_edge` 做一致性比对。
|
||||
- 已存在一致流水时跳过,不插入图谱。
|
||||
- 不存在一致流水时,再增量生成账户点、主体点、持有边、明细边和汇总边。
|
||||
|
||||
一致性比对建议使用稳定业务特征,而不是 `project_id`:
|
||||
|
||||
- 本方账号
|
||||
- 本方户名
|
||||
- 对手方账号
|
||||
- 对手方户名
|
||||
- 交易日期
|
||||
- 金额
|
||||
- 收支方向 `flag`
|
||||
- 摘要 `user_memo`
|
||||
- 银行流水号或交易流水号,如果有
|
||||
|
||||
增量插入要求:
|
||||
|
||||
- 点表按 `object_key` 去重插入。
|
||||
- 持有边按 `object_key` 去重插入。
|
||||
- 明细边先判重,未存在才插入。
|
||||
- 汇总边需要按主体对和方向重新聚合或局部 upsert,不能简单追加导致金额翻倍。
|
||||
|
||||
## 4.2 ODPS 基座同步到 MySQL
|
||||
|
||||
当前真实部署口径:
|
||||
|
||||
- 原图谱 SQL 已在 ODPS 中有一份结果。
|
||||
- ODPS 结果只涉及行内流水。
|
||||
- ODPS 已经产出五张图谱结果表。
|
||||
- 可以先将 ODPS 中五张结果表一次性同步到纪检 MySQL。
|
||||
- MySQL 同步建表脚本记录在 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`。
|
||||
- 生产数据库表结构变更由人工单独执行,不跟随测试环境或应用发布自动更新。
|
||||
- 项目内保留 SQL 文件,用于本地验证、评审和生产手动执行参考。
|
||||
|
||||
同步后的 MySQL 五张表继续使用原图谱表名:
|
||||
|
||||
- `lx_fund_flow_subject_node`
|
||||
- `lx_fund_flow_account_node`
|
||||
- `lx_fund_flow_own_account_edge`
|
||||
- `lx_fund_flow_detail_edge`
|
||||
- `lx_fund_flow_sum_edge`
|
||||
|
||||
同步要求:
|
||||
|
||||
- ODPS 到 MySQL 首次同步只做基座装载。
|
||||
- 基座装载完成后,后续不再通过清空五张表重建处理。
|
||||
- ODPS 字段和 MySQL 字段同名的按显式字段列表导入。
|
||||
- MySQL 侧新增字段如 `family_relation_type`、`summary_object_key`、`source_table`、`penetrate_level`、`bank_statement_id`、`bank_trx_number` 可为空。
|
||||
- `lx_fund_flow_sum_edge.detail_ids` 在 MySQL 中使用 `LONGTEXT` 接收 ODPS ARRAY 同步后的 JSON 或字符串表示,前后端查询不强依赖该字段。
|
||||
|
||||
## 4.3 MySQL 后续增量方式
|
||||
|
||||
后续新增数据都在纪检 MySQL 内处理:
|
||||
|
||||
- 来源表为 `ccdi_bank_statement`。
|
||||
- 增量逻辑从 `ccdi_bank_statement` 抽取候选流水。
|
||||
- 候选流水按原图谱 SQL 口径标准化、过滤、生成 object_key。
|
||||
- 先和既有 `lx_fund_flow_detail_edge` 做一致性判重。
|
||||
- 已存在一致流水时不插入图谱。
|
||||
- 不存在一致流水时,才增量插入点、账户、持有边、明细边。
|
||||
- 汇总边 `lx_fund_flow_sum_edge` 按主体对和方向重新聚合或局部 upsert。
|
||||
|
||||
调度建议:
|
||||
|
||||
- 一期建议做每日定时调度,不建议一开始做实时。
|
||||
- 推荐使用 RuoYi/Quartz 定时任务,每天凌晨或低峰期执行。
|
||||
- 同时保留后台手动触发能力,便于首次补跑、排查和修复。
|
||||
- 实时同步不是不能做,但没有必要优先做;实时会增加事务、锁、重复判断和汇总边更新复杂度。
|
||||
|
||||
推荐执行节奏:
|
||||
|
||||
1. ODPS 五张结果表一次性同步到 MySQL。
|
||||
2. MySQL 跑一次家庭关系补充和 `summary_object_key` 回填。
|
||||
3. 每日 Quartz 调度从 `ccdi_bank_statement` 抽取新增候选流水。
|
||||
4. 候选流水与 `lx_fund_flow_detail_edge` 判重。
|
||||
5. 未命中重复的流水增量入图。
|
||||
6. 更新对应主体层汇总边。
|
||||
7. 前端始终只基于 MySQL 五张 `lx_*` 图谱表查询展示。
|
||||
|
||||
## 4.4 数据库变更执行边界
|
||||
|
||||
数据库表结构改动属于生产库手工变更,不纳入测试环境自动更新。
|
||||
|
||||
后续开发分工:
|
||||
|
||||
- SQL 文件由代码库保留,作为生产手工执行依据和本地验证依据。
|
||||
- 生产执行由人工确认后单独处理。
|
||||
- 后端开发默认这些表在目标库中已经存在。
|
||||
- 前端开发不感知数据库变更,只调用后端接口。
|
||||
- 测试环境如没有这五张表,需要手动执行 SQL 后再联调。
|
||||
- 应用发布包不自动执行这些 DDL。
|
||||
|
||||
## 5. 家庭关系
|
||||
|
||||
家庭关系是本次项目内新增能力,参考 `tupu/资金流图谱代码/资金流图谱_家庭关系补充.sql`。
|
||||
|
||||
处理原则:
|
||||
|
||||
- 不改变资金方向。
|
||||
- 不改变主体归并逻辑。
|
||||
- 不改变账户层明细边生成逻辑。
|
||||
- 只在资金边上增加家庭关系标注。
|
||||
|
||||
匹配规则:
|
||||
|
||||
- 交易任意一侧可映射到员工主体。
|
||||
- 员工主体必须有身份证号。
|
||||
- 对手方姓名命中 `ccdi_staff_fmy_relation.relation_name`。
|
||||
- 同一员工和同一关系人姓名只有一个 `relation_type` 时才标注。
|
||||
- 多个关系类型冲突时不打标,避免误判。
|
||||
|
||||
建议补充字段:
|
||||
|
||||
- `lx_fund_flow_detail_edge.family_relation_type`
|
||||
- `lx_fund_flow_sum_edge.family_relation_type`
|
||||
|
||||
## 6. 查询逻辑
|
||||
|
||||
搜索主体:
|
||||
|
||||
```sql
|
||||
select *
|
||||
from lx_fund_flow_subject_node
|
||||
where idnocfno = #{keyword}
|
||||
or name like concat('%', #{keyword}, '%');
|
||||
```
|
||||
|
||||
查询主体层资金图:
|
||||
|
||||
```sql
|
||||
select *
|
||||
from lx_fund_flow_sum_edge
|
||||
where from_key = concat('idno_node/', #{objectKey})
|
||||
or to_key = concat('idno_node/', #{objectKey})
|
||||
order by amount desc, total_trans_cnt desc
|
||||
limit #{limit};
|
||||
```
|
||||
|
||||
点击汇总边查询逐笔流水:
|
||||
|
||||
```sql
|
||||
select *
|
||||
from lx_fund_flow_detail_edge
|
||||
where summary_object_key = #{sumObjectKey}
|
||||
order by trx_date desc
|
||||
limit #{offset}, #{pageSize};
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 原图谱平台 `detail_ids` 可以保留。
|
||||
- MySQL 分页查询建议增加 `summary_object_key`,用于从汇总边直接查明细边。
|
||||
- `summary_object_key` 是查询优化字段,不改变原图谱平台点边模型。
|
||||
|
||||
## 7. 前后端开发边界
|
||||
|
||||
后端负责:
|
||||
|
||||
- 从五张 `lx_*` 图谱结果表读取数据。
|
||||
- 按身份证号或姓名定位主体。
|
||||
- 返回主体层图谱节点和汇总边。
|
||||
- 支持点击资金汇总边分页查询逐笔流水。
|
||||
- 透出家庭关系字段。
|
||||
- 空表或脏数据时返回空结果,不让前端报错。
|
||||
|
||||
前端负责:
|
||||
|
||||
- 在“专项排查”页签中呈现图谱展示区域。
|
||||
- 支持身份证号或员工姓名搜索。
|
||||
- 支持“资金图谱 / 关系图谱”页签。
|
||||
- 一期资金图谱做实,关系图谱可先保留入口。
|
||||
- 默认展示主体层资金汇总图。
|
||||
- 点击边展示逐笔流水明细。
|
||||
- 展示图谱明细边中已写入的家庭关系标签,如配偶、父母、子女。
|
||||
|
||||
## 8. 基座保护与异常数据处理
|
||||
|
||||
图谱表承载已验证基座,不按“可随意清空重建”设计。人工可以维护或清理异常数据,但默认应保护已有基座。
|
||||
|
||||
处理要求:
|
||||
|
||||
- 后续增量同步不得清空五张 `lx_*` 表。
|
||||
- 增量同步前必须先判断候选流水是否已存在于 `lx_fund_flow_detail_edge`。
|
||||
- 已存在一致流水时跳过,避免重复金额和重复笔数。
|
||||
- 人工清理异常边后,后端查询需要能容忍局部缺失数据。
|
||||
- 边表有、点表缺失时,后端过滤无法匹配节点的边,不让前端报错。
|
||||
- 明细边为空时,点击汇总边提示暂无逐笔流水。
|
||||
- 如果人工确实清理了部分图谱数据,后续增量插入仍需按 `object_key` 和流水一致性规则防重。
|
||||
|
||||
## 9. UI 风格
|
||||
|
||||
固定设计口径:
|
||||
|
||||
- 浅色系统风格。
|
||||
- 正式后台质感。
|
||||
- 与当前纪检系统色调保持一致,蓝、白、灰为主。
|
||||
- 朝图谱平台式交互靠齐。
|
||||
- 不做黑色大屏。
|
||||
- 不做网感、霓虹、炫光科技风。
|
||||
- 图谱画布使用浅灰白背景,边界清楚。
|
||||
- 搜索区放在顶部,支持身份证号和姓名。
|
||||
- 页签为“资金图谱”和“关系图谱”。
|
||||
- 明细使用右侧抽屉或下方面板展示,优先保证字段清楚和分页性能。
|
||||
|
||||
## 10. 后续开发顺序
|
||||
|
||||
建议顺序:
|
||||
|
||||
1. 先落项目内 SQL:DDL、构建 SQL、家庭关系补充 SQL、索引 SQL。
|
||||
2. 先支持已导入员工流水明细作为图谱基座。
|
||||
3. 增加 `ccdi_bank_statement` 到图谱表的增量同步逻辑。
|
||||
4. 增量同步必须先和既有 `lx_fund_flow_detail_edge` 做一致性判重,已存在则不同步。
|
||||
5. 后端接口改为读取五张 `lx_*` 图谱表。
|
||||
6. 前端在“专项排查”页签接入图谱展示区域。
|
||||
7. 完成资金图谱搜索、展示、点击边查明细。
|
||||
8. 增加家庭关系标签展示。
|
||||
9. 验证基座保护、增量防重、一个人多个账号、家庭关系命中等场景。
|
||||
|
||||
## 11. 当前代码进度与偏差
|
||||
|
||||
截至 2026-05-28,项目内已经做过一版一期资金流图谱代码,但这版实现口径与当前最终方案不完全一致,后续需要重构而不是直接当最终版。
|
||||
|
||||
已完成过的代码:
|
||||
|
||||
- 后端新增 `CcdiFundGraphController`,接口路径为 `/ccdi/project/fund-graph/graph` 和 `/ccdi/project/fund-graph/edge-detail`。
|
||||
- 后端新增 DTO/VO:`CcdiFundGraphQueryDTO`、`CcdiFundGraphEdgeDetailQueryDTO`、`CcdiFundGraphVO`、`CcdiFundGraphNodeVO`、`CcdiFundGraphEdgeVO`、`CcdiFundGraphStatementVO`。
|
||||
- 后端新增 Mapper 和 Service:`CcdiFundGraphMapper`、`ICcdiFundGraphService`、`CcdiFundGraphServiceImpl`、`CcdiFundGraphMapper.xml`。
|
||||
- 前端新增接口文件 `ruoyi-ui/src/api/ccdi/fundGraph.js`。
|
||||
- 前端新增组件 `ProjectAnalysisFundFlowTab.vue`。
|
||||
- 前端已在 `ProjectAnalysisDialog.vue` 中接入资金流图谱页签。
|
||||
|
||||
旧版已验证情况:
|
||||
|
||||
- `mvn -pl ccdi-project -am compile -DskipTests` 通过。
|
||||
- `npm run build:prod` 通过。
|
||||
- 真实库只读校验过项目 33 和姓名样例,能查出资金边,点击边能查逐笔流水。
|
||||
- 曾修正 MySQL 8 保留词别名 `rows` 和字符集排序规则不一致问题。
|
||||
|
||||
当前偏差:
|
||||
|
||||
- 旧版接口是实时从 `ccdi_bank_statement` 聚合资金图谱,不读取五张 `lx_*` 图谱结果表。
|
||||
- 旧版查询带项目上下文,当前最终口径是不按 `project_id` 过滤,以全局 `cret_no` 或姓名为入口。
|
||||
- 旧版前端接在项目分析弹窗 `ProjectAnalysisDialog`,当前最终入口应放在“专项排查”页签。
|
||||
- 旧版没有按图谱平台五表基座和增量同步口径处理。
|
||||
- 旧版没有家庭关系标注。
|
||||
|
||||
后续处理原则:
|
||||
|
||||
- 可复用旧版的图谱展示、点击边查明细、分页表格等前端交互经验。
|
||||
- 可复用旧版 DTO/VO 中适合前端展示的字段,但字段来源需要改为五张 `lx_*` 表。
|
||||
- 后端 SQL 必须从实时聚合 `ccdi_bank_statement` 改为读取 `lx_fund_flow_subject_node`、`lx_fund_flow_account_node`、`lx_fund_flow_own_account_edge`、`lx_fund_flow_detail_edge`、`lx_fund_flow_sum_edge`。
|
||||
- 前端入口需要从项目分析弹窗迁移或重做到“专项排查”页签。
|
||||
- 旧版文件在重构时应谨慎处理,避免影响当前项目分析弹窗已有功能。
|
||||
|
||||
## 12. 页面查询与汇总表最新决策
|
||||
|
||||
最新决策:
|
||||
|
||||
- 纪检平台资金流图谱页面不强依赖 `lx_fund_flow_sum_edge`。
|
||||
- 页面查询以 `lx_fund_flow_detail_edge` 为事实表,由后端按当前查询条件实时聚合。
|
||||
- 前端不做金额和笔数聚合,只负责渲染后端返回的节点、边和明细。
|
||||
- `lx_fund_flow_sum_edge` 如生产侧不需要兼容图谱平台页面,可以不作为纪检页面必需表。
|
||||
- 如果 ODPS 已有 `lx_fund_flow_sum_edge`,可以选择不同步到 MySQL,或同步后仅作为参考缓存,不作为纪检页面查询依据。
|
||||
|
||||
原因:
|
||||
|
||||
- 用户每次查询通常以一个人为中心,一跳图谱范围可控。
|
||||
- 用户需要按 `trx_date` 任意筛选时间范围。
|
||||
- 全量 `lx_fund_flow_sum_edge` 不能准确表达任意时间段内的金额和笔数。
|
||||
- 每天新增明细后维护汇总表会增加复杂度。
|
||||
- 后端从明细边实时聚合能保证筛选结果准确,且比前端聚合更可靠。
|
||||
|
||||
后端聚合口径:
|
||||
|
||||
1. 用身份证号、姓名或 `object_key` 定位主体点。
|
||||
2. 查询该主体名下账户。
|
||||
3. 从 `lx_fund_flow_detail_edge` 查询这些账户相关流水。
|
||||
4. 按 `trx_date`、金额、方向、家庭关系等筛选条件过滤。
|
||||
5. 后端将账户层明细边聚合为主体层资金边。
|
||||
6. 返回前端用于图谱展示。
|
||||
|
||||
时间筛选字段:
|
||||
|
||||
- 所有时间筛选基于 `lx_fund_flow_detail_edge.trx_date`。
|
||||
- 不用 `lx_fund_flow_sum_edge.first_trx_date` 或 `lastest_trx_date` 判断筛选结果。
|
||||
|
||||
## 13. 节点穿透最新决策
|
||||
|
||||
节点穿透以 `lx_fund_flow_subject_node.object_key` 为唯一标识,不按姓名穿透。
|
||||
|
||||
口径:
|
||||
|
||||
- 实名主体按原 SQL 逻辑生成 `object_key`,即 `md5(trim(idnocfno))`。
|
||||
- 用户点击节点后,可选择“以此节点为中心查询”或“展开此节点”。
|
||||
- 默认不自动穿透,避免图谱过长和误展开。
|
||||
- 后端按被选节点的 `object_key` 查询其账户和流水。
|
||||
- 节点是否可穿透由后端返回 `canExpand` 控制。
|
||||
|
||||
允许穿透:
|
||||
|
||||
- 有明确身份证号或证件号的实名主体。
|
||||
- 有明确账户归属、能通过 `lx_fund_flow_own_account_edge` 找到账户的主体。
|
||||
|
||||
默认不穿透:
|
||||
|
||||
- 只有名称、没有证件号、没有明确账户归属的名称代理主体。
|
||||
- 无法通过 `object_key` 准确定位账户集合的节点。
|
||||
|
||||
交互建议:
|
||||
|
||||
- 一期做“设为中心查询”,即点击节点后重新以该节点为中心画一跳图。
|
||||
- 后续再做“在当前图上追加展开”,避免一期图谱状态管理过复杂。
|
||||
|
||||
## 14. 最终减法版决策
|
||||
|
||||
本节覆盖前文早期关于五张表、`lx_fund_flow_sum_edge`、关系图谱页签的旧设想。后续开发以本节为准。
|
||||
|
||||
本节为 2026-05-28 资金流图谱减法版决策,重点是不依赖 `lx_fund_flow_sum_edge`。关于关系图谱页签,后续已在第 16 节更新为“保留关系图谱能力”,以第 16 节为准。
|
||||
|
||||
必要表:
|
||||
|
||||
- `lx_fund_flow_subject_node`
|
||||
- `lx_fund_flow_account_node`
|
||||
- `lx_fund_flow_own_account_edge`
|
||||
- `lx_fund_flow_detail_edge`
|
||||
|
||||
不依赖:
|
||||
|
||||
- `lx_fund_flow_sum_edge`
|
||||
|
||||
页面查询:
|
||||
|
||||
- 输入身份证号、姓名或点击节点 `object_key`。
|
||||
- 后端定位主体点。
|
||||
- 后端查询主体持有账户。
|
||||
- 后端从 `lx_fund_flow_detail_edge` 按账户、`trx_date`、金额等条件实时聚合资金边。
|
||||
- 前端只渲染后端返回的资金节点、资金边和逐笔明细。
|
||||
|
||||
家庭关系:
|
||||
|
||||
- 只作为资金流图谱中的标签展示。
|
||||
- 家庭关系识别在图谱构建或数据加工阶段完成,并写入 `lx_fund_flow_detail_edge.family_relation_type`;后端查询接口只读取并返回该字段,不实时匹配家庭表。
|
||||
- 如果生产构建需要按对手方户名匹配 `ccdi_staff_fmy_relation.relation_name`,应在构建 SQL 中完成,并控制同名误判风险。
|
||||
- 有明确 `relation_cert_no` 的家庭关系人按实名主体处理,`object_key = md5(trim(relation_cert_no))`。
|
||||
- 用户点击该节点时,可按该节点 `object_key` 设为中心继续查询。
|
||||
|
||||
测试数据:
|
||||
|
||||
- 测试 DDL 为 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql`。
|
||||
- 测试数据脚本为 `sql/ccdi/graph/02_lx_fund_graph_seed_test_data.sql`。
|
||||
- 测试数据只写四张必要表。
|
||||
- 测试数据来源于 dev 库 `ccdi_bank_statement` 和 `ccdi_staff_fmy_relation`。
|
||||
- 原始流水表不被修改。
|
||||
|
||||
## 15. 前后端与页面交互设计
|
||||
|
||||
默认查询:
|
||||
|
||||
- 默认查询全部流水,不默认带交易日期过滤。
|
||||
- 用户选择交易日期后,后端才按 `lx_fund_flow_detail_edge.trx_date` 过滤。
|
||||
- 时间过滤不查汇总表,直接从明细边实时聚合。
|
||||
|
||||
后端接口建议:
|
||||
|
||||
```text
|
||||
GET /ccdi/project/fund-graph/search
|
||||
GET /ccdi/project/fund-graph/graph
|
||||
GET /ccdi/project/fund-graph/edge-detail
|
||||
POST /ccdi/project/fund-graph/manual-edge
|
||||
|
||||
GET /ccdi/project/relation-graph/search
|
||||
GET /ccdi/project/relation-graph/graph
|
||||
GET /ccdi/project/relation-graph/suspected-enterprises
|
||||
```
|
||||
|
||||
接口职责:
|
||||
|
||||
- `search`:按身份证号或姓名查主体点,返回候选主体列表。
|
||||
- `graph`:按主体 `object_key` 查询一跳资金图,默认全部流水,可选日期、金额、方向过滤。
|
||||
- `edge-detail`:点击资金边后,分页查询该边下的逐笔流水。
|
||||
|
||||
`graph` 入参:
|
||||
|
||||
```text
|
||||
objectKey 必填,主体 object_key
|
||||
startDate 可选,交易开始日期
|
||||
endDate 可选,交易结束日期
|
||||
amountMin 可选,最小金额
|
||||
amountMax 可选,最大金额
|
||||
direction 可选,1支出、2收入
|
||||
limit 可选,默认20
|
||||
```
|
||||
|
||||
`graph` 返回:
|
||||
|
||||
```text
|
||||
centerNode 当前中心主体
|
||||
nodes 图谱节点
|
||||
edges 聚合后的资金边
|
||||
summary 当前查询范围的总金额、总笔数、家庭关系边数量
|
||||
```
|
||||
|
||||
节点字段:
|
||||
|
||||
```text
|
||||
objectKey
|
||||
nodeKey idno_node/{object_key}
|
||||
name
|
||||
idNo
|
||||
nodeType PERSON / PROXY
|
||||
canExpand
|
||||
relationType 如果是家庭关系节点,返回配偶/父亲/母亲等
|
||||
```
|
||||
|
||||
边字段:
|
||||
|
||||
```text
|
||||
edgeKey
|
||||
fromKey
|
||||
toKey
|
||||
fromName
|
||||
toName
|
||||
direction 1支出、2收入
|
||||
amount
|
||||
transactionCount
|
||||
firstTrxDate
|
||||
lastTrxDate
|
||||
familyRelationType
|
||||
```
|
||||
|
||||
`edge-detail` 入参:
|
||||
|
||||
```text
|
||||
fromObjectKey
|
||||
toObjectKey
|
||||
direction
|
||||
startDate
|
||||
endDate
|
||||
pageNum
|
||||
pageSize
|
||||
```
|
||||
|
||||
页面交互:
|
||||
|
||||
- 页面位置:项目详情“专项排查”页签。
|
||||
- 页面标题:图谱展示。
|
||||
- 展示“资金流图谱”和“关系图谱”两个页签。
|
||||
- 搜索区只保留必要控件:身份证号/姓名、交易日期、查询、重置。
|
||||
- 默认空态提示:请输入身份证号或姓名查询资金流图谱。
|
||||
- 查询后画一跳图,中心节点为当前人员。
|
||||
- 边上只显示金额和笔数,家庭关系用标签显示。
|
||||
- 点击资金边,右侧抽屉展示逐笔流水。
|
||||
- 点击节点,提供“设为中心查询”;默认不自动穿透。
|
||||
- 只有 `canExpand = true` 的节点展示“设为中心查询”。
|
||||
|
||||
性能口径:
|
||||
|
||||
- 前端不聚合金额和笔数。
|
||||
- 后端只围绕一个主体的账户集合查明细边。
|
||||
- 默认全部流水也只查当前主体相关边,不扫全表。
|
||||
- 必须使用 `lx_fund_flow_own_account_edge.from_key`、`lx_fund_flow_detail_edge.from_key/trx_date`、`lx_fund_flow_detail_edge.to_key/trx_date` 索引。
|
||||
- 后端 SQL 参数比较需要显式 `COLLATE utf8mb4_general_ci`,避免当前库连接排序规则和表排序规则不一致。
|
||||
|
||||
当前 dev 测试数据:
|
||||
|
||||
```text
|
||||
测试身份证号:617673198109148314
|
||||
测试 object_key:以 `MD5('617673198109148314')` 为准
|
||||
主体点:10
|
||||
账户点:14
|
||||
持有边:14
|
||||
明细边:72
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
|
||||
- 默认全部流水聚合。
|
||||
- 日期范围筛选聚合。
|
||||
- 支出方向 `flag = 1`。
|
||||
- 收入方向 `flag = 2`。
|
||||
- 家庭关系标签:配偶、父亲、母亲。
|
||||
- 普通对手方:支付宝、淘宝、美团、财付通、小店、银行转账。
|
||||
- 点击家庭关系节点按 `object_key` 设为中心查询。
|
||||
|
||||
## 16. 2026-05-29 最新验收口径
|
||||
|
||||
本节为当前最新口径,用于覆盖前文早期变更记录中的冲突描述。
|
||||
|
||||
当前图谱功能保留两类能力:
|
||||
|
||||
- 资金流图谱:作为专项排查中的核心图谱能力,读取 `lx_fund_flow_subject_node`、`lx_fund_flow_account_node`、`lx_fund_flow_own_account_edge`、`lx_fund_flow_detail_edge`,并叠加 `lx_fund_flow_manual_edge` 手工资金流向。
|
||||
- 关系图谱:保留页面页签和接口能力,读取 `lx_rel_node`、`lx_rel_family_edge`、`lx_rel_stock_edge`、`lx_rel_represent_edge`,支持家庭关系、股东持股、法定代表人关系和疑似同名企业召回。
|
||||
|
||||
当前页面入口:
|
||||
|
||||
- 项目详情“专项排查”页签展示完整图谱工作台,包含“资金流图谱”和“关系图谱”两个页签。
|
||||
- 项目分析弹窗“资金流向”页签展示简版资金流图谱。
|
||||
- 项目分析弹窗“关系图谱”页签展示简版关系图谱。
|
||||
|
||||
当前接口入口:
|
||||
|
||||
```text
|
||||
GET /ccdi/project/fund-graph/search
|
||||
GET /ccdi/project/fund-graph/graph
|
||||
GET /ccdi/project/fund-graph/edge-detail
|
||||
POST /ccdi/project/fund-graph/manual-edge
|
||||
|
||||
GET /ccdi/project/relation-graph/search
|
||||
GET /ccdi/project/relation-graph/graph
|
||||
GET /ccdi/project/relation-graph/suspected-enterprises
|
||||
```
|
||||
|
||||
当前数据库执行口径:
|
||||
|
||||
- 新环境可参考 `sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql` 和 `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql`。
|
||||
- 已建资金流图谱表的环境使用 `sql/ccdi/graph/06_lx_fund_graph_existing_table_supplement.sql` 补字段和补索引,不删除、不重建、不清空基座数据。
|
||||
- 已建关系图谱表的环境使用 `sql/ccdi/graph/03_lx_relation_graph_mysql_ddl.sql` 中的补充逻辑补字段和补索引。
|
||||
- 生产 DDL 和补充 SQL 都由人工确认后手动执行,不随应用发布自动执行。
|
||||
|
||||
当前验收样例:
|
||||
|
||||
```text
|
||||
资金流图谱测试身份证号:617673198109148314
|
||||
关系图谱测试身份证号:330101198001010011
|
||||
```
|
||||
@@ -1,98 +0,0 @@
|
||||
# 图谱生产数据库手工变更清单
|
||||
|
||||
本清单只记录资金流图谱涉及的生产数据库表结构和数据准备事项。该部分由人工在生产库手动执行,不随应用发布自动执行,也不要求测试环境自动更新。
|
||||
|
||||
## 1. DDL 脚本
|
||||
|
||||
生产建表脚本:
|
||||
|
||||
```text
|
||||
sql/ccdi/graph/01_lx_fund_graph_mysql_ddl.sql
|
||||
```
|
||||
|
||||
当前减法版创建五张资金流图谱必要表:
|
||||
|
||||
- `lx_fund_flow_subject_node`
|
||||
- `lx_fund_flow_account_node`
|
||||
- `lx_fund_flow_own_account_edge`
|
||||
- `lx_fund_flow_detail_edge`
|
||||
- `lx_fund_flow_manual_edge`
|
||||
|
||||
不创建、不依赖 `lx_fund_flow_sum_edge`。资金图谱页面由后端基于 `lx_fund_flow_detail_edge.trx_date` 按当前查询条件实时聚合真实资金边,手工资金流向汇总边单独存入 `lx_fund_flow_manual_edge`。
|
||||
|
||||
## 2. 生产执行边界
|
||||
|
||||
- 生产库 DDL 由人工手动执行。
|
||||
- 应用发布包不自动执行 DDL。
|
||||
- 测试环境不会自动同步这些变更。
|
||||
- 代码库保留 SQL 文件,只作为生产执行、评审和本地验证依据。
|
||||
- 后端开发默认目标库中上述 `lx_*` 表已存在。
|
||||
|
||||
## 3. ODPS 基座同步
|
||||
|
||||
生产建表后,先从 ODPS 同步已验证的资金流图谱基座到 MySQL。
|
||||
|
||||
同步来源:
|
||||
|
||||
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_subject_node`
|
||||
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_account_node`
|
||||
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_own_account_edge`
|
||||
- ODPS 中原图谱 SQL 产出的 `lx_fund_flow_detail_edge`
|
||||
|
||||
同步原则:
|
||||
|
||||
- ODPS 基座是已验证数据,作为 MySQL 图谱基座保留。
|
||||
- 同步时建议使用显式字段列表,不依赖 `select *`。
|
||||
- MySQL 侧新增字段允许为空。
|
||||
- `lx_fund_flow_sum_edge` 不作为纪检资金图谱页面必要表,可不从 ODPS 同步。
|
||||
- `lx_fund_flow_manual_edge` 不从 ODPS 同步,生产建表后初始为空,由纪检平台手工分析功能写入。
|
||||
|
||||
## 4. 后续增量
|
||||
|
||||
ODPS 基座同步后,后续新增流水在纪检 MySQL 内处理。
|
||||
|
||||
增量来源:
|
||||
|
||||
- `ccdi_bank_statement`
|
||||
|
||||
增量原则:
|
||||
|
||||
- 先标准化候选流水。
|
||||
- 再和既有 `lx_fund_flow_detail_edge` 做一致性判重。
|
||||
- 已存在一致流水,不同步进图谱。
|
||||
- 不存在一致流水,才增量插入主体点、账户点、持有边、明细边。
|
||||
- 不维护汇总表;页面查询时实时聚合。
|
||||
|
||||
## 5. 调度建议
|
||||
|
||||
一期建议采用每日定时任务,不建议一开始做实时。
|
||||
|
||||
推荐方式:
|
||||
|
||||
- RuoYi/Quartz 定时任务。
|
||||
- 每日低峰期执行。
|
||||
- 保留手动触发能力,用于补跑、排查和修复。
|
||||
|
||||
## 6. 前后端依赖
|
||||
|
||||
前后端开发依赖上述 MySQL 图谱表的查询结果。
|
||||
|
||||
- 前端不直接访问数据库。
|
||||
- 后端接口读取 `lx_*` 表。
|
||||
- 页面入口放在项目详情的“专项排查”页签。
|
||||
- 资金流图谱中真实资金边基于 `lx_fund_flow_detail_edge` 实时聚合。
|
||||
- 手工资金边来自 `lx_fund_flow_manual_edge`,属于主体级汇总边,只存 `from_object_key`、`to_object_key`,不存冗余 `from_key`、`to_key`;图谱展示时由后端临时拼出 `idno_node/{object_key}`,不提供逐笔流水下钻。
|
||||
- 查询按全局 `cret_no`、姓名或节点 `object_key`,不按 `project_id` 过滤。
|
||||
|
||||
## 7. 性能和索引口径
|
||||
|
||||
一期资金图谱默认只查一个中心主体的一层资金边,并设置 `minTotalAmount = 1000`、`limit = 20`,不会默认拉全量毛刺边。
|
||||
|
||||
生产索引重点:
|
||||
|
||||
- `lx_fund_flow_subject_node`:`PRIMARY KEY(object_key)`、`idx_lx_fund_flow_subject_idnocfno(idnocfno)`、`idx_lx_fund_flow_subject_name(name)`。
|
||||
- `lx_fund_flow_own_account_edge`:`idx_lx_fund_flow_own_from_key(from_key)`、`idx_lx_fund_flow_own_to_key(to_key)`。
|
||||
- `lx_fund_flow_detail_edge`:`idx_lx_fund_flow_detail_from_date(from_key, trx_date)`、`idx_lx_fund_flow_detail_to_date(to_key, trx_date)`、`idx_lx_fund_flow_detail_from_to(from_key, to_key)`。
|
||||
- `lx_fund_flow_manual_edge`:`idx_lx_fund_flow_manual_from(from_object_key)`、`idx_lx_fund_flow_manual_to(to_object_key)`、`idx_lx_fund_flow_manual_pair_direction(from_object_key, to_object_key, direction)`。
|
||||
|
||||
如果后续单个主体关联流水达到几十万级,再考虑增加主体级冗余字段或月度汇总表;一期不建 `sum_edge`。
|
||||
@@ -1,912 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>结果总览 - 外部人员预警原型</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
--panel: #ffffff;
|
||||
--line: #dcdfe6;
|
||||
--line-soft: #ebeef5;
|
||||
--text: #303133;
|
||||
--sub: #606266;
|
||||
--muted: #909399;
|
||||
--primary: #2f6fed;
|
||||
--primary-soft: #eaf1ff;
|
||||
--danger: #d93026;
|
||||
--danger-soft: #fdecea;
|
||||
--warning: #b56a00;
|
||||
--warning-soft: #fff4df;
|
||||
--success: #1f7a45;
|
||||
--success-soft: #e8f5ee;
|
||||
--orange: #c75c00;
|
||||
--orange-soft: #fff0e3;
|
||||
--shadow: 0 8px 22px rgba(31, 45, 61, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 18px 24px 28px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1.3;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
margin-top: 8px;
|
||||
color: var(--sub);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
color: var(--primary);
|
||||
border-color: #b8cdfd;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-height: 86px;
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric-title {
|
||||
color: var(--sub);
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric.danger .metric-value {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.metric.warning .metric-value {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.metric.primary .metric-value {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(2, minmax(112px, 1fr));
|
||||
height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
border: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--sub);
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.segmented button:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.field {
|
||||
height: 34px;
|
||||
min-width: 148px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 0 10px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.field.search {
|
||||
min-width: 220px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th {
|
||||
height: 40px;
|
||||
background: #f5f7fa;
|
||||
color: var(--sub);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
padding: 0 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
padding: 8px 10px;
|
||||
vertical-align: middle;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
tr.selected {
|
||||
background: #f4f8ff;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
.col-cert {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.col-level {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.col-count {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.col-risk {
|
||||
width: 24%;
|
||||
}
|
||||
|
||||
.col-action {
|
||||
width: 9%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.tag + .tag {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.tag.danger {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
border-color: #f5c7c3;
|
||||
}
|
||||
|
||||
.tag.warning {
|
||||
color: var(--warning);
|
||||
background: var(--warning-soft);
|
||||
border-color: #ffd994;
|
||||
}
|
||||
|
||||
.tag.primary {
|
||||
color: var(--primary);
|
||||
background: var(--primary-soft);
|
||||
border-color: #c7d8ff;
|
||||
}
|
||||
|
||||
.tag.success {
|
||||
color: var(--success);
|
||||
background: var(--success-soft);
|
||||
border-color: #b7e0c9;
|
||||
}
|
||||
|
||||
.tag.orange {
|
||||
color: var(--orange);
|
||||
background: var(--orange-soft);
|
||||
border-color: #ffd0a8;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tags .tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.models {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.model-item {
|
||||
min-height: 92px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
color: var(--sub);
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.model-count {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.model-foot {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
min-height: 58px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 5px;
|
||||
padding: 9px 10px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.kv-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kv-value {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-weight: 650;
|
||||
margin: 18px 0 10px;
|
||||
}
|
||||
|
||||
.mini-table th {
|
||||
height: 34px;
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.mini-table td {
|
||||
height: 42px;
|
||||
font-size: 12px;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.risk-text {
|
||||
color: var(--sub);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.empty-graph {
|
||||
height: 126px;
|
||||
border: 1px dashed #c9cdd4;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.detail-table .col-date {
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
.detail-table .col-subject {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
.detail-table .col-party {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
.detail-table .col-labels {
|
||||
width: 27%;
|
||||
}
|
||||
|
||||
.detail-table .col-amount {
|
||||
width: 12%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.amount {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.overview {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.section-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.overview,
|
||||
.models,
|
||||
.identity {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field,
|
||||
.field.search,
|
||||
.segmented {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 880px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="breadcrumbs">初核项目 / 流水分析 / 结果总览</div>
|
||||
<h1>结果总览</h1>
|
||||
<div class="project-meta">
|
||||
<span>项目编号:HZ20260624001</span>
|
||||
<span>导入流水:18,426 笔</span>
|
||||
<span>分析批次:2026-06-24 09:38</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn">导出结果</button>
|
||||
<button class="btn ghost">专项排查</button>
|
||||
<button class="btn primary">重新分析</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="overview" aria-label="结果总览统计">
|
||||
<div class="metric">
|
||||
<div class="metric-title">分析主体数</div>
|
||||
<div class="metric-value">126</div>
|
||||
<div class="metric-note">员工、亲属及外部人员</div>
|
||||
</div>
|
||||
<div class="metric danger">
|
||||
<div class="metric-title">高风险主体</div>
|
||||
<div class="metric-value">8</div>
|
||||
<div class="metric-note">较上批次 +2</div>
|
||||
</div>
|
||||
<div class="metric warning">
|
||||
<div class="metric-title">中风险主体</div>
|
||||
<div class="metric-value">21</div>
|
||||
<div class="metric-note">待人工复核</div>
|
||||
</div>
|
||||
<div class="metric primary">
|
||||
<div class="metric-title">外部人员预警</div>
|
||||
<div class="metric-value">12</div>
|
||||
<div class="metric-note">中介 5 人,其他 7 人</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-title">命中模型</div>
|
||||
<div class="metric-value">17</div>
|
||||
<div class="metric-note">含外部人员模型 4 个</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-title">涉疑交易金额</div>
|
||||
<div class="metric-value">286.4万</div>
|
||||
<div class="metric-note">按交易标签汇总</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div>
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<div class="section-title">风险主体</div>
|
||||
<div class="segmented" role="tablist" aria-label="风险主体类型">
|
||||
<button class="active" type="button" data-subject-tab="external">外部人员预警</button>
|
||||
<button type="button" data-subject-tab="staff">员工风险</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="filters">
|
||||
<input class="field search" type="text" value="" placeholder="搜索姓名、证件号、账户" />
|
||||
<select class="field">
|
||||
<option>全部主体类型</option>
|
||||
<option>中介</option>
|
||||
<option>外部人员</option>
|
||||
</select>
|
||||
<select class="field">
|
||||
<option>全部风险等级</option>
|
||||
<option>高风险</option>
|
||||
<option>中风险</option>
|
||||
<option>低风险</option>
|
||||
</select>
|
||||
<button class="btn primary">查询</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-name">风险主体</th>
|
||||
<th class="col-type">主体类型</th>
|
||||
<th class="col-cert">证件号</th>
|
||||
<th class="col-level">风险等级</th>
|
||||
<th class="col-count">命中模型数</th>
|
||||
<th class="col-risk">核心风险点</th>
|
||||
<th class="col-action">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="subjectRows">
|
||||
<tr class="selected" data-name="王某" data-type="中介" data-cert="330************1234" data-level="高风险" data-models="4" data-risk="与员工亲属资金往来、大额转账、特殊金额交易" data-tags="外部人员大额交易,外部人员异常交易,外部人员可疑关系,特殊金额交易">
|
||||
<td>王某</td>
|
||||
<td><span class="tag primary">中介</span></td>
|
||||
<td>330************1234</td>
|
||||
<td><span class="tag danger">高风险</span></td>
|
||||
<td>4</td>
|
||||
<td>与员工亲属资金往来、大额转账、特殊金额交易</td>
|
||||
<td class="col-action"><button class="link" type="button">详情</button></td>
|
||||
</tr>
|
||||
<tr data-name="李某" data-type="外部人员" data-cert="341************6612" data-level="中风险" data-models="2" data-risk="疑似赌博敏感交易、同日多笔转账" data-tags="外部人员异常交易,可疑赌博">
|
||||
<td>李某</td>
|
||||
<td><span class="tag orange">外部人员</span></td>
|
||||
<td>341************6612</td>
|
||||
<td><span class="tag warning">中风险</span></td>
|
||||
<td>2</td>
|
||||
<td>疑似赌博敏感交易、同日多笔转账</td>
|
||||
<td class="col-action"><button class="link" type="button">详情</button></td>
|
||||
</tr>
|
||||
<tr data-name="赵某" data-type="外部人员" data-cert="320************4789" data-level="中风险" data-models="2" data-risk="与员工多次互转、夜间集中交易" data-tags="外部人员异常交易,外部人员可疑关系">
|
||||
<td>赵某</td>
|
||||
<td><span class="tag orange">外部人员</span></td>
|
||||
<td>320************4789</td>
|
||||
<td><span class="tag warning">中风险</span></td>
|
||||
<td>2</td>
|
||||
<td>与员工多次互转、夜间集中交易</td>
|
||||
<td class="col-action"><button class="link" type="button">详情</button></td>
|
||||
</tr>
|
||||
<tr data-name="陈某" data-type="中介" data-cert="362************9021" data-level="低风险" data-models="1" data-risk="单笔交易金额超过外部人员阈值" data-tags="外部人员大额交易">
|
||||
<td>陈某</td>
|
||||
<td><span class="tag primary">中介</span></td>
|
||||
<td>362************9021</td>
|
||||
<td><span class="tag success">低风险</span></td>
|
||||
<td>1</td>
|
||||
<td>单笔交易金额超过外部人员阈值</td>
|
||||
<td class="col-action"><button class="link" type="button">详情</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<div class="section-title">风险模型</div>
|
||||
<button class="btn">模型配置</button>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="models">
|
||||
<div class="model-item">
|
||||
<div class="model-name">外部人员大额交易</div>
|
||||
<div class="model-count">9</div>
|
||||
<div class="model-foot">单笔或累计金额超过阈值</div>
|
||||
</div>
|
||||
<div class="model-item">
|
||||
<div class="model-name">外部人员异常交易</div>
|
||||
<div class="model-count">6</div>
|
||||
<div class="model-foot">频次、时间、金额形态异常</div>
|
||||
</div>
|
||||
<div class="model-item">
|
||||
<div class="model-name">外部人员可疑赌博</div>
|
||||
<div class="model-count">3</div>
|
||||
<div class="model-foot">命中赌博敏感交易特征</div>
|
||||
</div>
|
||||
<div class="model-item">
|
||||
<div class="model-name">外部人员可疑关系</div>
|
||||
<div class="model-count">2</div>
|
||||
<div class="model-foot">涉及员工或员工亲属资金往来</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<div class="section-title">涉疑交易明细</div>
|
||||
<button class="btn">查看全部</button>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="table-wrap">
|
||||
<table class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-date">交易时间</th>
|
||||
<th class="col-subject">风险主体</th>
|
||||
<th>主体类型</th>
|
||||
<th class="col-party">交易对手</th>
|
||||
<th>对手类型</th>
|
||||
<th class="col-labels">异常标签</th>
|
||||
<th class="col-amount">金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>2026-05-18 10:42</td>
|
||||
<td>王某</td>
|
||||
<td><span class="tag primary">中介</span></td>
|
||||
<td>刘某某</td>
|
||||
<td><span class="tag warning">员工亲属</span></td>
|
||||
<td>
|
||||
<div class="tags">
|
||||
<span class="tag danger">外部人员可疑关系</span>
|
||||
<span class="tag warning">特殊金额交易</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="amount">180,000.00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2026-05-22 21:16</td>
|
||||
<td>李某</td>
|
||||
<td><span class="tag orange">外部人员</span></td>
|
||||
<td>周某</td>
|
||||
<td><span class="tag orange">外部人员</span></td>
|
||||
<td>
|
||||
<div class="tags">
|
||||
<span class="tag warning">可疑赌博</span>
|
||||
<span class="tag primary">夜间集中交易</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="amount">52,000.00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2026-06-03 09:27</td>
|
||||
<td>赵某</td>
|
||||
<td><span class="tag orange">外部人员</span></td>
|
||||
<td>张三</td>
|
||||
<td><span class="tag primary">员工</span></td>
|
||||
<td>
|
||||
<div class="tags">
|
||||
<span class="tag danger">外部人员可疑关系</span>
|
||||
<span class="tag primary">多次互转</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="amount">36,800.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="side-panel">
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<div class="section-title">外部人员风险详情</div>
|
||||
<span class="tag danger" id="detailLevel">高风险</span>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="identity">
|
||||
<div class="kv">
|
||||
<div class="kv-label">风险主体</div>
|
||||
<div class="kv-value" id="detailName">王某</div>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<div class="kv-label">主体类型</div>
|
||||
<div class="kv-value" id="detailType">中介</div>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<div class="kv-label">证件号</div>
|
||||
<div class="kv-value" id="detailCert">330************1234</div>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<div class="kv-label">命中模型数</div>
|
||||
<div class="kv-value" id="detailModels">4</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-title">命中标签</div>
|
||||
<div class="tags" id="detailTags">
|
||||
<span class="tag danger">外部人员大额交易</span>
|
||||
<span class="tag warning">外部人员异常交易</span>
|
||||
<span class="tag primary">外部人员可疑关系</span>
|
||||
<span class="tag orange">特殊金额交易</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-title">核心风险点</div>
|
||||
<div class="risk-text" id="detailRisk">与员工亲属资金往来、大额转账、特殊金额交易</div>
|
||||
|
||||
<div class="detail-title">关联交易</div>
|
||||
<table class="mini-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>对手方</th>
|
||||
<th>对手类型</th>
|
||||
<th>金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>刘某某</td>
|
||||
<td>员工亲属</td>
|
||||
<td class="amount">180,000.00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>张三</td>
|
||||
<td>员工</td>
|
||||
<td class="amount">36,800.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="detail-title">关系图谱</div>
|
||||
<div class="empty-graph">外部人员图谱暂未接入</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="footer-note">外部人员口径:本方证件号未命中员工及员工亲属,命中中介库本人证件号时标记为中介。</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const rows = Array.from(document.querySelectorAll("#subjectRows tr"));
|
||||
const detailName = document.getElementById("detailName");
|
||||
const detailType = document.getElementById("detailType");
|
||||
const detailCert = document.getElementById("detailCert");
|
||||
const detailModels = document.getElementById("detailModels");
|
||||
const detailRisk = document.getElementById("detailRisk");
|
||||
const detailLevel = document.getElementById("detailLevel");
|
||||
const detailTags = document.getElementById("detailTags");
|
||||
|
||||
function tagClass(text) {
|
||||
if (text.includes("大额") || text.includes("可疑关系")) return "danger";
|
||||
if (text.includes("异常") || text.includes("赌博")) return "warning";
|
||||
if (text.includes("特殊")) return "orange";
|
||||
return "primary";
|
||||
}
|
||||
|
||||
function selectRow(row) {
|
||||
rows.forEach((item) => item.classList.remove("selected"));
|
||||
row.classList.add("selected");
|
||||
|
||||
detailName.textContent = row.dataset.name;
|
||||
detailType.textContent = row.dataset.type;
|
||||
detailCert.textContent = row.dataset.cert;
|
||||
detailModels.textContent = row.dataset.models;
|
||||
detailRisk.textContent = row.dataset.risk;
|
||||
detailLevel.textContent = row.dataset.level;
|
||||
detailLevel.className = "tag " + (row.dataset.level === "高风险" ? "danger" : row.dataset.level === "中风险" ? "warning" : "success");
|
||||
|
||||
detailTags.innerHTML = "";
|
||||
row.dataset.tags.split(",").forEach((text) => {
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "tag " + tagClass(text);
|
||||
tag.textContent = text;
|
||||
detailTags.appendChild(tag);
|
||||
});
|
||||
}
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.addEventListener("click", () => selectRow(row));
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-subject-tab]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
document.querySelectorAll("[data-subject-tab]").forEach((item) => item.classList.remove("active"));
|
||||
button.classList.add("active");
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,40 +0,0 @@
|
||||
# 员工信息维护与招聘信息管理正式化外壳实施记录
|
||||
|
||||
## 变更日期
|
||||
|
||||
- 2026-04-29
|
||||
|
||||
## 变更范围
|
||||
|
||||
- 前端:`ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
|
||||
- 前端:`ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
|
||||
- 单测:`ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js`
|
||||
- 单测:`ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js`
|
||||
|
||||
## 实施内容
|
||||
|
||||
### 1. 页面外壳调整
|
||||
|
||||
- 复用 `app-container`、`query-form`、`mb8` 等现有结构,只补最少样式。
|
||||
- 为 `ccdiBaseStaff` 与 `ccdiStaffRecruitment` 的查询区增加正式化外壳视觉,包括浅底、边框、留白和输入框边线统一。
|
||||
- 保持搜索、重置、新增、导入、失败记录等按钮原有顺序不变。
|
||||
- 为两个页面新增 `formal-table-shell`,将表格和分页收口到同一视觉区域内。
|
||||
|
||||
### 2. 弹窗外壳调整
|
||||
|
||||
- 复用员工页已有 `employee-edit-dialog`、`employee-detail-dialog` class,只调整弹窗圆角、标题分隔线和弹窗正文背景。
|
||||
- 复用招聘页现有弹窗结构,只补统一的弹窗标题区和正文背景样式。
|
||||
- 未改动员工资产、历史工作经历等内部功能块结构。
|
||||
|
||||
### 3. 验证情况
|
||||
|
||||
- 单测通过:
|
||||
- `node tests/unit/base-staff-formal-shell-layout.test.js`
|
||||
- `node tests/unit/staff-recruitment-formal-shell-layout.test.js`
|
||||
- `node tests/unit/staff-recruitment-import-toolbar.test.js`
|
||||
- 现有单测异常:
|
||||
- `node tests/unit/employee-asset-maintenance-layout.test.js`
|
||||
- 失败原因为当前仓库源码不满足既有字符串断言 `createEmptyAssetRow(defaultPersonId = "")`,与本次外壳样式改动无关。
|
||||
- 浏览器验证:
|
||||
- 已使用 `browser-use` 打开 `http://localhost/prototype/staff-recruitment`,确认招聘信息管理页查询区、工具条、表格区已切换为正式化外壳,按钮仍保持原位。
|
||||
- 尝试打开 `http://localhost/ccdiBaseStaff` 时,当前本地前端路由返回 404 页面,因此未能在浏览器内完成员工信息维护页真实页面验证。
|
||||
@@ -1,92 +0,0 @@
|
||||
# 2026-04-29 批量正式化外壳样式实施记录
|
||||
|
||||
## 本次实施内容
|
||||
|
||||
本轮基于当前本地最新代码,批量将信息维护相关页面收口为统一的正式化外壳样式,继续保持“只改样式、不改内容和功能”的边界。
|
||||
|
||||
### 覆盖页面
|
||||
|
||||
- 账户库管理:`ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
|
||||
- 征信维护:`ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
|
||||
- 信贷客户实体关联:`ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
|
||||
- 信贷客户家庭关系:`ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
|
||||
- 中介库管理:
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
|
||||
- 招投标信息维护:`ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
|
||||
- 员工亲属实体关联:`ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
|
||||
- 员工亲属关系维护:`ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
|
||||
- 员工调动记录:`ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
|
||||
|
||||
## 具体调整
|
||||
|
||||
### 查询区
|
||||
|
||||
- 将筛选区统一收进白色边框面板。
|
||||
- 统一标签颜色、控件边框、控件圆角和表单项间距。
|
||||
- 保留全部原始筛选条件和原始布局顺序。
|
||||
|
||||
### 工具条
|
||||
|
||||
- 为工具条增加统一白色承载面板。
|
||||
- 按钮圆角统一收敛到约 4px。
|
||||
- 不调整搜索、重置、新增、导入、失败记录等按钮的位置和语义。
|
||||
|
||||
### 表格
|
||||
|
||||
- 为列表页统一增加 `formal-table-shell` 外层承载。
|
||||
- 收紧表头和表体留白,提升单屏显示密度。
|
||||
- 统一普通列左对齐,操作列和选择列保持居中。
|
||||
|
||||
### 弹窗
|
||||
|
||||
- 编辑、详情、导入弹窗统一使用更正式的边界和浅底信息面板风格。
|
||||
- 去掉原有偏演示感的悬浮和装饰感。
|
||||
- 不改变弹窗中的字段组织和业务交互。
|
||||
|
||||
## 修正项
|
||||
|
||||
- 批量调整过程中,`ccdiPurchaseTransaction/index.vue` 样式块曾出现一个多余的 `}`,导致前端编译报错。
|
||||
- 已在本轮内修正,重新通过真实页面检查。
|
||||
- 批量将 `.mb8` 统一为 `flex` 承载后,`right-toolbar` 的“显示/隐藏 / 刷新”按钮组一度被挤到左侧。
|
||||
- 已通过为各列表页补充 `.mb8 ::v-deep .top-right-btn { margin-left: auto; }` 恢复原有靠右位置。
|
||||
|
||||
## 验证结果
|
||||
|
||||
### 单测
|
||||
|
||||
- `node ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js` 通过
|
||||
- `node ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js` 通过
|
||||
- `node ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js` 通过
|
||||
- `node ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js` 通过
|
||||
|
||||
### 真实页面浏览器验证
|
||||
|
||||
已通过真实业务路由验证以下页面可以打开且关键外壳区域仍保持原有功能结构:
|
||||
|
||||
- `http://localhost/maintain/accountInfo`
|
||||
- `http://localhost/maintain/creditInfo`
|
||||
- `http://localhost/maintain/intermediary`
|
||||
- `http://localhost/maintain/purchaseTransaction`
|
||||
- `http://localhost/maintain/staffTransfer`
|
||||
- `http://localhost/maintain/staffEnterpriseRelation`
|
||||
- `http://localhost/maintain/staffFmyRelation`
|
||||
- `http://localhost/maintain/custEnterpriseRelation`
|
||||
- `http://localhost/maintain/custFmyRelation`
|
||||
- `http://localhost/maintain/staffRecruitment`
|
||||
|
||||
验证点:
|
||||
|
||||
- 页面标题、搜索按钮、新增按钮、导入按钮仍可见
|
||||
- 查询区与工具条仍位于原位置
|
||||
- 未发生按钮左右换位
|
||||
- 表格区与分页区仍按原内容结构展示
|
||||
|
||||
## 现有环境问题
|
||||
|
||||
- `staffRecruitment` 页面当前仍存在后端返回的字符集排序规则冲突报错:`utf8mb4_0900_ai_ci` 与 `utf8mb4_general_ci` 混用。
|
||||
- 该问题来自现有后端/数据库环境,不是本次样式改动引入的问题。
|
||||
@@ -1,101 +0,0 @@
|
||||
# 2026-04-29 正式化样式调整总说明
|
||||
|
||||
## 目标边界
|
||||
|
||||
- 本轮所有改动都基于当前本地最新代码进行。
|
||||
- 仅调整页面与弹窗外壳样式,不改变原有内容、字段、按钮语义、交互流程和功能逻辑。
|
||||
- 不参考 `2026-04-29-dev-ui-style-mixed-stash.patch` 中的结构性和功能性变动。
|
||||
|
||||
## 本轮纳入的页面
|
||||
|
||||
### 1. 项目分析详情弹窗
|
||||
|
||||
- 文件:
|
||||
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
|
||||
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
|
||||
- 调整方向:
|
||||
- 详情页外壳正式化、去卡片化
|
||||
- 标题区更平直,人物档案区更规整
|
||||
- 页签和主区承载更克制
|
||||
- 不变内容:
|
||||
- 异常明细、资产分析、征信摘要等业务内容结构不变
|
||||
- 数据请求、分页、按钮逻辑不变
|
||||
|
||||
### 2. 员工信息维护页
|
||||
|
||||
- 文件:
|
||||
- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
|
||||
- 调整方向:
|
||||
- 筛选区收进统一白色区域
|
||||
- 工具条按钮外观更正式,圆角收小
|
||||
- 表格与分页统一收进正式信息面板
|
||||
- 表格更紧凑、阅读更集中
|
||||
- 编辑/详情弹窗外壳更像正式信息面板
|
||||
- 不变内容:
|
||||
- 查询字段、按钮顺序、导入入口、资产信息与党员信息功能不变
|
||||
|
||||
### 3. 招聘信息管理页
|
||||
|
||||
- 文件:
|
||||
- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
|
||||
- 调整方向:
|
||||
- 筛选区、工具条、表格区统一正式化
|
||||
- 按钮、输入框、下拉框视觉更稳重
|
||||
- 表格行高与表头高度适当收紧
|
||||
- 弹窗外壳更克制
|
||||
- 不变内容:
|
||||
- 招聘类型、历史工作经历、导入入口、按钮位置和业务流程不变
|
||||
|
||||
### 4. 批量推进的信息维护页面
|
||||
|
||||
- 文件:
|
||||
- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
|
||||
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
|
||||
- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
|
||||
- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
|
||||
- 调整方向:
|
||||
- 查询区、工具条、列表区统一成正式化白色信息面板
|
||||
- 输入框、下拉框、日期控件边框与圆角统一收敛
|
||||
- 表格与分页通过 `formal-table-shell` 统一承载
|
||||
- 中介库的搜索、列表、详情、编辑、导入弹窗统一到相同视觉语言
|
||||
- 各列表维护页的弹窗边界与留白更克制
|
||||
- 不变内容:
|
||||
- 按钮顺序、字段结构、导入流程、失败记录入口、详情内容和业务逻辑不变
|
||||
|
||||
## 统一视觉原则
|
||||
|
||||
- 筛选区更规整:统一白色面板承载,结构清晰
|
||||
- 按钮更正式:圆角约 4px,弱化轻飘感
|
||||
- 表单控件更稳重:圆角更小,边框更统一
|
||||
- 表格更紧凑:降低表头和行内容留白,一屏展示更多信息
|
||||
- 列表阅读性更好:尽量左对齐,减少长字段换行和大片空白
|
||||
- 视觉装饰收敛:移除不必要的阴影、渐变、悬浮感
|
||||
- 卡片感减弱:边界、留白、圆角更克制,保留原有内容结构
|
||||
|
||||
## 验证说明
|
||||
|
||||
- 项目分析详情弹窗已完成真实页面验证
|
||||
- 员工信息维护页已完成源码与单测级校验
|
||||
- 招聘信息管理页和批量推进页面已通过真实业务路由验证:
|
||||
- `http://localhost/maintain/staffRecruitment`
|
||||
- `http://localhost/maintain/accountInfo`
|
||||
- `http://localhost/maintain/creditInfo`
|
||||
- `http://localhost/maintain/intermediary`
|
||||
- `http://localhost/maintain/purchaseTransaction`
|
||||
- `http://localhost/maintain/staffTransfer`
|
||||
- `http://localhost/maintain/staffEnterpriseRelation`
|
||||
- `http://localhost/maintain/staffFmyRelation`
|
||||
- `http://localhost/maintain/custEnterpriseRelation`
|
||||
- `http://localhost/maintain/custFmyRelation`
|
||||
- 浏览器验证过程中发现并修复了 `ccdiPurchaseTransaction/index.vue` 的样式编译错误
|
||||
- `staffRecruitment` 页面仍存在现有数据库字符集排序规则冲突报错,该问题不属于本轮样式改动
|
||||
@@ -1,44 +0,0 @@
|
||||
# 结果总览项目分析详情正式化外壳实施记录
|
||||
|
||||
## 变更日期
|
||||
|
||||
- 2026-04-29
|
||||
|
||||
## 变更范围
|
||||
|
||||
- 前端:`ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
|
||||
- 前端:`ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
|
||||
- 单测:`ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js`
|
||||
- 单测:`ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js`
|
||||
|
||||
## 实施内容
|
||||
|
||||
### 1. 弹窗外壳样式正式化
|
||||
|
||||
- 调整 `ProjectAnalysisDialog.vue` 的外层壳样式,去掉旧版渐变大圆角卡片视觉。
|
||||
- 将弹窗主体改为浅灰外层背景 + 白色内容工作台,强化边线、留白和分栏结构。
|
||||
- 将标题区改成“结果总览 / 项目分析详情”的正式化层级,保留当前命中模型提示,但收口为弱装饰信息块。
|
||||
- 调整右侧主区与左侧档案区之间的分隔线和间距,只改外壳,不进入各 tab 内部内容结构。
|
||||
|
||||
### 2. 左侧人物档案区样式映射
|
||||
|
||||
- 调整 `ProjectAnalysisSidebar.vue`,按“人物档案 + 命中模型摘要”两段式结构重排视觉。
|
||||
- 姓名、风险等级、工号、部门、所属项目继续沿用现有数据字段,不新增业务字段。
|
||||
- 将风险等级改为细边框状态标识,字段列表改为规整的标签/值双列展示。
|
||||
- 核心异常标签保留为现有标签数据,仅更新标签外观,不改渲染逻辑。
|
||||
|
||||
### 3. 验证情况
|
||||
|
||||
- 单测通过:
|
||||
- `node tests/unit/project-analysis-dialog-layout.test.js`
|
||||
- `node tests/unit/project-analysis-dialog-sidebar.test.js`
|
||||
- `node tests/unit/project-analysis-dialog-empty-field.test.js`
|
||||
- 浏览器实测:
|
||||
- 使用 `browser-use` 打开本地真实系统 `http://localhost/`
|
||||
- 进入项目详情页 `http://localhost/ccdiProject/detail/90337?tab=overview`
|
||||
- 在“结果总览”页点击“查看详情”,确认“项目分析详情”弹窗已应用正式化外壳样式
|
||||
- 确认左侧人物档案区样式已按预览稿方向收口,右侧“异常明细”内部业务内容未被重做
|
||||
- 环境记录:
|
||||
- `ruoyi-ui/.nvmrc` 期望版本为 `14.21.3`
|
||||
- 当前终端执行 `nvm use` 失败,原因是 `nvm` 未安装到 PowerShell PATH
|
||||
- 本次前端校验在当前可用 Node `v22.22.0` 下完成,相关单测通过
|
||||
@@ -0,0 +1,89 @@
|
||||
# 员工信息维护导入功能真实页面测试记录
|
||||
|
||||
## 测试范围
|
||||
|
||||
- 页面:`http://localhost:8080/maintain/baseStaff`
|
||||
- 后端:`http://localhost:62318`
|
||||
- 模块:员工信息维护导入
|
||||
- 测试方式:`browser-use` 打开真实业务页面,确认导入入口、模板下载和页面列表/详情展示;使用真实模板生成测试工作簿后调用同一导入接口上传,轮询导入任务状态并查询失败记录。
|
||||
|
||||
## 测试文件
|
||||
|
||||
- 真实模板:`output/spreadsheet/base_staff_import_template_20260430.xlsx`
|
||||
- 空模板:`output/spreadsheet/base_staff_import_empty_20260430.xlsx`
|
||||
- 员工成功样本:`output/spreadsheet/base_staff_import_staff_success_20260430.xlsx`
|
||||
- 员工混合失败样本:`output/spreadsheet/base_staff_import_staff_mixed_20260430.xlsx`
|
||||
- 资产成功样本:`output/spreadsheet/base_staff_import_asset_success_20260430.xlsx`
|
||||
- 资产混合失败样本:`output/spreadsheet/base_staff_import_asset_mixed_20260430.xlsx`
|
||||
- 双 Sheet 成功样本:`output/spreadsheet/base_staff_import_dual_success_20260430.xlsx`
|
||||
|
||||
## 页面验证
|
||||
|
||||
1. 打开真实员工信息维护页面,确认页面标题、查询区、列表、单一“导入”按钮加载正常。
|
||||
2. 打开“员工信息维护导入”弹窗,确认存在“下载模板”入口。
|
||||
3. 点击“下载模板”,页面触发真实模板下载。
|
||||
4. 弹窗提示确认:
|
||||
- 模板包含“员工信息”和“员工资产信息”两个 Sheet。
|
||||
- 两个 Sheet 可单独填写,也可同时填写。
|
||||
- 员工信息命中现有员工直接报错。
|
||||
- 资产信息仅支持员工本人资产。
|
||||
5. 页面查询员工 `9843001`,确认成功导入员工 `Codex导入员工A` 可在真实列表展示。
|
||||
6. 打开员工 `9843001` 详情,确认资产 `Codex资产住宅A` 展示在资产信息区。
|
||||
7. 页面查询员工 `9843004`,确认双 Sheet 成功样本中的员工 `Codex导入员工D` 可在真实列表展示。
|
||||
|
||||
## 接口与异步任务验证
|
||||
|
||||
| 场景 | 文件 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 空模板 | `base_staff_import_empty_20260430.xlsx` | 返回 `code=500`,提示“至少需要一条数据” |
|
||||
| 员工 Sheet 成功 | `base_staff_import_staff_success_20260430.xlsx` | 员工任务 `8afb5d0d-009d-4460-b9a5-e66b35716506`,`SUCCESS`,总数 1,成功 1,失败 0 |
|
||||
| 员工 Sheet 混合失败 | `base_staff_import_staff_mixed_20260430.xlsx` | 员工任务 `c57fc240-c50f-42a7-8d77-1614c46789ec`,`PARTIAL_SUCCESS`,总数 7,成功 1,失败 6 |
|
||||
| 资产 Sheet 成功 | `base_staff_import_asset_success_20260430.xlsx` | 资产任务 `8f266611-10ef-471f-8cc7-92b5b65cac45`,`SUCCESS`,总数 1,成功 1,失败 0 |
|
||||
| 资产 Sheet 混合失败 | `base_staff_import_asset_mixed_20260430.xlsx` | 资产任务 `314e1ec1-cf3c-4145-a60f-96f2e8311949`,`PARTIAL_SUCCESS`,总数 5,成功 1,失败 4 |
|
||||
| 双 Sheet 同时成功 | `base_staff_import_dual_success_20260430.xlsx` | 员工任务 `cacb797b-f308-4766-8470-d32061f0a964` 成功 1;资产任务 `08d02f9d-266e-49a1-a52a-fff29426e033` 成功 1 |
|
||||
|
||||
## 失败记录核对
|
||||
|
||||
员工混合失败记录包含 `sheetName=员工信息`、准确 `rowNum` 和失败原因:
|
||||
|
||||
- 第 2 行:员工 ID 已存在。
|
||||
- 第 3 行:姓名不能为空。
|
||||
- 第 4 行:所属部门 ID 不存在或已停用/删除。
|
||||
- 第 5 行:身份证号长度必须为 18 位。
|
||||
- 第 6 行:年收入不能为负数。
|
||||
- 第 8 行:员工 ID 在导入文件中重复。
|
||||
|
||||
资产混合失败记录包含 `sheetName=员工资产信息`、准确 `rowNum` 和失败原因:
|
||||
|
||||
- 第 2 行:资产记录已存在。
|
||||
- 第 3 行:员工资产导入仅支持员工本人证件号。
|
||||
- 第 4 行:资产名称不能为空。
|
||||
- 第 6 行:资产记录在导入文件中重复。
|
||||
|
||||
## 数据回查
|
||||
|
||||
- 员工 `9843001`:列表接口返回 1 条,姓名 `Codex导入员工A`,部门 `研发部门`,状态 `在职`。
|
||||
- 员工 `9843003`:列表接口返回 1 条,姓名 `Codex导入员工C`,验证混合失败文件中的成功行已入库。
|
||||
- 员工 `9843004`:列表接口返回 1 条,姓名 `Codex导入员工D`,验证双 Sheet 员工任务成功入库。
|
||||
- 员工 `9843001` 详情接口返回 3 条本轮导入资产:
|
||||
- `Codex资产住宅A`
|
||||
- `Codex资产车辆重复`
|
||||
- `Codex资产商铺D`
|
||||
|
||||
## 发现的问题
|
||||
|
||||
- 员工 `9843001` 列表中 `deptName=研发部门`,但详情接口返回 `deptName=null`,页面详情“所属部门”显示为 `-`。该问题不阻断导入成功,但属于员工详情展示链路的现存缺陷。
|
||||
|
||||
## 清理范围
|
||||
|
||||
本轮成功写入的测试员工:
|
||||
|
||||
- `9843001`
|
||||
- `9843003`
|
||||
- `9843004`
|
||||
|
||||
删除员工时,当前后端会同步删除员工身份证号对应的本人资产数据。
|
||||
|
||||
## 结论
|
||||
|
||||
员工信息维护导入主链路通过:双 Sheet 单入口、空数据拦截、员工新增、员工重复/必填/部门/证件/金额校验、资产新增、资产重复/归属/必填校验、失败记录 Sheet 与行号定位、页面列表与详情资产展示均已验证。需要单独处理员工详情部门名称缺失问题。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 员工信息维护分页重复修复实施记录
|
||||
|
||||
## 问题
|
||||
|
||||
- 页面:`http://localhost:1025/maintain/baseStaff`
|
||||
- 接口:`GET /ccdi/baseStaff/list`
|
||||
- 现象:第一页和第二页存在相同员工。
|
||||
- 复现结果:接口第一页和第二页出现 `9020004`、`9020005`、`9020009`、`9020003`、`9020008` 等重复员工。
|
||||
|
||||
## 原因
|
||||
|
||||
员工列表 SQL 仅按 `create_time DESC` 排序。批量导入的员工会在同一批次写入相同 `create_time`,数据库在相同排序值之间没有稳定顺序,分页时可能导致同一员工跨页重复出现。
|
||||
|
||||
## 修改内容
|
||||
|
||||
- `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiBaseStaffMapper.xml`
|
||||
- 将员工分页列表排序从 `ORDER BY e.create_time DESC` 调整为 `ORDER BY e.create_time DESC, e.staff_id DESC`。
|
||||
- 使用唯一的 `staff_id` 作为二级排序字段,保证同一创建时间内分页顺序稳定。
|
||||
- `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiBaseStaffMapperTest.java`
|
||||
- 增加 Mapper XML 断言,防止后续误删稳定分页排序。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 仅影响员工信息维护列表分页顺序。
|
||||
- 不改变筛选条件、列表字段、总数统计、导入、新增、编辑、删除逻辑。
|
||||
|
||||
## 验证
|
||||
|
||||
- 修改前已通过后端接口复现第一页和第二页员工重复。
|
||||
- `node -e` 轻量 XML 断言通过,确认 Mapper 中包含 `ORDER BY e.create_time DESC, e.staff_id DESC`。
|
||||
- `mvn -pl ruoyi-admin -am clean package -DskipTests` 构建通过。
|
||||
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiBaseStaffMapperTest -Dsurefire.failIfNoSpecifiedTests=false test` 通过,`CcdiBaseStaffMapperTest` 共 2 个用例全部通过。
|
||||
- 后端接口复验:
|
||||
- 第 1 页:`9020018` 至 `9020009`。
|
||||
- 第 2 页:`9020008` 至 `9020001`,并继续显示后续员工。
|
||||
- 第 1 页与第 2 页 `staffId` 交集为 `none`。
|
||||
- browser-use 打开真实页面 `http://localhost:1025/maintain/baseStaff` 验证:
|
||||
- 第 1 页首行显示 `许平 / 9020018`。
|
||||
- 点击分页第 2 页后,页面首行显示 `郑丽 / 9020008`,未再显示第一页首行员工。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user