From 551bf2b42dddc3a0d8d0949da17bb2b1ec733b01 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 07 九月 2026 12:39:19 +0800
Subject: [PATCH] 实现化成循环矩阵与独立报表版本

---
 battery-business/src/main/java/com/battery/business/report/FormationFieldTemplateService.java |   73 +++++
 battery-business/src/main/java/com/battery/business/report/CompareReportRepository.java       |   93 ------
 ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml                            |    4 
 ruoyi-admin/src/main/resources/db/migration-final/V15__formation_reports_and_templates.sql    |   18 +
 battery-business/src/main/resources/mapper/battery/TBatteryGroupFieldMapper.xml               |    2 
 battery-business/src/main/java/com/battery/business/report/FormationReportRequest.java        |   41 +++
 battery-business/src/main/java/com/battery/business/report/FormationReportService.java        |  249 +++++++++++++++++++
 ruoyi-admin/src/main/java/com/battery/business/web/FormationFieldTemplateController.java      |   31 ++
 battery-business/src/main/java/com/battery/business/report/FormationField.java                |   36 ++
 battery-business/src/main/java/com/battery/business/report/FormationReportRepository.java     |   11 
 battery-business/src/main/java/com/battery/business/report/ReportVersionRepository.java       |  102 +++++++
 ruoyi-admin/src/main/java/com/battery/business/web/FormationReportController.java             |   69 +++++
 12 files changed, 637 insertions(+), 92 deletions(-)

diff --git a/battery-business/src/main/java/com/battery/business/report/CompareReportRepository.java b/battery-business/src/main/java/com/battery/business/report/CompareReportRepository.java
index dd39495..866c240 100644
--- a/battery-business/src/main/java/com/battery/business/report/CompareReportRepository.java
+++ b/battery-business/src/main/java/com/battery/business/report/CompareReportRepository.java
@@ -1,98 +1,11 @@
 package com.battery.business.report;
 
-import java.sql.Timestamp;
-import java.util.List;
-import java.util.Map;
-import com.alibaba.fastjson2.JSON;
 import javax.sql.DataSource;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.jdbc.support.GeneratedKeyHolder;
 import org.springframework.stereotype.Repository;
-import com.ruoyi.common.exception.ServiceException;
 
-/** 报表身份和版本存储;重读只访问报表表,不联查来源测试。 */
+/** 对比报表身份与版本只使用对比表,复用独立版本持久化规则。 */
 @Repository
-public class CompareReportRepository
+public class CompareReportRepository extends ReportVersionRepository
 {
-    private final JdbcTemplate jdbc;
-
-    public CompareReportRepository(DataSource dataSource) { jdbc = new JdbcTemplate(dataSource); }
-
-    public record Summary(long id, String name, String creator, long createdAt) { }
-    public record Version(Summary report, int versionNo, String snapshotJson, String operatorName, long versionCreatedAt, String changeSummary) { }
-    public record History(int versionNo, String operatorName, long createdAt, String changeSummary) { }
-
-    /** 查询所有已保存报表摘要,允许授权用户跨创建人查看。 */
-    public List<Summary> list()
-    {
-        return jdbc.query("select report_id, report_name, creator_name, created_at_utc from t_compare_report order by created_at_utc desc, report_id desc",
-                (rs, index) -> new Summary(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4).getTime()));
-    }
-
-    /** 重读最新完整版本;来源不存在不影响结果。 */
-    public Version get(long id)
-    { return get(id, null); }
-
-    /** 读取指定历史版,版本号为空时读取最新版本。 */
-    public Version get(long id, Integer versionNo)
-    {
-        var rows = jdbc.query("select r.report_id, r.report_name, r.creator_name, r.created_at_utc, v.version_no, v.snapshot_json, v.operator_name, v.created_at_utc, v.change_summary "
-                + "from t_compare_report r join t_compare_report_version v on v.report_id=r.report_id where r.report_id=? "
-                + (versionNo == null ? "and v.version_no=(select max(v2.version_no) from t_compare_report_version v2 where v2.report_id=r.report_id)" : "and v.version_no=?"),
-                (rs, index) -> new Version(new Summary(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4).getTime()),
-                        rs.getInt(5), rs.getString(6), rs.getString(7), rs.getTimestamp(8).getTime(), rs.getString(9)),
-                versionNo == null ? new Object[] { id } : new Object[] { id, versionNo });
-        if (rows.isEmpty()) throw new ServiceException("对比报表或版本不存在");
-        return rows.get(0);
-    }
-
-    /** 历史摘要只来自正式版本,不返回测量快照。 */
-    public List<History> history(long id)
-    {
-        return jdbc.query("select version_no,operator_name,created_at_utc,change_summary from t_compare_report_version where report_id=? order by version_no desc",
-                (rs, index) -> new History(rs.getInt(1), rs.getString(2), rs.getTimestamp(3).getTime(), rs.getString(4)), id);
-    }
-
-    /** 业务事务内锁定报表,版本号分配与旧基准拒绝共用此锁。 */
-    public void lock(long id)
-    {
-        if (jdbc.queryForList("select report_id from t_compare_report where report_id=? for update", Long.class, id).isEmpty())
-            throw new ServiceException("对比报表不存在");
-    }
-
-    /** 仅在业务事务和报表锁内调用;物理清理全部版本,同步保留最小操作日志。 */
-    public void delete(long id, long operatorId, String operatorName)
-    {
-        String name = jdbc.queryForObject("select report_name from t_compare_report where report_id=?", String.class, id);
-        jdbc.update("delete from t_compare_report_version where report_id=?", id);
-        jdbc.update("delete from t_compare_report where report_id=?", id);
-        // 不走异步 @Log,日志失败必须回滚删除;只保留身份,不读取或记录测量快照。
-        jdbc.update("insert into sys_oper_log(title,business_type,method,request_method,operator_type,oper_name,oper_url,oper_param,status,oper_time) values(?,3,?,'DELETE',1,?,?,?,0,?)",
-                "对比报表删除", "compare-report.delete", operatorName, "/system/compare-report/" + id,
-                JSON.toJSONString(Map.of("reportId", id, "reportName", name, "operatorId", operatorId)), new Timestamp(System.currentTimeMillis()));
-    }
-
-    /** 只追加版本并更新列表名称;业务事务保证两步同时成功。 */
-    public void append(long id, int versionNo, String name, String snapshot, long operatorId, String operatorName, String summary)
-    {
-        jdbc.update("insert into t_compare_report_version(report_id,version_no,snapshot_json,operator_id,operator_name,created_at_utc,change_summary) values(?,?,?,?,?,?,?)",
-                id, versionNo, snapshot, operatorId, operatorName, new Timestamp(System.currentTimeMillis()), summary);
-        jdbc.update("update t_compare_report set report_name=? where report_id=?", name, id);
-    }
-
-    /** 由业务事务调用,身份与首个版本任一写入失败必须一起回滚。 */
-    public long insert(String name, String snapshot, long operatorId, String operatorName, long createdAt)
-    {
-        var keys = new GeneratedKeyHolder();
-        jdbc.update(connection -> {
-            var statement = connection.prepareStatement("insert into t_compare_report(report_name,creator_id,creator_name,created_at_utc) values(?,?,?,?)", java.sql.Statement.RETURN_GENERATED_KEYS);
-            statement.setString(1, name); statement.setLong(2, operatorId); statement.setString(3, operatorName);
-            statement.setTimestamp(4, new Timestamp(createdAt));
-            return statement;
-        }, keys);
-        long id = keys.getKey().longValue();
-        jdbc.update("insert into t_compare_report_version(report_id,version_no,snapshot_json,operator_id,operator_name,created_at_utc,change_summary) values(?,1,?,?,?,?,?)",
-                id, snapshot, operatorId, operatorName, new Timestamp(createdAt), "首次保存对比报表");
-        return id;
-    }
+    public CompareReportRepository(DataSource dataSource) { super(dataSource, "compare", "对比"); }
 }
