feat(lab): T7.1 室内质控Westgard规则 - AppService/Controller/前端质控图

This commit is contained in:
2026-06-18 12:34:39 +08:00
parent 98385e6553
commit ec8238ab26
5 changed files with 446 additions and 0 deletions

View File

@@ -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();
}

View File

@@ -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<LabInternalQc> 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<LabInternalQc> 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<String, Object> stats = new HashMap<>();
stats.put("total", internalQcService.count());
LambdaQueryWrapper<LabInternalQc> wp = new LambdaQueryWrapper<>();
wp.eq(LabInternalQc::getIsPass, true);
stats.put("passed", internalQcService.count(wp));
LambdaQueryWrapper<LabInternalQc> wf = new LambdaQueryWrapper<>();
wf.eq(LabInternalQc::getIsPass, false);
stats.put("failed", internalQcService.count(wf));
return R.ok(stats);
}
private List<LabInternalQc> getHistoryData(String qcItem, String instrumentName) {
LambdaQueryWrapper<LabInternalQc> 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<LabInternalQc> 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<LabInternalQc> 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<LabInternalQc> 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<LabInternalQc> history, BigDecimal currentValue, BigDecimal sd) {
if (history.size() < 7) return false;
List<BigDecimal> recent = history.subList(0, Math.min(7, history.size()))
.stream().map(LabInternalQc::getActualValue).collect(Collectors.toList());
List<BigDecimal> 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<LabInternalQc> 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<LabInternalQc> history) {
if (history.size() < 4) return false;
List<BigDecimal> 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<LabInternalQc> history) {
if (history.size() < 10) return false;
List<BigDecimal> 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<BigDecimal> values) {
BigDecimal sum = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
return sum.divide(new BigDecimal(values.size()), 4, RoundingMode.HALF_UP);
}
}

View File

@@ -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();
}
}

View File

@@ -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',
});
}

View File

