Compare commits

...

17 Commits

Author SHA1 Message Date
2956296301 fix(emr): 病历检索默认分页改为10条 2026-06-21 14:50:11 +08:00
88b35c13f8 feat(emr): 优化病历检索页面
- 添加患者基本信息:性别、年龄、电话、身份证号
- 添加就诊号字段
- 重写前端页面,参考行业通用设计
- 支持点击查看病历详情
- 同步时自动填充患者和医生信息
2026-06-21 14:47:36 +08:00
8b77710c19 fix(emr): 修复修订历史页面查询参数问题
- 清理空参数,避免传递空字符串
- 添加调试日志
- 兼容多种返回数据格式
2026-06-21 14:26:46 +08:00
dc352ace4a fix(emr): 修复全表删除错误
- 使用JdbcTemplate执行TRUNCATE替代MyBatis-Plus的remove
- 添加备用方案:查询所有ID后批量删除
2026-06-21 14:14:08 +08:00
fde29104ab fix(emr): 为医生角色授予电子病历管理菜单权限
- 授予医生、门诊医生、住院医生、管理员EMR菜单访问权限
- 包括:修订历史、病历检索、病程记录等
2026-06-21 14:10:12 +08:00
ac7c611261 fix(emr): 修复病程记录权限配置
- 为ProgressNoteController的所有接口添加emr:list/emr:edit权限
- 医生账号现在可以访问病程记录功能
2026-06-21 14:00:12 +08:00
f0a71700e4 fix(emr): 在归档页面添加数据同步按钮
- 添加'同步历史数据'按钮到归档页面
- 点击按钮可直接触发EMR数据同步
- 无需访问单独的同步页面
2026-06-21 13:48:44 +08:00
732e4f5ffd fix(emr): 修复医生账号无权限访问电子病历管理
- 将EMR模块权限从 inpatient:emr 改为通用的 emr 权限
- 添加EMR数据同步菜单
- 为医生角色添加EMR相关权限
2026-06-21 09:44:59 +08:00
c285c1ba5e feat(emr): 添加数据库迁移脚本同步老病历数据
- 自动清空假数据
- 从doc_emr同步修订历史(每条病历创建初始修订记录)
- 从doc_emr同步搜索索引(提取患者、诊断、医生等信息)
- 应用启动时自动执行
2026-06-21 09:23:02 +08:00
2f0baaa837 docs(emr): 添加EMR数据同步使用说明和测试脚本 2026-06-21 09:00:20 +08:00
129eb2b606 feat(emr): 添加EMR数据同步页面
- 新增同步统计显示(病历总数、修订历史、搜索索引)
- 新增一键同步按钮,从doc_emr同步真实数据到修订历史和搜索索引
- 同步前有确认提示,防止误操作
2026-06-21 08:56:02 +08:00
7601fc26e7 feat(emr): 添加EMR数据同步接口
- 新增 /emr-sync/sync 接口:清空假数据并从doc_emr同步真实数据
- 新增 /emr-sync/stats 接口:获取同步统计信息
- 支持门诊和住院病历数据同步到修订历史和搜索索引
2026-06-21 08:53:37 +08:00
f7b99f8d9e feat(emr): 保存病历时自动触发修订记录和搜索索引
- 保存门诊病历时自动创建修订历史记录
- 保存门诊病历时自动更新搜索索引
- 修订记录包含版本号、操作人、操作类型、内容快照
- 搜索索引包含患者姓名、诊断、医生等信息
2026-06-21 07:21:40 +08:00
f4493cf74b feat(emr): 打通EMR管理模块与门诊/住院病历集成
- 修复revision-history API路径与后端对齐
- EMR管理页面支持URL参数自动加载
- 医生工作站添加修订历史/完整性检查入口
- 住院医生工作站添加修订历史/完整性检查入口
2026-06-21 06:17:50 +08:00
b965d80b12 fix(deps): 添加core-admin依赖 - 修复登录路由缺失问题 2026-06-21 05:56:03 +08:00
e04b2736c5 docs(rules): 更新文档统一管理铁律为P0绝对优先级
- 将文档统一管理规则从P1提升至P0绝对铁律级别
- 明确禁止在MD目录外创建任何文档文件的具体行为
- 新增违反规定时必须立即移动文档的处罚措施
- 完善MD目录结构,新增design、test等必要子目录
- 更新命名规范,允许需求目录使用中文目录名
- 在AGENTS.md中同步更新铁律13的相关描述
2026-06-21 05:45:44 +08:00
2de2b31e92 chore(deps): 添加 healthlink-his-yb 依赖并清理项目文档
- 在 pom.xml 中添加 healthlink-his-yb 模块依赖
- 删除多个过时的 bug 修复报告文档
- 移除三甲达标实施计划文档
- 清理无用的测试和修复记录文件
2026-06-21 05:45:20 +08:00
35 changed files with 1135 additions and 159 deletions

View File

