diff --git a/lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/StatsSampleTag.java b/lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/StatsSampleTag.java new file mode 100644 index 0000000..49b9d1e --- /dev/null +++ b/lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/StatsSampleTag.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; +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/lims/LimsApplication.java b/lab-service/lab-lims/src/main/java/org/springblade/lims/LimsApplication.java index be846b6..b7dac47 100644 --- a/lab-service/lab-lims/src/main/java/org/springblade/lims/LimsApplication.java +++ b/lab-service/lab-lims/src/main/java/org/springblade/lims/LimsApplication.java @@ -6,6 +6,7 @@ import org.springblade.core.cloud.feign.EnableBladeFeign; import org.springblade.core.launch.BladeApplication; import org.springframework.cloud.client.SpringCloudApplication; +import org.springframework.context.annotation.ComponentScan; import org.springframework.scheduling.annotation.EnableScheduling; /** @@ -15,6 +16,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; */ @EnableBladeFeign @SpringCloudApplication +@ComponentScan({"org.springblade.lims", "org.springblade.stats"}) @EnableScheduling public class LimsApplication { diff --git a/lab-service/lab-lims/src/main/java/org/springblade/lims/excel/StatsAnalysisExportExcel.java b/lab-service/lab-lims/src/main/java/org/springblade/lims/excel/StatsAnalysisExportExcel.java new file mode 100644 index 0000000..82a96fa --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/lims/excel/StatsAnalysisExportExcel.java @@ -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; +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/stats/controller/StatsAnalysisController.java b/lab-service/lab-lims/src/main/java/org/springblade/stats/controller/StatsAnalysisController.java new file mode 100644 index 0000000..509cd7e --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/stats/controller/StatsAnalysisController.java @@ -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> list(StatsAnalysisQuery query, Query pageQuery) { + return R.data(service.queryCityLevel(query, pageQuery)); + } + + /** + * 区县级统计 + */ + @GetMapping("/district") + @ApiOperation(value = "区县级统计", notes = "传入城市编码,返回该城市下各区县的数据") + public R> 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> 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 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 data = service.exportData(query, level); + List exportList = StatsExcelUtil.toExportList(data); + ExcelUtil.export(response, "统计分析结果", "统计分析", exportList, StatsAnalysisExportExcel.class); + } catch (Exception e) { + log.error("导出Excel失败", e); + } + } + +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/stats/controller/StatsSampleTagController.java b/lab-service/lab-lims/src/main/java/org/springblade/stats/controller/StatsSampleTagController.java new file mode 100644 index 0000000..657c95e --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/stats/controller/StatsSampleTagController.java @@ -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> list(@ApiParam(value = "样品集ID") Long sampleSetId, Query query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(StatsSampleTag::getSampleSetId, sampleSetId); + IPage page = service.page(Condition.getPage(query), wrapper); + return R.data(page); + } + + /** + * 新增 + */ + @PostMapping("/save") + @ApiOperation(value = "新增", notes = "传入StatsSampleTag") + public R save(@RequestBody StatsSampleTag entry) { + // 检查重复标签 + List 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> tagDict() { + List tagList = Arrays.asList("紧急", "常规", "复检", "重点监控", "可疑"); + return R.data(tagList); + } +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/stats/dto/StatsAnalysisQuery.java b/lab-service/lab-lims/src/main/java/org/springblade/stats/dto/StatsAnalysisQuery.java new file mode 100644 index 0000000..f927572 --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/stats/dto/StatsAnalysisQuery.java @@ -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; +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/stats/mapper/StatsSampleTagMapper.java b/lab-service/lab-lims/src/main/java/org/springblade/stats/mapper/StatsSampleTagMapper.java new file mode 100644 index 0000000..61739ff --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/stats/mapper/StatsSampleTagMapper.java @@ -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 { +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/stats/service/IStatsAnalysisService.java b/lab-service/lab-lims/src/main/java/org/springblade/stats/service/IStatsAnalysisService.java new file mode 100644 index 0000000..51e95fc --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/stats/service/IStatsAnalysisService.java @@ -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 queryCityLevel(StatsAnalysisQuery query, Query pageQuery); + + /** + * 区县级聚合查询 + * + * @param cityCode 城市编码 + * @param query 筛选条件 + * @param pageQuery 分页参数 + * @return 分页的区县级统计结果 + */ + IPage queryDistrictLevel(String cityCode, StatsAnalysisQuery query, Query pageQuery); + + /** + * 被检单位级聚合查询 + * + * @param districtCode 区县编码 + * @param query 筛选条件 + * @param pageQuery 分页参数 + * @return 分页的被检单位级统计结果 + */ + IPage queryCustomerLevel(String districtCode, StatsAnalysisQuery query, Query pageQuery); + + /** + * 导出数据(不分页) + * + * @param query 筛选条件 + * @param level 聚合级别: city/district/customer + * @return 全部统计结果列表 + */ + List exportData(StatsAnalysisQuery query, String level); + + /** + * 整体统计数据(用于 summary cards) + * + * @param query 筛选条件 + * @return 总体统计结果 + */ + StatsAnalysisVO queryOverallStats(StatsAnalysisQuery query); +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/stats/service/IStatsSampleTagService.java b/lab-service/lab-lims/src/main/java/org/springblade/stats/service/IStatsSampleTagService.java new file mode 100644 index 0000000..3d09c79 --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/stats/service/IStatsSampleTagService.java @@ -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 { + + /** + * 根据样品集ID查询标签列表 + * + * @param sampleSetId 样品集ID + * @return 标签列表 + */ + List listBySampleSetId(Long sampleSetId); +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/stats/service/impl/StatsAnalysisServiceImpl.java b/lab-service/lab-lims/src/main/java/org/springblade/stats/service/impl/StatsAnalysisServiceImpl.java new file mode 100644 index 0000000..b282666 --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/stats/service/impl/StatsAnalysisServiceImpl.java @@ -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; + +/** + * 统计分析服务实现 — 核心聚合引擎 + *

+ * 跨 f_entrust_main / f_examine / f_examine_result 三表查询, + * 使用 StatsJsonParser 解析 originRecordResult JSON, + * 在 Java 端按城市/区县/被检单位分组聚合,计算 Mean / SD / CV / SE。 + *

+ * + * @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 queryCityLevel(StatsAnalysisQuery query, Query pageQuery) { + List records = loadRawRecords(query); + Map 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 queryDistrictLevel(String cityCode, StatsAnalysisQuery query, Query pageQuery) { + List records = loadRawRecords(query); + + // 筛选出指定城市的记录 + List cityRecords = records.stream() + .filter(r -> r.getCity() != null && r.getCity().equals(cityCode)) + .collect(Collectors.toList()); + + Map 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 queryCustomerLevel(String districtCode, StatsAnalysisQuery query, Query pageQuery) { + List records = loadRawRecords(query); + + // 筛选出指定区县的记录 + List districtRecords = records.stream() + .filter(r -> r.getRegionCode() != null && r.getRegionCode().equals(districtCode)) + .collect(Collectors.toList()); + + Map 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 exportData(StatsAnalysisQuery query, String level) { + List 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 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 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 loadRawRecords(StatsAnalysisQuery query) { + // Step 1: 查询符合筛选条件的委托单 + LambdaQueryWrapper ew = new LambdaQueryWrapper() + .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 entrusts = entrustMapper.selectList(ew); + if (entrusts.isEmpty()) { + return new ArrayList<>(); + } + + Set entrustIds = entrusts.stream().map(Entrust::getId).collect(Collectors.toSet()); + + // Step 2: 查询已完成的检验记录 + LambdaQueryWrapper examineWrapper = new LambdaQueryWrapper() + .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 examines = examineMapper.selectList(examineWrapper); + if (examines.isEmpty()) { + return new ArrayList<>(); + } + + Set examineIds = examines.stream().map(Examine::getId).collect(Collectors.toSet()); + + // Step 3: 查询检验结果 + List results = examineResultMapper.selectList( + new LambdaQueryWrapper() + .in(ExamineResult::getExamineId, examineIds) + ); + if (results.isEmpty()) { + return new ArrayList<>(); + } + + // Step 4: 构建映射关系并组装 RawRecord + Map examineMap = examines.stream() + .collect(Collectors.toMap(Examine::getId, e -> e)); + Map entrustMap = entrusts.stream() + .collect(Collectors.toMap(Entrust::getId, e -> e)); + + List 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 groupByCity(List records) { + Map 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 groupByDistrict(List records) { + Map 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 groupByCustomer(List records) { + Map 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 computeStats(Map 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 中 + *
    + *
  • Mean — 各子项目阳性率的算术平均值
  • + *
  • SD — 样本标准差 (n-1)
  • + *
  • CV — 变异系数 = SD / Mean * 100 (%)
  • + *
  • SE — 标准误 = SD / sqrt(n)
  • + *
+ */ + private void fillStatistics(StatsAnalysisVO vo, List 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 IPage paginate(List list, Query pageQuery) { + long current = pageQuery.getCurrent(); + long size = pageQuery.getSize(); + long total = list.size(); + + Page 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 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 getSubPositiveRates() { return subPositiveRates; } + } +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/stats/service/impl/StatsSampleTagServiceImpl.java b/lab-service/lab-lims/src/main/java/org/springblade/stats/service/impl/StatsSampleTagServiceImpl.java new file mode 100644 index 0000000..05888df --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/stats/service/impl/StatsSampleTagServiceImpl.java @@ -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 implements IStatsSampleTagService { + + @Override + public List listBySampleSetId(Long sampleSetId) { + return lambdaQuery().eq(StatsSampleTag::getSampleSetId, sampleSetId).list(); + } +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/stats/util/StatsExcelUtil.java b/lab-service/lab-lims/src/main/java/org/springblade/stats/util/StatsExcelUtil.java new file mode 100644 index 0000000..6ef54de --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/stats/util/StatsExcelUtil.java @@ -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 toExportList(List 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()); + } +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/stats/util/StatsJsonParser.java b/lab-service/lab-lims/src/main/java/org/springblade/stats/util/StatsJsonParser.java new file mode 100644 index 0000000..d99760d --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/stats/util/StatsJsonParser.java @@ -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 字符串的工具类。 + *

+ * 处理三种 JSON 结构: + *

    + *
  • Type A JSONArray 模式 — Brucella/PCR/血凝/生化鉴定/普通 ELISA 检测
  • + *
  • Type B JSONObject 多板模式 — 口蹄疫 templateType=2/3,包含 "data" 键
  • + *
  • Type C 深层嵌套 Map 模式 — 复杂 ELISA 试剂公式,key 为 "g1", "g2" 等
  • + *
+ *

+ * 线程安全 — 无实例状态,仅使用局部变量。 + */ +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 parseEntries(String originRecordResult) { + if (originRecordResult == null || originRecordResult.trim().isEmpty()) { + return new ArrayList<>(); + } + try { + Object parsed = JSON.parse(originRecordResult); + List 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 entries) { + for (int i = 0; i < arr.size(); i++) { + Object item = arr.get(i); + if (item instanceof JSONObject) { + processLeaf((JSONObject) item, entries); + } + } + } + + /** + * 递归遍历 JSON 结构,查找含有 "result" 字段的叶子节点。 + *

+ * 支持 Type B(多层嵌套含 data 键)和 Type C(g1/g2 分组)两种结构。 + */ + private static void traverseForResults(Object obj, List 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 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。 + *

+ * 兼容不同 JSON 结构的字段命名差异: + *

    + *
  • result/Result — 检测结果
  • + *
  • originalNum/num/experieNum/original_num — 原始编号
  • + *
  • simpleName/sampleName — 样品名称
  • + *
  • order — 序号(String 或 int)
  • + *
+ */ + private static void processLeaf(JSONObject leaf, List 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 entries = parseEntries(originRecordResult); + int total = entries.size(); + int positive = (int) entries.stream() + .filter(e -> "阳性".equals(e.getResult())) + .count(); + return new SampleCount(total, positive); + } +} diff --git a/lab-service/lab-lims/src/main/java/org/springblade/stats/vo/StatsAnalysisVO.java b/lab-service/lab-lims/src/main/java/org/springblade/stats/vo/StatsAnalysisVO.java new file mode 100644 index 0000000..8ebec5d --- /dev/null +++ b/lab-service/lab-lims/src/main/java/org/springblade/stats/vo/StatsAnalysisVO.java @@ -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%"; + } +}