兼容甲方三类数据单页导入并保留同秒记录
按当前测试绑定校验来源身份,整页校验后事务写入,重复内容跳过、冲突拒绝,并返回新增和跳过数量。保留设备时间原文及记录编号,新增 V8 迁移区分历史导入和主动轮询槽位。
后端全量测试 268 项通过,包含甲方完整样例及事务回滚验证;可执行包构建通过。
| | |
| | | @JsonIgnore |
| | | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) |
| | | private Date endCreatedAt; |
| | | /** 本次提交结果,不持久化到修订事实。 */ |
| | | private int importedCount; |
| | | private int skippedCount; |
| | | |
| | | public int getImportedCount() { return importedCount; } |
| | | public void setImportedCount(int value) { importedCount = value; } |
| | | public int getSkippedCount() { return skippedCount; } |
| | | public void setSkippedCount(int value) { skippedCount = value; } |
| | | |
| | | public Long getActionId() { return actionId; } |
| | | public void setActionId(Long value) { actionId = value; } |
| | |
| | | int updateDataRecord(@Param("testId") Long testId, @Param("dataType") String dataType, @Param("recordId") Long recordId, @Param("values") java.util.Map<String, Object> values); |
| | | int deleteDataRecord(@Param("testId") Long testId, @Param("dataType") String dataType, @Param("recordId") Long recordId); |
| | | java.util.Map<String, Object> selectImportBinding(@Param("testId") Long testId, @Param("batteryNo") String batteryNo); |
| | | /** 查询当前测试该类事实,供整页导入校验身份和内容冲突。 */ |
| | | List<java.util.Map<String, Object>> selectImportDataRecords(@Param("testId") Long testId, @Param("dataType") String dataType); |
| | | int insertImportedDataRecord(@Param("dataType") String dataType, @Param("values") java.util.Map<String, Object> values); |
| | | TTest selectTestById(Long testId); |
| | | TTest selectTestByIdForUpdate(Long testId); |
| | |
| | | package com.battery.business.service.impl; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.security.MessageDigest; |
| | | import java.time.Instant; |
| | | import java.time.ZoneOffset; |
| | | import java.util.ArrayList; |
| | | import java.util.Date; |
| | | import java.util.HashSet; |
| | |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import java.util.UUID; |
| | | import java.util.Objects; |
| | | import java.util.TreeMap; |
| | | import com.alibaba.fastjson2.JSON; |
| | | import com.battery.business.domain.DataRevisionItem; |
| | | import com.battery.business.domain.TTest; |
| | |
| | | try { rows = JSON.parseArray(action.getChangesSummary(), Map.class); } |
| | | catch (RuntimeException ex) { throw new ServiceException("导入数据格式无效"); } |
| | | if (rows == null || rows.isEmpty()) throw new ServiceException("导入数据不能为空"); |
| | | Set<String> identities = new HashSet<>(); |
| | | for (Map<String, Object> row : rows) |
| | | Map<String, Map<String, Object>> identities = new LinkedHashMap<>(); |
| | | Map<String, Map<String, Object>> bindings = new LinkedHashMap<>(); |
| | | for (Map<String, Object> existing : mapper.selectImportDataRecords(action.getTestId(), action.getDataType())) |
| | | { |
| | | String recordNo = row.get("recordNo") == null ? "" : row.get("recordNo").toString(); |
| | | if (recordNo.isBlank() || !identities.add(recordNo)) throw new ServiceException("导入记录编号为空或重复"); |
| | | Map<String, Object> values = importedRecord(action, row); |
| | | identities.put(importIdentity(existing), existing); |
| | | } |
| | | List<String> errors = new ArrayList<>(); |
| | | List<String> addedIdentities = new ArrayList<>(); |
| | | action.setImportedCount(0); action.setSkippedCount(0); |
| | | for (int index = 0; index < rows.size(); index++) |
| | | { |
| | | Map<String, Object> row = rows.get(index); |
| | | Object sourceRow = row == null ? index + 2 : row.getOrDefault("sourceRow", index + 2); |
| | | try |
| | | { |
| | | if (row == null) throw new ServiceException("列「记录编号」:数据行不能为空"); |
| | | String batteryNo = importText(row, "batteryNo", "电池号", 100); |
| | | Map<String, Object> binding = bindings.computeIfAbsent(batteryNo, key -> mapper.selectImportBinding(action.getTestId(), key)); |
| | | Map<String, Object> values = importedRecord(action, row, binding); |
| | | String identity = importIdentity(values); |
| | | Map<String, Object> previous = identities.get(identity); |
| | | if (previous != null) |
| | | { |
| | | if (!sameImportContent(values, previous)) throw new ServiceException("列「记录编号」:相同来源身份和编号存在不同内容,冲突拒绝"); |
| | | action.setSkippedCount(action.getSkippedCount() + 1); |
| | | continue; |
| | | } |
| | | identities.put(identity, values); |
| | | addedIdentities.add(identity); |
| | | mutations.add(() -> mapper.insertImportedDataRecord(action.getDataType(), values)); |
| | | changes.add(new LinkedHashMap<>(row)); |
| | | } |
| | | action.setIdentitySummary(JSON.toJSONString(identities)); |
| | | catch (ServiceException ex) { errors.add("第" + sourceRow + "行," + ex.getMessage()); } |
| | | } |
| | | // 先完成整页校验,再写入修订与数据;测试行锁同时串行化重复提交和整体删除。 |
| | | if (!errors.isEmpty()) throw new ServiceException(String.join(";", errors)); |
| | | action.setImportedCount(changes.size()); |
| | | action.setIdentitySummary(JSON.toJSONString(addedIdentities)); |
| | | if (changes.isEmpty()) return null; |
| | | } |
| | | else |
| | | { |
| | |
| | | values.put("extension_measurements_json", JSON.toJSONString(extension)); |
| | | } |
| | | |
| | | private Map<String, Object> importedRecord(TTestDataRevisionAction action, Map<String, Object> row) |
| | | private Map<String, Object> importedRecord(TTestDataRevisionAction action, Map<String, Object> row, Map<String, Object> binding) |
| | | { |
| | | Map<String, Object> binding = mapper.selectImportBinding(action.getTestId(), String.valueOf(row.get("batteryNo"))); |
| | | if (binding == null) throw new ServiceException("导入电池号不属于当前测试"); |
| | | Map<String, Object> values = new LinkedHashMap<>(binding); |
| | | if (binding == null || List.of("test_battery_id", "binding_id", "adapter_code", "devtype", "devid", "subdevid", "chlid") |
| | | .stream().anyMatch(key -> binding.get(key) == null)) |
| | | throw new ServiceException("列「电池号」:不属于当前测试或未绑定完整检测柜通道"); |
| | | Map<String, Object> values = new LinkedHashMap<>(); |
| | | for (String field : List.of("test_battery_id", "binding_id", "adapter_code")) values.put(field, binding.get(field)); |
| | | values.put("test_id", action.getTestId()); |
| | | values.put("source_identity", "IMPORT"); |
| | | values.put("source_record_id", String.valueOf(row.get("recordNo"))); |
| | | values.put("source_identity", String.join(":", List.of("adapter_code", "devtype", "devid", "subdevid", "chlid") |
| | | .stream().map(key -> String.valueOf(binding.get(key))).toList())); |
| | | values.put("source_record_id", importText(row, "recordNo", "记录编号", 128)); |
| | | Date time; |
| | | try { |
| | | Object raw = row.get("recordedAt"); |
| | | if (raw == null) raw = row.get("deviceTime"); |
| | | if (!(raw instanceof Number number)) throw new IllegalArgumentException(); |
| | | time = new Date(number.longValue()); |
| | | } catch (RuntimeException ex) { throw new ServiceException("导入设备时间必须为毫秒时间戳"); } |
| | | time = new Date(new BigDecimal(number.toString()).longValueExact()); |
| | | int year = time.toInstant().atZone(ZoneOffset.UTC).getYear(); |
| | | if (year < 1000 || year > 9999) throw new IllegalArgumentException(); |
| | | } catch (RuntimeException ex) { throw new ServiceException("列「设备时间」:必须为有效的整数毫秒时间戳"); } |
| | | String rawTime = row.get("deviceTimeRaw") == null ? time.toInstant().toString() : importText(row, "deviceTimeRaw", "设备时间原文", 64); |
| | | String timezone = row.get("adapterTimezoneId") == null ? "UTC" : String.valueOf(row.get("adapterTimezoneId")); |
| | | if (!Set.of("UTC", "Asia/Hong_Kong").contains(timezone)) throw new ServiceException("列「设备时区」:仅支持 UTC 或 Asia/Hong_Kong"); |
| | | values.put("server_received_at_utc", action.getCreatedAtUtc()); |
| | | values.put("cycle_no", row.get("cycleNo")); |
| | | values.put("cycle_no", importInteger(row, "cycleNo", "循环序号", Integer.MAX_VALUE)); |
| | | Map<String, Object> metrics = new LinkedHashMap<>(); |
| | | columns(action.getDataType()).keySet().forEach(key -> { if (row.containsKey(key)) metrics.put(key, row.get(key)); }); |
| | | if (!metrics.isEmpty()) values.putAll(editableValues(action.getDataType(), metrics)); |
| | | mergeStepExtension(action.getDataType(), null, row, values); |
| | | if (!"CYCLE".equals(action.getDataType())) { values.put("step_no", row.get("stepNo")); values.put("step_name", row.get("stepName")); } |
| | | columns(action.getDataType()).keySet().forEach(key -> metrics.put(key, importMetric(row, key))); |
| | | values.putAll(editableValues(action.getDataType(), metrics)); |
| | | if ("STEP".equals(action.getDataType())) |
| | | { |
| | | Map<String, Object> extension = new TreeMap<>(); |
| | | STEP_EXTENSION_FIELDS.forEach(key -> extension.put(key, importMetric(row, key))); |
| | | mergeStepExtension(action.getDataType(), null, extension, values); |
| | | } |
| | | if (!"CYCLE".equals(action.getDataType())) |
| | | { |
| | | values.put("step_no", importInteger(row, "stepNo", "工步序号", Integer.MAX_VALUE)); |
| | | values.put("step_name", importText(row, "stepName", "工步名称", 191)); |
| | | } |
| | | if ("REALTIME".equals(action.getDataType())) |
| | | { |
| | | values.put("cabinet_sequence_id", row.get("sequenceId")); |
| | | values.put("capture_slot_utc", time); |
| | | values.put("cabinet_sequence_id", importInteger(row, "sequenceId", "记录序号", 9007199254740991L)); |
| | | for (String field : List.of("devtype", "devid", "subdevid", "chlid")) values.put("cabinet_" + field, binding.get(field)); |
| | | // 导入不是轮询采样;同秒可有不同记录,设备时间不能为槽位唯一性而偏移。 |
| | | values.put("capture_slot_utc", null); |
| | | values.put("cabinet_device_time_utc", time); |
| | | values.put("cabinet_device_time_raw", time.toInstant().toString()); |
| | | values.put("cabinet_adapter_timezone_id", "UTC"); |
| | | values.put("cabinet_device_time_raw", rawTime); |
| | | values.put("cabinet_adapter_timezone_id", timezone); |
| | | } |
| | | else |
| | | { |
| | | values.put("dedupe_key", "import:" + UUID.randomUUID()); |
| | | values.put("dedupe_key", "record:" + values.get("source_record_id")); |
| | | values.put("device_time_utc", time); |
| | | values.put("device_time_raw", time.toInstant().toString()); |
| | | values.put("adapter_timezone_id", "UTC"); |
| | | values.put("device_time_raw", rawTime); |
| | | values.put("adapter_timezone_id", timezone); |
| | | } |
| | | try { values.put("content_sha256", MessageDigest.getInstance("SHA-256").digest(JSON.toJSONBytes(row))); } |
| | | catch (Exception ex) { throw new IllegalStateException(ex); } |
| | | return values; |
| | | } |
| | | |
| | | private String importText(Map<String, Object> row, String field, String label, int max) |
| | | { |
| | | String value = row.get(field) == null ? "" : row.get(field).toString().trim(); |
| | | if (value.isEmpty() || value.length() > max) throw new ServiceException("列「" + label + "」:不能为空且长度不能超过" + max); |
| | | return value; |
| | | } |
| | | |
| | | private long importInteger(Map<String, Object> row, String field, String label, long max) |
| | | { |
| | | try { |
| | | long value = new BigDecimal(String.valueOf(row.get(field))).longValueExact(); |
| | | if (value < 1 || value > max) throw new IllegalArgumentException(); |
| | | return value; |
| | | } catch (RuntimeException ex) { throw new ServiceException("列「" + label + "」:必须是范围内的正整数"); } |
| | | } |
| | | |
| | | private BigDecimal importMetric(Map<String, Object> row, String field) |
| | | { |
| | | Object raw = row.get(field); |
| | | if (raw == null) return null; |
| | | try { |
| | | BigDecimal value = new BigDecimal(raw.toString()).stripTrailingZeros(); |
| | | if (Math.max(value.scale(), 0) > 12 || value.precision() - value.scale() > 18) throw new IllegalArgumentException(); |
| | | return value; |
| | | } catch (RuntimeException ex) { throw new ServiceException("列「" + metricLabel(field) + "」:必须是最多12位小数的有效数值或空值"); } |
| | | } |
| | | |
| | | private String metricLabel(String field) |
| | | { |
| | | return switch (field) { |
| | | case "voltage" -> "电压(V)"; case "current" -> "电流(A)"; case "capacity" -> "容量(Ah)"; case "energy" -> "能量(Wh)"; |
| | | case "temperature" -> "温度(℃)"; case "positivePotential" -> "正电位(V)"; case "negativePotential" -> "负电位(V)"; |
| | | case "endVoltage" -> "终止电压(V)"; case "startVoltage" -> "起始电压(V)"; case "stepDuration" -> "工步时间"; |
| | | case "chargePeakVoltage" -> "充电峰值电压(V)"; case "dischargeValleyVoltage" -> "放电谷值电压(V)"; |
| | | case "chargeCapacity" -> "充电容量(Ah)"; case "dischargeCapacity" -> "放电容量(Ah)"; |
| | | case "chargeEnergy" -> "充电能量(Wh)"; case "dischargeEnergy" -> "放电能量(Wh)"; default -> "充放电效率(%)"; |
| | | }; |
| | | } |
| | | |
| | | private String importIdentity(Map<String, Object> row) |
| | | { return JSON.toJSONString(List.of(row.get("test_battery_id"), row.get("source_identity"), String.valueOf(row.get("source_record_id")))); } |
| | | |
| | | private boolean sameImportContent(Map<String, Object> values, Map<String, Object> previous) |
| | | { |
| | | // 比较当前数据库内容,不能用编辑前的内容哈希把已修订记录误判为相同数据。 |
| | | Set<String> metadata = Set.of("server_received_at_utc", "capture_slot_utc", "content_sha256", "dedupe_key", "device_time_raw", |
| | | "adapter_timezone_id", "cabinet_device_time_raw", "cabinet_adapter_timezone_id"); |
| | | for (String key : values.keySet()) |
| | | { |
| | | if (metadata.contains(key)) continue; |
| | | Object left = values.get(key), right = previous.get(key); |
| | | if ("extension_measurements_json".equals(key)) |
| | | { |
| | | Map<String, Object> expected = JSON.parseObject(String.valueOf(left)); |
| | | Map<String, Object> actual = right == null ? Map.of() : JSON.parseObject(right.toString()); |
| | | for (String field : expected.keySet()) if (!sameValue(expected.get(field), actual.get(field))) return false; |
| | | } |
| | | else if (!sameValue(left, right)) return false; |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | private boolean sameValue(Object left, Object right) |
| | | { |
| | | if (left instanceof Number && right instanceof Number) return new BigDecimal(left.toString()).compareTo(new BigDecimal(right.toString())) == 0; |
| | | if (left instanceof Date l && right instanceof Date r) return l.getTime() == r.getTime(); |
| | | return Objects.equals(left, right); |
| | | } |
| | | } |
| | |
| | | |
| | | <select id="selectPersistedFinalRealtimeRecords" resultType="map"> |
| | | select concat('realtime:', r.record_id) id, concat('realtime:', r.record_id) stableIdentity, |
| | | case when r.source_identity='IMPORT' then r.source_record_id else concat('RT-', r.record_id) end recordNo, |
| | | coalesce(r.source_record_id, concat('RT-', r.record_id)) recordNo, |
| | | 'SNAPSHOT' sourceType, b.battery_no batteryNo, |
| | | r.cycle_no cycleNo, r.step_no stepNo, r.step_name stepName, r.step_type stepType, |
| | | r.cabinet_sequence_id sequenceId, r.cabinet_device_time_utc deviceTime, |
| | |
| | | r.collector_channel_code channelCode, r.source_identity sourceIdentity, r.source_record_id sourceRecordId |
| | | from t_test_realtime_record r join t_test_battery b on b.test_battery_id = r.test_battery_id |
| | | where r.test_id = #{testId} and b.del_flag = 0 |
| | | order by r.capture_slot_utc desc, r.record_id desc |
| | | order by coalesce(r.capture_slot_utc, r.cabinet_device_time_utc) desc, r.record_id desc |
| | | </select> |
| | | <select id="selectPersistedFinalStepRecords" resultType="map"> |
| | | select concat('step:', r.step_record_id) id, concat('step:', r.step_record_id) stableIdentity, |
| | |
| | | delete from <include refid="dataTable"/> where test_id=#{testId} and <include refid="dataId"/>=#{recordId} |
| | | </delete> |
| | | <select id="selectImportBinding" resultType="map"> |
| | | select b.test_battery_id, binding.binding_id, cabinet.adapter_code |
| | | select b.test_battery_id, binding.binding_id, cabinet.adapter_code, ch.devtype, ch.devid, ch.subdevid, ch.chlid |
| | | from t_test_battery b join t_test_channel_binding binding on binding.test_battery_id=b.test_battery_id |
| | | join t_detection_channel ch on ch.channel_id=binding.channel_id |
| | | join t_detection_cabinet cabinet on cabinet.cabinet_id=ch.cabinet_id |
| | | where b.test_id=#{testId} and b.battery_no=#{batteryNo} |
| | | </select> |
| | | <!-- 显式按 UTC 日期对象读取,避免 JDBC 驱动将 DATETIME 返回为 LocalDateTime 后破坏幂等比较。 --> |
| | | <resultMap id="ImportDataRecordMap" type="java.util.LinkedHashMap" autoMapping="true"> |
| | | <result property="capture_slot_utc" column="capture_slot_utc" javaType="java.util.Date" jdbcType="TIMESTAMP"/> |
| | | <result property="cabinet_device_time_utc" column="cabinet_device_time_utc" javaType="java.util.Date" jdbcType="TIMESTAMP"/> |
| | | <result property="device_time_utc" column="device_time_utc" javaType="java.util.Date" jdbcType="TIMESTAMP"/> |
| | | </resultMap> |
| | | <select id="selectImportDataRecords" resultMap="ImportDataRecordMap"> |
| | | select * from <include refid="dataTable"/> where test_id=#{testId} |
| | | </select> |
| | | <insert id="insertImportedDataRecord"> |
| | | insert into <include refid="dataTable"/> |
| | | (<foreach collection="values" index="column" item="value" separator=",">${column}</foreach>) |
| | |
| | | action.setOperatorId(getUserId()); action.setOperatorName(getUsername()); |
| | | Long actionId = consoleDataService.submitAction(action, |
| | | request.overrides() == null ? java.util.Collections.emptyList() : request.overrides()); |
| | | return success().put("actionId", actionId); |
| | | return success().put("actionId", actionId).put("importedCount", action.getImportedCount()).put("skippedCount", action.getSkippedCount()); |
| | | } |
| | | |
| | | public record ConsoleDataActionRequest(String dataType, String operationType, String scopeSummary, |
| New file |
| | |
| | | -- 历史实时记录不是轮询槽位;同秒不同记录编号必须保留,详见 ADR-0051。 |
| | | ALTER TABLE t_test_realtime_record |
| | | MODIFY COLUMN capture_slot_utc DATETIME(3) NULL; |
| | |
| | | { |
| | | var action = action("IMPORT", "manual-1"); |
| | | mapper.selectTestByIdForUpdate(1L).setTestStatus("COMPLETED"); |
| | | when(mapper.selectImportBinding(1L, "BAT-1")).thenReturn(Map.of("test_battery_id", 4L, "binding_id", 5L, "adapter_code", "adapter-a")); |
| | | action.setChangesSummary("[{\"recordNo\":\"manual-1\",\"batteryNo\":\"BAT-1\",\"recordedAt\":1770000000123,\"sequenceId\":17,\"voltage\":3.6}]"); |
| | | when(mapper.selectImportBinding(1L, "BAT-1")).thenReturn(Map.of("test_battery_id", 4L, "binding_id", 5L, "adapter_code", "adapter-a", "devtype", 27, "devid", 1, "subdevid", 1, "chlid", 1)); |
| | | action.setChangesSummary("[{\"recordNo\":\"manual-1\",\"batteryNo\":\"BAT-1\",\"recordedAt\":1770000000123,\"cycleNo\":1,\"stepNo\":1,\"stepName\":\"恒流充电\",\"sequenceId\":17,\"voltage\":3.6}]"); |
| | | service.submitAction(action, List.of()); |
| | | var values = org.mockito.ArgumentCaptor.forClass(Map.class); |
| | | verify(mapper).insertImportedDataRecord(eq("REALTIME"), values.capture()); |
| | | assertEquals("manual-1", values.getValue().get("source_record_id")); |
| | | assertEquals(17, values.getValue().get("cabinet_sequence_id")); |
| | | assertEquals("IMPORT", values.getValue().get("source_identity")); |
| | | assertEquals(17L, values.getValue().get("cabinet_sequence_id")); |
| | | assertEquals("adapter-a:27:1:1:1", values.getValue().get("source_identity")); |
| | | } |
| | | |
| | | private TTestDataRevisionAction action(String operation, String identity) |
| New file |
| | |
| | | package com.battery.business.web; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.*; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.UUID; |
| | | import com.alibaba.fastjson2.JSON; |
| | | import com.battery.business.domain.TTestDataRevisionAction; |
| | | import com.battery.business.mapper.TTestMapper; |
| | | import com.battery.business.service.ITestConsoleDataService; |
| | | import com.battery.business.service.impl.TestConsoleDataServiceImpl; |
| | | import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; |
| | | import org.mybatis.spring.SqlSessionFactoryBean; |
| | | import org.mybatis.spring.SqlSessionTemplate; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.AfterEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.aop.framework.ProxyFactory; |
| | | import org.springframework.core.io.ClassPathResource; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.jdbc.datasource.DataSourceTransactionManager; |
| | | import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; |
| | | import org.springframework.transaction.interceptor.TransactionInterceptor; |
| | | import org.springframework.test.util.ReflectionTestUtils; |
| | | |
| | | /** 使用生产 Mapper 和 Spring 事务验证导入;内存数据库不连接共享开发库。 */ |
| | | class ConsoleSingleSheetImportDatabaseTest |
| | | { |
| | | private java.sql.Connection keepAlive; |
| | | private TTestController controller; |
| | | private TTestMapper mapper; |
| | | private ITestConsoleDataService service; |
| | | |
| | | @BeforeEach void setUp() throws Exception |
| | | { |
| | | var source = new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", ""); |
| | | keepAlive = source.getConnection(); |
| | | var jdbc = new JdbcTemplate(source); |
| | | jdbc.execute("create table sys_user(user_id bigint, nick_name varchar(64), del_flag varchar(1))"); |
| | | jdbc.execute("create table t_test_type(test_type_id bigint, test_type_name varchar(64), del_flag int)"); |
| | | jdbc.execute("create table t_test(test_id bigint primary key, test_name varchar(100), creator_id bigint, principal_id bigint, test_type_id bigint, test_purpose varchar(100), test_status varchar(20), reception_status varchar(20), monitor_timeout_minutes int, started_at_utc timestamp, reception_boundary_at_utc timestamp, ended_at_utc timestamp, del_flag int default 0, create_by varchar(64), create_time timestamp, update_by varchar(64), update_time timestamp, remark varchar(100), logic_step_mappings_json varchar(100), console_detail_json varchar(100))"); |
| | | jdbc.execute("insert into t_test(test_id,test_name,test_status,reception_status) values(1,'导入测试','COMPLETED','ENDED')"); |
| | | jdbc.execute("create table t_test_battery(test_battery_id bigint primary key, test_id bigint, battery_no varchar(100), del_flag int default 0)"); |
| | | jdbc.execute("insert into t_test_battery values(4,1,'BAT-1',0)"); |
| | | jdbc.execute("create table t_test_channel_binding(binding_id bigint, test_battery_id bigint, channel_id bigint)"); |
| | | jdbc.execute("insert into t_test_channel_binding values(5,4,11)"); |
| | | jdbc.execute("create table t_detection_channel(channel_id bigint, cabinet_id bigint, devtype int, devid int, subdevid int, chlid int)"); |
| | | jdbc.execute("insert into t_detection_channel values(11,1,27,1,1,2)"); |
| | | jdbc.execute("create table t_detection_cabinet(cabinet_id bigint, adapter_code varchar(64))"); |
| | | jdbc.execute("insert into t_detection_cabinet values(1,'adapter-a')"); |
| | | jdbc.execute("create table t_test_data_revision_action(action_id bigint auto_increment primary key, test_id bigint, data_type varchar(20), operation_type varchar(20), scope_summary varchar(200), identity_summary json, before_content_json json, changes_summary json, reason varchar(200), operator_id bigint, operator_name varchar(64), created_at_utc timestamp)"); |
| | | String common = "test_id bigint not null, test_battery_id bigint not null, binding_id bigint not null, adapter_code varchar(64) not null, source_identity varchar(256) not null, source_record_id varchar(128), server_received_at_utc timestamp(3), cycle_no int, content_sha256 binary(32) not null, extension_measurements_json varchar(2000)"; |
| | | String metrics = "step_no int, step_name varchar(191), step_type varchar(64), voltage_v decimal(30,12), current_a decimal(30,12), capacity_ah decimal(30,12), energy_wh decimal(30,12) check (energy_wh <> 999)"; |
| | | jdbc.execute("create table t_test_realtime_record(record_id bigint auto_increment primary key," + common + "," + metrics + ", cabinet_devtype int, cabinet_devid int, cabinet_subdevid int, cabinet_chlid int, cabinet_sequence_id bigint, cabinet_device_time_utc timestamp(3), cabinet_device_time_raw varchar(64), cabinet_adapter_timezone_id varchar(64), capture_slot_utc timestamp(3) not null, temperature decimal(30,12), positive_potential decimal(30,12), negative_potential decimal(30,12), collector_id bigint, collector_channel_code varchar(100), collector_device_time_utc timestamp(3), unique(test_id,test_battery_id,capture_slot_utc))"); |
| | | // 从 V7 的非空槽位起点执行生产迁移,覆盖存量库升级路径。 |
| | | org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(keepAlive, |
| | | new ClassPathResource("db/migration-final/V8__import_records_without_polling_slots.sql")); |
| | | String deviceTime = "dedupe_key varchar(512) not null, device_time_utc timestamp(3) not null, device_time_raw varchar(64) not null, adapter_timezone_id varchar(64) not null, unique(test_battery_id,source_identity,dedupe_key)"; |
| | | jdbc.execute("create table t_test_step_record(step_record_id bigint auto_increment primary key," + common + "," + metrics + "," + deviceTime + ")"); |
| | | jdbc.execute("create table t_test_cycle_record(cycle_record_id bigint auto_increment primary key," + common + ",charge_capacity_ah decimal(30,12),discharge_capacity_ah decimal(30,12),charge_energy_wh decimal(30,12),discharge_energy_wh decimal(30,12),efficiency decimal(30,12)," + deviceTime + ")"); |
| | | var factory = new SqlSessionFactoryBean(); factory.setDataSource(source); |
| | | factory.setTypeAliasesPackage("com.battery.business.domain"); |
| | | factory.setMapperLocations(new ClassPathResource("mapper/battery/TTestMapper.xml")); |
| | | mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TTestMapper.class); |
| | | var proxy = new ProxyFactory(new TestConsoleDataServiceImpl(mapper)); |
| | | proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); |
| | | service = (ITestConsoleDataService) proxy.getProxy(); |
| | | controller = new TTestController() { |
| | | @Override public Long getUserId() { return 1L; } |
| | | @Override public String getUsername() { return "tester"; } |
| | | }; |
| | | ReflectionTestUtils.setField(controller, "consoleDataService", service); |
| | | } |
| | | |
| | | @AfterEach void closeDatabase() throws Exception { if (keepAlive != null) keepAlive.close(); } |
| | | |
| | | @Test void threeTypesPersistActualBindingsTimesAndNumbersAndSkipRetries() |
| | | { |
| | | for (String type : List.of("REALTIME", "STEP", "CYCLE")) |
| | | { |
| | | assertEquals(1, submit(type, List.of(row())).get("importedCount")); |
| | | assertEquals(1, submit(type, List.of(row())).get("skippedCount")); |
| | | var records = mapper.selectImportDataRecords(1L, type); |
| | | assertEquals(1, records.size()); |
| | | assertEquals("17", records.get(0).get("source_record_id")); |
| | | assertEquals("adapter-a:27:1:1:2", records.get(0).get("source_identity")); |
| | | String timeColumn = type.equals("REALTIME") ? "cabinet_device_time_raw" : "device_time_raw"; |
| | | assertEquals("2023-05-08 11:42:11", records.get(0).get(timeColumn)); |
| | | String storedTimeColumn = type.equals("REALTIME") ? "cabinet_device_time_utc" : "device_time_utc"; |
| | | assertEquals(1683517331000L, ((java.util.Date) records.get(0).get(storedTimeColumn)).getTime()); |
| | | } |
| | | assertEquals("17", mapper.selectPersistedFinalRealtimeRecords(1L).get(0).get("recordno")); |
| | | assertEquals(3, service.selectActions(new TTestDataRevisionAction()).size()); |
| | | } |
| | | |
| | | @Test void databaseFailureRollsBackEarlierRowsAndTheRevision() |
| | | { |
| | | var failing = row(); failing.put("recordNo", "18"); failing.put("recordedAt", 1683517391000L); failing.put("energy", 999); |
| | | assertThrows(RuntimeException.class, () -> submit("REALTIME", List.of(row(), failing))); |
| | | assertTrue(mapper.selectImportDataRecords(1L, "REALTIME").isEmpty()); |
| | | assertTrue(service.selectActions(new TTestDataRevisionAction()).isEmpty()); |
| | | } |
| | | |
| | | @Test void differentRecordsInTheSameSecondPreserveDeviceTime() |
| | | { |
| | | var next = row(); next.put("recordNo", "18"); next.put("sequenceId", 18); next.put("stepNo", 2); |
| | | assertEquals(2, submit("REALTIME", List.of(row(), next)).get("importedCount")); |
| | | assertEquals(2, submit("REALTIME", List.of(row(), next)).get("skippedCount")); |
| | | var records = mapper.selectImportDataRecords(1L, "REALTIME"); |
| | | assertEquals(2, records.size()); |
| | | for (var record : records) { |
| | | assertNull(record.get("capture_slot_utc")); |
| | | assertEquals(1683517331000L, ((java.util.Date) record.get("cabinet_device_time_utc")).getTime()); |
| | | } |
| | | } |
| | | |
| | | @Test |
| | | @org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named = "TICKET03_SAMPLE_JSON", matches = ".+") |
| | | void suppliedPartyWorkbookImportsAllRowsAndSkipsEveryRetry() throws Exception |
| | | { |
| | | var sample = JSON.parseObject(java.nio.file.Files.readString(java.nio.file.Path.of(System.getenv("TICKET03_SAMPLE_JSON")))); |
| | | for (String type : List.of("REALTIME", "STEP", "CYCLE")) |
| | | { |
| | | List<Map<String, Object>> rows = (List) sample.getJSONArray(type).toJavaList(Map.class); |
| | | int count = switch (type) { case "REALTIME" -> 7605; case "STEP" -> 129; default -> 33; }; |
| | | assertEquals(count, submit(type, rows).get("importedCount")); |
| | | assertEquals(count, mapper.selectImportDataRecords(1L, type).size()); |
| | | assertEquals(count, submit(type, rows).get("skippedCount")); |
| | | } |
| | | assertEquals(3, service.selectActions(new TTestDataRevisionAction()).size()); |
| | | } |
| | | |
| | | private Map<String, Object> row() |
| | | { |
| | | var row = new LinkedHashMap<String, Object>(Map.of("recordNo", "17", "batteryNo", "BAT-1", "recordedAt", 1683517331000L, |
| | | "cycleNo", 1, "stepNo", 1, "stepName", "恒流放电", "sequenceId", 17, "capacity", 8.5122)); |
| | | row.put("deviceTimeRaw", "2023-05-08 11:42:11"); row.put("adapterTimezoneId", "Asia/Hong_Kong"); row.put("stepDuration", 3752); |
| | | return row; |
| | | } |
| | | |
| | | private com.ruoyi.common.core.domain.AjaxResult submit(String type, List<Map<String, Object>> rows) |
| | | { |
| | | return controller.dataAction(1L, new TTestController.ConsoleDataActionRequest(type, "IMPORT", "单页导入", "[]", |
| | | JSON.toJSONString(rows), "开发测试", List.of())); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.business.web; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.*; |
| | | import static org.mockito.Mockito.*; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import com.alibaba.fastjson2.JSON; |
| | | import com.battery.business.domain.TTest; |
| | | import com.battery.business.mapper.TTestMapper; |
| | | import com.battery.business.service.impl.TestConsoleDataServiceImpl; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import com.ruoyi.common.exception.ServiceException; |
| | | import org.springframework.test.util.ReflectionTestUtils; |
| | | |
| | | /** 从控制台提交入口验证单页导入,数据库使用边界替身。 */ |
| | | class ConsoleSingleSheetImportTest |
| | | { |
| | | private TTestMapper mapper; |
| | | private TTestController controller; |
| | | private final List<Map<String, Object>> saved = new ArrayList<>(); |
| | | |
| | | @BeforeEach void setUp() |
| | | { |
| | | mapper = mock(TTestMapper.class); |
| | | TTest test = new TTest(); test.setTestStatus("COMPLETED"); |
| | | when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test); |
| | | when(mapper.selectImportBinding(1L, "BAT-1")).thenReturn(Map.of( |
| | | "test_battery_id", 4L, "binding_id", 5L, "adapter_code", "adapter-a", |
| | | "devtype", 27, "devid", 1, "subdevid", 1, "chlid", 1)); |
| | | when(mapper.selectImportBinding(1L, "BAT-2")).thenReturn(Map.of( |
| | | "test_battery_id", 6L, "binding_id", 7L, "adapter_code", "adapter-a", |
| | | "devtype", 27, "devid", 1, "subdevid", 1, "chlid", 2)); |
| | | when(mapper.selectImportDataRecords(eq(1L), anyString())).thenAnswer(call -> new ArrayList<>(saved)); |
| | | when(mapper.insertImportedDataRecord(anyString(), anyMap())).thenAnswer(call -> { |
| | | saved.add(new LinkedHashMap<>(call.getArgument(1))); return 1; |
| | | }); |
| | | controller = new TTestController() { |
| | | @Override public Long getUserId() { return 1L; } |
| | | @Override public String getUsername() { return "tester"; } |
| | | }; |
| | | ReflectionTestUtils.setField(controller, "consoleDataService", new TestConsoleDataServiceImpl(mapper)); |
| | | } |
| | | |
| | | @Test void identicalRowsInOneSheetAreSkipped() |
| | | { |
| | | var response = submit("REALTIME", List.of(row(), row())); |
| | | assertEquals(1, response.get("importedCount")); |
| | | assertEquals(1, response.get("skippedCount")); |
| | | } |
| | | |
| | | @Test void allThreeTypesSkipRetryButRejectChangedContent() |
| | | { |
| | | for (String type : List.of("REALTIME", "STEP", "CYCLE")) |
| | | { |
| | | saved.clear(); |
| | | var row = row(); |
| | | row.put("deviceTimeRaw", "2023-05-08 11:42:11"); row.put("adapterTimezoneId", "Asia/Hong_Kong"); |
| | | row.put("capacity", 8.5122); row.put("dischargeCapacity", 8.5122); |
| | | assertEquals(1, submit(type, List.of(row)).get("importedCount")); |
| | | row.put("sourceRow", 19); |
| | | row.put("capacity", new java.math.BigDecimal("8.512200")); |
| | | var repeated = submit(type, List.of(row)); |
| | | assertEquals(0, repeated.get("importedCount")); |
| | | assertEquals(1, repeated.get("skippedCount")); |
| | | assertNull(repeated.get("actionId")); |
| | | row.put("cycleNo", 2); |
| | | var failure = assertThrows(ServiceException.class, () -> submit(type, List.of(row))); |
| | | assertTrue(failure.getMessage().contains("第19行,列「记录编号」")); |
| | | assertEquals(1, saved.size()); |
| | | } |
| | | } |
| | | |
| | | @Test void sameRecordNumberForDifferentBatteriesHasIndependentIdentity() |
| | | { |
| | | var other = row(); other.put("batteryNo", "BAT-2"); |
| | | assertEquals(2, submit("REALTIME", List.of(row(), other)).get("importedCount")); |
| | | assertEquals("adapter-a:27:1:1:1", saved.get(0).get("source_identity")); |
| | | assertEquals("adapter-a:27:1:1:2", saved.get(1).get("source_identity")); |
| | | } |
| | | |
| | | @Test void pollingSlotIsNotPartOfImportedDeviceContent() |
| | | { |
| | | submit("REALTIME", List.of(row())); |
| | | saved.get(0).put("capture_slot_utc", new java.util.Date(1683517380000L)); |
| | | assertEquals(1, submit("REALTIME", List.of(row())).get("skippedCount")); |
| | | } |
| | | |
| | | @Test void anyInvalidRowLeavesWholeSheetAndRevisionsUnwritten() |
| | | { |
| | | for (Map.Entry<String, Object> invalid : Map.<String, Object>of("cycleNo", 0, "recordedAt", "2023-05-08", |
| | | "voltage", "NaN", "batteryNo", "OTHER").entrySet()) |
| | | { |
| | | var bad = row(); bad.put(invalid.getKey(), invalid.getValue()); bad.put("sourceRow", 23); |
| | | var failure = assertThrows(ServiceException.class, () -> submit("REALTIME", List.of(row(), bad))); |
| | | assertTrue(failure.getMessage().contains("第23行,列「")); |
| | | assertTrue(saved.isEmpty()); |
| | | } |
| | | verify(mapper, never()).insertDataRevisionAction(any()); |
| | | } |
| | | |
| | | @Test void conflictAfterValidRowDoesNotWriteValidPrefixOrAudit() |
| | | { |
| | | submit("REALTIME", List.of(row())); |
| | | clearInvocations(mapper); |
| | | var next = row(); next.put("recordNo", "18"); next.put("recordedAt", 1683517391000L); |
| | | var conflict = row(); conflict.put("voltage", 9); |
| | | assertThrows(ServiceException.class, () -> submit("REALTIME", List.of(next, conflict))); |
| | | assertEquals(1, saved.size()); |
| | | verify(mapper, never()).insertDataRevisionAction(any()); |
| | | var sameSecond = row(); sameSecond.put("recordNo", "19"); sameSecond.put("sequenceId", 19); |
| | | assertEquals(1, submit("REALTIME", List.of(sameSecond)).get("importedCount")); |
| | | } |
| | | |
| | | private Map<String, Object> row() |
| | | { |
| | | return new LinkedHashMap<>(Map.of("recordNo", "17", "batteryNo", "BAT-1", "recordedAt", 1683517331000L, |
| | | "cycleNo", 1, "stepNo", 1, "stepName", "恒流放电", "sequenceId", 17, "voltage", 1.3349)); |
| | | } |
| | | |
| | | private com.ruoyi.common.core.domain.AjaxResult submit(String type, List<Map<String, Object>> rows) |
| | | { |
| | | return controller.dataAction(1L, new TTestController.ConsoleDataActionRequest(type, "IMPORT", |
| | | "单页导入", "[]", JSON.toJSONString(rows), "开发测试", List.of())); |
| | | } |
| | | } |