新增功能:统计与分析

feature-wangxilei-dev
wxl 17 hours ago
parent 59b54db9f6
commit 0170907f6d
  1. 18
      lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/StatsSampleTag.java
  2. 2
      lab-service/lab-lims/src/main/java/org/springblade/lims/LimsApplication.java
  3. 59
      lab-service/lab-lims/src/main/java/org/springblade/lims/excel/StatsAnalysisExportExcel.java
  4. 93
      lab-service/lab-lims/src/main/java/org/springblade/stats/controller/StatsAnalysisController.java
  5. 80
      lab-service/lab-lims/src/main/java/org/springblade/stats/controller/StatsSampleTagController.java
  6. 26
      lab-service/lab-lims/src/main/java/org/springblade/stats/dto/StatsAnalysisQuery.java
  7. 7
      lab-service/lab-lims/src/main/java/org/springblade/stats/mapper/StatsSampleTagMapper.java
  8. 63
      lab-service/lab-lims/src/main/java/org/springblade/stats/service/IStatsAnalysisService.java
  9. 23
      lab-service/lab-lims/src/main/java/org/springblade/stats/service/IStatsSampleTagService.java
  10. 442
      lab-service/lab-lims/src/main/java/org/springblade/stats/service/impl/StatsAnalysisServiceImpl.java
  11. 24
      lab-service/lab-lims/src/main/java/org/springblade/stats/service/impl/StatsSampleTagServiceImpl.java
  12. 43
      lab-service/lab-lims/src/main/java/org/springblade/stats/util/StatsExcelUtil.java
  13. 218
      lab-service/lab-lims/src/main/java/org/springblade/stats/util/StatsJsonParser.java
  14. 46
      lab-service/lab-lims/src/main/java/org/springblade/stats/vo/StatsAnalysisVO.java

@ -0,0 +1,18 @@
package org.springblade.lims.entry;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.springblade.core.mp.base.BaseEntity;
import java.io.Serializable;
/**
*/
@Data
@SuppressWarnings("all")
@TableName("t_stats_sample_tag")
public class StatsSampleTag extends BaseEntity implements Serializable {
private Long sampleSetId;
private String tagName;
}

