From 3d781111a5289987fdda13ce45bcfcabad207644 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期二, 01 九月 2026 00:48:33 +0800
Subject: [PATCH] 实现控制台数据修订导入删除

---
 ruoyi-admin/src/test/java/com/ruoyi/config/Ticket25MigrationContractTest.java            |   30 +++
 ruoyi-admin/src/main/resources/db/migration/V24__console_data_revision_actions.sql       |   37 ++++
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java           |   36 ++++
 ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml                            |   53 +++++
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestConsoleImportRecord.java         |   31 +++
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataRevisionAction.java          |   44 ++++
 ruoyi-admin/src/main/resources/db/migration/V25__console_import_records.sql              |   16 +
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java                      |   15 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java |  204 ++++++++++++++++++++++
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataOverride.java                |   35 +++
 ruoyi-system/src/main/java/com/ruoyi/system/service/ITestConsoleDataService.java         |   15 +
 11 files changed, 516 insertions(+), 0 deletions(-)

diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
index 5146d8a..e111730 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
@@ -21,7 +21,10 @@
 import com.ruoyi.system.domain.TTest;
 import com.ruoyi.system.domain.TTestResult;
 import com.ruoyi.system.domain.TTestLogicStepMapping;
+import com.ruoyi.system.domain.TTestDataRevisionAction;
+import com.ruoyi.system.domain.TTestDataOverride;
 import com.ruoyi.system.service.ITTestService;
+import com.ruoyi.system.service.ITestConsoleDataService;
 
 /** 测试管理 Controller。 */
 @RestController
@@ -30,6 +33,9 @@
 {
     @Autowired
     private ITTestService testService;
+
+    @Autowired
+    private ITestConsoleDataService consoleDataService;
 
     @PreAuthorize("@ss.hasAnyPermi('system:test:add,system:test:edit')")
     @GetMapping("/options")
@@ -147,6 +153,36 @@
         return success(testService.selectTestById(testId));
     }
 
+    /** 返回当前测试某类数据的人工覆盖和删除状态,供刷新后恢复展示。 */
+    @PreAuthorize("@ss.hasPermi('system:test:query')")
+    @GetMapping("/{testId}/console-data/{dataType}/overrides")
+    public AjaxResult dataOverrides(@PathVariable Long testId, @PathVariable String dataType)
+    {
+        return success(consoleDataService.selectOverrides(testId, dataType));
+    }
+
+    /** 一次提交编辑、导入或删除;服务端只追加一条修订行为。 */
+    @PreAuthorize("@ss.hasPermi('system:test:edit')")
+    @Log(title = "测试控制台数据修订", businessType = BusinessType.UPDATE)
+    @PostMapping("/{testId}/console-data/actions")
+    public AjaxResult dataAction(@PathVariable Long testId, @RequestBody ConsoleDataActionRequest request)
+    {
+        if (request == null) return error("数据操作不能为空");
+        TTestDataRevisionAction action = new TTestDataRevisionAction();
+        action.setTestId(testId); action.setDataType(request.dataType()); action.setOperationType(request.operationType());
+        action.setScopeSummary(request.scopeSummary()); action.setIdentitySummary(request.identitySummary());
+        action.setChangesSummary(request.changesSummary()); action.setReason(request.reason());
+        action.setOperatorId(getUserId()); action.setOperatorName(getUsername());
+        Long actionId = consoleDataService.submitAction(action,
+                request.overrides() == null ? java.util.Collections.emptyList() : request.overrides(),
+                request.btsSampleIds(), request.collectorSampleIds());
+        return success().put("actionId", actionId);
+    }
+
+    public record ConsoleDataActionRequest(String dataType, String operationType, String scopeSummary,
+            String identitySummary, String changesSummary, String reason, List<TTestDataOverride> overrides,
+            List<Long> btsSampleIds, List<Long> collectorSampleIds) { }
+
     @PreAuthorize("@ss.hasPermi('system:test:remove')")
     @Log(title = "测试管理", businessType = BusinessType.DELETE)
     @DeleteMapping("/{testIds}")