@@ -0,0 +1,194 @@
<template>
<div style="padding:16px">
<div style="margin-bottom:16px;display:flex;align-items:center;gap:16px">
<span style="font-size:18px;font-weight:bold">室内质控管理</span>
<el-tag v-if="stats.total" type="info"> {{ stats.total }} </el-tag>
<el-tag v-if="stats.passed" type="success">合格 {{ stats.passed }}</el-tag>
<el-tag v-if="stats.failed" type="danger">失控 {{ stats.failed }}</el-tag>
</div>
<el-row :gutter="16">
<el-col :span="8">
<el-card shadow="never">
<template #header>
<span>录入质控数据</span>
</template>
<el-form :model="form" label-width="80px" size="default">
<el-form-item label="质控项目">
<el-input v-model="form.qcItem" placeholder="如ALT, GLU" />
</el-form-item>
<el-form-item label="仪器">
<el-input v-model="form.instrumentName" placeholder="仪器名称" />
</el-form-item>
<el-form-item label="靶值">
<el-input-number v-model="form.targetValue" :precision="4" :step="0.1" style="width:100%" />
</el-form-item>
<el-form-item label="实测值">
<el-input-number v-model="form.actualValue" :precision="4" :step="0.1" style="width:100%" />
</el-form-item>
<el-form-item label="检测日期">
<el-date-picker v-model="form.qcDate" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item label="操作人">
<el-input v-model="form.operatorName" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remarks" type="textarea" :rows="2" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="runWestgard" :loading="loading">执行Westgard判断</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="16">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>质控图</span>
<div style="display:flex;gap:8px">
<el-input v-model="query.qcItem" placeholder="质控项目" clearable style="width:140px" />
<el-select v-model="query.isPass" placeholder="结果" clearable style="width:100px">
<el-option label="通过" :value="true" />
<el-option label="失控" :value="false" />
</el-select>
<el-button type="primary" @click="loadResults">查询</el-button>
</div>
</div>
</template>
<div ref="chartRef" style="width:100%;height:300px" />
</el-card>
<el-card shadow="never" style="margin-top:12px">
<template #header>
<span>质控结果列表</span>
</template>
<el-table :data="tableData" border stripe size="small">
<el-table-column prop="qcItem" label="质控项目" width="120" />
<el-table-column prop="instrumentName" label="仪器" width="120" />
<el-table-column prop="targetValue" label="靶值" width="90" />
<el-table-column prop="actualValue" label="实测值" width="90" />
<el-table-column prop="sdValue" label="SD" width="80" />
<el-table-column prop="cvRate" label="CV%" width="80" />
<el-table-column prop="westgardRule" label="Westgard判定" width="180">
<template #default="{row}">
<el-tag :type="row.isPass ? 'success' : 'danger'" size="small">{{ row.westgardRule }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="qcDate" label="日期" width="110" />
<el-table-column prop="operatorName" label="操作人" width="80" />
</el-table>
<el-pagination
v-model:current-page="query.pageNo"
v-model:page-size="query.pageSize"
style="margin-top:12px;justify-content:flex-end"
:total="total"
layout="total,prev,pager,next"
@current-change="loadResults"
/>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { runWestgard, getQcResults, getQcStats } from '@/api/lab/labQc'
const chartRef = ref(null)
let chartInstance = null
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const stats = ref({ total: 0, passed: 0, failed: 0 })
const query = ref({ qcItem: '', isPass: null, pageNo: 1, pageSize: 20 })
const defaultForm = () => ({
qcItem: '', instrumentName: '', targetValue: null, actualValue: null,
qcDate: '', operatorName: '', departmentName: '', departmentId: null, remarks: ''
})
const form = ref(defaultForm())
const loadResults = async () => {
const r = await getQcResults(query.value)
tableData.value = r.data?.records || []
total.value = r.data?.total || 0
renderChart()
}
const loadStats = async () => {
const r = await getQcStats()
stats.value = r.data || stats.value
}
const runWestgard = async () => {
if (!form.value.qcItem || !form.value.actualValue) {
ElMessage.warning('请填写质控项目和实测值')
return
}
loading.value = true
try {
const r = await runWestgard(form.value)
if (r.data?.isPass) {
ElMessage.success('Westgard判定: ' + r.data.westgardRule)
} else {
ElMessage.error('Westgard判定: ' + r.data.westgardRule)
}
form.value = defaultForm()
await loadResults()
await loadStats()
} finally {
loading.value = false
}
}
const renderChart = () => {
if (!chartRef.value || tableData.value.length === 0) return
if (!chartInstance) {
chartInstance = echarts.init(chartRef.value)
}
const items = [...tableData.value].reverse()
const dates = items.map(i => i.qcDate)
const values = items.map(i => i.actualValue)
const targetValues = items.map(i => i.targetValue)
const mean = targetValues.reduce((a, b) => a + (b || 0), 0) / targetValues.length || 0
const sdArr = items.map(i => i.sdValue || 0)
const sd = sdArr.reduce((a, b) => a + b, 0) / sdArr.length || 1
chartInstance.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['实测值', '靶值', '+2SD', '-2SD', '+3SD', '-3SD'] },
grid: { left: 60, right: 20, top: 40, bottom: 40 },
xAxis: { type: 'category', data: dates },
yAxis: { type: 'value', name: '检测值' },
series: [
{ name: '实测值', type: 'line', data: values, itemStyle: { color: '#409EFF' }, lineStyle: { width: 2 } },
{ name: '靶值', type: 'line', data: targetValues, lineStyle: { type: 'dashed', color: '#67C23A' }, itemStyle: { color: '#67C23A' } },
{ name: '+2SD', type: 'line', data: dates.map(() => mean + 2 * sd), lineStyle: { type: 'dotted', color: '#E6A23C' }, itemStyle: { color: '#E6A23C' }, symbol: 'none' },
{ name: '-2SD', type: 'line', data: dates.map(() => mean - 2 * sd), lineStyle: { type: 'dotted', color: '#E6A23C' }, itemStyle: { color: '#E6A23C' }, symbol: 'none' },
{ name: '+3SD', type: 'line', data: dates.map(() => mean + 3 * sd), lineStyle: { type: 'dotted', color: '#F56C6C' }, itemStyle: { color: '#F56C6C' }, symbol: 'none' },
{ name: '-3SD', type: 'line', data: dates.map(() => mean - 3 * sd), lineStyle: { type: 'dotted', color: '#F56C6C' }, itemStyle: { color: '#F56C6C' }, symbol: 'none' },
]
})
}
const handleResize = () => chartInstance?.resize()
onMounted(async () => {
await loadResults()
await loadStats()
await nextTick()
renderChart()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>