| | |
| | | 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; } |
| | | } |