Compare commits
13 Commits
0de248a039
...
c68e694536
| Author | SHA1 | Date | |
|---|---|---|---|
| c68e694536 | |||
| 4e696eff1e | |||
| 3d61f7d252 | |||
| bd09d483e0 | |||
| efebd4f76c | |||
| 1d777c4401 | |||
| 9e440dad41 | |||
| 9dd12d9ef0 | |||
| 52a5056a70 | |||
| 7a34cb337b | |||
| f93ff0d886 | |||
| 281d919e57 | |||
| d9f3165872 |
64
assets/implementation/scripts/test_ry_bat.ps1
Normal file
64
assets/implementation/scripts/test_ry_bat.ps1
Normal file
@@ -0,0 +1,64 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..')
|
||||
$expectedJar = Join-Path $repoRoot 'ruoyi-admin\target\ruoyi-admin.jar'
|
||||
$originalJar = Join-Path $repoRoot 'ruoyi-admin\target\ruoyi-admin.jar.original'
|
||||
$backupOriginalJar = "$originalJar.codex-test-backup"
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
$output = cmd /c "set RY_DRY_RUN=1&& call ry.bat start" 2>&1 | Out-String
|
||||
$exitCode = $LASTEXITCODE
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
throw "ry.bat dry-run failed with exit code $exitCode.`n$output"
|
||||
}
|
||||
|
||||
if ($output -notmatch 'START_CMD=java ') {
|
||||
throw "Expected START_CMD output was not found.`n$output"
|
||||
}
|
||||
|
||||
if ($output -notmatch [regex]::Escape($expectedJar)) {
|
||||
throw "Expected jar path was not found in dry-run output.`n$output"
|
||||
}
|
||||
|
||||
foreach ($unsupportedToken in @('PrintGCDateStamps', 'UseParallelOldGC', 'javaw')) {
|
||||
if ($output -match [regex]::Escape($unsupportedToken)) {
|
||||
throw "Unexpected token [$unsupportedToken] found in dry-run output.`n$output"
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $originalJar) {
|
||||
Move-Item -Path $originalJar -Destination $backupOriginalJar -Force
|
||||
try {
|
||||
$packageOutput = cmd /c "set RY_DRY_RUN=1&& call ry.bat start" 2>&1 | Out-String
|
||||
$packageExitCode = $LASTEXITCODE
|
||||
|
||||
if ($packageExitCode -ne 0) {
|
||||
throw "ry.bat package dry-run failed with exit code $packageExitCode.`n$packageOutput"
|
||||
}
|
||||
|
||||
if ($packageOutput -notmatch 'BUILD_CMD=mvn -pl ruoyi-admin -am package -DskipTests') {
|
||||
throw "Expected BUILD_CMD output was not found.`n$packageOutput"
|
||||
}
|
||||
|
||||
if ($packageOutput -notmatch [regex]::Escape($expectedJar)) {
|
||||
throw "Expected jar path was not found in package dry-run output.`n$packageOutput"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (Test-Path $backupOriginalJar) {
|
||||
Move-Item -Path $backupOriginalJar -Destination $originalJar -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host 'ry.bat dry-run verification passed.'
|
||||
}
|
||||
finally {
|
||||
if (Test-Path $backupOriginalJar) {
|
||||
Move-Item -Path $backupOriginalJar -Destination $originalJar -Force
|
||||
}
|
||||
|
||||
Pop-Location
|
||||
}
|
||||
@@ -27,5 +27,5 @@ public class FetchInnerFlowRequest {
|
||||
private Integer dataEndDateId;
|
||||
|
||||
/** 柜员号 */
|
||||
private Integer uploadUserId;
|
||||
private String uploadUserId;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.ruoyi.ccdi.project.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiPullBankInfoSubmitDTO;
|
||||
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiIdCardParseVO;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
@@ -15,9 +17,12 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
/**
|
||||
@@ -85,6 +90,48 @@ public class CcdiFileUploadController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析身份证文件
|
||||
*/
|
||||
@PostMapping("/parse-id-card-file")
|
||||
@Operation(summary = "解析身份证文件", description = "解析首个sheet第一列的身份证号")
|
||||
public AjaxResult parseIdCardFile(@RequestParam MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
return AjaxResult.error("身份证文件不能为空");
|
||||
}
|
||||
List<String> idCards = fileUploadService.parseIdCardFile(file);
|
||||
return AjaxResult.success("解析成功", new CcdiIdCardParseVO(idCards, idCards.size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交拉取本行信息任务
|
||||
*/
|
||||
@PostMapping("/pull-bank-info")
|
||||
@Operation(summary = "拉取本行信息", description = "按身份证号批量提交拉取本行信息任务")
|
||||
public AjaxResult pullBankInfo(@RequestBody CcdiPullBankInfoSubmitDTO dto) {
|
||||
if (dto == null || dto.getProjectId() == null) {
|
||||
return AjaxResult.error("项目ID不能为空");
|
||||
}
|
||||
if (CollectionUtils.isEmpty(dto.getIdCards())) {
|
||||
return AjaxResult.error("身份证号不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(dto.getStartDate()) || !StringUtils.hasText(dto.getEndDate())) {
|
||||
return AjaxResult.error("开始日期和结束日期不能为空");
|
||||
}
|
||||
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
String username = SecurityUtils.getUsername();
|
||||
String batchId = fileUploadService.submitPullBankInfo(
|
||||
dto.getProjectId(),
|
||||
dto.getIdCards(),
|
||||
dto.getStartDate(),
|
||||
dto.getEndDate(),
|
||||
userId,
|
||||
username
|
||||
);
|
||||
return AjaxResult.success("拉取任务已提交", batchId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询上传记录列表
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ruoyi.ccdi.project.domain.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 拉取本行信息提交参数
|
||||
*/
|
||||
@Data
|
||||
public class CcdiPullBankInfoSubmitDTO {
|
||||
|
||||
/** 项目ID */
|
||||
private Long projectId;
|
||||
|
||||
/** 身份证号列表 */
|
||||
private List<String> idCards;
|
||||
|
||||
/** 开始日期 */
|
||||
private String startDate;
|
||||
|
||||
/** 结束日期 */
|
||||
private String endDate;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ruoyi.ccdi.project.domain.excel;
|
||||
|
||||
import com.alibaba.excel.annotation.ExcelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 身份证导入行
|
||||
*/
|
||||
@Data
|
||||
public class CcdiIdCardExcelRow {
|
||||
|
||||
@ExcelProperty(index = 0)
|
||||
private String idCard;
|
||||
}
|
||||
@@ -90,4 +90,10 @@ public class CcdiBankStatementDetailVO {
|
||||
|
||||
/** 创建时间 */
|
||||
private Date createDate;
|
||||
|
||||
/** 原始文件名 */
|
||||
private String originalFileName;
|
||||
|
||||
/** 原始文件上传时间 */
|
||||
private Date uploadTime;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ruoyi.ccdi.project.domain.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 身份证文件解析结果
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CcdiIdCardParseVO {
|
||||
|
||||
/** 解析到的身份证列表 */
|
||||
private List<String> idCards;
|
||||
|
||||
/** 数量 */
|
||||
private Integer count;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件上传服务接口
|
||||
*
|
||||
@@ -24,6 +26,32 @@ public interface ICcdiFileUploadService {
|
||||
*/
|
||||
String batchUploadFiles(Long projectId, MultipartFile[] files, String username);
|
||||
|
||||
/**
|
||||
* 解析身份证文件
|
||||
*
|
||||
* @param file Excel 文件
|
||||
* @return 身份证号列表
|
||||
*/
|
||||
List<String> parseIdCardFile(MultipartFile file);
|
||||
|
||||
/**
|
||||
* 提交拉取本行信息任务
|
||||
*
|
||||
* @param projectId 项目ID
|
||||
* @param idCards 身份证号列表
|
||||
* @param startDate 开始日期
|
||||
* @param endDate 结束日期
|
||||
* @param userId 当前登录用户ID
|
||||
* @param username 当前登录用户名
|
||||
* @return 批次ID
|
||||
*/
|
||||
String submitPullBankInfo(Long projectId,
|
||||
List<String> idCards,
|
||||
String startDate,
|
||||
String endDate,
|
||||
Long userId,
|
||||
String username);
|
||||
|
||||
/**
|
||||
* 查询上传记录列表
|
||||
*
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
package com.ruoyi.ccdi.project.service.impl;
|
||||
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.ruoyi.ccdi.project.domain.CcdiProject;
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO;
|
||||
import com.ruoyi.ccdi.project.domain.entity.CcdiBankStatement;
|
||||
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||
import com.ruoyi.ccdi.project.domain.excel.CcdiIdCardExcelRow;
|
||||
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
|
||||
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
|
||||
import com.ruoyi.lsfx.constants.LsfxConstants;
|
||||
import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest;
|
||||
import com.ruoyi.lsfx.domain.request.GetBankStatementRequest;
|
||||
import com.ruoyi.lsfx.domain.request.GetFileUploadStatusRequest;
|
||||
import com.ruoyi.lsfx.domain.response.*;
|
||||
@@ -34,10 +38,13 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 文件上传服务实现
|
||||
@@ -50,6 +57,8 @@ import java.util.concurrent.RejectedExecutionException;
|
||||
public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
|
||||
private static final int MAX_ERROR_MESSAGE_LENGTH = 2000;
|
||||
private static final Pattern ID_CARD_PATTERN =
|
||||
Pattern.compile("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])([0-2]\\d|3[01])\\d{3}[0-9Xx]$");
|
||||
|
||||
@Data
|
||||
private static class FetchBankStatementResult {
|
||||
@@ -88,6 +97,116 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
return uploadPath + File.separator + "temp";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> parseIdCardFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new RuntimeException("身份证文件不能为空");
|
||||
}
|
||||
|
||||
try {
|
||||
List<CcdiIdCardExcelRow> rows = EasyExcel.read(file.getInputStream())
|
||||
.head(CcdiIdCardExcelRow.class)
|
||||
.sheet(0)
|
||||
.headRowNumber(1)
|
||||
.doReadSync();
|
||||
|
||||
LinkedHashSet<String> idCards = new LinkedHashSet<>();
|
||||
for (CcdiIdCardExcelRow row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String idCard = row.getIdCard();
|
||||
if (!StringUtils.hasText(idCard)) {
|
||||
continue;
|
||||
}
|
||||
String normalized = idCard.trim();
|
||||
if (!ID_CARD_PATTERN.matcher(normalized).matches()) {
|
||||
continue;
|
||||
}
|
||||
idCards.add(normalized);
|
||||
}
|
||||
|
||||
if (idCards.isEmpty()) {
|
||||
throw new RuntimeException("首个sheet第一列未解析到有效身份证号");
|
||||
}
|
||||
return new ArrayList<>(idCards);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("读取身份证文件失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public String submitPullBankInfo(Long projectId,
|
||||
List<String> idCards,
|
||||
String startDate,
|
||||
String endDate,
|
||||
Long userId,
|
||||
String username) {
|
||||
if (projectId == null) {
|
||||
throw new IllegalArgumentException("项目ID不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(startDate) || !StringUtils.hasText(endDate)) {
|
||||
throw new IllegalArgumentException("开始日期和结束日期不能为空");
|
||||
}
|
||||
if (idCards == null || idCards.isEmpty()) {
|
||||
throw new IllegalArgumentException("身份证号不能为空");
|
||||
}
|
||||
|
||||
LocalDate start = LocalDate.parse(startDate);
|
||||
LocalDate end = LocalDate.parse(endDate);
|
||||
if (start.isAfter(end)) {
|
||||
throw new IllegalArgumentException("开始日期不能晚于结束日期");
|
||||
}
|
||||
|
||||
CcdiProject project = projectMapper.selectById(projectId);
|
||||
if (project == null) {
|
||||
throw new IllegalArgumentException("项目不存在: projectId=" + projectId);
|
||||
}
|
||||
Integer lsfxProjectId = project.getLsfxProjectId();
|
||||
if (lsfxProjectId == null) {
|
||||
throw new IllegalStateException("项目未关联流水分析平台: projectId=" + projectId);
|
||||
}
|
||||
|
||||
String batchId = UUID.randomUUID().toString().replace("-", "");
|
||||
Date now = new Date();
|
||||
List<CcdiFileUploadRecord> records = new ArrayList<>();
|
||||
List<String> normalizedIdCards = new ArrayList<>();
|
||||
for (String idCard : idCards) {
|
||||
if (!StringUtils.hasText(idCard)) {
|
||||
continue;
|
||||
}
|
||||
String normalized = idCard.trim();
|
||||
normalizedIdCards.add(normalized);
|
||||
|
||||
CcdiFileUploadRecord record = new CcdiFileUploadRecord();
|
||||
record.setProjectId(projectId);
|
||||
record.setLsfxProjectId(lsfxProjectId);
|
||||
record.setFileName(normalized);
|
||||
record.setFileSize(0L);
|
||||
record.setFileStatus("uploading");
|
||||
record.setAccountNos(normalized);
|
||||
record.setUploadTime(now);
|
||||
record.setUploadUser(username);
|
||||
records.add(record);
|
||||
}
|
||||
if (records.isEmpty()) {
|
||||
throw new IllegalArgumentException("身份证号不能为空");
|
||||
}
|
||||
|
||||
recordMapper.insertBatch(records);
|
||||
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
CompletableFuture.runAsync(() -> submitPullBankInfoTasks(
|
||||
projectId, lsfxProjectId, records, normalizedIdCards, startDate, endDate, batchId
|
||||
));
|
||||
}
|
||||
});
|
||||
return batchId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<CcdiFileUploadRecord> selectPage(Page<CcdiFileUploadRecord> page,
|
||||
CcdiFileUploadQueryDTO queryDTO) {
|
||||
@@ -351,6 +470,88 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
statement.setLeAccountNo(trimAccountNo(statement.getLeAccountNo()));
|
||||
}
|
||||
|
||||
private void submitPullBankInfoTasks(Long projectId,
|
||||
Integer lsfxProjectId,
|
||||
List<CcdiFileUploadRecord> records,
|
||||
List<String> idCards,
|
||||
String startDate,
|
||||
String endDate,
|
||||
String batchId) {
|
||||
log.info("【拉取本行信息】调度线程启动: projectId={}, batchId={}", projectId, batchId);
|
||||
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
log.warn("【拉取本行信息】调度线程被中断,停止提交剩余任务");
|
||||
break;
|
||||
}
|
||||
|
||||
CcdiFileUploadRecord record = records.get(i);
|
||||
String idCard = idCards.get(i);
|
||||
boolean submitted = false;
|
||||
int retryCount = 0;
|
||||
|
||||
while (!submitted && retryCount < 2) {
|
||||
try {
|
||||
CompletableFuture.runAsync(
|
||||
() -> processPullBankInfoAsync(projectId, lsfxProjectId, record, idCard, startDate, endDate),
|
||||
fileUploadExecutor
|
||||
);
|
||||
submitted = true;
|
||||
log.info("【拉取本行信息】任务提交成功: idCard={}, recordId={}", idCard, record.getId());
|
||||
} catch (RejectedExecutionException e) {
|
||||
retryCount++;
|
||||
if (retryCount == 1) {
|
||||
log.warn("【拉取本行信息】线程池已满,等待30秒后重试: idCard={}", idCard);
|
||||
try {
|
||||
Thread.sleep(30000);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("【拉取本行信息】等待被中断: idCard={}", idCard);
|
||||
updateRecordStatus(record.getId(), "parsed_failed", "任务提交被中断");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
log.error("【拉取本行信息】重试失败,放弃任务: idCard={}", idCard);
|
||||
updateRecordStatus(record.getId(), "parsed_failed", "系统繁忙,请稍后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void processPullBankInfoAsync(Long projectId,
|
||||
Integer lsfxProjectId,
|
||||
CcdiFileUploadRecord record,
|
||||
String idCard,
|
||||
String startDate,
|
||||
String endDate ) {
|
||||
try {
|
||||
FetchInnerFlowRequest request = new FetchInnerFlowRequest();
|
||||
request.setGroupId(lsfxProjectId);
|
||||
request.setCustomerNo(idCard);
|
||||
request.setDataChannelCode(LsfxConstants.DEFAULT_DATA_CHANNEL_CODE);
|
||||
request.setRequestDateId(Integer.parseInt(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)));
|
||||
request.setDataStartDateId(Integer.parseInt(startDate.replace("-", "")));
|
||||
request.setDataEndDateId(Integer.parseInt(endDate.replace("-", "")));
|
||||
request.setUploadUserId(LsfxConstants.DEFAULT_USER_ID);
|
||||
|
||||
FetchInnerFlowResponse response = lsfxClient.fetchInnerFlow(request);
|
||||
if (response == null || response.getData() == null || response.getData().isEmpty()) {
|
||||
throw new RuntimeException("拉取本行信息失败: 未返回logId");
|
||||
}
|
||||
|
||||
Integer logId = response.getData().get(0);
|
||||
if (logId == null) {
|
||||
throw new RuntimeException("拉取本行信息失败: 未返回logId");
|
||||
}
|
||||
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId);
|
||||
} catch (Exception e) {
|
||||
log.error("【拉取本行信息】处理失败: idCard={}, recordId={}", idCard, record.getId(), e);
|
||||
updateFailedRecord(record, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步处理单个文件的完整流程
|
||||
* 包含:上传 → 轮询解析状态 → 获取结果 → 保存流水数据
|
||||
@@ -399,84 +600,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
}
|
||||
|
||||
log.info("【文件上传】文件上传成功: logId={}", logId);
|
||||
|
||||
// 步骤3:更新状态为 parsing
|
||||
log.info("【文件上传】步骤3: 更新状态为解析中, logId={}", logId);
|
||||
record.setLogId(logId);
|
||||
record.setFileStatus("parsing");
|
||||
recordMapper.updateById(record);
|
||||
|
||||
// 步骤4:轮询解析状态(最多300次,间隔2秒)
|
||||
log.info("【文件上传】步骤4: 开始轮询解析状态");
|
||||
boolean parsingComplete = waitForParsingComplete(lsfxProjectId, logId.toString());
|
||||
|
||||
if (!parsingComplete) {
|
||||
throw new RuntimeException("解析超时(超过10分钟),请检查文件格式是否正确");
|
||||
}
|
||||
|
||||
// 步骤5:获取文件上传状态
|
||||
log.info("【文件上传】步骤5: 获取文件上传状态: logId={}", logId);
|
||||
|
||||
GetFileUploadStatusRequest statusRequest = new GetFileUploadStatusRequest();
|
||||
statusRequest.setGroupId(lsfxProjectId);
|
||||
statusRequest.setLogId(logId);
|
||||
|
||||
GetFileUploadStatusResponse statusResponse = lsfxClient.getFileUploadStatus(statusRequest);
|
||||
|
||||
if (statusResponse == null || statusResponse.getData() == null
|
||||
|| statusResponse.getData().getLogs() == null
|
||||
|| statusResponse.getData().getLogs().isEmpty()) {
|
||||
throw new RuntimeException("获取文件上传状态失败: 响应数据为空");
|
||||
}
|
||||
|
||||
// 获取第一个log项(因为我们传了logId,应该只返回一个)
|
||||
GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0);
|
||||
Integer status = logItem.getStatus();
|
||||
String uploadStatusDesc = logItem.getUploadStatusDesc();
|
||||
|
||||
log.info("【文件上传】文件状态: status={}, uploadStatusDesc={}", status, uploadStatusDesc);
|
||||
|
||||
// 步骤6:判断解析结果
|
||||
// status=-5 且 uploadStatusDesc="data.wait.confirm.newaccount" 表示解析成功
|
||||
boolean parseSuccess = status != null && status == -5
|
||||
&& "data.wait.confirm.newaccount".equals(uploadStatusDesc);
|
||||
|
||||
if (parseSuccess) {
|
||||
// 解析成功
|
||||
log.info("【文件上传】步骤6: 解析成功,保存主体信息");
|
||||
|
||||
// 提取主体名称和账号
|
||||
List<String> enterpriseNames = logItem.getEnterpriseNameList();
|
||||
List<String> accountNos = logItem.getAccountNoList();
|
||||
|
||||
String enterpriseNamesStr = enterpriseNames != null ? String.join(",", enterpriseNames) : "";
|
||||
String accountNosStr = accountNos != null ? String.join(",", accountNos) : "";
|
||||
|
||||
|
||||
log.info("【文件上传】主体信息已保存: enterpriseNames={}, accountNos={}",
|
||||
enterpriseNamesStr, accountNosStr);
|
||||
|
||||
// 步骤7:获取流水数据并保存
|
||||
log.info("【文件上传】步骤7: 获取流水数据");
|
||||
FetchBankStatementResult fetchResult =
|
||||
fetchAndSaveBankStatements(projectId, lsfxProjectId, logId);
|
||||
if (!fetchResult.isSuccess()) {
|
||||
updateFailedRecord(record, fetchResult.getErrorMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
record.setFileStatus("parsed_success");
|
||||
record.setEnterpriseNames(enterpriseNamesStr);
|
||||
record.setAccountNos(accountNosStr);
|
||||
record.setErrorMessage(null);
|
||||
recordMapper.updateById(record);
|
||||
|
||||
} else {
|
||||
// 解析失败
|
||||
log.warn("【文件上传】步骤6: 解析失败: status={}, desc={}", status, uploadStatusDesc);
|
||||
updateFailedRecord(record, "解析失败: " + uploadStatusDesc);
|
||||
}
|
||||
|
||||
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId);
|
||||
log.info("【文件上传】处理完成: fileName={}", record.getFileName());
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -496,6 +620,74 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
private void processRecordAfterLogIdReady(Long projectId,
|
||||
Integer lsfxProjectId,
|
||||
CcdiFileUploadRecord record,
|
||||
Integer logId) {
|
||||
log.info("【文件上传】步骤3: 更新状态为解析中, logId={}", logId);
|
||||
record.setLogId(logId);
|
||||
record.setFileStatus("parsing");
|
||||
recordMapper.updateById(record);
|
||||
|
||||
log.info("【文件上传】步骤4: 开始轮询解析状态");
|
||||
boolean parsingComplete = waitForParsingComplete(lsfxProjectId, logId.toString());
|
||||
if (!parsingComplete) {
|
||||
throw new RuntimeException("解析超时(超过10分钟),请检查文件格式是否正确");
|
||||
}
|
||||
|
||||
log.info("【文件上传】步骤5: 获取文件上传状态: logId={}", logId);
|
||||
GetFileUploadStatusRequest statusRequest = new GetFileUploadStatusRequest();
|
||||
statusRequest.setGroupId(lsfxProjectId);
|
||||
statusRequest.setLogId(logId);
|
||||
|
||||
GetFileUploadStatusResponse statusResponse = lsfxClient.getFileUploadStatus(statusRequest);
|
||||
if (statusResponse == null || statusResponse.getData() == null
|
||||
|| statusResponse.getData().getLogs() == null
|
||||
|| statusResponse.getData().getLogs().isEmpty()) {
|
||||
throw new RuntimeException("获取文件上传状态失败: 响应数据为空");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
log.info("【文件上传】文件状态: status={}, uploadStatusDesc={}", status, uploadStatusDesc);
|
||||
boolean parseSuccess = status != null && status == -5
|
||||
&& "data.wait.confirm.newaccount".equals(uploadStatusDesc);
|
||||
if (!parseSuccess) {
|
||||
log.warn("【文件上传】步骤6: 解析失败: status={}, desc={}", status, uploadStatusDesc);
|
||||
updateFailedRecord(record, "解析失败: " + uploadStatusDesc);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("【文件上传】步骤6: 解析成功,保存主体信息");
|
||||
List<String> enterpriseNames = logItem.getEnterpriseNameList();
|
||||
List<String> accountNos = logItem.getAccountNoList();
|
||||
String enterpriseNamesStr = enterpriseNames != null ? String.join(",", enterpriseNames) : "";
|
||||
String accountNosStr = accountNos != null ? String.join(",", accountNos) : "";
|
||||
log.info("【文件上传】主体信息已保存: enterpriseNames={}, accountNos={}",
|
||||
enterpriseNamesStr, accountNosStr);
|
||||
|
||||
log.info("【文件上传】步骤7: 获取流水数据");
|
||||
FetchBankStatementResult fetchResult = fetchAndSaveBankStatements(projectId, lsfxProjectId, logId);
|
||||
if (!fetchResult.isSuccess()) {
|
||||
updateFailedRecord(record, fetchResult.getErrorMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
record.setFileStatus("parsed_success");
|
||||
record.setEnterpriseNames(enterpriseNamesStr);
|
||||
record.setAccountNos(accountNosStr);
|
||||
record.setErrorMessage(null);
|
||||
recordMapper.updateById(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询解析状态(固定间隔2秒,最多300次)
|
||||
*
|
||||
@@ -665,4 +857,11 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
|
||||
private void cleanupBankStatements(Long projectId, Integer logId) {
|
||||
bankStatementMapper.deleteByProjectIdAndBatchId(projectId, logId);
|
||||
}
|
||||
|
||||
private Integer toUploadUserId(Long userId) {
|
||||
if (userId == null) {
|
||||
throw new IllegalArgumentException("当前登录用户ID不能为空");
|
||||
}
|
||||
return Math.toIntExact(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<result property="paymentMethod" column="paymentMethod"/>
|
||||
<result property="cretNo" column="cretNo"/>
|
||||
<result property="createDate" column="createDate"/>
|
||||
<result property="originalFileName" column="originalFileName"/>
|
||||
<result property="uploadTime" column="uploadTime"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="CcdiBankStatementFilterOptionsVOResultMap"
|
||||
@@ -313,8 +315,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
bs.internal_flag AS internalFlag,
|
||||
bs.payment_method AS paymentMethod,
|
||||
bs.cret_no AS cretNo,
|
||||
bs.CREATE_DATE AS createDate
|
||||
bs.CREATE_DATE AS createDate,
|
||||
fur.file_name AS originalFileName,
|
||||
fur.upload_time AS uploadTime
|
||||
FROM ccdi_bank_statement bs
|
||||
LEFT JOIN ccdi_file_upload_record fur ON fur.log_id = bs.batch_id AND fur.project_id = bs.project_id
|
||||
WHERE bs.bank_statement_id = #{bankStatementId}
|
||||
</select>
|
||||
|
||||
|
||||
@@ -30,13 +30,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<insert id="insertBatch" parameterType="java.util.List" useGeneratedKeys="true" keyProperty="id">
|
||||
insert into ccdi_file_upload_record (
|
||||
project_id, lsfx_project_id, file_name, file_size, file_status,
|
||||
upload_time, upload_user
|
||||
enterprise_names, account_nos, upload_time, upload_user
|
||||
) values
|
||||
<foreach collection="list" item="item" separator=",">
|
||||
(
|
||||
#{item.projectId}, #{item.lsfxProjectId}, #{item.fileName},
|
||||
#{item.fileSize}, #{item.fileStatus}, #{item.uploadTime},
|
||||
#{item.uploadUser}
|
||||
#{item.fileSize}, #{item.fileStatus}, #{item.enterpriseNames},
|
||||
#{item.accountNos}, #{item.uploadTime}, #{item.uploadUser}
|
||||
)
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ruoyi.ccdi.project.controller;
|
||||
|
||||
import com.ruoyi.ccdi.project.domain.dto.CcdiPullBankInfoSubmitDTO;
|
||||
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CcdiFileUploadControllerTest {
|
||||
|
||||
private static final Long PROJECT_ID = 100L;
|
||||
|
||||
@InjectMocks
|
||||
private CcdiFileUploadController controller;
|
||||
|
||||
@Mock
|
||||
private ICcdiFileUploadService fileUploadService;
|
||||
|
||||
@Test
|
||||
void parseIdCardFile_shouldReturnAjaxResultSuccess() {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"ids.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"test".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
when(fileUploadService.parseIdCardFile(file)).thenReturn(List.of("110101199001018888"));
|
||||
|
||||
AjaxResult result = controller.parseIdCardFile(file);
|
||||
|
||||
assertEquals(200, result.get("code"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pullBankInfo_shouldUseCurrentLoginUserInfo() {
|
||||
CcdiPullBankInfoSubmitDTO dto = new CcdiPullBankInfoSubmitDTO();
|
||||
dto.setProjectId(PROJECT_ID);
|
||||
dto.setIdCards(List.of("110101199001018888"));
|
||||
dto.setStartDate("2026-03-01");
|
||||
dto.setEndDate("2026-03-10");
|
||||
|
||||
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
|
||||
mocked.when(SecurityUtils::getUserId).thenReturn(9527L);
|
||||
mocked.when(SecurityUtils::getUsername).thenReturn("admin");
|
||||
when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "2026-03-01", "2026-03-10", 9527L, "admin"))
|
||||
.thenReturn("batch-1");
|
||||
|
||||
AjaxResult result = controller.pullBankInfo(dto);
|
||||
|
||||
assertEquals(200, result.get("code"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,20 @@ class CcdiBankStatementMapperXmlTest {
|
||||
assertTrue(sql.contains("END ) <= ?"), sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectStatementDetailById_shouldJoinUploadRecordForOriginalFileMetadata() throws Exception {
|
||||
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
|
||||
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(
|
||||
xml.contains("LEFT JOIN ccdi_file_upload_record fur ON fur.log_id = bs.batch_id AND fur.project_id = bs.project_id"),
|
||||
xml
|
||||
);
|
||||
assertTrue(xml.contains("fur.file_name AS originalFileName"), xml);
|
||||
assertTrue(xml.contains("fur.upload_time AS uploadTime"), xml);
|
||||
}
|
||||
}
|
||||
|
||||
private MappedStatement loadMappedStatement(String statementId) throws Exception {
|
||||
Configuration configuration = new Configuration();
|
||||
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource()));
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.ruoyi.ccdi.project.service.impl;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.ruoyi.ccdi.project.domain.CcdiProject;
|
||||
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
|
||||
import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
|
||||
@@ -10,6 +12,7 @@ import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
|
||||
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
|
||||
import com.ruoyi.lsfx.domain.request.GetBankStatementRequest;
|
||||
import com.ruoyi.lsfx.domain.response.CheckParseStatusResponse;
|
||||
import com.ruoyi.lsfx.domain.response.FetchInnerFlowResponse;
|
||||
import com.ruoyi.lsfx.domain.response.GetBankStatementResponse;
|
||||
import com.ruoyi.lsfx.domain.response.GetFileUploadStatusResponse;
|
||||
import com.ruoyi.lsfx.domain.response.UploadFileResponse;
|
||||
@@ -20,8 +23,12 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
@@ -31,10 +38,13 @@ import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
@@ -72,6 +82,68 @@ class CcdiFileUploadServiceImplTest {
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void parseIdCardFile_shouldReadFirstSheetFirstColumnAndDeduplicate() throws Exception {
|
||||
MultipartFile file = createIdCardExcel(
|
||||
"身份证号",
|
||||
"110101199001018888",
|
||||
"",
|
||||
"110101199001018888",
|
||||
"110101199001019999"
|
||||
);
|
||||
|
||||
List<String> result = service.parseIdCardFile(file);
|
||||
|
||||
assertEquals(List.of("110101199001018888", "110101199001019999"), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseIdCardFile_shouldRejectInvalidIdCard() throws Exception {
|
||||
MultipartFile file = createIdCardExcel("身份证号", "123456");
|
||||
|
||||
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.parseIdCardFile(file));
|
||||
|
||||
assertTrue(exception.getMessage().contains("身份证"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitPullBankInfo_shouldInsertUploadingRecordsWithIdCardAsAccountNo() {
|
||||
CcdiProject project = new CcdiProject();
|
||||
project.setProjectId(PROJECT_ID);
|
||||
project.setLsfxProjectId(LSFX_PROJECT_ID);
|
||||
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
|
||||
|
||||
AtomicReference<List<CcdiFileUploadRecord>> inserted = new AtomicReference<>();
|
||||
doAnswer(invocation -> {
|
||||
List<CcdiFileUploadRecord> records = invocation.getArgument(0);
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
records.get(i).setId((long) (i + 1));
|
||||
}
|
||||
inserted.set(new ArrayList<>(records));
|
||||
return records.size();
|
||||
}).when(recordMapper).insertBatch(any());
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
String batchId = service.submitPullBankInfo(
|
||||
PROJECT_ID,
|
||||
List.of("110101199001018888", "110101199001019999"),
|
||||
"2026-03-01",
|
||||
"2026-03-10",
|
||||
9527L,
|
||||
"admin"
|
||||
);
|
||||
|
||||
assertNotNull(batchId);
|
||||
assertEquals("110101199001018888", inserted.get().get(0).getAccountNos());
|
||||
assertEquals("admin", inserted.get().get(0).getUploadUser());
|
||||
assertEquals("uploading", inserted.get().get(0).getFileStatus());
|
||||
assertEquals(1, TransactionSynchronizationManager.getSynchronizations().size());
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitTasksAsync_shouldNotCreateLocalBatchLogFiles() throws Exception {
|
||||
setField("uploadPath", tempDir.toString());
|
||||
@@ -151,6 +223,51 @@ class CcdiFileUploadServiceImplTest {
|
||||
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
|
||||
}
|
||||
|
||||
// @Test
|
||||
// void processPullBankInfoAsync_shouldUpdateFileNameFromStatusResponse() {
|
||||
// when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID));
|
||||
// when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
// .thenReturn(buildCheckParseStatusResponse(false));
|
||||
// when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse("XX身份证.xlsx"));
|
||||
// when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
// .thenReturn(buildEmptyBankStatementResponse());
|
||||
//
|
||||
// CcdiFileUploadRecord record = buildRecord();
|
||||
// service.processPullBankInfoAsync(
|
||||
// PROJECT_ID,
|
||||
// LSFX_PROJECT_ID,
|
||||
// record,
|
||||
// "110101199001018888",
|
||||
// "2026-03-01",
|
||||
// "2026-03-10",
|
||||
// 9527L
|
||||
// );
|
||||
//
|
||||
// verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
|
||||
// "XX身份证.xlsx".equals(item.getFileName())));
|
||||
// verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
|
||||
// "parsed_success".equals(item.getFileStatus())));
|
||||
// }
|
||||
|
||||
// @Test
|
||||
// void processPullBankInfoAsync_shouldMarkParsedFailedWhenFetchInnerFlowThrows() {
|
||||
// when(lsfxClient.fetchInnerFlow(any())).thenThrow(new RuntimeException("fetch inner flow failed"));
|
||||
//
|
||||
// CcdiFileUploadRecord record = buildRecord();
|
||||
// service.processPullBankInfoAsync(
|
||||
// PROJECT_ID,
|
||||
// LSFX_PROJECT_ID,
|
||||
// record,
|
||||
// "110101199001018888",
|
||||
// "2026-03-01",
|
||||
// "2026-03-10",
|
||||
// 9527L
|
||||
// );
|
||||
//
|
||||
// verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
|
||||
// "parsed_failed".equals(item.getFileStatus())));
|
||||
// }
|
||||
|
||||
@Test
|
||||
void processFileAsync_shouldFailWhenPagedFetchThrows() throws IOException {
|
||||
List<String> events = new ArrayList<>();
|
||||
@@ -364,6 +481,18 @@ class CcdiFileUploadServiceImplTest {
|
||||
return response;
|
||||
}
|
||||
|
||||
private FetchInnerFlowResponse buildFetchInnerFlowResponse(Integer logId) {
|
||||
FetchInnerFlowResponse response = new FetchInnerFlowResponse();
|
||||
response.setData(List.of(logId));
|
||||
return response;
|
||||
}
|
||||
|
||||
private GetFileUploadStatusResponse buildParsedSuccessStatusResponse(String uploadFileName) {
|
||||
GetFileUploadStatusResponse response = buildParsedSuccessStatusResponse();
|
||||
response.getData().getLogs().get(0).setUploadFileName(uploadFileName);
|
||||
return response;
|
||||
}
|
||||
|
||||
private GetBankStatementResponse buildEmptyBankStatementResponse() {
|
||||
GetBankStatementResponse.BankStatementData data = new GetBankStatementResponse.BankStatementData();
|
||||
data.setTotalCount(0);
|
||||
@@ -420,6 +549,21 @@ class CcdiFileUploadServiceImplTest {
|
||||
return item;
|
||||
}
|
||||
|
||||
private MultipartFile createIdCardExcel(String... values) throws IOException {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
List<List<String>> rows = new ArrayList<>();
|
||||
for (String value : values) {
|
||||
rows.add(List.of(value));
|
||||
}
|
||||
EasyExcel.write(outputStream).sheet("Sheet1").doWrite(rows);
|
||||
return new MockMultipartFile(
|
||||
"file",
|
||||
"id-cards.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
outputStream.toByteArray()
|
||||
);
|
||||
}
|
||||
|
||||
private int findEventIndex(List<String> events, String suffix) {
|
||||
for (int i = 0; i < events.size(); i++) {
|
||||
if (events.get(i).endsWith(suffix)) {
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
# Project Detail Pull Bank Info Backend Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build the backend parse-and-pull workflow for the project detail “拉取本行信息” modal, including ID-card Excel parsing, task submission, thread-pool scheduling, file-upload record updates, and bank-statement ingestion reuse.
|
||||
|
||||
**Architecture:** Extend the existing `CcdiFileUploadController` and `CcdiFileUploadServiceImpl` instead of creating a second task subsystem. Parse身份证文件 on demand, persist one `ccdi_file_upload_record` per身份证 with `accountNos` initialized to the身份证号, then reuse the existing `fileUploadExecutor` plus a shared “logId ready” pipeline to update file name, polling status, and bank statements consistently for both uploaded files and pulled inner-flow tasks.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, MyBatis XML, EasyExcel 3.3.4, JUnit 5, Mockito
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add parse/submit contracts and write the first failing parse test
|
||||
|
||||
**Files:**
|
||||
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiPullBankInfoSubmitDTO.java`
|
||||
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiIdCardParseVO.java`
|
||||
- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiIdCardExcelRow.java`
|
||||
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java`
|
||||
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
在 `CcdiFileUploadServiceImplTest` 中新增身份证文件解析测试,验证“首个 sheet 第一列、忽略表头、空行、重复值”:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void parseIdCardFile_shouldReadFirstSheetFirstColumnAndDeduplicate() throws Exception {
|
||||
MultipartFile file = createIdCardExcel(
|
||||
"身份证号",
|
||||
"110101199001018888",
|
||||
"",
|
||||
"110101199001018888",
|
||||
"110101199001019999"
|
||||
);
|
||||
|
||||
List<String> result = service.parseIdCardFile(file);
|
||||
|
||||
assertEquals(List.of("110101199001018888", "110101199001019999"), result);
|
||||
}
|
||||
```
|
||||
|
||||
再补一个非法身份证失败测试:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void parseIdCardFile_shouldRejectInvalidIdCard() throws Exception {
|
||||
MultipartFile file = createIdCardExcel("身份证号", "123456");
|
||||
|
||||
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.parseIdCardFile(file));
|
||||
|
||||
assertTrue(exception.getMessage().contains("身份证"));
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest`
|
||||
|
||||
Expected: FAIL because `parseIdCardFile` and the new DTO / VO / Excel row model do not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
在 `ICcdiFileUploadService` 中新增方法:
|
||||
|
||||
```java
|
||||
List<String> parseIdCardFile(MultipartFile file);
|
||||
```
|
||||
|
||||
创建 `CcdiIdCardExcelRow`:
|
||||
|
||||
```java
|
||||
@Data
|
||||
public class CcdiIdCardExcelRow {
|
||||
|
||||
@ExcelProperty(index = 0)
|
||||
private String idCard;
|
||||
}
|
||||
```
|
||||
|
||||
在 `CcdiFileUploadServiceImpl` 中用 EasyExcel 实现最小可用解析逻辑:
|
||||
|
||||
```java
|
||||
List<CcdiIdCardExcelRow> rows = EasyExcel.read(file.getInputStream(), CcdiIdCardExcelRow.class)
|
||||
.sheet(0)
|
||||
.headRowNumber(1)
|
||||
.doReadSync();
|
||||
```
|
||||
|
||||
然后:
|
||||
|
||||
- `trim()` 去空白
|
||||
- 过滤空值
|
||||
- 用 `LinkedHashSet` 去重保序
|
||||
- 使用 18 位身份证正则校验
|
||||
- 当无有效身份证时抛出 `RuntimeException("首个sheet第一列未解析到有效身份证号")`
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest`
|
||||
|
||||
Expected: PASS for the new parse tests; existing tests remain green.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiPullBankInfoSubmitDTO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiIdCardParseVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiIdCardExcelRow.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
|
||||
git commit -m "新增拉取本行信息参数模型与身份证解析能力"
|
||||
```
|
||||
|
||||
### Task 2: Add pull-bank-info submission logic and make record initialization fail first
|
||||
|
||||
**Files:**
|
||||
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java`
|
||||
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
|
||||
- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml`
|
||||
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
新增“提交拉取任务时先插入上传记录”的测试,验证 `accountNos` 和 `uploadUser` 初始化正确:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void submitPullBankInfo_shouldInsertUploadingRecordsWithIdCardAsAccountNo() {
|
||||
CcdiProject project = new CcdiProject();
|
||||
project.setProjectId(PROJECT_ID);
|
||||
project.setLsfxProjectId(LSFX_PROJECT_ID);
|
||||
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
|
||||
|
||||
AtomicReference<List<CcdiFileUploadRecord>> inserted = new AtomicReference<>();
|
||||
doAnswer(invocation -> {
|
||||
List<CcdiFileUploadRecord> records = invocation.getArgument(0);
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
records.get(i).setId((long) (i + 1));
|
||||
}
|
||||
inserted.set(new ArrayList<>(records));
|
||||
return records.size();
|
||||
}).when(recordMapper).insertBatch(any());
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
String batchId = service.submitPullBankInfo(
|
||||
PROJECT_ID,
|
||||
List.of("110101199001018888", "110101199001019999"),
|
||||
"2026-03-01",
|
||||
"2026-03-10",
|
||||
9527L,
|
||||
"admin"
|
||||
);
|
||||
|
||||
assertNotNull(batchId);
|
||||
assertEquals("110101199001018888", inserted.get().get(0).getAccountNos());
|
||||
assertEquals("admin", inserted.get().get(0).getUploadUser());
|
||||
assertEquals("uploading", inserted.get().get(0).getFileStatus());
|
||||
assertEquals(1, TransactionSynchronizationManager.getSynchronizations().size());
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest`
|
||||
|
||||
Expected: FAIL because `submitPullBankInfo` does not exist and `insertBatch` SQL does not persist `accountNos`.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
在 `ICcdiFileUploadService` 中新增方法:
|
||||
|
||||
```java
|
||||
String submitPullBankInfo(Long projectId,
|
||||
List<String> idCards,
|
||||
String startDate,
|
||||
String endDate,
|
||||
Long userId,
|
||||
String username);
|
||||
```
|
||||
|
||||
在 `CcdiFileUploadServiceImpl` 中实现:
|
||||
|
||||
- 校验项目存在且带有 `lsfxProjectId`
|
||||
- 校验日期非空、开始日期不大于结束日期
|
||||
- 校验身份证集合非空
|
||||
- 为每个身份证创建一条 `CcdiFileUploadRecord`
|
||||
- `record.setAccountNos(idCard);`
|
||||
- `record.setFileName(idCard);`
|
||||
- `record.setUploadUser(username);`
|
||||
- `record.setFileStatus("uploading");`
|
||||
- `record.setUploadTime(new Date());`
|
||||
|
||||
在 `CcdiFileUploadRecordMapper.xml` 的 `insertBatch` 中补上 `account_nos`:
|
||||
|
||||
```xml
|
||||
insert into ccdi_file_upload_record (
|
||||
project_id, lsfx_project_id, file_name, file_size, file_status,
|
||||
enterprise_names, account_nos, upload_time, upload_user
|
||||
)
|
||||
```
|
||||
|
||||
注册事务提交后的异步调度:
|
||||
|
||||
```java
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
CompletableFuture.runAsync(() -> submitPullBankInfoTasks(...));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest`
|
||||
|
||||
Expected: PASS for the new submission test and no regression in existing upload tests.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
|
||||
git commit -m "实现拉取本行信息任务提交与记录初始化"
|
||||
```
|
||||
|
||||
### Task 3: Refactor the shared logId pipeline and add the first failing pull-flow test
|
||||
|
||||
**Files:**
|
||||
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
|
||||
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
新增“拉取行内流水拿到 `logId` 后复用公共流水线”的测试,先验证文件名回写和最终成功:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void processPullBankInfoAsync_shouldUpdateFileNameFromStatusResponse() {
|
||||
when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID));
|
||||
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
|
||||
.thenReturn(buildCheckParseStatusResponse(false));
|
||||
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse("XX身份证.xlsx"));
|
||||
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
|
||||
.thenReturn(buildEmptyBankStatementResponse());
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
service.processPullBankInfoAsync(
|
||||
PROJECT_ID,
|
||||
LSFX_PROJECT_ID,
|
||||
record,
|
||||
"110101199001018888",
|
||||
"2026-03-01",
|
||||
"2026-03-10",
|
||||
9527L
|
||||
);
|
||||
|
||||
verify(recordMapper, atLeastOnce()).updateById(argThat(item ->
|
||||
"XX身份证.xlsx".equals(item.getFileName())));
|
||||
verify(recordMapper, atLeastOnce()).updateById(argThat(item ->
|
||||
"parsed_success".equals(item.getFileStatus())));
|
||||
}
|
||||
```
|
||||
|
||||
再补一个失败测试,验证 `fetchInnerFlow` 异常只影响当前记录:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void processPullBankInfoAsync_shouldMarkParsedFailedWhenFetchInnerFlowThrows() {
|
||||
when(lsfxClient.fetchInnerFlow(any())).thenThrow(new RuntimeException("fetch inner flow failed"));
|
||||
|
||||
CcdiFileUploadRecord record = buildRecord();
|
||||
service.processPullBankInfoAsync(
|
||||
PROJECT_ID,
|
||||
LSFX_PROJECT_ID,
|
||||
record,
|
||||
"110101199001018888",
|
||||
"2026-03-01",
|
||||
"2026-03-10",
|
||||
9527L
|
||||
);
|
||||
|
||||
verify(recordMapper, atLeastOnce()).updateById(argThat(item ->
|
||||
"parsed_failed".equals(item.getFileStatus())));
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest`
|
||||
|
||||
Expected: FAIL because `processPullBankInfoAsync` and the shared “logId ready” pipeline do not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
在 `CcdiFileUploadServiceImpl` 中拆分两段逻辑:
|
||||
|
||||
1. 文件来源阶段
|
||||
- `processFileAsync(...)` 中只负责上传文件并拿到 `logId`
|
||||
- `processPullBankInfoAsync(...)` 中只负责调用 `fetchInnerFlow` 并拿到 `logId`
|
||||
|
||||
2. 公共处理阶段
|
||||
- 新增 `processRecordAfterLogIdReady(...)`
|
||||
|
||||
公共处理方法至少负责:
|
||||
|
||||
```java
|
||||
private void processRecordAfterLogIdReady(Long projectId,
|
||||
Integer lsfxProjectId,
|
||||
CcdiFileUploadRecord record,
|
||||
Integer logId) {
|
||||
record.setLogId(logId);
|
||||
record.setFileStatus("parsing");
|
||||
recordMapper.updateById(record);
|
||||
boolean parsingComplete = waitForParsingComplete(lsfxProjectId, logId.toString());
|
||||
GetFileUploadStatusResponse statusResponse = lsfxClient.getFileUploadStatus(statusRequest);
|
||||
String fileName = StringUtils.hasText(logItem.getUploadFileName())
|
||||
? logItem.getUploadFileName()
|
||||
: logItem.getDownloadFileName();
|
||||
record.setFileName(fileName);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`processPullBankInfoAsync(...)` 最小实现:
|
||||
|
||||
```java
|
||||
FetchInnerFlowRequest request = new FetchInnerFlowRequest();
|
||||
request.setGroupId(lsfxProjectId);
|
||||
request.setCustomerNo(idCard);
|
||||
request.setDataChannelCode("ZJRCU");
|
||||
request.setRequestDateId(Integer.parseInt(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)));
|
||||
request.setDataStartDateId(Integer.parseInt(startDate.replace("-", "")));
|
||||
request.setDataEndDateId(Integer.parseInt(endDate.replace("-", "")));
|
||||
request.setUploadUserId(userId.intValue());
|
||||
```
|
||||
|
||||
从 `FetchInnerFlowResponse.getData()` 里取第一个 `logId` 后进入公共处理方法。
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest`
|
||||
|
||||
Expected: PASS for the new pull-flow tests and the existing file-upload tests.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
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 commit -m "抽取logId后公共处理流水线并接入本行拉取"
|
||||
```
|
||||
|
||||
### Task 4: Add controller endpoints and fail on controller tests first
|
||||
|
||||
**Files:**
|
||||
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`
|
||||
- Create: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
为解析接口和提交接口各写一个控制器测试:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void parseIdCardFile_shouldReturnAjaxResultSuccess() {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"ids.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"test".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
when(fileUploadService.parseIdCardFile(file)).thenReturn(List.of("110101199001018888"));
|
||||
|
||||
AjaxResult result = controller.parseIdCardFile(file);
|
||||
|
||||
assertEquals(200, result.get("code"));
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
@Test
|
||||
void pullBankInfo_shouldUseCurrentLoginUserInfo() {
|
||||
CcdiPullBankInfoSubmitDTO dto = new CcdiPullBankInfoSubmitDTO();
|
||||
dto.setProjectId(PROJECT_ID);
|
||||
dto.setIdCards(List.of("110101199001018888"));
|
||||
dto.setStartDate("2026-03-01");
|
||||
dto.setEndDate("2026-03-10");
|
||||
|
||||
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
|
||||
mocked.when(SecurityUtils::getUserId).thenReturn(9527L);
|
||||
mocked.when(SecurityUtils::getUserName).thenReturn("admin");
|
||||
when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "2026-03-01", "2026-03-10", 9527L, "admin"))
|
||||
.thenReturn("batch-1");
|
||||
|
||||
AjaxResult result = controller.pullBankInfo(dto);
|
||||
|
||||
assertEquals(200, result.get("code"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadControllerTest`
|
||||
|
||||
Expected: FAIL because the new controller methods do not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
在 `CcdiFileUploadController` 中新增接口:
|
||||
|
||||
```java
|
||||
@PostMapping("/parse-id-card-file")
|
||||
public AjaxResult parseIdCardFile(@RequestParam MultipartFile file) {
|
||||
List<String> idCards = fileUploadService.parseIdCardFile(file);
|
||||
return AjaxResult.success("解析成功", new CcdiIdCardParseVO(idCards, idCards.size()));
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
@PostMapping("/pull-bank-info")
|
||||
public AjaxResult pullBankInfo(@RequestBody CcdiPullBankInfoSubmitDTO dto) {
|
||||
Long userId = SecurityUtils.getUserId();
|
||||
String username = SecurityUtils.getUserName();
|
||||
String batchId = fileUploadService.submitPullBankInfo(
|
||||
dto.getProjectId(),
|
||||
dto.getIdCards(),
|
||||
dto.getStartDate(),
|
||||
dto.getEndDate(),
|
||||
userId,
|
||||
username
|
||||
);
|
||||
return AjaxResult.success("拉取任务已提交", batchId);
|
||||
}
|
||||
```
|
||||
|
||||
补上参数校验和 Swagger 注释。
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadControllerTest`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java
|
||||
git commit -m "新增拉取本行信息后端接口"
|
||||
```
|
||||
|
||||
### Task 5: Verify the backend end-to-end inside the module
|
||||
|
||||
**Files:**
|
||||
- Modify if needed after failures: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`
|
||||
- Modify if needed after failures: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
|
||||
- Modify if needed after failures: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml`
|
||||
- Modify if needed after failures: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
|
||||
- Modify if needed after failures: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java`
|
||||
|
||||
**Step 1: Run focused backend tests**
|
||||
|
||||
Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest,CcdiFileUploadControllerTest`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 2: Run module compile**
|
||||
|
||||
Run: `mvn clean compile -pl ccdi-project -am`
|
||||
|
||||
Expected: BUILD SUCCESS
|
||||
|
||||
**Step 3: Run the existing upload regression tests**
|
||||
|
||||
Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest`
|
||||
|
||||
Expected: PASS with no regression on file-upload flow, no MyBatis binding error, and no missing mapper statements.
|
||||
|
||||
**Step 4: Fix the smallest failing point if verification breaks**
|
||||
|
||||
优先排查:
|
||||
|
||||
- `insertBatch` 的 XML 列顺序与实体字段不一致
|
||||
- `uploadFileName` / `downloadFileName` 回写逻辑遗漏空值判断
|
||||
- `FetchInnerFlowResponse` 取 `logId` 时未处理空列表
|
||||
- `Long userId` 转 `Integer uploadUserId` 时的空值或溢出保护
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java
|
||||
git commit -m "完成拉取本行信息后端实现与校验"
|
||||
```
|
||||
390
docs/plans/2026-03-11-project-detail-pull-bank-info-design.md
Normal file
390
docs/plans/2026-03-11-project-detail-pull-bank-info-design.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# 项目详情拉取本行信息设计
|
||||
|
||||
## 概述
|
||||
|
||||
本次设计面向项目详情页“上传数据”菜单中的“拉取本行信息”能力。用户在页面点击按钮后,弹出录入弹窗,支持手动输入身份证号、上传身份证 Excel 文件自动解析回填、选择时间跨度,然后提交后台异步拉取本行流水。
|
||||
|
||||
后端以项目现有“文件上传记录 + 线程池 + 落本地流水表”的链路为基础实现,不再新增第二套独立任务体系。每个身份证对应一条文件上传记录和一个线程任务,先调用流水分析平台“拉取行内流水”接口获取 `logId`,再复用现有“解析状态轮询 -> 获取文件上传状态 -> 获取流水列表并入库”的后半段处理链路。
|
||||
|
||||
## 已确认范围
|
||||
|
||||
- 页面入口保留在 `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
- 点击“拉取本行信息”后弹出表单弹窗,不再使用简单确认框
|
||||
- 弹窗字段包括:
|
||||
- 证件号码文本域
|
||||
- 身份证文件上传
|
||||
- 时间跨度
|
||||
- 身份证文件解析规则:
|
||||
- 读取首个 sheet
|
||||
- 只读取第一列
|
||||
- 忽略表头
|
||||
- 忽略空行
|
||||
- 忽略重复值
|
||||
- 上传身份证文件后立即自动解析,并将结果回填到文本域
|
||||
- 文本域与文件解析结果合并后按输入顺序去重
|
||||
- 文件上传记录表中的 `uploadUser` 使用 `SecurityUtils.getUserName()`
|
||||
- 调用流水分析平台 `fetchInnerFlow` 时的 `uploadUserId` 使用 `SecurityUtils.getUserId()`
|
||||
- 创建上传记录时,先将身份证号写入 `accountNos` 作为主体账号
|
||||
- 每个身份证使用一个线程处理
|
||||
- 在调用“获取单个文件上传后的状态”接口时,根据返回值中的文件名更新文件上传记录
|
||||
- 获取 `logId` 之后的处理步骤与现有“导入流水文件”方法保持一致
|
||||
|
||||
## 方案对比
|
||||
|
||||
### 方案一:在现有文件上传服务中扩展并抽取公共流水线
|
||||
|
||||
- 继续使用 `CcdiFileUploadController`、`ICcdiFileUploadService`、`CcdiFileUploadServiceImpl`
|
||||
- 新增身份证文件解析接口和拉取本行信息提交接口
|
||||
- 将现有“文件上传成功拿到 logId 后”的处理逻辑抽成公共方法,供文件上传和本行拉取共同复用
|
||||
|
||||
优点:
|
||||
|
||||
- 与现有上传记录列表、线程池、状态轮询、流水入库逻辑完全一致
|
||||
- 复用度最高,后续维护成本最低
|
||||
- 记录表、状态统计、页面轮询无需新增体系
|
||||
|
||||
缺点:
|
||||
|
||||
- 需要对现有 `CcdiFileUploadServiceImpl` 做一次中等规模重构
|
||||
|
||||
### 方案二:新增独立的本行拉取服务
|
||||
|
||||
- 新建独立 Controller 和 Service
|
||||
- 仅在最后复用“获取流水列表并入库”的局部能力
|
||||
|
||||
优点:
|
||||
|
||||
- 对现有文件上传代码侵入较小
|
||||
|
||||
缺点:
|
||||
|
||||
- 会出现两套高度相似的任务调度和状态回写逻辑
|
||||
- 后续容易出现功能漂移和修复不一致
|
||||
|
||||
### 方案三:在 Controller 中直接串接已有逻辑
|
||||
|
||||
- Controller 直接完成记录插入、线程调度和接口调用
|
||||
|
||||
优点:
|
||||
|
||||
- 早期开发速度快
|
||||
|
||||
缺点:
|
||||
|
||||
- Controller 职责过重
|
||||
- 不利于测试和后续扩展
|
||||
|
||||
## 选型
|
||||
|
||||
采用方案一:在现有文件上传服务中扩展,并抽取“拿到 `logId` 后的公共处理流水线”。
|
||||
|
||||
该方案最符合当前项目已经存在的上传记录表、线程池和流水落库架构,可以保证文件上传和本行拉取在状态、错误处理和数据口径上保持一致。
|
||||
|
||||
## 前端交互设计
|
||||
|
||||
页面文件仍为 `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`。
|
||||
|
||||
### 弹窗结构
|
||||
|
||||
新增“拉取本行信息”弹窗,包含以下控件:
|
||||
|
||||
1. 证件号码文本域
|
||||
- 占位提示:支持逗号、中文逗号、换行分隔
|
||||
- 用于展示最终待提交的身份证集合
|
||||
|
||||
2. 身份证文件上传
|
||||
- 仅支持 `.xlsx`、`.xls`
|
||||
- 选中文件后立即自动调用后端解析接口
|
||||
- 解析成功后,把有效身份证集合合并回填到文本域
|
||||
|
||||
3. 时间跨度
|
||||
- 开始日期
|
||||
- 结束日期
|
||||
- 提交时必填
|
||||
|
||||
### 前端交互规则
|
||||
|
||||
1. 用户选择身份证文件后:
|
||||
- 立即上传到解析接口
|
||||
- 前端显示“正在解析身份证文件”
|
||||
- 解析成功后:
|
||||
- 将文件解析出的身份证集合与文本域当前内容合并
|
||||
- 去重后回填到文本域
|
||||
- 提示解析成功及有效条数
|
||||
- 解析失败后:
|
||||
- 保留文本域现有内容
|
||||
- 显示明确错误提示
|
||||
|
||||
2. 用户点击“确认拉取”时:
|
||||
- 前端先对文本域内容做本地拆分和去重
|
||||
- 校验身份证集合非空、日期范围完整
|
||||
- 调用正式提交接口
|
||||
- 提交成功后关闭弹窗,刷新上传记录列表和统计,并开启现有轮询
|
||||
|
||||
3. 页面上传记录列表无需新增新页面:
|
||||
- 本行拉取创建的记录与文件上传记录共用同一列表
|
||||
- 状态、上传时间、上传人展示口径保持一致
|
||||
|
||||
## 后端接口设计
|
||||
|
||||
接口统一放在 `CcdiFileUploadController` 下。
|
||||
|
||||
### 1. 解析身份证文件
|
||||
|
||||
- 路径:`POST /ccdi/file-upload/parse-id-card-file`
|
||||
- 请求类型:`multipart/form-data`
|
||||
- 入参:
|
||||
- `file`
|
||||
- 返回:
|
||||
- `idCards`
|
||||
- `count`
|
||||
|
||||
用途:
|
||||
|
||||
- 供弹窗选择文件后即时解析
|
||||
- 只负责读取、去重、校验身份证,不创建任务
|
||||
|
||||
### 2. 提交拉取本行信息任务
|
||||
|
||||
- 路径:`POST /ccdi/file-upload/pull-bank-info`
|
||||
- 请求类型:`application/json`
|
||||
- 入参:
|
||||
- `projectId`
|
||||
- `idCards`
|
||||
- `startDate`
|
||||
- `endDate`
|
||||
- 返回:
|
||||
- `batchId`
|
||||
|
||||
用途:
|
||||
|
||||
- 正式提交本行拉取任务
|
||||
- 一次请求可提交多个身份证
|
||||
|
||||
## DTO / VO 设计
|
||||
|
||||
建议新增:
|
||||
|
||||
- `CcdiPullBankInfoSubmitDTO`
|
||||
- `Long projectId`
|
||||
- `List<String> idCards`
|
||||
- `String startDate`
|
||||
- `String endDate`
|
||||
|
||||
- `CcdiIdCardParseVO`
|
||||
- `List<String> idCards`
|
||||
- `Integer count`
|
||||
|
||||
## 服务层设计
|
||||
|
||||
继续使用:
|
||||
|
||||
- `ICcdiFileUploadService`
|
||||
- `CcdiFileUploadServiceImpl`
|
||||
|
||||
建议新增两个对外方法:
|
||||
|
||||
1. `parseIdCardFile(MultipartFile file)`
|
||||
- 读取首个 sheet 第一列
|
||||
- 忽略表头、空值、重复值
|
||||
- 统一执行身份证格式校验
|
||||
|
||||
2. `submitPullBankInfo(Long projectId, List<String> idCards, String startDate, String endDate, Long userId, String username)`
|
||||
- 校验项目和日期
|
||||
- 插入上传记录
|
||||
- 在事务提交后调度线程池任务
|
||||
|
||||
## 核心数据流设计
|
||||
|
||||
### 一、身份证文件解析
|
||||
|
||||
1. Controller 接收 Excel 文件
|
||||
2. Service 使用 EasyExcel 读取首个 sheet 第一列
|
||||
3. 将单元格内容转成字符串并清理空白
|
||||
4. 忽略首行表头
|
||||
5. 使用 `LinkedHashSet` 去重并保序
|
||||
6. 对每个值执行身份证格式校验
|
||||
7. 返回有效身份证集合
|
||||
|
||||
### 二、正式提交任务
|
||||
|
||||
1. 校验 `projectId`
|
||||
2. 查询项目,获取 `lsfxProjectId`
|
||||
3. 校验 `startDate`、`endDate`
|
||||
4. 校验身份证集合非空
|
||||
5. 为每个身份证创建一条 `ccdi_file_upload_record`
|
||||
- `projectId = 当前项目`
|
||||
- `lsfxProjectId = 项目关联流水分析ID`
|
||||
- `fileStatus = uploading`
|
||||
- `fileName = 身份证号`
|
||||
- `accountNos = 身份证号`
|
||||
- `uploadUser = SecurityUtils.getUserName()`
|
||||
- `uploadTime = 当前时间`
|
||||
6. 批量插入记录
|
||||
7. 在事务提交后启动调度线程
|
||||
|
||||
### 三、线程处理单个身份证
|
||||
|
||||
每个身份证一个线程,使用现有 `fileUploadExecutor`。
|
||||
|
||||
单线程处理步骤:
|
||||
|
||||
1. 调用 `fetchInnerFlow`
|
||||
- `groupId = lsfxProjectId`
|
||||
- `customerNo = 身份证号`
|
||||
- `dataChannelCode = ZJRCU`
|
||||
- `requestDateId = 当天 yyyyMMdd`
|
||||
- `dataStartDateId = 开始日期 yyyyMMdd`
|
||||
- `dataEndDateId = 结束日期 yyyyMMdd`
|
||||
- `uploadUserId = SecurityUtils.getUserId()`
|
||||
|
||||
2. 从响应中获取唯一 `logId`
|
||||
|
||||
3. 进入公共处理流水线
|
||||
- 更新记录 `logId`
|
||||
- 更新状态为 `parsing`
|
||||
- 轮询解析状态
|
||||
- 调用“获取单个文件上传状态”接口
|
||||
- 读取文件名字段,优先使用 `uploadFileName`,取不到则回退 `downloadFileName`
|
||||
- 将文件名回写到 `ccdi_file_upload_record.file_name`
|
||||
- 提取主体名称、主体账号
|
||||
- 调用“获取流水列表”接口分页拉取并入库
|
||||
- 成功后更新状态为 `parsed_success`
|
||||
- 失败时更新状态为 `parsed_failed`
|
||||
|
||||
## 公共流水线重构设计
|
||||
|
||||
当前 `CcdiFileUploadServiceImpl` 中,`processFileAsync` 同时承担“上传文件并拿到 `logId`”和“拿到 `logId` 后继续处理”两段职责。
|
||||
|
||||
本次建议拆成两段:
|
||||
|
||||
1. 文件来源阶段
|
||||
- 上传文件拿到 `logId`
|
||||
- 或者拉取本行信息拿到 `logId`
|
||||
|
||||
2. 公共处理阶段
|
||||
- 接收 `projectId`、`lsfxProjectId`、`record`、`logId`
|
||||
- 负责后续统一处理
|
||||
|
||||
拆分后:
|
||||
|
||||
- 现有 `processFileAsync` 仍然保留,但只负责文件上传到平台并获得 `logId`
|
||||
- 新增 `processPullBankInfoAsync` 负责调用 `fetchInnerFlow` 并获得 `logId`
|
||||
- 两者统一调用新的公共处理方法,例如:
|
||||
- `processRecordAfterLogIdReady(...)`
|
||||
|
||||
## 文件上传记录表回写规则
|
||||
|
||||
### 记录初始化
|
||||
|
||||
- `fileName`:先写身份证号占位
|
||||
- `accountNos`:写身份证号
|
||||
- `enterpriseNames`:初始为空
|
||||
|
||||
### 状态接口返回后
|
||||
|
||||
- 若状态接口返回 `uploadFileName`,更新到 `fileName`
|
||||
- 若 `uploadFileName` 为空但 `downloadFileName` 不为空,回写 `downloadFileName`
|
||||
- `enterpriseNameList` 存在时更新 `enterpriseNames`
|
||||
- `accountNoList` 存在时更新 `accountNos`
|
||||
|
||||
## 异常处理
|
||||
|
||||
### 提交阶段异常
|
||||
|
||||
以下场景直接拦截,不进入异步任务:
|
||||
|
||||
- 项目不存在
|
||||
- 项目未绑定 `lsfxProjectId`
|
||||
- 身份证集合为空
|
||||
- 开始日期或结束日期为空
|
||||
- 开始日期大于结束日期
|
||||
|
||||
### 文件解析异常
|
||||
|
||||
- 文件为空
|
||||
- 文件格式不是 Excel
|
||||
- 首个 sheet 第一列没有有效身份证
|
||||
- 存在非法身份证号
|
||||
|
||||
解析异常直接返回错误,不覆盖前端已有输入值。
|
||||
|
||||
### 异步执行异常
|
||||
|
||||
每个身份证单独处理,单条失败不影响其他条目:
|
||||
|
||||
- `fetchInnerFlow` 失败:当前记录标记 `parsed_failed`
|
||||
- 轮询超时:当前记录标记 `parsed_failed`
|
||||
- 获取状态失败:当前记录标记 `parsed_failed`
|
||||
- 获取流水失败:清理该 `logId` 已入库流水后标记 `parsed_failed`
|
||||
|
||||
错误信息统一落入 `error_message`,并延续现有超长错误截断规则。
|
||||
|
||||
## 测试设计
|
||||
|
||||
### 后端测试
|
||||
|
||||
重点新增以下测试:
|
||||
|
||||
1. 身份证文件解析测试
|
||||
- 读取首个 sheet 第一列
|
||||
- 忽略表头、空行、重复值
|
||||
- 非法身份证时返回失败
|
||||
|
||||
2. 提交任务测试
|
||||
- 为每个身份证插入一条上传记录
|
||||
- 初始化 `accountNos` 为身份证号
|
||||
- `uploadUser` 正确记录当前用户名
|
||||
|
||||
3. 公共流水线复用测试
|
||||
- `fetchInnerFlow` 成功后能进入公共处理链路
|
||||
- 状态接口返回文件名时能正确回写到记录表
|
||||
- 流水入库失败时能清理已写入数据
|
||||
|
||||
4. Controller 测试
|
||||
- 解析接口成功/失败
|
||||
- 提交接口成功/失败
|
||||
|
||||
### 前端验证
|
||||
|
||||
- 选择身份证文件后自动解析并回填
|
||||
- 手输身份证与文件解析结果正确合并去重
|
||||
- 日期必填校验生效
|
||||
- 提交成功后弹窗关闭并刷新列表
|
||||
- 正在执行的记录可通过现有轮询刷新状态
|
||||
|
||||
## 验收标准
|
||||
|
||||
### 功能验收
|
||||
|
||||
- 上传数据页面可打开“拉取本行信息”弹窗
|
||||
- 身份证 Excel 上传后能自动解析并回填输入框
|
||||
- 提交后每个身份证都创建一条上传记录
|
||||
- 每个身份证走一个线程处理
|
||||
- 状态接口返回文件名后,记录列表能展示更新后的文件名
|
||||
- 成功记录能拉取流水并入库
|
||||
- 失败记录不会影响其他身份证继续执行
|
||||
|
||||
### 技术验收
|
||||
|
||||
- 复用现有 `fileUploadExecutor`
|
||||
- 复用现有上传记录列表、统计和轮询机制
|
||||
- Controller 使用 Swagger 注释
|
||||
- Service 中公共处理逻辑不重复实现两套
|
||||
- 后端测试覆盖解析、提交、公共流水线复用
|
||||
|
||||
## 风险与约束
|
||||
|
||||
1. 身份证文件模板不固定
|
||||
- 首版只按“首个 sheet 第一列”解析
|
||||
- 如后续存在多模板,再扩展模板识别
|
||||
|
||||
2. `fetchInnerFlow` 返回值只提供 `logId`
|
||||
- 必须严格复用后续状态接口和流水接口,不能只看首个响应判断成功
|
||||
|
||||
3. 线程池容量与批量身份证数量存在上限
|
||||
- 沿用现有线程池拒绝重试机制
|
||||
- 超量场景下记录单条失败,不阻断整批任务
|
||||
|
||||
4. 文件名依赖状态接口返回
|
||||
- 需要兼容 `uploadFileName` 为空的场景,并回退 `downloadFileName`
|
||||
@@ -0,0 +1,385 @@
|
||||
# Project Detail Pull Bank Info Frontend Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build the “拉取本行信息” modal on the project detail upload page, including ID-card Excel auto-parse and backfill, date-range submission, and reuse of the existing upload-record polling refresh flow.
|
||||
|
||||
**Architecture:** Keep the implementation inside the existing `UploadData.vue` page and `ccdiProjectUpload.js` API module instead of introducing a new page or a new API file. Replace the current confirm-only placeholder with a real dialog, call a dedicated parse endpoint as soon as the user chooses an Excel file, merge the returned身份证集合 back into the textarea, then submit the final list and reuse the existing statistics, record list, and polling behavior.
|
||||
|
||||
**Tech Stack:** Vue 2.6, Element UI 2.15, Axios request wrapper, existing polling/list refresh logic, `npm run build:prod`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add API contracts and make the build fail first
|
||||
|
||||
**Files:**
|
||||
- Modify: `ruoyi-ui/src/api/ccdiProjectUpload.js`
|
||||
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
|
||||
**Step 1: Write the failing verification**
|
||||
|
||||
先在 `UploadData.vue` 中把原来的简单确认流程替换成新的 API 引用,但暂时不创建 API 方法:
|
||||
|
||||
```javascript
|
||||
import {
|
||||
getImportStatus,
|
||||
getNameListOptions,
|
||||
getUploadStatus,
|
||||
pullBankInfo,
|
||||
parseIdCardFile,
|
||||
updateNameListSelection,
|
||||
uploadFile,
|
||||
batchUploadFiles,
|
||||
getFileUploadList,
|
||||
getFileUploadStatistics,
|
||||
} from "@/api/ccdiProjectUpload";
|
||||
```
|
||||
|
||||
并把 `handleFetchBankInfo` 改成只打开弹窗:
|
||||
|
||||
```javascript
|
||||
handleFetchBankInfo() {
|
||||
this.pullBankInfoDialogVisible = true;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run build to verify it fails**
|
||||
|
||||
Run: `cd ruoyi-ui; npm run build:prod`
|
||||
|
||||
Expected: FAIL because `parseIdCardFile` does not exist in `ccdiProjectUpload.js`, and the new dialog state has not been defined.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
在 `ccdiProjectUpload.js` 中补两个接口:
|
||||
|
||||
```javascript
|
||||
export function parseIdCardFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return request({
|
||||
url: "/ccdi/file-upload/parse-id-card-file",
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
export function pullBankInfo(data) {
|
||||
return request({
|
||||
url: "/ccdi/file-upload/pull-bank-info",
|
||||
method: "post",
|
||||
data
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
注意:把原来 `pullBankInfo(projectId)` 的签名改成 JSON 提交,不再走 `/ccdi/project/{projectId}/pull-bank-info` 占位接口。
|
||||
|
||||
**Step 4: Run build to verify it still only fails on missing dialog state**
|
||||
|
||||
Run: `cd ruoyi-ui; npm run build:prod`
|
||||
|
||||
Expected: FAIL only because `UploadData.vue` 还没有新增弹窗数据和模板绑定。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ruoyi-ui/src/api/ccdiProjectUpload.js ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue
|
||||
git commit -m "补充拉取本行信息前端接口契约"
|
||||
```
|
||||
|
||||
### Task 2: Build the modal shell and page state
|
||||
|
||||
**Files:**
|
||||
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
|
||||
**Step 1: Write the failing verification**
|
||||
|
||||
在模板里新增弹窗骨架,但先不实现方法:
|
||||
|
||||
- `el-dialog` 标题:`拉取本行信息`
|
||||
- `el-input type="textarea"` 用于证件号码输入
|
||||
- `el-upload` 用于身份证文件上传
|
||||
- `el-date-picker type="daterange"` 用于时间跨度
|
||||
- 底部按钮:`取消`、`确认拉取`
|
||||
|
||||
使用以下数据字段:
|
||||
|
||||
```javascript
|
||||
pullBankInfoDialogVisible: false,
|
||||
pullBankInfoLoading: false,
|
||||
parsingIdCardFile: false,
|
||||
idCardFileList: [],
|
||||
pullBankInfoForm: {
|
||||
idCardText: "",
|
||||
dateRange: []
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run build to verify it fails**
|
||||
|
||||
Run: `cd ruoyi-ui; npm run build:prod`
|
||||
|
||||
Expected: FAIL because the template references `pullBankInfoDialogVisible`, `pullBankInfoForm`, and upload handlers that are not implemented yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
在 `data()` 中补齐新状态,并实现基础弹窗方法:
|
||||
|
||||
```javascript
|
||||
openPullBankInfoDialog() {
|
||||
this.pullBankInfoDialogVisible = true;
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
resetPullBankInfoForm() {
|
||||
this.pullBankInfoForm = {
|
||||
idCardText: "",
|
||||
dateRange: []
|
||||
};
|
||||
this.idCardFileList = [];
|
||||
this.parsingIdCardFile = false;
|
||||
this.pullBankInfoLoading = false;
|
||||
}
|
||||
```
|
||||
|
||||
同时调整 `handleFetchBankInfo()` 改为:
|
||||
|
||||
```javascript
|
||||
handleFetchBankInfo() {
|
||||
this.resetPullBankInfoForm();
|
||||
this.openPullBankInfoDialog();
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run build to verify it passes**
|
||||
|
||||
Run: `cd ruoyi-ui; npm run build:prod`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue
|
||||
git commit -m "搭建拉取本行信息弹窗骨架"
|
||||
```
|
||||
|
||||
### Task 3: Implement instant Excel parsing and textarea backfill
|
||||
|
||||
**Files:**
|
||||
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
|
||||
**Step 1: Write the failing verification**
|
||||
|
||||
把文件上传控件接到实际事件,但先不写实现:
|
||||
|
||||
```html
|
||||
<el-upload
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:file-list="idCardFileList"
|
||||
:on-change="handleIdCardFileChange"
|
||||
:on-remove="handleIdCardFileRemove">
|
||||
</el-upload>
|
||||
```
|
||||
|
||||
并在文件列表下面显示解析提示:
|
||||
|
||||
```html
|
||||
<div v-if="parsingIdCardFile">正在解析身份证文件...</div>
|
||||
```
|
||||
|
||||
**Step 2: Run build to verify it fails**
|
||||
|
||||
Run: `cd ruoyi-ui; npm run build:prod`
|
||||
|
||||
Expected: FAIL because `handleIdCardFileChange` and `handleIdCardFileRemove` do not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
实现 4 个前端辅助方法:
|
||||
|
||||
1. `parseIdCardText(text)`
|
||||
|
||||
```javascript
|
||||
parseIdCardText(text) {
|
||||
return Array.from(new Set(
|
||||
(text || "")
|
||||
.split(/[\n,,]+/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
2. `mergeIdCards(currentText, parsedIdCards)`
|
||||
|
||||
```javascript
|
||||
mergeIdCards(currentText, parsedIdCards) {
|
||||
const merged = [
|
||||
...this.parseIdCardText(currentText),
|
||||
...(parsedIdCards || [])
|
||||
];
|
||||
return Array.from(new Set(merged)).join(", ");
|
||||
}
|
||||
```
|
||||
|
||||
3. `handleIdCardFileChange(file, fileList)`
|
||||
- 只保留一个文件
|
||||
- 校验扩展名为 `.xls` / `.xlsx`
|
||||
- 设置 `parsingIdCardFile = true`
|
||||
- 调用 `parseIdCardFile(file.raw)`
|
||||
- 成功后把返回的 `idCards` 合并回填到 `pullBankInfoForm.idCardText`
|
||||
- 失败后提示错误并清空文件列表
|
||||
|
||||
4. `handleIdCardFileRemove()`
|
||||
- 清空 `idCardFileList`
|
||||
|
||||
解析成功后的关键回填逻辑:
|
||||
|
||||
```javascript
|
||||
this.pullBankInfoForm.idCardText = this.mergeIdCards(
|
||||
this.pullBankInfoForm.idCardText,
|
||||
res.data.idCards || []
|
||||
);
|
||||
```
|
||||
|
||||
**Step 4: Run build to verify it passes**
|
||||
|
||||
Run: `cd ruoyi-ui; npm run build:prod`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue
|
||||
git commit -m "实现身份证文件自动解析与输入框回填"
|
||||
```
|
||||
|
||||
### Task 4: Submit the final pull request and reuse the existing polling refresh flow
|
||||
|
||||
**Files:**
|
||||
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
|
||||
**Step 1: Write the failing verification**
|
||||
|
||||
先把“确认拉取”按钮接到真正的方法名,但先不写逻辑:
|
||||
|
||||
```html
|
||||
<el-button type="primary" :loading="pullBankInfoLoading" @click="handleConfirmPullBankInfo">
|
||||
确认拉取
|
||||
</el-button>
|
||||
```
|
||||
|
||||
**Step 2: Run build to verify it fails**
|
||||
|
||||
Run: `cd ruoyi-ui; npm run build:prod`
|
||||
|
||||
Expected: FAIL because `handleConfirmPullBankInfo` does not exist yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
实现提交前的最终整理与校验:
|
||||
|
||||
1. `buildFinalIdCardList()`
|
||||
|
||||
```javascript
|
||||
buildFinalIdCardList() {
|
||||
return this.parseIdCardText(this.pullBankInfoForm.idCardText);
|
||||
}
|
||||
```
|
||||
|
||||
2. `handleConfirmPullBankInfo()`
|
||||
- 校验证件号码非空
|
||||
- 校验 `dateRange` 长度为 2
|
||||
- 组装请求体:
|
||||
|
||||
```javascript
|
||||
const [startDate, endDate] = this.pullBankInfoForm.dateRange || [];
|
||||
const payload = {
|
||||
projectId: this.projectId,
|
||||
idCards: this.buildFinalIdCardList(),
|
||||
startDate,
|
||||
endDate
|
||||
};
|
||||
```
|
||||
|
||||
- 调用 `pullBankInfo(payload)`
|
||||
- 成功后:
|
||||
- 关闭弹窗
|
||||
- 提示“拉取任务已提交”
|
||||
- `await Promise.all([this.loadStatistics(), this.loadFileList()])`
|
||||
- 若有 `uploading` / `parsing` 记录则执行 `this.startPolling()`
|
||||
|
||||
失败后:
|
||||
- 保留弹窗内容
|
||||
- 显示后端错误信息
|
||||
|
||||
**Step 4: Run build to verify it passes**
|
||||
|
||||
Run: `cd ruoyi-ui; npm run build:prod`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue
|
||||
git commit -m "接通拉取本行信息提交流程与列表刷新"
|
||||
```
|
||||
|
||||
### Task 5: Final verification and manual smoke-check
|
||||
|
||||
**Files:**
|
||||
- Modify if needed after failures: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
- Modify if needed after failures: `ruoyi-ui/src/api/ccdiProjectUpload.js`
|
||||
|
||||
**Step 1: Run production build**
|
||||
|
||||
Run: `cd ruoyi-ui; npm run build:prod`
|
||||
|
||||
Expected: BUILD SUCCESS
|
||||
|
||||
**Step 2: Manual smoke in the browser**
|
||||
|
||||
手工验证以下场景:
|
||||
|
||||
1. 打开项目详情页 `上传数据`
|
||||
2. 点击“拉取本行信息”,确认弹窗打开
|
||||
3. 手工输入两个身份证,确认文本框保留原值
|
||||
4. 上传身份证 Excel,确认自动解析并把去重后的身份证回填到文本框
|
||||
5. 不选日期时点击“确认拉取”,确认拦截
|
||||
6. 选择日期后提交,确认弹窗关闭、提示提交成功
|
||||
7. 确认上传记录列表新增记录并进入 `上传中 / 解析中`
|
||||
8. 确认已有轮询逻辑能自动刷新状态
|
||||
|
||||
**Step 3: Fix the smallest UI or data-binding issue**
|
||||
|
||||
优先排查:
|
||||
|
||||
- 日期控件 `value-format` 是否返回 `yyyy-MM-dd`
|
||||
- 文件移除后是否错误保留旧文件列表
|
||||
- 文本框合并去重后是否出现多余逗号或空白
|
||||
- 提交成功后是否忘记重置弹窗状态
|
||||
|
||||
**Step 4: Run final build again**
|
||||
|
||||
Run: `cd ruoyi-ui; npm run build:prod`
|
||||
|
||||
Expected: BUILD SUCCESS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ruoyi-ui/src/api/ccdiProjectUpload.js ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue
|
||||
git commit -m "完成拉取本行信息前端弹窗与自动解析"
|
||||
```
|
||||
68
docs/plans/2026-03-11-upload-data-account-display-design.md
Normal file
68
docs/plans/2026-03-11-upload-data-account-display-design.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 上传数据主体账号展示设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
在项目详情页的“上传数据”模块中,文件列表当前仅展示“主体名称”,未展示后端已经返回的“主体账号”。本次调整在不变更后端接口和数据库结构的前提下,为列表新增“主体账号”列,帮助用户直接核对解析出的账号信息。
|
||||
|
||||
## 需求分析
|
||||
|
||||
### 背景
|
||||
|
||||
- 上传数据文件列表位于项目详情页“上传数据”页签。
|
||||
- 后端文件上传记录实体已包含 `accountNos` 字段,解析成功后会写入主体账号数据。
|
||||
- 前端列表当前只渲染 `enterpriseNames`,导致用户无法在列表中直接查看主体账号。
|
||||
|
||||
### 目标
|
||||
|
||||
- 在上传数据文件列表中新增“主体账号”列。
|
||||
- 直接复用现有接口返回的 `accountNos` 字段。
|
||||
- 保持现有分页、状态、刷新逻辑不变。
|
||||
|
||||
## 方案对比
|
||||
|
||||
### 方案一:新增独立“主体账号”列
|
||||
|
||||
优点:
|
||||
- 信息结构最清晰,主体名称和主体账号各自独立。
|
||||
- 改动范围最小,只需调整前端表格模板。
|
||||
- 与现有“主体名称”列保持一致,用户学习成本低。
|
||||
|
||||
缺点:
|
||||
- 表格横向宽度略有增加。
|
||||
|
||||
### 方案二:主体名称和主体账号合并在同一列内分行展示
|
||||
|
||||
优点:
|
||||
- 不增加表格列数。
|
||||
|
||||
缺点:
|
||||
- 可读性下降,不利于后续排序、筛选或截图核对。
|
||||
- 需要额外的模板和样式调整。
|
||||
|
||||
## 方案选择
|
||||
|
||||
采用方案一:新增独立“主体账号”列。
|
||||
|
||||
选择理由:
|
||||
- 用户已经明确确认采用单独新增一列的展示方式。
|
||||
- 后端字段已就绪,前端读取即用。
|
||||
- 改动只落在上传数据列表模板和对应回归测试,风险最低。
|
||||
|
||||
## 数据流
|
||||
|
||||
1. 前端继续调用 `/ccdi/file-upload/list` 获取分页数据。
|
||||
2. 后端返回每条上传记录的 `accountNos` 字段。
|
||||
3. 前端在文件列表中新增“主体账号”列,读取 `scope.row.accountNos`。
|
||||
4. 当账号为空时,展示 `-`,与“主体名称”列的兜底方式保持一致。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- `accountNos` 为空、`null` 或未返回时,前端展示 `-`。
|
||||
- 不新增请求,不改变轮询与刷新逻辑,因此不引入新的接口异常处理分支。
|
||||
|
||||
## 测试策略
|
||||
|
||||
- 新增前端静态回归测试,断言文件列表模板中存在 `prop="accountNos"` 且标签为“主体账号”。
|
||||
- 按 TDD 流程先运行新测试并确认失败,再补模板代码。
|
||||
- 回归执行现有上传数据列表相关静态测试,确保未误伤现有布局与分页设置。
|
||||
- 执行前端生产构建,确认模板修改不影响编译。
|
||||
@@ -0,0 +1,116 @@
|
||||
# 上传数据主体账号展示实施计划
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**目标:** 在项目详情的上传数据文件列表中新增“主体账号”列,展示后端已返回的 `accountNos`
|
||||
|
||||
**架构:** 保持后端接口、数据结构和页面交互逻辑不变,仅在前端表格模板增加一列,并通过静态回归测试锁定该列的存在与字段绑定
|
||||
|
||||
**技术栈:** Vue.js 2.6.12, Element UI 2.15.14, Node.js 内置 `assert/fs/path`
|
||||
|
||||
---
|
||||
|
||||
### 任务 1: 编写失败中的回归测试
|
||||
|
||||
**文件:**
|
||||
- 新建: `ruoyi-ui/tests/unit/upload-data-account-column.test.js`
|
||||
|
||||
**步骤 1: 写出失败测试**
|
||||
|
||||
新增一个零依赖静态测试,读取 `UploadData.vue` 源码并断言:
|
||||
|
||||
- 文件列表区域存在 `主体账号` 文案
|
||||
- 存在绑定 `prop="accountNos"` 的表格列
|
||||
|
||||
测试主体可采用以下结构:
|
||||
|
||||
```javascript
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const componentPath = path.resolve(
|
||||
__dirname,
|
||||
"../../src/views/ccdiProject/components/detail/UploadData.vue"
|
||||
);
|
||||
const source = fs.readFileSync(componentPath, "utf8");
|
||||
|
||||
assert(/prop="accountNos"/.test(source), "...");
|
||||
assert(/label="主体账号"/.test(source), "...");
|
||||
```
|
||||
|
||||
**步骤 2: 运行测试确认失败**
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
node ruoyi-ui/tests/unit/upload-data-account-column.test.js
|
||||
```
|
||||
|
||||
预期:
|
||||
- 命令失败
|
||||
- 失败信息指出未找到“主体账号”列或 `accountNos` 绑定
|
||||
|
||||
---
|
||||
|
||||
### 任务 2: 修改上传数据列表模板
|
||||
|
||||
**文件:**
|
||||
- 修改: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
|
||||
**步骤 1: 新增主体账号列**
|
||||
|
||||
在“主体名称”列后添加“主体账号”列,保持与主体名称相同的兜底展示:
|
||||
|
||||
```vue
|
||||
<el-table-column prop="accountNos" label="主体账号" min-width="180">
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.accountNos || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
```
|
||||
|
||||
**步骤 2: 保持现有布局不变**
|
||||
|
||||
- 不修改接口请求
|
||||
- 不调整分页、刷新、轮询逻辑
|
||||
- 不修改表格其他列的行为
|
||||
|
||||
---
|
||||
|
||||
### 任务 3: 重新运行测试并回归验证
|
||||
|
||||
**文件:**
|
||||
- 验证: `ruoyi-ui/tests/unit/upload-data-account-column.test.js`
|
||||
- 验证: `ruoyi-ui/tests/unit/upload-data-file-list-table.test.js`
|
||||
- 验证: `ruoyi-ui/tests/unit/upload-data-file-list-settings.test.js`
|
||||
|
||||
**步骤 1: 运行新增测试确认通过**
|
||||
|
||||
```bash
|
||||
node ruoyi-ui/tests/unit/upload-data-account-column.test.js
|
||||
```
|
||||
|
||||
预期:
|
||||
- 输出 `upload-data-account-column test passed`
|
||||
|
||||
**步骤 2: 运行关联回归测试**
|
||||
|
||||
```bash
|
||||
node ruoyi-ui/tests/unit/upload-data-file-list-table.test.js
|
||||
node ruoyi-ui/tests/unit/upload-data-file-list-settings.test.js
|
||||
```
|
||||
|
||||
预期:
|
||||
- 两个测试都通过
|
||||
|
||||
**步骤 3: 执行前端构建验证**
|
||||
|
||||
```bash
|
||||
cd ruoyi-ui
|
||||
npm run build:prod
|
||||
```
|
||||
|
||||
预期:
|
||||
- 构建成功
|
||||
- 无模板编译错误
|
||||
@@ -0,0 +1,40 @@
|
||||
# Pull Bank Info Upload Button Hit Area Backend Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Confirm this UI optimization does not require backend changes and document the verification boundary.
|
||||
|
||||
**Architecture:** The issue is caused by the frontend dialog structure and scoped styles in the upload page. Backend APIs, request payloads, and parsing logic remain unchanged, so this plan only records the no-op backend conclusion and the checks needed to avoid accidental interface regressions.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 3, Maven, existing `ccdi-project` upload APIs
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Verify backend impact is zero
|
||||
|
||||
**Files:**
|
||||
- Review: `docs/plans/2026-03-12-pull-bank-info-upload-button-hit-area-design.md`
|
||||
- Review: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/`
|
||||
- Review: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/`
|
||||
|
||||
**Step 1: Confirm the bug scope**
|
||||
|
||||
Check that the reported problem is limited to the frontend dialog button hit area and layout, not request construction or backend response handling.
|
||||
|
||||
**Step 2: Verify no API contract changes are needed**
|
||||
|
||||
Review the existing pull-bank-info request fields and confirm the dialog still submits the same `projectId`, `idCards`, `startDate`, and `endDate`.
|
||||
|
||||
**Step 3: Keep backend code unchanged**
|
||||
|
||||
Do not modify controller, service, mapper, or DTO classes for this task.
|
||||
|
||||
**Step 4: Run targeted regression verification if frontend payload changes are suspected**
|
||||
|
||||
Run only if implementation unexpectedly touches request assembly:
|
||||
|
||||
```bash
|
||||
mvn test -Dtest=CcdiFileUploadServiceImplTest
|
||||
```
|
||||
|
||||
Expected: related backend tests pass and no interface behavior changes are introduced.
|
||||
@@ -0,0 +1,30 @@
|
||||
# 拉取本行信息上传按钮交互区域修复设计
|
||||
|
||||
## 背景
|
||||
“上传信息”页面的“拉取本行信息”弹窗中,上传文件按钮的视觉大小与实际交互区域不一致,造成点击范围异常偏大的体验问题。
|
||||
|
||||
## 目标
|
||||
- 让“选择文件”按钮的可点击区域与视觉边界一致
|
||||
- 保持拖拽上传弹窗仍为全宽拖拽区域
|
||||
- 不改动上传逻辑与接口
|
||||
|
||||
## 非目标
|
||||
- 不调整上传流程与校验逻辑
|
||||
- 不新增后端接口或数据结构
|
||||
|
||||
## 方案概述
|
||||
将对话框内 `el-upload` 的通用“全宽”样式收窄到拖拽上传区域(如 `.upload-area`、`.batch-upload-area`),避免对按钮型上传产生影响。必要时仅对“拉取本行信息”弹窗维持默认按钮布局。
|
||||
|
||||
## 影响范围
|
||||
- 前端样式:`UploadData.vue` 的 `scoped` 样式块
|
||||
- 组件结构与逻辑保持不变
|
||||
|
||||
## 风险与回归验证
|
||||
- 风险:拖拽上传区域样式变形
|
||||
- 回归验证:
|
||||
- 打开“拉取本行信息”弹窗,确认按钮点击区域与视觉一致
|
||||
- 打开“上传数据/批量上传”弹窗,确认拖拽区域仍为全宽
|
||||
|
||||
## 验收标准
|
||||
- “拉取本行信息”弹窗中按钮交互区域正常
|
||||
- 拖拽上传弹窗布局无回归
|
||||
@@ -0,0 +1,113 @@
|
||||
# Pull Bank Info Upload Button Hit Area Frontend Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Make the pull-bank-info dialog file selector hit area match the visible trigger and improve the field layout without changing upload behavior.
|
||||
|
||||
**Architecture:** Keep the existing `el-upload` parsing flow and API calls, but replace the current loose inline upload layout with a dedicated upload panel inside the dialog. Add a focused unit test that asserts the new dialog structure and class hooks, then update scoped styles so the button-style uploader no longer inherits the full-width drag-upload hit area behavior.
|
||||
|
||||
**Tech Stack:** Vue 2, Element UI 2, scoped SCSS, Node-based source assertions in `ruoyi-ui/tests/unit`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add a regression test for the dialog structure
|
||||
|
||||
**Files:**
|
||||
- Create: `ruoyi-ui/tests/unit/upload-data-pull-bank-info-dialog-layout.test.js`
|
||||
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add a source-based unit test that checks all of the following in the pull-bank-info dialog:
|
||||
|
||||
- the dialog contains a dedicated `pull-bank-info-form` container
|
||||
- the file import area uses a `pull-bank-file-panel`
|
||||
- the upload trigger uses a `pull-bank-file-upload`
|
||||
- the template includes a visible selected-file summary block
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node ruoyi-ui/tests/unit/upload-data-pull-bank-info-dialog-layout.test.js
|
||||
```
|
||||
|
||||
Expected: FAIL because the current dialog does not contain the new structure/classes.
|
||||
|
||||
### Task 2: Restructure the dialog template with minimal logic changes
|
||||
|
||||
**Files:**
|
||||
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
- Test: `ruoyi-ui/tests/unit/upload-data-pull-bank-info-dialog-layout.test.js`
|
||||
|
||||
**Step 1: Update the dialog markup**
|
||||
|
||||
Keep the existing fields and event handlers, but:
|
||||
|
||||
- wrap the dialog form with `pull-bank-info-form`
|
||||
- split the dialog into clearer sections
|
||||
- move the upload button and helper text into `pull-bank-file-panel`
|
||||
- add a selected-file summary row bound to `idCardFileList`
|
||||
- keep `handleIdCardFileChange`, `handleIdCardFileRemove`, and `parsingIdCardFile` intact
|
||||
|
||||
**Step 2: Run the new test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node ruoyi-ui/tests/unit/upload-data-pull-bank-info-dialog-layout.test.js
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Adjust scoped styles so hit area and layout align
|
||||
|
||||
**Files:**
|
||||
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
- Test: `ruoyi-ui/tests/unit/upload-data-pull-bank-info-dialog-layout.test.js`
|
||||
|
||||
**Step 1: Add focused dialog styles**
|
||||
|
||||
Add SCSS for:
|
||||
|
||||
- `pull-bank-info-form`
|
||||
- `pull-bank-file-panel`
|
||||
- `pull-bank-file-upload`
|
||||
- `selected-id-card-file`
|
||||
- `pull-bank-range-picker`
|
||||
|
||||
Use these styles to make the trigger area content-sized instead of full-row clickable, and improve spacing/alignment for desktop and mobile.
|
||||
|
||||
**Step 2: Keep drag-upload dialogs unchanged**
|
||||
|
||||
Retain the existing full-width dragger behavior for `upload-area` and `batch-upload-area`.
|
||||
|
||||
**Step 3: Run regression tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node ruoyi-ui/tests/unit/upload-data-pull-bank-info-dialog-layout.test.js
|
||||
node ruoyi-ui/tests/unit/upload-data-batch-upload.test.js
|
||||
node ruoyi-ui/tests/unit/upload-data-file-list-settings.test.js
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
### Task 4: Run final verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
|
||||
|
||||
**Step 1: Run production build verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build:prod
|
||||
```
|
||||
|
||||
Workdir: `ruoyi-ui`
|
||||
|
||||
Expected: build succeeds without introducing Vue template or style compilation errors.
|
||||
@@ -57,10 +57,25 @@ export function executeQualityCheck(projectId) {
|
||||
}
|
||||
|
||||
// 拉取本行信息
|
||||
export function pullBankInfo(projectId) {
|
||||
export function parseIdCardFile(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request({
|
||||
url: '/ccdi/project/' + projectId + '/pull-bank-info',
|
||||
method: 'post'
|
||||
url: '/ccdi/file-upload/parse-id-card-file',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 拉取本行信息
|
||||
export function pullBankInfo(data) {
|
||||
return request({
|
||||
url: '/ccdi/file-upload/pull-bank-info',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
:data="dataList"
|
||||
style="width: 100%"
|
||||
>
|
||||
<!-- 项目名称(含描述) -->
|
||||
<el-table-column
|
||||
label="项目名称"
|
||||
min-width="180"
|
||||
@@ -14,12 +13,11 @@
|
||||
<template slot-scope="scope">
|
||||
<div class="project-info-cell">
|
||||
<div class="project-name">{{ scope.row.projectName }}</div>
|
||||
<div class="project-desc">{{ scope.row.description || '暂无描述' }}</div>
|
||||
<div class="project-desc">{{ scope.row.description || "暂无描述" }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 更新/创建时间 -->
|
||||
<el-table-column
|
||||
prop="updateTime"
|
||||
label="更新/创建时间"
|
||||
@@ -31,7 +29,6 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 创建人 -->
|
||||
<el-table-column
|
||||
prop="createByName"
|
||||
label="创建人"
|
||||
@@ -39,7 +36,6 @@
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<!-- 状态 -->
|
||||
<el-table-column
|
||||
prop="status"
|
||||
label="状态"
|
||||
@@ -49,12 +45,11 @@
|
||||
<template slot-scope="scope">
|
||||
<div class="status-tag">
|
||||
<span class="status-dot" :style="{ color: getStatusColor(scope.row.status) }">●</span>
|
||||
<dict-tag :options="dict.type.ccdi_project_status" :value="scope.row.status"/>
|
||||
<dict-tag :options="dict.type.ccdi_project_status" :value="scope.row.status" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 目标人数 -->
|
||||
<el-table-column
|
||||
prop="targetCount"
|
||||
label="目标人数"
|
||||
@@ -62,7 +57,6 @@
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<!-- 预警人数(带悬停详情) -->
|
||||
<el-table-column
|
||||
label="预警人数"
|
||||
width="120"
|
||||
@@ -71,26 +65,24 @@
|
||||
<template slot-scope="scope">
|
||||
<el-tooltip placement="top" effect="light">
|
||||
<div slot="content">
|
||||
<div style="padding: 8px;">
|
||||
<div style="margin-bottom: 8px; font-weight: bold; color: #303133;">
|
||||
风险人数统计
|
||||
<div class="risk-tooltip">
|
||||
<div class="risk-tooltip-title">风险人数统计</div>
|
||||
<div class="risk-tooltip-item risk-tooltip-item--high">
|
||||
<span>● 高风险:</span>
|
||||
<span class="risk-tooltip-count">{{ scope.row.highRiskCount }} 人</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 6px;">
|
||||
<span style="color: #f56c6c;">● 高风险:</span>
|
||||
<span style="font-weight: bold;">{{ scope.row.highRiskCount }} 人</span>
|
||||
<div class="risk-tooltip-item risk-tooltip-item--medium">
|
||||
<span>● 中风险:</span>
|
||||
<span class="risk-tooltip-count">{{ scope.row.mediumRiskCount }} 人</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 6px;">
|
||||
<span style="color: #e6a23c;">● 中风险:</span>
|
||||
<span style="font-weight: bold;">{{ scope.row.mediumRiskCount }} 人</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: #909399;">● 低风险:</span>
|
||||
<span style="font-weight: bold;">{{ scope.row.lowRiskCount }} 人</span>
|
||||
<div class="risk-tooltip-item risk-tooltip-item--low">
|
||||
<span>● 低风险:</span>
|
||||
<span class="risk-tooltip-count">{{ scope.row.lowRiskCount }} 人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="warning-count-wrapper">
|
||||
<span :class="getWarningClass(scope.row)" style="cursor: pointer;">
|
||||
<span :class="getWarningClass(scope.row)" class="warning-count">
|
||||
{{ scope.row.highRiskCount + scope.row.mediumRiskCount + scope.row.lowRiskCount }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -98,7 +90,6 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<el-table-column
|
||||
label="操作"
|
||||
width="350"
|
||||
@@ -106,50 +97,56 @@
|
||||
fixed="right"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<!-- 进行中状态 (status = '0') -->
|
||||
<el-button
|
||||
v-if="scope.row.status === '0'"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-right"
|
||||
@click="handleEnter(scope.row)"
|
||||
>进入项目</el-button>
|
||||
>
|
||||
进入项目
|
||||
</el-button>
|
||||
|
||||
<!-- 已完成状态 (status = '1') -->
|
||||
<template v-if="scope.row.status === '1'">
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-view"
|
||||
@click="handleViewResult(scope.row)"
|
||||
>查看结果</el-button>
|
||||
>
|
||||
查看结果
|
||||
</el-button>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-refresh"
|
||||
@click="handleReAnalyze(scope.row)"
|
||||
>重新分析</el-button>
|
||||
>
|
||||
重新分析
|
||||
</el-button>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-folder"
|
||||
@click="handleArchive(scope.row)"
|
||||
>归档</el-button>
|
||||
>
|
||||
归档
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 已归档状态 (status = '2') -->
|
||||
<el-button
|
||||
v-if="scope.row.status === '2'"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-view"
|
||||
@click="handleViewResult(scope.row)"
|
||||
>查看结果</el-button>
|
||||
>
|
||||
查看结果
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-show="total > 0"
|
||||
:current-page="pageParams.pageNum"
|
||||
@@ -159,147 +156,89 @@
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
style="margin-top: 16px; text-align: right;"
|
||||
style="margin-top: 16px; text-align: right"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ProjectTable',
|
||||
dicts: ['ccdi_project_status', 'ccdi_config_type'],
|
||||
name: "ProjectTable",
|
||||
dicts: ["ccdi_project_status", "ccdi_config_type"],
|
||||
props: {
|
||||
dataList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0
|
||||
default: 0,
|
||||
},
|
||||
pageParams: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
pageNum: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
}
|
||||
pageSize: 10,
|
||||
}),
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getStatusColor(status) {
|
||||
const colorMap = {
|
||||
'0': '#1890ff', // 进行中 - 蓝色
|
||||
'1': '#52c41a', // 已完成 - 绿色
|
||||
'2': '#8c8c8c' // 已归档 - 灰色
|
||||
}
|
||||
return colorMap[status] || '#8c8c8c'
|
||||
0: "#1890ff",
|
||||
1: "#52c41a",
|
||||
2: "#8c8c8c",
|
||||
};
|
||||
return colorMap[status] || "#8c8c8c";
|
||||
},
|
||||
|
||||
getWarningClass(row) {
|
||||
const total = row.highRiskCount + row.mediumRiskCount + row.lowRiskCount
|
||||
const total = row.highRiskCount + row.mediumRiskCount + row.lowRiskCount;
|
||||
if (row.highRiskCount > 0) {
|
||||
return 'text-danger text-bold'
|
||||
} else if (row.mediumRiskCount > 0) {
|
||||
return 'text-warning text-bold'
|
||||
} else if (total > 0) {
|
||||
return 'text-info'
|
||||
return "text-danger text-bold";
|
||||
}
|
||||
return ''
|
||||
if (row.mediumRiskCount > 0) {
|
||||
return "text-warning text-bold";
|
||||
}
|
||||
if (total > 0) {
|
||||
return "text-info";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
|
||||
handleEnter(row) {
|
||||
this.$emit('enter', row)
|
||||
this.$emit("enter", row);
|
||||
},
|
||||
|
||||
handleViewResult(row) {
|
||||
this.$emit('view-result', row)
|
||||
this.$emit("view-result", row);
|
||||
},
|
||||
|
||||
handleReAnalyze(row) {
|
||||
this.$emit('re-analyze', row)
|
||||
this.$emit("re-analyze", row);
|
||||
},
|
||||
|
||||
handleArchive(row) {
|
||||
this.$emit('archive', row)
|
||||
this.$emit("archive", row);
|
||||
},
|
||||
|
||||
handleSizeChange(val) {
|
||||
this.$emit('pagination', { pageNum: this.pageParams.pageNum, pageSize: val })
|
||||
this.$emit("pagination", {
|
||||
pageNum: this.pageParams.pageNum,
|
||||
pageSize: val,
|
||||
});
|
||||
},
|
||||
|
||||
handleCurrentChange(val) {
|
||||
this.$emit('pagination', { pageNum: val, pageSize: this.pageParams.pageSize })
|
||||
}
|
||||
}
|
||||
}
|
||||
this.$emit("pagination", {
|
||||
pageNum: val,
|
||||
pageSize: this.pageParams.pageSize,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-table-container {
|
||||
margin-top: 16px;
|
||||
|
||||
// 表格整体样式 - 扁平化设计
|
||||
::v-deep .el-table {
|
||||
// 移除边框和卡片效果,设置透明背景
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
overflow: hidden;
|
||||
|
||||
// 表头样式 - 扁平化,无背景色
|
||||
th.el-table__cell {
|
||||
background-color: transparent !important;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
height: 44px;
|
||||
padding: 12px 10px;
|
||||
|
||||
// 只保留底部一条分隔线
|
||||
border-bottom: 2px solid #e0e0e0 !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
// 数据行样式 - 增加留白,移除分隔线
|
||||
td.el-table__cell {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
height: 48px;
|
||||
padding: 12px 10px;
|
||||
border-bottom: none !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
// 移除列分隔线
|
||||
.el-table__body-wrapper {
|
||||
.cell {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 悬停效果
|
||||
.el-table__row {
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover > td.el-table__cell {
|
||||
background-color: #fafafa !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 移除额外边框
|
||||
&::before,
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// 移除 inner border
|
||||
.el-table__inner-wrapper::before {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
@@ -336,10 +275,44 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.risk-tooltip {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.risk-tooltip-title {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.risk-tooltip-item {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.risk-tooltip-item--high {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.risk-tooltip-item--medium {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.risk-tooltip-item--low {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.risk-tooltip-count {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warning-count-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.warning-count {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
@@ -353,10 +326,9 @@ export default {
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// 操作按钮样式 - Material Design 风格
|
||||
::v-deep .el-button--text {
|
||||
color: #1890ff;
|
||||
padding: 8px 12px;
|
||||
@@ -373,18 +345,15 @@ export default {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
// 按钮间距
|
||||
& + .el-button--text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 分页样式优化 - Material Design 风格
|
||||
::v-deep .el-pagination {
|
||||
margin-top: 24px;
|
||||
text-align: right;
|
||||
|
||||
// 扁平化按钮
|
||||
.btn-prev,
|
||||
.btn-next,
|
||||
.el-pager li {
|
||||
@@ -398,7 +367,7 @@ export default {
|
||||
|
||||
.el-pager li.active {
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
</div>
|
||||
<el-input
|
||||
v-model="queryParams.userMemo"
|
||||
placeholder="请输入摘要关键字"
|
||||
placeholder="请输入摘要关键词"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -184,8 +184,6 @@
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
border
|
||||
stripe
|
||||
class="result-table"
|
||||
@sort-change="handleSortChange"
|
||||
>
|
||||
@@ -265,103 +263,89 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-drawer
|
||||
<el-dialog
|
||||
:visible.sync="detailVisible"
|
||||
append-to-body
|
||||
custom-class="detail-drawer"
|
||||
size="520px"
|
||||
custom-class="detail-dialog"
|
||||
title="流水详情"
|
||||
width="980px"
|
||||
@close="closeDetailDialog"
|
||||
>
|
||||
<div v-loading="detailLoading" class="detail-drawer-body">
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">基础信息</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">流水ID</span>
|
||||
<span class="detail-value">{{ formatField(detailData.bankStatementId) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">交易时间</span>
|
||||
<span class="detail-value">{{ formatField(detailData.trxDate) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">币种</span>
|
||||
<span class="detail-value">{{ formatField(detailData.currency) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">交易类型</span>
|
||||
<span class="detail-value">{{ formatField(detailData.cashType) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">显示金额</span>
|
||||
<span class="detail-value">{{ formatAmount(detailData.displayAmount) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">余额</span>
|
||||
<span class="detail-value">{{ formatAmount(detailData.amountBalance) }}</span>
|
||||
<div v-loading="detailLoading" class="detail-dialog-body">
|
||||
<div class="detail-overview-grid">
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">交易时间</div>
|
||||
<div class="detail-value">{{ formatField(detailData.trxDate) }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">交易金额</div>
|
||||
<div class="detail-value amount-text" :class="getAmountClass(detailData.displayAmount)">
|
||||
{{ formatSignedAmount(detailData.displayAmount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">账户信息</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item detail-item-full">
|
||||
<span class="detail-label">本方账户</span>
|
||||
<span class="detail-value">
|
||||
{{ formatField(detailData.leAccountName) }} / {{ formatField(detailData.leAccountNo) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item detail-item-full">
|
||||
<span class="detail-label">对方账户</span>
|
||||
<span class="detail-value">
|
||||
{{ formatField(detailData.customerAccountName) }} /
|
||||
{{ formatField(detailData.customerAccountNo) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">本方银行</span>
|
||||
<span class="detail-value">{{ formatField(detailData.bank) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">对方银行</span>
|
||||
<span class="detail-value">{{ formatField(detailData.customerBank) }}</span>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">交易后余额</div>
|
||||
<div class="detail-value">{{ formatAmount(detailData.amountBalance) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">补充信息</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item detail-item-full">
|
||||
<span class="detail-label">摘要</span>
|
||||
<span class="detail-value">{{ formatField(detailData.userMemo) }}</span>
|
||||
</div>
|
||||
<div class="detail-item detail-item-full">
|
||||
<span class="detail-label">银行摘要</span>
|
||||
<span class="detail-value">{{ formatField(detailData.bankComments) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">银行交易号</span>
|
||||
<span class="detail-value">{{ formatField(detailData.bankTrxNumber) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">交易方式</span>
|
||||
<span class="detail-value">{{ formatField(detailData.paymentMethod) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">异常类型</span>
|
||||
<span class="detail-value">{{ formatField(detailData.exceptionType) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">创建时间</span>
|
||||
<span class="detail-value">{{ formatDate(detailData.createDate) }}</span>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">本方主体</div>
|
||||
<div class="detail-value">{{ formatField(detailData.leAccountName) }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">本方账号</div>
|
||||
<div class="detail-value">{{ formatField(detailData.leAccountNo) }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">本方银行</div>
|
||||
<div class="detail-value">{{ formatField(detailData.bank) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">对方名称</div>
|
||||
<div class="detail-value">{{ formatCounterpartyName(detailData) }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">对方账户</div>
|
||||
<div class="detail-value">{{ formatField(detailData.customerAccountNo) }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">对方银行</div>
|
||||
<div class="detail-value">{{ formatField(detailData.customerBank) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">摘要</div>
|
||||
<div class="detail-value">{{ formatField(detailData.userMemo) }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">交易类型</div>
|
||||
<div class="detail-value">{{ formatField(detailData.cashType) }}</div>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<div class="detail-label">银行摘要</div>
|
||||
<div class="detail-value">{{ formatField(detailData.bankComments) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-field detail-field--full">
|
||||
<div class="detail-label">原始文件</div>
|
||||
<div class="detail-file-block">
|
||||
<i class="el-icon-document detail-file-icon"></i>
|
||||
<div class="detail-file-meta">
|
||||
<div class="detail-file-name">{{ formatOriginalFileName(detailData) }}</div>
|
||||
<div class="detail-file-time">
|
||||
上传时间:{{ formatOriginalFileUploadTime(detailData) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
<div slot="footer" class="detail-dialog-footer">
|
||||
<el-button @click="closeDetailDialog">取消</el-button>
|
||||
<el-button type="primary" @click="closeDetailDialog">确定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -414,6 +398,7 @@ const createEmptyOptionData = () => ({
|
||||
|
||||
const createEmptyDetailData = () => ({
|
||||
bankStatementId: "",
|
||||
projectId: "",
|
||||
trxDate: "",
|
||||
currency: "",
|
||||
leAccountNo: "",
|
||||
@@ -421,16 +406,27 @@ const createEmptyDetailData = () => ({
|
||||
customerAccountName: "",
|
||||
customerAccountNo: "",
|
||||
customerBank: "",
|
||||
customerReference: "",
|
||||
userMemo: "",
|
||||
bankComments: "",
|
||||
bankTrxNumber: "",
|
||||
bank: "",
|
||||
cashType: "",
|
||||
amountDr: "",
|
||||
amountCr: "",
|
||||
amountBalance: "",
|
||||
displayAmount: "",
|
||||
paymentMethod: "",
|
||||
trxFlag: "",
|
||||
trxType: "",
|
||||
exceptionType: "",
|
||||
internalFlag: "",
|
||||
paymentMethod: "",
|
||||
cretNo: "",
|
||||
createDate: "",
|
||||
originalFileName: "",
|
||||
uploadTime: "",
|
||||
sourceFileName: "",
|
||||
fileName: "",
|
||||
});
|
||||
|
||||
export default {
|
||||
@@ -593,6 +589,7 @@ export default {
|
||||
},
|
||||
closeDetailDialog() {
|
||||
this.detailVisible = false;
|
||||
this.detailLoading = false;
|
||||
this.detailData = createEmptyDetailData();
|
||||
},
|
||||
handleExport() {
|
||||
@@ -607,7 +604,10 @@ export default {
|
||||
);
|
||||
},
|
||||
formatField(value) {
|
||||
return value || "-";
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
formatDate(value) {
|
||||
return value ? parseTime(value, "{y}-{m}-{d} {h}:{i}:{s}") : "-";
|
||||
@@ -616,11 +616,55 @@ export default {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
return Number(value).toLocaleString("zh-CN", {
|
||||
const amount = Number(value);
|
||||
if (Number.isNaN(amount)) {
|
||||
return "-";
|
||||
}
|
||||
return amount.toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
},
|
||||
formatSignedAmount(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
const amount = Number(value);
|
||||
if (Number.isNaN(amount)) {
|
||||
return "-";
|
||||
}
|
||||
const text = this.formatAmount(amount);
|
||||
return amount > 0 ? `+${text}` : text;
|
||||
},
|
||||
getAmountClass(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "";
|
||||
}
|
||||
return Number(value) >= 0 ? "amount-in" : "amount-out";
|
||||
},
|
||||
formatCounterpartyName(detail) {
|
||||
if (!detail) {
|
||||
return "-";
|
||||
}
|
||||
return this.formatField(detail.customerAccountName);
|
||||
},
|
||||
formatOriginalFileName(detail) {
|
||||
if (!detail) {
|
||||
return "暂无原始文件";
|
||||
}
|
||||
return (
|
||||
detail.originalFileName ||
|
||||
detail.sourceFileName ||
|
||||
detail.fileName ||
|
||||
"暂无原始文件"
|
||||
);
|
||||
},
|
||||
formatOriginalFileUploadTime(detail) {
|
||||
if (!detail) {
|
||||
return "-";
|
||||
}
|
||||
return this.formatDate(detail.uploadTime);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -747,7 +791,7 @@ export default {
|
||||
}
|
||||
|
||||
.result-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -789,51 +833,106 @@ export default {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.detail-drawer-body {
|
||||
padding: 0 4px 24px;
|
||||
.detail-dialog-body {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
margin-bottom: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
.detail-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 16px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 24px 32px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
.detail-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-item-full {
|
||||
.detail-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #303133;
|
||||
line-height: 20px;
|
||||
line-height: 22px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-file-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.detail-file-icon {
|
||||
margin-top: 2px;
|
||||
font-size: 20px;
|
||||
color: #f59a23;
|
||||
}
|
||||
|
||||
.detail-file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-file-name {
|
||||
color: #303133;
|
||||
line-height: 22px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-file-time {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.detail-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog) {
|
||||
border-radius: 8px;
|
||||
|
||||
.el-dialog__header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.el-dialog__title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 8px 24px 24px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.query-page-shell {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -842,6 +941,11 @@ export default {
|
||||
.filter-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-overview-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -862,8 +966,30 @@ export default {
|
||||
margin: 12px 12px 0;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
.detail-overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.detail-dialog-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.detail-dialog) {
|
||||
width: calc(100vw - 24px) !important;
|
||||
margin-top: 8vh !important;
|
||||
|
||||
.el-dialog__header,
|
||||
.el-dialog__body,
|
||||
.el-dialog__footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<el-button icon="el-icon-refresh" @click="handleManualRefresh">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="fileUploadList" v-loading="listLoading" stripe border>
|
||||
<el-table :data="fileUploadList" v-loading="listLoading">
|
||||
<el-table-column prop="fileName" label="文件名" min-width="200"></el-table-column>
|
||||
<el-table-column prop="fileSize" label="文件大小" width="120">
|
||||
<template slot-scope="scope">
|
||||
@@ -77,6 +77,11 @@
|
||||
{{ scope.row.enterpriseNames || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="accountNos" label="主体账号" min-width="180">
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.accountNos || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="uploadTime" label="上传时间" width="180">
|
||||
<template slot-scope="scope">
|
||||
{{ formatUploadTime(scope.row.uploadTime) }}
|
||||
@@ -199,6 +204,90 @@
|
||||
</span>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
title="拉取本行信息"
|
||||
:visible.sync="pullBankInfoDialogVisible"
|
||||
:close-on-click-modal="false"
|
||||
width="640px"
|
||||
>
|
||||
<el-form
|
||||
class="pull-bank-info-form"
|
||||
:model="pullBankInfoForm"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="证件号码">
|
||||
<el-input
|
||||
v-model="pullBankInfoForm.idCardText"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="支持逗号、中文逗号、换行分隔"
|
||||
/>
|
||||
<div class="pull-bank-field-tip">
|
||||
支持逗号、中文逗号、换行分隔,文件解析结果会自动合并并去重
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证文件">
|
||||
<div class="pull-bank-file-panel">
|
||||
<div class="pull-bank-file-actions">
|
||||
<el-upload
|
||||
class="pull-bank-file-upload"
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:show-file-list="false"
|
||||
:file-list="idCardFileList"
|
||||
accept=".xls,.xlsx"
|
||||
:on-change="handleIdCardFileChange"
|
||||
:on-remove="handleIdCardFileRemove"
|
||||
>
|
||||
<el-button slot="trigger" size="small" type="primary" plain>
|
||||
选择文件
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<div class="pull-bank-file-tip">
|
||||
支持 .xls、.xlsx 文件,解析后自动补充证件号码
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="idCardFileList.length > 0" class="selected-id-card-file">
|
||||
<div class="selected-id-card-file__info">
|
||||
<i class="el-icon-document"></i>
|
||||
<span class="selected-id-card-file__name">
|
||||
{{ idCardFileList[0].name }}
|
||||
</span>
|
||||
</div>
|
||||
<el-button type="text" @click="handleIdCardFileRemove">
|
||||
移除
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="parsingIdCardFile" class="parse-tip">
|
||||
正在解析身份证文件...
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间跨度">
|
||||
<el-date-picker
|
||||
class="pull-bank-range-picker"
|
||||
v-model="pullBankInfoForm.dateRange"
|
||||
type="daterange"
|
||||
value-format="yyyy-MM-dd"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<span slot="footer">
|
||||
<el-button @click="pullBankInfoDialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="pullBankInfoLoading"
|
||||
@click="handleConfirmPullBankInfo"
|
||||
>
|
||||
确认拉取
|
||||
</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量上传弹窗 -->
|
||||
<el-dialog
|
||||
title="批量上传流水文件"
|
||||
@@ -265,6 +354,7 @@ import {
|
||||
getNameListOptions,
|
||||
getUploadStatus,
|
||||
pullBankInfo,
|
||||
parseIdCardFile,
|
||||
updateNameListSelection,
|
||||
uploadFile,
|
||||
batchUploadFiles,
|
||||
@@ -306,6 +396,14 @@ export default {
|
||||
uploadFileTypes: "",
|
||||
fileList: [],
|
||||
uploading: false,
|
||||
pullBankInfoDialogVisible: false,
|
||||
pullBankInfoLoading: false,
|
||||
parsingIdCardFile: false,
|
||||
idCardFileList: [],
|
||||
pullBankInfoForm: {
|
||||
idCardText: "",
|
||||
dateRange: [],
|
||||
},
|
||||
// 名单选择弹窗
|
||||
showNameListDialog: false,
|
||||
nameListForm: {
|
||||
@@ -684,37 +782,139 @@ export default {
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
openPullBankInfoDialog() {
|
||||
this.pullBankInfoDialogVisible = true;
|
||||
},
|
||||
resetPullBankInfoForm() {
|
||||
this.pullBankInfoForm = {
|
||||
idCardText: "",
|
||||
dateRange: [],
|
||||
};
|
||||
this.idCardFileList = [];
|
||||
this.parsingIdCardFile = false;
|
||||
this.pullBankInfoLoading = false;
|
||||
},
|
||||
parseIdCardText(text) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(text || "")
|
||||
.split(/[\n,,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
},
|
||||
mergeIdCards(currentText, parsedIdCards) {
|
||||
const merged = [
|
||||
...this.parseIdCardText(currentText),
|
||||
...((parsedIdCards || [])
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean)),
|
||||
];
|
||||
return Array.from(new Set(merged)).join(", ");
|
||||
},
|
||||
async handleIdCardFileChange(file, fileList) {
|
||||
const latestFile = (fileList || []).slice(-1);
|
||||
const currentFile = latestFile[0] || file;
|
||||
const fileName = (currentFile && currentFile.name) || "";
|
||||
const isExcel = /\.(xls|xlsx)$/i.test(fileName);
|
||||
|
||||
if (!isExcel) {
|
||||
this.idCardFileList = [];
|
||||
this.$message.error("仅支持上传 .xls 或 .xlsx 文件");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentFile || !currentFile.raw) {
|
||||
this.idCardFileList = [];
|
||||
this.$message.error("未获取到有效文件");
|
||||
return;
|
||||
}
|
||||
|
||||
this.idCardFileList = latestFile;
|
||||
this.parsingIdCardFile = true;
|
||||
|
||||
try {
|
||||
const res = await parseIdCardFile(currentFile.raw);
|
||||
const parsedIdCards =
|
||||
(res && res.data && Array.isArray(res.data.idCards) && res.data.idCards) ||
|
||||
[];
|
||||
this.pullBankInfoForm.idCardText = this.mergeIdCards(
|
||||
this.pullBankInfoForm.idCardText,
|
||||
parsedIdCards
|
||||
);
|
||||
this.$message.success(
|
||||
`身份证文件解析成功,共 ${parsedIdCards.length} 条有效身份证`
|
||||
);
|
||||
} catch (error) {
|
||||
this.idCardFileList = [];
|
||||
this.$message.error(
|
||||
"身份证文件解析失败:" +
|
||||
((error && error.message) || "未知错误")
|
||||
);
|
||||
} finally {
|
||||
this.parsingIdCardFile = false;
|
||||
}
|
||||
},
|
||||
handleIdCardFileRemove() {
|
||||
this.idCardFileList = [];
|
||||
this.parsingIdCardFile = false;
|
||||
},
|
||||
buildFinalIdCardList() {
|
||||
return this.parseIdCardText(this.pullBankInfoForm.idCardText);
|
||||
},
|
||||
async handleConfirmPullBankInfo() {
|
||||
const idCards = this.buildFinalIdCardList();
|
||||
const [startDate, endDate] = this.pullBankInfoForm.dateRange || [];
|
||||
|
||||
if (idCards.length === 0) {
|
||||
this.$message.warning("请至少输入一个身份证号");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
this.$message.warning("请选择完整的时间跨度");
|
||||
return;
|
||||
}
|
||||
|
||||
this.pullBankInfoLoading = true;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
projectId: this.projectId,
|
||||
idCards,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
const res = await pullBankInfo(payload);
|
||||
|
||||
this.pullBankInfoDialogVisible = false;
|
||||
this.resetPullBankInfoForm();
|
||||
this.$message.success((res && res.msg) || "拉取任务已提交");
|
||||
|
||||
await Promise.all([this.loadStatistics(), this.loadFileList()]);
|
||||
|
||||
const hasPollingRecords =
|
||||
this.statistics.uploading > 0 ||
|
||||
this.statistics.parsing > 0 ||
|
||||
this.fileUploadList.some((item) =>
|
||||
["uploading", "parsing"].includes(item.fileStatus)
|
||||
);
|
||||
|
||||
if (hasPollingRecords) {
|
||||
this.startPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
this.pullBankInfoLoading = false;
|
||||
this.$message.error(
|
||||
"拉取本行信息失败:" + ((error && error.message) || "未知错误")
|
||||
);
|
||||
}
|
||||
},
|
||||
/** 拉取本行信息 */
|
||||
async handleFetchBankInfo() {
|
||||
this.$confirm("确认拉取本行信息吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
const loading = this.$loading({
|
||||
lock: true,
|
||||
text: "正在拉取本行信息...",
|
||||
spinner: "el-icon-loading",
|
||||
background: "rgba(0, 0, 0, 0.7)",
|
||||
});
|
||||
|
||||
await pullBankInfo(this.projectId);
|
||||
|
||||
loading.close();
|
||||
this.$message.success("本行信息拉取成功");
|
||||
this.$emit("fetch-bank-info");
|
||||
|
||||
// 刷新质量指标
|
||||
this.updateQualityMetrics();
|
||||
} catch (error) {
|
||||
this.$message.error(
|
||||
"拉取本行信息失败:" + (error.msg || "未知错误")
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
handleFetchBankInfo() {
|
||||
this.resetPullBankInfoForm();
|
||||
this.openPullBankInfoDialog();
|
||||
},
|
||||
/** 获取进度条偏移 */
|
||||
getProgressOffset(value) {
|
||||
@@ -1268,17 +1468,20 @@ export default {
|
||||
|
||||
// 上传弹窗样式
|
||||
::v-deep .el-dialog__wrapper {
|
||||
.upload-area {
|
||||
.upload-area,
|
||||
.batch-upload-area {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.el-upload {
|
||||
.upload-area .el-upload,
|
||||
.batch-upload-area .el-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.el-upload-dragger {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
.upload-area .el-upload-dragger,
|
||||
.batch-upload-area .el-upload-dragger {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.el-upload__tip {
|
||||
@@ -1288,6 +1491,76 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.pull-bank-info-form {
|
||||
.pull-bank-field-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.pull-bank-file-panel {
|
||||
padding: 16px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background: #fafcff;
|
||||
|
||||
.pull-bank-file-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pull-bank-file-tip {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.pull-bank-file-upload {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.selected-id-card-file {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ebeef5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.selected-id-card-file__info {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.selected-id-card-file__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pull-bank-range-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 批量上传弹窗样式
|
||||
.batch-upload-area {
|
||||
width: 100%;
|
||||
@@ -1365,6 +1638,12 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.parse-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 1200px) {
|
||||
.upload-section .upload-cards {
|
||||
@@ -1406,5 +1685,23 @@ export default {
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pull-bank-file-panel {
|
||||
padding: 12px;
|
||||
|
||||
.pull-bank-file-actions {
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.pull-bank-file-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selected-id-card-file {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
46
ruoyi-ui/tests/unit/detail-query-detail-dialog.test.js
Normal file
46
ruoyi-ui/tests/unit/detail-query-detail-dialog.test.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const componentPath = path.resolve(
|
||||
__dirname,
|
||||
"../../src/views/ccdiProject/components/detail/DetailQuery.vue"
|
||||
);
|
||||
const source = fs.readFileSync(componentPath, "utf8");
|
||||
|
||||
assert(
|
||||
source.includes("<el-dialog"),
|
||||
"详情应改为 el-dialog 弹窗展示"
|
||||
);
|
||||
|
||||
assert(
|
||||
!source.includes("<el-drawer"),
|
||||
"详情不应再使用抽屉展示"
|
||||
);
|
||||
|
||||
[
|
||||
'class="detail-dialog"',
|
||||
'class="detail-overview-grid"',
|
||||
'class="detail-dialog-footer"',
|
||||
"formatCounterpartyName(detailData)",
|
||||
"formatOriginalFileName(detailData)",
|
||||
'width="980px"',
|
||||
].forEach((token) => {
|
||||
assert(
|
||||
source.includes(token),
|
||||
`详情弹窗缺少关键结构或方法: ${token}`
|
||||
);
|
||||
});
|
||||
|
||||
const tableBlockMatch = source.match(/<el-table[\s\S]*?class="result-table"[\s\S]*?>/m);
|
||||
assert(tableBlockMatch, "未找到流水明细列表表格");
|
||||
assert(
|
||||
!/\sborder(\s|>)/.test(tableBlockMatch[0]),
|
||||
"流水明细列表不应再启用表格边框"
|
||||
);
|
||||
assert(
|
||||
!/\sstripe(\s|>)/.test(tableBlockMatch[0]),
|
||||
"流水明细列表不应再启用斑马纹"
|
||||
);
|
||||
|
||||
console.log("detail-query-detail-dialog test passed");
|
||||
@@ -53,12 +53,12 @@ assert(
|
||||
|
||||
assert(
|
||||
!source.includes("queryParams.transactionType"),
|
||||
"筛选逻辑不应再保留交易类型参数"
|
||||
"筛选逻辑中不应再保留交易类型参数"
|
||||
);
|
||||
|
||||
assert(
|
||||
!source.includes("queryParams.transactionTypeEmpty"),
|
||||
"筛选逻辑不应再保留交易类型空值匹配参数"
|
||||
"筛选逻辑中不应再保留交易类型空值匹配参数"
|
||||
);
|
||||
|
||||
assert(
|
||||
|
||||
27
ruoyi-ui/tests/unit/project-table-style.test.js
Normal file
27
ruoyi-ui/tests/unit/project-table-style.test.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const componentPath = path.resolve(
|
||||
__dirname,
|
||||
"../../src/views/ccdiProject/components/ProjectTable.vue"
|
||||
);
|
||||
const source = fs.readFileSync(componentPath, "utf8");
|
||||
|
||||
const tableBlockMatch = source.match(/<el-table[\s\S]*?style="width: 100%"[\s\S]*?>/m);
|
||||
|
||||
assert(tableBlockMatch, "未找到项目列表表格");
|
||||
assert(
|
||||
!/\sborder(\s|>)/.test(tableBlockMatch[0]),
|
||||
"项目列表表格不应启用边框"
|
||||
);
|
||||
assert(
|
||||
!/\sstripe(\s|>)/.test(tableBlockMatch[0]),
|
||||
"项目列表表格不应启用斑马纹"
|
||||
);
|
||||
assert(
|
||||
!source.includes("::v-deep .el-table"),
|
||||
"项目列表不应继续保留自定义深度表格皮肤"
|
||||
);
|
||||
|
||||
console.log("project-table-style test passed");
|
||||
21
ruoyi-ui/tests/unit/upload-data-account-column.test.js
Normal file
21
ruoyi-ui/tests/unit/upload-data-account-column.test.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const componentPath = path.resolve(
|
||||
__dirname,
|
||||
"../../src/views/ccdiProject/components/detail/UploadData.vue"
|
||||
);
|
||||
const source = fs.readFileSync(componentPath, "utf8");
|
||||
|
||||
assert(
|
||||
/<el-table-column\s+prop="accountNos"\s+label="主体账号"/.test(source),
|
||||
"上传数据文件列表应新增绑定 accountNos 的主体账号列"
|
||||
);
|
||||
|
||||
assert(
|
||||
/scope\.row\.accountNos\s*\|\|\s*['"]-['"]/.test(source),
|
||||
"主体账号列为空时应展示 '-'"
|
||||
);
|
||||
|
||||
console.log("upload-data-account-column test passed");
|
||||
25
ruoyi-ui/tests/unit/upload-data-file-list-table.test.js
Normal file
25
ruoyi-ui/tests/unit/upload-data-file-list-table.test.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const componentPath = path.resolve(
|
||||
__dirname,
|
||||
"../../src/views/ccdiProject/components/detail/UploadData.vue"
|
||||
);
|
||||
const source = fs.readFileSync(componentPath, "utf8");
|
||||
|
||||
const tableBlockMatch = source.match(
|
||||
/<el-table[\s\S]*?:data="fileUploadList"[\s\S]*?>/m
|
||||
);
|
||||
|
||||
assert(tableBlockMatch, "未找到上传数据文件列表表格");
|
||||
assert(
|
||||
!/\sborder(\s|>)/.test(tableBlockMatch[0]),
|
||||
"上传数据文件列表表格不应再启用边框"
|
||||
);
|
||||
assert(
|
||||
!/\sstripe(\s|>)/.test(tableBlockMatch[0]),
|
||||
"上传数据文件列表表格不应再启用斑马纹"
|
||||
);
|
||||
|
||||
console.log("upload-data-file-list-table test passed");
|
||||
@@ -0,0 +1,39 @@
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const componentPath = path.resolve(
|
||||
__dirname,
|
||||
"../../src/views/ccdiProject/components/detail/UploadData.vue"
|
||||
);
|
||||
const source = fs.readFileSync(componentPath, "utf8");
|
||||
|
||||
const dialogIndex = source.indexOf('title="拉取本行信息"');
|
||||
assert.notStrictEqual(dialogIndex, -1, "未找到拉取本行信息弹窗");
|
||||
|
||||
const dialogEndIndex = source.indexOf("</el-dialog>", dialogIndex);
|
||||
assert.notStrictEqual(dialogEndIndex, -1, "未找到拉取本行信息弹窗结束标签");
|
||||
|
||||
const dialogSource = source.slice(dialogIndex, dialogEndIndex);
|
||||
|
||||
assert(
|
||||
/class="pull-bank-info-form"/.test(dialogSource),
|
||||
"拉取本行信息弹窗应使用独立表单容器,便于控制排版"
|
||||
);
|
||||
|
||||
assert(
|
||||
/class="pull-bank-file-panel"/.test(dialogSource),
|
||||
"拉取本行信息弹窗应提供独立的文件导入面板"
|
||||
);
|
||||
|
||||
assert(
|
||||
/class="pull-bank-file-upload"/.test(dialogSource),
|
||||
"文件选择区域应有独立样式钩子,避免点击范围铺满整行"
|
||||
);
|
||||
|
||||
assert(
|
||||
/class="selected-id-card-file"/.test(dialogSource),
|
||||
"选择文件后应显示已选文件摘要区域"
|
||||
);
|
||||
|
||||
console.log("upload-data-pull-bank-info-dialog-layout test passed");
|
||||
184
ry.bat
184
ry.bat
@@ -1,67 +1,147 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
cd ../ruoyi-admin/target
|
||||
|
||||
rem jar平级目录
|
||||
set AppName=ruoyi-admin.jar
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
|
||||
|
||||
rem JVM参数
|
||||
set JVM_OPTS="-Dname=%AppName% -Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:NewRatio=1 -XX:SurvivorRatio=30 -XX:+UseParallelGC -XX:+UseParallelOldGC"
|
||||
set "APP_NAME=ruoyi-admin.jar"
|
||||
set "JAVA_EXE=%JAVA_EXE%"
|
||||
if not defined JAVA_EXE set "JAVA_EXE=java"
|
||||
set "JPS_EXE=%JPS_EXE%"
|
||||
if not defined JPS_EXE set "JPS_EXE=jps"
|
||||
set "MVN_EXE=%MVN_EXE%"
|
||||
if not defined MVN_EXE set "MVN_EXE=mvn"
|
||||
set "MVN_ARGS=-pl ruoyi-admin -am package -DskipTests"
|
||||
set "JVM_OPTS=-Dname=%APP_NAME% -Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -XX:NewRatio=1 -XX:SurvivorRatio=30 -XX:+UseParallelGC"
|
||||
set "DRY_RUN="
|
||||
if /I "%RY_DRY_RUN%"=="1" set "DRY_RUN=1"
|
||||
if /I "%~2"=="--dry-run" set "DRY_RUN=1"
|
||||
|
||||
if /I "%~1"=="start" goto start
|
||||
if /I "%~1"=="stop" goto stop
|
||||
if /I "%~1"=="restart" goto restart
|
||||
if /I "%~1"=="status" goto status
|
||||
if /I "%~1"=="help" goto help
|
||||
if not "%~1"=="" goto help
|
||||
|
||||
:menu
|
||||
ECHO.
|
||||
ECHO. [1] 启动%AppName%
|
||||
ECHO. [2] 关闭%AppName%
|
||||
ECHO. [3] 重启%AppName%
|
||||
ECHO. [4] 启动状态 %AppName%
|
||||
ECHO. [5] 退 出
|
||||
ECHO. [1] Start %APP_NAME%
|
||||
ECHO. [2] Stop %APP_NAME%
|
||||
ECHO. [3] Restart %APP_NAME%
|
||||
ECHO. [4] Status %APP_NAME%
|
||||
ECHO. [5] Exit
|
||||
ECHO.
|
||||
|
||||
ECHO.请输入选择项目的序号:
|
||||
ECHO.Select an action:
|
||||
set /p ID=
|
||||
IF "%id%"=="1" GOTO start
|
||||
IF "%id%"=="2" GOTO stop
|
||||
IF "%id%"=="3" GOTO restart
|
||||
IF "%id%"=="4" GOTO status
|
||||
IF "%id%"=="5" EXIT
|
||||
PAUSE
|
||||
if /I "%ID%"=="1" goto start
|
||||
if /I "%ID%"=="2" goto stop
|
||||
if /I "%ID%"=="3" goto restart
|
||||
if /I "%ID%"=="4" goto status
|
||||
if /I "%ID%"=="5" exit /b 0
|
||||
echo Invalid selection.
|
||||
pause
|
||||
goto menu
|
||||
|
||||
:help
|
||||
echo Usage: ry.bat ^<start^|stop^|restart^|status^> [--dry-run]
|
||||
exit /b 1
|
||||
|
||||
:resolveAppPath
|
||||
set "APP_PATH=%SCRIPT_DIR%\%APP_NAME%"
|
||||
if exist "%APP_PATH%" exit /b 0
|
||||
set "APP_PATH=%SCRIPT_DIR%\ruoyi-admin\target\%APP_NAME%"
|
||||
if exist "%APP_PATH%" exit /b 0
|
||||
set "APP_PATH="
|
||||
exit /b 1
|
||||
|
||||
:shouldPackageJar
|
||||
set "NEED_PACKAGE="
|
||||
if not exist "%SCRIPT_DIR%\pom.xml" exit /b 0
|
||||
if not exist "%SCRIPT_DIR%\ruoyi-admin\pom.xml" exit /b 0
|
||||
if exist "%SCRIPT_DIR%\ruoyi-admin\target\%APP_NAME%.original" exit /b 0
|
||||
set "NEED_PACKAGE=1"
|
||||
exit /b 0
|
||||
|
||||
:findProcess
|
||||
set "pid="
|
||||
set "image_name="
|
||||
for /f "tokens=1,*" %%a in ('%JPS_EXE% -l ^| findstr /I /C:"%APP_NAME%"') do (
|
||||
set "pid=%%a"
|
||||
set "image_name=%%b"
|
||||
goto findProcessDone
|
||||
)
|
||||
:findProcessDone
|
||||
exit /b 0
|
||||
|
||||
:start
|
||||
for /f "usebackq tokens=1-2" %%a in (`jps -l ^| findstr %AppName%`) do (
|
||||
set pid=%%a
|
||||
set image_name=%%b
|
||||
)
|
||||
if defined pid (
|
||||
echo %%is running
|
||||
PAUSE
|
||||
)
|
||||
call :shouldPackageJar
|
||||
if defined NEED_PACKAGE (
|
||||
if defined DRY_RUN echo BUILD_CMD=%MVN_EXE% %MVN_ARGS%
|
||||
if not defined DRY_RUN (
|
||||
echo Packaging executable jar for %APP_NAME%...
|
||||
pushd "%SCRIPT_DIR%"
|
||||
call %MVN_EXE% %MVN_ARGS%
|
||||
set "build_exit=!ERRORLEVEL!"
|
||||
popd
|
||||
if not "!build_exit!"=="0" exit /b !build_exit!
|
||||
)
|
||||
)
|
||||
|
||||
start javaw %JVM_OPTS% -jar %AppName%
|
||||
call :resolveAppPath
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Unable to find %APP_NAME%.
|
||||
echo Checked:
|
||||
echo %SCRIPT_DIR%\%APP_NAME%
|
||||
echo %SCRIPT_DIR%\ruoyi-admin\target\%APP_NAME%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo starting……
|
||||
echo Start %AppName% success...
|
||||
goto:eof
|
||||
call :findProcess
|
||||
if defined pid (
|
||||
echo %APP_NAME% is already running. PID=!pid!
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
if defined DRY_RUN (
|
||||
echo START_CMD=%JAVA_EXE% %JVM_OPTS% -jar "%APP_PATH%"
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo Starting %APP_NAME%...
|
||||
%JAVA_EXE% %JVM_OPTS% -jar "%APP_PATH%"
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
rem 函数stop通过jps命令查找pid并结束进程
|
||||
:stop
|
||||
for /f "usebackq tokens=1-2" %%a in (`jps -l ^| findstr %AppName%`) do (
|
||||
set pid=%%a
|
||||
set image_name=%%b
|
||||
)
|
||||
if not defined pid (echo process %AppName% does not exists) else (
|
||||
echo prepare to kill %image_name%
|
||||
echo start kill %pid% ...
|
||||
rem 根据进程ID,kill进程
|
||||
taskkill /f /pid %pid%
|
||||
)
|
||||
goto:eof
|
||||
call :findProcess
|
||||
if not defined pid (
|
||||
echo process %APP_NAME% does not exist
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo Stopping %APP_NAME% (PID !pid! )...
|
||||
taskkill /f /pid !pid! >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo [ERROR] Failed to stop %APP_NAME%.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo %APP_NAME% stopped.
|
||||
exit /b 0
|
||||
|
||||
:restart
|
||||
call :stop
|
||||
call :start
|
||||
goto:eof
|
||||
call :stop
|
||||
if errorlevel 1 exit /b 1
|
||||
call :start
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:status
|
||||
for /f "usebackq tokens=1-2" %%a in (`jps -l ^| findstr %AppName%`) do (
|
||||
set pid=%%a
|
||||
set image_name=%%b
|
||||
)
|
||||
if not defined pid (echo process %AppName% is dead ) else (
|
||||
echo %image_name% is running
|
||||
)
|
||||
goto:eof
|
||||
call :findProcess
|
||||
if not defined pid (
|
||||
echo process %APP_NAME% is dead
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo %image_name% is running with PID !pid!
|
||||
exit /b 0
|
||||
|
||||
Reference in New Issue
Block a user