From 890049eeece6fc94cd974368451cdc36ec89fdf4 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 05 九月 2026 22:28:38 +0800
Subject: [PATCH] 兼容甲方三类数据单页导入并保留同秒记录

---
 battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java |  179 +++++++++++++++++++++++++++++++++++++++++++++++++----------
 1 files changed, 148 insertions(+), 31 deletions(-)

diff --git a/battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java b/battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java
index d1385fe..4a62ff9 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java
@@ -1,9 +1,8 @@
 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;
@@ -11,7 +10,8 @@
 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;
@@ -65,16 +65,45 @@
             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);
-                mutations.add(() -> mapper.insertImportedDataRecord(action.getDataType(), values));
-                changes.add(new LinkedHashMap<>(row));
+                identities.put(importIdentity(existing), existing);
             }
-            action.setIdentitySummary(JSON.toJSONString(identities));
+            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));
+                }
+                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
         {
@@ -175,45 +204,133 @@
         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);
+    }
 }

--
Gitblit v1.7.1