yeg
2026-09-09 a011865a0989b51d5818f2791d04790b6408acd1
battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java
@@ -38,75 +38,69 @@
        return mapper.selectDataRevisionActions(query);
    }
    /** 查询修订动作详情及修改前内容。 */
    /** 查询修订摘要及数量,仅编辑附带展示所需明细,完整修改前快照不进入响应。 */
    @Override public TTestDataRevisionAction selectActionById(Long actionId)
    { return mapper.selectDataRevisionActionById(actionId); }
    /**
     * 一次提交与接收、测试整体删除互斥;任一目标无效时整次动作回滚。导入仅允许检测完成的测试。
     * @return 新增修订编号;导入全部重复时返回 null,不新增修订,跳过数量写入 action。
     * @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("导入数据不能为空");
            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()))
            {
                identities.put(importIdentity(existing), existing);
            }
            boolean workbook = "ALL".equals(action.getDataType());
            Map<String, List<Map<String, Object>>> sheets = importSheets(action, workbook);
            List<String> errors = new ArrayList<>();
            List<String> addedIdentities = 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 (int index = 0; index < rows.size(); index++)
            for (var sheet : sheets.entrySet())
            {
                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()); }
                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());
            }
            // 先完成整页校验,再写入修订与数据;测试行锁同时串行化重复提交和整体删除。
            // ALL 仅为请求调度标记;三类全量校验后按类型写修订,共享测试行锁和事务。
            // 整批全重复不留修订;部分重复仍分别记录各类实际新增数量,失败全部回滚。
            if (!errors.isEmpty()) throw new ServiceException(String.join(";", errors));
            action.setImportedCount(changes.size());
            action.setIdentitySummary(JSON.toJSONString(addedIdentities));
            if (changes.isEmpty()) return null;
            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
        {
@@ -151,8 +145,76 @@
        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)
    {
@@ -191,7 +253,7 @@
        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)
    {
@@ -207,7 +269,7 @@
        values.put("extension_measurements_json", JSON.toJSONString(extension));
    }
    private Map<String, Object> importedRecord(TTestDataRevisionAction action, Map<String, Object> row, Map<String, Object> binding)
    private Map<String, Object> importedRecord(TTestDataRevisionAction action, String type, Map<String, Object> row, Map<String, Object> binding)
    {
        if (binding == null || List.of("test_battery_id", "binding_id", "adapter_code", "devtype", "devid", "subdevid", "chlid")
                .stream().anyMatch(key -> binding.get(key) == null))
@@ -233,21 +295,27 @@
        values.put("server_received_at_utc", action.getCreatedAtUtc());
        values.put("cycle_no", importInteger(row, "cycleNo", "循环序号", Integer.MAX_VALUE));
        Map<String, Object> metrics = new LinkedHashMap<>();
        columns(action.getDataType()).keySet().forEach(key -> metrics.put(key, importMetric(row, key)));
        values.putAll(editableValues(action.getDataType(), metrics));
        if ("STEP".equals(action.getDataType()))
        columns(type).keySet().forEach(key -> metrics.put(key, importMetric(row, key)));
        values.putAll(editableValues(type, metrics));
        if ("STEP".equals(type))
        {
            Map<String, Object> extension = new TreeMap<>();
            STEP_EXTENSION_FIELDS.forEach(key -> extension.put(key, importMetric(row, key)));
            mergeStepExtension(action.getDataType(), null, extension, values);
            mergeStepExtension(type, null, extension, values);
        }
        if (!"CYCLE".equals(action.getDataType()))
        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(action.getDataType()))
        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));
            // 导入不是轮询采样;同秒可有不同记录,设备时间不能为槽位唯一性而偏移。
@@ -298,10 +366,11 @@
    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 "chargePeakVoltage" -> "充电峰值电压(V)"; case "dischargeValleyVoltage" -> "放电谷值电压(V)";
            case "chargeMedianVoltage" -> "充电中值电压(V)"; case "dischargeMedianVoltage" -> "放电中值电压(V)";
            case "chargeCapacity" -> "充电容量(Ah)"; case "dischargeCapacity" -> "放电容量(Ah)";
            case "chargeEnergy" -> "充电能量(Wh)"; case "dischargeEnergy" -> "放电能量(Wh)"; default -> "充放电效率(%)";
        };
@@ -323,7 +392,9 @@
            {
                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;
                // 已有额外非空测量也属于内容差异;缺失与空值仍等价。
                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;
        }