diff --git a/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/lab/appservice/ILabQcAppService.java b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/lab/appservice/ILabQcAppService.java new file mode 100644 index 000000000..1e77f32ec --- /dev/null +++ b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/lab/appservice/ILabQcAppService.java @@ -0,0 +1,14 @@ +package com.healthlink.his.web.lab.appservice; + +import com.core.common.core.domain.R; +import com.healthlink.his.lab.domain.LabInternalQc; +import java.util.List; + +public interface ILabQcAppService { + + R runWestgard(LabInternalQc qc); + + R getQcResults(String qcItem, Boolean isPass, Integer pageNo, Integer pageSize); + + R getQcStats(); +} diff --git a/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/lab/appservice/impl/LabQcAppServiceImpl.java b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/lab/appservice/impl/LabQcAppServiceImpl.java new file mode 100644 index 000000000..36642525d --- /dev/null +++ b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/lab/appservice/impl/LabQcAppServiceImpl.java @@ -0,0 +1,173 @@ +package com.healthlink.his.web.lab.appservice.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.core.common.core.domain.R; +import com.healthlink.his.lab.domain.LabInternalQc; +import com.healthlink.his.lab.service.ILabInternalQcService; +import com.healthlink.his.web.lab.appservice.ILabQcAppService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import jakarta.annotation.Resource; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class LabQcAppServiceImpl implements ILabQcAppService { + + @Resource + private ILabInternalQcService internalQcService; + + private static final BigDecimal SD1 = new BigDecimal("1"); + private static final BigDecimal SD2 = new BigDecimal("2"); + private static final BigDecimal SD3 = new BigDecimal("3"); + + @Override + public R runWestgard(LabInternalQc qc) { + List history = getHistoryData(qc.getQcItem(), qc.getInstrumentName()); + if (history.isEmpty()) { + qc.setSdValue(BigDecimal.ZERO); + qc.setCvRate(BigDecimal.ZERO); + qc.setWestgardRule("首次检测,无历史数据"); + qc.setIsPass(true); + internalQcService.save(qc); + return R.ok(qc); + } + + BigDecimal mean = calcMean(history); + BigDecimal sd = calcSd(history, mean); + BigDecimal cv = sd.multiply(new BigDecimal("100")).divide(mean, 4, RoundingMode.HALF_UP); + + qc.setSdValue(sd); + qc.setCvRate(cv); + + BigDecimal deviation = qc.getActualValue().subtract(mean).abs(); + String rule = checkWestgardRules(deviation, sd, history, qc.getActualValue()); + qc.setWestgardRule(rule); + qc.setIsPass("通过".equals(rule) || rule.startsWith("Westgard: 1-2s")); + + internalQcService.save(qc); + return R.ok(qc); + } + + @Override + public R getQcResults(String qcItem, Boolean isPass, Integer pageNo, Integer pageSize) { + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.like(StringUtils.hasText(qcItem), LabInternalQc::getQcItem, qcItem) + .eq(isPass != null, LabInternalQc::getIsPass, isPass) + .orderByDesc(LabInternalQc::getQcDate); + return R.ok(internalQcService.page(new Page<>(pageNo, pageSize), w)); + } + + @Override + public R getQcStats() { + Map stats = new HashMap<>(); + stats.put("total", internalQcService.count()); + LambdaQueryWrapper wp = new LambdaQueryWrapper<>(); + wp.eq(LabInternalQc::getIsPass, true); + stats.put("passed", internalQcService.count(wp)); + LambdaQueryWrapper wf = new LambdaQueryWrapper<>(); + wf.eq(LabInternalQc::getIsPass, false); + stats.put("failed", internalQcService.count(wf)); + return R.ok(stats); + } + + private List getHistoryData(String qcItem, String instrumentName) { + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.eq(StringUtils.hasText(qcItem), LabInternalQc::getQcItem, qcItem) + .eq(StringUtils.hasText(instrumentName), LabInternalQc::getInstrumentName, instrumentName) + .orderByDesc(LabInternalQc::getQcDate) + .last("LIMIT 20"); + return internalQcService.list(w); + } + + private BigDecimal calcMean(List data) { + BigDecimal sum = data.stream() + .map(LabInternalQc::getActualValue) + .reduce(BigDecimal.ZERO, BigDecimal::add); + return sum.divide(new BigDecimal(data.size()), 4, RoundingMode.HALF_UP); + } + + private BigDecimal calcSd(List data, BigDecimal mean) { + BigDecimal sumSq = data.stream() + .map(d -> d.getActualValue().subtract(mean).pow(2)) + .reduce(BigDecimal.ZERO, BigDecimal::add); + return BigDecimal.valueOf(Math.sqrt(sumSq.divide(new BigDecimal(data.size()), 8, RoundingMode.HALF_UP).doubleValue())) + .setScale(4, RoundingMode.HALF_UP); + } + + private String checkWestgardRules(BigDecimal deviation, BigDecimal sd, List history, BigDecimal currentValue) { + if (sd.compareTo(BigDecimal.ZERO) == 0) { + return "通过"; + } + BigDecimal zScore = deviation.divide(sd, 4, RoundingMode.HALF_UP); + + if (zScore.compareTo(SD3) >= 0) { + return "Westgard: 1-3s 失控"; + } + if (zScore.compareTo(SD2) >= 0) { + if (isShiftOrTrend(history, currentValue, sd)) { + return "Westgard: 2-2s 失控"; + } + return "Westgard: 1-2s 警告"; + } + if (zScore.compareTo(SD1) >= 0) { + if (checkR4Rule(history)) { + return "Westgard: R-4s 失控"; + } + if (check41sRule(history)) { + return "Westgard: 4-1s 失控"; + } + if (check10xRule(history)) { + return "Westgard: 10x 失控"; + } + } + return "通过"; + } + + private boolean isShiftOrTrend(List history, BigDecimal currentValue, BigDecimal sd) { + if (history.size() < 7) return false; + List recent = history.subList(0, Math.min(7, history.size())) + .stream().map(LabInternalQc::getActualValue).collect(Collectors.toList()); + List allValues = new ArrayList<>(recent); + allValues.add(0, currentValue); + BigDecimal mean = calcMeanFromValues(allValues); + boolean allAbove = allValues.stream().allMatch(v -> v.compareTo(mean) > 0); + boolean allBelow = allValues.stream().allMatch(v -> v.compareTo(mean) < 0); + return allAbove || allBelow; + } + + private boolean checkR4Rule(List history) { + if (history.size() < 2) return false; + BigDecimal v1 = history.get(0).getActualValue(); + BigDecimal v2 = history.get(1).getActualValue(); + return v1.subtract(v2).abs().compareTo(new BigDecimal("4")) > 0; + } + + private boolean check41sRule(List history) { + if (history.size() < 4) return false; + List recent = history.subList(0, 4) + .stream().map(LabInternalQc::getActualValue).collect(Collectors.toList()); + BigDecimal mean = calcMeanFromValues(recent); + return recent.stream().noneMatch(v -> v.subtract(mean).abs().compareTo(SD1) < 0); + } + + private boolean check10xRule(List history) { + if (history.size() < 10) return false; + List recent = history.subList(0, 10) + .stream().map(LabInternalQc::getActualValue).collect(Collectors.toList()); + BigDecimal mean = calcMeanFromValues(recent); + return recent.stream().allMatch(v -> v.compareTo(mean) > 0) + || recent.stream().allMatch(v -> v.compareTo(mean) < 0); + } + + private BigDecimal calcMeanFromValues(List values) { + BigDecimal sum = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + return sum.divide(new BigDecimal(values.size()), 4, RoundingMode.HALF_UP); + } +} diff --git a/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/lab/controller/LabQcController.java b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/lab/controller/LabQcController.java new file mode 100644 index 000000000..a41670112 --- /dev/null +++ b/healthlink-his-server/healthlink-his-application/src/main/java/com/healthlink/his/web/lab/controller/LabQcController.java @@ -0,0 +1,41 @@ +package com.healthlink.his.web.lab.controller; + +import com.core.common.core.domain.R; +import com.healthlink.his.lab.domain.LabInternalQc; +import com.healthlink.his.web.lab.appservice.ILabQcAppService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import jakarta.annotation.Resource; + +@RestController +@RequestMapping("/lab/qc") +@Slf4j +public class LabQcController { + + @Resource + private ILabQcAppService labQcAppService; + + @PostMapping("/run") + @PreAuthorize("hasPermi('infection:lab:edit')") + public R runWestgard(@RequestBody LabInternalQc qc) { + return labQcAppService.runWestgard(qc); + } + + @GetMapping("/results") + @PreAuthorize("hasPermi('infection:lab:list')") + public R getQcResults( + @RequestParam(value = "qcItem", required = false) String qcItem, + @RequestParam(value = "isPass", required = false) Boolean isPass, + @RequestParam(value = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) { + return labQcAppService.getQcResults(qcItem, isPass, pageNo, pageSize); + } + + @GetMapping("/stats") + @PreAuthorize("hasPermi('infection:lab:list')") + public R getQcStats() { + return labQcAppService.getQcStats(); + } +} diff --git a/healthlink-his-ui/src/api/lab/labQc.js b/healthlink-his-ui/src/api/lab/labQc.js new file mode 100644 index 000000000..7237fa85e --- /dev/null +++ b/healthlink-his-ui/src/api/lab/labQc.js @@ -0,0 +1,24 @@ +import request from '@/utils/request'; + +export function runWestgard(data) { + return request({ + url: '/lab/qc/run', + method: 'post', + data: data, + }); +} + +export function getQcResults(query) { + return request({ + url: '/lab/qc/results', + method: 'get', + params: query, + }); +} + +export function getQcStats() { + return request({ + url: '/lab/qc/stats', + method: 'get', + }); +} diff --git a/healthlink-his-ui/src/views/lab/LabQc.vue b/healthlink-his-ui/src/views/lab/LabQc.vue new file mode 100644 index 000000000..76229b5e9 --- /dev/null +++ b/healthlink-his-ui/src/views/lab/LabQc.vue @@ -0,0 +1,194 @@ + + +