feat(P3): 病案管理完善 — DRG/DIP分组+统计分析

- MrDrgController: DRG/DIP分组/无效标记/统计/排名
- MrDrgGrouping: 分组结果实体+V28 Flyway迁移
- 前端drg: 分组列表+DRG排名+统计卡片
- 后端编译通过,前端构建通过
This commit is contained in:
2026-06-06 20:55:14 +08:00
parent 454f717bac
commit 2cff313539
8 changed files with 377 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
package com.healthlink.his.web.mrhomepage.controller;
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.mrhomepage.domain.MrDrgGrouping;
import com.healthlink.his.mrhomepage.service.IMrDrgGroupingService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.*;
/**
* DRG/DIP分组 + 病案归档统计 Controller
*/
@RestController
@RequestMapping("/mr-drg")
@Slf4j
@AllArgsConstructor
public class MrDrgController {
private final IMrDrgGroupingService drgGroupingService;
// ==================== DRG/DIP分组 ====================
@GetMapping("/page")
public R<?> getPage(
@RequestParam(value = "groupingType", required = false) String groupingType,
@RequestParam(value = "drgCode", required = false) String drgCode,
@RequestParam(value = "patientName", required = false) String patientName,
@RequestParam(value = "isValid", required = false) Boolean isValid,
@RequestParam(value = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
LambdaQueryWrapper<MrDrgGrouping> w = new LambdaQueryWrapper<>();
w.eq(StringUtils.hasText(groupingType), MrDrgGrouping::getGroupingType, groupingType)
.like(StringUtils.hasText(drgCode), MrDrgGrouping::getDrgCode, drgCode)
.like(StringUtils.hasText(patientName), MrDrgGrouping::getPatientName, patientName)
.eq(isValid != null, MrDrgGrouping::getIsValid, isValid)
.orderByDesc(MrDrgGrouping::getDischargeDate);
return R.ok(drgGroupingService.page(new Page<>(pageNo, pageSize), w));
}
@PostMapping("/group")
@Transactional(rollbackFor = Exception.class)
public R<?> groupDrg(@RequestBody MrDrgGrouping grouping) {
// 简化DRG分组逻辑 — 根据主诊断+主手术推算DRG组
String drgCode = calculateDrgCode(grouping);
grouping.setDrgCode(drgCode);
grouping.setDrgName(getDrgName(drgCode));
grouping.setDrgWeight(getDrgWeight(drgCode));
grouping.setIsValid(true);
grouping.setCreateTime(new Date());
drgGroupingService.save(grouping);
return R.ok(grouping);
}
@PutMapping("/invalidate/{id}")
@Transactional(rollbackFor = Exception.class)
public R<?> invalidate(@PathVariable Long id, @RequestParam("reason") String reason) {
MrDrgGrouping g = drgGroupingService.getById(id);
if (g == null) return R.fail("分组记录不存在");
g.setIsValid(false);
g.setInvalidReason(reason);
drgGroupingService.updateById(g);
return R.ok();
}
// ==================== 统计分析 ====================
@GetMapping("/stats/overview")
public R<?> getOverview(
@RequestParam(value = "startDate", required = false) String startDate,
@RequestParam(value = "endDate", required = false) String endDate) {
Map<String, Object> stats = new HashMap<>();
// DRG分组统计
LambdaQueryWrapper<MrDrgGrouping> drgW = new LambdaQueryWrapper<>();
drgW.eq(MrDrgGrouping::getGroupingType, "DRG");
stats.put("drgTotal", drgGroupingService.count(drgW));
// DIP分组统计
LambdaQueryWrapper<MrDrgGrouping> dipW = new LambdaQueryWrapper<>();
dipW.eq(MrDrgGrouping::getGroupingType, "DIP");
stats.put("dipTotal", drgGroupingService.count(dipW));
// 无效分组数
LambdaQueryWrapper<MrDrgGrouping> invW = new LambdaQueryWrapper<>();
invW.eq(MrDrgGrouping::getIsValid, false);
stats.put("invalidCount", drgGroupingService.count(invW));
// 费用统计
List<MrDrgGrouping> all = drgGroupingService.list();
BigDecimal totalCost = BigDecimal.ZERO;
BigDecimal totalInsurance = BigDecimal.ZERO;
for (MrDrgGrouping g : all) {
if (g.getTotalCost() != null) totalCost = totalCost.add(g.getTotalCost());
if (g.getInsurancePayment() != null) totalInsurance = totalInsurance.add(g.getInsurancePayment());
}
stats.put("totalCost", totalCost);
stats.put("totalInsurance", totalInsurance);
return R.ok(stats);
}
@GetMapping("/stats/top-drg")
public R<?> getTopDrg(@RequestParam(value = "limit", defaultValue = "10") Integer limit) {
List<MrDrgGrouping> all = drgGroupingService.list();
Map<String, Integer> drgCount = new LinkedHashMap<>();
Map<String, BigDecimal> drgCost = new LinkedHashMap<>();
for (MrDrgGrouping g : all) {
String code = g.getDrgCode();
if (code != null) {
drgCount.merge(code, 1, Integer::sum);
drgCost.merge(code, g.getTotalCost() != null ? g.getTotalCost() : BigDecimal.ZERO, BigDecimal::add);
}
}
List<Map<String, Object>> topList = new ArrayList<>();
drgCount.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.limit(limit)
.forEach(e -> {
Map<String, Object> item = new HashMap<>();
item.put("drgCode", e.getKey());
item.put("count", e.getValue());
item.put("totalCost", drgCost.getOrDefault(e.getKey(), BigDecimal.ZERO));
topList.add(item);
});
return R.ok(topList);
}
// ==================== DRG分组算法(简化版) ====================
private String calculateDrgCode(MrDrgGrouping g) {
// 简化DRG分组: 根据主诊断前3位+主手术确定DRG组
String diagCode = g.getPrimaryDiagnosisCode();
String procCode = g.getPrimaryProcedureCode();
if (diagCode == null || diagCode.isEmpty()) return "ZZ99";
String mdc = diagCode.substring(0, Math.min(3, diagCode.length()));
if (procCode != null && !procCode.isEmpty()) {
return mdc + "-" + procCode.substring(0, Math.min(2, procCode.length()));
}
return mdc + "-MED";
}
private String getDrgName(String code) {
return "DRG组(" + code + ")";
}
private BigDecimal getDrgWeight(String code) {
// 简化权重计算
return new BigDecimal("1.0000");
}
}

View File

@@ -0,0 +1,52 @@
-- V28: 病案管理增强 — DRG/DIP分组+归档统计
-- DRG/DIP分组结果表
CREATE TABLE IF NOT EXISTS mr_drg_grouping (
id BIGSERIAL PRIMARY KEY,
encounter_id BIGINT NOT NULL,
patient_id BIGINT NOT NULL,
patient_name VARCHAR(50),
discharge_date DATE,
primary_diagnosis VARCHAR(200),
primary_diagnosis_code VARCHAR(20),
secondary_diagnosis_code VARCHAR(20),
primary_procedure VARCHAR(200),
primary_procedure_code VARCHAR(20),
drg_code VARCHAR(20),
drg_name VARCHAR(200),
drg_weight DECIMAL(8,4),
dip_code VARCHAR(20),
dip_name VARCHAR(200),
grouping_type VARCHAR(10) NOT NULL,
total_cost DECIMAL(12,2),
insurance_payment DECIMAL(12,2),
patient_payment DECIMAL(12,2),
los_days INT,
grouping_result TEXT,
is_valid BOOLEAN DEFAULT TRUE,
invalid_reason VARCHAR(200),
tenant_id BIGINT DEFAULT 0,
is_deleted INT NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT CURRENT_TIMESTAMP
);
COMMENT ON TABLE mr_drg_grouping IS 'DRG/DIP分组结果';
COMMENT ON COLUMN mr_drg_grouping.grouping_type IS '分组类型(DRG/DIP)';
CREATE INDEX idx_drg_encounter ON mr_drg_grouping(encounter_id);
CREATE INDEX idx_drg_code ON mr_drg_grouping(drg_code);
CREATE INDEX idx_dip_code ON mr_drg_grouping(dip_code);
-- 病案归档统计表
CREATE TABLE IF NOT EXISTS mr_archive_stats (
id BIGSERIAL PRIMARY KEY,
stat_date DATE NOT NULL,
department_id BIGINT,
department_name VARCHAR(100),
total_discharged INT DEFAULT 0,
archived_count INT DEFAULT 0,
archive_rate DECIMAL(5,2),
avg_archive_hours DECIMAL(8,2),
tenant_id BIGINT DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT CURRENT_TIMESTAMP
);
COMMENT ON TABLE mr_archive_stats IS '病案归档统计(每日)';
CREATE UNIQUE INDEX idx_mras_date_dept ON mr_archive_stats(stat_date, department_id);

View File

@@ -0,0 +1,42 @@
package com.healthlink.his.mrhomepage.domain;
import com.baomidou.mybatisplus.annotation.*;
import com.core.common.core.domain.HisBaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* DRG/DIP分组结果
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mr_drg_grouping")
public class MrDrgGrouping extends HisBaseEntity {
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
private Long encounterId;
private Long patientId;
private String patientName;
private LocalDate dischargeDate;
private String primaryDiagnosis;
private String primaryDiagnosisCode;
private String secondaryDiagnosisCode;
private String primaryProcedure;
private String primaryProcedureCode;
private String drgCode;
private String drgName;
private BigDecimal drgWeight;
private String dipCode;
private String dipName;
private String groupingType;
private BigDecimal totalCost;
private BigDecimal insurancePayment;
private BigDecimal patientPayment;
private Integer losDays;
private String groupingResult;
private Boolean isValid;
private String invalidReason;
}

View File

@@ -0,0 +1,9 @@
package com.healthlink.his.mrhomepage.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.healthlink.his.mrhomepage.domain.MrDrgGrouping;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MrDrgGroupingMapper extends BaseMapper<MrDrgGrouping> {
}

View File

@@ -0,0 +1,7 @@
package com.healthlink.his.mrhomepage.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.healthlink.his.mrhomepage.domain.MrDrgGrouping;
public interface IMrDrgGroupingService extends IService<MrDrgGrouping> {
}

View File

@@ -0,0 +1,13 @@
package com.healthlink.his.mrhomepage.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.healthlink.his.mrhomepage.domain.MrDrgGrouping;
import com.healthlink.his.mrhomepage.mapper.MrDrgGroupingMapper;
import com.healthlink.his.mrhomepage.service.IMrDrgGroupingService;
import org.springframework.stereotype.Service;
@Service
public class MrDrgGroupingServiceImpl
extends ServiceImpl<MrDrgGroupingMapper, MrDrgGrouping>
implements IMrDrgGroupingService {
}

View File

@@ -0,0 +1,6 @@
import request from '@/utils/request'
export function getPage(p){return request({url:'/mr-drg/page',method:'get',params:p})}
export function groupDrg(d){return request({url:'/mr-drg/group',method:'post',data:d})}
export function invalidate(id,reason){return request({url:'/mr-drg/invalidate/'+id,method:'put',params:{reason}})}
export function getOverview(p){return request({url:'/mr-drg/stats/overview',method:'get',params:p})}
export function getTopDrg(p){return request({url:'/mr-drg/stats/top-drg',method:'get',params:p})}

View File

@@ -0,0 +1,91 @@
<template>
<div style="padding:16px">
<div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:18px;font-weight:bold">DRG/DIP分组管理</span>
<el-button type="primary" @click="loadStats">刷新统计</el-button>
</div>
<el-row :gutter="12" style="margin-bottom:16px">
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#409eff">{{ overview.drgTotal||0 }}</div><div style="font-size:12px;color:#999">DRG分组</div></div></el-card></el-col>
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#67c23a">{{ overview.dipTotal||0 }}</div><div style="font-size:12px;color:#999">DIP分组</div></div></el-card></el-col>
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#f56c6c">{{ overview.invalidCount||0 }}</div><div style="font-size:12px;color:#999">无效分组</div></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#e6a23c">¥{{ (overview.totalCost||0).toLocaleString() }}</div><div style="font-size:12px;color:#999">总费用</div></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#909399">¥{{ (overview.totalInsurance||0).toLocaleString() }}</div><div style="font-size:12px;color:#999">医保支付</div></div></el-card></el-col>
</el-row>
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="分组列表" name="list">
<div style="margin-bottom:12px;display:flex;gap:8px">
<el-select v-model="q.groupingType" placeholder="分组类型" clearable style="width:120px">
<el-option label="DRG" value="DRG"/><el-option label="DIP" value="DIP"/>
</el-select>
<el-input v-model="q.patientName" placeholder="患者" clearable style="width:140px"/>
<el-button type="primary" @click="loadData">查询</el-button>
<el-button type="success" @click="openGroup">新建分组</el-button>
</div>
<el-table :data="tableData" border stripe>
<el-table-column prop="patientName" label="患者" width="100"/>
<el-table-column prop="groupingType" label="类型" width="70" align="center">
<template #default="{row}"><el-tag :type="row.groupingType==='DRG'?'primary':'success'" size="small">{{ row.groupingType }}</el-tag></template>
</el-table-column>
<el-table-column prop="drgCode" label="DRG编码" width="120"/>
<el-table-column prop="drgName" label="DRG名称" width="150" show-overflow-tooltip/>
<el-table-column prop="drgWeight" label="权重" width="70" align="center"/>
<el-table-column prop="primaryDiagnosis" label="主诊断" width="150" show-overflow-tooltip/>
<el-table-column prop="totalCost" label="总费用" width="100" align="right">
<template #default="{row}">¥{{ row.totalCost }}</template>
</el-table-column>
<el-table-column prop="losDays" label="住院天数" width="80" align="center"/>
<el-table-column prop="isValid" label="有效" width="60" align="center">
<template #default="{row}"><el-tag :type="row.isValid?'success':'danger'" size="small">{{ row.isValid?'是':'否' }}</el-tag></template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:12px;justify-content:flex-end" v-model:current-page="q.pageNo" v-model:page-size="q.pageSize" :total="total" layout="total,prev,pager,next" @current-change="loadData"/>
</el-tab-pane>
<el-tab-pane label="DRG排名" name="top">
<el-table :data="topDrg" border stripe>
<el-table-column type="index" label="排名" width="70"/>
<el-table-column prop="drgCode" label="DRG编码" width="120"/>
<el-table-column prop="count" label="病例数" width="80" align="center"/>
<el-table-column prop="totalCost" label="总费用" width="120" align="right">
<template #default="{row}">¥{{ row.totalCost }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="groupDialog" title="新建DRG/DIP分组" width="600px">
<el-form :model="groupForm" label-width="100px">
<el-form-item label="患者ID"><el-input v-model.number="groupForm.patientId"/></el-form-item>
<el-form-item label="就诊ID"><el-input v-model.number="groupForm.encounterId"/></el-form-item>
<el-form-item label="患者姓名"><el-input v-model="groupForm.patientName"/></el-form-item>
<el-form-item label="出院日期"><el-date-picker v-model="groupForm.dischargeDate" type="date"/></el-form-item>
<el-form-item label="主诊断"><el-input v-model="groupForm.primaryDiagnosis"/></el-form-item>
<el-form-item label="主诊断编码"><el-input v-model="groupForm.primaryDiagnosisCode"/></el-form-item>
<el-form-item label="主手术"><el-input v-model="groupForm.primaryProcedure"/></el-form-item>
<el-form-item label="主手术编码"><el-input v-model="groupForm.primaryProcedureCode"/></el-form-item>
<el-form-item label="分组类型">
<el-radio-group v-model="groupForm.groupingType"><el-radio value="DRG">DRG</el-radio><el-radio value="DIP">DIP</el-radio></el-radio-group>
</el-form-item>
<el-form-item label="总费用"><el-input-number v-model="groupForm.totalCost" :min="0" :precision="2"/></el-form-item>
<el-form-item label="住院天数"><el-input-number v-model="groupForm.losDays" :min="1"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="groupDialog=false">取消</el-button>
<el-button type="primary" @click="doGroup">执行分组</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {ref,onMounted} from 'vue'
import {ElMessage} from 'element-plus'
import {getPage,groupDrg,getOverview,getTopDrg} from './api'
const activeTab=ref('list')
const tableData=ref([]);const total=ref(0);const overview=ref({});const topDrg=ref([])
const q=ref({pageNo:1,pageSize:20,groupingType:'',patientName:''})
const groupDialog=ref(false)
const groupForm=ref({patientId:null,encounterId:null,patientName:'',dischargeDate:null,primaryDiagnosis:'',primaryDiagnosisCode:'',primaryProcedure:'',primaryProcedureCode:'',groupingType:'DRG',totalCost:0,losDays:1})
const loadData=async()=>{const r=await getPage(q.value);tableData.value=r.data?.records||[];total.value=r.data?.total||0}
const loadStats=async()=>{const r=await getOverview();overview.value=r.data||{};const t=await getTopDrg();topDrg.value=t.data||[]}
const openGroup=()=>{groupDialog.value=true}
const doGroup=async()=>{await groupDrg(groupForm.value);ElMessage.success('分组完成');groupDialog.value=false;loadData();loadStats()}
onMounted(()=>{loadData();loadStats()})
</script>