diff --git a/ruoyi-admin/src/main/resources/db/migration/V24__console_data_revision_actions.sql b/ruoyi-admin/src/main/resources/db/migration/V24__console_data_revision_actions.sql
new file mode 100644
index 0000000..190e164
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V24__console_data_revision_actions.sql
@@ -0,0 +1,37 @@
+-- Ticket25:控制台三类数据的一次用户行为修订、覆盖状态和物理删除支持。
+CREATE TABLE t_test_data_revision_action (
+    action_id BIGINT NOT NULL AUTO_INCREMENT,
+    test_id BIGINT NOT NULL,
+    data_type VARCHAR(16) NOT NULL,
+    operation_type VARCHAR(16) NOT NULL,
+    scope_summary VARCHAR(1000) NOT NULL,
+    identity_summary JSON NOT NULL,
+    changes_summary JSON NULL,
+    reason VARCHAR(500) NOT NULL,
+    operator_id BIGINT NULL,
+    operator_name VARCHAR(64) NOT NULL,
+    created_at_utc DATETIME(3) NOT NULL,
+    PRIMARY KEY (action_id),
+    KEY idx_data_revision_test_time (test_id, data_type, created_at_utc),
+    CONSTRAINT fk_data_revision_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
+    CONSTRAINT chk_data_revision_type CHECK (data_type IN ('REALTIME', 'STEP', 'CYCLE')),
+    CONSTRAINT chk_data_revision_operation CHECK (operation_type IN ('EDIT', 'IMPORT', 'DELETE')),
+    CONSTRAINT chk_data_revision_reason CHECK (CHAR_LENGTH(TRIM(reason)) > 0)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测试控制台一次数据修订行为';
+
+CREATE TABLE t_test_data_override (
+    override_id BIGINT NOT NULL AUTO_INCREMENT,
+    test_id BIGINT NOT NULL,
+    data_type VARCHAR(16) NOT NULL,
+    stable_identity VARCHAR(512) NOT NULL,
+    field_values JSON NULL,
+    deleted TINYINT(1) NOT NULL DEFAULT 0,
+    action_id BIGINT NOT NULL,
+    created_at_utc DATETIME(3) NOT NULL,
+    PRIMARY KEY (override_id),
+    UNIQUE KEY uk_data_override_identity (test_id, data_type, stable_identity),
+    KEY idx_data_override_action (action_id),
+    CONSTRAINT fk_data_override_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
+    CONSTRAINT fk_data_override_action FOREIGN KEY (action_id) REFERENCES t_test_data_revision_action (action_id),
+    CONSTRAINT chk_data_override_type CHECK (data_type IN ('REALTIME', 'STEP', 'CYCLE'))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='控制台数据最新人工覆盖或删除状态';
diff --git a/ruoyi-admin/src/main/resources/db/migration/V25__console_import_records.sql b/ruoyi-admin/src/main/resources/db/migration/V25__console_import_records.sql
new file mode 100644
index 0000000..bbe0324
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V25__console_import_records.sql
@@ -0,0 +1,16 @@
+-- Ticket25:导入数据保留独立的后端事实记录,覆盖状态只负责展示修订。
+CREATE TABLE t_test_console_import_record (
+    import_record_id BIGINT NOT NULL AUTO_INCREMENT,
+    test_id BIGINT NOT NULL,
+    data_type VARCHAR(16) NOT NULL,
+    stable_identity VARCHAR(512) NOT NULL,
+    row_values JSON NOT NULL,
+    action_id BIGINT NOT NULL,
+    created_at_utc DATETIME(3) NOT NULL,
+    PRIMARY KEY (import_record_id),
+    UNIQUE KEY uk_console_import_identity (test_id, data_type, stable_identity),
+    KEY idx_console_import_action (action_id),
+    CONSTRAINT fk_console_import_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
+    CONSTRAINT fk_console_import_action FOREIGN KEY (action_id) REFERENCES t_test_data_revision_action (action_id),
+    CONSTRAINT chk_console_import_type CHECK (data_type IN ('REALTIME', 'STEP', 'CYCLE'))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='控制台导入原始记录';
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/Ticket25MigrationContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/Ticket25MigrationContractTest.java
new file mode 100644
index 0000000..db49cc8
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/Ticket25MigrationContractTest.java
@@ -0,0 +1,30 @@
+package com.ruoyi.config;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import org.junit.jupiter.api.Test;
+
+/** Ticket25 迁移契约:一次行为审计、覆盖状态和物理删除所需的持久化结构。 */
+class Ticket25MigrationContractTest
+{
+    @Test
+    void storesSingleActionAndStableIdentityOverrides()
+    {
+        String sql;
+        try (InputStream stream = getClass().getClassLoader().getResourceAsStream(
+                "db/migration/V24__console_data_revision_actions.sql"))
+        {
+            if (stream == null) throw new AssertionError("缺少 Ticket25 迁移");
+            sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+        }
+        catch (Exception exception) { throw new AssertionError(exception); }
+        assertTrue(sql.contains("t_test_data_revision_action"));
+        assertTrue(sql.contains("t_test_data_override"));
+        assertTrue(sql.contains("identity_summary JSON"));
+        assertTrue(sql.contains("operation_type"));
+        assertTrue(sql.contains("uk_data_override_identity"));
+        assertFalse(sql.matches("(?s).*\\bDROP\\s+(TABLE|COLUMN)\\b.*"));
+    }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestConsoleImportRecord.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestConsoleImportRecord.java
new file mode 100644
index 0000000..d7e86b9
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestConsoleImportRecord.java
@@ -0,0 +1,31 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/** 控制台导入后端事实记录。 */
+public class TTestConsoleImportRecord implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+    private Long importRecordId;
+    private Long testId;
+    private String dataType;
+    private String stableIdentity;
+    private String rowValues;
+    private Long actionId;
+    private Date createdAtUtc;
+    public Long getImportRecordId() { return importRecordId; }
+    public void setImportRecordId(Long value) { importRecordId = value; }
+    public Long getTestId() { return testId; }
+    public void setTestId(Long value) { testId = value; }
+    public String getDataType() { return dataType; }
+    public void setDataType(String value) { dataType = value; }
+    public String getStableIdentity() { return stableIdentity; }
+    public void setStableIdentity(String value) { stableIdentity = value; }
+    public String getRowValues() { return rowValues; }
+    public void setRowValues(String value) { rowValues = value; }
+    public Long getActionId() { return actionId; }
+    public void setActionId(Long value) { actionId = value; }
+    public Date getCreatedAtUtc() { return createdAtUtc; }
+    public void setCreatedAtUtc(Date value) { createdAtUtc = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataOverride.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataOverride.java
new file mode 100644
index 0000000..5e18a76
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataOverride.java
@@ -0,0 +1,35 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/** 某个稳定数据身份的最新人工覆盖状态。 */
+public class TTestDataOverride implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+    private Long overrideId;
+    private Long testId;
+    private String dataType;
+    private String stableIdentity;
+    private String fieldValues;
+    private Boolean deleted;
+    private Long actionId;
+    private Date createdAtUtc;
+
+    public Long getOverrideId() { return overrideId; }
+    public void setOverrideId(Long value) { overrideId = value; }
+    public Long getTestId() { return testId; }
+    public void setTestId(Long value) { testId = value; }
+    public String getDataType() { return dataType; }
+    public void setDataType(String value) { dataType = value; }
+    public String getStableIdentity() { return stableIdentity; }
+    public void setStableIdentity(String value) { stableIdentity = value; }
+    public String getFieldValues() { return fieldValues; }
+    public void setFieldValues(String value) { fieldValues = value; }
+    public Boolean getDeleted() { return deleted; }
+    public void setDeleted(Boolean value) { deleted = value; }
+    public Long getActionId() { return actionId; }
+    public void setActionId(Long value) { actionId = value; }
+    public Date getCreatedAtUtc() { return createdAtUtc; }
+    public void setCreatedAtUtc(Date value) { createdAtUtc = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataRevisionAction.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataRevisionAction.java
new file mode 100644
index 0000000..1e5d79f
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataRevisionAction.java
@@ -0,0 +1,44 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/** 一次控制台数据编辑、导入或删除行为。 */
+public class TTestDataRevisionAction implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+    private Long actionId;
+    private Long testId;
+    private String dataType;
+    private String operationType;
+    private String scopeSummary;
+    private String identitySummary;
+    private String changesSummary;
+    private String reason;
+    private Long operatorId;
+    private String operatorName;
+    private Date createdAtUtc;
+
+    public Long getActionId() { return actionId; }
+    public void setActionId(Long value) { actionId = value; }
+    public Long getTestId() { return testId; }
+    public void setTestId(Long value) { testId = value; }
+    public String getDataType() { return dataType; }
+    public void setDataType(String value) { dataType = value; }
+    public String getOperationType() { return operationType; }
+    public void setOperationType(String value) { operationType = value; }
+    public String getScopeSummary() { return scopeSummary; }
+    public void setScopeSummary(String value) { scopeSummary = value; }
+    public String getIdentitySummary() { return identitySummary; }
+    public void setIdentitySummary(String value) { identitySummary = value; }
+    public String getChangesSummary() { return changesSummary; }
+    public void setChangesSummary(String value) { changesSummary = value; }
+    public String getReason() { return reason; }
+    public void setReason(String value) { reason = value; }
+    public Long getOperatorId() { return operatorId; }
+    public void setOperatorId(Long value) { operatorId = value; }
+    public String getOperatorName() { return operatorName; }
+    public void setOperatorName(String value) { operatorName = value; }
+    public Date getCreatedAtUtc() { return createdAtUtc; }
+    public void setCreatedAtUtc(Date value) { createdAtUtc = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
index c4baf6e..33224d9 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
@@ -18,6 +18,9 @@
 import com.ruoyi.system.domain.TTestBackfillCursor;
 import com.ruoyi.system.domain.TTestIntegrityIssue;
 import com.ruoyi.system.domain.TDetectionChannel;
+import com.ruoyi.system.domain.TTestDataRevisionAction;
+import com.ruoyi.system.domain.TTestConsoleImportRecord;
+import com.ruoyi.system.domain.TTestDataOverride;
 import com.ruoyi.system.domain.vo.TestBatteryOptionVo;
 import com.ruoyi.system.domain.vo.TestChannelOptionVo;
 import com.ruoyi.system.domain.vo.TestCollectorOptionVo;
@@ -187,4 +190,16 @@
     int resolveIntegrityIssue(@Param("issueId") Long issueId, @Param("resolvedAtUtc") Date resolvedAtUtc);
     int countOpenIntegrityIssues(Long testId);
     int countOpenExceptions(Long testId);
+
+    int insertDataRevisionAction(TTestDataRevisionAction action);
+    int upsertDataOverride(TTestDataOverride override);
+    List<TTestDataOverride> selectDataOverrides(@Param("testId") Long testId,
+            @Param("dataType") String dataType);
+    int deleteBtsSamplesByIds(@Param("testId") Long testId, @Param("sampleIds") List<Long> sampleIds);
+    int deleteBtsMeasurementsBySampleIds(@Param("sampleIds") List<Long> sampleIds);
+    int deleteCollectorSamplesByIds(@Param("testId") Long testId, @Param("sampleIds") List<Long> sampleIds);
+    Long selectBtsSampleIdByChannelSequence(@Param("testId") Long testId,
+            @Param("channelId") Long channelId, @Param("sequenceId") Long sequenceId);
+    int insertConsoleImportRecord(TTestConsoleImportRecord record);
+    int countTestBatteryNo(@Param("testId") Long testId, @Param("batteryNo") String batteryNo);
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestConsoleDataService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestConsoleDataService.java
new file mode 100644
index 0000000..ef91987
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestConsoleDataService.java
@@ -0,0 +1,15 @@
+package com.ruoyi.system.service;
+
+import java.util.List;
+import com.ruoyi.system.domain.TTestDataOverride;
+import com.ruoyi.system.domain.TTestDataRevisionAction;
+
+/** 控制台三类数据的修订、导入和物理删除服务。 */
+public interface ITestConsoleDataService
+{
+    List<TTestDataOverride> selectOverrides(Long testId, String dataType);
+
+    /** 在一个事务中写入一次行为、覆盖状态并执行请求的物理删除。 */
+    Long submitAction(TTestDataRevisionAction action, List<TTestDataOverride> overrides,
+            List<Long> btsSampleIds, List<Long> collectorSampleIds);
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java
new file mode 100644
index 0000000..d557873
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java
@@ -0,0 +1,204 @@
+package com.ruoyi.system.service.impl;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+import java.util.Map;
+import java.util.ArrayList;
+import java.util.HashSet;
+import com.alibaba.fastjson2.JSON;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.TTestDataOverride;
+import com.ruoyi.system.domain.TTestDataRevisionAction;
+import com.ruoyi.system.domain.TTestConsoleImportRecord;
+import com.ruoyi.system.mapper.TTestMapper;
+import com.ruoyi.system.service.ITestConsoleDataService;
+
+/** 控制台数据操作的边界校验和事务协调。 */
+@Service
+public class TestConsoleDataServiceImpl implements ITestConsoleDataService
+{
+    private static final Set<String> DATA_TYPES = Set.of("REALTIME", "STEP", "CYCLE");
+    private static final Set<String> OPERATIONS = Set.of("EDIT", "IMPORT", "DELETE");
+
+    @Autowired
+    private TTestMapper mapper;
+
+    @Override
+    public List<TTestDataOverride> selectOverrides(Long testId, String dataType)
+    {
+        validateType(testId, dataType);
+        return mapper.selectDataOverrides(testId, dataType);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long submitAction(TTestDataRevisionAction action, List<TTestDataOverride> overrides,
+            List<Long> btsSampleIds, List<Long> collectorSampleIds)
+    {
+        if (action == null || action.getTestId() == null) throw new ServiceException("测试ID不能为空");
+        validateType(action.getTestId(), action.getDataType());
+        if (!OPERATIONS.contains(action.getOperationType())) throw new ServiceException("数据操作类型无效");
+        if ("IMPORT".equals(action.getOperationType()))
+        {
+            com.ruoyi.system.domain.TTest test = mapper.selectTestById(action.getTestId());
+            if (test == null || !"ENDED".equals(test.getReceptionStatus()) || !"COMPLETED".equals(test.getTestStatus()))
+                throw new ServiceException("测试完成后才可以导入数据");
+        }
+        if ("EDIT".equals(action.getOperationType())) validateEditableFields(action);
+        if (action.getReason() == null || action.getReason().trim().isEmpty()) throw new ServiceException("数据操作原因不能为空");
+        if (action.getOperatorName() == null || action.getOperatorName().trim().isEmpty()) action.setOperatorName("未知用户");
+        action.setReason(action.getReason().trim());
+        action.setCreatedAtUtc(new Date());
+        mapper.insertDataRevisionAction(action);
+        if ("IMPORT".equals(action.getOperationType()) && action.getChangesSummary() != null)
+        {
+            try
+            {
+                @SuppressWarnings("unchecked")
+                List<Map<String, Object>> rows = (List<Map<String, Object>>) (List<?>) JSON.parseArray(action.getChangesSummary(), Map.class);
+                Set<String> identities = new HashSet<>();
+                for (Map<String, Object> row : rows)
+                {
+                    Object identity = row.get("recordNo");
+                    if (identity == null || !identities.add(String.valueOf(identity)))
+                        throw new ServiceException("导入数据存在重复稳定身份");
+                    Object batteryNo = row.get("batteryNo");
+                    if (batteryNo != null && !String.valueOf(batteryNo).isBlank()
+                            && mapper.countTestBatteryNo(action.getTestId(), String.valueOf(batteryNo)) == 0)
+                        throw new ServiceException("导入数据包含不属于当前测试的电池号: " + batteryNo);
+                    TTestConsoleImportRecord record = new TTestConsoleImportRecord();
+                    record.setTestId(action.getTestId()); record.setDataType(action.getDataType());
+                    record.setStableIdentity(String.valueOf(identity)); record.setRowValues(JSON.toJSONString(row));
+                    record.setActionId(action.getActionId()); record.setCreatedAtUtc(action.getCreatedAtUtc());
+                    mapper.insertConsoleImportRecord(record);
+                }
+            }
+            catch (ServiceException exception) { throw exception; }
+            catch (Exception exception) { throw new ServiceException("导入数据格式无效"); }
+        }
+        if (overrides != null)
+        {
+            Set<String> identities = new HashSet<>();
+            for (TTestDataOverride item : overrides)
+            {
+                if (item == null || item.getStableIdentity() == null || item.getStableIdentity().trim().isEmpty())
+                    throw new ServiceException("数据稳定身份不能为空");
+                item.setStableIdentity(item.getStableIdentity().trim());
+                if (!identities.add(item.getStableIdentity())) throw new ServiceException("同一批数据存在重复稳定身份");
+                validateOverride(item, action.getOperationType(), action.getDataType());
+                item.setTestId(action.getTestId()); item.setDataType(action.getDataType());
+                item.setActionId(action.getActionId()); item.setCreatedAtUtc(action.getCreatedAtUtc());
+                mapper.upsertDataOverride(item);
+            }
+        }
+        if ("DELETE".equals(action.getOperationType()) && "REALTIME".equals(action.getDataType()) && overrides != null)
+        {
+            List<Long> resolvedIds = new ArrayList<>();
+            for (TTestDataOverride item : overrides)
+            {
+                Long[] identity = parseRealtimeIdentity(item.getStableIdentity());
+                if (identity == null) continue;
+                Long sampleId = mapper.selectBtsSampleIdByChannelSequence(action.getTestId(), identity[0], identity[1]);
+                if (sampleId != null) resolvedIds.add(sampleId);
+            }
+            if (!resolvedIds.isEmpty())
+            {
+                mapper.deleteBtsMeasurementsBySampleIds(resolvedIds);
+                mapper.deleteBtsSamplesByIds(action.getTestId(), resolvedIds);
+            }
+        }
+        if (btsSampleIds != null && !btsSampleIds.isEmpty())
+        {
+            // 采样明细受外键保护,必须在主采样物理删除前清理。
+            mapper.deleteBtsMeasurementsBySampleIds(btsSampleIds);
+            mapper.deleteBtsSamplesByIds(action.getTestId(), btsSampleIds);
+        }
+        if (collectorSampleIds != null && !collectorSampleIds.isEmpty()) mapper.deleteCollectorSamplesByIds(action.getTestId(), collectorSampleIds);
+        return action.getActionId();
+    }
+
+    private void validateType(Long testId, String dataType)
+    {
+        if (testId == null || testId < 1) throw new ServiceException("测试ID无效");
+        if (!DATA_TYPES.contains(dataType)) throw new ServiceException("控制台数据类型无效");
+    }
+
+    private void validateEditableFields(TTestDataRevisionAction action)
+    {
+        if (action.getChangesSummary() == null || action.getChangesSummary().isBlank())
+            throw new ServiceException("修改内容不能为空");
+        try
+        {
+            @SuppressWarnings("unchecked")
+            List<Map<String, Object>> changes = (List<Map<String, Object>>) (List<?>) JSON.parseArray(action.getChangesSummary(), Map.class);
+            Set<String> editable = switch (action.getDataType())
+            {
+                case "REALTIME" -> Set.of("voltage", "current", "capacity", "energy");
+                case "STEP" -> Set.of("stepDuration", "capacity", "energy", "startVoltage", "endVoltage",
+                        "chargePeakVoltage", "dischargeValleyVoltage");
+                case "CYCLE" -> Set.of("chargeCapacity", "dischargeCapacity", "efficiency", "chargeEnergy",
+                        "dischargeEnergy");
+                default -> Set.of();
+            };
+            Set<String> immutable = Set.of("recordNo", "batteryNo", "cycleNo", "stepNo", "stepName",
+                    "sequenceId", "recordedAt", "absoluteTime");
+            for (Map<String, Object> item : changes)
+            {
+                Map<String, Object> before = objectMap(item.get("before"));
+                Map<String, Object> after = objectMap(item.get("after"));
+                for (String key : immutable)
+                    if (!java.util.Objects.equals(before.get(key), after.get(key)))
+                        throw new ServiceException("记录身份字段不可修改: " + key);
+                for (String key : after.keySet())
+                    if (!java.util.Objects.equals(before.get(key), after.get(key)) && !editable.contains(key))
+                        throw new ServiceException("字段不可修改: " + key);
+            }
+        }
+        catch (ServiceException exception) { throw exception; }
+        catch (Exception exception) { throw new ServiceException("修改内容格式无效"); }
+    }
+
+    private void validateOverride(TTestDataOverride item, String operation, String dataType)
+    {
+        if (!"EDIT".equals(operation) || item.getFieldValues() == null || item.getFieldValues().isBlank()) return;
+        try
+        {
+            Map<String, Object> values = JSON.parseObject(item.getFieldValues(), Map.class);
+            for (String key : values.keySet())
+                if (!editableFields(dataType).contains(key)) throw new ServiceException("字段不可修改: " + key);
+        }
+        catch (ServiceException exception) { throw exception; }
+        catch (Exception exception) { throw new ServiceException("修改字段格式无效"); }
+    }
+
+    private Set<String> editableFields(String dataType)
+    {
+        return switch (dataType)
+        {
+            case "REALTIME" -> Set.of("voltage", "current", "capacity", "energy");
+            case "STEP" -> Set.of("stepDuration", "capacity", "energy", "startVoltage", "endVoltage",
+                    "chargePeakVoltage", "dischargeValleyVoltage");
+            case "CYCLE" -> Set.of("chargeCapacity", "dischargeCapacity", "efficiency", "chargeEnergy",
+                    "dischargeEnergy");
+            default -> Set.of();
+        };
+    }
+
+    private Long[] parseRealtimeIdentity(String value)
+    {
+        String[] parts = value.split(":", -1);
+        if (parts.length != 3) return null;
+        try { return new Long[] { Long.valueOf(parts[0]), Long.valueOf(parts[2]) }; }
+        catch (NumberFormatException exception) { return null; }
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> objectMap(Object value)
+    {
+        return value instanceof Map ? (Map<String, Object>) value : Map.of();
+    }
+}
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index 0364b4d..7f26929 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -784,4 +784,57 @@
         where exception_fact.test_id = #{testId} and exception_fact.interval_status = 'OPEN'
         order by exception_fact.opened_at_utc, exception_fact.interval_id
     </select>
+    <resultMap id="TTestDataOverrideResultMap" type="com.ruoyi.system.domain.TTestDataOverride">
+        <id property="overrideId" column="override_id"/><result property="testId" column="test_id"/>
+        <result property="dataType" column="data_type"/><result property="stableIdentity" column="stable_identity"/>
+        <result property="fieldValues" column="field_values"/><result property="deleted" column="deleted"/>
+        <result property="actionId" column="action_id"/><result property="createdAtUtc" column="created_at_utc"/>
+    </resultMap>
+    <insert id="insertDataRevisionAction" parameterType="com.ruoyi.system.domain.TTestDataRevisionAction"
+            useGeneratedKeys="true" keyProperty="actionId">
+        insert into t_test_data_revision_action
+        (test_id, data_type, operation_type, scope_summary, identity_summary, changes_summary,
+         reason, operator_id, operator_name, created_at_utc)
+        values (#{testId}, #{dataType}, #{operationType}, #{scopeSummary}, cast(#{identitySummary} as json),
+                cast(#{changesSummary} as json), #{reason}, #{operatorId}, #{operatorName}, #{createdAtUtc})
+    </insert>
+    <insert id="upsertDataOverride" parameterType="com.ruoyi.system.domain.TTestDataOverride">
+        insert into t_test_data_override
+        (test_id, data_type, stable_identity, field_values, deleted, action_id, created_at_utc)
+        values (#{testId}, #{dataType}, #{stableIdentity}, cast(#{fieldValues} as json), #{deleted}, #{actionId}, #{createdAtUtc})
+        on duplicate key update field_values = values(field_values), deleted = values(deleted),
+            action_id = values(action_id), created_at_utc = values(created_at_utc)
+    </insert>
+    <select id="selectDataOverrides" resultMap="TTestDataOverrideResultMap">
+        select override_id, test_id, data_type, stable_identity, field_values, deleted, action_id, created_at_utc
+        from t_test_data_override where test_id = #{testId} and data_type = #{dataType}
+        order by override_id
+    </select>
+    <delete id="deleteBtsSamplesByIds">
+        delete from t_test_sample where test_id = #{testId} and sample_id in
+        <foreach collection="sampleIds" item="id" open="(" separator="," close=")">#{id}</foreach>
+    </delete>
+    <delete id="deleteBtsMeasurementsBySampleIds">
+        delete from t_test_sample_measurement where sample_id in
+        <foreach collection="sampleIds" item="id" open="(" separator="," close=")">#{id}</foreach>
+    </delete>
+    <delete id="deleteCollectorSamplesByIds">
+        delete from t_test_collector_sample where test_id = #{testId} and sample_id in
+        <foreach collection="sampleIds" item="id" open="(" separator="," close=")">#{id}</foreach>
+    </delete>
+    <select id="selectBtsSampleIdByChannelSequence" resultType="long">
+        select sample_id from t_test_sample
+        where test_id = #{testId} and channel_id = #{channelId} and seq_id = #{sequenceId}
+        limit 1
+    </select>
+    <insert id="insertConsoleImportRecord" parameterType="com.ruoyi.system.domain.TTestConsoleImportRecord"
+            useGeneratedKeys="true" keyProperty="importRecordId">
+        insert into t_test_console_import_record
+        (test_id, data_type, stable_identity, row_values, action_id, created_at_utc)
+        values (#{testId}, #{dataType}, #{stableIdentity}, cast(#{rowValues} as json), #{actionId}, #{createdAtUtc})
+    </insert>
+    <select id="countTestBatteryNo" resultType="int">
+        select count(1) from t_test_battery
+        where test_id = #{testId} and battery_no = #{batteryNo} and del_flag = 0
+    </select>
 </mapper>

--
Gitblit v1.7.1