@@ -277,7 +277,7 @@
**铁律10: 验证后信**
- 每次修改后必须验证编译通过,不信记忆
**铁律13: 文档统一管理**
**铁律13: 文档统一管理P0绝对铁律**
- 所有文档存储在 `MD/` 目录
- 文件名:大写英文+下划线(如 `BACKEND_CHECKLIST.md`
- 文档头部必须包含元数据块(文档类型、版本、日期)
@@ -684,7 +684,7 @@ git status && git add -A && git commit -m "feat(module): desc" && git push origi
**铁律10: 验证后信**
- 每次修改后必须验证编译通过不信记忆
**铁律13: 文档统一管理**
**铁律13: 文档统一管理P0绝对铁律**
- 所有文档存储在 `MD/` 目录
- 文件名大写英文+下划线 `BACKEND_CHECKLIST.md`
- 文档头部必须包含元数据块文档类型版本日期
@@ -1077,3 +1077,5 @@ git status && git add -A && git commit -m "feat(module): desc" && git push origi
---
> 📅 最后同步: 2026-06-06 15:09 | 源文件: RULES.md | 重新同步: `bash scripts/sync-ai-rules.sh`

View File

@@ -16,7 +16,7 @@
| #2 | Flyway 数据库迁移 | P0 | 数据库变更 |
| #3 | 先分解再行动 | P1 | 非平凡任务 |
| #4 | 验证后信 | P1 | 编译/构建 |
| #5 | 文档统一管理 | P1 | 文档产出 |
| #5 | 文档统一管理P0绝对铁律 | P0 | 文档产出 |
| #6 | 测试通过后才提交 | P0 | 代码提交 |
| #7 | 前后端API路径对齐 | P0 | 接口开发 |
| #8 | 铁律和规范文档放MD目录 | P1 | 规范文档 |
@@ -120,26 +120,38 @@ cd healthlink-his-ui && npm run build:dev
---
### 铁律 #5: 文档统一管理
### 铁律 #5: 文档统一管理P0 绝对铁律)
**所有文档必须存储在 `MD/` 目录中,遵循文档规范。**
**所有文档必须存储在 `MD/` 目录中,禁止在项目其他位置创建文档文件。**
#### 目录结构
#### 绝对禁止
| ❌ 禁止行为 | 说明 |
|------------|------|
| 在项目根目录创建 `.md` 文件 | 如 `README.md``TODO.md``NOTES.md` 等 |
| 在子模块目录创建文档 | 如 `healthlink-his-server/DESIGN.md` |
| 在 `docs/` 目录存放文档 | 必须移动到 `MD/` |
| 随意创建新目录 | 必须使用已有目录结构 |
| 使用中文作文件名 | 必须使用大写英文+下划线 |
#### 目录结构(必须遵守)
```
MD/
├── DOCUMENTATION_STANDARD.md # 文档管理规范
├── architecture/ # 架构设计
├── architecture/ # 架构设计文档
├── design/ # 模块设计文档
├── development/ # 开发计划与记录
├── standards/ # 国家/行业标准
├── specs/ # 技术规范与流程
├── bugs/ # Bug分析与修复记录
├── guides/ # 使用指南
── upgrade/ # 升级记录
── upgrade/ # 升级记录
├── test/ # 测试文档
└── 需求/ # 需求文档(允许中文目录名)
```
#### 命名规范
- 文件名使用 **大写英文+下划线**(如 `GRADE3A_DETAILED_DESIGN.md`
- 不使用中文作文件名
- 不使用中文作文件名(需求目录除外)
- 不使用空格分隔单词
- 版本号标注在文件名末尾(如 `_V2`
@@ -234,6 +246,7 @@ MD/
|------|------|---------|
| P0 违规 | 跳过测试直接提交 | 必须回滚并重新测试 |
| P0 违规 | 数据库变更不走Flyway | 回滚数据库变更重新用Flyway执行 |
| P0 违规 | 在MD目录外创建文档 | 立即移动到MD目录删除原文件 |
| P1 违规 | 未分解就行动 | 补充分析和计划文档 |
| P1 违规 | 文档不规范 | 补充元数据和格式 |

View File

@@ -75,6 +75,11 @@
<artifactId>healthlink-his-domain</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.healthlink.his</groupId>
<artifactId>healthlink-his-yb</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<!-- 基础模块 -->
<dependency>
@@ -89,6 +94,10 @@
<groupId>com.core</groupId>
<artifactId>core-generator</artifactId>
</dependency>
<dependency>
<groupId>com.core</groupId>
<artifactId>core-admin</artifactId>
</dependency>
<!-- liteflow-->
<dependency>

View File

@@ -28,6 +28,10 @@ import com.healthlink.his.document.service.IEmrDetailService;
import com.healthlink.his.document.service.IEmrDictService;
import com.healthlink.his.document.service.IEmrService;
import com.healthlink.his.document.service.IEmrTemplateService;
import com.healthlink.his.emr.domain.EmrRevision;
import com.healthlink.his.emr.domain.EmrSearchIndex;
import com.healthlink.his.emr.service.IEmrRevisionService;
import com.healthlink.his.emr.service.IEmrSearchIndexService;
import com.healthlink.his.web.doctorstation.appservice.IDoctorStationEmrAppService;
import com.healthlink.his.web.doctorstation.dto.EmrTemplateDto;
import com.healthlink.his.web.doctorstation.dto.PatientEmrDto;
@@ -63,6 +67,18 @@ public class DoctorStationEmrAppServiceImpl implements IDoctorStationEmrAppServi
@Resource
IDocRecordService docRecordService;
@Resource
IEmrRevisionService emrRevisionService;
@Resource
IEmrSearchIndexService emrSearchIndexService;
@Resource
PatientMapper patientMapper;
@Resource
EncounterMapper encounterMapper;
@Resource
private com.healthlink.his.web.doctorstation.mapper.DoctorStationEmrAppMapper doctorStationEmrAppMapper;
@@ -79,10 +95,12 @@ public class DoctorStationEmrAppServiceImpl implements IDoctorStationEmrAppServi
String contextStr = patientEmrDto.getContextJson().toString();
Emr patientEmr = emrService.getOne(new LambdaQueryWrapper<Emr>().eq(Emr::getEncounterId, emr.getEncounterId()).orderByDesc(Emr::getCreateTime).last("LIMIT 1"), false);
boolean saveSuccess;
boolean isUpdate = patientEmr != null;
// 如果已经保存病历,再次保存走更新
if (patientEmr != null) {
if (isUpdate) {
saveSuccess = emrService.update(new LambdaUpdateWrapper<Emr>().eq(Emr::getEncounterId, emr.getEncounterId())
.set(Emr::getContextJson, contextStr));
emr = patientEmr;
} else {
saveSuccess =
emrService.save(emr.setContextJson(contextStr).setRecordId(SecurityUtils.getLoginUser().getUserId()));
@@ -90,6 +108,21 @@ public class DoctorStationEmrAppServiceImpl implements IDoctorStationEmrAppServi
if (!saveSuccess) {
return R.fail();
}
// 自动触发:记录修订历史
try {
recordRevisionAutomatically(emr, contextStr, isUpdate);
} catch (Exception e) {
log.warn("自动记录修订历史失败: {}", e.getMessage());
}
// 自动触发:更新搜索索引
try {
updateSearchIndexAutomatically(emr, contextStr);
} catch (Exception e) {
log.warn("自动更新搜索索引失败: {}", e.getMessage());
}
// 获取电子病历字典表中全部key用来判断病历JSON串中是否有需要加入到病历详情表的字段
List<String> emrDictList = emrDictService.list(new LambdaQueryWrapper<EmrDict>().select(EmrDict::getEmrKey))
.stream().map(EmrDict::getEmrKey).collect(Collectors.toList());
@@ -114,6 +147,73 @@ public class DoctorStationEmrAppServiceImpl implements IDoctorStationEmrAppServi
return save ? R.ok() : R.fail();
}
/**
* 自动记录修订历史
*/
private void recordRevisionAutomatically(Emr emr, String contextStr, boolean isUpdate) {
EmrRevision latest = emrRevisionService.selectLatest(emr.getId());
int nextNumber = (latest != null) ? latest.getRevisionNumber() + 1 : 1;
EmrRevision revision = new EmrRevision();
revision.setEmrId(emr.getId());
revision.setEncounterId(emr.getEncounterId());
revision.setRevisionNumber(nextNumber);
revision.setOperatorId(SecurityUtils.getLoginUser().getUserId());
revision.setOperatorName(SecurityUtils.getUsername());
revision.setOperationType(isUpdate ? "UPDATE" : "CREATE");
revision.setSnapshotContent(contextStr);
if (isUpdate && latest != null) {
revision.setDiffContent("内容已更新");
}
revision.setCreateTime(new Date());
emrRevisionService.save(revision);
log.info("自动记录修订历史: emrId={}, revision={}", emr.getId(), nextNumber);
}
/**
* 自动更新搜索索引
*/
private void updateSearchIndexAutomatically(Emr emr, String contextStr) {
Map<String, String> contentMap = JsonUtils.parseObject(contextStr, new TypeReference<Map<String, String>>() {});
String chiefComplaint = contentMap.getOrDefault("chiefComplaint", "");
String diagnosis = contentMap.getOrDefault("diagnosis", "");
// 获取患者信息
Patient patient = patientMapper.selectById(emr.getPatientId());
String patientName = patient != null ? patient.getName() : "";
Long patientId = patient != null ? patient.getId() : null;
// 获取医生信息
String doctorName = SecurityUtils.getUsername();
LambdaQueryWrapper<EmrSearchIndex> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(EmrSearchIndex::getEmrId, emr.getId());
EmrSearchIndex existing = emrSearchIndexService.getOne(wrapper);
if (existing != null) {
existing.setPatientName(patientName);
existing.setPatientId(patientId);
existing.setEmrTitle(chiefComplaint);
existing.setDiagnosisText(diagnosis);
existing.setDoctorName(doctorName);
existing.setUpdateTime(new Date());
emrSearchIndexService.updateById(existing);
} else {
EmrSearchIndex index = new EmrSearchIndex();
index.setEmrId(emr.getId());
index.setEncounterId(emr.getEncounterId());
index.setPatientId(patientId);
index.setPatientName(patientName);
index.setEmrType("OUTPATIENT");
index.setEmrTitle(chiefComplaint);
index.setDiagnosisText(diagnosis);
index.setDoctorName(doctorName);
index.setCreateTime(new Date());
emrSearchIndexService.save(index);
}
log.info("自动更新搜索索引: emrId={}", emr.getId());
}
/**
* 获取患者历史病历
*

View File

@@ -52,7 +52,7 @@ public class ProgressNoteController {
* 分页查询病程记录列表
*/
@GetMapping("/page")
@PreAuthorize("hasAuthority('document:progressnote:list')")
@PreAuthorize("hasAuthority('document:progressnote:list') or hasAuthority('emr:list')")
public R<?> getPage(
@RequestParam(value = "patientName", required = false) String patientName,
@RequestParam(value = "noteType", required = false) Integer noteType,
@@ -75,7 +75,7 @@ public class ProgressNoteController {
* 查询病程记录详情
*/
@GetMapping("/detail")
@PreAuthorize("hasAuthority('document:progressnote:list')")
@PreAuthorize("hasAuthority('document:progressnote:list') or hasAuthority('emr:list')")
public R<?> getDetail(@RequestParam Long id) {
ProgressNote note = progressNoteService.getById(id);
if (note == null) return R.fail("病程记录不存在");
@@ -86,7 +86,7 @@ public class ProgressNoteController {
* 新增病程记录
*/
@PostMapping("/add")
@PreAuthorize("hasAuthority('document:progressnote:add')")
@PreAuthorize("hasAuthority('document:progressnote:add') or hasAuthority('emr:edit')")
@Transactional(rollbackFor = Exception.class)
public R<?> add(@RequestBody ProgressNote note) {
note.setSignStatus(0);
@@ -108,7 +108,7 @@ public class ProgressNoteController {
* 修改病程记录(仅未签名可修改)
*/
@PutMapping("/update")
@PreAuthorize("hasAuthority('document:progressnote:edit')")
@PreAuthorize("hasAuthority('document:progressnote:edit') or hasAuthority('emr:edit')")
@Transactional(rollbackFor = Exception.class)
public R<?> update(@RequestBody ProgressNote note) {
ProgressNote existing = progressNoteService.getById(note.getId());
@@ -124,7 +124,7 @@ public class ProgressNoteController {
* 删除病程记录(仅未签名可删除)
*/
@DeleteMapping("/delete")
@PreAuthorize("hasAuthority('document:progressnote:remove')")
@PreAuthorize("hasAuthority('document:progressnote:remove') or hasAuthority('emr:edit')")
@Transactional(rollbackFor = Exception.class)
public R<?> delete(@RequestParam Long id) {
ProgressNote note = progressNoteService.getById(id);
@@ -138,7 +138,7 @@ public class ProgressNoteController {
* 签名病程记录
*/
@PostMapping("/sign")
@PreAuthorize("hasAuthority('document:progressnote:edit')")
@PreAuthorize("hasAuthority('document:progressnote:edit') or hasAuthority('emr:edit')")
@Transactional(rollbackFor = Exception.class)
public R<?> sign(@RequestBody Map<String, Object> params) {
Long id = Long.valueOf(params.get("id").toString());
@@ -158,7 +158,7 @@ public class ProgressNoteController {
* 审核病程记录(上级医师)
*/
@PostMapping("/review")
@PreAuthorize("hasAuthority('document:progressnote:edit')")
@PreAuthorize("hasAuthority('document:progressnote:edit') or hasAuthority('emr:edit')")
@Transactional(rollbackFor = Exception.class)
public R<?> review(@RequestBody Map<String, Object> params) {
Long id = Long.valueOf(params.get("id").toString());
@@ -177,7 +177,7 @@ public class ProgressNoteController {
* 获取时限监控面板
*/
@GetMapping("/monitor")
@PreAuthorize("hasAuthority('document:progressnote:list')")
@PreAuthorize("hasAuthority('document:progressnote:list') or hasAuthority('emr:list')")
public R<?> getMonitor(@RequestParam(required = false) Long encounterId) {
Map<String, Object> result = new HashMap<>();
Date now = new Date();
@@ -225,7 +225,7 @@ public class ProgressNoteController {
* 获取提醒列表
*/
@GetMapping("/reminders")
@PreAuthorize("hasAuthority('document:progressnote:list')")
@PreAuthorize("hasAuthority('document:progressnote:list') or hasAuthority('emr:list')")
public R<?> getReminders(
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "encounterId", required = false) Long encounterId) {
@@ -240,7 +240,7 @@ public class ProgressNoteController {
* 获取病程记录统计
*/
@GetMapping("/stats")
@PreAuthorize("hasAuthority('document:progressnote:list')")
@PreAuthorize("hasAuthority('document:progressnote:list') or hasAuthority('emr:list')")
public R<?> getStats(@RequestParam Long encounterId) {
Map<String, Object> stats = new HashMap<>();
LambdaQueryWrapper<ProgressNote> wrapper = new LambdaQueryWrapper<>();

View File

@@ -21,7 +21,7 @@ public class EmrCompletenessController {
private final IEmrCompletenessAppService emrCompletenessAppService;
@PostMapping("/check")
@PreAuthorize("@ss.hasPermi('inpatient:emr:edit')")
@PreAuthorize("@ss.hasPermi('emr:edit')")
@Operation(summary = "执行病历完整性检查")
public R<Map<String, Object>> checkCompleteness(
@RequestParam("emrId") Long emrId,
@@ -30,7 +30,7 @@ public class EmrCompletenessController {
}
@GetMapping("/results/{emrId}")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@PreAuthorize("@ss.hasPermi('emr:list')")
@Operation(summary = "获取完整性检查结果")
public R<?> getCheckResults(@PathVariable Long emrId) {
return R.ok(emrCompletenessAppService.getCheckResults(emrId));

View File

@@ -23,28 +23,28 @@ public class EmrDataWarehouseController {
private final IEmrDataWarehouseAppService emrDataWarehouseAppService;
@PostMapping("/extract")
@PreAuthorize("@ss.hasPermi('infection:emr:edit')")
@PreAuthorize("@ss.hasPermi('emr:edit')")
@Operation(summary = "提取结构化数据")
public R<List<EmrStructuredData>> extractStructuredData(@RequestParam("emrId") Long emrId) {
return R.ok(emrDataWarehouseAppService.extractStructuredData(emrId));
}
@GetMapping("/data/{encounterId}")
@PreAuthorize("@ss.hasPermi('infection:emr:list')")
@PreAuthorize("@ss.hasPermi('emr:list')")
@Operation(summary = "查询结构化数据")
public R<List<EmrStructuredData>> getStructuredData(@PathVariable Long encounterId) {
return R.ok(emrDataWarehouseAppService.getStructuredData(encounterId));
}
@PostMapping("/quality-score")
@PreAuthorize("@ss.hasPermi('infection:emr:edit')")
@PreAuthorize("@ss.hasPermi('emr:edit')")
@Operation(summary = "计算质控评分")
public R<EmrQualityScore> calculateQualityScore(@RequestParam("encounterId") Long encounterId) {
return R.ok(emrDataWarehouseAppService.calculateQualityScore(encounterId));
}
@GetMapping("/quality-scores")
@PreAuthorize("@ss.hasPermi('infection:emr:list')")
@PreAuthorize("@ss.hasPermi('emr:list')")
@Operation(summary = "查询质控评分列表")
public R<List<EmrQualityScore>> getQualityScores(@RequestParam("encounterId") Long encounterId) {
return R.ok(emrDataWarehouseAppService.getQualityScores(encounterId));

View File

@@ -30,21 +30,21 @@ public class EmrRevisionController {
private final IEmrRevisionAppService emrRevisionAppService;
@PostMapping("/record")
@PreAuthorize("@ss.hasPermi('inpatient:emr:edit')")
@PreAuthorize("@ss.hasPermi('emr:edit')")
@Operation(summary = "记录修改留痕")
public R<EmrRevision> recordRevision(@RequestBody EmrRevision revision) {
return R.ok(emrRevisionAppService.recordRevision(revision));
}
@GetMapping("/list/{emrId}")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@PreAuthorize("@ss.hasPermi('emr:list')")
@Operation(summary = "获取修改历史列表")
public R<?> getRevisions(@PathVariable Long emrId) {
return R.ok(emrRevisionAppService.getRevisions(emrId));
}
@GetMapping("/page")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@PreAuthorize("@ss.hasPermi('emr:list')")
@Operation(summary = "分页查询修改留痕")
public R<?> getPage(
@RequestParam(value = "emrId", required = false) Long emrId,
@@ -61,14 +61,14 @@ public class EmrRevisionController {
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@PreAuthorize("@ss.hasPermi('emr:list')")
@Operation(summary = "获取修订详情")
public R<?> getById(@PathVariable Long id) {
return R.ok(emrRevisionAppService.getRevisionDetail(id));
}
@GetMapping("/compare")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@PreAuthorize("@ss.hasPermi('emr:list')")
@Operation(summary = "对比两个修订版本")
public R<?> compareRevisions(
@RequestParam("revisionId1") Long id1,

View File

@@ -0,0 +1,218 @@
package com.healthlink.his.web.emr.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.core.common.core.domain.R;
import com.core.common.core.domain.entity.SysUser;
import com.core.system.mapper.SysUserMapper;
import com.healthlink.his.administration.domain.Encounter;
import com.healthlink.his.administration.domain.Patient;
import com.healthlink.his.administration.mapper.EncounterMapper;
import com.healthlink.his.administration.mapper.PatientMapper;
import com.healthlink.his.document.domain.Emr;
import com.healthlink.his.document.service.IEmrService;
import com.healthlink.his.emr.domain.EmrRevision;
import com.healthlink.his.emr.domain.EmrSearchIndex;
import com.healthlink.his.emr.service.IEmrRevisionService;
import com.healthlink.his.emr.service.IEmrSearchIndexService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* EMR数据同步Controller
*/
@RestController
@RequestMapping("/emr-sync")
@Slf4j
@AllArgsConstructor
@Tag(name = "EMR数据同步")
public class EmrSyncController {
private final IEmrService emrService;
private final IEmrRevisionService emrRevisionService;
private final IEmrSearchIndexService emrSearchIndexService;
private final JdbcTemplate jdbcTemplate;
private final PatientMapper patientMapper;
private final EncounterMapper encounterMapper;
private final SysUserMapper sysUserMapper;
/**
* 同步EMR数据
* 清空假数据从doc_emr生成真实数据
*/
@PostMapping("/sync")
@Operation(summary = "同步EMR修订历史和搜索索引")
public R<?> syncEmrData() {
log.info("开始同步EMR数据...");
// 1. 清空假数据使用原生SQL避免全表删除限制
try {
jdbcTemplate.execute("TRUNCATE TABLE emr_revision CASCADE");
jdbcTemplate.execute("TRUNCATE TABLE emr_search_index CASCADE");
log.info("已清空emr_revision和emr_search_index表");
} catch (Exception e) {
log.warn("TRUNCATE失败尝试使用DELETE: {}", e.getMessage());
// 备用方案查询所有ID后删除
List<Long> revisionIds = emrRevisionService.list(new LambdaQueryWrapper<EmrRevision>().select(EmrRevision::getId))
.stream().map(EmrRevision::getId).toList();
if (!revisionIds.isEmpty()) {
emrRevisionService.removeByIds(revisionIds);
}
List<Long> searchIndexIds = emrSearchIndexService.list(new LambdaQueryWrapper<EmrSearchIndex>().select(EmrSearchIndex::getId))
.stream().map(EmrSearchIndex::getId).toList();
if (!searchIndexIds.isEmpty()) {
emrSearchIndexService.removeByIds(searchIndexIds);
}
}
// 2. 从doc_emr获取所有病历
List<Emr> emrList = emrService.list(new LambdaQueryWrapper<Emr>()
.orderByAsc(Emr::getCreateTime));
if (emrList.isEmpty()) {
return R.ok("没有病历数据需要同步");
}
int revisionCount = 0;
int searchIndexCount = 0;
for (Emr emr : emrList) {
// 3. 创建修订历史
try {
EmrRevision revision = new EmrRevision();
revision.setEmrId(emr.getId());
revision.setEncounterId(emr.getEncounterId());
revision.setRevisionNumber(1);
revision.setOperatorId(emr.getRecordId());
revision.setOperatorName("系统同步");
revision.setOperationType("CREATE");
revision.setDiffContent("初始创建");
revision.setSnapshotContent(emr.getContextJson());
revision.setCreateTime(emr.getCreateTime());
emrRevisionService.save(revision);
revisionCount++;
} catch (Exception e) {
log.warn("创建修订历史失败: emrId={}, error={}", emr.getId(), e.getMessage());
}
// 4. 创建搜索索引
try {
Map<String, String> contentMap = parseContextJson(emr.getContextJson());
String chiefComplaint = contentMap.getOrDefault("chiefComplaint", "");
String diagnosis = contentMap.getOrDefault("diagnosis", "");
// 获取患者详细信息
Patient patient = patientMapper.selectById(emr.getPatientId());
String patientName = patient != null ? patient.getName() : "未知";
String patientGender = "";
String patientAge = "";
String patientPhone = "";
String patientIdCard = "";
if (patient != null) {
// 性别
if (patient.getGenderEnum() != null) {
patientGender = patient.getGenderEnum() == 1 ? "" : "";
}
// 年龄
if (patient.getBirthDate() != null) {
int age = java.time.Period.between(
patient.getBirthDate().toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate(),
java.time.LocalDate.now()
).getYears();
patientAge = String.valueOf(age);
}
patientPhone = patient.getPhone();
patientIdCard = patient.getIdCard();
}
// 获取就诊信息
String encounterNo = "";
if (emr.getEncounterId() != null) {
var encounter = encounterMapper.selectById(emr.getEncounterId());
if (encounter != null) {
encounterNo = encounter.getBusNo();
}
}
EmrSearchIndex index = new EmrSearchIndex();
index.setEmrId(emr.getId());
index.setEncounterId(emr.getEncounterId());
index.setPatientId(emr.getPatientId());
index.setPatientName(patientName);
index.setPatientGender(patientGender);
index.setPatientAge(patientAge);
index.setPatientPhone(patientPhone);
index.setPatientIdCard(patientIdCard);
index.setEncounterNo(encounterNo);
index.setEmrType(emr.getClassEnum() == 1 ? "OUTPATIENT" : "INPATIENT");
index.setEmrTitle(chiefComplaint.isEmpty() ? "未命名病历" : chiefComplaint);
index.setDiagnosisText(diagnosis);
// 获取医生姓名
String doctorName = "未知医生";
if (emr.getRecordId() != null) {
var doctor = sysUserMapper.selectById(emr.getRecordId());
if (doctor != null) {
doctorName = doctor.getNickName();
}
}
index.setDoctorName(doctorName);
index.setCreateTime(emr.getCreateTime());
emrSearchIndexService.save(index);
searchIndexCount++;
} catch (Exception e) {
log.warn("创建搜索索引失败: emrId={}, error={}", emr.getId(), e.getMessage());
}
}
String result = String.format("同步完成: 修订历史%d条, 搜索索引%d条", revisionCount, searchIndexCount);
log.info(result);
return R.ok(result);
}
/**
* 获取同步统计
*/
@GetMapping("/stats")
@Operation(summary = "获取EMR同步统计")
public R<?> getSyncStats() {
Map<String, Object> stats = new HashMap<>();
stats.put("emrCount", emrService.count());
stats.put("revisionCount", emrRevisionService.count());
stats.put("searchIndexCount", emrSearchIndexService.count());
return R.ok(stats);
}
/**
* 解析contextJson字符串
*/
private Map<String, String> parseContextJson(String contextJson) {
Map<String, String> map = new HashMap<>();
if (contextJson == null || contextJson.isEmpty()) {
return map;
}
try {
// 简单解析JSON字符串
String json = contextJson.trim();
if (json.startsWith("{") && json.endsWith("}")) {
json = json.substring(1, json.length() - 1);
String[] pairs = json.split(",");
for (String pair : pairs) {
String[] kv = pair.split(":");
if (kv.length == 2) {
String key = kv[0].trim().replace("\"", "");
String value = kv[1].trim().replace("\"", "");
map.put(key, value);
}
}
}
} catch (Exception e) {
log.warn("解析contextJson失败: {}", e.getMessage());
}
return map;
}
}

View File

@@ -23,7 +23,7 @@ public class EmrTimelinessController {
private final IEmrTimelinessAppService emrTimelinessAppService;
@PostMapping("/check")
@PreAuthorize("@ss.hasPermi('inpatient:emr:edit')")
@PreAuthorize("@ss.hasPermi('emr:edit')")
@Operation(summary = "执行病历时限检查")
public R<EmrTimelinessStatisticsDto> checkTimeliness(
@RequestParam(value = "encounterId", required = false) Long encounterId) {
@@ -31,7 +31,7 @@ public class EmrTimelinessController {
}
@GetMapping("/alerts")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@PreAuthorize("@ss.hasPermi('emr:list')")
@Operation(summary = "获取病历时限提醒列表")
public R<Map<String, Object>> getTimelinessAlerts(
@RequestParam(value = "emrType", required = false) String emrType,

View File

@@ -20,21 +20,21 @@ public class EmrVersionController {
private final IEmrVersionAppService emrVersionAppService;
@PostMapping("/save")
@PreAuthorize("@ss.hasPermi('inpatient:emr:edit')")
@PreAuthorize("@ss.hasPermi('emr:edit')")
@Operation(summary = "保存病历版本")
public R<EmrVersion> saveVersion(@RequestBody EmrVersion version) {
return R.ok(emrVersionAppService.saveVersion(version));
}
@GetMapping("/list/{emrId}")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@PreAuthorize("@ss.hasPermi('emr:list')")
@Operation(summary = "获取病历版本列表")
public R<?> getVersions(@PathVariable Long emrId) {
return R.ok(emrVersionAppService.getVersions(emrId));
}
@GetMapping("/compare")
@PreAuthorize("@ss.hasPermi('inpatient:emr:list')")
@PreAuthorize("@ss.hasPermi('emr:list')")
@Operation(summary = "对比两个版本")
public R<?> compareVersions(
@RequestParam("versionId1") Long versionId1,

View File

@@ -0,0 +1,78 @@
-- V100__sync_existing_emr_data.sql
-- 同步已有的门诊/住院病历到修订历史和搜索索引
-- 1. 清空假数据
TRUNCATE TABLE emr_revision CASCADE;
TRUNCATE TABLE emr_search_index CASCADE;
-- 2. 从doc_emr同步修订历史为每条病历创建初始修订记录
INSERT INTO emr_revision (
id, emr_id, encounter_id, revision_number,
operator_id, operator_name, operation_type,
diff_content, snapshot_content, create_time
)
SELECT
nextval('emr_revision_id_seq'),
e.id,
e.encounter_id,
1,
COALESCE(e.record_id, 1),
'系统同步',
'CREATE',
'初始创建 - 从历史病历同步',
e.context_json,
COALESCE(e.create_time, NOW())
FROM doc_emr e
WHERE e.id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM emr_revision r WHERE r.emr_id = e.id
);
-- 3. 从doc_emr同步搜索索引
INSERT INTO emr_search_index (
id, emr_id, encounter_id, patient_id, patient_name,
emr_type, emr_title, diagnosis_text,
doctor_name, department_name, create_time, update_time
)
SELECT
nextval('emr_search_index_id_seq'),
e.id,
e.encounter_id,
e.patient_id,
COALESCE(p.name, '患者' || e.patient_id),
CASE
WHEN e.class_enum = 1 THEN 'OUTPATIENT'
WHEN e.class_enum = 2 THEN 'INPATIENT'
ELSE 'OTHER'
END,
COALESCE(
NULLIF(TRIM(BOTH '"' FROM
SPLIT_PART(
SPLIT_PART(e.context_json, '"chiefComplaint"', 2),
':', 2
)
), ''),
'未命名病历'
),
COALESCE(
NULLIF(TRIM(BOTH '"' FROM
SPLIT_PART(
SPLIT_PART(e.context_json, '"diagnosis"', 2),
':', 2
)
), ''),
''
),
COALESCE(u.name, '医生' || e.record_id),
COALESCE(o.name, '未知科室'),
COALESCE(e.create_time, NOW()),
COALESCE(e.update_time, NOW())
FROM doc_emr e
LEFT JOIN patient p ON e.patient_id = p.id
LEFT JOIN sys_user u ON e.record_id = u.user_id
LEFT JOIN adm_encounter enc ON e.encounter_id = enc.id
LEFT JOIN sys_organization o ON enc.organization_id = o.id
WHERE e.id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM emr_search_index si WHERE si.emr_id = e.id
);

View File

@@ -0,0 +1,47 @@
-- V101__add_emr_sync_menu_and_permissions.sql
-- 添加EMR数据同步菜单和医生权限
-- 1. 添加EMR数据同步菜单在电子病历管理下
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES (
'EMR数据同步',
(SELECT menu_id FROM sys_menu WHERE menu_name = '电子病历管理' LIMIT 1),
99,
'sync',
'emr/sync/index',
'C',
'0',
'0',
'emr:sync:list',
'upload',
'admin',
NOW(),
'admin',
NOW(),
'EMR数据同步 - 从病历表同步数据到修订历史和搜索索引'
);
-- 2. 为医生角色添加EMR权限
-- 获取医生角色ID假设角色名为'医生'或'doctor'
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT
r.role_id,
m.menu_id
FROM sys_role r
CROSS JOIN sys_menu m
WHERE r.role_name IN ('医生', 'doctor', '门诊医生', '住院医生')
AND m.perms IN (
'emr:list',
'emr:edit',
'emr:sync:list'
)
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu rm
WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id
);
-- 3. 更新EMR相关菜单的权限将inpatient:emr改为emr
UPDATE sys_menu SET perms = 'emr:list' WHERE perms = 'inpatient:emr:list';
UPDATE sys_menu SET perms = 'emr:edit' WHERE perms = 'inpatient:emr:edit';
UPDATE sys_menu SET perms = 'emr:list' WHERE perms = 'infection:emr:list';
UPDATE sys_menu SET perms = 'emr:edit' WHERE perms = 'infection:emr:edit';

View File

@@ -0,0 +1,44 @@
-- V102__grant_emr_menu_to_doctor.sql
-- 为医生角色授予电子病历管理相关菜单权限
-- 1. 获取医生角色ID并授予EMR菜单权限
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT
r.role_id,
m.menu_id
FROM sys_role r
CROSS JOIN sys_menu m
WHERE r.role_name IN ('医生', 'doctor', '门诊医生', '住院医生', '管理员', 'admin')
AND m.menu_id IN (
20201, -- 电子病历管理
20202, -- 病案归档
20203, -- 修订历史
20204, -- 病历时效
20205, -- 病历检索
20206, -- 进程记录
20207 -- 知识库
)
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu rm
WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id
);
-- 2. 为医生角色授予EMR相关权限
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT
r.role_id,
m.menu_id
FROM sys_role r
CROSS JOIN sys_menu m
WHERE r.role_name IN ('医生', 'doctor', '门诊医生', '住院医生', '管理员', 'admin')
AND m.perms IN (
'emr:list',
'emr:edit',
'document:progressnote:list',
'document:progressnote:add',
'document:progressnote:edit'
)
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu rm
WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id
);

View File

@@ -0,0 +1,13 @@
-- V103__add_patient_info_to_emr_search_index.sql
-- 为病历检索索引添加患者基本信息
-- 添加患者信息字段
ALTER TABLE emr_search_index ADD COLUMN IF NOT EXISTS patient_gender VARCHAR(10);
ALTER TABLE emr_search_index ADD COLUMN IF NOT EXISTS patient_age VARCHAR(10);
ALTER TABLE emr_search_index ADD COLUMN IF NOT EXISTS patient_phone VARCHAR(20);
ALTER TABLE emr_search_index ADD COLUMN IF NOT EXISTS patient_id_card VARCHAR(20);
ALTER TABLE emr_search_index ADD COLUMN IF NOT EXISTS encounter_no VARCHAR(50);
-- 添加索引
CREATE INDEX IF NOT EXISTS idx_emr_search_patient_name ON emr_search_index(patient_name);
CREATE INDEX IF NOT EXISTS idx_emr_search_encounter_no ON emr_search_index(encounter_no);

View File

@@ -5,6 +5,8 @@ import com.core.common.core.domain.HisBaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Date;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("emr_search_index")
@@ -19,6 +21,14 @@ public class EmrSearchIndex extends HisBaseEntity {
private Long patientId;
@TableField("patient_name")
private String patientName;
@TableField("patient_gender")
private String patientGender;
@TableField("patient_age")
private String patientAge;
@TableField("patient_phone")
private String patientPhone;
@TableField("patient_id_card")
private String patientIdCard;
@TableField("emr_type")
private String emrType;
@TableField("emr_title")
@@ -29,4 +39,6 @@ public class EmrSearchIndex extends HisBaseEntity {
private String doctorName;
@TableField("department_name")
private String departmentName;
@TableField("encounter_no")
private String encounterNo;
}

View File

@@ -30,6 +30,22 @@
>
历史病历
</el-button>
<el-button
type="info"
plain
style="margin-left: 20px"
@click="viewRevisionHistory()"
>
修订历史
</el-button>
<el-button
type="info"
plain
style="margin-left: 20px"
@click="checkCompleteness()"
>
完整性检查
</el-button>
<!-- 可选添加打印按钮 -->
<!-- <el-button type="primary" plain @click="printEmr" style="margin-left: 20px">
打印病历
@@ -196,10 +212,13 @@
import {getEmrDetail, saveEmr, saveEmrTemplate} from '../api';
import emrTemplate from '../emr/emrtemplate.vue';
import emrhistory from '../emr/emrhistory.vue';
import {checkCompleteness as checkEmrCompleteness} from '@/api/emr';
import {useRouter} from 'vue-router';
import {computed, getCurrentInstance, ref, watch} from 'vue';
import {formatDate as formatDateUtil} from '@/utils/index';
const router = useRouter();
// Props
const props = defineProps({
patientInfo: {
@@ -320,6 +339,37 @@ function handleSaveTemplate() {
openEmrTemplate.value = true;
}
// 查看修订历史
function viewRevisionHistory() {
if (!props.patientInfo?.encounterId) {
proxy.$message.warning('请先选择患者');
return;
}
router.push({
path: '/emr/revision-history',
query: { emrId: props.patientInfo.encounterId }
});
}
// 完整性检查
async function checkCompleteness() {
if (!props.patientInfo?.encounterId) {
proxy.$message.warning('请先选择患者');
return;
}
try {
const res = await checkEmrCompleteness(props.patientInfo.encounterId, props.patientInfo.encounterId);
const data = res.data || res;
if (data.isComplete) {
proxy.$modal.msgSuccess('病历完整性检查通过');
} else {
proxy.$message.warning(`病历完整性检查未通过,${data.requiredFailed}项必填项未填写`);
}
} catch (e) {
proxy.$message.error('检查失败');
}
}
// 选择模板/历史
function templateSelect(row) {
form.value = { ...row };

View File

@@ -2,12 +2,22 @@
<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">病历打印归档</span>
<el-button
type="primary"
@click="loadStats"
>
刷新统计
</el-button>
<div>
<el-button
type="warning"
:loading="syncing"
@click="handleSync"
style="margin-right:10px"
>
{{ syncing ? '同步中...' : '同步历史数据' }}
</el-button>
<el-button
type="primary"
@click="loadStats"
>
刷新统计
</el-button>
</div>
</div>
<el-row
:gutter="12"
@@ -226,13 +236,34 @@
</template>
<script setup>
import {ref,onMounted} from 'vue'
import {useRoute} from 'vue-router'
import {ElMessage,ElMessageBox} from 'element-plus'
import {getArchivePage,archive,reprint,getArchiveStats} from './api'
import request from '@/utils/request'
const route=useRoute()
const tableData=ref([]);const total=ref(0);const stats=ref({})
const syncing=ref(false)
const q=ref({pageNo:1,pageSize:20,patientName:'',archiveStatus:''})
const loadData=async()=>{const r=await getArchivePage(q.value);tableData.value=r.data?.records||[];total.value=r.data?.total||0}
const loadStats=async()=>{const r=await getArchiveStats();stats.value=r.data||{}}
const doArchive=async(row)=>{const {value}=await ElMessageBox.prompt('归档人','确认归档');if(value){await archive(row.id,value);ElMessage.success('已归档');loadData();loadStats()}}
const doReprint=async(row)=>{await reprint(row.id);ElMessage.success('补打记录已添加');loadData()}
onMounted(()=>{loadData();loadStats()})
const handleSync=async()=>{
try{
await ElMessageBox.confirm('将从病历表同步数据到修订历史和搜索索引,确定继续?','确认同步',{type:'warning'})
syncing.value=true
const res=await request({url:'/emr-sync/sync',method:'post'})
ElMessage.success(res.data||'同步完成')
loadData();loadStats()
}catch(e){
if(e!=='cancel') ElMessage.error('同步失败')
}finally{
syncing.value=false
}
}
onMounted(()=>{
if(route.query.encounterId){q.value.encounterId=route.query.encounterId}
if(route.query.patientName){q.value.patientName=route.query.patientName}
loadData();loadStats()
})
</script>

View File

@@ -90,10 +90,12 @@
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { checkCompleteness, getCompletenessResults } from '@/api/emr'
import { ElMessage } from 'element-plus'
const route = useRoute()
const checkLoading = ref(false)
const resultLoading = ref(false)
const checkResult = ref(null)
@@ -148,6 +150,12 @@ const loadResults = async () => {
resultLoading.value = false
}
}
onMounted(() => {
if (route.query.emrId) { checkForm.emrId = route.query.emrId }
if (route.query.encounterId) { checkForm.encounterId = route.query.encounterId }
if (checkForm.emrId && checkForm.encounterId) { handleCheck() }
})
</script>
<style scoped>

View File

@@ -1,5 +1,5 @@
import request from '@/utils/request'
export function getRevisionPage(p){return request({url:'/emr-revision/page',method:'get',params:p})}
export function getRevisionList(p){return request({url:'/emr-revision/list',method:'get',params:p})}
export function recordRevision(d){return request({url:'/emr-revision/record',method:'post',data:d})}
export function compareRevisions(id1,id2){return request({url:'/emr-revision/compare',method:'get',params:{revisionId1:id1,revisionId2:id2}})}
export function getRevisionPage(p){return request({url:'/emr/revision/page',method:'get',params:p})}
export function getRevisionList(emrId){return request({url:'/emr/revision/list/'+emrId,method:'get'})}
export function recordRevision(d){return request({url:'/emr/revision/record',method:'post',data:d})}
export function compareRevisions(id1,id2){return request({url:'/emr/revision/compare',method:'get',params:{revisionId1:id1,revisionId2:id2}})}

View File

@@ -129,11 +129,31 @@
</template>
<script setup>
import {ref,onMounted} from 'vue'
import {useRoute} from 'vue-router'
import {getRevisionPage} from './api'
import {ElMessage} from 'element-plus'
const route=useRoute()
const tableData=ref([]);const total=ref(0)
const q=ref({pageNo:1,pageSize:20,emrId:'',operatorName:''})
const q=ref({pageNo:1,pageSize:20,emrId:null,operatorName:''})
const detailVisible=ref(false);const detail=ref({})
const loadData=async()=>{const r=await getRevisionPage(q.value);tableData.value=r.data?.records||[];total.value=r.data?.total||0}
const loadData=async()=>{
try{
// 清理空参数
const params={pageNo:q.value.pageNo,pageSize:q.value.pageSize}
if(q.value.emrId) params.emrId=q.value.emrId
if(q.value.operatorName) params.operatorName=q.value.operatorName
const r=await getRevisionPage(params)
console.log('修订历史响应:',r)
tableData.value=r.data?.records||r.data||[]
total.value=r.data?.total||tableData.value.length
}catch(e){
console.error('加载失败:',e)
ElMessage.error('加载失败')
}
}
const viewDetail=(row)=>{detail.value=row;detailVisible.value=true}
onMounted(()=>loadData())
onMounted(()=>{
if(route.query.emrId){q.value.emrId=route.query.emrId}
loadData()
})
</script>

View File

@@ -0,0 +1,85 @@
<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">EMR数据同步</span>
</div>
<el-card shadow="hover" style="margin-bottom:16px">
<template #header>
<span>同步统计</span>
</template>
<el-row :gutter="20">
<el-col :span="8">
<el-statistic title="病历总数" :value="stats.emrCount||0" />
</el-col>
<el-col :span="8">
<el-statistic title="修订历史" :value="stats.revisionCount||0" />
</el-col>
<el-col :span="8">
<el-statistic title="搜索索引" :value="stats.searchIndexCount||0" />
</el-col>
</el-row>
</el-card>
<el-card shadow="hover">
<template #header>
<span>数据同步操作</span>
</template>
<el-alert
type="warning"
show-icon
style="margin-bottom:16px"
>
<template #title>
同步操作将清空现有的修订历史和搜索索引数据然后从门诊/住院病历表(doc_emr)重新生成
</template>
</el-alert>
<el-button
type="primary"
:loading="syncing"
@click="handleSync"
>
{{ syncing ? '同步中...' : '开始同步' }}
</el-button>
<el-button @click="loadStats">
刷新统计
</el-button>
</el-card>
</div>
</template>
<script setup>
import {ref, onMounted} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import request from '@/utils/request'
const syncing = ref(false)
const stats = ref({})
const loadStats = async () => {
const res = await request({url: '/emr-sync/stats', method: 'get'})
stats.value = res.data || {}
}
const handleSync = async () => {
try {
await ElMessageBox.confirm(
'确定要同步EMR数据吗这将清空现有的修订历史和搜索索引然后从病历表重新生成。',
'确认同步',
{type: 'warning'}
)
syncing.value = true
const res = await request({url: '/emr-sync/sync', method: 'post'})
ElMessage.success(res.data || '同步完成')
loadStats()
} catch (e) {
if (e !== 'cancel') {
ElMessage.error('同步失败: ' + (e.message || '未知错误'))
}
} finally {
syncing.value = false
}
}
onMounted(() => loadStats())
</script>

View File

@@ -132,14 +132,20 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getTimelinessByEncounter, getOverdueList, getTimelinessStatistics } from '@/api/emr'
const route = useRoute()
const loading = ref(false)
const dataList = ref([])
const stats = reactive({ pending: 0, completed: 0, overdue: 0, rate: 0 })
const queryParams = reactive({ departmentName: '', emrType: '' })
const queryParams = reactive({ departmentName: '', emrType: '', encounterId: '' })
const emrTypeMap = { ADMISSION: '入院记录', FIRST_COURSE: '首次病程', DAILY_COURSE: '日常病程', DISCHARGE: '出院记录' }
const statusMap = { PENDING: { label: '待完成', type: 'info' }, COMPLETED: { label: '已完成', type: 'success' }, OVERDUE: { label: '超时', type: 'danger' } }
const getList = async () => { loading.value = true; const res = await getOverdueList(); dataList.value = res.data || res.rows || []; loading.value = false }
const handleQuery = () => getList()
onMounted(() => { getList() })
onMounted(() => {
if (route.query.encounterId) { queryParams.encounterId = route.query.encounterId }
if (route.query.departmentName) { queryParams.departmentName = route.query.departmentName }
getList()
})
</script>

View File

@@ -1,136 +1,296 @@
<template>
<div style="padding:16px">
<div style="margin-bottom:16px">
<span style="font-size:18px;font-weight:bold">病历检索</span>
</div>
<el-card
shadow="never"
style="margin-bottom:16px"
>
<el-form
:model="queryParams"
inline
>
<div class="emr-search-container">
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header">
<span>病历检索</span>
<el-tag type="info" size="small"> {{ total }} 条记录</el-tag>
</div>
</template>
<el-form :model="queryParams" inline class="search-form">
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="诊断/标题/患者"
placeholder="病历号/患者/诊断"
clearable
style="width:200px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="患者">
<el-form-item label="患者姓名">
<el-input
v-model="queryParams.patientName"
placeholder="患者姓名"
clearable
style="width:120px"
style="width:140px"
/>
</el-form-item>
<el-form-item label="类型">
<el-select
v-model="queryParams.emrType"
clearable
style="width:120px"
>
<el-option
v-for="t in [{l:'入院记录',v:'admission'},{l:'日常病程',v:'daily'},{l:'出院记录',v:'discharge'},{l:'手术记录',v:'surgery'},{l:'会诊记录',v:'consultation'}]"
:key="t.v"
:label="t.l"
:value="t.v"
/>
<el-form-item label="病历类型">
<el-select v-model="queryParams.emrType" placeholder="全部" clearable style="width:130px">
<el-option v-for="t in emrTypeOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item>
<el-form-item label="医生">
<el-input
v-model="queryParams.doctorName"
clearable
style="width:100px"
/>
<el-input v-model="queryParams.doctorName" placeholder="医生姓名" clearable style="width:120px" />
</el-form-item>
<el-form-item label="科室">
<el-input v-model="queryParams.departmentName" placeholder="科室名称" clearable style="width:120px" />
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="handleSearch"
>
检索
</el-button>
<el-button @click="resetQuery">
重置
</el-button>
<el-button type="primary" icon="Search" @click="handleSearch">检索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-table
v-loading="loading"
:data="searchData"
border
stripe
>
<el-table-column
prop="patientName"
label="患者"
width="90"
/>
<el-table-column
prop="emrType"
label="类型"
width="100"
<el-card shadow="never" class="result-card">
<el-table
v-loading="loading"
:data="searchData"
border
stripe
highlight-current-row
@row-click="handleRowClick"
style="cursor: pointer"
>
<template #default="{row}">
{{ {admission:'入院记录',daily:'日常病程',discharge:'出院记录',surgery:'手术记录',consultation:'会诊记录'}[row.emrType]||row.emrType }}
</template>
</el-table-column>
<el-table-column
prop="emrTitle"
label="标题"
min-width="180"
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="encounterNo" label="病历号" width="120" show-overflow-tooltip />
<el-table-column prop="patientName" label="患者姓名" width="100">
<template #default="{row}">
<span class="patient-name">{{ row.patientName }}</span>
</template>
</el-table-column>
<el-table-column prop="patientGender" label="性别" width="60" align="center">
<template #default="{row}">
<el-tag :type="row.patientGender === '男' ? '' : 'danger'" size="small">{{ row.patientGender }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="patientAge" label="年龄" width="60" align="center">
<template #default="{row}">
{{ row.patientAge ? row.patientAge + '岁' : '-' }}
</template>
</el-table-column>
<el-table-column prop="patientPhone" label="联系电话" width="120">
<template #default="{row}">
{{ row.patientPhone || '-' }}
</template>
</el-table-column>
<el-table-column prop="emrType" label="病历类型" width="100">
<template #default="{row}">
<el-tag size="small" :type="getEmrTypeTag(row.emrType)">{{ getEmrTypeLabel(row.emrType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="emrTitle" label="病历标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="diagnosisText" label="诊断" min-width="180" show-overflow-tooltip>
<template #default="{row}">
<span class="diagnosis-text">{{ row.diagnosisText || '暂无诊断' }}</span>
</template>
</el-table-column>
<el-table-column prop="doctorName" label="主治医生" width="100" />
<el-table-column prop="departmentName" label="科室" width="100" />
<el-table-column prop="createTime" label="就诊时间" width="160" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{row}">
<el-button type="primary" link size="small" @click.stop="handleViewDetail(row)">
<el-icon><View /></el-icon> 详情
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@size-change="handleSearch"
@current-change="handleSearch"
/>
<el-table-column
prop="diagnosisText"
label="诊断"
min-width="200"
show-overflow-tooltip
/>
<el-table-column
prop="doctorName"
label="医生"
width="90"
/>
<el-table-column
prop="departmentName"
label="科室"
width="120"
/>
<el-table-column
prop="createTime"
label="创建时间"
width="170"
/>
</el-table>
<el-pagination
v-model:current-page="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
layout="total,prev,pager,next"
style="margin-top:16px;text-align:right"
@current-change="handleSearch"
/>
</el-card>
<!-- 病历详情弹窗 -->
<el-drawer
v-model="detailVisible"
title="病历详情"
size="60%"
direction="rtl"
>
<template v-if="currentRow">
<el-descriptions :column="2" border>
<el-descriptions-item label="病历号">{{ currentRow.encounterNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="病历类型">
<el-tag>{{ getEmrTypeLabel(currentRow.emrType) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="患者姓名">{{ currentRow.patientName }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ currentRow.patientGender }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ currentRow.patientAge ? currentRow.patientAge + '岁' : '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ currentRow.patientPhone || '-' }}</el-descriptions-item>
<el-descriptions-item label="身份证号" :span="2">{{ currentRow.patientIdCard || '-' }}</el-descriptions-item>
<el-descriptions-item label="主治医生">{{ currentRow.doctorName }}</el-descriptions-item>
<el-descriptions-item label="科室">{{ currentRow.departmentName }}</el-descriptions-item>
<el-descriptions-item label="就诊时间" :span="2">{{ currentRow.createTime }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">诊断信息</el-divider>
<el-input
v-model="currentRow.diagnosisText"
type="textarea"
:rows="3"
readonly
placeholder="暂无诊断信息"
/>
<el-divider content-position="left">病历标题</el-divider>
<el-input
v-model="currentRow.emrTitle"
type="textarea"
:rows="2"
readonly
placeholder="暂无标题"
/>
<el-divider />
<div class="drawer-footer">
<el-button @click="detailVisible = false">关闭</el-button>
<el-button type="primary" @click="handleViewFullEmr">查看完整病历</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup>
import {ref,reactive,onMounted} from 'vue'
import {searchEmr} from './api'
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchEmr } from './api'
import { ElMessage } from 'element-plus'
import { View } from '@element-plus/icons-vue'
const loading=ref(false)
const searchData=ref([])
const total=ref(0)
const queryParams=reactive({keyword:'',patientName:'',emrType:'',doctorName:'',departmentName:'',pageNo:1,pageSize:20})
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const searchData = ref([])
const total = ref(0)
const detailVisible = ref(false)
const currentRow = ref(null)
const handleSearch=async()=>{
loading.value=true
try{const r=await searchEmr(queryParams);searchData.value=r.data?.records||[];total.value=r.data?.total||0}finally{loading.value=false}
const queryParams = reactive({
keyword: '',
patientName: '',
emrType: '',
doctorName: '',
departmentName: '',
pageNo: 1,
pageSize: 10
})
const emrTypeOptions = [
{ label: '入院记录', value: 'OUTPATIENT' },
{ label: '日常病程', value: 'DAILY' },
{ label: '出院记录', value: 'DISCHARGE' },
{ label: '手术记录', value: 'SURGERY' },
{ label: '会诊记录', value: 'CONSULTATION' }
]
const emrTypeMap = {
OUTPATIENT: { label: '入院记录', type: '' },
INPATIENT: { label: '住院病历', type: 'success' },
DAILY: { label: '日常病程', type: 'warning' },
DISCHARGE: { label: '出院记录', type: 'info' },
SURGERY: { label: '手术记录', type: 'danger' },
CONSULTATION: { label: '会诊记录', type: '' }
}
const resetQuery=()=>{Object.assign(queryParams,{keyword:'',patientName:'',emrType:'',doctorName:'',pageNo:1});handleSearch()}
onMounted(()=>handleSearch())
const getEmrTypeLabel = (type) => emrTypeMap[type]?.label || type
const getEmrTypeTag = (type) => emrTypeMap[type]?.type || 'info'
const handleSearch = async () => {
loading.value = true
try {
const params = { ...queryParams }
// 清理空参数
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null) delete params[key]
})
const res = await searchEmr(params)
searchData.value = res.data?.records || res.data || []
total.value = res.data?.total || searchData.value.length
} catch (e) {
console.error('检索失败:', e)
ElMessage.error('检索失败')
} finally {
loading.value = false
}
}
const resetQuery = () => {
Object.assign(queryParams, {
keyword: '',
patientName: '',
emrType: '',
doctorName: '',
departmentName: '',
pageNo: 1,
pageSize: 10
})
handleSearch()
}
const handleRowClick = (row) => {
handleViewDetail(row)
}
const handleViewDetail = (row) => {
currentRow.value = row
detailVisible.value = true
}
const handleViewFullEmr = () => {
if (currentRow.value?.encounterId) {
router.push({
path: '/doctorstation',
query: { encounterId: currentRow.value.encounterId }
})
}
}
onMounted(() => {
if (route.query.patientName) queryParams.patientName = route.query.patientName
if (route.query.emrType) queryParams.emrType = route.query.emrType
if (route.query.keyword) queryParams.keyword = route.query.keyword
handleSearch()
})
</script>
<style scoped>
.emr-search-container {
padding: 16px;
}
.search-card {
margin-bottom: 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.search-form {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.patient-name {
font-weight: 500;
color: #303133;
}
.diagnosis-text {
color: #606266;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -78,6 +78,20 @@
>
打印表单
</el-button>
<el-button
type="info"
plain
@click="viewRevisionHistory"
>
修订历史
</el-button>
<el-button
type="info"
plain
@click="checkCompleteness"
>
完整性检查
</el-button>
<!-- <el-button type="primary" @click="" disabled>病案上传</el-button>
<el-button type="primary" @click="">结算上传</el-button> -->
<!-- <el-button type="primary" @click="onNursingStatus">护理状态</el-button> -->
@@ -923,6 +937,41 @@ const onPrint = () => {
}
};
// 查看修订历史
import {useRouter} from 'vue-router';
import {checkCompleteness as checkEmrCompleteness} from '@/api/emr';
const router = useRouter();
const viewRevisionHistory = () => {
if (!patientInfo.value?.encounterId) {
ElMessage.warning('请先选择患者');
return;
}
router.push({
path: '/emr/revision-history',
query: { emrId: patientInfo.value.encounterId }
});
};
// 完整性检查
const checkCompleteness = async () => {
if (!patientInfo.value?.encounterId) {
ElMessage.warning('请先选择患者');
return;
}
try {
const res = await checkEmrCompleteness(patientInfo.value.encounterId, patientInfo.value.encounterId);
const data = res.data || res;
if (data.isComplete) {
ElMessage.success('病历完整性检查通过');
} else {
ElMessage.warning(`病历完整性检查未通过,${data.requiredFailed}项必填项未填写`);
}
} catch (e) {
ElMessage.error('检查失败');
}
};
// 添加一个方法供父组件调用,处理患者切换
const handlePatientChange = (patient) => {
// 更新患者信息

31
scripts/test-emr-sync.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# EMR数据同步测试脚本
BASE_URL="http://localhost:18082"
TOKEN=""
echo "=== EMR数据同步测试 ==="
# 1. 获取统计信息
echo ""
echo "1. 获取同步统计..."
curl -s "${BASE_URL}/emr-sync/stats" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" | python3 -m json.tool 2>/dev/null || echo "请先登录获取Token"
# 2. 执行同步
echo ""
echo "2. 执行数据同步..."
curl -s -X POST "${BASE_URL}/emr-sync/sync" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" | python3 -m json.tool 2>/dev/null || echo "同步失败请检查Token"
# 3. 再次获取统计
echo ""
echo "3. 同步后统计..."
curl -s "${BASE_URL}/emr-sync/stats" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" | python3 -m json.tool 2>/dev/null || echo "请先登录获取Token"
echo ""
echo "=== 测试完成 ==="