From a971e41526802625338a42885da3501af75bb79b Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 07 九月 2026 21:06:26 +0800
Subject: [PATCH] 补齐拼接共同电池统计与独立字段模板

---
 ruoyi-admin/src/main/resources/db/migration-final/V19__splice_fields_and_action_permissions.sql |   10 +
 battery-business/src/main/java/com/battery/business/report/SpliceField.java                     |   24 +++
 ruoyi-admin/src/main/java/com/battery/business/web/SpliceFieldTemplateController.java           |   31 ++++
 battery-business/src/main/java/com/battery/business/report/SpliceReportService.java             |  260 ++++++++++++++++++++++++++++++++++---
 battery-business/src/main/java/com/battery/business/report/SpliceFieldTemplateService.java      |   92 +++++++++++++
 5 files changed, 396 insertions(+), 21 deletions(-)

diff --git a/battery-business/src/main/java/com/battery/business/report/SpliceField.java b/battery-business/src/main/java/com/battery/business/report/SpliceField.java
new file mode 100644
index 0000000..b5f3112
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/report/SpliceField.java
@@ -0,0 +1,24 @@
+package com.battery.business.report;
+
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.ruoyi.common.exception.ServiceException;
+
+/** 拼接详情字段只描述身份、顺序与展示语义,不携带测试或数据值。 */
+public record SpliceField(String name, String category, String unit, Long fieldDefinitionId, String displayName)
+{
+    public SpliceField
+    {
+        if (name == null || name.isBlank() || name.length() > 100
+                || !("battery".equals(category) || "special".equals(category)))
+            throw new ServiceException("拼接字段名称或分类无效");
+        unit = unit == null ? "" : unit.trim();
+        displayName = displayName == null ? name : displayName.trim();
+        if (unit.length() > 30 || displayName.isBlank() || displayName.length() > 100
+                || (fieldDefinitionId != null && (fieldDefinitionId <= 0 || !"battery".equals(category))))
+            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/SpliceFieldTemplateService.java b/battery-business/src/main/java/com/battery/business/report/SpliceFieldTemplateService.java
new file mode 100644
index 0000000..92027f9
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/report/SpliceFieldTemplateService.java
@@ -0,0 +1,92 @@
+package com.battery.business.report;
+
+import java.sql.Timestamp;
+import java.util.HashSet;
+import java.util.List;
+import javax.sql.DataSource;
+import com.alibaba.fastjson2.JSON;
+import com.battery.business.domain.TBatteryGroupField;
+import com.battery.business.service.ITBatteryGroupFieldService;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+import com.ruoyi.common.exception.ServiceException;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.*;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.stereotype.Service;
+
+/** 拼接模板独立存储,应用与修改均不回写任何已保存报表。 */
+@Service
+public class SpliceFieldTemplateService
+{
+    private final JdbcTemplate jdbc;
+    private final ITBatteryGroupFieldService definitions;
+    public SpliceFieldTemplateService(DataSource source, ITBatteryGroupFieldService definitions)
+    { jdbc = new JdbcTemplate(source); this.definitions = definitions; }
+
+    public record Template(long id, String name, String type, List<SpliceField> fields, String creator, long updatedAt) { }
+    public record Request(@NotBlank @Size(max = 100) String name,
+            @NotNull @Size(min = 1, max = 100) List<@NotNull @Valid SpliceField> fields)
+    {
+        /** 模板不能保存报表来源或人工填写值。 */
+        @JsonAnySetter
+        public void rejectUnknown(String field, Object value) { throw new ServiceException("拼接模板不允许提交属性:" + field); }
+    }
+
+    /** 校验字段身份;普通编辑只使用冻结身份,不依赖仍然存在的共享定义。 */
+    public List<SpliceField> validate(List<SpliceField> fields, boolean resolveDefinitions)
+    {
+        if (fields == null || 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.name())) throw new ServiceException("拼接字段不能重复");
+            Long id = field.fieldDefinitionId();
+            if ("battery".equals(field.category()) && resolveDefinitions)
+                id = current.stream().filter(item -> field.fieldDefinitionId() == null
+                        ? item.getFieldName().equals(field.name()) : item.getFieldId().equals(field.fieldDefinitionId()))
+                    .findFirst().orElseThrow(() -> new ServiceException("共享字段不存在:" + field.name())).getFieldId();
+            if (id != null && !ids.add(id)) throw new ServiceException("共享字段不能重复");
+            return new SpliceField(field.name(), field.category(), field.unit(), id, field.displayName());
+        }).toList();
+    }
+
+    /** 返回拼接所属类型的全部模板,保持字段保存顺序。 */
+    public List<Template> list()
+    {
+        return jdbc.query("select template_id,template_name,fields_json,creator_name,updated_at_utc from t_splice_report_field_template where template_type='splice' order by template_name,template_id",
+                (rs, index) -> new Template(rs.getLong(1), rs.getString(2), "splice", JSON.parseArray(rs.getString(3), SpliceField.class), rs.getString(4), rs.getTimestamp(5).getTime()));
+    }
+
+    /** 新增仅含字段结构的拼接模板,同名模板须显式更新。 */
+    public long create(Request request, long userId, String userName)
+    {
+        var fields = validate(request.fields(), true);
+        var key = new GeneratedKeyHolder();
+        try
+        {
+            jdbc.update(connection -> {
+                var statement = connection.prepareStatement("insert into t_splice_report_field_template(template_name,template_type,fields_json,creator_id,creator_name,created_at_utc,updated_at_utc) values(?,'splice',?,?,?,?,?)", 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 = validate(request.fields(), true);
+        try
+        {
+            if (jdbc.update("update t_splice_report_field_template set template_name=?,fields_json=?,updated_at_utc=? where template_id=? and template_type='splice'",
+                    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/SpliceReportService.java b/battery-business/src/main/java/com/battery/business/report/SpliceReportService.java
index 90d092f..1ad9ec5 100644
--- a/battery-business/src/main/java/com/battery/business/report/SpliceReportService.java
+++ b/battery-business/src/main/java/com/battery/business/report/SpliceReportService.java
@@ -1,29 +1,247 @@
 package com.battery.business.report;
 
-import java.math.BigDecimal; import java.math.MathContext; import java.util.*;
-import com.alibaba.fastjson2.*; import com.battery.business.domain.*; import com.battery.business.service.*;
-import com.ruoyi.common.exception.ServiceException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional;
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.util.*;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.battery.business.domain.CurrentBatteryField;
+import com.battery.business.domain.TTest;
+import com.battery.business.domain.TTestBattery;
+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 SpliceReportService {
- private final ITTestService tests; private final ITestDataQueryService data; private final SpliceReportRepository reports;
- public SpliceReportService(ITTestService t, ITestDataQueryService d, SpliceReportRepository r){tests=t;data=d;reports=r;}
+/** 拼接报表按测试顺序接续循环,工步与循环指标分别读取对应的当前有效记录。 */
+@Service
+public class SpliceReportService {
+ private final ITTestService tests;
+ private final ITestDataQueryService data;
+ private final SpliceReportRepository reports;
+ private final SpliceFieldTemplateService templates;
+ public SpliceReportService(ITTestService tests, ITestDataQueryService data, SpliceReportRepository reports, SpliceFieldTemplateService templates) {
+  this.tests = tests; this.data = data; this.reports = reports; this.templates = templates;
+ }
  public record Candidate(SpliceReportRequest.Selection selection,String name,String type,long groupId,String group,String batteryNo,long startedAt){}
  public record Matrix(List<Map<String,Object>> segments,List<Integer> cycleNos){}
- public List<Candidate> sources(){ TTest q=new TTest(); q.setTestStatus("COMPLETED"); List<Candidate> out=new ArrayList<>(); for(TTest s:tests.selectTestList(q)){TTest t=tests.selectTestById(s.getTestId()); for(TTestBattery b:t.getBatteries()) out.add(candidate(t,b));} return out; }
- public Matrix preview(List<SpliceReportRequest.Selection> selections){
-  if(selections==null||selections.size()<2||new HashSet<>(selections).size()!=selections.size()) throw new ServiceException("请选择至少两个不重复的检测完成测试电池");
-  List<Map<String,Object>> segments=new ArrayList<>(); Set<Long> groups=new HashSet<>(); Set<String> batteries=new HashSet<>(); int offset=0; long group=0; String commonBattery=null;
-  for(var sel:selections){TTest t=tests.selectTestById(sel.testId()); if(t==null||!"COMPLETED".equals(t.getTestStatus())) throw new ServiceException("仅可选择检测完成测试"); TTestBattery b=t.getBatteries().stream().filter(x->sel.testBatteryId().equals(x.getTestBatteryId())).findFirst().orElseThrow(()->new ServiceException("电池不属于所选测试")); if(group==0)group=b.getGroupId(); if(group!=b.getGroupId()) throw new ServiceException("拼接测试必须属于同一电池组"); if(commonBattery==null)commonBattery=b.getBatteryNo(); if(!commonBattery.equals(b.getBatteryNo())) throw new ServiceException("拼接测试必须选择共同参与的电池号"); groups.add(b.getGroupId()); batteries.add(b.getBatteryNo());
-   List<Map<String,Object>> rows=data.cycles(sel.testId()).stream().filter(r->Objects.equals(r.get("batteryNo"),b.getBatteryNo())).toList(); List<Integer> nos=rows.stream().map(this::cycle).filter(x->x>0).distinct().sorted().toList(); List<Map<String,Object>> stepRows=data.steps(sel.testId()).stream().filter(r->Objects.equals(r.get("batteryNo"),b.getBatteryNo())).toList(); Map<String,Object> seg=new LinkedHashMap<>(); seg.put("source",candidate(t,b)); seg.put("offset",offset); seg.put("cycles",rows); seg.put("originalCycleNos",nos); seg.put("statistics",statistics(rows,stepRows,nos)); segments.add(seg); offset += nos.isEmpty()?0:nos.get(nos.size()-1); }
-  if(batteries.size()==0) throw new ServiceException("未找到共同参与电池"); return new Matrix(segments,java.util.stream.IntStream.range(1,offset+1).boxed().toList()); }
- private Map<String,Object> statistics(List<Map<String,Object>> rows,List<Map<String,Object>> stepRows,List<Integer> nos){Map<String,Object> out=new LinkedHashMap<>(); for(int no:nos){var m=rows.stream().filter(r->cycle(r)==no).toList(); Map<String,Object> v=new LinkedHashMap<>(); if(m.size()==1){Map<String,Object> row=m.get(0); for(String k:List.of("chargeEnergy","dischargeEnergy","chargeCapacity","dischargeCapacity")) if(row.get(k)!=null)v.put(k,row.get(k)); List<Map<String,Object>> steps=stepRows.stream().filter(r->cycle(r)==no).toList(); Object end=steps.stream().map(r->r.get("endVoltage")).filter(Objects::nonNull).reduce((a,b)->b).orElse(null); if(end!=null)v.put("finalVoltage1",end); Number c=num(v.get("chargeEnergy")),d=num(v.get("dischargeEnergy")); if(c!=null&&d!=null&&c.doubleValue()>0)v.put("energyEfficiency",BigDecimal.valueOf(d.doubleValue()).multiply(BigDecimal.valueOf(100)).divide(BigDecimal.valueOf(c.doubleValue()),MathContext.DECIMAL128));} out.put(Integer.toString(no),v);} return out;}
- private Number num(Object x){return x instanceof Number?n((Number)x):null;} private Number n(Number x){return x;}
- @Transactional public Map<String,Object> create(SpliceReportRequest r,long uid,String user){Matrix m=preview(r.selections()); Map<String,Object> snap=new LinkedHashMap<>(); snap.put("name",r.name().trim());snap.put("description",r.description());snap.put("selections",r.selections());snap.put("segments",m.segments());snap.put("cycleNos",m.cycleNos());snap.put("settings",r.settings()); return get(reports.insert(r.name().trim(),JSON.toJSONString(snap),uid,user,System.currentTimeMillis()),null);}
- public Map<String,Object> get(long id,Integer version){var v=reports.get(id,version);Map<String,Object> x=JSON.parseObject(v.snapshotJson());x.put("id",id);x.put("versionNo",v.versionNo());x.put("creator",v.report().creator());x.put("createdAt",v.report().createdAt());x.put("operatorName",v.operatorName());x.put("versionCreatedAt",v.versionCreatedAt());x.put("changeSummary",v.changeSummary());return x;}
- @Transactional public Map<String,Object> edit(long id,SpliceReportRequest.Edit r,long uid,String user){reports.lock(id);var v=reports.get(id);if(v.versionNo()!=r.baseVersionNo())throw new ServiceException("报表已有新版本,请重新打开后编辑");var x=JSON.parseObject(v.snapshotJson());x.put("name",r.name().trim());x.put("description",r.description());x.put("settings",r.settings());reports.append(id,r.baseVersionNo()+1,r.name().trim(),x.toJSONString(),uid,user,r.changeSummary().trim());return get(id,null);}
- @Transactional public Map<String,Object> refresh(long id,int base,String summary,long uid,String user){reports.lock(id);var v=reports.get(id);if(v.versionNo()!=base)throw new ServiceException("报表已有新版本,请重新打开后编辑");var x=JSON.parseObject(v.snapshotJson());var selections=JSON.parseArray(x.getString("selections"),SpliceReportRequest.Selection.class);Matrix m=preview(selections);x.put("segments",m.segments());x.put("cycleNos",m.cycleNos());reports.append(id,base+1,x.getString("name"),x.toJSONString(),uid,user,"按最新来源生成:"+summary);return get(id,null);}
+ /** 仅返回检测完成测试的真实参与电池,默认按开始时间及测试身份排序。 */
+ public List<Candidate> sources() {
+  TTest query = new TTest(); query.setTestStatus("COMPLETED");
+  List<Candidate> result = new ArrayList<>();
+  for (TTest summary : tests.selectTestList(query)) {
+   TTest test = tests.selectTestById(summary.getTestId());
+   for (TTestBattery battery : test.getBatteries()) result.add(candidate(test, battery));
+  }
+  return result.stream().sorted(Comparator.comparingLong(Candidate::startedAt)
+      .thenComparingLong(value -> value.selection().testId())).toList();
+ }
+ /** 校验至少两个同组测试选择相同的参与电池集合,按提交顺序接续真实循环。 */
+ public Matrix preview(List<SpliceReportRequest.Selection> selections) {
+  if (selections == null || selections.size() < 2 || selections.contains(null)
+      || new HashSet<>(selections).size() != selections.size())
+   throw new ServiceException("请选择至少两个不重复的检测完成测试电池");
+  Map<Long, TTest> loaded = new LinkedHashMap<>();
+  Map<Long, List<TTestBattery>> selected = new LinkedHashMap<>();
+  Long group = null;
+  for (var selection : selections) {
+   if (selection.testId() == null || selection.testBatteryId() == null) throw new ServiceException("测试和电池身份不能为空");
+   TTest test = loaded.computeIfAbsent(selection.testId(), tests::selectTestById);
+   if (test == null || !"COMPLETED".equals(test.getTestStatus())) throw new ServiceException("仅可选择检测完成测试");
+   TTestBattery battery = test.getBatteries().stream().filter(row -> selection.testBatteryId().equals(row.getTestBatteryId()))
+       .findFirst().orElseThrow(() -> new ServiceException("电池不属于所选测试"));
+   if (group == null) group = battery.getGroupId();
+   if (!Objects.equals(group, battery.getGroupId())) throw new ServiceException("拼接测试必须属于同一电池组");
+   selected.computeIfAbsent(selection.testId(), key -> new ArrayList<>()).add(battery);
+  }
+  if (selected.size() < 2) throw new ServiceException("请至少选择两个不同测试");
+  Set<String> common = null;
+  for (var batteries : selected.values()) {
+   Set<String> names = new HashSet<>(batteries.stream().map(TTestBattery::getBatteryNo).toList());
+   if (common == null) common = names;
+   if (names.size() != batteries.size() || !common.equals(names)) throw new ServiceException("拼接测试必须选择相同的共同参与电池集合");
+  }
+  List<Map<String, Object>> segments = new ArrayList<>();
+  Set<Integer> mappedCycles = new TreeSet<>();
+  Integer previousEnd = null;
+  for (var entry : selected.entrySet()) {
+   List<Map<String, Object>> cycles = data.cycles(entry.getKey()), steps = data.steps(entry.getKey()), records = data.records(entry.getKey());
+   List<Map<String, Object>> current = new ArrayList<>();
+   Set<Integer> sourceCycles = new TreeSet<>();
+   for (TTestBattery battery : entry.getValue()) {
+    var rows = batteryRows(cycles, battery);
+    var stepRows = batteryRows(steps, battery);
+    var nos = java.util.stream.Stream.of(rows, stepRows, batteryRows(records, battery)).flatMap(List::stream)
+        .map(this::cycle).filter(no -> no > 0).distinct().sorted().toList();
+    Map<String, Object> segment = new LinkedHashMap<>();
+    segment.put("source", candidate(loaded.get(entry.getKey()), battery));
+    segment.put("cycles", rows); segment.put("steps", stepRows);
+    segment.put("businessFields", battery.getFields());
+    segment.put("originalCycleNos", nos); segment.put("statistics", statistics(rows, stepRows, nos));
+    current.add(segment); sourceCycles.addAll(nos);
+   }
+   // 同一测试的全部电池共用偏移;首个非空段保留原号,后段首循环紧接前段末循环。
+   int offset = previousEnd == null || sourceCycles.isEmpty() ? 0 : previousEnd + 1 - sourceCycles.iterator().next();
+   current.forEach(segment -> segment.put("offset", offset));
+   for (int no : sourceCycles) mappedCycles.add(no + offset);
+   if (!sourceCycles.isEmpty()) previousEnd = Collections.max(sourceCycles) + offset;
+   segments.addAll(current);
+  }
+  return new Matrix(segments, new ArrayList<>(mappedCycles));
+ }
+ private List<Map<String, Object>> batteryRows(List<Map<String, Object>> rows, TTestBattery battery) {
+  return rows.stream().filter(row -> Objects.equals(row.get("batteryNo"), battery.getBatteryNo())).toList();
+ }
+ private Map<String,Object> statistics(List<Map<String,Object>> rows, List<Map<String,Object>> stepRows, List<Integer> nos) {
+  Map<String,Object> result = new LinkedHashMap<>();
+  for (int no : nos) {
+   Map<String,Object> values = new LinkedHashMap<>();
+   var cycleRows = rows.stream().filter(row -> cycle(row) == no).toList();
+   if (cycleRows.size() == 1) {
+    for (String key : List.of("chargeEnergy", "dischargeEnergy", "chargeCapacity", "dischargeCapacity"))
+     if (cycleRows.get(0).get(key) != null) values.put(key, cycleRows.get(0).get(key));
+    Number charge = num(values.get("chargeEnergy")), discharge = num(values.get("dischargeEnergy"));
+    if (charge != null && discharge != null && charge.doubleValue() > 0)
+     values.put("energyEfficiency", new BigDecimal(discharge.toString()).multiply(BigDecimal.valueOf(100))
+         .divide(new BigDecimal(charge.toString()), MathContext.DECIMAL128));
+   }
+   // 查询接口按实际发生顺序返回工步;先选末记录再取值,空值不得向前回退。
+   var actualSteps = stepRows.stream().filter(row -> cycle(row) == no).toList();
+   if (!actualSteps.isEmpty()) values.put("finalVoltage1", actualSteps.get(actualSteps.size() - 1).get("endVoltage"));
+   result.put(Integer.toString(no), values);
+  }
+  return result;
+ }
+ private Number num(Object value) { return value instanceof Number number ? number : null; }
+
+ /** 首次保存时重新读取来源并冻结字段与计算原值,人工调整只写入显示设置。 */
+ @Transactional
+ public Map<String,Object> create(SpliceReportRequest request, long uid, String user) {
+  var settings = settings(request.settings(), true);
+  Matrix matrix = freeze(preview(request.selections()), fields(settings));
+  validateStatistics(settings, matrix.segments());
+  Map<String,Object> snapshot = new LinkedHashMap<>();
+  snapshot.put("name", request.name().trim()); snapshot.put("description", request.description());
+  snapshot.put("selections", request.selections()); snapshot.put("segments", matrix.segments());
+  snapshot.put("cycleNos", matrix.cycleNos()); snapshot.put("settings", settings);
+  return get(reports.insert(request.name().trim(), JSON.toJSONString(snapshot), uid, user, System.currentTimeMillis()), null);
+ }
+
+ /** 当前和历史版本只读独立快照,来源删除后仍可重开。 */
+ public Map<String,Object> get(long id, Integer version) {
+  var saved = reports.get(id, version);
+  Map<String,Object> result = JSON.parseObject(saved.snapshotJson());
+  result.put("id", id); result.put("versionNo", saved.versionNo());
+  result.put("creator", saved.report().creator()); result.put("createdAt", saved.report().createdAt());
+  result.put("operatorName", saved.operatorName()); result.put("versionCreatedAt", saved.versionCreatedAt());
+  result.put("changeSummary", saved.changeSummary());
+  return result;
+ }
+
+ /** 普通编辑只追加展示设置,保留原版本来源、计算原值及字段事实。 */
+ @Transactional
+ public Map<String,Object> edit(long id, SpliceReportRequest.Edit request, long uid, String user) {
+  var snapshot = current(id, request.baseVersionNo());
+  var settings = settings(request.settings(), false);
+  validateStatistics(settings, segments(snapshot));
+  snapshot.put("name", request.name().trim()); snapshot.put("description", request.description()); snapshot.put("settings", settings);
+  reports.append(id, request.baseVersionNo() + 1, request.name().trim(), snapshot.toJSONString(), uid, user, request.changeSummary().trim());
+  return get(id, null);
+ }
+
+ /** 主动按最新来源生成版本,沿用已保存选择及字段结构;来源失效则整次回滚。 */
+ @Transactional
+ public Map<String,Object> refresh(long id, int base, String summary, long uid, String user) {
+  if (summary == null || summary.isBlank() || summary.length() > 500) throw new ServiceException("请填写有效修改说明");
+  var snapshot = current(id, base);
+  var selections = JSON.parseArray(snapshot.getString("selections"), SpliceReportRequest.Selection.class);
+  var settings = settings(snapshot.getJSONObject("settings"), false);
+  Matrix matrix = freeze(preview(selections), fields(settings));
+  var overrides = JSON.parseObject(JSON.toJSONString(settings.getOrDefault("statisticValues", Map.of())));
+  overrides.keySet().retainAll(statisticKeys(matrix.segments()));
+  settings.put("statisticValues", overrides);
+  snapshot.put("settings", settings); snapshot.put("segments", matrix.segments()); snapshot.put("cycleNos", matrix.cycleNos());
+  reports.append(id, base + 1, snapshot.getString("name"), snapshot.toJSONString(), uid, user, "按最新来源生成:" + summary.trim());
+  return get(id, null);
+ }
+
  /** 整份报表及全部版本与最小删除日志在同一事务中提交,日志失败时保留报表。 */
  @Transactional
- public void delete(long id,long uid,String user){reports.lock(id);reports.delete(id,uid,user);} private Candidate candidate(TTest t,TTestBattery b){return new Candidate(new SpliceReportRequest.Selection(t.getTestId(),b.getTestBatteryId()),t.getTestName(),t.getTestTypeName(),b.getGroupId(),b.getGroupName(),b.getBatteryNo(),t.getStartedAtUtc()==null?0:t.getStartedAtUtc().getTime());} private int cycle(Map<String,Object> r){return r.get("cycleNo") instanceof Number n?n.intValue():0;}
+ public void delete(long id, long uid, String user) { reports.lock(id); reports.delete(id, uid, user); }
+
+ private JSONObject current(long id, int base) {
+  // 与删除共用身份锁,拒绝从陈旧基准覆盖较新的版本。
+  reports.lock(id);
+  var saved = reports.get(id);
+  if (saved.versionNo() != base) throw new ServiceException("报表已有新版本,请重新打开后编辑");
+  return JSON.parseObject(saved.snapshotJson());
+ }
+
+ private Map<String,Object> settings(Map<String,Object> input, boolean resolveDefinitions) {
+  var result = new LinkedHashMap<>(input);
+  List<SpliceField> structure;
+  if (input.containsKey("fieldStructure")) structure = fields(input);
+  else {
+   structure = new ArrayList<>();
+   for (String key : List.of("batteryFields", "specialFields")) {
+    var names = JSON.parseArray(JSON.toJSONString(input.getOrDefault(key, List.of())), String.class);
+    for (String name : names) structure.add(new SpliceField(name, key.equals("batteryFields") ? "battery" : "special", "", null, name));
+   }
+  }
+  structure = templates.validate(structure, resolveDefinitions);
+  result.put("fieldStructure", structure);
+  result.put("batteryFields", structure.stream().filter(field -> field.category().equals("battery")).map(SpliceField::name).toList());
+  result.put("specialFields", structure.stream().filter(field -> field.category().equals("special")).map(SpliceField::name).toList());
+  return result;
+ }
+
+ private List<SpliceField> fields(Map<String,Object> settings) {
+  return JSON.parseArray(JSON.toJSONString(settings.get("fieldStructure")), SpliceField.class);
+ }
+
+ private List<Map<String,Object>> segments(JSONObject snapshot) {
+  return snapshot.getJSONArray("segments").stream().map(value -> (Map<String,Object>) value).toList();
+ }
+
+ private Matrix freeze(Matrix matrix, List<SpliceField> fields) {
+  for (var segment : matrix.segments()) {
+   var business = JSON.parseArray(JSON.toJSONString(segment.get("businessFields")), CurrentBatteryField.class);
+   segment.put("businessFields", business.stream().filter(value -> fields.stream().anyMatch(field ->
+       field.category().equals("battery") && (field.fieldDefinitionId() == null
+           ? field.name().equals(value.getFieldName()) : field.fieldDefinitionId().equals(value.getFieldDefinitionId())))).toList());
+  }
+  return matrix;
+ }
+
+ private Set<String> statisticKeys(List<Map<String,Object>> segments) {
+  Set<String> allowed = new HashSet<>();
+  for (var segment : segments) {
+   var source = JSON.parseObject(JSON.toJSONString(segment.get("source")), Candidate.class);
+   var cycles = JSON.parseArray(JSON.toJSONString(segment.get("originalCycleNos")), Integer.class);
+   for (int no : cycles) for (String metric : List.of("finalVoltage1", "energyEfficiency"))
+    allowed.add(source.selection().testId() + "|" + source.selection().testBatteryId() + "|" + no + "|" + metric);
+  }
+  return allowed;
+ }
+
+ private void validateStatistics(Map<String,Object> settings, List<Map<String,Object>> segments) {
+  Object submitted = settings.get("statisticValues");
+  if (submitted == null) return;
+  if (!(submitted instanceof Map<?,?> values)) throw new ServiceException("人工指标格式无效");
+  Set<String> allowed = statisticKeys(segments);
+  for (var entry : values.entrySet()) {
+   if (!allowed.contains(entry.getKey())) throw new ServiceException("人工指标不属于当前报表循环");
+   if (entry.getValue() == null || entry.getValue().toString().isBlank()) continue;
+   try { new BigDecimal(entry.getValue().toString()); }
+   catch (NumberFormatException error) { throw new ServiceException("人工指标必须为数值或留空"); }
+  }
+ }
+
+ private Candidate candidate(TTest test, TTestBattery battery) {
+  return new Candidate(new SpliceReportRequest.Selection(test.getTestId(), battery.getTestBatteryId()),
+      test.getTestName(), test.getTestTypeName(), battery.getGroupId(), battery.getGroupName(), battery.getBatteryNo(),
+      test.getStartedAtUtc() == null ? 0 : test.getStartedAtUtc().getTime());
+ }
+ private int cycle(Map<String,Object> row) { return row.get("cycleNo") instanceof Number number ? number.intValue() : 0; }
 }
diff --git a/ruoyi-admin/src/main/java/com/battery/business/web/SpliceFieldTemplateController.java b/ruoyi-admin/src/main/java/com/battery/business/web/SpliceFieldTemplateController.java
new file mode 100644
index 0000000..fcb7456
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/battery/business/web/SpliceFieldTemplateController.java
@@ -0,0 +1,31 @@
+package com.battery.business.web;
+
+import com.battery.business.report.SpliceFieldTemplateService;
+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/splice-report/templates")
+public class SpliceFieldTemplateController extends BaseController
+{
+    private final SpliceFieldTemplateService service;
+    public SpliceFieldTemplateController(SpliceFieldTemplateService service) { this.service = service; }
+
+    /** 查看、新增或编辑权限均可读取拼接模板。 */
+    @GetMapping @PreAuthorize("@ss.hasAnyPermi('system:splice-report:query,system:splice-report:add,system:splice-report:edit')")
+    public AjaxResult list() { return success(service.list()); }
+
+    /** 新增只接收字段结构。 */
+    @PostMapping @PreAuthorize("@ss.hasPermi('system:splice-report:add')")
+    public AjaxResult create(@Valid @RequestBody SpliceFieldTemplateService.Request request)
+    { return success(service.create(request, getUserId(), getUsername())); }
+
+    /** 显式更新不会改变已保存版本。 */
+    @PutMapping("/{id}") @PreAuthorize("@ss.hasPermi('system:splice-report:edit')")
+    public AjaxResult update(@PathVariable long id, @Valid @RequestBody SpliceFieldTemplateService.Request request)
+    { service.update(id, request); return success(); }
+}
diff --git a/ruoyi-admin/src/main/resources/db/migration-final/V19__splice_fields_and_action_permissions.sql b/ruoyi-admin/src/main/resources/db/migration-final/V19__splice_fields_and_action_permissions.sql
new file mode 100644
index 0000000..2594e36
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration-final/V19__splice_fields_and_action_permissions.sql
@@ -0,0 +1,10 @@
+-- 隔离库核对 V18 仅有查看与导出;新增迁移补齐动作及独立模板,不改写历史。
+CREATE TABLE t_splice_report_field_template LIKE t_compare_report_field_template;
+ALTER TABLE t_splice_report_field_template
+    ALTER COLUMN template_type SET DEFAULT 'splice', COMMENT='拼接报表字段模板';
+
+SET @splice_action_menu_id = (SELECT menu_id FROM sys_menu WHERE path='report-splice' AND menu_type='C' LIMIT 1);
+INSERT INTO sys_menu(menu_name,parent_id,order_num,path,menu_type,visible,status,perms,create_by,create_time)
+VALUES ('新增',@splice_action_menu_id,2,'#','F','0','0','system:splice-report:add','system',UTC_TIMESTAMP(3)),
+       ('编辑',@splice_action_menu_id,3,'#','F','0','0','system:splice-report:edit','system',UTC_TIMESTAMP(3)),
+       ('删除',@splice_action_menu_id,4,'#','F','0','0','system:splice-report:remove','system',UTC_TIMESTAMP(3));

--
Gitblit v1.7.1