| | |
| | | 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; |
| | |
| | | return mapper.selectDataRevisionActions(query); |
| | | } |
| | | |
| | | /** 查询修订动作详情及修改前内容。 */ |
| | | /** 查询修订摘要及数量,仅编辑附带展示所需明细,完整修改前快照不进入响应。 */ |
| | | @Override public TTestDataRevisionAction selectActionById(Long actionId) |
| | | { return mapper.selectDataRevisionActionById(actionId); } |
| | | |
| | | /** 一次提交与接收、测试整体删除互斥;任一目标无效时整次动作回滚。 */ |
| | | /** |
| | | * 一次提交与接收、测试整体删除互斥;任一目标无效时整次动作回滚。导入仅允许检测完成的测试。 |
| | | * @return 首条新增修订编号,全部编号写入 action;导入全部重复时返回 null,不新增修订。 |
| | | */ |
| | | @Override @Transactional(rollbackFor = Exception.class) |
| | | public Long submitAction(TTestDataRevisionAction action, List<DataRevisionItem> items) |
| | | { |
| | | if (action == null || action.getTestId() == null) throw new ServiceException("测试ID不能为空"); |
| | | if (action.getDataType() == null || !Set.of("REALTIME", "STEP", "CYCLE").contains(action.getDataType())) throw new ServiceException("数据类型无效"); |
| | | if (action.getDataType() == null || !Set.of("REALTIME", "STEP", "CYCLE", "ALL").contains(action.getDataType())) throw new ServiceException("数据类型无效"); |
| | | if (action.getOperationType() == null || !Set.of("EDIT", "IMPORT", "DELETE").contains(action.getOperationType())) throw new ServiceException("操作类型无效"); |
| | | if ("ALL".equals(action.getDataType()) && !"IMPORT".equals(action.getOperationType())) throw new ServiceException("三 Sheet 仅支持导入"); |
| | | if (action.getReason() == null || action.getReason().isBlank()) throw new ServiceException("数据操作原因不能为空"); |
| | | if (action.getScopeSummary() == null || action.getScopeSummary().isBlank()) throw new ServiceException("数据操作范围不能为空"); |
| | | TTest test = mapper.selectTestByIdForUpdate(action.getTestId()); |
| | | if (test == null) throw new ServiceException("测试不存在"); |
| | | action.setCreatedAtUtc(new Date()); |
| | | action.setReason(action.getReason().trim()); |
| | | action.setActionIds(List.of()); |
| | | List<Map<String, Object>> before = new ArrayList<>(); |
| | | List<Map<String, Object>> changes = new ArrayList<>(); |
| | | List<Runnable> mutations = new ArrayList<>(); |
| | | if ("IMPORT".equals(action.getOperationType())) |
| | | { |
| | | if (!"COMPLETED".equals(test.getTestStatus())) throw new ServiceException("测试完成后才可以导入数据"); |
| | | List<Map> rows; |
| | | 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) |
| | | boolean workbook = "ALL".equals(action.getDataType()); |
| | | Map<String, List<Map<String, Object>>> sheets = importSheets(action, workbook); |
| | | List<String> errors = new ArrayList<>(); |
| | | List<TTestDataRevisionAction> revisions = new ArrayList<>(); |
| | | Map<String, Map<String, Object>> bindings = new LinkedHashMap<>(); |
| | | Set<String> expectedBatteries = null; |
| | | action.setImportedCount(0); action.setSkippedCount(0); |
| | | for (var sheet : sheets.entrySet()) |
| | | { |
| | | 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); |
| | | mutations.add(() -> mapper.insertImportedDataRecord(action.getDataType(), values)); |
| | | changes.add(new LinkedHashMap<>(row)); |
| | | List<String> addedIdentities = new ArrayList<>(); |
| | | List<Map<String, Object>> sheetChanges = new ArrayList<>(); |
| | | Set<String> batteries = prepareImport(action, sheet.getKey(), sheet.getValue(), workbook, bindings, |
| | | errors, addedIdentities, sheetChanges, mutations); |
| | | if (workbook && expectedBatteries != null && !expectedBatteries.equals(batteries)) |
| | | errors.add(sheetLabel(sheet.getKey()) + "工作表,列「电池号」:三个 Sheet 的电池号集合不一致"); |
| | | expectedBatteries = batteries; |
| | | var revision = new TTestDataRevisionAction(); |
| | | revision.setTestId(action.getTestId()); revision.setDataType(sheet.getKey()); revision.setOperationType("IMPORT"); |
| | | revision.setScopeSummary("导入" + sheetChanges.size() + "条" + sheetLabel(sheet.getKey()) + "数据"); |
| | | revision.setIdentitySummary(JSON.toJSONString(addedIdentities)); |
| | | revision.setChangesSummary(JSON.toJSONString(sheetChanges)); revision.setBeforeContentJson("[]"); |
| | | revision.setOperatorId(action.getOperatorId()); revision.setOperatorName(action.getOperatorName()); |
| | | revision.setReason(action.getReason()); revision.setCreatedAtUtc(action.getCreatedAtUtc()); |
| | | revisions.add(revision); |
| | | action.setImportedCount(action.getImportedCount() + sheetChanges.size()); |
| | | } |
| | | action.setIdentitySummary(JSON.toJSONString(identities)); |
| | | // ALL 仅为请求调度标记;三类全量校验后按类型写修订,共享测试行锁和事务。 |
| | | // 整批全重复不留修订;部分重复仍分别记录各类实际新增数量,失败全部回滚。 |
| | | if (!errors.isEmpty()) throw new ServiceException(String.join(";", errors)); |
| | | if (action.getImportedCount() == 0) return null; |
| | | revisions.forEach(mapper::insertDataRevisionAction); |
| | | mutations.forEach(Runnable::run); |
| | | action.setActionIds(revisions.stream().map(TTestDataRevisionAction::getActionId).toList()); |
| | | action.setActionId(action.getActionIds().get(0)); |
| | | return action.getActionId(); |
| | | } |
| | | else |
| | | { |
| | |
| | | action.setChangesSummary(JSON.toJSONString("DELETE".equals(action.getOperationType()) ? before : changes)); |
| | | mapper.insertDataRevisionAction(action); |
| | | mutations.forEach(Runnable::run); |
| | | action.setActionIds(List.of(action.getActionId())); |
| | | return action.getActionId(); |
| | | } |
| | | |
| | | private Map<String, List<Map<String, Object>>> importSheets(TTestDataRevisionAction action, boolean workbook) |
| | | { |
| | | Map<String, List<Map<String, Object>>> sheets = new LinkedHashMap<>(); |
| | | try |
| | | { |
| | | Object parsed = JSON.parse(action.getChangesSummary()); |
| | | if (workbook && (!(parsed instanceof Map<?, ?> map) || !map.keySet().equals(Set.of("REALTIME", "STEP", "CYCLE")))) |
| | | throw new ServiceException("三 Sheet 必须包含实时、工步、循环工作表"); |
| | | for (String type : workbook ? List.of("REALTIME", "STEP", "CYCLE") : List.of(action.getDataType())) |
| | | { |
| | | Object value = workbook ? ((Map<?, ?>) parsed).get(type) : parsed; |
| | | if (!(value instanceof List<?> rows) || rows.isEmpty()) throw new ServiceException(sheetLabel(type) + "工作表:导入数据不能为空"); |
| | | List<Map<String, Object>> records = new ArrayList<>(); |
| | | for (Object row : rows) |
| | | { |
| | | if (row != null && !(row instanceof Map)) throw new ServiceException(sheetLabel(type) + "工作表:数据行格式无效"); |
| | | records.add((Map<String, Object>) row); |
| | | } |
| | | sheets.put(type, records); |
| | | } |
| | | } |
| | | catch (ServiceException ex) { throw ex; } |
| | | catch (RuntimeException ex) { throw new ServiceException("导入数据格式无效"); } |
| | | return sheets; |
| | | } |
| | | |
| | | private Set<String> prepareImport(TTestDataRevisionAction action, String type, List<Map<String, Object>> rows, |
| | | boolean workbook, Map<String, Map<String, Object>> bindings, List<String> errors, |
| | | List<String> addedIdentities, List<Map<String, Object>> changes, List<Runnable> mutations) |
| | | { |
| | | Map<String, Map<String, Object>> identities = new LinkedHashMap<>(); |
| | | for (Map<String, Object> existing : mapper.selectImportDataRecords(action.getTestId(), type)) |
| | | identities.put(importIdentity(existing), existing); |
| | | Set<String> batteries = new HashSet<>(); |
| | | 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); |
| | | batteries.add(batteryNo); |
| | | Map<String, Object> binding = bindings.computeIfAbsent(batteryNo, key -> mapper.selectImportBinding(action.getTestId(), key)); |
| | | Map<String, Object> values = importedRecord(action, type, 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(type, values)); |
| | | Map<String, Object> change = new LinkedHashMap<>(row); |
| | | changes.add(change); |
| | | } |
| | | catch (ServiceException ex) { errors.add((workbook ? sheetLabel(type) + "工作表," : "") + "第" + sourceRow + "行," + ex.getMessage()); } |
| | | } |
| | | return batteries; |
| | | } |
| | | |
| | | private String sheetLabel(String type) |
| | | { return switch (type) { case "REALTIME" -> "实时"; case "STEP" -> "工步"; default -> "循环"; }; } |
| | | |
| | | private long recordId(String type, String identity) |
| | | { |
| | |
| | | return values; |
| | | } |
| | | |
| | | private static final Set<String> STEP_EXTENSION_FIELDS = Set.of("stepDuration", "startVoltage", "chargePeakVoltage", "dischargeValleyVoltage"); |
| | | private static final Set<String> STEP_EXTENSION_FIELDS = Set.of("stepDuration", "startVoltage", "chargeMedianVoltage", "dischargeMedianVoltage"); |
| | | |
| | | private void mergeStepExtension(String type, Object previous, Map<String, Object> fields, Map<String, Object> values) |
| | | { |
| | |
| | | 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, String type, 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")); } |
| | | if ("REALTIME".equals(action.getDataType())) |
| | | columns(type).keySet().forEach(key -> metrics.put(key, importMetric(row, key))); |
| | | values.putAll(editableValues(type, metrics)); |
| | | if ("STEP".equals(type)) |
| | | { |
| | | values.put("cabinet_sequence_id", row.get("sequenceId")); |
| | | values.put("capture_slot_utc", time); |
| | | Map<String, Object> extension = new TreeMap<>(); |
| | | STEP_EXTENSION_FIELDS.forEach(key -> extension.put(key, importMetric(row, key))); |
| | | mergeStepExtension(type, null, extension, values); |
| | | } |
| | | if (!"CYCLE".equals(type)) |
| | | { |
| | | values.put("step_no", importInteger(row, "stepNo", "工步序号", Integer.MAX_VALUE)); |
| | | values.put("step_name", importText(row, "stepName", "工步名称", 191)); |
| | | } |
| | | if ("REALTIME".equals(type)) |
| | | { |
| | | // 相对时长是来源事实,独立于绝对设备时间;旧记录缺失不补算。 |
| | | BigDecimal relativeTime = importMetric(row, "relativeTimeSeconds"); |
| | | if (relativeTime != null && relativeTime.signum() < 0) throw new ServiceException("列「记录时间(h:min:s.ms)」:不能为负数"); |
| | | Map<String, Object> extension = new LinkedHashMap<>(); |
| | | extension.put("relativeTimeSeconds", relativeTime); |
| | | values.put("extension_measurements_json", JSON.toJSONString(extension)); |
| | | 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 "relativeTimeSeconds" -> "记录时间(h:min:s.ms)"; |
| | | 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 "chargeMedianVoltage" -> "充电中值电压(V)"; case "dischargeMedianVoltage" -> "放电中值电压(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()); |
| | | // 已有额外非空测量也属于内容差异;缺失与空值仍等价。 |
| | | Set<String> fields = new HashSet<>(expected.keySet()); fields.addAll(actual.keySet()); |
| | | for (String field : fields) 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); |
| | | } |
| | | } |