Compare commits
40 Commits
90cf7f43d7
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| b536eadd92 | |||
|
|
3472aa790e | ||
|
|
ec89ead14c | ||
|
|
136235fe4c | ||
|
|
c2cac12b9f | ||
|
|
b424d73542 | ||
|
|
decac542c8 | ||
|
|
783ee48ec8 | ||
|
|
e1ad4965eb | ||
|
|
fd1880f1c8 | ||
|
|
d4d05267ad | ||
| 2b0acce1db | |||
| 4312c0c557 | |||
|
|
caa45c3310 | ||
| 7fabad14f9 | |||
|
|
405a9dfb72 | ||
| d1be841688 | |||
|
|
9b8655748e | ||
| 00fd6c8710 | |||
| bbd9d48fa6 | |||
| 8fb1d3e583 | |||
| 34ba7cae6a | |||
| 305ab15436 | |||
| 46a7076460 | |||
| e0e6693897 | |||
|
|
7d1e50d045 | ||
| 25ce12cebf | |||
| 7d55717037 | |||
|
|
290e8f8f15 | ||
| fc84fd61ab | |||
|
|
d79690a371 | ||
| 7bccbc7085 | |||
|
|
059ef483ca | ||
|
|
4beb4c40c5 | ||
| 914f2d8229 | |||
| 2f57b3e7c1 | |||
|
|
39ccd27df8 | ||
| d370b6a888 | |||
| 3c61e39e09 | |||
| f2c71b08bb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -63,3 +63,6 @@ public.sql
|
||||
发版记录/2025-11-12/发版日志.docx
|
||||
.gitignore
|
||||
openhis-server-new/openhis-application/src/main/resources/application-dev.yml
|
||||
.env.test.local
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
162
docs/specs/backend-checklist.md
Normal file
162
docs/specs/backend-checklist.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 后端发布前检查清单
|
||||
|
||||
## 📋 基础检查项
|
||||
|
||||
### Maven编译验证
|
||||
- [ ] 本地执行 `mvn compile` 编译通过,无ERROR
|
||||
- [ ] 执行 `mvn package -DskipTests` 打包成功
|
||||
- [ ] 依赖版本无冲突(`mvn dependency:tree` 检查)
|
||||
- [ ] 无编译警告(或已有书面说明可忽略)
|
||||
|
||||
### 构建产物验证
|
||||
- [ ] JAR/WAR包生成完整,大小合理
|
||||
- [ ] `application.yml` 等配置文件已打包进产物
|
||||
- [ ] 第三方依赖jar包完整(lib目录无缺失)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Spring Boot 配置检查
|
||||
|
||||
### 多环境配置
|
||||
- [ ] `application-dev.yml`(开发)配置正确
|
||||
- [ ] `application-test.yml`(测试)配置正确
|
||||
- [ ] `application-prod.yml`(生产)配置正确
|
||||
- [ ] 启动参数 `--spring.profiles.active` 指定正确环境
|
||||
- [ ] 生产环境未启用devtools热部署
|
||||
|
||||
### Actuator安全
|
||||
- [ ] 生产环境 `/actuator` 端点已禁用或限制访问
|
||||
- [ ] `/actuator/env`、`/actuator/heapdump` 等敏感端点已关闭
|
||||
- [ ] 健康检查端点 `/actuator/health` 返回信息已脱敏
|
||||
|
||||
### 启动校验
|
||||
- [ ] 数据库连接池配置合理(HikariCP最大/最小连接数)
|
||||
- [ ] Redis/消息中间件连接配置正确
|
||||
- [ ] 启动日志无ERROR级别异常
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ MyBatis Plus 规范检查
|
||||
|
||||
### 实体-表映射
|
||||
- [ ] 所有实体类标注 `@TableName`,表名与实际一致
|
||||
- [ ] 主键字段标注 `@TableId(type = IdType.AUTO)` 或对应策略
|
||||
- [ ] 非表字段标注 `@TableField(exist = false)`
|
||||
- [ ] 字段命名符合下划线转驼峰规则
|
||||
|
||||
### SQL安全
|
||||
- [ ] 所有查询使用参数化查询(`QueryWrapper` / `LambdaQueryWrapper`)
|
||||
- [ ] 禁止字符串拼接SQL(`"WHERE name = '" + name + "'"`)
|
||||
- [ ] 批量操作使用MyBatis Plus `saveBatch` / `updateBatchById`
|
||||
- [ ] 复杂SQL使用XML映射,避免注解内嵌长SQL
|
||||
|
||||
### 事务管理
|
||||
- [ ] 涉及多表写操作的方法标注 `@Transactional`
|
||||
- [ ] 事务边界合理,不包含外部HTTP调用
|
||||
- [ ] 异常回滚配置正确(`rollbackFor = Exception.class`)
|
||||
- [ ] 事务方法未被同一类内方法直接调用(自调用失效问题)
|
||||
|
||||
### 分页插件
|
||||
- [ ] `PaginationInnerInterceptor` 已正确配置
|
||||
- [ ] 分页查询使用 `Page<T>` 对象,非手动limit/offset
|
||||
|
||||
---
|
||||
|
||||
## 🔌 RESTful API 设计检查
|
||||
|
||||
### 统一返回格式
|
||||
- [ ] 所有接口返回 `{code, msg, data}` 统一结构
|
||||
- [ ] 成功返回 `code=200`,业务错误使用自定义错误码
|
||||
- [ ] 异常通过 `@ControllerAdvice` + `@ExceptionHandler` 统一处理
|
||||
|
||||
### HTTP状态码
|
||||
- [ ] 资源创建返回 `201 Created`
|
||||
- [ ] 资源删除返回 `204 No Content`
|
||||
- [ ] 参数校验失败返回 `400 Bad Request`
|
||||
- [ ] 未认证返回 `401 Unauthorized`
|
||||
- [ ] 无权限返回 `403 Forbidden`
|
||||
- [ ] 资源不存在返回 `404 Not Found`
|
||||
|
||||
### 参数校验
|
||||
- [ ] 请求参数使用 `@Valid` / `@Validated` 注解校验
|
||||
- [ ] 必填字段标注 `@NotBlank` / `@NotNull`
|
||||
- [ ] 数值范围标注 `@Min` / `@Max`
|
||||
- [ ] 格式校验使用 `@Pattern`(如手机号、身份证号)
|
||||
- [ ] 校验失败返回明确错误信息(非500堆栈)
|
||||
|
||||
### API版本管理
|
||||
- [ ] 接口路径包含版本号(`/api/v1/`、`/api/v2/`)
|
||||
- [ ] 废弃接口标注 `@Deprecated`,并在文档中说明
|
||||
- [ ] 不兼容变更必须升级版本号
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全与合规检查
|
||||
|
||||
### 数据脱敏
|
||||
- [ ] 患者身份证号在日志中脱敏(`***` 掩码)
|
||||
- [ ] 患者手机号在日志中脱敏(前3后4,中间`****`)
|
||||
- [ ] 敏感字段序列化时使用 `@JsonSerialize` 自定义脱敏器
|
||||
- [ ] 接口返回中非必需字段不暴露(如密码、salt)
|
||||
|
||||
### 权限控制
|
||||
- [ ] 所有涉及患者数据的接口标注 `@PreAuthorize`
|
||||
- [ ] 数据级权限校验(医生只能访问本科室患者)
|
||||
- [ ] 越权访问返回 `403`,非 `404` 或 `500`
|
||||
- [ ] 敏感操作(删除、修改诊断)需二次确认或额外权限
|
||||
|
||||
### 审计日志
|
||||
- [ ] 处方修改记录操作人、时间、变更内容
|
||||
- [ ] 病历删除操作记录完整审计链
|
||||
- [ ] 审计日志独立存储,不可被业务用户删除
|
||||
- [ ] 关键业务操作记录IP地址和操作终端
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 性能检查
|
||||
|
||||
### 数据库查询
|
||||
- [ ] 无N+1查询问题(使用 `JOIN` 或批量查询)
|
||||
- [ ] 大表查询必须有分页限制
|
||||
- [ ] 慢查询已优化(执行时间 < 500ms)
|
||||
- [ ] 索引已覆盖高频查询条件
|
||||
|
||||
### 接口性能
|
||||
- [ ] 核心接口响应时间 < 1秒
|
||||
- [ ] 列表接口支持分页,无全量返回
|
||||
- [ ] 大文件下载使用流式传输,非全量加载到内存
|
||||
|
||||
---
|
||||
|
||||
## 📝 文档与发布准备
|
||||
|
||||
### 文档更新
|
||||
- [ ] API接口文档已同步更新(路径、参数、返回值)
|
||||
- [ ] 数据库变更脚本已提供(DDL/DML)
|
||||
- [ ] 配置变更说明已记录(新增/修改的配置项)
|
||||
- [ ] 影响范围说明已明确(哪些模块、哪些接口受影响)
|
||||
|
||||
### 回滚预案
|
||||
- [ ] 数据库变更可回滚(提供反向SQL脚本)
|
||||
- [ ] 配置变更可快速回退
|
||||
- [ ] 紧急回滚流程已明确(谁、怎么做、多长时间)
|
||||
- [ ] 回滚后数据一致性已验证
|
||||
|
||||
---
|
||||
|
||||
## ✅ 最终确认
|
||||
|
||||
### 发布前最后检查
|
||||
- [ ] `mvn compile` 构建成功(附终端截图)
|
||||
- [ ] 关键单元测试通过
|
||||
- [ ] 测试环境部署验证通过
|
||||
- [ ] Code Review 已完成并获得批准
|
||||
- [ ] 相关Bug已关闭或延期说明
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**最后更新**:2026年4月24日
|
||||
**负责人**:关羽(后端开发)
|
||||
**适用范围**:HIS 系统所有后端模块(his-server)
|
||||
**补充说明**:本清单与陈琳的《前端发布前检查清单》对称互补,共同构成HIS系统发布前完整质量保障体系
|
||||
@@ -21,7 +21,7 @@
|
||||
**触发时机**:`git push` 执行前
|
||||
**验证内容**:
|
||||
- 完整的单元测试套件
|
||||
- 构建验证(`npm run build`)
|
||||
- 构建验证(`npm run build:prod`)
|
||||
- 集成测试(核心流程)
|
||||
|
||||
**工具配置**:
|
||||
@@ -52,6 +52,54 @@
|
||||
## ⚙️ 具体配置要求
|
||||
|
||||
### ESLint 配置
|
||||
```javascript
|
||||
// eslint.config.js 关键配置
|
||||
import globals from "globals";
|
||||
import pluginVue from "eslint-plugin-vue";
|
||||
import parserVue from "vue-eslint-parser";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
|
||||
export default [
|
||||
{
|
||||
name: "app/files-to-lint",
|
||||
files: ["**/*.{js,mjs,jsx,vue}"],
|
||||
},
|
||||
|
||||
{
|
||||
name: "app/files-to-ignore",
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/help-center/**"],
|
||||
},
|
||||
|
||||
...pluginVue.configs["flat/recommended"],
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parser: parserVue,
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
|
||||
rules: {
|
||||
// 确保导入的模块实际存在(核心规则,防止构建失败)
|
||||
"import/no-unresolved": "error",
|
||||
// 确保导入的命名导出实际存在
|
||||
"import/named": "error",
|
||||
// 确保默认导出存在
|
||||
"import/default": "error",
|
||||
// 确保命名空间导出存在
|
||||
"import/namespace": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
```
|
||||
|
||||
|
||||
@@ -101,7 +149,7 @@ npm run lint-staged
|
||||
|
||||
# .husky/pre-push
|
||||
#!/bin/sh
|
||||
npm run test:unit && npm run build
|
||||
npm run test:unit && npm run build:prod
|
||||
```
|
||||
|
||||
### lint-staged 配置
|
||||
@@ -114,6 +162,15 @@ npm run test:unit && npm run build
|
||||
}
|
||||
}
|
||||
```
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"lint-staged": {
|
||||
"*.{js,vue}": ["eslint --fix", "prettier --write"],
|
||||
"*.{css,scss}": ["stylelint --fix", "prettier --write"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🚫 失败处理机制
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ refactor(nurse): 重构护士站护理记录组件
|
||||
## 🖼️ 构建验证截图要求
|
||||
|
||||
### 必须包含的信息
|
||||
1. **终端窗口**:显示 `npm run build` 命令执行过程
|
||||
1. **终端窗口**:显示 `npm run build:prod` 命令执行过程
|
||||
2. **成功标识**:明确显示构建成功的提示信息
|
||||
3. **时间戳**:截图包含当前时间,证明是最新构建
|
||||
4. **分支信息**:显示当前工作分支名称
|
||||
@@ -86,7 +86,7 @@ refactor(nurse): 重构护士站护理记录组件
|
||||
### 截图示例
|
||||
```
|
||||
$ git checkout feature/patient-edit
|
||||
$ npm run build
|
||||
$ npm run build:prod
|
||||
|
||||
> his-system@1.0.0 build
|
||||
> vue-cli-service build
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- [ ] 函数职责单一,复杂度适中
|
||||
|
||||
### 构建验证
|
||||
- [ ] 本地执行 `npm run build` 成功完成
|
||||
- [ ] 本地执行 `npm run build:prod` 成功完成
|
||||
- [ ] 构建产物无报错,体积合理
|
||||
- [ ] 静态资源路径正确,无404错误
|
||||
- [ ] 环境变量配置正确(开发/测试/生产)
|
||||
@@ -70,7 +70,7 @@
|
||||
## 🔧 后端检查项
|
||||
|
||||
### 编译验证
|
||||
- [ ] Maven编译成功(`mvn clean compile`)
|
||||
- [ ] Maven编译成功(`mvn clean package -DskipTests`)
|
||||
- [ ] 无编译错误,仅有可接受的警告
|
||||
- [ ] 依赖版本兼容性确认
|
||||
|
||||
|
||||
575
docs/specs/his-release-checklist-v1.0.md
Normal file
575
docs/specs/his-release-checklist-v1.0.md
Normal file
@@ -0,0 +1,575 @@
|
||||
# HIS项目发布检查清单 v1.0
|
||||
|
||||
> **文档说明**:本清单整合了提交规范、前端检查、后端检查、CI/CD门禁四个部分,作为HIS项目发布的标准化检查依据。每次发布前必须逐项确认。
|
||||
|
||||
## 目录
|
||||
- [1. 提交规范(commit-template)](#1-提交规范commit-template)
|
||||
- [2. 前端检查(frontend-checklist)](#2-前端检查frontend-checklist)
|
||||
- [3. 后端检查(backend-checklist)](#3-后端检查backend-checklist)
|
||||
- [4. CI/CD门禁(cicd-gatekeeper)](#4-cicd门禁cicd-gatekeeper)
|
||||
- [5. 发布确认与回滚预案](#5-发布确认与回滚预案)
|
||||
|
||||
---
|
||||
|
||||
## 1. 提交规范(commit-template)
|
||||
|
||||
### 📝 PR/Commit 模板
|
||||
|
||||
#### 标题格式
|
||||
```
|
||||
<类型>(<模块>): <简短描述>
|
||||
|
||||
示例:
|
||||
feat(patient): 添加患者基本信息编辑功能
|
||||
fix(doctor): 修复医生排班显示异常问题
|
||||
docs(api): 更新预约挂号接口文档
|
||||
refactor(nurse): 重构护士站护理记录组件
|
||||
```
|
||||
|
||||
#### 正文模板
|
||||
```markdown
|
||||
## 🔍 变更背景
|
||||
- **问题描述**:详细说明要解决的问题或实现的需求
|
||||
- **影响范围**:列出受影响的模块、页面、功能
|
||||
- **相关链接**:禅道任务ID、需求文档链接等
|
||||
|
||||
## 🛠️ 变更内容
|
||||
- **主要修改**:核心代码变更点
|
||||
- **技术方案**:采用的技术方案和设计思路
|
||||
- **兼容性**:是否涉及API或数据结构变更
|
||||
|
||||
## 🗄️ 数据库变更
|
||||
- **表结构变更**:列出新增/修改的表和字段
|
||||
- **数据迁移**:是否需要数据迁移脚本
|
||||
- **回滚方案**:数据库变更的回滚策略
|
||||
|
||||
## ✅ 验证情况
|
||||
- **测试覆盖**:单元测试、集成测试覆盖情况
|
||||
- **手动验证**:手动测试的场景和结果
|
||||
- **构建验证**:本地构建截图(必填)
|
||||
|
||||
## 📋 检查清单
|
||||
- [ ] 代码已通过 ESLint 检查
|
||||
- [ ] 本地构建成功(附截图)
|
||||
- [ ] 核心功能已测试验证
|
||||
- [ ] 文档已同步更新
|
||||
- [ ] Code Review 已完成
|
||||
|
||||
## 👥 相关人员
|
||||
- **开发者**:@开发者姓名
|
||||
- **测试者**:@测试者姓名
|
||||
- **审核人**:@架构师姓名
|
||||
```
|
||||
|
||||
### 🏷️ 提交类型说明
|
||||
|
||||
| 类型 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| feat | 新功能 | `feat: 添加用户登录功能` |
|
||||
| fix | Bug修复 | `fix: 修复表单验证错误` |
|
||||
| docs | 文档更新 | `docs: 更新API文档` |
|
||||
| style | 代码格式调整 | `style: 格式化代码` |
|
||||
| refactor | 代码重构 | `refactor: 重构组件结构` |
|
||||
| test | 测试相关 | `test: 添加单元测试` |
|
||||
| chore | 构建/依赖等 | `chore: 升级依赖版本` |
|
||||
| perf | 性能优化 | `perf: 优化列表加载速度` |
|
||||
|
||||
### 📁 模块命名规范
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| patient | 患者管理相关 |
|
||||
| doctor | 医生工作站相关 |
|
||||
| nurse | 护士站相关 |
|
||||
| admin | 后台管理相关 |
|
||||
| common | 公共组件/工具 |
|
||||
| api | API接口相关 |
|
||||
| auth | 认证授权相关 |
|
||||
| payment | 支付相关 |
|
||||
|
||||
### 🖼️ 构建验证截图要求
|
||||
|
||||
#### 必须包含的信息
|
||||
1. **终端窗口**:显示 `npm run build:prod` 命令执行过程
|
||||
2. **成功标识**:明确显示构建成功的提示信息
|
||||
3. **时间戳**:截图包含当前时间,证明是最新构建
|
||||
4. **分支信息**:显示当前工作分支名称
|
||||
|
||||
### ⚠️ 禁止行为
|
||||
|
||||
#### 严重违规(直接拒绝合并)
|
||||
- 无构建验证截图
|
||||
- 代码存在 ESLint 错误
|
||||
- 未填写变更说明
|
||||
- 修改无关代码文件
|
||||
|
||||
---
|
||||
|
||||
## 2. 前端检查(frontend-checklist)
|
||||
|
||||
### 📋 基础检查项
|
||||
|
||||
#### 代码质量
|
||||
- [ ] 代码已通过 ESLint 检查,无警告和错误
|
||||
- [ ] 代码已通过 Prettier 格式化
|
||||
- [ ] 无 console.log() 等调试代码残留
|
||||
- [ ] 变量命名符合规范,语义清晰
|
||||
- [ ] 函数职责单一,复杂度适中
|
||||
|
||||
#### 构建验证
|
||||
- [ ] 本地执行 `npm run build:prod` 成功完成
|
||||
- [ ] 构建产物无报错,体积合理
|
||||
- [ ] 静态资源路径正确,无404错误
|
||||
- [ ] 环境变量配置正确(开发/测试/生产)
|
||||
|
||||
#### 功能验证
|
||||
- [ ] 核心功能流程完整测试通过
|
||||
- [ ] 边界条件和异常场景已覆盖
|
||||
- [ ] 表单验证逻辑正确
|
||||
- [ ] API 接口调用正常,错误处理完善
|
||||
- [ ] 路由跳转逻辑正确
|
||||
|
||||
### 🔧 技术检查项
|
||||
|
||||
#### 模块导入检查
|
||||
- [ ] 所有 import 语句引用的模块实际存在
|
||||
- [ ] 无未使用的 import 导入
|
||||
- [ ] 路径别名(@/)配置正确
|
||||
- [ ] 第三方库版本兼容性确认
|
||||
|
||||
#### 性能优化
|
||||
- [ ] 组件按需加载(懒加载)已配置
|
||||
- [ ] 大数据列表已实现虚拟滚动或分页
|
||||
- [ ] 图片资源已压缩,格式合适
|
||||
- [ ] 无内存泄漏风险(事件监听器、定时器等)
|
||||
|
||||
#### 安全检查
|
||||
- [ ] 用户输入已做 XSS 防护
|
||||
- [ ] 敏感信息不在前端硬编码
|
||||
- [ ] API 请求已做 CSRF 防护
|
||||
- [ ] 权限控制逻辑正确
|
||||
|
||||
### 🌐 兼容性检查
|
||||
|
||||
#### 浏览器兼容
|
||||
- [ ] 主流浏览器(Chrome、Firefox、Safari、Edge)显示正常
|
||||
- [ ] 移动端适配良好(如适用)
|
||||
- [ ] 分辨率适配(1366x768、1920x1080等)
|
||||
|
||||
#### 设备兼容
|
||||
- [ ] 触摸设备操作体验良好
|
||||
- [ ] 键盘导航支持完整
|
||||
- [ ] 屏幕阅读器兼容性(无障碍)
|
||||
|
||||
### 📱 发布准备
|
||||
|
||||
#### 文档更新
|
||||
- [ ] 相关 API 文档已同步更新
|
||||
- [ ] 用户操作手册已更新(如适用)
|
||||
- [ ] 变更日志已记录
|
||||
|
||||
#### 回滚预案
|
||||
- [ ] 回滚方案已准备
|
||||
- [ ] 数据兼容性已确认
|
||||
- [ ] 紧急联系人已明确
|
||||
|
||||
### ✅ 最终确认
|
||||
|
||||
#### 发布前最后检查
|
||||
- [ ] 本地构建截图已附在 PR 中
|
||||
- [ ] 测试环境部署验证通过
|
||||
- [ ] Code Review 已完成并获得批准
|
||||
- [ ] 相关 Bug 已关闭或延期说明
|
||||
|
||||
---
|
||||
|
||||
## 3. 后端检查(backend-checklist)
|
||||
|
||||
### 📋 基础检查项
|
||||
|
||||
#### Maven编译验证
|
||||
- [ ] 本地执行 `mvn compile` 编译通过,无ERROR
|
||||
- [ ] 执行 `mvn package -DskipTests` 打包成功
|
||||
- [ ] 依赖版本无冲突(`mvn dependency:tree` 检查)
|
||||
- [ ] 无编译警告(或已有书面说明可忽略)
|
||||
|
||||
#### 构建产物验证
|
||||
- [ ] JAR/WAR包生成完整,大小合理
|
||||
- [ ] `application.yml` 等配置文件已打包进产物
|
||||
- [ ] 第三方依赖jar包完整(lib目录无缺失)
|
||||
|
||||
### 🔧 Spring Boot 配置检查
|
||||
|
||||
#### 多环境配置
|
||||
- [ ] `application-dev.yml`(开发)配置正确
|
||||
- [ ] `application-test.yml`(测试)配置正确
|
||||
- [ ] `application-prod.yml`(生产)配置正确
|
||||
- [ ] 启动参数 `--spring.profiles.active` 指定正确环境
|
||||
- [ ] 生产环境未启用devtools热部署
|
||||
|
||||
#### Actuator安全
|
||||
- [ ] 生产环境 `/actuator` 端点已禁用或限制访问
|
||||
- [ ] `/actuator/env`、`/actuator/heapdump` 等敏感端点已关闭
|
||||
- [ ] 健康检查端点 `/actuator/health` 返回信息已脱敏
|
||||
|
||||
#### 启动校验
|
||||
- [ ] 数据库连接池配置合理(HikariCP最大/最小连接数)
|
||||
- [ ] Redis/消息中间件连接配置正确
|
||||
- [ ] 启动日志无ERROR级别异常
|
||||
|
||||
### 🗄️ MyBatis Plus 规范检查
|
||||
|
||||
#### 实体-表映射
|
||||
- [ ] 所有实体类标注 `@TableName`,表名与实际一致
|
||||
- [ ] 主键字段标注 `@TableId(type = IdType.AUTO)` 或对应策略
|
||||
- [ ] 非表字段标注 `@TableField(exist = false)`
|
||||
- [ ] 字段命名符合下划线转驼峰规则
|
||||
|
||||
#### SQL安全
|
||||
- [ ] 所有查询使用参数化查询(`QueryWrapper` / `LambdaQueryWrapper`)
|
||||
- [ ] 禁止字符串拼接SQL(`"WHERE name = '" + name + "'"`)
|
||||
- [ ] 批量操作使用MyBatis Plus `saveBatch` / `updateBatchById`
|
||||
- [ ] 复杂SQL使用XML映射,避免注解内嵌长SQL
|
||||
|
||||
#### 事务管理
|
||||
- [ ] 涉及多表写操作的方法标注 `@Transactional`
|
||||
- [ ] 事务边界合理,不包含外部HTTP调用
|
||||
- [ ] 异常回滚配置正确(`rollbackFor = Exception.class`)
|
||||
- [ ] 事务方法未被同一类内方法直接调用(自调用失效问题)
|
||||
|
||||
#### 分页插件
|
||||
- [ ] `PaginationInnerInterceptor` 已正确配置
|
||||
- [ ] 分页查询使用 `Page<T>` 对象,非手动limit/offset
|
||||
|
||||
### 🔌 RESTful API 设计检查
|
||||
|
||||
#### 统一返回格式
|
||||
- [ ] 所有接口返回 `{code, msg, data}` 统一结构
|
||||
- [ ] 成功返回 `code=200`,业务错误使用自定义错误码
|
||||
- [ ] 异常通过 `@ControllerAdvice` + `@ExceptionHandler` 统一处理
|
||||
|
||||
#### HTTP状态码
|
||||
- [ ] 资源创建返回 `201 Created`
|
||||
- [ ] 资源删除返回 `204 No Content`
|
||||
- [ ] 参数校验失败返回 `400 Bad Request`
|
||||
- [ ] 未认证返回 `401 Unauthorized`
|
||||
- [ ] 无权限返回 `403 Forbidden`
|
||||
- [ ] 资源不存在返回 `404 Not Found`
|
||||
|
||||
#### 参数校验
|
||||
- [ ] 请求参数使用 `@Valid` / `@Validated` 注解校验
|
||||
- [ ] 必填字段标注 `@NotBlank` / `@NotNull`
|
||||
- [ ] 数值范围标注 `@Min` / `@Max`
|
||||
- [ ] 格式校验使用 `@Pattern`(如手机号、身份证号)
|
||||
- [ ] 校验失败返回明确错误信息(非500堆栈)
|
||||
|
||||
#### API版本管理
|
||||
- [ ] 接口路径包含版本号(`/api/v1/`、`/api/v2/`)
|
||||
- [ ] 废弃接口标注 `@Deprecated`,并在文档中说明
|
||||
- [ ] 不兼容变更必须升级版本号
|
||||
|
||||
### 🔒 安全与合规检查
|
||||
|
||||
#### 数据脱敏
|
||||
- [ ] 患者身份证号在日志中脱敏(`***` 掩码)
|
||||
- [ ] 患者手机号在日志中脱敏(前3后4,中间`****`)
|
||||
- [ ] 敏感字段序列化时使用 `@JsonSerialize` 自定义脱敏器
|
||||
- [ ] 接口返回中非必需字段不暴露(如密码、salt)
|
||||
|
||||
#### 权限控制
|
||||
- [ ] 所有涉及患者数据的接口标注 `@PreAuthorize`
|
||||
- [ ] 数据级权限校验(医生只能访问本科室患者)
|
||||
- [ ] 越权访问返回 `403`,非 `404` 或 `500`
|
||||
- [ ] 敏感操作(删除、修改诊断)需二次确认或额外权限
|
||||
|
||||
#### 审计日志
|
||||
- [ ] 处方修改记录操作人、时间、变更内容
|
||||
- [ ] 病历删除操作记录完整审计链
|
||||
- [ ] 审计日志独立存储,不可被业务用户删除
|
||||
- [ ] 关键业务操作记录IP地址和操作终端
|
||||
|
||||
### ⚡ 性能检查
|
||||
|
||||
#### 数据库查询
|
||||
- [ ] 无N+1查询问题(使用 `JOIN` 或批量查询)
|
||||
- [ ] 大表查询必须有分页限制
|
||||
- [ ] 慢查询已优化(执行时间 < 500ms)
|
||||
- [ ] 索引已覆盖高频查询条件
|
||||
|
||||
#### 接口性能
|
||||
- [ ] 核心接口响应时间 < 1秒
|
||||
- [ ] 列表接口支持分页,无全量返回
|
||||
- [ ] 大文件下载使用流式传输,非全量加载到内存
|
||||
|
||||
### 📝 文档与发布准备
|
||||
|
||||
#### 文档更新
|
||||
- [ ] API接口文档已同步更新(路径、参数、返回值)
|
||||
- [ ] 数据库变更脚本已提供(DDL/DML)
|
||||
- [ ] 配置变更说明已记录(新增/修改的配置项)
|
||||
- [ ] 影响范围说明已明确(哪些模块、哪些接口受影响)
|
||||
|
||||
#### 回滚预案
|
||||
- [ ] 数据库变更可回滚(提供反向SQL脚本)
|
||||
- [ ] 配置变更可快速回退
|
||||
- [ ] 紧急回滚流程已明确(谁、怎么做、多长时间)
|
||||
- [ ] 回滚后数据一致性已验证
|
||||
|
||||
### ✅ 最终确认
|
||||
|
||||
#### 发布前最后检查
|
||||
- [ ] `mvn compile` 构建成功(附终端截图)
|
||||
- [ ] 关键单元测试通过
|
||||
- [ ] 测试环境部署验证通过
|
||||
- [ ] Code Review 已完成并获得批准
|
||||
- [ ] 相关Bug已关闭或延期说明
|
||||
|
||||
---
|
||||
|
||||
## 4. CI/CD门禁(cicd-gatekeeper)
|
||||
|
||||
### 🎯 规范目标
|
||||
|
||||
建立自动化质量门禁,确保每次代码提交都经过严格验证,防止低质量代码进入主干分支,提升系统稳定性和开发效率。
|
||||
|
||||
### 🔒 门禁层级
|
||||
|
||||
#### 1. 提交前门禁(Pre-commit)
|
||||
**触发时机**:`git commit` 执行前
|
||||
**验证内容**:
|
||||
- ESLint 代码规范检查
|
||||
- Prettier 代码格式化
|
||||
- 简单的单元测试(快速执行)
|
||||
|
||||
**工具配置**:
|
||||
- Husky + lint-staged
|
||||
- 配置文件:`.husky/pre-commit`
|
||||
|
||||
#### 2. 推送前门禁(Pre-push)
|
||||
**触发时机**:`git push` 执行前
|
||||
**验证内容**:
|
||||
- 完整的单元测试套件
|
||||
- 构建验证(`npm run build:prod`)
|
||||
- 集成测试(核心流程)
|
||||
|
||||
**工具配置**:
|
||||
- Husky pre-push hook
|
||||
- 配置文件:`.husky/pre-push`
|
||||
|
||||
#### 3. CI流水线门禁(CI Pipeline)
|
||||
**触发时机**:代码推送到远程仓库后
|
||||
**验证内容**:
|
||||
- 完整的测试套件(单元+集成+端到端)
|
||||
- 代码覆盖率检查(分阶段目标:Q1≥30%,Q2≥50%,Q3≥80%)
|
||||
- 安全扫描(SAST)
|
||||
- 构建产物验证
|
||||
- 部署到测试环境
|
||||
|
||||
**工具配置**:
|
||||
- Spug CI/CD 流水线
|
||||
- Gitea Webhook 触发
|
||||
|
||||
#### 4. 发布前门禁(Release Gate)
|
||||
**触发时机**:准备发布到生产环境前
|
||||
**验证内容**:
|
||||
- 生产环境冒烟测试
|
||||
- 性能基准测试
|
||||
- 安全合规检查
|
||||
- 回滚预案验证
|
||||
|
||||
### ⚙️ 具体配置要求
|
||||
|
||||
#### ESLint 配置
|
||||
```javascript
|
||||
// eslint.config.js 关键配置
|
||||
import globals from "globals";
|
||||
import pluginVue from "eslint-plugin-vue";
|
||||
import parserVue from "vue-eslint-parser";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
|
||||
export default [
|
||||
{
|
||||
name: "app/files-to-lint",
|
||||
files: ["**/*.{js,mjs,jsx,vue}"],
|
||||
},
|
||||
|
||||
{
|
||||
name: "app/files-to-ignore",
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/help-center/**"],
|
||||
},
|
||||
|
||||
...pluginVue.configs["flat/recommended"],
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parser: parserVue,
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
|
||||
rules: {
|
||||
// 确保导入的模块实际存在(核心规则,防止构建失败)
|
||||
"import/no-unresolved": "error",
|
||||
// 确保导入的命名导出实际存在
|
||||
"import/named": "error",
|
||||
// 确保默认导出存在
|
||||
"import/default": "error",
|
||||
// 确保命名空间导出存在
|
||||
"import/namespace": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
#### Java 后端配置
|
||||
```xml
|
||||
<!-- pom.xml 关键插件 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.8.1</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
<version>4.2.0</version>
|
||||
</plugin>
|
||||
```
|
||||
|
||||
#### 数据库迁移配置
|
||||
```yaml
|
||||
# application.yml Flyway配置
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
baseline-on-migrate: true
|
||||
```
|
||||
|
||||
#### Husky 配置
|
||||
```bash
|
||||
# .husky/pre-commit
|
||||
#!/bin/sh
|
||||
npm run lint-staged
|
||||
|
||||
# .husky/pre-push
|
||||
#!/bin/sh
|
||||
npm run test:unit && npm run build:prod
|
||||
```
|
||||
|
||||
#### lint-staged 配置
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"lint-staged": {
|
||||
"*.{js,vue}": ["eslint --fix", "prettier --write"],
|
||||
"*.{css,scss}": ["stylelint --fix", "prettier --write"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🚫 失败处理机制
|
||||
|
||||
#### 自动处理
|
||||
- **构建失败**:自动阻止 PR 合并
|
||||
- **测试失败**:标记 PR 为失败状态
|
||||
- **安全漏洞**:立即通知安全团队
|
||||
|
||||
#### 人工处理
|
||||
- **紧急修复**:可申请临时绕过(需架构师批准)
|
||||
- **误报处理**:提交豁免申请并说明原因
|
||||
- **规则调整**:通过 RFC 流程申请规则变更
|
||||
|
||||
### 📊 监控与度量
|
||||
|
||||
#### 关键指标
|
||||
- 门禁通过率 ≥ 95%
|
||||
- 平均修复时间 ≤ 2小时
|
||||
- 误报率 ≤ 5%
|
||||
|
||||
#### 报告机制
|
||||
- 每日门禁失败统计
|
||||
- 周度质量趋势报告
|
||||
- 月度规则优化建议
|
||||
|
||||
### 🔄 持续改进
|
||||
|
||||
#### 规则演进
|
||||
- 每月评审门禁规则有效性
|
||||
- 根据项目需求调整检查强度
|
||||
- 引入新的质量检查工具
|
||||
|
||||
#### 团队培训
|
||||
- 新成员入职培训包含门禁规范
|
||||
- 定期分享最佳实践案例
|
||||
- 建立常见问题解决方案库
|
||||
|
||||
---
|
||||
|
||||
## 5. 发布确认与回滚预案
|
||||
|
||||
### 📋 发布前最终确认清单
|
||||
|
||||
#### 前端确认
|
||||
- [ ] 本地构建成功(`npm run build:prod`)
|
||||
- [ ] 核心功能流程测试通过
|
||||
- [ ] 模块导入检查通过(无import错误)
|
||||
- [ ] 兼容性测试完成
|
||||
|
||||
#### 后端确认
|
||||
- [ ] Maven编译成功(`mvn compile`)
|
||||
- [ ] 单元测试通过
|
||||
- [ ] 数据库脚本验证通过
|
||||
- [ ] API接口测试通过
|
||||
|
||||
#### 协同确认
|
||||
- [ ] 前后端接口契约一致
|
||||
- [ ] 联调测试通过
|
||||
- [ ] Code Review 已完成
|
||||
- [ ] 测试环境部署验证通过
|
||||
|
||||
### 🚨 回滚预案
|
||||
|
||||
#### 触发条件
|
||||
- [ ] 生产环境出现严重Bug
|
||||
- [ ] 性能严重下降
|
||||
- [ ] 数据一致性问题
|
||||
- [ ] 安全漏洞暴露
|
||||
|
||||
#### 回滚步骤
|
||||
1. **立即停止**:暂停新流量进入
|
||||
2. **版本回退**:部署上一个稳定版本
|
||||
3. **数据回滚**:执行数据库回滚脚本(如有)
|
||||
4. **验证恢复**:确认系统功能正常
|
||||
5. **问题分析**:记录根本原因和改进措施
|
||||
|
||||
#### 责任分工
|
||||
- **技术负责人**:执行回滚操作
|
||||
- **测试负责人**:验证回滚后功能
|
||||
- **项目经理**:协调沟通和进度同步
|
||||
- **运维团队**:监控系统状态
|
||||
|
||||
### 📞 紧急联系人
|
||||
|
||||
| 角色 | 姓名 | 联系方式 | 职责 |
|
||||
|------|------|----------|------|
|
||||
| 技术负责人 | 诸葛亮 | @诸葛亮 | 架构决策和技术指导 |
|
||||
| 前端负责人 | 赵云 | @赵云 | 前端问题处理 |
|
||||
| 后端负责人 | 关羽 | @关羽 | 后端问题处理 |
|
||||
| 测试负责人 | 张飞 | @张飞 | 质量验证和问题复现 |
|
||||
| 项目经理 | 刘备 | @刘备 | 项目协调和进度管理 |
|
||||
| 文档负责人 | 陈琳 | @陈琳 | 文档维护和知识沉淀 |
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**最后更新**:2026年4月25日
|
||||
**负责人**:陈琳(文档专家)
|
||||
**适用范围**:HIS 系统所有开发人员
|
||||
214
docs/specs/playwright-e2e-testing-plan.md
Normal file
214
docs/specs/playwright-e2e-testing-plan.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# HIS项目 Playwright E2E 自动化测试方案 v1.0
|
||||
|
||||
## 一、方案概述
|
||||
|
||||
### 1.1 选型理由
|
||||
- **Playwright** 是微软开源的端到端测试框架,完美适配 Vue 3 + Vite 技术栈
|
||||
- 自动等待机制适合HIS系统复杂交互场景(异步加载、动态渲染)
|
||||
- 支持多浏览器(Chromium/Firefox/WebKit),CI/CD集成成熟
|
||||
- 已有 `@playwright/test ^1.58.2` 依赖 installed
|
||||
|
||||
### 1.2 目标
|
||||
1. 核心业务流程自动化覆盖率达到 80%+
|
||||
2. 已修复Bug 100% 回归测试覆盖
|
||||
3. 每次代码推送自动触发测试,失败阻断发布
|
||||
|
||||
## 二、项目结构
|
||||
|
||||
```
|
||||
openhis-ui-vue3/
|
||||
├── tests/
|
||||
│ ├── e2e/
|
||||
│ │ ├── fixtures/ # 测试夹具
|
||||
│ │ │ └── auth.ts # 登录认证fixture
|
||||
│ │ ├── pages/ # 页面对象模型(POM)
|
||||
│ │ │ ├── LoginPage.ts
|
||||
│ │ │ ├── DoctorStationPage.ts
|
||||
│ │ │ └── SurgeryBillingPage.ts
|
||||
│ │ ├── specs/ # 测试用例
|
||||
│ │ │ ├── login.spec.ts
|
||||
│ │ │ ├── doctor-station.spec.ts
|
||||
│ │ │ ├── surgery-billing.spec.ts
|
||||
│ │ │ └── bug-regression.spec.ts # Bug回归测试
|
||||
│ │ └── utils/
|
||||
│ │ └── test-data.ts # 测试数据
|
||||
│ └── playwright.config.ts # Playwright配置
|
||||
├── .env.test # 测试环境变量
|
||||
└── package.json # 已有playwright依赖
|
||||
```
|
||||
|
||||
## 三、环境配置
|
||||
|
||||
### 3.1 环境变量(.env.test)
|
||||
```bash
|
||||
# 测试环境配置
|
||||
VITE_APP_BASE_API=http://192.168.110.253:8080
|
||||
TEST_USERNAME=test_admin
|
||||
TEST_PASSWORD=test123456
|
||||
TEST_BASE_URL=http://localhost:80
|
||||
```
|
||||
|
||||
### 3.2 Playwright配置(playwright.config.ts)
|
||||
```typescript
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e/specs',
|
||||
timeout: 60 * 1000,
|
||||
expect: { timeout: 10000 },
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: [['html', { outputFolder: 'playwright-report' }], ['list']],
|
||||
use: {
|
||||
baseURL: process.env.TEST_BASE_URL || 'http://localhost:80',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## 四、核心测试用例
|
||||
|
||||
### 4.1 登录测试(login.spec.ts)
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('用户登录成功', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', process.env.TEST_USERNAME || 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', process.env.TEST_PASSWORD || '123456');
|
||||
await page.click('button:has-text("登录")');
|
||||
await expect(page).toHaveURL(/.*dashboard.*/);
|
||||
await expect(page.locator('.user-avatar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('登录失败-错误密码', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', 'wrongpassword');
|
||||
await page.click('button:has-text("登录")');
|
||||
await expect(page.locator('.el-message--error')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### 4.2 门诊医生站测试(doctor-station.spec.ts)
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('门诊医生站', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 登录
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', process.env.TEST_USERNAME || 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', process.env.TEST_PASSWORD || '123456');
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForURL(/.*dashboard.*/);
|
||||
});
|
||||
|
||||
test('#427 检查项目分类手风琴展开', async ({ page }) => {
|
||||
await page.goto('/doctorstation');
|
||||
// 点击第一个分类
|
||||
await page.click('.category-item >> nth=0');
|
||||
await expect(page.locator('.category-content >> nth=0')).toBeVisible();
|
||||
// 点击第二个分类,第一个应收起
|
||||
await page.click('.category-item >> nth=1');
|
||||
await expect(page.locator('.category-content >> nth=0')).not.toBeVisible();
|
||||
await expect(page.locator('.category-content >> nth=1')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4.3 手术计费回归测试(bug-regression.spec.ts)
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Bug回归测试', () => {
|
||||
test('#437 手术计费防重复提交', async ({ page }) => {
|
||||
// 登录并导航到手术计费
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', process.env.TEST_USERNAME || 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', process.env.TEST_PASSWORD || '123456');
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForURL(/.*dashboard.*/);
|
||||
await page.goto('/surgery-billing');
|
||||
|
||||
// 快速连续点击新增按钮(测试防重复锁)
|
||||
const addBtn = page.locator('button:has-text("新增")');
|
||||
await addBtn.click();
|
||||
await addBtn.click(); // 第二次应被阻止
|
||||
await addBtn.click(); // 第三次应被阻止
|
||||
|
||||
// 验证只弹出一个表单
|
||||
await expect(page.locator('.el-dialog')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 五、执行命令
|
||||
|
||||
```bash
|
||||
# 安装浏览器
|
||||
npx playwright install chromium
|
||||
|
||||
# 运行所有测试
|
||||
npm run test:e2e
|
||||
|
||||
# 运行单个测试文件
|
||||
npx playwright test login.spec.ts
|
||||
|
||||
# 生成HTML报告
|
||||
npx playwright show-report
|
||||
|
||||
# UI模式(调试用)
|
||||
npx playwright test --ui
|
||||
```
|
||||
|
||||
## 六、CI/CD集成
|
||||
|
||||
### 6.1 package.json脚本
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Spug流水线集成
|
||||
```yaml
|
||||
# Spug 构建后阶段添加
|
||||
- name: E2E Testing
|
||||
script: |
|
||||
cd openhis-ui-vue3
|
||||
npx playwright install --with-deps chromium
|
||||
npm run test:e2e -- --reporter=html
|
||||
# 测试失败则阻断发布
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "E2E测试失败,阻断发布!"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## 七、实施计划
|
||||
|
||||
| 阶段 | 时间 | 内容 | 负责人 |
|
||||
|------|------|------|--------|
|
||||
| Phase 1 | 第1周 | 登录+核心页面冒烟测试 | 张飞+赵云 |
|
||||
| Phase 2 | 第2-3周 | 门诊医生站+手术计费全流程 | 张飞 |
|
||||
| Phase 3 | 第4周 | Bug回归测试全覆盖 | 张飞 |
|
||||
| Phase 4 | 第5周 | CI/CD流水线集成 | 赵云+运维 |
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. **测试数据隔离**:使用独立的测试数据库,不污染生产数据
|
||||
2. **环境变量**:敏感信息通过 `.env.test` 管理,不提交到git
|
||||
3. **截图留痕**:失败时自动截图,便于排查
|
||||
4. **测试优先**:新功能开发时同步编写测试用例
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.core.web.controller.system;
|
||||
|
||||
import com.core.common.config.CoreConfig;
|
||||
import com.core.common.core.domain.AjaxResult;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 系统版本信息
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/system")
|
||||
public class SysVersionController {
|
||||
|
||||
@Autowired
|
||||
private CoreConfig coreConfig;
|
||||
|
||||
/**
|
||||
* 获取后端版本号
|
||||
*/
|
||||
@GetMapping("/version")
|
||||
public AjaxResult getVersion() {
|
||||
AjaxResult ajax = AjaxResult.success();
|
||||
ajax.put("backendVersion", coreConfig.getVersion());
|
||||
return ajax;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,9 @@ public class SecurityConfig {
|
||||
.permitAll()
|
||||
.antMatchers("/patientmanage/information/**")
|
||||
.permitAll()
|
||||
// 登录页展示用的系统版本信息,允许匿名访问
|
||||
.antMatchers("/system/version")
|
||||
.permitAll()
|
||||
// 除上面外的所有请求全部需要鉴权认证
|
||||
.anyRequest().authenticated();
|
||||
})
|
||||
|
||||
@@ -40,7 +40,9 @@ import com.openhis.web.chargemanage.dto.PatientMetadata;
|
||||
import com.openhis.web.chargemanage.dto.PractitionerMetadata;
|
||||
import com.openhis.web.chargemanage.dto.ReprintRegistrationDto;
|
||||
import com.openhis.web.chargemanage.mapper.OutpatientRegistrationAppMapper;
|
||||
import com.openhis.triageandqueuemanage.domain.DivLog;
|
||||
import com.openhis.triageandqueuemanage.domain.TriageCandidateExclusion;
|
||||
import com.openhis.triageandqueuemanage.service.DivLogService;
|
||||
import com.openhis.triageandqueuemanage.service.TriageCandidateExclusionService;
|
||||
import com.openhis.web.paymentmanage.appservice.IPaymentRecService;
|
||||
import com.openhis.web.paymentmanage.dto.CancelPaymentDto;
|
||||
@@ -111,6 +113,9 @@ public class OutpatientRegistrationAppServiceImpl implements IOutpatientRegistra
|
||||
@Resource
|
||||
com.openhis.triageandqueuemanage.service.TriageQueueItemService triageQueueItemService;
|
||||
|
||||
@Resource
|
||||
DivLogService divLogService;
|
||||
|
||||
@Resource
|
||||
ScheduleSlotMapper scheduleSlotMapper;
|
||||
|
||||
@@ -807,6 +812,23 @@ public class OutpatientRegistrationAppServiceImpl implements IOutpatientRegistra
|
||||
queueItem.setDeleteFlag("1");
|
||||
queueItem.setUpdateTime(LocalDateTime.now());
|
||||
triageQueueItemService.updateById(queueItem);
|
||||
|
||||
// 写入分诊操作日志:诊前退号
|
||||
try {
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
DivLog divLog = new DivLog()
|
||||
.setPoolId(queueItem.getPoolId())
|
||||
.setSlotId(queueItem.getSlotId())
|
||||
.setOpUserId(loginUser != null ? loginUser.getUserId() : null)
|
||||
.setAction("REFUND")
|
||||
.setCreateTime(LocalDateTime.now())
|
||||
.setUpdateAt(LocalDateTime.now())
|
||||
.setCreatedAt(LocalDateTime.now());
|
||||
divLogService.save(divLog);
|
||||
} catch (Exception e) {
|
||||
log.error("写入分诊退号日志失败,encounterId={}", encounterId, e);
|
||||
}
|
||||
|
||||
log.info("退号成功,已移除分诊队列记录,encounterId={}, queueItemId={}", encounterId, queueItem.getId());
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,13 @@ public class OutpatientPricingController {
|
||||
@RequestParam(value = "organizationId") Long organizationId,
|
||||
@RequestParam(value = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize,
|
||||
@RequestParam(value = "categoryCode", required = false) String categoryCode) {
|
||||
@RequestParam(value = "categoryCode", required = false) String categoryCode,
|
||||
@RequestParam(value = "adviceType", required = false) Integer adviceType) {
|
||||
// 将 categoryCode 设置到 adviceBaseDto 中
|
||||
// Bug #438 修复:接收并处理 adviceType 参数
|
||||
if (adviceType != null) {
|
||||
adviceBaseDto.setAdviceType(adviceType);
|
||||
}
|
||||
if (categoryCode != null && !categoryCode.isEmpty()) {
|
||||
adviceBaseDto.setCategoryCode(categoryCode);
|
||||
}
|
||||
|
||||
@@ -170,4 +170,9 @@ public class CurrentDayEncounterDto {
|
||||
*/
|
||||
private String clinicRoom;
|
||||
|
||||
/**
|
||||
* 预约序号(来自 adm_schedule_slot.seq_no)
|
||||
*/
|
||||
private Integer seqNo;
|
||||
|
||||
}
|
||||
|
||||
@@ -209,4 +209,14 @@ public class EncounterPatientPrescriptionDto {
|
||||
private String discountRate = "0";
|
||||
private String discountRate_dictText;
|
||||
|
||||
/**
|
||||
* 账单生成来源
|
||||
*/
|
||||
private Integer generateSourceEnum;
|
||||
|
||||
/**
|
||||
* 来源单据号(手术单号等)
|
||||
*/
|
||||
private String sourceBillNo;
|
||||
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.openhis.common.constant.CommonConstants;
|
||||
import com.openhis.common.enums.*;
|
||||
import com.openhis.common.utils.EnumUtils;
|
||||
import com.openhis.common.utils.HisQueryUtils;
|
||||
import com.openhis.web.doctorstation.appservice.IDoctorStationMainAppService;
|
||||
import com.openhis.web.doctorstation.appservice.ITodayOutpatientService;
|
||||
import com.openhis.web.doctorstation.dto.TodayOutpatientPatientDto;
|
||||
import com.openhis.web.doctorstation.dto.TodayOutpatientQueryParam;
|
||||
@@ -32,6 +33,9 @@ public class TodayOutpatientServiceImpl implements ITodayOutpatientService {
|
||||
@Resource
|
||||
private TodayOutpatientMapper todayOutpatientMapper;
|
||||
|
||||
@Resource
|
||||
private IDoctorStationMainAppService doctorStationMainAppService;
|
||||
|
||||
@Override
|
||||
public TodayOutpatientStatsDto getTodayOutpatientStats(HttpServletRequest request) {
|
||||
Long doctorId = SecurityUtils.getLoginUser().getUserId();
|
||||
@@ -259,22 +263,19 @@ public class TodayOutpatientServiceImpl implements ITodayOutpatientService {
|
||||
@Override
|
||||
public R<?> receivePatient(Long encounterId, HttpServletRequest request) {
|
||||
// 调用现有的接诊逻辑
|
||||
// 这里可以复用 DoctorStationMainAppServiceImpl 中的 receiveEncounter 方法
|
||||
// 或者直接调用相应的服务
|
||||
|
||||
return R.ok("接诊成功");
|
||||
return doctorStationMainAppService.receiveEncounter(encounterId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<?> completeVisit(Long encounterId, HttpServletRequest request) {
|
||||
// 调用现有的完诊逻辑
|
||||
return R.ok("就诊完成");
|
||||
return doctorStationMainAppService.completeEncounter(encounterId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public R<?> cancelVisit(Long encounterId, String reason, HttpServletRequest request) {
|
||||
// 调用现有的取消就诊逻辑
|
||||
return R.ok("就诊取消成功");
|
||||
return doctorStationMainAppService.cancelEncounter(encounterId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,4 +303,4 @@ public class TodayOutpatientServiceImpl implements ITodayOutpatientService {
|
||||
|
||||
return orderBy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,11 +350,16 @@ public class NurseBillingAppService implements INurseBillingAppService {
|
||||
// 1. 筛选临时类型耗材:仅处理临时医嘱(TherapyTimeType.TEMPORARY),且请求ID不为空
|
||||
List<AdviceSaveDto> tempDeviceList = deviceAdviceList.stream().collect(Collectors.toList());
|
||||
|
||||
// 2. 校验发放库房:必须指定耗材发放库房(locationId),否则抛出业务异常
|
||||
if (tempDeviceList.stream().anyMatch(t -> t.getLocationId() == null)) {
|
||||
throw new ServiceException("耗材划价失败:发放库房为空,请重新选择");
|
||||
// 2. 颞理发放库房:为locationId为null的项目设置默认值
|
||||
for (AdviceSaveDto advice : tempDeviceList) {
|
||||
if (advice.getLocationId() == null) {
|
||||
// 设置默认位置为用户组织ID作为fallback
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
if (loginUser != null && loginUser.getOrgId() != null) {
|
||||
advice.setLocationId(loginUser.getOrgId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 循环处理每个临时耗材医嘱(逐条生成关联数据)
|
||||
for (AdviceSaveDto adviceDto : tempDeviceList) {
|
||||
// 3.1 生成耗材请求记录(WOR_DEVICE_REQUEST):状态设为激活,来源标记为护士划价
|
||||
@@ -665,7 +670,7 @@ public class NurseBillingAppService implements INurseBillingAppService {
|
||||
// 1. 校验:待删除项是否已收费,已收费则抛出异常阻止删除
|
||||
checkDeletedDeviceChargeStatus(requestIds);
|
||||
// 软删除执行记录
|
||||
List<Procedure> procedureList = iProcedureService.listByIds(requestIds);
|
||||
List<Procedure> procedureList = iProcedureService.getProcedureRecords(requestIds, serviceTable);
|
||||
List<Long> procedureIds = procedureList.stream().filter(Objects::nonNull) // 过滤掉null的Procedure对象
|
||||
.map(Procedure::getId).filter(Objects::nonNull) // 过滤掉id为null的记录(按需添加)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.openhis.workflow.service.IActivityDefinitionService;
|
||||
import com.openhis.workflow.service.IServiceRequestService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
@@ -40,6 +41,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public class RequestFormManageAppServiceImpl implements IRequestFormManageAppService {
|
||||
|
||||
@Resource
|
||||
@@ -71,6 +73,7 @@ public class RequestFormManageAppServiceImpl implements IRequestFormManageAppSer
|
||||
* @return 结果
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<?> saveRequestForm(RequestFormSaveDto requestFormSaveDto, String typeCode) {
|
||||
// 诊疗处方号
|
||||
String prescriptionNo;
|
||||
@@ -330,7 +333,7 @@ public class RequestFormManageAppServiceImpl implements IRequestFormManageAppSer
|
||||
surgeryChargeItem.setBusNo(AssignSeqEnum.CHARGE_ITEM_NO.getPrefix().concat(surgeryServiceRequest.getBusNo()));
|
||||
surgeryChargeItem.setGenerateSourceEnum(GenerateSource.DOCTOR_PRESCRIPTION.getValue());
|
||||
surgeryChargeItem.setPatientId(patientId);
|
||||
surgeryChargeItem.setContextEnum(6); // 6-手术
|
||||
surgeryChargeItem.setContextEnum(3); // 3-项目(手术属于诊疗项目)
|
||||
surgeryChargeItem.setEncounterId(encounterId);
|
||||
surgeryChargeItem.setEntererId(practitionerId);
|
||||
surgeryChargeItem.setEnteredDate(curDate);
|
||||
|
||||
@@ -2,8 +2,9 @@ package com.openhis.web.reportmanage.dto;
|
||||
|
||||
|
||||
import java.util.Date;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
@@ -12,7 +13,8 @@ import java.math.BigDecimal;
|
||||
* @author yuxj
|
||||
* @date 2025/8/25
|
||||
*/
|
||||
@Data
|
||||
@Getter
|
||||
@Setter
|
||||
@Accessors(chain = true)
|
||||
public class InpatientMedicalRecordHomePageCollectionDto {
|
||||
|
||||
|
||||
@@ -5,22 +5,32 @@ import cn.hutool.core.util.ObjectUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.core.common.core.domain.R;
|
||||
import com.core.common.utils.SecurityUtils;
|
||||
import com.openhis.triageandqueuemanage.domain.CallRecord;
|
||||
import com.openhis.triageandqueuemanage.domain.DivLog;
|
||||
import com.openhis.triageandqueuemanage.domain.TriageCandidateExclusion;
|
||||
import com.openhis.triageandqueuemanage.domain.TriageQueueItem;
|
||||
import com.openhis.triageandqueuemanage.service.CallRecordService;
|
||||
import com.openhis.triageandqueuemanage.service.DivLogService;
|
||||
import com.openhis.triageandqueuemanage.service.TriageCandidateExclusionService;
|
||||
import com.openhis.triageandqueuemanage.service.TriageQueueItemService;
|
||||
import com.openhis.appointmentmanage.domain.ScheduleSlot;
|
||||
import com.openhis.appointmentmanage.mapper.ScheduleSlotMapper;
|
||||
import com.openhis.common.enums.CallType;
|
||||
import com.openhis.web.triageandqueuemanage.appservice.TriageQueueAppService;
|
||||
import com.openhis.web.triageandqueuemanage.dto.*;
|
||||
import com.openhis.web.triageandqueuemanage.sse.CallNumberSseManager;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import com.core.common.core.domain.model.LoginUser;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
|
||||
@@ -39,13 +49,22 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
|
||||
@Resource
|
||||
private TriageQueueItemService triageQueueItemService;
|
||||
|
||||
|
||||
@Resource
|
||||
private CallNumberSseManager callNumberSseManager;
|
||||
|
||||
|
||||
@Resource
|
||||
private TriageCandidateExclusionService triageCandidateExclusionService;
|
||||
|
||||
@Resource
|
||||
private DivLogService divLogService;
|
||||
|
||||
@Resource
|
||||
private CallRecordService callRecordService;
|
||||
|
||||
@Resource
|
||||
private ScheduleSlotMapper scheduleSlotMapper;
|
||||
|
||||
@Override
|
||||
public R<?> list(Long organizationId, LocalDate date) {
|
||||
Integer tenantId = SecurityUtils.getLoginUser().getTenantId();
|
||||
@@ -65,31 +84,33 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
}
|
||||
|
||||
List<TriageQueueItem> list = triageQueueItemService.list(wrapper);
|
||||
|
||||
// 通过 slotId 查询 seqNo 并填充到返回结果中(用于选呼显示预约号)
|
||||
if (list != null && !list.isEmpty()) {
|
||||
Map<Long, Integer> seqNoMap = buildSlotSeqNoMap(list);
|
||||
list.forEach(item -> {
|
||||
if (item.getSlotId() != null && seqNoMap.containsKey(item.getSlotId())) {
|
||||
item.setSeqNo(seqNoMap.get(item.getSlotId()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 双重保险:再次过滤掉 COMPLETED 状态的患者(防止数据库中有异常数据)
|
||||
if (list != null && !list.isEmpty()) {
|
||||
int beforeSize = list.size();
|
||||
list = list.stream()
|
||||
.filter(item -> !STATUS_COMPLETED.equals(item.getStatus()))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
if (beforeSize != list.size()) {
|
||||
System.out.println(">>> [TriageQueue] list() 警告:过滤掉了 " + (beforeSize - list.size()) + " 条 COMPLETED 状态的记录");
|
||||
}
|
||||
}
|
||||
|
||||
// 调试日志:检查状态值
|
||||
if (list != null && !list.isEmpty()) {
|
||||
System.out.println(">>> [TriageQueue] list() 返回 " + list.size() + " 条记录(已排除 COMPLETED)");
|
||||
for (int i = 0; i < Math.min(3, list.size()); i++) {
|
||||
TriageQueueItem item = list.get(i);
|
||||
System.out.println(" [" + i + "] patientName=" + item.getPatientName()
|
||||
+ ", status=" + item.getStatus()
|
||||
+ ", organizationId=" + item.getOrganizationId());
|
||||
}
|
||||
}
|
||||
return R.ok(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将候选池患者的挂号移入队列操作
|
||||
* 并同时写入移入队列操作日志
|
||||
*
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<?> add(TriageQueueAddReq req) {
|
||||
@@ -133,6 +154,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
.setRoomNo(it.getRoomNo()) // ✅ 新增字段(可选)
|
||||
.setPoolId(it.getPoolId()) // ✅ 号源池ID(用于div_log审计)
|
||||
.setSlotId(it.getSlotId()) // ✅ 号源槽位ID(用于div_log审计)
|
||||
.setSeqNo(it.getSeqNo()) // ✅ 预约序号(用于叫号显示)
|
||||
.setStatus(STATUS_WAITING)
|
||||
.setQueueOrder(++maxOrder)
|
||||
.setDeleteFlag("0")
|
||||
@@ -140,7 +162,8 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
.setUpdateTime(LocalDateTime.now());
|
||||
|
||||
triageQueueItemService.save(qi);
|
||||
|
||||
// 写入分诊日志
|
||||
writeDivLog(it.getPoolId(), it.getSlotId(), "ADD_QUEUE");
|
||||
// 记录到候选池排除列表(避免刷新后重新出现在候选池)
|
||||
TriageCandidateExclusion exclusion = triageCandidateExclusionService.getOne(
|
||||
new LambdaQueryWrapper<TriageCandidateExclusion>()
|
||||
@@ -171,17 +194,26 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
return R.ok(added);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除队列操作并同时写入移除操作日志
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<?> remove(Long id) {
|
||||
if (id == null) return R.fail("id 不能为空");
|
||||
TriageQueueItem item = triageQueueItemService.getById(id);
|
||||
if (item == null) return R.fail("队列项不存在");
|
||||
|
||||
if (item.getStatus() != null && item.getStatus() != 0) {
|
||||
return R.fail("仅等待状态的患者可移出队列,当前状态码:" + item.getStatus());
|
||||
}
|
||||
// 逻辑删除队列项
|
||||
item.setDeleteFlag("1").setUpdateTime(LocalDateTime.now());
|
||||
triageQueueItemService.updateById(item);
|
||||
|
||||
// 写入分诊日志
|
||||
writeDivLog(item.getPoolId(), item.getSlotId(), "REMOVE_QUEUE");
|
||||
// 从排除列表中删除记录,使患者重新出现在候选池中
|
||||
Integer tenantId = item.getTenantId();
|
||||
LocalDate exclusionDate = item.getQueueDate();
|
||||
@@ -239,6 +271,12 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
return R.ok(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能分诊选呼功能,在进行选呼操作时同时写入叫号记录和选呼操作日志
|
||||
*
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<?> call(TriageQueueActionReq req) {
|
||||
@@ -253,7 +291,9 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
|
||||
// 叫号后推送 SSE 消息(实时通知显示屏刷新)
|
||||
pushDisplayUpdate(selected.getOrganizationId(), selected.getQueueDate(), selected.getTenantId());
|
||||
|
||||
// 写入分诊日志和叫号记录
|
||||
writeDivLog(selected.getPoolId(), selected.getSlotId(), "CALL");
|
||||
writeCallRecord(selected.getId(), selected.getPractitionerId(), CallType.CALL, selected.getRoomNo());
|
||||
return R.ok(true);
|
||||
} else if (STATUS_CALLING.equals(selected.getStatus())) {
|
||||
// 如果已经是"叫号中"状态,直接返回成功(不做任何操作)
|
||||
@@ -264,6 +304,13 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能分诊完成操作
|
||||
* 在进行完成操作后同时写入叫号记录和完成操作日志
|
||||
*
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<?> complete(TriageQueueActionReq req) {
|
||||
@@ -283,7 +330,6 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
} else {
|
||||
// 如果没有提供 id,通过查询条件查找(兼容旧逻辑)
|
||||
Long orgId = req != null && req.getOrganizationId() != null ? req.getOrganizationId() : null;
|
||||
System.out.println(">>> [TriageQueue] complete() 开始执行(不限制日期,通过查询条件), tenantId=" + tenantId + ", orgId=" + orgId);
|
||||
|
||||
LambdaQueryWrapper<TriageQueueItem> callingWrapper = new LambdaQueryWrapper<TriageQueueItem>()
|
||||
.eq(TriageQueueItem::getTenantId, tenantId)
|
||||
@@ -297,8 +343,6 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
}
|
||||
|
||||
calling = triageQueueItemService.getOne(callingWrapper, false);
|
||||
|
||||
System.out.println(">>> [TriageQueue] complete() 查询叫号中患者(不限制日期): orgId=" + orgId + ", 结果=" + (calling != null ? calling.getPatientName() + "(status=" + calling.getStatus() + ", queueDate=" + calling.getQueueDate() + ")" : "null"));
|
||||
|
||||
if (calling == null) {
|
||||
return R.fail("当前没有叫号中的患者");
|
||||
@@ -328,8 +372,6 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
}
|
||||
|
||||
TriageQueueItem next = triageQueueItemService.getOne(nextWrapper, false);
|
||||
|
||||
System.out.println(">>> [TriageQueue] complete() 查询等待患者(不限制日期): actualOrgId=" + actualOrgId + ", 结果=" + (next != null ? next.getPatientName() + "(status=" + next.getStatus() + ")" : "null"));
|
||||
|
||||
if (next != null) {
|
||||
next.setStatus(STATUS_CALLING).setUpdateTime(LocalDateTime.now());
|
||||
@@ -340,16 +382,45 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
|
||||
// 完成后推送 SSE 消息(实时通知显示屏刷新)
|
||||
pushDisplayUpdate(actualOrgId, calling.getQueueDate(), tenantId);
|
||||
|
||||
// 写入分诊日志和叫号记录
|
||||
writeDivLog(calling.getPoolId(), calling.getSlotId(), "COMPLETE");
|
||||
writeCallRecord(calling.getId(), calling.getPractitionerId(), CallType.COMPLETE, calling.getRoomNo());
|
||||
return R.ok(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能队列重排序功能操作单元
|
||||
* 在进行队列重排序时同时在div_log表中写入重排序日志和叫号记录
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<?> requeue(TriageQueueActionReq req) {
|
||||
return doRequeue(req, "REQUEUE");
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能分诊跳过功能操作单元内部包含将队列正在叫号状态的号进行跳过
|
||||
* 同时将跳过操作日志写入div_log表中,以及写入叫号记录表。
|
||||
*
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<?> skip(TriageQueueActionReq req) {
|
||||
// 当前业务"跳过"按"过号重排"处理:叫号中 -> 跳过并移到末尾,自动推进下一等待
|
||||
return doRequeue(req, "SKIP");
|
||||
}
|
||||
|
||||
/**
|
||||
* 过号重排/跳过的核心逻辑
|
||||
* @param action 日志动作:REQUEUE 或 SKIP
|
||||
*/
|
||||
private R<?> doRequeue(TriageQueueActionReq req, String action) {
|
||||
Integer tenantId = SecurityUtils.getLoginUser().getTenantId();
|
||||
TriageQueueItem calling = null;
|
||||
|
||||
|
||||
if (req != null && req.getId() != null) {
|
||||
calling = triageQueueItemService.getById(req.getId());
|
||||
@@ -358,7 +429,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
}
|
||||
// 验证状态
|
||||
if (!STATUS_CALLING.equals(calling.getStatus())) {
|
||||
return R.fail("只能对\"叫号中\"状态的患者进行过号重排,当前患者状态为:" + calling.getStatus());
|
||||
return R.fail("只能对\"叫号中\"状态的患者进行" + ("SKIP".equals(action) ? "跳过" : "过号重排") + ",当前患者状态为:" + calling.getStatus());
|
||||
}
|
||||
} else {
|
||||
// 如果没有提供 id,通过查询条件查找(兼容旧逻辑)
|
||||
@@ -379,12 +450,11 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
|
||||
if (calling == null) return R.fail("当前没有叫号中的患者");
|
||||
}
|
||||
|
||||
|
||||
// 使用实际找到的科室ID
|
||||
Long actualOrgId = calling.getOrganizationId();
|
||||
|
||||
// 关键改进:在执行"跳过"操作之前,先检查是否有等待中的患者(判断队列状态)
|
||||
// 如果没有等待中的患者,就不应该执行"过号重排"操作
|
||||
// 关键改进:在执行跳过/重排操作之前,先检查是否有等待中的患者(判断队列状态)
|
||||
LambdaQueryWrapper<TriageQueueItem> nextWrapper = new LambdaQueryWrapper<TriageQueueItem>()
|
||||
.eq(TriageQueueItem::getTenantId, tenantId)
|
||||
.eq(TriageQueueItem::getDeleteFlag, "0")
|
||||
@@ -393,22 +463,14 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
.eq(TriageQueueItem::getStatus, STATUS_SKIPPED))
|
||||
.orderByAsc(TriageQueueItem::getQueueOrder)
|
||||
.last("LIMIT 1");
|
||||
|
||||
// 如果指定了科室ID,则按科室过滤;否则查询所有科室(全科模式)
|
||||
|
||||
if (actualOrgId != null) {
|
||||
nextWrapper.eq(TriageQueueItem::getOrganizationId, actualOrgId);
|
||||
}
|
||||
|
||||
|
||||
TriageQueueItem next = triageQueueItemService.getOne(nextWrapper, false);
|
||||
|
||||
// 调试日志:检查查询结果
|
||||
System.out.println(">>> [TriageQueue] requeue() 查询等待中的患者:");
|
||||
System.out.println(">>> - 科室ID: " + actualOrgId);
|
||||
System.out.println(">>> - 找到的等待患者: " + (next != null ? next.getPatientName() + " (状态: " + next.getStatus() + ")" : "null"));
|
||||
|
||||
// 如果找不到等待中的患者,直接返回失败(不执行跳过操作)
|
||||
|
||||
if (next == null) {
|
||||
System.out.println(">>> [TriageQueue] requeue() 失败:没有等待中的患者");
|
||||
return R.fail("当前没有等待中的患者");
|
||||
}
|
||||
|
||||
@@ -433,27 +495,20 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
triageQueueItemService.updateById(next);
|
||||
|
||||
recalcOrders(actualOrgId, null);
|
||||
|
||||
// ✅ 过号重排后推送 SSE 消息(实时通知显示屏刷新)
|
||||
// 推送 SSE 消息
|
||||
pushDisplayUpdate(actualOrgId, calling.getQueueDate(), tenantId);
|
||||
|
||||
// 写入分诊日志和叫号记录
|
||||
writeDivLog(calling.getPoolId(), calling.getSlotId(), action);
|
||||
writeCallRecord(calling.getId(), calling.getPractitionerId(),
|
||||
"SKIP".equals(action) ? CallType.SKIP : CallType.REQUEUE, calling.getRoomNo());
|
||||
return R.ok(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<?> skip(TriageQueueActionReq req) {
|
||||
// 当前业务“跳过”按“过号重排”处理:叫号中 -> 跳过并移到末尾,自动推进下一等待
|
||||
return requeue(req);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public R<?> next(TriageQueueActionReq req) {
|
||||
Integer tenantId = SecurityUtils.getLoginUser().getTenantId();
|
||||
TriageQueueItem calling = null;
|
||||
|
||||
System.out.println(">>> [TriageQueue] next() 开始执行(不限制日期), tenantId=" + tenantId);
|
||||
|
||||
// 关键改进:如果提供了 id,优先使用 id 直接查找(像 call 方法一样)
|
||||
if (req != null && req.getId() != null) {
|
||||
@@ -513,14 +568,6 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
}
|
||||
|
||||
TriageQueueItem next = triageQueueItemService.getOne(nextWrapper, false);
|
||||
|
||||
// 调试日志:打印查询条件和结果
|
||||
System.out.println(">>> [TriageQueue] next() 查询条件(不限制日期): tenantId=" + tenantId
|
||||
+ ", actualOrgId=" + actualOrgId
|
||||
+ ", deleteFlag=0"
|
||||
+ ", status IN (WAITING, SKIPPED)");
|
||||
System.out.println(">>> [TriageQueue] next() 查询结果: calling=" + (calling != null ? calling.getPatientName() + "(status=" + calling.getStatus() + ", queueDate=" + calling.getQueueDate() + ")" : "null")
|
||||
+ ", next=" + (next != null ? next.getPatientName() + "(status=" + next.getStatus() + ", queueDate=" + next.getQueueDate() + ")" : "null"));
|
||||
|
||||
if (next == null) {
|
||||
if (actualOrgId != null) {
|
||||
@@ -535,6 +582,13 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
if (next.getOrganizationId() != null) {
|
||||
recalcOrders(next.getOrganizationId(), null);
|
||||
}
|
||||
|
||||
// 写入叫号记录
|
||||
if (calling != null) {
|
||||
writeCallRecord(calling.getId(), calling.getPractitionerId(), CallType.NEXT, calling.getRoomNo());
|
||||
}
|
||||
writeCallRecord(next.getId(), next.getPractitionerId(), CallType.NEXT, next.getRoomNo());
|
||||
|
||||
return R.ok(true);
|
||||
}
|
||||
|
||||
@@ -608,7 +662,10 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
.eq(TriageQueueItem::getDeleteFlag, "0")
|
||||
.orderByAsc(TriageQueueItem::getQueueOrder)
|
||||
);
|
||||
|
||||
|
||||
// 通过 slotId 批量查询 seqNo(方案 B:不修改 triage_queue_item 表结构)
|
||||
Map<Long, Integer> slotSeqNoMap = buildSlotSeqNoMap(allItems);
|
||||
|
||||
CallNumberDisplayResp resp = new CallNumberDisplayResp();
|
||||
|
||||
// 1. 获取科室名称(从第一条数据中取)
|
||||
@@ -626,7 +683,8 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
|
||||
if (callingItem != null) {
|
||||
CallNumberDisplayResp.CurrentCallInfo currentCall = new CallNumberDisplayResp.CurrentCallInfo();
|
||||
currentCall.setNumber(callingItem.getQueueOrder());
|
||||
Integer displayNo = resolveDisplayNumber(callingItem, slotSeqNoMap);
|
||||
currentCall.setNumber(displayNo);
|
||||
currentCall.setName(maskPatientName(callingItem.getPatientName()));
|
||||
currentCall.setRoom(callingItem.getRoomNo() != null ? callingItem.getRoomNo() : "1号");
|
||||
currentCall.setDoctor(callingItem.getPractitionerName());
|
||||
@@ -680,7 +738,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
patient.setId(item.getId());
|
||||
patient.setName(maskPatientName(item.getPatientName()));
|
||||
patient.setStatus(item.getStatus());
|
||||
patient.setQueueOrder(item.getQueueOrder());
|
||||
patient.setQueueOrder(resolveDisplayNumber(item, slotSeqNoMap));
|
||||
patients.add(patient);
|
||||
|
||||
// 统计等待人数(不包括 CALLING 状态)
|
||||
@@ -748,6 +806,82 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
|
||||
System.err.println("推送显示屏更新失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入分诊操作日志
|
||||
*
|
||||
* @param poolId 号源池ID
|
||||
* @param slotId 号源槽位ID
|
||||
* @param action 操作动作:ADD_QUEUE/REMOVE_QUEUE/CALL/COMPLETE/SKIP/REQUEUE
|
||||
*/
|
||||
private void writeDivLog(Long poolId, Long slotId, String action) {
|
||||
try {
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
DivLog log = new DivLog()
|
||||
.setPoolId(poolId)
|
||||
.setSlotId(slotId)
|
||||
.setOpUserId(loginUser != null ? loginUser.getUserId() : null)
|
||||
.setAction(action)
|
||||
.setCreateTime(LocalDateTime.now())
|
||||
.setUpdateAt(LocalDateTime.now())
|
||||
.setCreatedAt(LocalDateTime.now());
|
||||
divLogService.save(log);
|
||||
} catch (Exception e) {
|
||||
log.error("写入分诊日志失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入叫号记录
|
||||
*
|
||||
* @param queueId 队列ID
|
||||
* @param doctorId 医生ID
|
||||
* @param callType 叫号类型枚举
|
||||
* @param room 诊室号
|
||||
*/
|
||||
private void writeCallRecord(Long queueId, Long doctorId, CallType callType, String room) {
|
||||
try {
|
||||
CallRecord record = new CallRecord()
|
||||
.setQueueId(queueId)
|
||||
.setDoctorId(doctorId)
|
||||
.setCallTime(LocalDateTime.now())
|
||||
.setCallType(callType.getValue().toString())
|
||||
.setRoom(room)
|
||||
.setCreateAt(LocalDateTime.now());
|
||||
callRecordService.save(record);
|
||||
} catch (Exception e) {
|
||||
log.error("写入叫号记录失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 slotId 批量查询 seqNo,返回 slotId -> seqNo 映射
|
||||
*/
|
||||
private Map<Long, Integer> buildSlotSeqNoMap(List<TriageQueueItem> items) {
|
||||
List<Long> slotIds = items.stream()
|
||||
.map(TriageQueueItem::getSlotId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (slotIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<ScheduleSlot> slots = scheduleSlotMapper.selectSeqNoBySlotIds(slotIds);
|
||||
return slots.stream()
|
||||
.filter(s -> s.getId() != null && s.getSeqNo() != null)
|
||||
.collect(Collectors.toMap(ScheduleSlot::getId, ScheduleSlot::getSeqNo, (a, b) -> a));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算患者在叫号显示屏上应显示的号码:优先 seqNo(预约序号),否则 queueOrder(排队号)
|
||||
*/
|
||||
private Integer resolveDisplayNumber(TriageQueueItem item, Map<Long, Integer> slotSeqNoMap) {
|
||||
if (item == null) return null;
|
||||
if (item.getSlotId() != null && slotSeqNoMap.containsKey(item.getSlotId())) {
|
||||
return slotSeqNoMap.get(item.getSlotId());
|
||||
}
|
||||
return item.getQueueOrder();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ public class TriageQueueEncounterItem {
|
||||
private Long poolId;
|
||||
/** 号源槽位ID(关联 adm_schedule_slot.id,用于 div_log 审计日志) */
|
||||
private Long slotId;
|
||||
/** 预约序号(来自 adm_schedule_slot.seq_no,用于叫号显示) */
|
||||
private Integer seqNo;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ core:
|
||||
# 名称
|
||||
name: HEALTHLINK-HIS
|
||||
# 版本
|
||||
version: 0.0.1
|
||||
version: ${CORE_VERSION:0.0.1}
|
||||
# 版权年份
|
||||
copyrightYear: 2025
|
||||
# 文件路径
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
T1.quantity_unit,
|
||||
T1.unit_price,
|
||||
T1.total_price,
|
||||
T1.generate_source_enum,
|
||||
T1.prescription_no AS source_bill_no,
|
||||
mmr.prescription_no,
|
||||
mmr.method_code AS method_code,
|
||||
mmr.rate_code,
|
||||
@@ -190,6 +192,8 @@
|
||||
T1.quantity_unit,
|
||||
T1.unit_price,
|
||||
T1.total_price,
|
||||
T1.generate_source_enum,
|
||||
T1.prescription_no AS source_bill_no,
|
||||
mmr.prescription_no,
|
||||
mmr.method_code AS method_code,
|
||||
mmr.rate_code,
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
COALESCE(T9.identifier_no, T9.patient_bus_no, '') AS identifierNo,
|
||||
COALESCE(T9.order_id IS NOT NULL, false) AS isFromAppointment,
|
||||
T9.slot_id AS slotId,
|
||||
T9.pool_id AS poolId
|
||||
T9.pool_id AS poolId,
|
||||
T9.seq_no AS seqNo
|
||||
from (
|
||||
SELECT T1.tenant_id AS tenant_id,
|
||||
T1.id AS encounter_id,
|
||||
@@ -100,6 +101,7 @@
|
||||
T18.identifier_no AS identifier_no,
|
||||
T1.order_id AS order_id,
|
||||
om.slot_id AS slot_id,
|
||||
ss.seq_no AS seq_no,
|
||||
ss.pool_id AS pool_id,
|
||||
sp.clinic_room AS clinic_room -- Bug #410:从号源池获取诊室
|
||||
FROM adm_encounter AS T1
|
||||
|
||||
@@ -331,8 +331,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
) t
|
||||
WHERE rn = 1
|
||||
) pi ON s.patient_id = pi.patient_id
|
||||
<!-- 排除已生成医嘱的手术 -->
|
||||
LEFT JOIN wor_service_request sr ON sr.activity_id = s.id AND sr.delete_flag = '0' AND sr.category_enum = 4
|
||||
<where>
|
||||
s.delete_flag = '0'
|
||||
<!-- 只显示未生成医嘱的手术 -->
|
||||
AND sr.id IS NULL
|
||||
<if test="ew.sqlSegment != null and ew.sqlSegment != ''">
|
||||
<![CDATA[
|
||||
AND ${ew.sqlSegment.replace('tenant_id', 's.tenant_id').replace('create_time', 's.create_time').replace('surgery_no', 's.surgery_no').replace('surgery_name', 's.surgery_name').replace('patient_name', 's.patient_name').replace('main_surgeon_name', 's.main_surgeon_name').replace('anesthetist_name', 's.anesthetist_name').replace('org_name', 's.org_name').replace('status_enum', 's.status_enum').replace('planned_time', 's.planned_time')}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
COALESCE(pi.identifier_no, ap.bus_no, '') AS identifierNo
|
||||
FROM op_schedule os
|
||||
LEFT JOIN adm_patient ap ON os.patient_id = ap.id
|
||||
LEFT JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0'
|
||||
INNER JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0'
|
||||
LEFT JOIN adm_organization o ON cs.org_id = o.id
|
||||
LEFT JOIN sys_tenant st ON st.id = os.tenant_id
|
||||
LEFT JOIN sys_user su ON su.user_id = os.creator_id
|
||||
@@ -92,7 +92,7 @@
|
||||
COALESCE(pi.identifier_no, ap.bus_no, '') AS identifierNo
|
||||
FROM op_schedule os
|
||||
LEFT JOIN adm_patient ap ON os.patient_id = ap.id
|
||||
LEFT JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0'
|
||||
INNER JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0'
|
||||
LEFT JOIN adm_organization o ON cs.org_id = o.id
|
||||
LEFT JOIN doc_request_form drf ON drf.prescription_no=cs.surgery_no
|
||||
LEFT JOIN (
|
||||
@@ -153,7 +153,7 @@
|
||||
COALESCE(pi.identifier_no, ap.bus_no, '') AS identifierNo
|
||||
FROM op_schedule os
|
||||
LEFT JOIN adm_patient ap ON os.patient_id = ap.id
|
||||
LEFT JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0'
|
||||
INNER JOIN cli_surgery cs ON os.oper_code = cs.surgery_no AND cs.delete_flag = '0'
|
||||
LEFT JOIN adm_organization o ON cs.org_id = o.id
|
||||
LEFT JOIN sys_tenant st ON st.id = os.tenant_id
|
||||
LEFT JOIN sys_user su ON su.user_id = os.creator_id
|
||||
|
||||
@@ -143,10 +143,10 @@
|
||||
</if>
|
||||
ORDER BY T1.id DESC
|
||||
<if test="searchKey != null and searchKey != ''">
|
||||
LIMIT 1500
|
||||
LIMIT 10000
|
||||
</if>
|
||||
<if test="searchKey == null or searchKey == ''">
|
||||
LIMIT 500
|
||||
LIMIT 10000
|
||||
</if>
|
||||
</select>
|
||||
|
||||
|
||||
@@ -186,6 +186,9 @@
|
||||
<if test="searchKey != null and searchKey != ''">
|
||||
AND (t1.name ILIKE '%' || #{searchKey} || '%' OR t1.py_str ILIKE '%' || #{searchKey} || '%')
|
||||
</if>
|
||||
<if test="categoryCode != null and categoryCode != ''">
|
||||
AND t1.category_code = #{categoryCode}
|
||||
</if>
|
||||
<if test="adviceDefinitionIdParamList != null and !adviceDefinitionIdParamList.isEmpty()">
|
||||
AND t1.id IN
|
||||
<foreach collection="adviceDefinitionIdParamList" item="itemId" open="(" separator="," close=")">
|
||||
@@ -274,12 +277,15 @@
|
||||
AND (T1.category_code = '手术' OR T1.category_code = '24')
|
||||
</if>
|
||||
<!-- 如果只选择诊疗(adviceType=3),排除手术 -->
|
||||
<if test="adviceTypes.contains(3) and !adviceTypes.contains(6)">
|
||||
<if test="adviceTypes.contains(3) and !adviceTypes.contains(6) and (categoryCode == null or categoryCode == '')">
|
||||
AND T1.category_code != '手术' AND T1.category_code != '24'
|
||||
</if>
|
||||
<if test="searchKey != null and searchKey != ''">
|
||||
AND (t1.name ILIKE '%' || #{searchKey} || '%' OR t1.py_str ILIKE '%' || #{searchKey} || '%')
|
||||
</if>
|
||||
<if test="categoryCode != null and categoryCode != ''">
|
||||
AND t1.category_code = #{categoryCode}
|
||||
</if>
|
||||
<if test="adviceDefinitionIdParamList != null and !adviceDefinitionIdParamList.isEmpty()">
|
||||
AND t1.id IN
|
||||
<foreach collection="adviceDefinitionIdParamList" item="itemId" open="(" separator="," close=")">
|
||||
|
||||
@@ -96,9 +96,9 @@
|
||||
fc.contract_name AS fee_type,
|
||||
COALESCE(pi.identifier_no, ap.bus_no, '') AS identifier_no
|
||||
FROM doc_request_form drf
|
||||
LEFT JOIN cli_surgery cs ON cs.surgery_no = drf.prescription_no
|
||||
LEFT JOIN adm_patient ap ON ap.id = cs.patient_id
|
||||
LEFT JOIN adm_encounter ae ON ae.id = cs.encounter_id
|
||||
LEFT JOIN cli_surgery cs ON cs.surgery_no = drf.prescription_no AND cs.delete_flag = '0'
|
||||
LEFT JOIN adm_patient ap ON ap.id = cs.patient_id AND ap.delete_flag = '0'
|
||||
LEFT JOIN adm_encounter ae ON ae.id = cs.encounter_id AND ae.delete_flag = '0'
|
||||
LEFT JOIN adm_account aa ON aa.encounter_id = ae.id AND aa.delete_flag = '0'
|
||||
LEFT JOIN fin_contract fc ON fc.bus_no = aa.contract_no AND fc.delete_flag = '0'
|
||||
LEFT JOIN op_schedule os ON os.apply_id = drf.id AND os.delete_flag = '0'
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.openhis.common.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 叫号类型
|
||||
*
|
||||
* @author wangjian963
|
||||
* @date 2026-04-29
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum CallType implements HisEnumInterface {
|
||||
|
||||
/** 手动叫号(选呼) */
|
||||
CALL(10, "CALL", "手动叫号(选呼)"),
|
||||
|
||||
/** 手动叫号(下一患者) */
|
||||
NEXT(20, "NEXT", "手动叫号(下一患者)"),
|
||||
|
||||
/** 自动叫号 */
|
||||
AUTO_CALL(30, "AUTO_CALL", "自动叫号"),
|
||||
|
||||
/** 跳过 */
|
||||
SKIP(40, "SKIP", "跳过"),
|
||||
|
||||
/** 完成 */
|
||||
COMPLETE(50, "COMPLETE", "完成"),
|
||||
|
||||
/** 重排 */
|
||||
REQUEUE(60, "REQUEUE", "重排");
|
||||
|
||||
/** 状态码 */
|
||||
private Integer value;
|
||||
/** 英文标识 */
|
||||
private String code;
|
||||
/** 中文描述 */
|
||||
private String info;
|
||||
|
||||
/**
|
||||
* 根据状态码获取对应的叫号类型枚举
|
||||
*
|
||||
* @param value 状态码
|
||||
* @return 对应的枚举值,未匹配时返回 null
|
||||
*/
|
||||
public static CallType getByValue(Integer value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
for (CallType val : values()) {
|
||||
if (val.getValue().equals(value)) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -61,4 +61,9 @@ public interface ScheduleSlotMapper extends BaseMapper<ScheduleSlot> {
|
||||
*/
|
||||
List<DoctorAvailabilityDTO> selectDoctorAvailabilitySummary(@Param("query") TicketQueryDTO query);
|
||||
|
||||
/**
|
||||
* 批量查询槽位序号(用于分诊叫号显示)
|
||||
*/
|
||||
List<ScheduleSlot> selectSeqNoBySlotIds(@Param("slotIds") List<Long> slotIds);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.openhis.triageandqueuemanage.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@TableName(value = "call_record")
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
public class CallRecord {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long recordId;
|
||||
|
||||
/** 队列ID (FK triage_queue_item.id) */
|
||||
private Long queueId;
|
||||
|
||||
/** 医生ID */
|
||||
private Long doctorId;
|
||||
|
||||
/** 叫号时间 */
|
||||
private LocalDateTime callTime;
|
||||
|
||||
/**
|
||||
* 叫号类型,使用 {@link com.openhis.common.enums.CallType} 枚举值
|
||||
* 10-CALL(选呼), 20-NEXT(下一患者), 30-AUTO_CALL(自动叫号),
|
||||
* 40-SKIP(跳过), 50-COMPLETE(完成), 60-REQUEUE(重排)
|
||||
*/
|
||||
private String callType;
|
||||
|
||||
/** 诊室(冗余) */
|
||||
private String room;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createAt;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.openhis.triageandqueuemanage.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@TableName(value = "div_log")
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
public class DivLog {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long logId;
|
||||
|
||||
/** 号源池ID */
|
||||
private Long poolId;
|
||||
|
||||
/** 号源槽位ID */
|
||||
private Long slotId;
|
||||
|
||||
/** 操作人ID */
|
||||
private Long opUserId;
|
||||
|
||||
/** 操作动作:ADD_QUEUE/REMOVE_QUEUE/CALL/REFUND/COMPLETE/SKIP/REQUEUE */
|
||||
private String action;
|
||||
|
||||
/** 操作时间 */
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/** 更新时间 */
|
||||
private LocalDateTime updateAt;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.openhis.triageandqueuemanage.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
@@ -40,7 +41,10 @@ public class TriageQueueItem {
|
||||
* 30=COMPLETED(已完成), 40=SKIPPED(已跳过), 50=REFUNDED(已退费), 60=FOLLOW(已随访)
|
||||
*/
|
||||
private Integer status;
|
||||
private Integer queueOrder; //“排队序号”,也就是患者在当前科室、当天队列里的 顺序号(从 1 开始递增)。
|
||||
private Integer queueOrder; //”排队序号”,也就是患者在当前科室、当天队列里的 顺序号(从 1 开始递增)。
|
||||
|
||||
@TableField(exist = false)
|
||||
private Integer seqNo; // 预约序号(来自 adm_schedule_slot.seq_no,非数据库字段,通过 JOIN 查询)
|
||||
|
||||
private LocalDateTime createTime;
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.openhis.triageandqueuemanage.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.openhis.triageandqueuemanage.domain.CallRecord;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface CallRecordMapper extends BaseMapper<CallRecord> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.openhis.triageandqueuemanage.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.openhis.triageandqueuemanage.domain.DivLog;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface DivLogMapper extends BaseMapper<DivLog> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.openhis.triageandqueuemanage.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.openhis.triageandqueuemanage.domain.CallRecord;
|
||||
|
||||
public interface CallRecordService extends IService<CallRecord> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.openhis.triageandqueuemanage.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.openhis.triageandqueuemanage.domain.DivLog;
|
||||
|
||||
public interface DivLogService extends IService<DivLog> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.openhis.triageandqueuemanage.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.openhis.triageandqueuemanage.domain.CallRecord;
|
||||
import com.openhis.triageandqueuemanage.mapper.CallRecordMapper;
|
||||
import com.openhis.triageandqueuemanage.service.CallRecordService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class CallRecordServiceImpl extends ServiceImpl<CallRecordMapper, CallRecord> implements CallRecordService {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.openhis.triageandqueuemanage.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.openhis.triageandqueuemanage.domain.DivLog;
|
||||
import com.openhis.triageandqueuemanage.mapper.DivLogMapper;
|
||||
import com.openhis.triageandqueuemanage.service.DivLogService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class DivLogServiceImpl extends ServiceImpl<DivLogMapper, DivLog> implements DivLogService {
|
||||
}
|
||||
@@ -437,5 +437,15 @@
|
||||
p.doctor_name ASC
|
||||
</select>
|
||||
|
||||
<select id="selectSeqNoBySlotIds" resultType="com.openhis.appointmentmanage.domain.ScheduleSlot">
|
||||
SELECT id, seq_no
|
||||
FROM adm_schedule_slot
|
||||
WHERE id IN
|
||||
<foreach collection="slotIds" item="slotId" open="(" separator="," close=")">
|
||||
#{slotId}
|
||||
</foreach>
|
||||
AND delete_flag = '0'
|
||||
</select>
|
||||
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# 页面标题
|
||||
VITE_APP_TITLE = 医院信息管理系统
|
||||
|
||||
# 测试环境配置
|
||||
VITE_APP_ENV = 'test'
|
||||
|
||||
# OpenHIS管理系统/测试环境
|
||||
|
||||
VITE_APP_BASE_API = '/test-api'
|
||||
|
||||
# 租户ID配置
|
||||
VITE_APP_TENANT_ID = '1'
|
||||
# Playwright E2E 测试环境变量
|
||||
# 注意:此文件仅用于本地开发,生产环境使用CI Secret管理
|
||||
TEST_BASE_URL=http://localhost:80
|
||||
TEST_USERNAME=admin
|
||||
TEST_PASSWORD=changeme_in_local_env
|
||||
|
||||
5
openhis-ui-vue3/.gitignore
vendored
5
openhis-ui-vue3/.gitignore
vendored
@@ -21,3 +21,8 @@ selenium-debug.log
|
||||
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Playwright test results
|
||||
test-results/
|
||||
tests/e2e/report/
|
||||
tests/tests/
|
||||
|
||||
59
openhis-ui-vue3/eslint.config.js
Normal file
59
openhis-ui-vue3/eslint.config.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/* eslint-env node */
|
||||
import globals from "globals";
|
||||
import pluginVue from "eslint-plugin-vue";
|
||||
import parserVue from "vue-eslint-parser";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
|
||||
export default [
|
||||
{
|
||||
name: "app/files-to-lint",
|
||||
files: ["**/*.{js,mjs,jsx,vue}"],
|
||||
},
|
||||
|
||||
{
|
||||
name: "app/files-to-ignore",
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/help-center/**"],
|
||||
},
|
||||
|
||||
...pluginVue.configs["flat/recommended"],
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parser: parserVue,
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
|
||||
rules: {
|
||||
// 确保导入的模块实际存在(核心规则,防止构建失败)
|
||||
"import/no-unresolved": "error",
|
||||
// 确保导入的命名导出实际存在
|
||||
"import/named": "error",
|
||||
// 确保默认导出存在
|
||||
"import/default": "error",
|
||||
// 确保命名空间导出存在
|
||||
"import/namespace": "error",
|
||||
// Vue 相关规则
|
||||
"vue/multi-word-component-names": "off",
|
||||
},
|
||||
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
alias: {
|
||||
map: [
|
||||
["@", "./src"],
|
||||
],
|
||||
extensions: [".js", ".jsx", ".vue"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
1367
openhis-ui-vue3/package-lock.json
generated
1367
openhis-ui-vue3/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,10 @@
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"prepare": "cd .. && husky openhis-ui-vue3/.husky"
|
||||
"lint": "eslint . --ext .js,.vue src/",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -68,6 +71,11 @@
|
||||
"@vitejs/plugin-vue": "4.5.0",
|
||||
"@vue/compiler-sfc": "3.3.9",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-vue": "^10.9.0",
|
||||
"globals": "^17.5.0",
|
||||
"happy-dom": "^20.8.3",
|
||||
"jsdom": "^28.1.0",
|
||||
"pg": "^8.18.0",
|
||||
@@ -81,14 +89,5 @@
|
||||
"vite-plugin-vue-mcp": "^0.3.2",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^3.1.8"
|
||||
},
|
||||
"lint-staged": {
|
||||
"openhis-ui-vue3/**/*.{js,vue,ts}": [
|
||||
"cd openhis-ui-vue3 && npm run lint -- --fix",
|
||||
"cd openhis-ui-vue3 && npm run build:dev"
|
||||
],
|
||||
"**/*.{js,vue,ts}": [
|
||||
"echo \"文件变更已记录,构建检查将在pre-commit中执行\""
|
||||
]
|
||||
}
|
||||
}
|
||||
28
openhis-ui-vue3/playwright.config.ts
Normal file
28
openhis-ui-vue3/playwright.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e/specs',
|
||||
fullyParallel: true,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: [
|
||||
['html', { outputFolder: 'tests/e2e/report', open: 'never' }],
|
||||
['list'],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.TEST_BASE_URL || 'http://localhost:81',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
locale: 'zh-CN',
|
||||
timezoneId: 'Asia/Shanghai',
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
});
|
||||
10
openhis-ui-vue3/src/api/system/info.js
Normal file
10
openhis-ui-vue3/src/api/system/info.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getSystemVersion(options = {}) {
|
||||
return request({
|
||||
url: '/system/version',
|
||||
method: 'get',
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
@@ -378,6 +378,7 @@ import {getCurrentInstance, nextTick, watch} from 'vue';
|
||||
const { proxy } = getCurrentInstance();
|
||||
const { unit_code, med_chrgitm_type, fin_type_code, activity_category_code, chrgitm_lv } =
|
||||
proxy.useDict(
|
||||
'specimen_code',
|
||||
'unit_code',
|
||||
'med_chrgitm_type',
|
||||
'fin_type_code',
|
||||
|
||||
@@ -383,6 +383,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
const isAdding = ref(false);
|
||||
const isSaving = ref(false); // #437 防重复提交锁
|
||||
const prescriptionRef = ref();
|
||||
const expandOrder = ref([]); //目前的展开行
|
||||
const stockList = ref([]);
|
||||
@@ -1028,13 +1029,18 @@ function handleSave() {
|
||||
})
|
||||
groupIndexList.value = []
|
||||
groupList.value = []
|
||||
nextId.value == 1;
|
||||
nextId.value = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 单行处方保存
|
||||
function handleSaveSign(row, index) {
|
||||
// 🔧 Bug Fix #437: 防重复提交锁
|
||||
if (isSaving.value) {
|
||||
proxy.$modal.msgWarning('正在保存中,请勿重复提交');
|
||||
return;
|
||||
}
|
||||
// 🔧 Bug Fix #238: 诊疗项目必须选择执行科室
|
||||
if (row.adviceType === 3 && !row.orgId) {
|
||||
proxy.$modal.msgWarning('诊疗项目必须选择执行科室');
|
||||
@@ -1042,33 +1048,37 @@ function handleSaveSign(row, index) {
|
||||
}
|
||||
proxy.$refs['formRef' + index].validate((valid) => {
|
||||
if (valid) {
|
||||
isSaving.value = true; // #437 加锁
|
||||
row.isEdit = false;
|
||||
isAdding.value = false;
|
||||
expandOrder.value = [];
|
||||
row.patientId = props.patientInfo.patientId;
|
||||
row.encounterId = props.patientInfo.encounterId;
|
||||
row.accountId = props.patientInfo.accountId;
|
||||
row.contentJson = JSON.stringify(row);
|
||||
row.dbOpType = row.requestId ? '2' : '1';
|
||||
row.minUnitQuantity = row.quantity * row.partPercent;
|
||||
row.categoryEnum = row.adviceType
|
||||
const cleanRow = JSON.parse(JSON.stringify(row));
|
||||
cleanRow.contentJson = JSON.stringify(cleanRow);
|
||||
cleanRow.dbOpType = cleanRow.requestId ? '2' : '1';
|
||||
cleanRow.minUnitQuantity = cleanRow.quantity * cleanRow.partPercent;
|
||||
cleanRow.categoryEnum = cleanRow.adviceType
|
||||
// 如果是手术计费,设置生成来源和来源业务单据号
|
||||
if (props.patientInfo.sourceBillNo) {
|
||||
row.generateSourceEnum = 6; // 手术计费
|
||||
row.sourceBillNo = props.patientInfo.sourceBillNo;
|
||||
cleanRow.generateSourceEnum = 6; // 手术计费
|
||||
cleanRow.sourceBillNo = props.patientInfo.sourceBillNo;
|
||||
}
|
||||
console.log('row', row)
|
||||
savePrescription({ adviceSaveList: [row] }).then((res) => {
|
||||
console.log('cleanRow', cleanRow)
|
||||
savePrescription({ adviceSaveList: [cleanRow] }).then((res) => {
|
||||
if (res.code === 200) {
|
||||
proxy.$modal.msgSuccess('保存成功');
|
||||
getListInfo(false);
|
||||
nextId.value == 1;
|
||||
nextId.value = 1;
|
||||
// 🔧 Bug Fix #238: 如果诊疗项目缺少执行科室,标记为需要修复的脏数据
|
||||
if (row.adviceType === 3 && !row.orgId) {
|
||||
console.warn('Bug #238: 检测到诊疗项目保存时缺少执行科室,请手动编辑修正:', row);
|
||||
console.warn('Bug #238: 检测到诊疗项目保存时缺少执行科室,请手动编辑修正:', cleanRow);
|
||||
proxy.$modal.msgWarning('诊疗项目执行科室信息不完整,请编辑后重新保存');
|
||||
}
|
||||
}
|
||||
}).finally(() => {
|
||||
isSaving.value = false; // #437 释放锁
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -562,7 +562,7 @@
|
||||
prescriptionList[scope.$index].minUnitQuantity = prescriptionList[scope.$index].quantity || 1;
|
||||
prescriptionList[scope.$index].minUnitCode = prescriptionList[scope.$index].unitCode;
|
||||
prescriptionList[scope.$index].minUnitCode_dictText = prescriptionList[scope.$index].unitCode_dictText;
|
||||
adviceQueryParams.adviceTypes = value; // 🎯 修复:改为 adviceTypes(复数)
|
||||
adviceQueryParams.adviceTypes = [value]; // 🎯 修复:改为 adviceTypes(复数)
|
||||
|
||||
// 根据选择的类型设置categoryCode,用于药品分类筛选
|
||||
if (value == 1) { // 西药
|
||||
|
||||
@@ -116,7 +116,7 @@ const getList = () => {
|
||||
pageNum: 1,
|
||||
categoryCode: '24',
|
||||
organizationId: patientInfo.value.inHospitalOrgId,
|
||||
adviceTypes: '3', //1 药品 2耗材 3诊疗
|
||||
adviceTypes: [3], //1 药品 2耗材 3诊疗
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
|
||||
@@ -100,12 +100,16 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div class="footer">
|
||||
© 2025 {{ currentTenantName || settings.systemName }}信息管理系统 | 版本 v2.5.1
|
||||
<!-- 公司版权信息(新增) -->
|
||||
<div class="company-copyright">
|
||||
技术支持:上海经创贺联信息技术有限公司
|
||||
</div>
|
||||
© 2025 {{ currentTenantName || settings.systemName }}信息管理系统
|
||||
| 前端版本 {{ formattedFrontendVersion }}
|
||||
<span v-if="backendVersion">
|
||||
| 后端版本 {{ formattedBackendVersion }}
|
||||
</span>
|
||||
<!-- 公司版权信息(新增) -->
|
||||
<div class="company-copyright">
|
||||
技术支持:上海经创贺联信息技术有限公司
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<!-- 底部 -->
|
||||
@@ -126,7 +130,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {getCurrentInstance, onMounted, ref, watch, nextTick} from 'vue';
|
||||
import {computed, getCurrentInstance, onMounted, ref, watch, nextTick} from 'vue';
|
||||
import settings from '@/settings';
|
||||
import {getCodeImg, getUserBindTenantList, sign} from '@/api/login';
|
||||
import {invokeYbPlugin5001} from '@/api/public';
|
||||
@@ -134,6 +138,7 @@ import Cookies from 'js-cookie';
|
||||
import {decrypt, encrypt} from '@/utils/jsencrypt';
|
||||
import useUserStore from '@/store/modules/user';
|
||||
import {ElMessage} from 'element-plus';
|
||||
import {getSystemVersion} from '@/api/system/info';
|
||||
import logoNew from '@/assets/logo/LOGO.jpg';
|
||||
|
||||
const userStore = useUserStore();
|
||||
@@ -141,6 +146,31 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { proxy } = getCurrentInstance();
|
||||
const env = import.meta.env.MODE;
|
||||
const loginVersion = import.meta.env.VITE_APP_BUILD_VERSION;
|
||||
const backendVersion = ref('');
|
||||
|
||||
const formattedFrontendVersion = computed(() => {
|
||||
if (!loginVersion) return '';
|
||||
// 期望格式:YYYYMMDDHHmmss -> 显示 YYYY-MM-DD
|
||||
if (loginVersion.length >= 8) {
|
||||
const y = loginVersion.substring(0, 4);
|
||||
const m = loginVersion.substring(4, 6);
|
||||
const d = loginVersion.substring(6, 8);
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
return loginVersion;
|
||||
});
|
||||
|
||||
const formattedBackendVersion = computed(() => {
|
||||
if (!backendVersion.value) return '';
|
||||
if (backendVersion.value.length >= 8) {
|
||||
const y = backendVersion.value.substring(0, 4);
|
||||
const m = backendVersion.value.substring(4, 6);
|
||||
const d = backendVersion.value.substring(6, 8);
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
return backendVersion.value;
|
||||
});
|
||||
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
@@ -236,6 +266,15 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取后端版本号
|
||||
getSystemVersion().then((res) => {
|
||||
if (res && res.backendVersion) {
|
||||
backendVersion.value = res.backendVersion;
|
||||
}
|
||||
}).catch(() => {
|
||||
backendVersion.value = '';
|
||||
});
|
||||
});
|
||||
|
||||
function handleLogin() {
|
||||
|
||||
@@ -1941,6 +1941,7 @@ function submitForm() {
|
||||
// 新增手术安排
|
||||
addSurgerySchedule(submitData).then((res) => {
|
||||
proxy.$modal.msgSuccess('新增成功')
|
||||
queryParams.pageNo = 1
|
||||
open.value = false
|
||||
getPageList()
|
||||
}).catch(() => {
|
||||
|
||||
@@ -37,7 +37,7 @@ export function getCandidatePool(params) {
|
||||
pageNo: params?.pageNo || 1,
|
||||
pageSize: params?.pageSize || 10000,
|
||||
searchKey: params?.searchKey || '',
|
||||
statusEnum: params?.statusEnum || -1 // -1表示排除退号记录(正常挂号)
|
||||
statusEnum: params?.statusEnum ?? 1 // 1=PLANNED(待诊),已挂号未接诊的患者;不传或传-1会返回已接诊的患者
|
||||
},
|
||||
skipErrorMsg: true // 跳过错误提示,由组件处理
|
||||
})
|
||||
@@ -169,4 +169,4 @@ export function getLocationTree(query) {
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="header-section">
|
||||
<div class="header-left">
|
||||
<span class="title">智能分诊排队管理 - {{ currentDeptName }}</span>
|
||||
<span class="title">智能分诊排队管理 - {{ currentDeptName }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="handleRefresh">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button @click="handleExit">退出</el-button>
|
||||
<el-button @click="handleConfig">后台配置</el-button>
|
||||
<el-button @click="handleExit">
|
||||
退出
|
||||
</el-button>
|
||||
<el-button @click="handleConfig">
|
||||
后台配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,28 +38,67 @@
|
||||
style="width: 100%"
|
||||
@selection-change="handleCandidateSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column prop="sequenceNo" label="序号" width="80" align="center" />
|
||||
<el-table-column prop="patientName" label="患者" width="100" align="center" />
|
||||
<el-table-column prop="age" label="年龄" width="80" align="center" />
|
||||
<el-table-column prop="appointmentType" label="号别" width="100" align="center" />
|
||||
<el-table-column prop="room" label="诊室" width="120" align="center" />
|
||||
<el-table-column prop="doctor" label="医生" width="120" align="center" />
|
||||
<el-table-column prop="matchingRule" label="命中规则" min-width="150" align="center" />
|
||||
<el-table-column
|
||||
type="selection"
|
||||
width="55"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="sequenceNo"
|
||||
label="序号"
|
||||
width="80"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="patientName"
|
||||
label="患者"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="age"
|
||||
label="年龄"
|
||||
width="80"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="appointmentType"
|
||||
label="号别"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="room"
|
||||
label="诊室"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="doctor"
|
||||
label="医生"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="matchingRule"
|
||||
label="命中规则"
|
||||
min-width="150"
|
||||
align="center"
|
||||
/>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="candidate-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleAddToQueue"
|
||||
:disabled="selectedCandidates.length === 0"
|
||||
@click="handleAddToQueue"
|
||||
>
|
||||
加入队列 >>
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleAddAllToQueue"
|
||||
:disabled="filteredCandidatePoolList.length === 0"
|
||||
@click="handleAddAllToQueue"
|
||||
>
|
||||
一键加入队列
|
||||
</el-button>
|
||||
@@ -75,13 +121,48 @@
|
||||
highlight-current-row
|
||||
@row-click="handleQueueRowClick"
|
||||
>
|
||||
<el-table-column prop="queueOrder" label="队序" width="80" align="center" />
|
||||
<el-table-column prop="patientName" label="患者" width="100" align="center" />
|
||||
<el-table-column prop="appointmentType" label="号别" width="100" align="center" />
|
||||
<el-table-column prop="room" label="诊室" width="120" align="center" />
|
||||
<el-table-column prop="doctor" label="医生" width="120" align="center" />
|
||||
<el-table-column prop="waitingTime" label="等待" width="100" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<el-table-column
|
||||
prop="queueOrder"
|
||||
label="队序"
|
||||
width="80"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="patientName"
|
||||
label="患者"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="appointmentType"
|
||||
label="号别"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="room"
|
||||
label="诊室"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="doctor"
|
||||
label="医生"
|
||||
width="120"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="waitingTime"
|
||||
label="等待"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="status"
|
||||
label="状态"
|
||||
width="100"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusTagType(scope.row.status)">
|
||||
{{ scope.row.status }}
|
||||
@@ -94,25 +175,25 @@
|
||||
<div class="queue-actions-left">
|
||||
<el-button
|
||||
type="danger"
|
||||
@click="handleRemoveFromQueue"
|
||||
:disabled="!selectedQueueRow"
|
||||
size="small"
|
||||
@click="handleRemoveFromQueue"
|
||||
>
|
||||
<< 移出队列
|
||||
<< 移出队列
|
||||
</el-button>
|
||||
<el-button
|
||||
type="info"
|
||||
@click="handleMoveUp"
|
||||
:disabled="!selectedQueueRow || !canMoveUp"
|
||||
size="small"
|
||||
@click="handleMoveUp"
|
||||
>
|
||||
↑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="info"
|
||||
@click="handleMoveDown"
|
||||
:disabled="!selectedQueueRow || !canMoveDown"
|
||||
size="small"
|
||||
@click="handleMoveDown"
|
||||
>
|
||||
↓
|
||||
</el-button>
|
||||
@@ -120,15 +201,15 @@
|
||||
<div class="queue-actions-right">
|
||||
<el-button
|
||||
:type="showOnlyWaiting ? 'primary' : ''"
|
||||
@click="showOnlyWaiting = true"
|
||||
size="small"
|
||||
@click="showOnlyWaiting = true"
|
||||
>
|
||||
只显示等待
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="!showOnlyWaiting ? 'primary' : ''"
|
||||
@click="showOnlyWaiting = false"
|
||||
size="small"
|
||||
@click="showOnlyWaiting = false"
|
||||
>
|
||||
显示全部状态
|
||||
</el-button>
|
||||
@@ -141,7 +222,9 @@
|
||||
<div class="footer-section">
|
||||
<!-- 就诊科室快速过滤栏 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-label">③ 就诊科室快速过滤栏</div>
|
||||
<div class="filter-label">
|
||||
③ 就诊科室快速过滤栏
|
||||
</div>
|
||||
<div class="filter-select-wrapper">
|
||||
<el-select
|
||||
v-model="selectedDept"
|
||||
@@ -167,24 +250,53 @@
|
||||
|
||||
<!-- 叫号控制板 -->
|
||||
<div class="call-control-section">
|
||||
<div class="call-control-label">④ 叫号控制板</div>
|
||||
<div class="call-control-label">
|
||||
④ 叫号控制板
|
||||
</div>
|
||||
<div class="call-control-content">
|
||||
<div class="current-call-display">
|
||||
当前呼叫: {{ currentCall.number }} {{ currentCall.name }} 诊室: {{ currentCall.room }}
|
||||
</div>
|
||||
<div class="control-buttons">
|
||||
<el-button type="primary" @click="handleSelectCall">选呼</el-button>
|
||||
<el-button type="success" @click="handleNextPatient">下一患者</el-button>
|
||||
<el-button type="warning" @click="handleSkip">跳过</el-button>
|
||||
<el-button type="primary" @click="handleComplete">完成</el-button>
|
||||
<el-button type="info" @click="handleRequeue">过号重排</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSelectCall"
|
||||
>
|
||||
选呼
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
@click="handleNextPatient"
|
||||
>
|
||||
下一患者
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
@click="handleSkip"
|
||||
>
|
||||
跳过
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleComplete"
|
||||
>
|
||||
完成
|
||||
</el-button>
|
||||
<el-button
|
||||
type="info"
|
||||
@click="handleRequeue"
|
||||
>
|
||||
过号重排
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LED显示 -->
|
||||
<div class="led-section">
|
||||
<div class="led-label">⑤ LED:</div>
|
||||
<div class="led-label">
|
||||
⑤ LED:
|
||||
</div>
|
||||
<div class="led-display">
|
||||
[{{ currentCall.number }}]{{ currentCall.name }}请到{{ currentCall.room }}({{ callType }})
|
||||
</div>
|
||||
@@ -197,205 +309,333 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 后台配置弹窗 -->
|
||||
<el-dialog
|
||||
v-model="configDialogVisible"
|
||||
width="90%"
|
||||
top="5vh"
|
||||
>
|
||||
<template #header>
|
||||
<div class="config-dialog-header">
|
||||
<div class="config-dialog-title">智能分诊规则引擎配置 - 心内科</div>
|
||||
<div class="config-topbar-actions">
|
||||
<el-button type="primary" @click="handleAddRule">新增规则</el-button>
|
||||
<el-button type="primary" @click="handleSaveAllRules">保存全部</el-button>
|
||||
<el-button @click="handleTestRule">测试规则</el-button>
|
||||
<!-- 后台配置弹窗 -->
|
||||
<el-dialog
|
||||
v-model="configDialogVisible"
|
||||
width="90%"
|
||||
top="5vh"
|
||||
>
|
||||
<template #header>
|
||||
<div class="config-dialog-header">
|
||||
<div class="config-dialog-title">
|
||||
智能分诊规则引擎配置 - 心内科
|
||||
</div>
|
||||
<div class="config-topbar-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleAddRule"
|
||||
>
|
||||
新增规则
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSaveAllRules"
|
||||
>
|
||||
保存全部
|
||||
</el-button>
|
||||
<el-button @click="handleTestRule">
|
||||
测试规则
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="config-container">
|
||||
<div class="config-left">
|
||||
<el-scrollbar height="560px">
|
||||
<div
|
||||
v-for="(item, idx) in rules"
|
||||
:key="idx"
|
||||
class="rule-card"
|
||||
:class="{ active: idx === editingIndex }"
|
||||
@click="handleSelectRule(idx)"
|
||||
>
|
||||
<div class="rule-title">
|
||||
规则{{ idx + 1 }}
|
||||
</div>
|
||||
<div class="rule-sub">
|
||||
prio={{ item.priority }}
|
||||
</div>
|
||||
<div class="rule-sub">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="rule-actions">
|
||||
<el-button
|
||||
size="small"
|
||||
@click.stop="handleSelectRule(idx)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
@click.stop="handleDeleteRule(idx)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="idx === 0"
|
||||
@click.stop="handleRuleMoveUp(idx)"
|
||||
>
|
||||
↑
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:disabled="idx === rules.length - 1"
|
||||
@click.stop="handleRuleMoveDown(idx)"
|
||||
>
|
||||
↓
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<div class="config-right">
|
||||
<el-form
|
||||
label-width="110px"
|
||||
class="config-form"
|
||||
>
|
||||
<el-form-item
|
||||
label="规则名称:"
|
||||
required
|
||||
>
|
||||
<el-input
|
||||
v-model="ruleForm.name"
|
||||
placeholder="请输入规则名称"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="科室:">
|
||||
<el-select
|
||||
v-model="ruleForm.dept"
|
||||
class="config-fullwidth"
|
||||
disabled
|
||||
>
|
||||
<el-option
|
||||
label="心内科"
|
||||
value="心内科"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="规则描述:">
|
||||
<el-input
|
||||
v-model="ruleForm.desc"
|
||||
placeholder="请输入规则描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="优先级:"
|
||||
required
|
||||
>
|
||||
<el-input v-model="ruleForm.priority" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="周几生效:">
|
||||
<el-checkbox-group v-model="ruleForm.weeks">
|
||||
<el-checkbox
|
||||
v-for="w in weekOptions"
|
||||
:key="w.value"
|
||||
:label="w.value"
|
||||
>
|
||||
{{ w.label }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="条件表达式(JSON):">
|
||||
<el-input
|
||||
v-model="ruleForm.expr"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="{"age":">=60","regType":"专家"}"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="config-inline-actions">
|
||||
<el-button @click="handleQuickGenerate">
|
||||
快速生成器
|
||||
</el-button>
|
||||
<el-button @click="handleValidateRule">
|
||||
语法检查
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="config-container">
|
||||
<div class="config-left">
|
||||
<el-scrollbar height="560px">
|
||||
<div
|
||||
v-for="(item, idx) in rules"
|
||||
:key="idx"
|
||||
class="rule-card"
|
||||
:class="{ active: idx === editingIndex }"
|
||||
@click="handleSelectRule(idx)"
|
||||
<template #footer>
|
||||
<div class="config-footer">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSaveCurrentRule"
|
||||
>
|
||||
<div class="rule-title">规则{{ idx + 1 }}</div>
|
||||
<div class="rule-sub">prio={{ item.priority }}</div>
|
||||
<div class="rule-sub">{{ item.name }}</div>
|
||||
<div class="rule-actions">
|
||||
<el-button size="small" @click.stop="handleSelectRule(idx)">编辑</el-button>
|
||||
<el-button size="small" @click.stop="handleDeleteRule(idx)">删除</el-button>
|
||||
<el-button size="small" @click.stop="handleRuleMoveUp(idx)" :disabled="idx === 0">↑</el-button>
|
||||
<el-button size="small" @click.stop="handleRuleMoveDown(idx)" :disabled="idx === rules.length - 1">↓</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
保存
|
||||
</el-button>
|
||||
<el-button @click="configDialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<div class="config-right">
|
||||
<el-form label-width="110px" class="config-form">
|
||||
<el-form-item label="规则名称:" required>
|
||||
<el-input v-model="ruleForm.name" placeholder="请输入规则名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="科室:">
|
||||
<el-select v-model="ruleForm.dept" class="config-fullwidth" disabled>
|
||||
<el-option label="心内科" value="心内科" />
|
||||
<!-- 快速生成器对话框 -->
|
||||
<el-dialog
|
||||
v-model="quickGeneratorDialogVisible"
|
||||
title="快速生成器"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
:model="quickGeneratorForm"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="年龄条件:">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<el-select
|
||||
v-model="quickGeneratorForm.ageOperator"
|
||||
style="width: 100px;"
|
||||
>
|
||||
<el-option
|
||||
label=">="
|
||||
value=">="
|
||||
/>
|
||||
<el-option
|
||||
label="<="
|
||||
value="<="
|
||||
/>
|
||||
<el-option
|
||||
label="="
|
||||
value="="
|
||||
/>
|
||||
<el-option
|
||||
label=">"
|
||||
value=">"
|
||||
/>
|
||||
<el-option
|
||||
label="<"
|
||||
value="<"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="规则描述:">
|
||||
<el-input v-model="ruleForm.desc" placeholder="请输入规则描述" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="优先级:" required>
|
||||
<el-input v-model="ruleForm.priority" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="周几生效:">
|
||||
<el-checkbox-group v-model="ruleForm.weeks">
|
||||
<el-checkbox v-for="w in weekOptions" :key="w.value" :label="w.value">
|
||||
{{ w.label }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="条件表达式(JSON):">
|
||||
<el-input
|
||||
v-model="ruleForm.expr"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder='{"age":">=60","regType":"专家"}'
|
||||
<el-input-number
|
||||
v-model="quickGeneratorForm.ageValue"
|
||||
:min="0"
|
||||
:max="150"
|
||||
:precision="0"
|
||||
placeholder="年龄"
|
||||
style="width: 150px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="config-inline-actions">
|
||||
<el-button @click="handleQuickGenerate">快速生成器</el-button>
|
||||
<el-button @click="handleValidateRule">语法检查</el-button>
|
||||
<el-button
|
||||
type="text"
|
||||
@click="quickGeneratorForm.ageOperator = null; quickGeneratorForm.ageValue = null"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<template #footer>
|
||||
<div class="config-footer">
|
||||
<el-button type="primary" @click="handleSaveCurrentRule">保存</el-button>
|
||||
<el-button @click="configDialogVisible = false">取消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 快速生成器对话框 -->
|
||||
<el-dialog
|
||||
v-model="quickGeneratorDialogVisible"
|
||||
title="快速生成器"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form :model="quickGeneratorForm" label-width="120px">
|
||||
<el-form-item label="年龄条件:">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<el-select v-model="quickGeneratorForm.ageOperator" style="width: 100px;">
|
||||
<el-option label=">=" value=">=" />
|
||||
<el-option label="<=" value="<=" />
|
||||
<el-option label="=" value="=" />
|
||||
<el-option label=">" value=">" />
|
||||
<el-option label="<" value="<" />
|
||||
<el-form-item label="号别/类型:">
|
||||
<el-select
|
||||
v-model="quickGeneratorForm.regType"
|
||||
placeholder="请选择号别"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
label="专家"
|
||||
value="专家"
|
||||
/>
|
||||
<el-option
|
||||
label="普通"
|
||||
value="普通"
|
||||
/>
|
||||
<el-option
|
||||
label="特需"
|
||||
value="特需"
|
||||
/>
|
||||
<el-option
|
||||
label="急诊"
|
||||
value="急诊"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input-number
|
||||
v-model="quickGeneratorForm.ageValue"
|
||||
:min="0"
|
||||
:max="150"
|
||||
:precision="0"
|
||||
placeholder="年龄"
|
||||
style="width: 150px;"
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="科室:">
|
||||
<el-select
|
||||
v-model="quickGeneratorForm.dept"
|
||||
placeholder="请选择科室"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
label="心内科"
|
||||
value="心内科"
|
||||
/>
|
||||
<el-option
|
||||
label="心外科"
|
||||
value="心外科"
|
||||
/>
|
||||
<el-option
|
||||
label="神经内科"
|
||||
value="神经内科"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="医生:">
|
||||
<el-input
|
||||
v-model="quickGeneratorForm.doctor"
|
||||
placeholder="请输入医生姓名(支持模糊匹配)"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="自定义条件:">
|
||||
<el-input
|
||||
v-model="quickGeneratorForm.customKey"
|
||||
placeholder="字段名(如:gender)"
|
||||
style="width: 150px; margin-right: 10px;"
|
||||
/>
|
||||
<el-input
|
||||
v-model="quickGeneratorForm.customValue"
|
||||
placeholder="字段值(如:男)"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<el-button
|
||||
type="text"
|
||||
@click="quickGeneratorForm.ageOperator = null; quickGeneratorForm.ageValue = null"
|
||||
@click="quickGeneratorForm.customKey = ''; quickGeneratorForm.customValue = ''"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="预览JSON:">
|
||||
<el-input
|
||||
:value="previewJson"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
readonly
|
||||
style="font-family: monospace;"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div style="text-align: right;">
|
||||
<el-button @click="quickGeneratorDialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleApplyQuickGenerate"
|
||||
>
|
||||
应用
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="号别/类型:">
|
||||
<el-select
|
||||
v-model="quickGeneratorForm.regType"
|
||||
placeholder="请选择号别"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="专家" value="专家" />
|
||||
<el-option label="普通" value="普通" />
|
||||
<el-option label="特需" value="特需" />
|
||||
<el-option label="急诊" value="急诊" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="科室:">
|
||||
<el-select
|
||||
v-model="quickGeneratorForm.dept"
|
||||
placeholder="请选择科室"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="心内科" value="心内科" />
|
||||
<el-option label="心外科" value="心外科" />
|
||||
<el-option label="神经内科" value="神经内科" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="医生:">
|
||||
<el-input
|
||||
v-model="quickGeneratorForm.doctor"
|
||||
placeholder="请输入医生姓名(支持模糊匹配)"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="自定义条件:">
|
||||
<el-input
|
||||
v-model="quickGeneratorForm.customKey"
|
||||
placeholder="字段名(如:gender)"
|
||||
style="width: 150px; margin-right: 10px;"
|
||||
/>
|
||||
<el-input
|
||||
v-model="quickGeneratorForm.customValue"
|
||||
placeholder="字段值(如:男)"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<el-button
|
||||
type="text"
|
||||
@click="quickGeneratorForm.customKey = ''; quickGeneratorForm.customValue = ''"
|
||||
>
|
||||
清除
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="预览JSON:">
|
||||
<el-input
|
||||
:value="previewJson"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
readonly
|
||||
style="font-family: monospace;"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div style="text-align: right;">
|
||||
<el-button @click="quickGeneratorDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleApplyQuickGenerate">应用</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -627,29 +867,40 @@ const parseAge = (ageStr) => {
|
||||
}
|
||||
|
||||
// 后端队列状态 -> 前端展示状态
|
||||
// 后端状态码:0=WAITING, 10=CALLING, 20=IN_CLINIC, 30=COMPLETED, 40=SKIPPED, 50=REFUNDED, 60=FOLLOW
|
||||
const mapBackendStatusToFrontend = (status) => {
|
||||
if (!status) {
|
||||
if (status === null || status === undefined) {
|
||||
console.warn('【心内科】状态映射:收到空状态值')
|
||||
return '等待'
|
||||
}
|
||||
// 转换为大写并去除空格,确保匹配
|
||||
const normalizedStatus = String(status).trim().toUpperCase()
|
||||
if (normalizedStatus === 'CALLING') return '叫号中'
|
||||
if (normalizedStatus === 'WAITING') return '等待'
|
||||
if (normalizedStatus === 'SKIPPED') return '跳过'
|
||||
if (normalizedStatus === 'COMPLETED') return '已完成'
|
||||
console.warn('【心内科】状态映射:未知状态值', status, '-> 默认返回"等待"')
|
||||
return '等待'
|
||||
const numStatus = Number(status)
|
||||
switch (numStatus) {
|
||||
case 0: return '等待'
|
||||
case 10: return '叫号中'
|
||||
case 20: return '诊中'
|
||||
case 30: return '已完成'
|
||||
case 40: return '跳过'
|
||||
case 50: return '已退费'
|
||||
case 60: return '已随访'
|
||||
default:
|
||||
console.warn('【心内科】状态映射:未知状态值', status, '-> 默认返回"等待"')
|
||||
return '等待'
|
||||
}
|
||||
}
|
||||
|
||||
// 前端状态 -> 后端状态(目前仅展示用)
|
||||
// 前端状态 -> 后端状态码
|
||||
const mapFrontendStatusToBackend = (status) => {
|
||||
if (!status) return 'WAITING'
|
||||
if (status === '叫号中') return 'CALLING'
|
||||
if (status === '等待') return 'WAITING'
|
||||
if (status === '跳过') return 'SKIPPED'
|
||||
if (status === '已完成') return 'COMPLETED'
|
||||
return 'WAITING'
|
||||
if (!status) return 0
|
||||
switch (status) {
|
||||
case '叫号中': return 10
|
||||
case '等待': return 0
|
||||
case '诊中': return 20
|
||||
case '已完成': return 30
|
||||
case '跳过': return 40
|
||||
case '已退费': return 50
|
||||
case '已随访': return 60
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库加载队列
|
||||
|
||||
17
openhis-ui-vue3/tests/e2e/fixtures/auth.ts
Normal file
17
openhis-ui-vue3/tests/e2e/fixtures/auth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { test as base } from '@playwright/test';
|
||||
import { TEST_USERS } from '../utils/test-data';
|
||||
|
||||
export const test = base.extend({
|
||||
async authenticatedPage({ page }, use) {
|
||||
// 登录
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', TEST_USERS.admin.username);
|
||||
await page.fill('input[placeholder="请输入密码"]', TEST_USERS.admin.password);
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForURL(/.*(dashboard|home).*/);
|
||||
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
26
openhis-ui-vue3/tests/e2e/pages/DoctorStationPage.ts
Normal file
26
openhis-ui-vue3/tests/e2e/pages/DoctorStationPage.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export class DoctorStationPage {
|
||||
readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/doctorstation');
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async expandCategory(index: number = 0) {
|
||||
const item = this.page.locator('.el-collapse-item, .category-item').nth(index);
|
||||
await item.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async searchPatient(name: string) {
|
||||
await this.page.fill('input[placeholder*="患者"], input[placeholder*="姓名"]', name);
|
||||
await this.page.click('button:has-text("搜索"), button:has-text("查询")');
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
}
|
||||
46
openhis-ui-vue3/tests/e2e/pages/LoginPage.ts
Normal file
46
openhis-ui-vue3/tests/e2e/pages/LoginPage.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export class LoginPage {
|
||||
readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/');
|
||||
await this.page.waitForLoadState('domcontentloaded');
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
// Actual placeholders from login.vue: "账号" and "密码"
|
||||
await this.page.fill('input[placeholder="账号"]', username);
|
||||
await this.page.fill('input[placeholder="密码"]', password);
|
||||
// Check for tenant selection if exists
|
||||
const tenantSelect = this.page.locator('.el-select__wrapper, input[placeholder="请选择医疗机构"]').first();
|
||||
if (await tenantSelect.isVisible().catch(() => false)) {
|
||||
await tenantSelect.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
// Select first option
|
||||
const firstOption = this.page.locator('.el-select-dropdown__item, .el-option').first();
|
||||
if (await firstOption.isVisible().catch(() => false)) {
|
||||
await firstOption.click();
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
await this.page.click('button:has-text("登 录")');
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async expectLoginSuccess() {
|
||||
await expect(this.page).toHaveURL(/.*(dashboard|home|index).*/, { timeout: 15000 });
|
||||
}
|
||||
|
||||
async expectLoginFailed() {
|
||||
await expect(this.page.locator('.el-message--error')).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
async expectOnLoginPage() {
|
||||
await expect(this.page.locator('input[placeholder="账号"]')).toBeVisible();
|
||||
}
|
||||
}
|
||||
34
openhis-ui-vue3/tests/e2e/pages/SurgeryBillingPage.ts
Normal file
34
openhis-ui-vue3/tests/e2e/pages/SurgeryBillingPage.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export class SurgeryBillingPage {
|
||||
readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/operatingroom');
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async rapidClickGenerate(times: number = 5) {
|
||||
const btn = this.page.locator('button:has-text("生成"), button:has-text("新增")');
|
||||
for (let i = 0; i < times; i++) {
|
||||
await btn.click().catch(() => {});
|
||||
}
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
async getDialogCount(): Promise<number> {
|
||||
return await this.page.locator('.el-dialog, .el-message-box').count();
|
||||
}
|
||||
|
||||
async expectNoLocationIdError() {
|
||||
await expect(this.page.locator('text=发放库房为空')).toHaveCount(0, { timeout: 5000 });
|
||||
}
|
||||
|
||||
async expectSaveSuccess() {
|
||||
await expect(this.page.locator('.el-message--success')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
}
|
||||
53
openhis-ui-vue3/tests/e2e/specs/bug-regression.spec.ts
Normal file
53
openhis-ui-vue3/tests/e2e/specs/bug-regression.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { TEST_USERS, TEST_URLS } from '../utils/test-data';
|
||||
|
||||
test.describe('🐛 Bug回归测试', () => {
|
||||
let loginPage: LoginPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password);
|
||||
await loginPage.expectLoginSuccess();
|
||||
});
|
||||
|
||||
test('#437 手术计费防重复提交 @bug437 @regression', async ({ page }) => {
|
||||
await page.goto(TEST_URLS.surgeryBilling);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const addBtn = page.locator('button:has-text("新增"), button:has-text("生成")');
|
||||
if (await addBtn.isVisible()) {
|
||||
await addBtn.click();
|
||||
await addBtn.click();
|
||||
await addBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const dialogs = page.locator('.el-dialog, .el-message-box');
|
||||
expect(await dialogs.count()).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('#443 手术计费签发耗材 @bug443 @regression', async ({ page }) => {
|
||||
await page.goto(TEST_URLS.surgeryBilling);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const signBtn = page.locator('button:has-text("签发"), button:has-text("提交")');
|
||||
if (await signBtn.isVisible()) {
|
||||
await signBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
const errorMsg = page.locator('text=发放库房为空');
|
||||
expect(await errorMsg.count()).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('#427 检查项目分类手风琴展开 @regression', async ({ page }) => {
|
||||
await page.goto(TEST_URLS.doctorStation);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const categories = page.locator('.el-collapse-item, .category-item');
|
||||
const count = await categories.count();
|
||||
if (count > 0) {
|
||||
await categories.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
54
openhis-ui-vue3/tests/e2e/specs/concurrency.spec.ts
Normal file
54
openhis-ui-vue3/tests/e2e/specs/concurrency.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_USERS, TEST_URLS } from '../utils/test-data';
|
||||
|
||||
test.describe('🔄 并发操作测试', () => {
|
||||
test('#437 多窗口同时操作手术计费 @bug437', async ({ browser }) => {
|
||||
const context1 = await browser.newContext();
|
||||
const context2 = await browser.newContext();
|
||||
|
||||
const page1 = await context1.newPage();
|
||||
const page2 = await context2.newPage();
|
||||
|
||||
// Login on both pages
|
||||
for (const page of [page1, page2]) {
|
||||
await page.goto(TEST_URLS.login);
|
||||
await page.fill('input[placeholder="账号"]', TEST_USERS.admin.username);
|
||||
await page.fill('input[placeholder="密码"]', TEST_USERS.admin.password);
|
||||
await page.click('button:has-text("登 录")');
|
||||
await page.waitForURL(/.*(dashboard|home|index).*/);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
page1.goto(TEST_URLS.surgeryBilling),
|
||||
page2.goto(TEST_URLS.surgeryBilling),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
page1.waitForLoadState('networkidle'),
|
||||
page2.waitForLoadState('networkidle'),
|
||||
]);
|
||||
|
||||
const genBtn1 = page1.locator('button:has-text("生成")');
|
||||
const genBtn2 = page2.locator('button:has-text("生成")');
|
||||
|
||||
if (await genBtn1.isVisible() && await genBtn2.isVisible()) {
|
||||
await Promise.all([
|
||||
genBtn1.click().catch(() => {}),
|
||||
genBtn2.click().catch(() => {}),
|
||||
]);
|
||||
|
||||
await page1.waitForTimeout(3000);
|
||||
|
||||
const table1 = page1.locator('el-table__body tr, .el-table__row');
|
||||
const table2 = page2.locator('el-table__body tr, .el-table__row');
|
||||
|
||||
const count1 = await table1.count();
|
||||
const count2 = await table2.count();
|
||||
|
||||
expect(count1).toBe(count2);
|
||||
}
|
||||
|
||||
await context1.close();
|
||||
await context2.close();
|
||||
});
|
||||
});
|
||||
34
openhis-ui-vue3/tests/e2e/specs/doctor-station.spec.ts
Normal file
34
openhis-ui-vue3/tests/e2e/specs/doctor-station.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { TEST_USERS, TEST_URLS } from '../utils/test-data';
|
||||
|
||||
test.describe('🏥 门诊医生站', () => {
|
||||
let loginPage: LoginPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password);
|
||||
await loginPage.expectLoginSuccess();
|
||||
});
|
||||
|
||||
test('#427 分类手风琴展开/收起 @regression', async ({ page }) => {
|
||||
await page.goto(TEST_URLS.doctorStation);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const items = page.locator('.el-collapse-item, .category-item');
|
||||
const count = await items.count();
|
||||
|
||||
if (count >= 2) {
|
||||
await items.nth(0).click();
|
||||
await page.waitForTimeout(500);
|
||||
await items.nth(1).click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-DOCTOR-001: 医生站页面加载 @smoke', async ({ page }) => {
|
||||
await page.goto(TEST_URLS.doctorStation);
|
||||
await expect(page).toHaveURL(/.*doctorstation.*/);
|
||||
});
|
||||
});
|
||||
38
openhis-ui-vue3/tests/e2e/specs/login.spec.ts
Normal file
38
openhis-ui-vue3/tests/e2e/specs/login.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { TEST_USERS } from '../utils/test-data';
|
||||
|
||||
test.describe('🔐 登录模块', () => {
|
||||
let loginPage: LoginPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
});
|
||||
|
||||
test('TC-LOGIN-001: 管理员正常登录 @smoke', async ({ page }) => {
|
||||
await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password);
|
||||
await loginPage.expectLoginSuccess();
|
||||
});
|
||||
|
||||
test('TC-LOGIN-002: 错误密码登录 @smoke', async ({ page }) => {
|
||||
await loginPage.login(TEST_USERS.admin.username, 'wrong_password_123');
|
||||
// Check for any error indication (message, toast, or stayed on login page)
|
||||
const hasError = await page.locator('.el-message--error, .el-message-box, text=密码错误, text=用户名或密码错误').isVisible().catch(() => false);
|
||||
const stillOnLogin = page.url().includes('login') || page.url() === 'http://localhost:81/' || page.url() === 'http://localhost:81/index';
|
||||
expect(hasError || stillOnLogin).toBeTruthy();
|
||||
});
|
||||
|
||||
test('TC-LOGIN-003: 空用户名登录', async ({ page }) => {
|
||||
await loginPage.login('', TEST_USERS.admin.password);
|
||||
// Should show validation error or stay on login page
|
||||
const hasError = await page.locator('.el-form-item__error, .el-message--error').isVisible().catch(() => false);
|
||||
const stillOnLogin = page.url().includes('login') || page.url() === 'http://localhost:81/';
|
||||
expect(hasError || stillOnLogin).toBeTruthy();
|
||||
});
|
||||
|
||||
test('TC-LOGIN-004: 密码输入框可见性切换', async ({ page }) => {
|
||||
const passwordInput = page.locator('input[placeholder="密码"]');
|
||||
await expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
});
|
||||
42
openhis-ui-vue3/tests/e2e/specs/surgery-billing.spec.ts
Normal file
42
openhis-ui-vue3/tests/e2e/specs/surgery-billing.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { TEST_USERS, TEST_URLS } from '../utils/test-data';
|
||||
|
||||
test.describe('💊 手术计费模块', () => {
|
||||
let loginPage: LoginPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login(TEST_USERS.admin.username, TEST_USERS.admin.password);
|
||||
await loginPage.expectLoginSuccess();
|
||||
});
|
||||
|
||||
test('#437 快速连续点击防重复 @bug437 @smoke', async ({ page }) => {
|
||||
await page.goto(TEST_URLS.surgeryBilling);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const genBtn = page.locator('button:has-text("生成"), button:has-text("新增")');
|
||||
if (await genBtn.isVisible()) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await genBtn.click().catch(() => {});
|
||||
}
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const count = await page.locator('.el-dialog, .el-message-box').count();
|
||||
expect(count).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('#443 签发耗材不报库房错误 @bug443 @smoke', async ({ page }) => {
|
||||
await page.goto(TEST_URLS.surgeryBilling);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const signBtn = page.locator('button:has-text("签发"), button:has-text("提交")');
|
||||
if (await signBtn.isVisible()) {
|
||||
await signBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator('text=发放库房为空')).toHaveCount(0, { timeout: 5000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
13
openhis-ui-vue3/tests/e2e/utils/test-data.ts
Normal file
13
openhis-ui-vue3/tests/e2e/utils/test-data.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const TEST_USERS = {
|
||||
admin: {
|
||||
username: process.env.TEST_USERNAME || 'admin',
|
||||
password: process.env.TEST_PASSWORD || 'admin123',
|
||||
},
|
||||
};
|
||||
|
||||
export const TEST_URLS = {
|
||||
login: '/',
|
||||
dashboard: '/index',
|
||||
doctorStation: '/doctorstation',
|
||||
surgeryBilling: '/operatingroom',
|
||||
};
|
||||
28
openhis-ui-vue3/tests/playwright.config.ts
Normal file
28
openhis-ui-vue3/tests/playwright.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e/specs',
|
||||
fullyParallel: true,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: [
|
||||
['html', { outputFolder: 'tests/e2e/report', open: 'never' }],
|
||||
['list'],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.TEST_BASE_URL || 'http://localhost:81',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
locale: 'zh-CN',
|
||||
timezoneId: 'Asia/Shanghai',
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
});
|
||||
@@ -11,11 +11,11 @@ import createVitePlugins from './vite/plugins';
|
||||
export default defineConfig(({ mode, command }) => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
const { VITE_APP_ENV } = env;
|
||||
const buildVersion = process.env.VITE_APP_VERSION || env.VITE_APP_VERSION || Date.now().toString();
|
||||
return {
|
||||
// define: {
|
||||
// // enable hydration mismatch details in production build
|
||||
// __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true'
|
||||
// },
|
||||
define: {
|
||||
'import.meta.env.VITE_APP_BUILD_VERSION': JSON.stringify(buildVersion),
|
||||
},
|
||||
// 部署生产环境和开发环境下的URL。
|
||||
// 默认情况下,vite 会假设你的应用是被部署在一个域名的根路径上
|
||||
// 例如 https://www.openHIS.vip/。如果应用被部署在一个子路径上,你就需要用这个选项指定这个子路径。例如,如果你的应用被部署在 https://www.openhis.vip/admin/,则设置 baseUrl 为 /admin/。
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Bug #437 数据排查:查询手术计费中的重复收费项
|
||||
-- 用法:在生产环境执行前先在测试环境验证
|
||||
-- 作者:荀彧
|
||||
|
||||
-- 1. 查找手术计费中的重复收费项(同一手术+同一产品产生多条记录)
|
||||
SELECT
|
||||
encounter_id AS "就诊ID",
|
||||
product_table AS "产品表",
|
||||
product_id AS "产品ID",
|
||||
service_table AS "服务表",
|
||||
service_id AS "服务ID",
|
||||
generate_source_enum AS "生成来源",
|
||||
COUNT(*) AS "重复次数",
|
||||
STRING_AGG(id::TEXT, ',') AS "收费项ID列表",
|
||||
STRING_AGG(status_enum::TEXT, ',') AS "状态列表",
|
||||
STRING_AGG(total_price::TEXT, ',') AS "金额列表",
|
||||
STRING_AGG(create_time::TEXT, ',') AS "创建时间列表"
|
||||
FROM adm_charge_item
|
||||
WHERE delete_flag = '0'
|
||||
AND generate_source_enum IN (1, 2) -- 医生开立(1) 或 护士划价(2)
|
||||
AND product_table IN ('cli_surgery', 'wor_device_request', 'wor_activity_definition')
|
||||
GROUP BY encounter_id, product_table, product_id, service_table, service_id, generate_source_enum
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY COUNT(*) DESC;
|
||||
|
||||
-- 2. 查找同一手术单号下是否有重复的收费项
|
||||
SELECT
|
||||
ci.product_id AS "手术ID",
|
||||
cs.surgery_no AS "手术单号",
|
||||
cs.surgery_name AS "手术名称",
|
||||
COUNT(ci.id) AS "收费项数量",
|
||||
STRING_AGG(ci.id::TEXT || '(' || ci.status_enum || ')' || '=' || ci.total_price, '; ') AS "收费项详情"
|
||||
FROM adm_charge_item ci
|
||||
LEFT JOIN cli_surgery cs ON ci.product_id = cs.id
|
||||
WHERE ci.delete_flag = '0'
|
||||
AND ci.product_table = 'cli_surgery'
|
||||
AND ci.generate_source_enum = 1
|
||||
GROUP BY ci.product_id, cs.surgery_no, cs.surgery_name
|
||||
HAVING COUNT(ci.id) > 1
|
||||
ORDER BY COUNT(ci.id) DESC;
|
||||
|
||||
-- 3. 统计重复数据量(用于评估影响范围)
|
||||
SELECT
|
||||
generate_source_enum AS "生成来源(1=医生开立,2=护士划价)",
|
||||
product_table AS "产品表",
|
||||
COUNT(*) AS "重复组数",
|
||||
SUM(cnt - 1) AS "多余记录数"
|
||||
FROM (
|
||||
SELECT
|
||||
generate_source_enum,
|
||||
product_table,
|
||||
COUNT(*) AS cnt
|
||||
FROM adm_charge_item
|
||||
WHERE delete_flag = '0'
|
||||
GROUP BY encounter_id, product_table, product_id, service_table, service_id, generate_source_enum
|
||||
HAVING COUNT(*) > 1
|
||||
) sub
|
||||
GROUP BY generate_source_enum, product_table
|
||||
ORDER BY SUM(cnt - 1) DESC;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Bug #437 修复:手术计费重复生成三条收费记录
|
||||
-- 根因:adm_charge_item 表缺少唯一约束,同一手术/耗材的收费项可被重复插入
|
||||
-- 影响范围:手术计费、护士划价等所有生成收费项的场景
|
||||
-- 作者:荀彧
|
||||
-- 日期:2026-04-25
|
||||
|
||||
-- 1. 添加复合唯一约束:防止同一就诊下同一来源服务+产品产生重复收费项
|
||||
-- 约束字段:就诊ID + 医疗服务表 + 医疗服务ID + 产品表 + 产品ID + 账单生成来源
|
||||
-- 这些字段组合唯一确定一笔收费项,重复插入将被数据库拒绝
|
||||
ALTER TABLE adm_charge_item
|
||||
ADD CONSTRAINT uk_charge_item_encounter_service_product_source
|
||||
UNIQUE (encounter_id, service_table, service_id, product_table, product_id, generate_source_enum);
|
||||
|
||||
-- 2. 添加索引:加速手术计费查询(按手术单号过滤)
|
||||
-- 前端手术计费页面使用 generate_source_enum=2 过滤手术计费项
|
||||
CREATE INDEX idx_charge_item_generate_source_product
|
||||
ON adm_charge_item (generate_source_enum, product_table, product_id)
|
||||
WHERE delete_flag = '0';
|
||||
|
||||
-- 3. 添加索引:加速按就诊ID+状态查询收费项
|
||||
CREATE INDEX idx_charge_item_encounter_status
|
||||
ON adm_charge_item (encounter_id, status_enum)
|
||||
WHERE delete_flag = '0';
|
||||
|
||||
-- DOWN 迁移(回滚脚本)
|
||||
-- ALTER TABLE adm_charge_item DROP CONSTRAINT IF EXISTS uk_charge_item_encounter_service_product_source;
|
||||
-- DROP INDEX IF EXISTS idx_charge_item_generate_source_product;
|
||||
-- DROP INDEX IF EXISTS idx_charge_item_encounter_status;
|
||||
Reference in New Issue
Block a user