diff --git a/battery-business/src/main/java/com/battery/business/report/FormationField.java b/battery-business/src/main/java/com/battery/business/report/FormationField.java
new file mode 100644
index 0000000..5968dc5
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/report/FormationField.java
@@ -0,0 +1,36 @@
+package com.battery.business.report;
+
+import java.util.Map;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.ruoyi.common.exception.ServiceException;
+
+/** 化成字段结构不携带数据;循环测量单位固定,未知公式仅允许人工项。 */
+public record FormationField(String key, String scope, String kind, String displayName, String unit, Long fieldDefinitionId)
+{
+    public static final Map<String, String> METRIC_UNITS = Map.of("chargeCapacity", "mAh", "dischargeCapacity", "mAh",
+            "chargeEnergy", "mWh", "dischargeEnergy", "mWh", "efficiency", "%", "energyEfficiency", "%");
+
+    public FormationField
+    {
+        if (key == null || key.isBlank() || key.length() > 100 || key.contains("|")
+                || !("detail".equals(scope) || "cycle".equals(scope))
+                || !("battery".equals(kind) || "metric".equals(kind) || "manual".equals(kind)))
+            throw new ServiceException("化成字段身份或分类无效");
+        displayName = displayName == null ? key : displayName.trim();
+        unit = unit == null ? "" : unit.trim();
+        if (displayName.isBlank() || displayName.length() > 100 || unit.length() > 30
+                || ("battery".equals(kind) && !"detail".equals(scope))
+                || (fieldDefinitionId != null && (fieldDefinitionId <= 0 || !"battery".equals(kind)))
+                || ("metric".equals(kind) && (!"cycle".equals(scope) || !unit.equals(METRIC_UNITS.get(key)))))
+            throw new ServiceException("化成字段展示名称、单位或共享身份无效");
+        if ((METRIC_UNITS.containsKey(key) && !"metric".equals(kind))
+                || (java.util.Set.of("Z", "H", "F", "A", "B").contains(key) && !"battery".equals(kind))
+                || (java.util.Set.of("FN", "C", "method2", "manual", "chargePerGram", "chargePerGramTotal", "capacity1v", "capacity1vf",
+                        "dischargeInflection", "dischargeInflectionTotal").contains(key) && !"manual".equals(kind)))
+            throw new ServiceException("固定字段不能改变来源分类");
+    }
+
+    /** 模板和字段设置均拒绝混入测量、测试选择或人工值。 */
+    @JsonAnySetter
+    public void rejectUnknown(String field, Object value) { throw new ServiceException("化成字段不允许提交属性:" + field); }
+}
diff --git a/battery-business/src/main/java/com/battery/business/report/FormationFieldTemplateService.java b/battery-business/src/main/java/com/battery/business/report/FormationFieldTemplateService.java
new file mode 100644
index 0000000..e55af41
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/report/FormationFieldTemplateService.java
@@ -0,0 +1,73 @@
+package com.battery.business.report;
+
+import java.sql.Timestamp;
+import java.util.List;
+import javax.sql.DataSource;
+import com.alibaba.fastjson2.JSON;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.ruoyi.common.exception.ServiceException;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.stereotype.Service;
+
+/** 化成模板仅存详情和循环字段结构,独立于对比模板及所有报表版本。 */
+@Service
+public class FormationFieldTemplateService
+{
+    private final JdbcTemplate jdbc;
+    private final FormationReportService reports;
+    public FormationFieldTemplateService(DataSource source, FormationReportService reports)
+    { this.jdbc = new JdbcTemplate(source); this.reports = reports; }
+
+    public record Template(long id, String name, String type, List<FormationField> fields, String creator, long updatedAt) { }
+    public record Request(@NotBlank @Size(max = 100) String name,
+            @NotNull @Size(min = 1, max = 100) List<@NotNull @Valid FormationField> fields)
+    {
+        /** 模板不接受整份报表、来源选择或数据值。 */
+        @JsonAnySetter
+        public void rejectUnknown(String field, Object value) { throw new ServiceException("化成模板不允许提交属性:" + field); }
+    }
+
+    /** 查看该类型全部模板,保存顺序即应用顺序。 */
+    public List<Template> list()
+    {
+        return jdbc.query("select template_id,template_name,fields_json,creator_name,updated_at_utc from t_formation_report_field_template where template_type='formation' order by template_name,template_id",
+                (rs, index) -> new Template(rs.getLong(1), rs.getString(2), "formation", JSON.parseArray(rs.getString(3), FormationField.class), rs.getString(4), rs.getTimestamp(5).getTime()));
+    }
+
+    /** 新增前解析共享定义身份,同名模板须显式更新。 */
+    public long create(Request request, long userId, String userName)
+    {
+        var fields = reports.validateFields(request.fields(), true);
+        var key = new GeneratedKeyHolder();
+        try
+        {
+            jdbc.update(connection -> {
+                var statement = connection.prepareStatement("insert into t_formation_report_field_template(template_name,template_type,fields_json,creator_id,creator_name,created_at_utc,updated_at_utc) values(?,'formation',?,?,?,?,?)", java.sql.Statement.RETURN_GENERATED_KEYS);
+                statement.setString(1, request.name().trim()); statement.setString(2, JSON.toJSONString(fields));
+                statement.setLong(3, userId); statement.setString(4, userName);
+                var now = new Timestamp(System.currentTimeMillis()); statement.setTimestamp(5, now); statement.setTimestamp(6, now);
+                return statement;
+            }, key);
+            return key.getKey().longValue();
+        }
+        catch (org.springframework.dao.DuplicateKeyException error) { throw new ServiceException("模板名称已存在"); }
+    }
+
+    /** 修改只影响后续主动应用,不访问报表版本;允许跨创建人协作。 */
+    public void update(long id, Request request)
+    {
+        var fields = reports.validateFields(request.fields(), true);
+        try
+        {
+            if (jdbc.update("update t_formation_report_field_template set template_name=?,fields_json=?,updated_at_utc=? where template_id=? and template_type='formation'",
+                    request.name().trim(), JSON.toJSONString(fields), new Timestamp(System.currentTimeMillis()), id) == 0)
+                throw new ServiceException("化成字段模板不存在");
+        }
+        catch (org.springframework.dao.DuplicateKeyException error) { throw new ServiceException("模板名称已存在"); }
+    }
+}
diff --git a/battery-business/src/main/java/com/battery/business/report/FormationReportRepository.java b/battery-business/src/main/java/com/battery/business/report/FormationReportRepository.java
new file mode 100644
index 0000000..52dbef0
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/report/FormationReportRepository.java
@@ -0,0 +1,11 @@
+package com.battery.business.report;
+
+import javax.sql.DataSource;
+import org.springframework.stereotype.Repository;
+
+/** 化成身份与版本使用独立表,复用对比报表的事务内存储规则。 */
+@Repository
+public class FormationReportRepository extends ReportVersionRepository
+{
+    public FormationReportRepository(DataSource dataSource) { super(dataSource, "formation", "化成"); }
+}
diff --git a/battery-business/src/main/java/com/battery/business/report/FormationReportRequest.java b/battery-business/src/main/java/com/battery/business/report/FormationReportRequest.java
new file mode 100644
index 0000000..ba4b875
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/report/FormationReportRequest.java
@@ -0,0 +1,41 @@
+package com.battery.business.report;
+
+import java.util.List;
+import java.util.Map;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.ruoyi.common.exception.ServiceException;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.Size;
+
+/** 新建化成报表仅提交来源身份和展示内容,事实在保存时由后端读取。 */
+public record FormationReportRequest(@NotBlank @Size(max = 100) String name, @Size(max = 2000) String description,
+        @NotNull @Size(min = 1) List<@NotNull @Valid Selection> selections, @NotNull @Valid Settings settings)
+{
+    public record Selection(@NotNull @Positive Long testId, @NotNull @Positive Long testBatteryId) { }
+
+    /** 值按测试、电池、循环、字段归属,只属于报表展示,不反写测量快照。 */
+    public record Settings(@NotNull @Size(min = 1, max = 100) List<@NotNull @Valid FormationField> fields,
+            @NotNull Map<String, @NotNull String> values)
+    {
+        /** 展示设置拒绝来源和测量快照。 */
+        @JsonAnySetter
+        public void rejectUnknown(String field, Object value) { throw new ServiceException("化成设置不允许提交属性:" + field); }
+    }
+
+    /** 编辑保持后端冻结来源范围,不接收替代来源或测量。 */
+    public record Edit(@Positive int baseVersionNo, @NotBlank @Size(max = 100) String name,
+            @Size(max = 2000) String description, @NotNull @Valid Settings settings,
+            @NotBlank @Size(max = 500) String changeSummary)
+    {
+        /** 拒绝借普通编辑替换冻结来源或测量。 */
+        @JsonAnySetter
+        public void rejectUnknown(String field, Object value) { throw new ServiceException("化成编辑不允许提交属性:" + field); }
+    }
+
+    /** 新建只接收定义的展示内容与真实来源身份。 */
+    @JsonAnySetter
+    public void rejectUnknown(String field, Object value) { throw new ServiceException("化成报表不允许提交属性:" + field); }
+}
diff --git a/battery-business/src/main/java/com/battery/business/report/FormationReportService.java b/battery-business/src/main/java/com/battery/business/report/FormationReportService.java
new file mode 100644
index 0000000..014f3c5
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/report/FormationReportService.java
@@ -0,0 +1,249 @@
+package com.battery.business.report;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Stream;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.alibaba.fastjson2.JSONWriter;
+import com.battery.business.domain.CurrentBatteryField;
+import com.battery.business.domain.TBatteryGroupField;
+import com.battery.business.domain.TTest;
+import com.battery.business.domain.TTestBattery;
+import com.battery.business.report.FormationReportRequest.Selection;
+import com.battery.business.report.FormationReportRequest.Settings;
+import com.battery.business.service.ITBatteryGroupFieldService;
+import com.battery.business.service.ITTestService;
+import com.battery.business.service.ITestDataQueryService;
+import com.ruoyi.common.exception.ServiceException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/** 化成按实际循环并集展示;来源读取仅发生在预览、首次保存及主动更新。 */
+@Service
+public class FormationReportService
+{
+    private final ITTestService tests;
+    private final ITestDataQueryService data;
+    private final FormationReportRepository reports;
+    private final ITBatteryGroupFieldService definitions;
+
+    public FormationReportService(ITTestService tests, ITestDataQueryService data,
+            FormationReportRepository reports, ITBatteryGroupFieldService definitions)
+    { this.tests = tests; this.data = data; this.reports = reports; this.definitions = definitions; }
+
+    public record Candidate(Selection selection, String name, String type, long groupId, String batteryGroup, String batteryNo) { }
+    public record Item(Candidate source, List<Integer> cycleNos, List<Map<String, Object>> cycles,
+            Map<String, Map<String, BigDecimal>> metrics, List<CurrentBatteryField> businessFields) { }
+    public record Matrix(List<Item> items, List<Integer> cycleNos) { }
+    public record Summary(long id, String name, String creator, long createdAt, String testName, String batteryGroup) { }
+
+    /** 列表来源名称取自已保存版本,测试删除后仍保留筛选与识别信息。 */
+    public List<Summary> list()
+    {
+        return reports.list().stream().map(summary -> {
+            var sources = items(JSON.parseObject(reports.get(summary.id()).snapshotJson()));
+            return new Summary(summary.id(), summary.name(), summary.creator(), summary.createdAt(),
+                    String.join("、", sources.stream().map(item -> item.source().name()).distinct().toList()),
+                    String.join("、", sources.stream().map(item -> item.source().batteryGroup()).distinct().toList()));
+        }).toList();
+    }
+
+    /** 只提供类型名称精确为“化成”的检测完成测试及其真实参与电池。 */
+    public List<Candidate> sources()
+    {
+        TTest query = new TTest(); query.setTestStatus("COMPLETED");
+        List<Candidate> result = new ArrayList<>();
+        for (TTest summary : tests.selectTestList(query))
+        {
+            if (!"化成".equals(summary.getTestTypeName())) continue;
+            TTest test = tests.selectTestById(summary.getTestId());
+            if (eligible(test)) for (TTestBattery battery : test.getBatteries()) result.add(candidate(test, battery));
+        }
+        return result;
+    }
+
+    /** 校验电池属于所选测试,返回三类持久化记录实际出现的循环号与循环统计。 */
+    public Matrix preview(List<Selection> selections)
+    {
+        if (selections == null || selections.isEmpty() || selections.contains(null)
+                || new HashSet<>(selections).size() != selections.size()) throw new ServiceException("请选择不重复的化成测试电池");
+        Map<Long, TTest> loaded = new HashMap<>();
+        Map<Long, List<Map<String, Object>>> cycles = new HashMap<>(), records = new HashMap<>(), steps = new HashMap<>();
+        List<Item> items = new ArrayList<>();
+        for (Selection selection : selections)
+        {
+            if (selection.testId() == null || selection.testBatteryId() == null) throw new ServiceException("测试和电池身份不能为空");
+            TTest test = loaded.computeIfAbsent(selection.testId(), tests::selectTestById);
+            if (!eligible(test)) throw new ServiceException("仅可选择类型为化成的检测完成测试");
+            TTestBattery battery = test.getBatteries().stream().filter(row -> selection.testBatteryId().equals(row.getTestBatteryId()))
+                    .findFirst().orElseThrow(() -> new ServiceException("电池不属于所选测试"));
+            List<Map<String, Object>> rows = batteryRows(cycles.computeIfAbsent(selection.testId(), data::cycles), battery);
+            var actual = Stream.of(rows, batteryRows(records.computeIfAbsent(selection.testId(), data::records), battery),
+                    batteryRows(steps.computeIfAbsent(selection.testId(), data::steps), battery)).flatMap(List::stream)
+                    .map(this::cycleNo).filter(no -> no > 0).distinct().sorted().toList();
+            items.add(new Item(candidate(test, battery), actual, rows, metrics(rows, actual), battery.getFields()));
+        }
+        return new Matrix(items, items.stream().flatMap(item -> item.cycleNos().stream()).distinct().sorted().toList());
+    }
+
+    /** 从当前来源冻结所选字段;首版和报表身份在同一事务提交。 */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> create(FormationReportRequest request, long userId, String userName)
+    {
+        Settings settings = new Settings(validateFields(request.settings().fields(), true), request.settings().values());
+        Matrix matrix = freeze(preview(request.selections()), settings.fields());
+        validateValues(settings, matrix.items());
+        var snapshot = new LinkedHashMap<String, Object>();
+        snapshot.put("name", request.name().trim()); snapshot.put("description", request.description());
+        snapshot.put("items", matrix.items()); snapshot.put("cycleNos", matrix.cycleNos()); snapshot.put("settings", settings);
+        snapshot.put("units", Map.of("chargeCapacity", "Ah", "dischargeCapacity", "Ah", "chargeEnergy", "Wh", "dischargeEnergy", "Wh", "efficiency", "%", "energyEfficiency", "%"));
+        snapshot.put("displayUnits", FormationField.METRIC_UNITS); snapshot.put("timeUnit", "epoch-milliseconds");
+        return get(reports.insert(request.name().trim(), json(snapshot), userId, userName, System.currentTimeMillis()), null);
+    }
+
+    /** 当前或历史版本只读取独立快照,来源删除后仍能重开。 */
+    public Map<String, Object> get(long id, Integer versionNo)
+    {
+        var version = reports.get(id, versionNo);
+        var result = JSON.parseObject(version.snapshotJson());
+        result.put("id", id); result.put("versionNo", version.versionNo());
+        result.put("creator", version.report().creator()); result.put("createdAt", version.report().createdAt());
+        result.put("operatorName", version.operatorName()); result.put("versionCreatedAt", version.versionCreatedAt());
+        result.put("changeSummary", version.changeSummary());
+        return result;
+    }
+
+    /** 仅追加报表展示内容;共享定义或来源已删除也不影响基于快照的普通编辑。 */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> edit(long id, FormationReportRequest.Edit request, long userId, String userName)
+    {
+        var snapshot = current(id, request.baseVersionNo());
+        validateFields(request.settings().fields(), false);
+        validateValues(request.settings(), items(snapshot));
+        snapshot.put("name", request.name().trim()); snapshot.put("description", request.description()); snapshot.put("settings", request.settings());
+        reports.append(id, request.baseVersionNo() + 1, request.name().trim(), json(snapshot), userId, userName, request.changeSummary().trim());
+        return get(id, null);
+    }
+
+    /** 主动重读已保存范围,保留仍属于当前循环的人工值;不存在的来源使整次提交失败。 */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> refresh(long id, CompareReportEditRequest.Refresh request, long userId, String userName)
+    {
+        var snapshot = current(id, request.baseVersionNo());
+        var settings = JSON.parseObject(json(snapshot.get("settings")), Settings.class);
+        Matrix matrix = freeze(preview(items(snapshot).stream().map(item -> item.source().selection()).toList()), settings.fields());
+        Set<String> allowed = valueKeys(settings.fields(), matrix.items());
+        Map<String, String> values = new LinkedHashMap<>();
+        settings.values().forEach((key, value) -> { if (allowed.contains(key)) values.put(key, value); });
+        snapshot.put("items", matrix.items()); snapshot.put("cycleNos", matrix.cycleNos()); snapshot.put("settings", new Settings(settings.fields(), values));
+        reports.append(id, request.baseVersionNo() + 1, snapshot.getString("name"), json(snapshot), userId, userName, "按最新来源生成:" + request.changeSummary().trim());
+        return get(id, null);
+    }
+
+    /** 删除全部化成版本,与最小删除日志在同一事务提交,不删除测试事实。 */
+    @Transactional(rollbackFor = Exception.class)
+    public void delete(long id, long userId, String userName) { reports.lock(id); reports.delete(id, userId, userName); }
+
+    /** 校验有序结构;首次保存及模板解析共享身份,普通编辑沿用冻结身份。 */
+    public List<FormationField> validateFields(List<FormationField> fields, boolean resolveDefinitions)
+    {
+        if (fields == null || fields.isEmpty() || fields.size() > 100 || fields.contains(null)) throw new ServiceException("化成字段不能为空");
+        var names = new HashSet<String>(); var ids = new HashSet<Long>();
+        var current = resolveDefinitions ? definitions.selectBatteryGroupFieldList(new TBatteryGroupField()) : List.<TBatteryGroupField>of();
+        return fields.stream().map(field -> {
+            if (!names.add(field.scope() + "|" + field.key())) throw new ServiceException("化成字段不能重复");
+            if (!"battery".equals(field.kind())) return field;
+            Long id = field.fieldDefinitionId();
+            if (resolveDefinitions)
+            {
+                var definition = current.stream().filter(value -> field.fieldDefinitionId() == null
+                        ? value.getFieldName().equals(field.key()) : value.getFieldId().equals(field.fieldDefinitionId())).findFirst();
+                if (definition.isEmpty() && !Set.of("Z", "H", "F", "A", "B").contains(field.key()))
+                    throw new ServiceException("共享字段不存在,请重新选择:" + field.key());
+                if (definition.isPresent()) id = definition.get().getFieldId();
+            }
+            if (id != null && !ids.add(id)) throw new ServiceException("共享字段不能重复");
+            return new FormationField(field.key(), field.scope(), field.kind(), field.displayName(), field.unit(), id);
+        }).toList();
+    }
+
+    private JSONObject current(long id, int baseVersionNo)
+    {
+        // 与删除共用报表身份锁;旧页面只能重开,不能从陈旧基准覆盖新版本。
+        reports.lock(id);
+        var version = reports.get(id);
+        if (version.versionNo() != baseVersionNo) throw new ServiceException("报表已有新版本,请重新打开后编辑");
+        return JSON.parseObject(version.snapshotJson());
+    }
+
+    private Matrix freeze(Matrix matrix, List<FormationField> fields)
+    {
+        Set<String> selected = new HashSet<>(fields.stream().filter(field -> "metric".equals(field.kind())).map(FormationField::key).toList());
+        if (selected.contains("energyEfficiency")) selected.addAll(List.of("chargeEnergy", "dischargeEnergy"));
+        var frozen = matrix.items().stream().map(item -> {
+            var rows = item.cycles().stream().map(row -> {
+                Map<String, Object> copy = new LinkedHashMap<>(row);
+                FormationField.METRIC_UNITS.keySet().stream().filter(key -> !selected.contains(key)).forEach(copy::remove);
+                return copy;
+            }).toList();
+            return new Item(item.source(), item.cycleNos(), rows, metrics(rows, item.cycleNos()), item.businessFields().stream()
+                    .filter(value -> fields.stream().anyMatch(field -> "battery".equals(field.kind()) && (field.fieldDefinitionId() == null
+                            ? field.key().equals(value.getFieldName()) : field.fieldDefinitionId().equals(value.getFieldDefinitionId())))).toList());
+        }).toList();
+        return new Matrix(frozen, matrix.cycleNos());
+    }
+
+    private void validateValues(Settings settings, List<Item> items)
+    {
+        if (settings.values() == null || !valueKeys(settings.fields(), items).containsAll(settings.values().keySet())
+                || settings.values().values().stream().anyMatch(value -> value == null || value.length() > 500))
+            throw new ServiceException("人工值不属于当前字段、测试电池或实际循环");
+    }
+
+    private Set<String> valueKeys(List<FormationField> fields, List<Item> items)
+    {
+        Set<String> keys = new HashSet<>();
+        for (Item item : items) for (FormationField field : fields)
+        {
+            String prefix = item.source().selection().testId() + "|" + item.source().selection().testBatteryId() + "|";
+            for (int cycle : "detail".equals(field.scope()) ? List.of(0) : item.cycleNos()) keys.add(prefix + cycle + "|" + field.key());
+        }
+        return keys;
+    }
+
+    private Map<String, Map<String, BigDecimal>> metrics(List<Map<String, Object>> rows, List<Integer> cycleNos)
+    {
+        Map<String, Map<String, BigDecimal>> result = new LinkedHashMap<>();
+        for (int no : cycleNos)
+        {
+            var matches = rows.stream().filter(row -> cycleNo(row) == no).toList();
+            Map<String, BigDecimal> values = new LinkedHashMap<>();
+            // 多条统计记录没有已确认的合并口径,保持缺失,不挑任意一条或借用其他循环。
+            if (matches.size() == 1) for (String key : FormationField.METRIC_UNITS.keySet())
+                if (matches.get(0).get(key) instanceof Number number) values.put(key, new BigDecimal(number.toString()));
+            BigDecimal charge = values.get("chargeEnergy"), discharge = values.get("dischargeEnergy");
+            if (charge != null && discharge != null && charge.signum() > 0)
+                values.put("energyEfficiency", discharge.multiply(BigDecimal.valueOf(100)).divide(charge, MathContext.DECIMAL128));
+            result.put(Integer.toString(no), values);
+        }
+        return result;
+    }
+
+    private boolean eligible(TTest test) { return "COMPLETED".equals(test.getTestStatus()) && "化成".equals(test.getTestTypeName()); }
+    private Candidate candidate(TTest test, TTestBattery battery)
+    { return new Candidate(new Selection(test.getTestId(), battery.getTestBatteryId()), test.getTestName(), test.getTestTypeName(), battery.getGroupId(), battery.getGroupName(), battery.getBatteryNo()); }
+    private List<Map<String, Object>> batteryRows(List<Map<String, Object>> rows, TTestBattery battery)
+    { return rows.stream().filter(row -> Objects.equals(battery.getBatteryNo(), row.get("batteryNo"))).toList(); }
+    private int cycleNo(Map<String, Object> row) { return row.get("cycleNo") instanceof Number number ? number.intValue() : 0; }
+    private List<Item> items(JSONObject snapshot) { return JSON.parseArray(json(snapshot.get("items")), Item.class); }
+    private String json(Object value) { return JSON.toJSONString(value, JSONWriter.Feature.WriteMapNullValue); }
+}
diff --git a/battery-business/src/main/java/com/battery/business/report/ReportVersionRepository.java b/battery-business/src/main/java/com/battery/business/report/ReportVersionRepository.java
new file mode 100644
index 0000000..32b05e0
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/report/ReportVersionRepository.java
@@ -0,0 +1,102 @@
+package com.battery.business.report;
+
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import com.alibaba.fastjson2.JSON;
+import javax.sql.DataSource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import com.ruoyi.common.exception.ServiceException;
+
+/** 报表身份和版本存储;重读只访问报表表,不联查来源测试。 */
+public abstract class ReportVersionRepository
+{
+    private final JdbcTemplate jdbc;
+    private final String type;
+    private final String title;
+
+    protected ReportVersionRepository(DataSource dataSource, String type, String title)
+    { jdbc = new JdbcTemplate(dataSource); this.type = type; this.title = title; }
+
+    // 类型仅由两个仓储构造器指定,SQL 表名不接受请求输入;两类共用已验证的版本和删除规则。
+    private String sql(String statement) { return statement.replace("t_compare_report", "t_" + type + "_report"); }
+
+    public record Summary(long id, String name, String creator, long createdAt) { }
+    public record Version(Summary report, int versionNo, String snapshotJson, String operatorName, long versionCreatedAt, String changeSummary) { }
+    public record History(int versionNo, String operatorName, long createdAt, String changeSummary) { }
+
+    /** 查询所有已保存报表摘要,允许授权用户跨创建人查看。 */
+    public List<Summary> list()
+    {
+        return jdbc.query(sql("select report_id, report_name, creator_name, created_at_utc from t_compare_report order by created_at_utc desc, report_id desc"),
+                (rs, index) -> new Summary(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4).getTime()));
+    }
+
+    /** 重读最新完整版本;来源不存在不影响结果。 */
+    public Version get(long id)
+    { return get(id, null); }
+
+    /** 读取指定历史版,版本号为空时读取最新版本。 */
+    public Version get(long id, Integer versionNo)
+    {
+        var rows = jdbc.query(sql("select r.report_id, r.report_name, r.creator_name, r.created_at_utc, v.version_no, v.snapshot_json, v.operator_name, v.created_at_utc, v.change_summary "
+                + "from t_compare_report r join t_compare_report_version v on v.report_id=r.report_id where r.report_id=? "
+                + (versionNo == null ? "and v.version_no=(select max(v2.version_no) from t_compare_report_version v2 where v2.report_id=r.report_id)" : "and v.version_no=?")),
+                (rs, index) -> new Version(new Summary(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4).getTime()),
+                        rs.getInt(5), rs.getString(6), rs.getString(7), rs.getTimestamp(8).getTime(), rs.getString(9)),
+                versionNo == null ? new Object[] { id } : new Object[] { id, versionNo });
+        if (rows.isEmpty()) throw new ServiceException(title + "报表或版本不存在");
+        return rows.get(0);
+    }
+
+    /** 历史摘要只来自正式版本,不返回测量快照。 */
+    public List<History> history(long id)
+    {
+        return jdbc.query(sql("select version_no,operator_name,created_at_utc,change_summary from t_compare_report_version where report_id=? order by version_no desc"),
+                (rs, index) -> new History(rs.getInt(1), rs.getString(2), rs.getTimestamp(3).getTime(), rs.getString(4)), id);
+    }
+
+    /** 业务事务内锁定报表,版本号分配与旧基准拒绝共用此锁。 */
+    public void lock(long id)
+    {
+        if (jdbc.queryForList(sql("select report_id from t_compare_report where report_id=? for update"), Long.class, id).isEmpty())
+            throw new ServiceException(title + "报表不存在");
+    }
+
+    /** 仅在业务事务和报表锁内调用;物理清理全部版本,同步保留最小操作日志。 */
+    public void delete(long id, long operatorId, String operatorName)
+    {
+        String name = jdbc.queryForObject(sql("select report_name from t_compare_report where report_id=?"), String.class, id);
+        jdbc.update(sql("delete from t_compare_report_version where report_id=?"), id);
+        jdbc.update(sql("delete from t_compare_report where report_id=?"), id);
+        // 不走异步 @Log,日志失败必须回滚删除;只保留身份,不读取或记录测量快照。
+        jdbc.update("insert into sys_oper_log(title,business_type,method,request_method,operator_type,oper_name,oper_url,oper_param,status,oper_time) values(?,3,?,'DELETE',1,?,?,?,0,?)",
+                title + "报表删除", type + "-report.delete", operatorName, "/system/" + type + "-report/" + id,
+                JSON.toJSONString(Map.of("reportId", id, "reportName", name, "operatorId", operatorId)), new Timestamp(System.currentTimeMillis()));
+    }
+
+    /** 只追加版本并更新列表名称;业务事务保证两步同时成功。 */
+    public void append(long id, int versionNo, String name, String snapshot, long operatorId, String operatorName, String summary)
+    {
+        jdbc.update(sql("insert into t_compare_report_version(report_id,version_no,snapshot_json,operator_id,operator_name,created_at_utc,change_summary) values(?,?,?,?,?,?,?)"),
+                id, versionNo, snapshot, operatorId, operatorName, new Timestamp(System.currentTimeMillis()), summary);
+        jdbc.update(sql("update t_compare_report set report_name=? where report_id=?"), name, id);
+    }
+
+    /** 由业务事务调用,身份与首个版本任一写入失败必须一起回滚。 */
+    public long insert(String name, String snapshot, long operatorId, String operatorName, long createdAt)
+    {
+        var keys = new GeneratedKeyHolder();
+        jdbc.update(connection -> {
+            var statement = connection.prepareStatement(sql("insert into t_compare_report(report_name,creator_id,creator_name,created_at_utc) values(?,?,?,?)"), java.sql.Statement.RETURN_GENERATED_KEYS);
+            statement.setString(1, name); statement.setLong(2, operatorId); statement.setString(3, operatorName);
+            statement.setTimestamp(4, new Timestamp(createdAt));
+            return statement;
+        }, keys);
+        long id = keys.getKey().longValue();
+        jdbc.update(sql("insert into t_compare_report_version(report_id,version_no,snapshot_json,operator_id,operator_name,created_at_utc,change_summary) values(?,1,?,?,?,?,?)"),
+                id, snapshot, operatorId, operatorName, new Timestamp(createdAt), "首次保存" + title + "报表");
+        return id;
+    }
+}
diff --git a/battery-business/src/main/resources/mapper/battery/TBatteryGroupFieldMapper.xml b/battery-business/src/main/resources/mapper/battery/TBatteryGroupFieldMapper.xml
index 56007ea..5eebafc 100644
--- a/battery-business/src/main/resources/mapper/battery/TBatteryGroupFieldMapper.xml
+++ b/battery-business/src/main/resources/mapper/battery/TBatteryGroupFieldMapper.xml
@@ -104,5 +104,7 @@
              where battery_group_field_id = #{fieldId} and del_flag = '0')
           + (select count(1) from t_compare_report_field_template
              where template_type = 'compare' and JSON_CONTAINS(fields_json, JSON_OBJECT('fieldDefinitionId', #{fieldId})))
+          + (select count(1) from t_formation_report_field_template
+             where template_type = 'formation' and JSON_CONTAINS(fields_json, JSON_OBJECT('fieldDefinitionId', #{fieldId})))
     </select>
 </mapper>
diff --git a/ruoyi-admin/src/main/java/com/battery/business/web/FormationFieldTemplateController.java b/ruoyi-admin/src/main/java/com/battery/business/web/FormationFieldTemplateController.java
new file mode 100644
index 0000000..4302ae2
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/battery/business/web/FormationFieldTemplateController.java
@@ -0,0 +1,31 @@
+package com.battery.business.web;
+
+import com.battery.business.report.FormationFieldTemplateService;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+/** 化成模板沿用化成动作权限,不读取或修改对比模板。 */
+@RestController
+@RequestMapping("/system/formation-report/templates")
+public class FormationFieldTemplateController extends BaseController
+{
+    private final FormationFieldTemplateService service;
+    public FormationFieldTemplateController(FormationFieldTemplateService service) { this.service = service; }
+
+    /** 查看、新增或编辑报表均可读取模板。 */
+    @GetMapping @PreAuthorize("@ss.hasAnyPermi('system:formation-report:query,system:formation-report:add,system:formation-report:edit')")
+    public AjaxResult list() { return success(service.list()); }
+
+    /** 新增模板仅接受结构,拒绝值和来源身份。 */
+    @PostMapping @PreAuthorize("@ss.hasPermi('system:formation-report:add')")
+    public AjaxResult create(@Valid @RequestBody FormationFieldTemplateService.Request request)
+    { return success(service.create(request, getUserId(), getUsername())); }
+
+    /** 显式更新所属类型模板,已有报表不随之变化。 */
+    @PutMapping("/{id}") @PreAuthorize("@ss.hasPermi('system:formation-report:edit')")
+    public AjaxResult update(@PathVariable long id, @Valid @RequestBody FormationFieldTemplateService.Request request)
+    { service.update(id, request); return success(); }
+}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/web/FormationReportController.java b/ruoyi-admin/src/main/java/com/battery/business/web/FormationReportController.java
new file mode 100644
index 0000000..9617a90
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/battery/business/web/FormationReportController.java
@@ -0,0 +1,69 @@
+package com.battery.business.web;
+
+import java.util.List;
+import com.battery.business.report.FormationReportRepository;
+import com.battery.business.report.FormationReportService;
+import com.battery.business.report.FormationReportRequest;
+import com.battery.business.report.CompareReportEditRequest;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+/** 化成报表按所属动作授权,授权用户可以跨创建人协作。 */
+@RestController
+@RequestMapping("/system/formation-report")
+public class FormationReportController extends BaseController
+{
+    private final FormationReportService service;
+    private final FormationReportRepository reports;
+    public FormationReportController(FormationReportService service, FormationReportRepository reports)
+    { this.service = service; this.reports = reports; }
+
+    /** 查询真实化成来源,不额外要求测试管理权限。 */
+    @GetMapping("/sources") @PreAuthorize("@ss.hasPermi('system:formation-report:add')")
+    public AjaxResult sources() { return success(service.sources()); }
+
+    /** 后端校验归属并读取当前循环及业务字段。 */
+    @PostMapping("/preview") @PreAuthorize("@ss.hasPermi('system:formation-report:add')")
+    public AjaxResult preview(@RequestBody List<FormationReportRequest.Selection> selections) { return success(service.preview(selections)); }
+
+    /** 查询独立报表摘要。 */
+    @GetMapping("/list") @PreAuthorize("@ss.hasPermi('system:formation-report:query')")
+    public AjaxResult list() { return success().put("rows", service.list()); }
+
+    /** 创建身份及首个完整版本。 */
+    @PostMapping @PreAuthorize("@ss.hasPermi('system:formation-report:add')")
+    public AjaxResult create(@Valid @RequestBody FormationReportRequest request) { return success(service.create(request, getUserId(), getUsername())); }
+
+    /** 重开最新保存版本,不读取来源测试。 */
+    @GetMapping("/{id}") @PreAuthorize("@ss.hasPermi('system:formation-report:query')")
+    public AjaxResult get(@PathVariable long id) { return success(service.get(id, null)); }
+
+    /** 编辑权限独立读取最新基准。 */
+    @GetMapping("/{id}/editing") @PreAuthorize("@ss.hasPermi('system:formation-report:edit')")
+    public AjaxResult editing(@PathVariable long id) { return success(service.get(id, null)); }
+
+    /** 查看历史版本的操作人、时间和说明。 */
+    @GetMapping("/{id}/versions") @PreAuthorize("@ss.hasPermi('system:formation-report:query')")
+    public AjaxResult history(@PathVariable long id) { return success(reports.history(id)); }
+
+    /** 指定历史版本只读,不提供回滚。 */
+    @GetMapping("/{id}/versions/{versionNo}") @PreAuthorize("@ss.hasPermi('system:formation-report:query')")
+    public AjaxResult version(@PathVariable long id, @PathVariable int versionNo) { return success(service.get(id, versionNo)); }
+
+    /** 普通编辑不接受来源或测量快照。 */
+    @PostMapping("/{id}/versions") @PreAuthorize("@ss.hasPermi('system:formation-report:edit')")
+    public AjaxResult edit(@PathVariable long id, @Valid @RequestBody FormationReportRequest.Edit request)
+    { return success(service.edit(id, request, getUserId(), getUsername())); }
+
+    /** 明确按当前来源生成新版本。 */
+    @PostMapping("/{id}/refresh-source") @PreAuthorize("@ss.hasPermi('system:formation-report:edit')")
+    public AjaxResult refresh(@PathVariable long id, @Valid @RequestBody CompareReportEditRequest.Refresh request)
+    { return success(service.refresh(id, request, getUserId(), getUsername())); }
+
+    /** 整体删除及最小日志同成同败。 */
+    @DeleteMapping("/{id}") @PreAuthorize("@ss.hasPermi('system:formation-report:remove')")
+    public AjaxResult delete(@PathVariable long id) { service.delete(id, getUserId(), getUsername()); return success(); }
+}
diff --git a/ruoyi-admin/src/main/resources/db/migration-final/V15__formation_reports_and_templates.sql b/ruoyi-admin/src/main/resources/db/migration-final/V15__formation_reports_and_templates.sql
new file mode 100644
index 0000000..8fd654e
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration-final/V15__formation_reports_and_templates.sql
@@ -0,0 +1,18 @@
+-- 化成报表与来源测试独立;保存和整体删除复用对比版本规则。
+CREATE TABLE t_formation_report LIKE t_compare_report;
+ALTER TABLE t_formation_report COMMENT='化成报表身份';
+CREATE TABLE t_formation_report_version LIKE t_compare_report_version;
+ALTER TABLE t_formation_report_version COMMENT='化成报表独立版本';
+CREATE TABLE t_formation_report_field_template LIKE t_compare_report_field_template;
+ALTER TABLE t_formation_report_field_template
+    ALTER COLUMN template_type SET DEFAULT 'formation', COMMENT='化成报表字段模板';
+
+SET @analysis_menu_id = (SELECT menu_id FROM sys_menu WHERE path='data-analysis' AND parent_id=0 LIMIT 1);
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+VALUES ('化成报表',@analysis_menu_id,2,'report-formation','C','0','0','','system',UTC_TIMESTAMP(3));
+SET @formation_menu_id = LAST_INSERT_ID();
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+VALUES ('查看',@formation_menu_id,1,'#','F','0','0','system:formation-report:query','system',UTC_TIMESTAMP(3)),
+       ('新增',@formation_menu_id,2,'#','F','0','0','system:formation-report:add','system',UTC_TIMESTAMP(3)),
+       ('编辑',@formation_menu_id,3,'#','F','0','0','system:formation-report:edit','system',UTC_TIMESTAMP(3)),
+       ('删除',@formation_menu_id,4,'#','F','0','0','system:formation-report:remove','system',UTC_TIMESTAMP(3));
diff --git a/ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml
index 3ab9300..7849c6e 100644
--- a/ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml
@@ -74,7 +74,7 @@
  			#{operId}
         </foreach> 
         <!-- 报表删除只保留这一份最小追溯记录,平台日志清理也不能移除。 -->
-        and (method is null or method &lt;&gt; 'compare-report.delete')
+        and (method is null or method not in ('compare-report.delete', 'formation-report.delete'))
  	</delete>
  	
  	<select id="selectOperLogById" parameterType="Long" resultMap="SysOperLogResult">
@@ -83,7 +83,7 @@
 	</select>
 	
 	<update id="cleanOperLog">
-        delete from sys_oper_log where method is null or method &lt;&gt; 'compare-report.delete'
+        delete from sys_oper_log where method is null or method not in ('compare-report.delete', 'formation-report.delete')
     </update>
 
 </mapper>

--
Gitblit v1.7.1