feat(energy): 添加三步导入接口(预览/确认/进度)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-03-16 01:00:21 +08:00
parent 06101aac02
commit 7b51cf282d
6 changed files with 339 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ public interface ErrorCodeConstants {
ErrorCode HYDROGEN_RECORD_NOT_EXISTS = new ErrorCode(1_050_000_000, "加氢记录不存在"); ErrorCode HYDROGEN_RECORD_NOT_EXISTS = new ErrorCode(1_050_000_000, "加氢记录不存在");
ErrorCode HYDROGEN_RECORD_IMPORT_EMPTY = new ErrorCode(1_050_000_001, "导入数据为空"); ErrorCode HYDROGEN_RECORD_IMPORT_EMPTY = new ErrorCode(1_050_000_001, "导入数据为空");
ErrorCode HYDROGEN_RECORD_DUPLICATE = new ErrorCode(1_050_000_002, "存在重复导入记录"); ErrorCode HYDROGEN_RECORD_DUPLICATE = new ErrorCode(1_050_000_002, "存在重复导入记录");
ErrorCode HYDROGEN_RECORD_IMPORT_PREVIEW_EXPIRED = new ErrorCode(1_050_000_003, "导入预览已过期或不存在,请重新上传文件");
// ========== 加氢明细 1-050-001-000 ========== // ========== 加氢明细 1-050-001-000 ==========
ErrorCode HYDROGEN_DETAIL_NOT_EXISTS = new ErrorCode(1_050_001_000, "加氢明细不存在"); ErrorCode HYDROGEN_DETAIL_NOT_EXISTS = new ErrorCode(1_050_001_000, "加氢明细不存在");

View File

@@ -102,6 +102,33 @@ public class HydrogenRecordController {
return success(result); return success(result);
} }
@PostMapping("/import-preview")
@Operation(summary = "导入预览")
@PreAuthorize("@ss.hasPermission('energy:hydrogen-record:import')")
public CommonResult<HydrogenRecordImportPreviewVO> importPreview(
@RequestParam("stationId") Long stationId,
@RequestParam("file") MultipartFile file) throws Exception {
List<HydrogenRecordImportVO> list = ExcelUtils.read(file, HydrogenRecordImportVO.class);
return success(hydrogenRecordService.importPreview(stationId, list));
}
@PostMapping("/import-confirm")
@Operation(summary = "确认导入")
@PreAuthorize("@ss.hasPermission('energy:hydrogen-record:import')")
public CommonResult<Map<String, Integer>> importConfirm(
@RequestParam("batchNo") String batchNo,
@RequestParam(value = "duplicateStrategy", defaultValue = "skip") String duplicateStrategy) {
return success(hydrogenRecordService.importConfirm(batchNo, duplicateStrategy));
}
@GetMapping("/import-progress")
@Operation(summary = "导入进度")
@PreAuthorize("@ss.hasPermission('energy:hydrogen-record:import')")
public CommonResult<HydrogenRecordImportProgressVO> getImportProgress(
@RequestParam("batchNo") String batchNo) {
return success(hydrogenRecordService.getImportProgress(batchNo));
}
@PutMapping("/match") @PutMapping("/match")
@Operation(summary = "手动匹配车辆") @Operation(summary = "手动匹配车辆")
@PreAuthorize("@ss.hasPermission('energy:hydrogen-record:update')") @PreAuthorize("@ss.hasPermission('energy:hydrogen-record:update')")

View File

@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.energy.controller.admin.record.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Schema(description = "管理后台 - 加氢记录导入预览 Response VO")
@Data
public class HydrogenRecordImportPreviewVO {
@Schema(description = "批次号")
private String batchNo;
@Schema(description = "总行数")
private Integer totalCount;
@Schema(description = "有效行数")
private Integer validCount;
@Schema(description = "重复行数")
private Integer duplicateCount;
@Schema(description = "错误行数")
private Integer errorCount;
@Schema(description = "预览记录")
private List<RecordPreviewItem> records;
@Schema(description = "重复记录")
private List<RecordPreviewItem> duplicates;
@Schema(description = "错误明细")
private List<ImportErrorItem> errors;
@Data
public static class RecordPreviewItem {
private Integer rowNum;
private String plateNumber;
private LocalDate hydrogenDate;
private BigDecimal hydrogenQuantity;
private BigDecimal unitPrice;
private BigDecimal amount;
private BigDecimal mileage;
private Boolean isDuplicate;
}
@Data
public static class ImportErrorItem {
private Integer rowNum;
private String reason;
}
}

