parent
59b54db9f6
commit
0170907f6d
14 changed files with 1144 additions and 0 deletions
@ -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; |
||||||
|
} |
||||||
@ -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 C(g1/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…
Reference in new issue