@ -6,6 +6,7 @@ import org.springblade.core.cloud.feign.EnableBladeFeign;
import org.springblade.core.launch.BladeApplication; import org.springblade.core.launch.BladeApplication;
import org.springframework.cloud.client.SpringCloudApplication; import org.springframework.cloud.client.SpringCloudApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
@ -15,6 +16,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
*/ */
@EnableBladeFeign @EnableBladeFeign
@SpringCloudApplication @SpringCloudApplication
@ComponentScan({"org.springblade.lims", "org.springblade.stats"})
@EnableScheduling @EnableScheduling
public class LimsApplication { public class LimsApplication {

@ -0,0 +1,59 @@
package org.springblade.lims.excel;
import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;
import java.io.Serializable;
/**
* 统计分析导出 Excel DTO
*
* @author blade
* @since 2026-05-29
*/
@Data
public class StatsAnalysisExportExcel implements Serializable {
private static final long serialVersionUID = 1L;
@ExcelProperty("地区/场")
private String regionName;
@ExcelProperty("采样数")
private Integer totalSamples;
@ExcelProperty("阳性数")
private Integer positiveCount;
@ExcelProperty("阳性率")
private String positiveRate;
@ExcelProperty("均值(Mean)")
private String meanValue;
@ExcelProperty("标准差(SD)")
private String stdDev;
@ExcelProperty("变异系数(CV)")
private String cv;
@ExcelProperty("标准误(SE)")
private String se;
@ExcelProperty("受理编号")
private String acceptanceNum;
@ExcelProperty("动物种类")
private String animalType;
@ExcelProperty("免疫情况")
private String immuneSituation;
@ExcelProperty("检测项目")
private String examineItem;
@ExcelProperty("检测方法")
private String examineMethod;
@ExcelProperty("样品来源")
private String simpleSource;
}

@ -0,0 +1,93 @@
package org.springblade.stats.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.excel.util.ExcelUtil;
import org.springblade.core.mp.support.Query;
import org.springblade.core.tool.api.R;
import org.springblade.lims.excel.StatsAnalysisExportExcel;
import org.springblade.stats.dto.StatsAnalysisQuery;
import org.springblade.stats.service.IStatsAnalysisService;
import org.springblade.stats.util.StatsExcelUtil;
import org.springblade.stats.vo.StatsAnalysisVO;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 统计分析 控制器
*
* @author blade
* @since 2026-05-29
*/
@Slf4j
@RestController
@AllArgsConstructor
@RequestMapping("/stats/analysis")
@Api(value = "统计分析", tags = "统计分析")
public class StatsAnalysisController extends BladeController {
private final IStatsAnalysisService service;
/**
* 城市级统计列表
*/
@GetMapping("/list")
@ApiOperation(value = "城市级统计列表", notes = "传入筛选条件和分页参数,返回城市级聚合数据")
public R<IPage<StatsAnalysisVO>> list(StatsAnalysisQuery query, Query pageQuery) {
return R.data(service.queryCityLevel(query, pageQuery));
}
/**
* 区县级统计
*/
@GetMapping("/district")
@ApiOperation(value = "区县级统计", notes = "传入城市编码,返回该城市下各区县的数据")
public R<IPage<StatsAnalysisVO>> district(@ApiParam(value = "城市编码") @RequestParam String cityCode,
StatsAnalysisQuery query, Query pageQuery) {
return R.data(service.queryDistrictLevel(cityCode, query, pageQuery));
}
/**
* 被检单位级统计
*/
@GetMapping("/customer")
@ApiOperation(value = "被检单位级统计", notes = "传入区县编码,返回该区县下各被检单位的数据")
public R<IPage<StatsAnalysisVO>> customer(@ApiParam(value = "区县编码") @RequestParam String districtCode,
StatsAnalysisQuery query, Query pageQuery) {
return R.data(service.queryCustomerLevel(districtCode, query, pageQuery));
}
/**
* 整体统计数据用于 summary cards
*/
@GetMapping("/overall")
@ApiOperation(value = "整体统计数据", notes = "返回汇总统计数据,用于首页概览卡片展示")
public R<StatsAnalysisVO> overall(StatsAnalysisQuery query) {
return R.data(service.queryOverallStats(query));
}
/**
* 导出 Excel
*/
@GetMapping("/export")
@ApiOperation(value = "导出Excel", notes = "按指定级别导出统计结果(city/district/customer)")
public void export(StatsAnalysisQuery query,
@ApiParam(value = "聚合级别: city/district/customer", required = true) @RequestParam String level,
HttpServletResponse response) {
try {
List<StatsAnalysisVO> data = service.exportData(query, level);
List<StatsAnalysisExportExcel> exportList = StatsExcelUtil.toExportList(data);
ExcelUtil.export(response, "统计分析结果", "统计分析", exportList, StatsAnalysisExportExcel.class);
} catch (Exception e) {
log.error("导出Excel失败", e);
}
}
}

@ -0,0 +1,80 @@
package org.springblade.stats.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.lims.entry.StatsSampleTag;
import org.springblade.stats.service.IStatsSampleTagService;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
/**
* 样品标签 控制器
*
* @author swj
* @since 2026-05-29
*/
@RestController
@AllArgsConstructor
@RequestMapping("/stats/sampleTag")
@Api(value = "样品标签", tags = "样品标签")
public class StatsSampleTagController extends BladeController {
private final IStatsSampleTagService service;
/**
* 分页列表
*/
@GetMapping("/list")
@ApiOperation(value = "分页", notes = "传入sampleSetId")
public R<IPage<StatsSampleTag>> list(@ApiParam(value = "样品集ID") Long sampleSetId, Query query) {
LambdaQueryWrapper<StatsSampleTag> wrapper = new LambdaQueryWrapper<StatsSampleTag>()
.eq(StatsSampleTag::getSampleSetId, sampleSetId);
IPage<StatsSampleTag> page = service.page(Condition.getPage(query), wrapper);
return R.data(page);
}
/**
* 新增
*/
@PostMapping("/save")
@ApiOperation(value = "新增", notes = "传入StatsSampleTag")
public R save(@RequestBody StatsSampleTag entry) {
// 检查重复标签
List<StatsSampleTag> existing = service.listBySampleSetId(entry.getSampleSetId());
boolean isDuplicate = existing.stream().anyMatch(t -> t.getTagName().equals(entry.getTagName()));
if (isDuplicate) {
return R.fail("标签已存在");
}
return R.status(service.save(entry));
}
/**
* 删除
*/
@PostMapping("/deleteByIds")
@ApiOperation(value = "逻辑删除", notes = "传入ids")
public R deleteByIds(@ApiParam(value = "主键集合", required = true) @RequestParam String ids) {
return R.status(service.deleteLogic(Func.toLongList(ids)));
}
/**
* 标签字典
*/
@GetMapping("/tagDict")
@ApiOperation(value = "标签字典", notes = "返回预定义的标签列表")
public R<List<String>> tagDict() {
List<String> tagList = Arrays.asList("紧急", "常规", "复检", "重点监控", "可疑");
return R.data(tagList);
}
}

@ -0,0 +1,26 @@
package org.springblade.stats.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 统计分析查询 DTO
*
* @author blade
* @since 2026-05-29
*/
@Data
public class StatsAnalysisQuery implements Serializable {
private static final long serialVersionUID = 1L;
/** 样品来源 */
private String simpleSource;
/** 动物种类 */
private String animalType;
/** 免疫情况 */
private String immuneSituation;
/** 开始时间 (yyyy-MM-dd) */
private String startTime;
/** 结束时间 (yyyy-MM-dd) */
private String endTime;
}

@ -0,0 +1,7 @@
package org.springblade.stats.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springblade.lims.entry.StatsSampleTag;
public interface StatsSampleTagMapper extends BaseMapper<StatsSampleTag> {
}

@ -0,0 +1,63 @@
package org.springblade.stats.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.support.Query;
import org.springblade.stats.dto.StatsAnalysisQuery;
import org.springblade.stats.vo.StatsAnalysisVO;
import java.util.List;
/**
* 统计分析服务接口
*
* @author blade
* @since 2026-05-29
*/
public interface IStatsAnalysisService {
/**
* 城市级聚合查询
*
* @param query 筛选条件
* @param pageQuery 分页参数
* @return 分页的城市级统计结果
*/
IPage<StatsAnalysisVO> queryCityLevel(StatsAnalysisQuery query, Query pageQuery);
/**
* 区县级聚合查询
*
* @param cityCode 城市编码
* @param query 筛选条件
* @param pageQuery 分页参数
* @return 分页的区县级统计结果
*/
IPage<StatsAnalysisVO> queryDistrictLevel(String cityCode, StatsAnalysisQuery query, Query pageQuery);
/**
* 被检单位级聚合查询
*
* @param districtCode 区县编码
* @param query 筛选条件
* @param pageQuery 分页参数
* @return 分页的被检单位级统计结果
*/
IPage<StatsAnalysisVO> queryCustomerLevel(String districtCode, StatsAnalysisQuery query, Query pageQuery);
/**
* 导出数据不分页
*
* @param query 筛选条件
* @param level 聚合级别: city/district/customer
* @return 全部统计结果列表
*/
List<StatsAnalysisVO> exportData(StatsAnalysisQuery query, String level);
/**
* 整体统计数据用于 summary cards
*
* @param query 筛选条件
* @return 总体统计结果
*/
StatsAnalysisVO queryOverallStats(StatsAnalysisQuery query);
}

@ -0,0 +1,23 @@
package org.springblade.stats.service;
import org.springblade.core.mp.base.BaseService;
import org.springblade.lims.entry.StatsSampleTag;
import java.util.List;
/**
* 样品标签 服务类
*
* @author swj
* @since 2026-05-29
*/
public interface IStatsSampleTagService extends BaseService<StatsSampleTag> {
/**
* 根据样品集ID查询标签列表
*
* @param sampleSetId 样品集ID
* @return 标签列表
*/
List<StatsSampleTag> listBySampleSetId(Long sampleSetId);
}

@ -0,0 +1,442 @@
package org.springblade.stats.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springblade.core.mp.support.Query;
import org.springblade.lims.entry.Entrust;
import org.springblade.lims.entry.Examine;
import org.springblade.lims.entry.ExamineResult;
import org.springblade.lims.mapper.EntrustMapper;
import org.springblade.lims.mapper.ExamineMapper;
import org.springblade.lims.mapper.ExamineResultMapper;
import org.springblade.stats.dto.StatsAnalysisQuery;
import org.springblade.stats.service.IStatsAnalysisService;
import org.springblade.stats.util.StatsJsonParser;
import org.springblade.stats.vo.StatsAnalysisVO;
import org.springframework.stereotype.Service;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 统计分析服务实现 核心聚合引擎
* <p>
* f_entrust_main / f_examine / f_examine_result 三表查询
* 使用 StatsJsonParser 解析 originRecordResult JSON
* Java 端按城市/区县/被检单位分组聚合计算 Mean / SD / CV / SE
* </p>
*
* @author blade
* @since 2026-05-29
*/
@Slf4j
@Service
@AllArgsConstructor
public class StatsAnalysisServiceImpl implements IStatsAnalysisService {
private final EntrustMapper entrustMapper;
private final ExamineMapper examineMapper;
private final ExamineResultMapper examineResultMapper;
// ==================== Public API ====================
@Override
public IPage<StatsAnalysisVO> queryCityLevel(StatsAnalysisQuery query, Query pageQuery) {
List<RawRecord> records = loadRawRecords(query);
Map<String, StatsAccumulator> cityAccum = new LinkedHashMap<>();
for (RawRecord rec : records) {
if (rec.getCity() == null) continue;
StatsJsonParser.SampleCount sc = StatsJsonParser.countSamples(rec.getOriginRecordResult());
double rate = sc.getTotal() > 0 ? (double) sc.getPositive() / sc.getTotal() * 100 : 0;
cityAccum.computeIfAbsent(rec.getCity(), k ->
new StatsAccumulator(rec.getCity(), rec.getRegionCode(), "city"))
.add(sc.getTotal(), sc.getPositive(), rate);
}
return paginate(computeStats(cityAccum), pageQuery);
}
@Override
public IPage<StatsAnalysisVO> queryDistrictLevel(String cityCode, StatsAnalysisQuery query, Query pageQuery) {
List<RawRecord> records = loadRawRecords(query);
// 筛选出指定城市的记录
List<RawRecord> cityRecords = records.stream()
.filter(r -> r.getCity() != null && r.getCity().equals(cityCode))
.collect(Collectors.toList());
Map<String, StatsAccumulator> districtAccum = new LinkedHashMap<>();
for (RawRecord rec : cityRecords) {
// 使用 regionCode 作为区县分组键(实际应用中可按 regionCode 前缀匹配字典表)
String districtKey = StringUtils.isNotBlank(rec.getRegionCode()) ? rec.getRegionCode() : "未知区县";
StatsJsonParser.SampleCount sc = StatsJsonParser.countSamples(rec.getOriginRecordResult());
double rate = sc.getTotal() > 0 ? (double) sc.getPositive() / sc.getTotal() * 100 : 0;
districtAccum.computeIfAbsent(districtKey, k ->
new StatsAccumulator(districtKey, rec.getRegionCode(), "district"))
.add(sc.getTotal(), sc.getPositive(), rate);
}
return paginate(computeStats(districtAccum), pageQuery);
}
@Override
public IPage<StatsAnalysisVO> queryCustomerLevel(String districtCode, StatsAnalysisQuery query, Query pageQuery) {
List<RawRecord> records = loadRawRecords(query);
// 筛选出指定区县的记录
List<RawRecord> districtRecords = records.stream()
.filter(r -> r.getRegionCode() != null && r.getRegionCode().equals(districtCode))
.collect(Collectors.toList());
Map<String, StatsAccumulator> customerAccum = new LinkedHashMap<>();
for (RawRecord rec : districtRecords) {
String customerName = StringUtils.isNotBlank(rec.getCustomerName()) ? rec.getCustomerName() : "未知单位";
StatsJsonParser.SampleCount sc = StatsJsonParser.countSamples(rec.getOriginRecordResult());
double rate = sc.getTotal() > 0 ? (double) sc.getPositive() / sc.getTotal() * 100 : 0;
customerAccum.computeIfAbsent(customerName, k ->
new StatsAccumulator(customerName, rec.getRegionCode(), "customer"))
.add(sc.getTotal(), sc.getPositive(), rate);
}
return paginate(computeStats(customerAccum), pageQuery);
}
@Override
public List<StatsAnalysisVO> exportData(StatsAnalysisQuery query, String level) {
List<RawRecord> records = loadRawRecords(query);
if ("city".equals(level)) {
return computeStats(groupByCity(records));
} else if ("district".equals(level)) {
return computeStats(groupByDistrict(records));
} else if ("customer".equals(level)) {
return computeStats(groupByCustomer(records));
}
return new ArrayList<>();
}
@Override
public StatsAnalysisVO queryOverallStats(StatsAnalysisQuery query) {
List<RawRecord> records = loadRawRecords(query);
if (records.isEmpty()) {
StatsAnalysisVO vo = new StatsAnalysisVO();
vo.setRegionName("总计");
vo.setRegionLevel("overall");
vo.setTotalSamples(0);
vo.setPositiveCount(0);
vo.setPositiveRate(0.0);
vo.setMeanValue(0.0);
vo.setStdDev(0.0);
vo.setCv(0.0);
vo.setSe(0.0);
vo.setHasChildren(true);
return vo;
}
int totalSamples = 0;
int positiveCount = 0;
List<Double> rates = new ArrayList<>();
for (RawRecord rec : records) {
StatsJsonParser.SampleCount sc = StatsJsonParser.countSamples(rec.getOriginRecordResult());
totalSamples += sc.getTotal();
positiveCount += sc.getPositive();
double rate = sc.getTotal() > 0 ? (double) sc.getPositive() / sc.getTotal() * 100 : 0;
rates.add(rate);
}
StatsAnalysisVO vo = new StatsAnalysisVO();
vo.setRegionName("总计");
vo.setRegionLevel("overall");
vo.setTotalSamples(totalSamples);
vo.setPositiveCount(positiveCount);
vo.setPositiveRate(totalSamples > 0 ? (double) positiveCount / totalSamples * 100 : 0.0);
fillStatistics(vo, rates);
vo.setHasChildren(true);
return vo;
}
// ==================== Internal helpers ====================
/**
* 从数据库加载符合条件的原始记录三表关联查询Java 端过滤
*/
private List<RawRecord> loadRawRecords(StatsAnalysisQuery query) {
// Step 1: 查询符合筛选条件的委托单
LambdaQueryWrapper<Entrust> ew = new LambdaQueryWrapper<Entrust>()
.eq(Entrust::getIsDeleted, 0)
.ne(Entrust::getEntrustStatus, "-1");
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
if (query.getStartTime() != null) {
try {
ew.ge(Entrust::getSamplingDate, sdf.parse(query.getStartTime()));
} catch (ParseException e) {
log.warn("无法解析 startTime: {}", query.getStartTime());
}
}
if (query.getEndTime() != null) {
try {
ew.le(Entrust::getSamplingDate, sdf.parse(query.getEndTime()));
} catch (ParseException e) {
log.warn("无法解析 endTime: {}", query.getEndTime());
}
}
if (StringUtils.isNotBlank(query.getSimpleSource())) {
ew.eq(Entrust::getSimpleSource, query.getSimpleSource());
}
if (StringUtils.isNotBlank(query.getImmuneSituation())) {
ew.eq(Entrust::getImmuneSituation, query.getImmuneSituation());
}
List<Entrust> entrusts = entrustMapper.selectList(ew);
if (entrusts.isEmpty()) {
return new ArrayList<>();
}
Set<Long> entrustIds = entrusts.stream().map(Entrust::getId).collect(Collectors.toSet());
// Step 2: 查询已完成的检验记录
LambdaQueryWrapper<Examine> examineWrapper = new LambdaQueryWrapper<Examine>()
.in(Examine::getEntrustId, entrustIds)
.eq(Examine::getIsFinished, "1")
.eq(Examine::getIsDeleted, 0);
// animalType 过滤: Examine.simpleName 格式为 animalType-XXXX (如 牛-口蹄疫)
if (StringUtils.isNotBlank(query.getAnimalType())) {
examineWrapper.likeRight(Examine::getSimpleName, query.getAnimalType() + "-");
}
List<Examine> examines = examineMapper.selectList(examineWrapper);
if (examines.isEmpty()) {
return new ArrayList<>();
}
Set<Long> examineIds = examines.stream().map(Examine::getId).collect(Collectors.toSet());
// Step 3: 查询检验结果
List<ExamineResult> results = examineResultMapper.selectList(
new LambdaQueryWrapper<ExamineResult>()
.in(ExamineResult::getExamineId, examineIds)
);
if (results.isEmpty()) {
return new ArrayList<>();
}
// Step 4: 构建映射关系并组装 RawRecord
Map<Long, Examine> examineMap = examines.stream()
.collect(Collectors.toMap(Examine::getId, e -> e));
Map<Long, Entrust> entrustMap = entrusts.stream()
.collect(Collectors.toMap(Entrust::getId, e -> e));
List<RawRecord> rawRecords = new ArrayList<>();
for (ExamineResult er : results) {
Examine ex = examineMap.get(er.getExamineId());
if (ex == null) continue;
Entrust en = entrustMap.get(ex.getEntrustId());
if (en == null) continue;
rawRecords.add(new RawRecord(
en.getCity(),
en.getRegionCode(),
en.getEntrustCustomerName(),
er.getOriginRecordResult()
));
}
return rawRecords;
}
/** 按城市分组 */
private Map<String, StatsAccumulator> groupByCity(List<RawRecord> records) {
Map<String, StatsAccumulator> map = new LinkedHashMap<>();
for (RawRecord rec : records) {
if (rec.getCity() == null) continue;
StatsJsonParser.SampleCount sc = StatsJsonParser.countSamples(rec.getOriginRecordResult());
double rate = sc.getTotal() > 0 ? (double) sc.getPositive() / sc.getTotal() * 100 : 0;
map.computeIfAbsent(rec.getCity(), k -> new StatsAccumulator(rec.getCity(), rec.getRegionCode(), "city"))
.add(sc.getTotal(), sc.getPositive(), rate);
}
return map;
}
/** 按区县分组 */
private Map<String, StatsAccumulator> groupByDistrict(List<RawRecord> records) {
Map<String, StatsAccumulator> map = new LinkedHashMap<>();
for (RawRecord rec : records) {
String key = StringUtils.isNotBlank(rec.getRegionCode()) ? rec.getRegionCode() : "未知区县";
StatsJsonParser.SampleCount sc = StatsJsonParser.countSamples(rec.getOriginRecordResult());
double rate = sc.getTotal() > 0 ? (double) sc.getPositive() / sc.getTotal() * 100 : 0;
map.computeIfAbsent(key, k -> new StatsAccumulator(key, rec.getRegionCode(), "district"))
.add(sc.getTotal(), sc.getPositive(), rate);
}
return map;
}
/** 按被检单位分组 */
private Map<String, StatsAccumulator> groupByCustomer(List<RawRecord> records) {
Map<String, StatsAccumulator> map = new LinkedHashMap<>();
for (RawRecord rec : records) {
String key = StringUtils.isNotBlank(rec.getCustomerName()) ? rec.getCustomerName() : "未知单位";
StatsJsonParser.SampleCount sc = StatsJsonParser.countSamples(rec.getOriginRecordResult());
double rate = sc.getTotal() > 0 ? (double) sc.getPositive() / sc.getTotal() * 100 : 0;
map.computeIfAbsent(key, k -> new StatsAccumulator(key, rec.getRegionCode(), "customer"))
.add(sc.getTotal(), sc.getPositive(), rate);
}
return map;
}
/** 将 Accumulator 列表转为 VO 列表,并计算各项统计量 */
private List<StatsAnalysisVO> computeStats(Map<String, StatsAccumulator> accumMap) {
return accumMap.values().stream()
.map(this::toStatsAnalysisVO)
.collect(Collectors.toList());
}
/** 单个 Accumulator → VO */
private StatsAnalysisVO toStatsAnalysisVO(StatsAccumulator acc) {
StatsAnalysisVO vo = new StatsAnalysisVO();
vo.setRegionName(acc.getRegionName());
vo.setRegionCode(acc.getRegionCode());
vo.setRegionLevel(acc.getRegionLevel());
vo.setTotalSamples(acc.getTotalSamples());
vo.setPositiveCount(acc.getPositiveCount());
vo.setPositiveRate(acc.getTotalSamples() > 0
? (double) acc.getPositiveCount() / acc.getTotalSamples() * 100 : 0.0);
fillStatistics(vo, acc.getSubPositiveRates());
vo.setHasChildren(true);
return vo;
}
/**
* 计算统计量并填充到 VO
* <ul>
* <li>Mean 各子项目阳性率的算术平均值</li>
* <li>SD 样本标准差 (n-1)</li>
* <li>CV 变异系数 = SD / Mean * 100 (%)</li>
* <li>SE 标准误 = SD / sqrt(n)</li>
* </ul>
*/
private void fillStatistics(StatsAnalysisVO vo, List<Double> subRates) {
int n = subRates.size();
if (n == 0) {
vo.setMeanValue(0.0);
vo.setStdDev(0.0);
vo.setCv(0.0);
vo.setSe(0.0);
return;
}
double mean = subRates.stream().mapToDouble(Double::doubleValue).average().orElse(0.0);
vo.setMeanValue(round2(mean));
if (n > 1) {
double sumSquaredDiff = subRates.stream()
.mapToDouble(v -> Math.pow(v - mean, 2))
.sum();
double variance = sumSquaredDiff / (n - 1);
double sd = Math.sqrt(variance);
vo.setStdDev(round2(sd));
vo.setSe(round2(sd / Math.sqrt(n)));
vo.setCv(mean > 0 ? round2(sd / mean * 100) : 0.0);
} else {
vo.setStdDev(0.0);
vo.setSe(0.0);
vo.setCv(0.0);
}
}
/** 对 paginated VO 列表进行分页 */
private <T> IPage<T> paginate(List<T> list, Query pageQuery) {
long current = pageQuery.getCurrent();
long size = pageQuery.getSize();
long total = list.size();
Page<T> page = new Page<>(current, size, total);
if (total == 0) {
page.setRecords(new ArrayList<>());
return page;
}
int fromIndex = (int) Math.min((current - 1) * size, total);
int toIndex = (int) Math.min(fromIndex + size, total);
page.setRecords(list.subList(fromIndex, toIndex));
return page;
}
/** 四舍五入保留两位小数 */
private static double round2(double value) {
return Math.round(value * 100.0) / 100.0;
}
// ==================== Inner types ====================
/**
* 三表关联后的平铺记录用于 Java 端分组聚合
*/
private static class RawRecord {
private final String city;
private final String regionCode;
private final String customerName;
private final String originRecordResult;
RawRecord(String city, String regionCode, String customerName, String originRecordResult) {
this.city = city;
this.regionCode = regionCode;
this.customerName = customerName;
this.originRecordResult = originRecordResult;
}
String getCity() { return city; }
String getRegionCode() { return regionCode; }
String getCustomerName() { return customerName; }
String getOriginRecordResult() { return originRecordResult; }
}
/**
* 分组聚合累加器记录该组的采样总数/阳性数/各子项阳性率
*/
private static class StatsAccumulator {
private final String regionName;
private final String regionCode;
private final String regionLevel;
private int totalSamples;
private int positiveCount;
private final List<Double> subPositiveRates = new ArrayList<>();
StatsAccumulator(String regionName, String regionCode, String regionLevel) {
this.regionName = regionName;
this.regionCode = regionCode;
this.regionLevel = regionLevel;
}
void add(int total, int positive, double positiveRate) {
this.totalSamples += total;
this.positiveCount += positive;
this.subPositiveRates.add(positiveRate);
}
String getRegionName() { return regionName; }
String getRegionCode() { return regionCode; }
String getRegionLevel() { return regionLevel; }
int getTotalSamples() { return totalSamples; }
int getPositiveCount() { return positiveCount; }
List<Double> getSubPositiveRates() { return subPositiveRates; }
}
}

@ -0,0 +1,24 @@
package org.springblade.stats.service.impl;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.lims.entry.StatsSampleTag;
import org.springblade.stats.mapper.StatsSampleTagMapper;
import org.springblade.stats.service.IStatsSampleTagService;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 样品标签 服务实现类
*
* @author swj
* @since 2026-05-29
*/
@Service
public class StatsSampleTagServiceImpl extends BaseServiceImpl<StatsSampleTagMapper, StatsSampleTag> implements IStatsSampleTagService {
@Override
public List<StatsSampleTag> listBySampleSetId(Long sampleSetId) {
return lambdaQuery().eq(StatsSampleTag::getSampleSetId, sampleSetId).list();
}
}

@ -0,0 +1,43 @@
package org.springblade.stats.util;
import org.springblade.lims.excel.StatsAnalysisExportExcel;
import org.springblade.stats.vo.StatsAnalysisVO;
import java.util.List;
import java.util.stream.Collectors;
/**
* 统计分析 Excel 转换工具
*
* @author blade
* @since 2026-05-29
*/
public class StatsExcelUtil {
/**
* StatsAnalysisVO 列表转换为 StatsAnalysisExportExcel 列表
*
* @param voList 统计分析 VO 列表
* @return Excel 导出 DTO 列表
*/
public static List<StatsAnalysisExportExcel> toExportList(List<StatsAnalysisVO> voList) {
return voList.stream().map(vo -> {
StatsAnalysisExportExcel excel = new StatsAnalysisExportExcel();
excel.setRegionName(vo.getRegionName());
excel.setTotalSamples(vo.getTotalSamples());
excel.setPositiveCount(vo.getPositiveCount());
excel.setPositiveRate(vo.getPositiveRateDisplay());
excel.setMeanValue(vo.getMeanValue() != null ? String.format("%.2f", vo.getMeanValue()) : "");
excel.setStdDev(vo.getStdDev() != null ? String.format("%.2f", vo.getStdDev()) : "");
excel.setCv(vo.getCvDisplay());
excel.setSe(vo.getSe() != null ? String.format("%.2f", vo.getSe()) : "");
excel.setAcceptanceNum("");
excel.setAnimalType("");
excel.setImmuneSituation("");
excel.setExamineItem("");
excel.setExamineMethod("");
excel.setSimpleSource("");
return excel;
}).collect(Collectors.toList());
}
}

@ -0,0 +1,218 @@
package org.springblade.stats.util;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 解析 ExamineResult.originRecordResult JSON 字符串的工具类
* <p>
* 处理三种 JSON 结构
* <ul>
* <li><b>Type A JSONArray 模式</b> Brucella/PCR/血凝/生化鉴定/普通 ELISA 检测</li>
* <li><b>Type B JSONObject 多板模式</b> 口蹄疫 templateType=2/3包含 "data" </li>
* <li><b>Type C 深层嵌套 Map 模式</b> 复杂 ELISA 试剂公式key "g1", "g2" </li>
* </ul>
* <p>
* 线程安全 无实例状态仅使用局部变量
*/
public class StatsJsonParser {
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class SampleResultEntry {
/** 原始编号(检测编号) */
private String originalNum;
/** 样品名称 */
private String sampleName;
/** 检测结果:阳性/阴性/可疑 */
private String result;
/** 序号 */
private String order;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class SampleCount {
/** 总样品数 */
private int total;
/** 阳性样品数(result = "阳性") */
private int positive;
}
/**
* 解析 originRecordResult JSON 字符串提取所有样品级结果条目
*
* @param originRecordResult JSON 字符串可为 null 或空
* @return null SampleResultEntry 列表
*/
public static List<SampleResultEntry> parseEntries(String originRecordResult) {
if (originRecordResult == null || originRecordResult.trim().isEmpty()) {
return new ArrayList<>();
}
try {
Object parsed = JSON.parse(originRecordResult);
List<SampleResultEntry> entries = new ArrayList<>();
if (parsed instanceof JSONArray) {
// Type A: JSONArray 模式
parseJSONArray((JSONArray) parsed, entries);
} else if (parsed instanceof JSONObject) {
JSONObject obj = (JSONObject) parsed;
if (obj.containsKey("data")) {
// Type B: 口蹄疫多板模式,包含 "data" key
// data → {g1: [...], g2: [...]}
traverseForResults(obj, entries);
} else {
// Type C: 深层嵌套 Map 模式(或 Type B 无 data 的兜底)
traverseForResults(obj, entries);
}
}
return entries;
} catch (Exception e) {
// 任何解析异常 → 返回空列表
return new ArrayList<>();
}
}
/**
* 遍历 JSONArray提取每个 JSONObject 中的样品结果
*/
private static void parseJSONArray(JSONArray arr, List<SampleResultEntry> entries) {
for (int i = 0; i < arr.size(); i++) {
Object item = arr.get(i);
if (item instanceof JSONObject) {
processLeaf((JSONObject) item, entries);
}
}
}
/**
* 递归遍历 JSON 结构查找含有 "result" 字段的叶子节点
* <p>
* 支持 Type B多层嵌套含 data Type Cg1/g2 分组两种结构
*/
private static void traverseForResults(Object obj, List<SampleResultEntry> entries) {
if (obj instanceof JSONObject) {
JSONObject jsonObj = (JSONObject) obj;
// 判断是否为叶子节点(存在 result 字段)
if (jsonObj.containsKey("result") || jsonObj.containsKey("Result")) {
processLeaf(jsonObj, entries);
return; // 叶子节点不再继续深入
}
// 遍历所有 value,跳过 data 键的值(data 下可能也有 result 结构,但视为叶子处理)
for (Map.Entry<String, Object> entry : jsonObj.entrySet()) {
Object value = entry.getValue();
if (value instanceof JSONArray || value instanceof JSONObject) {
traverseForResults(value, entries);
}
}
} else if (obj instanceof JSONArray) {
JSONArray arr = (JSONArray) obj;
for (int i = 0; i < arr.size(); i++) {
traverseForResults(arr.get(i), entries);
}
}
}
/**
* JSONObject 叶子节点提取样品结果字段构造 SampleResultEntry
* <p>
* 兼容不同 JSON 结构的字段命名差异
* <ul>
* <li>result/Result 检测结果</li>
* <li>originalNum/num/experieNum/original_num 原始编号</li>
* <li>simpleName/sampleName 样品名称</li>
* <li>order 序号String int</li>
* </ul>
*/
private static void processLeaf(JSONObject leaf, List<SampleResultEntry> entries) {
String result = getStringSafe(leaf, "result");
if (result == null) {
result = getStringSafe(leaf, "Result");
}
if (result != null) {
result = result.trim();
}
if (result == null || result.isEmpty()) {
return; // 没有 result 字段的叶子节点不计为样品(可能是对照孔或空位)
}
String num = getStringSafe(leaf, "originalNum");
if (num == null) {
num = getStringSafe(leaf, "num");
}
if (num == null) {
num = getStringSafe(leaf, "experieNum");
}
if (num == null) {
num = getStringSafe(leaf, "original_num");
}
String sampleName = getStringSafe(leaf, "simpleName");
if (sampleName == null) {
sampleName = getStringSafe(leaf, "sampleName");
}
String order = getStringSafe(leaf, "order");
if (order == null) {
// 尝试读取 int 类型的 order
int orderInt = getIntSafe(leaf, "order");
if (orderInt != 0 || leaf.containsKey("order")) {
order = String.valueOf(orderInt);
}
}
entries.add(new SampleResultEntry(
num != null ? num : "",
sampleName != null ? sampleName : "",
result,
order != null ? order : ""
));
}
/**
* 安全地从 JSONObject 获取 String 异常时返回 null
*/
private static String getStringSafe(JSONObject obj, String key) {
try {
return obj.getString(key);
} catch (Exception e) {
return null;
}
}
/**
* 安全地从 JSONObject 获取 int 异常时返回 0
*/
private static int getIntSafe(JSONObject obj, String key) {
try {
return obj.getIntValue(key);
} catch (Exception e) {
return 0;
}
}
/**
* 统计 originRecordResult 中的总样品数和阳性样品数
*
* @param originRecordResult JSON 字符串
* @return SampleCount 对象total positive不会为 null
*/
public static SampleCount countSamples(String originRecordResult) {
List<SampleResultEntry> entries = parseEntries(originRecordResult);
int total = entries.size();
int positive = (int) entries.stream()
.filter(e -> "阳性".equals(e.getResult()))
.count();
return new SampleCount(total, positive);
}
}

@ -0,0 +1,46 @@
package org.springblade.stats.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 统计分析结果 VO
*
* @author blade
* @since 2026-05-29
*/
@Data
public class StatsAnalysisVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 地区/场所名称 */
private String regionName;
/** 区域级别: city/district/customer */
private String regionLevel;
/** 总采样数 */
private Integer totalSamples;
/** 阳性数 */
private Integer positiveCount;
/** 阳性率 (%) */
private Double positiveRate;
/** 均值 (Mean) */
private Double meanValue;
/** 标准差 (SD) */
private Double stdDev;
/** 变异系数 (CV%) */
private Double cv;
/** 标准误 (SE) */
private Double se;
/** 是否有下级数据 */
private Boolean hasChildren;
/** 区域编码 (cityCode/districtCode) */
private String regionCode;
// Display-friendly computed fields
public String getPositiveRateDisplay() {
return positiveRate != null ? String.format("%.2f%%", positiveRate) : "0.00%";
}
public String getCvDisplay() {
return cv != null ? String.format("%.2f%%", cv) : "0.00%";
}
}
Loading…
Cancel
Save