View File

@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.energy.controller.admin.record.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 加氢记录导入进度 Response VO")
@Data
public class HydrogenRecordImportProgressVO {
@Schema(description = "当前进度")
private Integer current;
@Schema(description = "总数")
private Integer total;
@Schema(description = "状态: processing / completed / failed")
private String status;
}

View File

@@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.energy.service.record; package cn.iocoder.yudao.module.energy.service.record;
import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordImportPreviewVO;
import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordImportProgressVO;
import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordImportVO; import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordImportVO;
import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordPageReqVO; import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordPageReqVO;
import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordSaveReqVO; import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordSaveReqVO;
@@ -17,4 +19,7 @@ public interface HydrogenRecordService {
Map<String, Integer> importRecords(Long stationId, List<HydrogenRecordImportVO> list, String batchNo); Map<String, Integer> importRecords(Long stationId, List<HydrogenRecordImportVO> list, String batchNo);
void matchRecord(Long id, Long vehicleId, Long customerId); void matchRecord(Long id, Long vehicleId, Long customerId);
Map<String, Integer> batchMatch(); Map<String, Integer> batchMatch();
HydrogenRecordImportPreviewVO importPreview(Long stationId, List<HydrogenRecordImportVO> list);
Map<String, Integer> importConfirm(String batchNo, String duplicateStrategy);
HydrogenRecordImportProgressVO getImportProgress(String batchNo);
} }

View File

@@ -1,6 +1,9 @@
package cn.iocoder.yudao.module.energy.service.record; package cn.iocoder.yudao.module.energy.service.record;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordImportPreviewVO;
import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordImportProgressVO;
import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordImportVO; import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordImportVO;
import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordPageReqVO; import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordPageReqVO;
import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordSaveReqVO; import cn.iocoder.yudao.module.energy.controller.admin.record.vo.HydrogenRecordSaveReqVO;
@@ -15,10 +18,17 @@ import cn.iocoder.yudao.module.energy.event.RecordMatchedEvent;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.energy.enums.ErrorCodeConstants.*; import static cn.iocoder.yudao.module.energy.enums.ErrorCodeConstants.*;
@@ -28,10 +38,16 @@ import static cn.iocoder.yudao.module.energy.enums.ErrorCodeConstants.*;
@Slf4j @Slf4j
public class HydrogenRecordServiceImpl implements HydrogenRecordService { public class HydrogenRecordServiceImpl implements HydrogenRecordService {
private static final String REDIS_KEY_PREVIEW = "energy:import:preview:";
private static final String REDIS_KEY_PROGRESS = "energy:import:progress:";
private static final long PREVIEW_TTL_MINUTES = 15L;
@Resource @Resource
private EnergyHydrogenRecordMapper hydrogenRecordMapper; private EnergyHydrogenRecordMapper hydrogenRecordMapper;
@Resource @Resource
private ApplicationEventPublisher eventPublisher; private ApplicationEventPublisher eventPublisher;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override @Override
public Long createRecord(HydrogenRecordSaveReqVO createReqVO) { public Long createRecord(HydrogenRecordSaveReqVO createReqVO) {
@@ -144,6 +160,229 @@ public class HydrogenRecordServiceImpl implements HydrogenRecordService {
return result; return result;
} }
// ==================== 三步导入接口 ====================
@Override
public HydrogenRecordImportPreviewVO importPreview(Long stationId, List<HydrogenRecordImportVO> list) {
if (list == null || list.isEmpty()) {
throw exception(HYDROGEN_RECORD_IMPORT_EMPTY);
}
String batchNo = generateBatchNo();
List<HydrogenRecordImportPreviewVO.RecordPreviewItem> validRecords = new ArrayList<>();
List<HydrogenRecordImportPreviewVO.RecordPreviewItem> duplicateRecords = new ArrayList<>();
List<HydrogenRecordImportPreviewVO.ImportErrorItem> errors = new ArrayList<>();
// Build a set of keys for existing DB records (stationId+plateNumber+hydrogenDate)
// We'll check duplicates on-the-fly per record to avoid pulling too much data at once
Set<String> seenInBatch = new HashSet<>();
int rowNum = 1;
for (HydrogenRecordImportVO importVO : list) {
// Validate required fields
if (importVO.getPlateNumber() == null || importVO.getPlateNumber().isBlank()) {
HydrogenRecordImportPreviewVO.ImportErrorItem err = new HydrogenRecordImportPreviewVO.ImportErrorItem();
err.setRowNum(rowNum);
err.setReason("车牌号不能为空");
errors.add(err);
rowNum++;
continue;
}
if (importVO.getHydrogenDate() == null) {
HydrogenRecordImportPreviewVO.ImportErrorItem err = new HydrogenRecordImportPreviewVO.ImportErrorItem();
err.setRowNum(rowNum);
err.setReason("加氢日期不能为空");
errors.add(err);
rowNum++;
continue;
}
if (importVO.getHydrogenQuantity() == null) {
HydrogenRecordImportPreviewVO.ImportErrorItem err = new HydrogenRecordImportPreviewVO.ImportErrorItem();
err.setRowNum(rowNum);
err.setReason("加氢量不能为空");
errors.add(err);
rowNum++;
continue;
}
// Check duplicate: stationId + plateNumber + hydrogenDate
String dedupKey = stationId + ":" + importVO.getPlateNumber() + ":" + importVO.getHydrogenDate();
boolean isDuplicateInBatch = seenInBatch.contains(dedupKey);
boolean isDuplicateInDb = false;
if (!isDuplicateInBatch) {
Long count = hydrogenRecordMapper.selectCount(
new LambdaQueryWrapperX<EnergyHydrogenRecordDO>()
.eq(EnergyHydrogenRecordDO::getStationId, stationId)
.eq(EnergyHydrogenRecordDO::getPlateNumber, importVO.getPlateNumber())
.eq(EnergyHydrogenRecordDO::getHydrogenDate, importVO.getHydrogenDate()));
isDuplicateInDb = count != null && count > 0;
}
HydrogenRecordImportPreviewVO.RecordPreviewItem item = new HydrogenRecordImportPreviewVO.RecordPreviewItem();
item.setRowNum(rowNum);
item.setPlateNumber(importVO.getPlateNumber());
item.setHydrogenDate(importVO.getHydrogenDate());
item.setHydrogenQuantity(importVO.getHydrogenQuantity());
item.setUnitPrice(importVO.getUnitPrice());
item.setAmount(importVO.getAmount());
item.setMileage(importVO.getMileage());
item.setIsDuplicate(isDuplicateInBatch || isDuplicateInDb);
if (isDuplicateInBatch || isDuplicateInDb) {
duplicateRecords.add(item);
} else {
validRecords.add(item);
seenInBatch.add(dedupKey);
}
rowNum++;
}
// Build cache payload
Map<String, Object> cachePayload = new HashMap<>();
cachePayload.put("stationId", stationId);
cachePayload.put("validRecords", validRecords);
cachePayload.put("duplicateRecords", duplicateRecords);
// Store in Redis with 15-minute TTL
stringRedisTemplate.opsForValue().set(
REDIS_KEY_PREVIEW + batchNo,
JSONUtil.toJsonStr(cachePayload),
PREVIEW_TTL_MINUTES,
TimeUnit.MINUTES);
// Build response
HydrogenRecordImportPreviewVO result = new HydrogenRecordImportPreviewVO();
result.setBatchNo(batchNo);
result.setTotalCount(list.size());
result.setValidCount(validRecords.size());
result.setDuplicateCount(duplicateRecords.size());
result.setErrorCount(errors.size());
// Return first 50 preview records
result.setRecords(validRecords.stream().limit(50).collect(Collectors.toList()));
result.setDuplicates(duplicateRecords.stream().limit(50).collect(Collectors.toList()));
result.setErrors(errors);
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Integer> importConfirm(String batchNo, String duplicateStrategy) {
// Read cached preview data
String cacheJson = stringRedisTemplate.opsForValue().get(REDIS_KEY_PREVIEW + batchNo);
if (cacheJson == null || cacheJson.isBlank()) {
throw exception(HYDROGEN_RECORD_IMPORT_PREVIEW_EXPIRED);
}
cn.hutool.json.JSONObject payload = JSONUtil.parseObj(cacheJson);
Long stationId = payload.getLong("stationId");
List<HydrogenRecordImportPreviewVO.RecordPreviewItem> validRecords =
JSONUtil.toList(payload.getJSONArray("validRecords"), HydrogenRecordImportPreviewVO.RecordPreviewItem.class);
List<HydrogenRecordImportPreviewVO.RecordPreviewItem> duplicateRecords =
JSONUtil.toList(payload.getJSONArray("duplicateRecords"), HydrogenRecordImportPreviewVO.RecordPreviewItem.class);
// Determine records to insert based on strategy
List<HydrogenRecordImportPreviewVO.RecordPreviewItem> toInsert = new ArrayList<>(validRecords);
if ("overwrite".equals(duplicateStrategy)) {
toInsert.addAll(duplicateRecords);
}
int total = toInsert.size();
// Set initial progress
HydrogenRecordImportProgressVO progress = new HydrogenRecordImportProgressVO();
progress.setCurrent(0);
progress.setTotal(total);
progress.setStatus("processing");
stringRedisTemplate.opsForValue().set(
REDIS_KEY_PROGRESS + batchNo,
JSONUtil.toJsonStr(progress),
PREVIEW_TTL_MINUTES,
TimeUnit.MINUTES);
int successCount = 0;
int failCount = 0;
List<Long> recordIds = new ArrayList<>();
for (int i = 0; i < toInsert.size(); i++) {
HydrogenRecordImportPreviewVO.RecordPreviewItem item = toInsert.get(i);
try {
if ("overwrite".equals(duplicateStrategy) && Boolean.TRUE.equals(item.getIsDuplicate())) {
// Delete existing record(s) with same key before inserting
hydrogenRecordMapper.delete(
new LambdaQueryWrapperX<EnergyHydrogenRecordDO>()
.eq(EnergyHydrogenRecordDO::getStationId, stationId)
.eq(EnergyHydrogenRecordDO::getPlateNumber, item.getPlateNumber())
.eq(EnergyHydrogenRecordDO::getHydrogenDate, item.getHydrogenDate()));
}
EnergyHydrogenRecordDO record = EnergyHydrogenRecordDO.builder()
.stationId(stationId)
.plateNumber(item.getPlateNumber())
.hydrogenDate(item.getHydrogenDate())
.hydrogenQuantity(item.getHydrogenQuantity())
.unitPrice(item.getUnitPrice())
.amount(item.getAmount())
.mileage(item.getMileage())
.sourceType(SourceTypeEnum.EXCEL.getType())
.matchStatus(MatchStatusEnum.UNMATCHED.getStatus())
.uploadBatchNo(batchNo)
.build();
hydrogenRecordMapper.insert(record);
recordIds.add(record.getId());
successCount++;
} catch (Exception e) {
log.warn("[importConfirm] Failed to import record row {}: {}", item.getRowNum(), e.getMessage());
failCount++;
}
// Update progress
progress.setCurrent(i + 1);
stringRedisTemplate.opsForValue().set(
REDIS_KEY_PROGRESS + batchNo,
JSONUtil.toJsonStr(progress),
PREVIEW_TTL_MINUTES,
TimeUnit.MINUTES);
}
// Mark progress as completed
progress.setStatus(failCount == 0 ? "completed" : "failed");
stringRedisTemplate.opsForValue().set(
REDIS_KEY_PROGRESS + batchNo,
JSONUtil.toJsonStr(progress),
PREVIEW_TTL_MINUTES,
TimeUnit.MINUTES);
// Publish event
if (!recordIds.isEmpty()) {
eventPublisher.publishEvent(new RecordImportedEvent(stationId, recordIds, batchNo));
}
// Clean up preview cache
stringRedisTemplate.delete(REDIS_KEY_PREVIEW + batchNo);
Map<String, Integer> result = new HashMap<>();
result.put("successCount", successCount);
result.put("failCount", failCount);
return result;
}
@Override
public HydrogenRecordImportProgressVO getImportProgress(String batchNo) {
String json = stringRedisTemplate.opsForValue().get(REDIS_KEY_PROGRESS + batchNo);
if (json == null || json.isBlank()) {
HydrogenRecordImportProgressVO vo = new HydrogenRecordImportProgressVO();
vo.setCurrent(0);
vo.setTotal(0);
vo.setStatus("completed");
return vo;
}
return JSONUtil.toBean(json, HydrogenRecordImportProgressVO.class);
}
// ==================== 私有方法 ====================
private EnergyHydrogenRecordDO validateRecordExists(Long id) { private EnergyHydrogenRecordDO validateRecordExists(Long id) {
EnergyHydrogenRecordDO record = hydrogenRecordMapper.selectById(id); EnergyHydrogenRecordDO record = hydrogenRecordMapper.selectById(id);
if (record == null) { if (record == null) {
@@ -151,4 +390,9 @@ public class HydrogenRecordServiceImpl implements HydrogenRecordService {
} }
return record; return record;
} }
private String generateBatchNo() {
return "IMP" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
+ UUID.randomUUID().toString().substring(0, 4).toUpperCase();
}
} }