65 Commits

Author SHA1 Message Date
21743237bb fix: #428修复selectedItems.push缺少isPackage和packageId字段
- 在selectedItems.push添加isPackage和packageId字段
- 在loadCategoryList的mapped对象添加packageId映射
- 确保套餐明细懒加载功能正常工作
2026-05-01 01:01:07 +08:00
70726f6aaa fix: 修复#446临时医嘱提交后弹窗关闭逻辑
- 添加成功提示消息:临时医嘱已生成,弹窗即将关闭
- 延迟1秒关闭弹窗,让用户看到成功反馈
- 避免界面'非法关闭'的用户体验问题
2026-05-01 00:09:33 +08:00
97d0011fc3 fix: 修复#444计费药品列表未显示药品名称
- 添加更多字段回退逻辑:chargeName, itemName等
- 确保即使contentJson中没有adviceName也能显示药品名称
- 添加默认值'未知药品'避免空显示
2026-05-01 00:08:51 +08:00
guanyu
701f5fed82 fix: 修复#447/#453/#455申请单adviceTypes格式错误
- 修复#447手术申请:adviceTypes改为[3,6]包含诊疗和手术类型
- 修复#453检验申请:adviceTypes从字符串'3'改为数组[3]
- 修复#455检查申请:adviceTypes从字符串'3'改为数组[3]
- 修复#455输血申请:adviceTypes从字符串'3'改为数组[3]
- 统一所有申请单的adviceTypes为数组格式,确保后端正确解析
2026-05-01 00:07:56 +08:00
guanyu
97b4e396d9 fix: 修复#469检验申请操作列临床业务逻辑
- 在handleCellClick中添加isDeleting.value检查
- 避免删除操作期间触发数据填充
- 确保删除后不会错误加载申请单数据到表单
2026-05-01 00:01:07 +08:00
guanyu
d62ac41f66 fix: 修复#470/#447手术/输血申请单加载项目耗时过长
- 将pageSize从10000改为500,与检验/检查申请保持一致
- 减少单次请求数据量,提升加载性能
- 同时修复手术申请单和输血申请单
2026-04-30 23:59:22 +08:00
guanyu
c712a42f79 fix: 修复#428赵云代码语法错误 - loadPackageDetailsForItem函数位置错误
- 修复loadPackageDetailsForItem函数被错误插入到loadPackageDetails函数内部的问题
- 格式化模板代码,提高可读性
- 确保套餐明细加载逻辑正确工作
2026-04-30 23:56:01 +08:00
a007721c8f 住院医生站:开立诊疗项目时,明细配置区的项目单价显示为负数 2026-04-30 17:12:10 +08:00
616aa46a0c fix: 修复#428检查申请分类联动功能
- 修复1: 将el-collapse-item的@click事件改为el-collapse的@change事件
- 修复2: 添加handleCollapseChange函数处理展开事件
- 修复3: 修改已选择区域展开后显示套餐明细或检查方法
- 修复4: 添加loadPackageDetailsForItem函数加载套餐明细
- 修复5: 修改toggleItemExpand函数在展开时加载套餐明细
2026-04-30 17:08:16 +08:00
d5d112b2cc Merge remote-tracking branch 'origin/develop' into develop 2026-04-30 16:49:26 +08:00
c4a7261de0 疾病报告管理-》报告卡管理:查看报卡详情页缺失“历史审核记录”展示 2026-04-30 16:49:15 +08:00
wangjian963
7799282b86 fix: 修复#420 #416 检验申请单显示优化与布局调整
#420: 申请单项目列表显示"售价/单位"替代数据库ID
  - 从priceList取售价price,从AdviceBaseDto取单位unitCode_dictText
  - 诊疗类项目priceList不含unitCode,须从顶层item取单位
  - 同步修复检验、检查、输血、手术四个申请单

  #416: 申请单弹窗布局调整
  - 弹窗宽度 1000px → 1200px
  - transfer穿梭框面板 400px → 480px
  - 优化左右双栏视觉效果,减少滚动
2026-04-30 16:48:59 +08:00
wangjian963
4a01825a30 feat(分诊队列): 实现分诊队列核心功能与日志记录
新增分诊队列相关服务接口与实现,包括队列管理、叫号操作和日志记录
添加DivLogService和CallRecordService用于记录分诊操作和叫号历史
在CurrentDayEncounterDto和TriageQueueItem中增加seqNo字段用于显示预约序号
实现分诊操作日志记录功能,包括添加队列、移除队列、叫号、完成等操作
新增CallType枚举定义叫号类型,并实现叫号记录功能
优化队列状态映射逻辑,支持更多状态类型显示
2026-04-30 16:02:52 +08:00
guanyu
81daaccdda fix: 修复#404医嘱组套编辑字段回显丢失 - 恢复被误删的列别名
根因: 关羽修复#403时(e2808fd6)误删了#404的列别名(AS)修复,
导致dose/rate_code/method_code/dose_quantity/dispense_per_duration/therapy_enum
无法正确映射到DTO,编辑组套时这些字段回显为空。

修复: 在OrdersGroupPackageAppMapper.xml的getGroupPackageDetail查询中
恢复6个字段的显式列别名映射。
2026-04-30 15:21:08 +08:00
82ef66794b 住院护士站-》医嘱执行页面:勾选医嘱后点击“执行选中”按钮无反应,无法完成执行操作.
疾病报告管理-》报告卡管理:审核报卡界面内容与门诊医生站登记界面不一致
2026-04-30 13:33:03 +08:00
b536eadd92 修复前端获取版本号的bug 2026-04-29 17:54:34 +08:00
guanyu
3472aa790e fix: 修复#436手术计费界面显示无关费用项
根因: 前端按generateSourceEnum和sourceBillNo过滤手术计费项目,
但后端SQL查询和DTO未返回这两个字段,导致过滤失效,显示所有费用项。

修复:
1. EncounterPatientPrescriptionDto添加generateSourceEnum和sourceBillNo字段
2. SQL查询添加T1.generate_source_enum和T1.prescription_no AS source_bill_no
2026-04-29 17:40:13 +08:00
guanyu
ec89ead14c fix: 修复#456门诊医生站医嘱类型和状态异常
根因: 处方列表组件中adviceTypes参数传递格式错误,
将单个adviceType值直接赋值给adviceTypes参数,
但后端期望List<Integer>数组格式。

修复: 将adviceQueryParams.adviceTypes = value改为
adviceQueryParams.adviceTypes = [value],确保参数格式正确。
2026-04-29 17:24:07 +08:00
guanyu
136235fe4c fix: 修复#459检验申请报错仍生成记录
根因: saveRequestForm方法缺少@Transactional事务注解,
导致处理多个诊疗项目时,部分成功保存后发生异常,
已保存的数据无法回滚,造成脏数据。

修复: 在saveRequestForm方法上添加@Transactional(rollbackFor = Exception.class)注解,
确保整个操作原子性,异常时自动回滚。
2026-04-29 17:21:15 +08:00
guanyu
c2cac12b9f fix: 修复#459检验申请报错仍生成记录
根因: RequestFormManageAppServiceImpl缺少@Transactional事务注解,
导致保存申请单过程中如果后续步骤报错,已保存的申请单不会回滚,
产生脏数据。

修复: 在类上添加@Transactional(rollbackFor = Exception.class)注解,
确保整个保存操作在同一个事务中,任何异常都会回滚所有数据库操作。
2026-04-29 17:20:13 +08:00
guanyu
b424d73542 fix: 修复#471手术申请查询混入脏数据
根因: 手术申请分页查询SQL中cli_surgery、adm_patient、adm_encounter表
LEFT JOIN时缺少delete_flag='0'过滤条件,导致已删除的数据混入查询结果。

修复: 在LEFT JOIN条件中添加AND cs.delete_flag='0'、AND ap.delete_flag='0'、AND ae.delete_flag='0'。
2026-04-29 17:18:18 +08:00
guanyu
decac542c8 fix: 修复#462诊疗目录标本下拉框无数据
根因: diagnosisTreatmentDialog.vue中useDict未引入specimen_code字典,
导致标本下拉框无数据。

修复: 在useDict调用中添加'specimen_code'字典。
2026-04-29 17:14:29 +08:00
guanyu
783ee48ec8 fix: 修复#465检验项目列表限制500项
根因: LabActivityDefinitionManageMapper.xml中getLabActivityDefinitionSimpleList查询
设置了LIMIT 500/1500的限制,导致检验项目超过500项时无法完整显示。

修复: 将LIMIT限制提高到10000,支持更多检验项目。
2026-04-29 17:13:44 +08:00
guanyu
e1ad4965eb fix: 修复#457门诊收费手术医嘱不显示名称
根因: 手术收费项目的contextEnum错误设置为6(中成药),
导致门诊收费查询SQL无法正确匹配手术名称。

修复: 将手术收费项目的contextEnum改为3(项目),
因为手术属于诊疗项目类别。
2026-04-29 17:11:16 +08:00
guanyu
fd1880f1c8 fix: 修复#438门诊划价选择'西药'时无数据
根因: 门诊划价控制器(OutpatientPricingController)未接收adviceType参数,
导致前端传递的药品类型过滤条件无法生效。

修复: 在getAdviceBaseInfo方法中添加adviceType参数接收和处理,
确保西药(adviceType=1, categoryCode='2')能正确过滤。
2026-04-29 17:09:58 +08:00
wangjian963
d4d05267ad feat(分诊队列): 实现分诊队列核心功能与日志记录
新增分诊队列相关服务接口与实现,包括队列管理、叫号操作和日志记录
添加DivLogService和CallRecordService用于记录分诊操作和叫号历史
在CurrentDayEncounterDto和TriageQueueItem中增加seqNo字段用于显示预约序号
实现分诊操作日志记录功能,包括添加队列、移除队列、叫号、完成等操作
新增CallType枚举定义叫号类型,并实现叫号记录功能
优化队列状态映射逻辑,支持更多状态类型显示
2026-04-29 17:05:17 +08:00
2b0acce1db Merge remote-tracking branch 'origin/develop' into develop 2026-04-29 17:00:05 +08:00
4312c0c557 增加后端版本展示 2026-04-29 16:59:44 +08:00
guanyu
caa45c3310 fix: 修复#472住院医生站手术申请单勾选无效
根因: 前端获取手术项目列表时传递的adviceTypes为字符串'3',
后端期望List<Integer>格式, 可能导致解析异常。

修复: 将adviceTypes: '3'改为adviceTypes: [3]数组格式,
确保Spring MVC能正确解析为List<Integer>。
2026-04-29 16:48:52 +08:00
7fabad14f9 将InpatientMedicalRecordHomePageCollectionDto 中的 @Data 注解替换为 @Getter 和 @Setter 注解; 2026-04-29 16:29:22 +08:00
guanyu
405a9dfb72 fix: Bug #249 门诊手术安排查询未过滤已删除手术申请单 - 将cli_surgery表的LEFT JOIN改为INNER JOIN,确保已删除作废的手术申请单不在手术安排查询界面显示 2026-04-28 14:03:14 +08:00
d1be841688 fix: Bug #451 门诊医生站-提交新增手术申请后列表刷新失败 2026-04-28 12:33:16 +08:00
guanyu
9b8655748e fix: Bug #449/#450 门诊医生站接诊/数据加载失败 - 修复TodayOutpatientServiceImpl中receivePatient/completeVisit/cancelVisit方法空壳问题,改为调用DoctorStationMainAppService正确业务逻辑 2026-04-28 12:07:38 +08:00
00fd6c8710 在 vite.config.js 中添加了动态构建版本定义,通过环境变量 VITE_APP_VERSION 实现。
更新了 login.vue,使其动态显示构建版本,而非使用硬编码的值。
2026-04-27 14:16:32 +08:00
bbd9d48fa6 test: Playwright E2E测试12个用例全部通过!
- 修复登录按钮选择器:'登 录'(带空格)
- 修复placeholder:'账号'/'密码'
- 修复登录失败检测逻辑
- 12/12用例通过,耗时16.9秒
- 覆盖:登录4场景 + Bug回归3个(#437/#443/#427) + 手术计费2个 + 医生站2个 + 并发1个
2026-04-25 22:33:53 +08:00
8fb1d3e583 fix: 修正Playwright登录页选择器 - 使用实际placeholder '账号'/'密码' 2026-04-25 22:29:23 +08:00
34ba7cae6a fix: 修复Playwright页面对象定义错误 + 根目录config
- 修复LoginPage/SurgeryBillingPage/DoctorStationPage中page变量作用域问题
- 新增根目录playwright.config.ts(解决配置加载问题)
- .gitignore添加test-results和report目录排除
2026-04-25 22:14:19 +08:00
305ab15436 test: 增强Playwright E2E测试方案 - 新增手术计费/医生站/并发测试用例
- 新增页面对象: SurgeryBillingPage, DoctorStationPage
- 新增测试用例: 手术计费防重复(#437), 签发耗材验证(#443), 并发操作测试
- 增强登录测试: 多场景覆盖
- 完善测试数据工具: 支持多角色用户配置
- 清理冗余备份文件
2026-04-25 22:04:36 +08:00
46a7076460 Merge branch 'develop' of http://192.168.110.253:3000/wangyizhe/his into develop 2026-04-25 21:07:43 +08:00
e0e6693897 fix: 修正Playwright测试方案架构问题(诸葛亮审查反馈)
- 新增fixtures/auth.ts 登录认证夹具
- 新增pages/LoginPage.ts 页面对象模型
- 新增specs/login.spec.ts 登录测试用例(成功/失败/空用户名)
- 新增specs/bug-regression.spec.ts Bug回归测试(#437/#427)
- 新增.env.test 测试环境变量模板
- package.json添加test:e2e/test:e2e:ui/test:e2e:report脚本
- 移除test-data.ts中密码硬编码,改用环境变量
- .gitignore添加.env.test.local/playwright-report/test-results

感谢诸葛亮架构审查!
2026-04-25 21:07:40 +08:00
guanyu
7d1e50d045 fix: 修复#443手术计费签发耗材报错
根因: handleAddDeviceBilling方法中对locationId的验证过于严格,
当前端未传递locationId时直接抛出ServiceException导致签发失败。

修复: 将严格验证改为预处理设置默认值,
当advice.getLocationId()为null时, 使用SecurityUtils.getLoginUser().getOrgId()作为默认位置ID。
2026-04-25 21:05:05 +08:00
25ce12cebf Merge branch 'develop' of http://192.168.110.253:3000/wangyizhe/his into develop 2026-04-25 21:02:16 +08:00
7d55717037 feat: 添加Playwright E2E自动化测试完整方案
- 创建完整Playwright测试方案文档(docs/specs/)
- 创建Playwright配置文件(tests/playwright.config.ts)
- 创建测试数据工具类(tests/e2e/utils/test-data.ts)
- 建立测试目录结构:fixtures/pages/specs/utils
- 支持CI/CD集成,测试失败阻断发布
- 覆盖登录、门诊医生站、手术计费、Bug回归测试

关联任务: UI功能性测试方案落地
2026-04-25 21:02:13 +08:00
guanyu
290e8f8f15 fix: 修复#445门诊手术待生成列表未剔除已生成医嘱
根因: getSurgeryPage查询只查cli_surgery表,没有关联wor_service_request表检查是否已生成医嘱。
所有手术都会显示在待生成列表中,不管是否已处理。

修复: 在getSurgeryPage查询中LEFT JOIN wor_service_request表,
通过sr.id IS NULL过滤掉已生成医嘱的手术。
2026-04-25 20:10:31 +08:00
fc84fd61ab fix(#437): 数据库层修复手术计费重复生成收费项
- 添加复合唯一约束 uk_charge_item_encounter_service_product_source
  防止同一就诊下同一来源服务+产品产生重复收费项
  约束字段:encounter_id + service_table + service_id + product_table + product_id + generate_source_enum
- 添加索引 idx_charge_item_generate_source_product 加速手术计费查询
- 添加索引 idx_charge_item_encounter_status 加速按就诊状态查询
- 提供重复数据检测SQL供运维排查历史数据

根因分析:
1. adm_charge_item 表无任何唯一约束,同一收费项可被多次插入
2. 前端手术计费页面使用 sourceBillNo 过滤,但该字段不存在于 ChargeItem 实体中
3. 多处代码路径(SurgeryAppServiceImpl/RequestFormManageAppServiceImpl)均可生成收费项
4. 缺少数据库层面的兜底防护

Author: xunyu
2026-04-25 20:04:54 +08:00
guanyu
d79690a371 fix: 修复#442手术计费删除待签发耗材报错
根因: handleDel方法中使用iProcedureService.listByIds(requestIds)错误,
requestIds是WOR_DEVICE_REQUEST/WOR_SERVICE_REQUEST表主键,
不是CLI_PROCEDURE表主键。

修复: 改用iProcedureService.getProcedureRecords(requestIds, serviceTable)
通过request_id字段正确关联执行记录。
2026-04-25 20:02:11 +08:00
7bccbc7085 fix: Bug #427 检查项目分类手风琴展开 + Bug #437 手术计费重复记录修复
- #427: switchCategory函数已实现手风琴逻辑(切换时收起其他分类)
- #437: prescriptionlist.vue添加isSaving防重复提交锁
- #437: 使用JSON.parse(JSON.stringify(row))清理Vue响应式对象
- #437: 添加finally块确保锁释放
2026-04-25 19:47:05 +08:00
guanyu
059ef483ca fix: 修复#447住院医生站手术申请弹窗无法加载手术类诊疗目录数据
根因: adviceType=3(诊疗)查询时强制排除手术项(category_code!='手术'且!='24'),
导致通过categoryCode='24'显式查询手术项目时结果为空。

修复: 仅在未指定categoryCode时才排除手术,
当显式指定categoryCode='24'或'手术'时允许加载手术类项目。
2026-04-25 16:12:03 +08:00
guanyu
4beb4c40c5 fix: 修复#448门诊划价项目分类过滤失效 - 耗材和诊疗查询缺少categoryCode过滤条件
- adviceType=2(耗材)查询添加categoryCode过滤
- adviceType=3(诊疗)查询添加categoryCode过滤
- 与adviceType=1(药品)保持一致的过滤逻辑
- 修复选择耗材类型时仍检索出药品的问题
2026-04-25 15:27:02 +08:00
914f2d8229 docs: 按刘备建议结构重新整理《HIS项目发布检查清单 v1.0》 2026-04-25 12:12:41 +08:00
2f57b3e7c1 docs: 整合四份清单为《HIS项目发布检查清单 v1.0》 2026-04-25 12:08:51 +08:00
guanyu
39ccd27df8 feat: 新增《后端发布前检查清单》- 关羽
补充后端发布前六大模块检查项:
1. Maven编译验证
2. Spring Boot多环境配置
3. MyBatis Plus规范(实体映射/SQL安全/事务管理)
4. RESTful API设计(统一返回/参数校验/版本管理)
5. 安全与合规(数据脱敏/权限控制/审计日志)
6. 性能检查(N+1查询/慢查询优化)

与陈琳的前端清单形成对称体系,覆盖getDepartmentList类问题的后端等价场景。
2026-04-24 19:19:23 +08:00
d370b6a888 docs: 补充ESLint flat config配置示例到CI/CD门禁规范 2026-04-24 19:16:44 +08:00
3c61e39e09 docs: 修正构建门禁文档中的命令不一致问题 - 统一前端构建命令为 build:prod,后端编译命令为 mvn clean package -DskipTests 2026-04-24 19:06:23 +08:00
f2c71b08bb feat: 启用ESLint import规则 - 实时检测缺失导出,防止构建失败 2026-04-24 18:12:27 +08:00
90cf7f43d7 Merge branch 'develop' of http://192.168.110.253:3000/wangyizhe/his into develop 2026-04-24 18:06:44 +08:00
1f5d392c08 chore: 清理.git残留的.orig文件 2026-04-24 18:06:39 +08:00
d52bbda8c3 docs: 完善三份构建门禁文档 - 补充前后端协同检查、Java后端门禁、数据库变更字段
架构评审改进项:
- frontend-checklist.md: 增加后端Maven编译、数据库脚本、接口兼容性检查
- cicd-gatekeeper.md: 补充Java后端构建配置(SpotBugs)、分阶段覆盖率目标
- commit-template.md: 增加数据库变更影响评估字段、精简截图要求
2026-04-24 18:03:45 +08:00
guanyu
986510278b feat: 配置Husky pre-commit钩子 - 提交前自动执行前端构建检查
- 创建.husky/pre-commit文件
- 配置提交前自动执行npm run build:dev检查语法
- 添加node_modules存在性校验
- 预留ESLint检查接口(待赵云配置后启用)
- 更新openhis-ui-vue3/package.json添加lint-staged配置

【关羽】构建门禁第一步落地
2026-04-24 18:02:27 +08:00
758921b633 Merge branch 'develop' of http://192.168.110.253:3000/wangyizhe/his into develop 2026-04-24 17:14:29 +08:00
8e7ebd3461 chore: 更新package-lock.json(husky安装) 2026-04-24 17:13:31 +08:00
8c05782549 fix: 修复bloodTransfusion.vue构建报错 - public.js补充getDepartmentList导出 2026-04-24 17:11:20 +08:00
060d1910dd Merge branch 'develop' of http://192.168.110.253:3000/wangyizhe/his into develop 2026-04-24 17:05:23 +08:00
44ae216612 feat: 添加husky pre-commit hook配置实现构建验证 (#441)
- 配置husky作为pre-commit钩子
- 添加构建验证脚本,提交前自动执行构建检查
- 防止构建失败的代码被提交到仓库

关联任务: 自动化构建门禁方案第一步
2026-04-24 17:04:49 +08:00
0076753c19 docs: 添加三份构建门禁相关文档
- 《前端发布前检查清单》
- 《CI/CD构建门禁规范》
- 《代码提交变更说明模板》

为解决getDepartmentList导入错误等构建问题提供标准化文档支持
2026-04-24 17:04:42 +08:00
94 changed files with 5308 additions and 1959 deletions

3
.gitignore vendored
View File

@@ -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/

51
.husky/pre-commit Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env sh
# ============================================================
# Husky Pre-commit Hook - HIS项目
# 配置: 关羽 | 日期: 2026-04-24
# 功能: 提交前自动检查前端构建
# ============================================================
echo "========================================"
echo "🔍 [Pre-commit] HIS项目提交检查"
echo "========================================"
# 检查前端目录是否存在
if [ ! -d "openhis-ui-vue3" ]; then
echo "⚠️ [Pre-commit] 未找到openhis-ui-vue3目录跳过前端检查"
exit 0
fi
cd openhis-ui-vue3
# 检查node_modules是否存在
if [ ! -d "node_modules" ]; then
echo "⚠️ [Pre-commit] node_modules未安装请先执行 npm install"
echo " 提示: 首次使用或依赖变更后需要安装依赖"
exit 1
fi
# 执行lint检查ESLint配置由赵云下周完善后启用
if grep -q '"lint"' package.json 2>/dev/null; then
echo "📋 [Pre-commit] 执行Lint检查..."
if npm run lint -- --max-warnings 0 2>&1; then
echo "✅ [Pre-commit] Lint检查通过"
else
echo "❌ [Pre-commit] Lint检查失败请修复代码规范问题"
exit 1
fi
else
echo "⏭️ [Pre-commit] 未配置lint脚本待赵云配置ESLint后启用"
fi
# 执行快速构建检查development模式仅检查语法和类型
echo "🔨 [Pre-commit] 执行构建检查 (build:dev)..."
if timeout 120 npm run build:dev 2>&1; then
echo "✅ [Pre-commit] 构建检查通过"
else
echo "❌ [Pre-commit] 构建检查失败!请修复编译错误后重新提交"
exit 1
fi
echo "========================================"
echo "✅ [Pre-commit] 所有检查通过,允许提交"
echo "========================================"

View File

@@ -1,6 +0,0 @@
{
"tools": {
"approvalMode": "yolo"
},
"$version": 2
}

View 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系统发布前完整质量保障体系

View File

@@ -0,0 +1,217 @@
# CI/CD构建门禁规范
## 🎯 规范目标
建立自动化质量门禁,确保每次代码提交都经过严格验证,防止低质量代码进入主干分支,提升系统稳定性和开发效率。
## 🔒 门禁层级
### 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
```
javascript
// .eslintrc.js 关键配置
module.exports = {
plugins: ['import'],
rules: {
// 确保导入的模块实际存在
'import/no-unresolved': 'error',
// 确保导入的成员实际存在
'import/named': 'error',
// 禁止未使用的导入
'import/no-unused-modules': 'warn'
}
};
```
### 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"]
}
}
```
```json
// package.json
{
"lint-staged": {
"*.{js,vue}": ["eslint --fix", "prettier --write"],
"*.{css,scss}": ["stylelint --fix", "prettier --write"]
}
}
```
## 🚫 失败处理机制
### 自动处理
- **构建失败**:自动阻止 PR 合并
- **测试失败**:标记 PR 为失败状态
- **安全漏洞**:立即通知安全团队
### 人工处理
- **紧急修复**:可申请临时绕过(需架构师批准)
- **误报处理**:提交豁免申请并说明原因
- **规则调整**:通过 RFC 流程申请规则变更
## 📊 监控与度量
### 关键指标
- 门禁通过率 ≥ 95%
- 平均修复时间 ≤ 2小时
- 误报率 ≤ 5%
### 报告机制
- 每日门禁失败统计
- 周度质量趋势报告
- 月度规则优化建议
## 🔄 持续改进
### 规则演进
- 每月评审门禁规则有效性
- 根据项目需求调整检查强度
- 引入新的质量检查工具
### 团队培训
- 新成员入职培训包含门禁规范
- 定期分享最佳实践案例
- 建立常见问题解决方案库
---
**文档版本**v1.0
**最后更新**2026年4月24日
**负责人**:陈琳(文档专家)
**技术方案**:诸葛亮(架构师)
**适用范围**HIS 系统所有项目

View File

@@ -0,0 +1,135 @@
# 代码提交变更说明模板
## 📝 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. **分支信息**:显示当前工作分支名称
### 截图示例
```
$ git checkout feature/patient-edit
$ npm run build:prod
> his-system@1.0.0 build
> vue-cli-service build
⠇ Building for production...
DONE Build complete. The dist directory is ready to be deployed.
INFO Check out deployment instructions at https://cli.vuejs.org/guide/deployment.html
✨ Done in 45.23s.
```
## ⚠️ 禁止行为
### 严重违规(直接拒绝合并)
- 无构建验证截图
- 代码存在 ESLint 错误
- 未填写变更说明
- 修改无关代码文件
### 轻微违规(要求修正后重新提交)
- 描述过于简单
- 测试覆盖不完整
- 文档更新滞后
- 格式不符合规范
## 💡 最佳实践
### 高质量提交特征
- **原子性**:每次提交只解决一个问题
- **可追溯**关联具体的需求或Bug ID
- **可验证**:提供完整的验证证据
- **可理解**:描述清晰,他人能快速理解
### 团队协作建议
- 提交前先在本地完整测试
- 复杂变更提前与团队沟通
- 及时更新相关文档
- 主动帮助新人熟悉规范
---
**文档版本**v1.0
**最后更新**2026年4月24日
**负责人**:陈琳(文档专家)
**适用范围**HIS 系统所有开发人员

View File

@@ -0,0 +1,102 @@
# 前端发布前检查清单
## 📋 基础检查项
### 代码质量
- [ ] 代码已通过 ESLint 检查,无警告和错误
- [ ] 代码已通过 Prettier 格式化
- [ ] 无 console.log() 等调试代码残留
- [ ] 变量命名符合规范,语义清晰
- [ ] 函数职责单一,复杂度适中
### 构建验证
- [ ] 本地执行 `npm run build:prod` 成功完成
- [ ] 构建产物无报错,体积合理
- [ ] 静态资源路径正确无404错误
- [ ] 环境变量配置正确(开发/测试/生产)
### 功能验证
- [ ] 核心功能流程完整测试通过
- [ ] 边界条件和异常场景已覆盖
- [ ] 表单验证逻辑正确
- [ ] API 接口调用正常,错误处理完善
- [ ] 路由跳转逻辑正确
## 🔧 技术检查项
### 模块导入检查
- [ ] 所有 import 语句引用的模块实际存在
- [ ] 无未使用的 import 导入
- [ ] 路径别名(@/)配置正确
- [ ] 第三方库版本兼容性确认
### 性能优化
- [ ] 组件按需加载(懒加载)已配置
- [ ] 大数据列表已实现虚拟滚动或分页
- [ ] 图片资源已压缩,格式合适
- [ ] 无内存泄漏风险(事件监听器、定时器等)
### 安全检查
- [ ] 用户输入已做 XSS 防护
- [ ] 敏感信息不在前端硬编码
- [ ] API 请求已做 CSRF 防护
- [ ] 权限控制逻辑正确
## 🌐 兼容性检查
### 浏览器兼容
- [ ] 主流浏览器Chrome、Firefox、Safari、Edge显示正常
- [ ] 移动端适配良好(如适用)
- [ ] 分辨率适配1366x768、1920x1080等
### 设备兼容
- [ ] 触摸设备操作体验良好
- [ ] 键盘导航支持完整
- [ ] 屏幕阅读器兼容性(无障碍)
## 📱 发布准备
### 文档更新
- [ ] 相关 API 文档已同步更新
- [ ] 用户操作手册已更新(如适用)
- [ ] 变更日志已记录
### 回滚预案
- [ ] 回滚方案已准备
- [ ] 数据兼容性已确认
- [ ] 紧急联系人已明确
## 🔧 后端检查项
### 编译验证
- [ ] Maven编译成功`mvn clean package -DskipTests`
- [ ] 无编译错误,仅有可接受的警告
- [ ] 依赖版本兼容性确认
### 数据库脚本
- [ ] DDL/DML脚本语法正确
- [ ] 回滚脚本已准备
- [ ] 数据迁移脚本已测试
## 🔄 前后端协同
### 接口兼容性
- [ ] API接口契约变更已双方确认
- [ ] 前端调用后端接口正常
- [ ] 错误码处理逻辑一致
## ✅ 最终确认
### 发布前最后检查
- [ ] 本地构建截图已附在 PR 中
- [ ] 测试环境部署验证通过
- [ ] Code Review 已完成并获得批准
- [ ] 相关 Bug 已关闭或延期说明
---
**文档版本**v1.0
**最后更新**2026年4月24日
**负责人**:陈琳(文档专家)
**适用范围**HIS 系统所有前端项目

View 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
**触发时机**代码推送到远程仓库后
**验证内容**
- 完整的测试套件单元+集成+端到端
- 代码覆盖率检查分阶段目标Q130%Q250%Q380%
- 安全扫描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 系统所有开发人员

View File

@@ -0,0 +1,214 @@
# HIS项目 Playwright E2E 自动化测试方案 v1.0
## 一、方案概述
### 1.1 选型理由
- **Playwright** 是微软开源的端到端测试框架,完美适配 Vue 3 + Vite 技术栈
- 自动等待机制适合HIS系统复杂交互场景异步加载、动态渲染
- 支持多浏览器Chromium/Firefox/WebKitCI/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. **测试优先**:新功能开发时同步编写测试用例

View File

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

View File

@@ -107,6 +107,9 @@ public class SecurityConfig {
.permitAll()
.antMatchers("/patientmanage/information/**")
.permitAll()
// 登录页展示用的系统版本信息,允许匿名访问
.antMatchers("/system/version")
.permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
})

View File

@@ -20,12 +20,12 @@ public interface InfectiousAuditMapper extends BaseMapper<InfectiousAudit> {
/**
* 根据报卡编号查询审核记录
*/
@Select("SELECT * FROM infectious_audit WHERE card_id = #{cardId} ORDER BY audit_time DESC")
@Select("SELECT * FROM infectious_audit WHERE card_id::text = #{cardId} ORDER BY audit_time DESC")
List<InfectiousAudit> selectByCardId(@Param("cardId") String cardId);
/**
* 获取下一个审核序号
*/
@Select("SELECT COALESCE(MAX(audit_seq), 0) + 1 FROM infectious_audit WHERE card_id = #{cardId}")
@Select("SELECT COALESCE(MAX(audit_seq), 0) + 1 FROM infectious_audit WHERE card_id::text = #{cardId}")
Integer getNextAuditSeq(@Param("cardId") String cardId);
}

View File

@@ -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;
@@ -520,10 +525,11 @@ public class OutpatientRegistrationAppServiceImpl implements IOutpatientRegistra
ParticipantType.ADMITTER.getCode(), ParticipantType.REGISTRATION_DOCTOR.getCode(), queryWrapper,
ChargeItemContext.REGISTER.getValue(), PaymentStatus.SUCCESS.getValue());
// 过滤候选池排除列表(如果是从智能候选池查询,排除已加入队列的患者)
// 检查请求参数 excludeFromCandidatePool,如果为 true 或未设置,则过滤排除列表
// 过滤候选池排除列表
// 仅当调用方显式传 excludeFromCandidatePool=true 时才过滤,避免非分诊场景(挂号/收费)
// 因未传参导致默认过滤,使已入队患者不可见
String excludeParam = request.getParameter("excludeFromCandidatePool");
boolean shouldExclude = excludeParam == null || "true".equalsIgnoreCase(excludeParam);
boolean shouldExclude = "true".equalsIgnoreCase(excludeParam);
if (shouldExclude && currentDayEncounter != null && !currentDayEncounter.getRecords().isEmpty()) {
try {
// 获取当前租户和日期
@@ -807,6 +813,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());
}

View File

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

View File

@@ -170,4 +170,9 @@ public class CurrentDayEncounterDto {
*/
private String clinicRoom;
/**
* 预约序号(来自 adm_schedule_slot.seq_no
*/
private Integer seqNo;
}

View File

@@ -209,4 +209,14 @@ public class EncounterPatientPrescriptionDto {
private String discountRate = "0";
private String discountRate_dictText;
/**
* 账单生成来源
*/
private Integer generateSourceEnum;
/**
* 来源单据号(手术单号等)
*/
private String sourceBillNo;
}

View File

@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.core.common.core.domain.R;
import com.core.common.core.domain.model.LoginUser;
import com.core.common.utils.AgeCalculatorUtil;
import com.core.common.utils.SecurityUtils;
import com.openhis.administration.domain.Encounter;
@@ -17,7 +18,9 @@ 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.triageandqueuemanage.domain.DivLog;
import com.openhis.triageandqueuemanage.domain.TriageQueueItem;
import com.openhis.triageandqueuemanage.service.DivLogService;
import com.openhis.triageandqueuemanage.service.TriageQueueItemService;
import com.openhis.web.doctorstation.appservice.*;
import com.openhis.web.doctorstation.dto.PatientInfoDto;
@@ -32,6 +35,7 @@ import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
@@ -70,6 +74,10 @@ public class DoctorStationMainAppServiceImpl implements IDoctorStationMainAppSer
@Resource
private TriageQueueItemService triageQueueItemService;
@Resource
private DivLogService divLogService;
/**
* 查询就诊患者信息
*
@@ -194,10 +202,13 @@ public class DoctorStationMainAppServiceImpl implements IDoctorStationMainAppSer
new LambdaQueryWrapper<TriageQueueItem>()
.eq(TriageQueueItem::getTenantId, tenantId)
.eq(TriageQueueItem::getEncounterId, encounterId)
// 限定当天日期,避免复诊患者匹配到历史队列记录
.eq(TriageQueueItem::getQueueDate, LocalDate.now())
.eq(TriageQueueItem::getDeleteFlag, "0")
);
if (queueItem != null) {
queueItem.setStatus(20); // 20=IN_CLINIC(诊中),患者进入诊室接诊
// 使用 TriageQueueStatus 枚举替代原有硬编码数字 20保证状态值一致性
queueItem.setStatus(TriageQueueStatus.IN_CLINIC.getValue());
queueItem.setUpdateTime(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
triageQueueItemService.updateById(queueItem);
log.info("接诊时更新队列状态为IN_CLINIC(诊中)encounterId={}, queueItemId={}", encounterId, queueItem.getId());
@@ -253,40 +264,38 @@ public class DoctorStationMainAppServiceImpl implements IDoctorStationMainAppSer
return R.fail("非就诊中患者不能完诊");
}
// 2. 查找队列项
// 2. 查找队列项(限定当天,避免复诊患者匹配到历史队列记录)
Integer tenantId = SecurityUtils.getLoginUser().getTenantId();
TriageQueueItem queueItem = triageQueueItemService.getOne(
new LambdaQueryWrapper<TriageQueueItem>()
.eq(TriageQueueItem::getTenantId, tenantId)
.eq(TriageQueueItem::getEncounterId, encounterId)
.eq(TriageQueueItem::getQueueDate, LocalDate.now())
.eq(TriageQueueItem::getDeleteFlag, "0")
);
// 如果队列项存在,检查状态并更新
// 允许从 CALLING(10) 或 IN_CLINIC(20) 完成就诊
// 如果队列项存在且未完成,更新队列状态为已完成
// 使用排除法而非白名单:只要不是"已完成"就可以完诊,覆盖跳过、等待等非标准流转状态
if (queueItem != null &&
(Integer.valueOf(10).equals(queueItem.getStatus()) ||
Integer.valueOf(20).equals(queueItem.getStatus()))) {
!TriageQueueStatus.COMPLETED.getValue().equals(queueItem.getStatus())) {
// 更新队列状态为已完成
java.time.LocalDateTime nowLocal = java.time.LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS);
queueItem.setStatus(30); // 30=COMPLETED(已完成)
queueItem.setStatus(TriageQueueStatus.COMPLETED.getValue());
queueItem.setUpdateTime(nowLocal);
triageQueueItemService.updateById(queueItem);
// 写入 div_log 审计日志
// 写入 div_log 审计日志(使用实体+Service替代原生JDBC SQL避免SQL注入风险便于维护
try {
Long userId = SecurityUtils.getLoginUser().getUserId();
String divLogSql = "INSERT INTO hisdev.div_log "
+ "(pool_id, slot_id, queue_no, op_user_id, action, create_time) "
+ "VALUES (?, ?, ?, ?, 30, NOW()::timestamp(0))";
// action=30 表示 COMPLETED(已完成),与 triage_queue_item.status 数字编码保持一致
// 0=WAITING, 10=CALLING, 20=IN_CLINIC, 30=COMPLETED, 40=SKIPPED, 50=REFUNDED, 60=FOLLOW
jdbcTemplate.update(divLogSql,
queueItem.getPoolId(), // pool_id: 号源池ID来自 adm_schedule_pool.id
queueItem.getSlotId(), // slot_id: 号源槽位ID来自 adm_schedule_slot.id
queueItem.getQueueOrder(), // queue_no: 队列序号
userId); // op_user_id: 操作用户ID
LoginUser loginUser = SecurityUtils.getLoginUser();
DivLog divLog = new DivLog()
.setPoolId(queueItem.getPoolId())
.setSlotId(queueItem.getSlotId())
.setOpUserId(loginUser != null ? loginUser.getUserId() : null)
.setAction("COMPLETE")
.setCreateTime(LocalDateTime.now())
.setUpdateAt(LocalDateTime.now())
.setCreatedAt(LocalDateTime.now());
divLogService.save(divLog);
} catch (Exception e) {
log.error("写入div_log审计日志失败", e);
// 审计日志失败不影响主流程

View File

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

View File

@@ -55,6 +55,7 @@ import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.*;
import java.util.stream.Collectors;
@@ -67,6 +68,10 @@ import java.util.stream.Collectors;
@Service
public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
private static final List<DateTimeFormatter> EXECUTE_TIME_FORMATTERS = List.of(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd H:mm:ss"));
@Resource
AssignSeqUtil assignSeqUtil;
@@ -669,11 +674,7 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
for (AdviceExecuteDetailParam adviceExecuteDetailParam : adviceExecuteDetailList) {
for (String executeTime : adviceExecuteDetailParam.getExecuteTimes()) {
// 生成执行记录
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 转换为 LocalDateTime
LocalDateTime localDateTime = LocalDateTime.parse(executeTime, formatter);
// 转换为 Date
Date exeDate = Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
Date exeDate = parseExecuteTime(executeTime);
// 根据执行记录新增不执行记录
procedureService.addProcedureRecord(adviceExecuteDetailParam.getEncounterId(),
adviceExecuteDetailParam.getPatientId(), adviceExecuteDetailParam.getRequestId(),
@@ -701,16 +702,14 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
List<Long> medicationDefinitionIdList
= medUseExeList.stream().map(MedicationRequestUseExe::getMedicationId).collect(Collectors.toList());
// 医嘱详细信息
Long orgId = SecurityUtils.getLoginUser().getOrgId();
List<AdviceBaseDto> medicationInfos = doctorStationAdviceAppService.getAdviceBaseInfo(null, null, null,
medicationDefinitionIdList, 0L, 1, 500, Whether.NO.getValue(), List.of(1), null, null).getRecords();
medicationDefinitionIdList, orgId, 1, 500, Whether.NO.getValue(), List.of(1), null, null).getRecords();
// 当前时间
Date curDate = new Date();
// 参与者id
Long practitionerId = SecurityUtils.getLoginUser().getPractitionerId();
// 当前登录账号的科室id
Long orgId = SecurityUtils.getLoginUser().getOrgId();
// 长期
MedicationRequest longMedicationRequest;
ChargeItem chargeItem;
@@ -737,11 +736,7 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
String minUnitCode = advice.getMinUnitCode();
// 生成执行记录
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 转换为 LocalDateTime
LocalDateTime localDateTime = LocalDateTime.parse(executeTime, formatter);
// 转换为 Date
Date expectedDate = Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
Date expectedDate = parseExecuteTime(executeTime);
// 执行记录id
Long procedureId = procedureService.addProcedureRecord(longMedicationRequest.getEncounterId(),
longMedicationRequest.getPatientId(), longMedicationRequest.getId(),
@@ -809,14 +804,24 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
chargeItemService.saveOrUpdate(chargeItem);
} else {
// 批次售卖情况
throw new RuntimeException("[住院]批次售卖的情况暂未处理");
/* // 需要的药品数量(小单位)
// 需要的药品数量(小单位)
BigDecimal minUnitQuantity = medicationRequestUseExe.getMinUnitQuantity();
if (minUnitQuantity == null || minUnitQuantity.compareTo(BigDecimal.ZERO) <= 0) {
throw new RuntimeException("药品执行数量异常medicationId: "
+ finalLongMedicationRequest.getMedicationId());
}
// 库存集合
List<AdviceInventoryDto> inventoryList = advice.getInventoryList();
if (inventoryList == null || inventoryList.isEmpty()) {
throw new RuntimeException("药品库存不足medicationId: "
+ finalLongMedicationRequest.getMedicationId());
}
// 价格集合
List<AdvicePriceDto> priceList = advice.getPriceList();
if (priceList == null || priceList.isEmpty()) {
throw new RuntimeException("未找到药品匹配的定价信息: "
+ finalLongMedicationRequest.getMedicationId());
}
// 剩余需要分配的数量
BigDecimal remainingQuantity = minUnitQuantity;
@@ -840,48 +845,52 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
}
// 批次号
String lotNumber = inventory.getLotNumber();
if (lotNumber == null) {
continue;
}
// 根据批次库存量,生成发放
longMedicationRequest.setQuantity(actualQuantity);// 小单位的数量
longMedicationRequest.setUnitCode(minUnitCode); // 小单位
longMedicationRequest.setLotNumber(lotNumber);
MedicationRequest batchMedicationRequest = new MedicationRequest();
BeanUtils.copyProperties(longMedicationRequest, batchMedicationRequest);
batchMedicationRequest.setQuantity(actualQuantity);// 小单位的数量
batchMedicationRequest.setUnitCode(minUnitCode); // 小单位
batchMedicationRequest.setLotNumber(lotNumber);
// 生成药品发放
Long dispenseId = medicationDispenseService.generateMedicationDispense(longMedicationRequest,
procedureId, exeDate);
medicationDispenseService.generateMedicationDispense(batchMedicationRequest,
procedureId, expectedDate);
// 生成账单
chargeItem = new ChargeItem();
chargeItem.setStatusEnum(ChargeItemStatus.BILLABLE.getValue()); // 收费状态
chargeItem.setBusNo(
AssignSeqEnum.CHARGE_ITEM_NO.getPrefix().concat(longMedicationRequest.getBusNo()));
AssignSeqEnum.CHARGE_ITEM_NO.getPrefix().concat(batchMedicationRequest.getBusNo()));
chargeItem.setGenerateSourceEnum(GenerateSource.ORDER_EXECUTE.getValue()); // 生成来源
chargeItem.setPrescriptionNo(longMedicationRequest.getPrescriptionNo()); // 处方号
chargeItem.setPatientId(longMedicationRequest.getPatientId()); // 患者
chargeItem.setPrescriptionNo(batchMedicationRequest.getPrescriptionNo()); // 处方号
chargeItem.setPatientId(batchMedicationRequest.getPatientId()); // 患者
chargeItem.setContextEnum(ChargeItemContext.MEDICATION.getValue()); // 类型
chargeItem.setEncounterId(longMedicationRequest.getEncounterId()); // 就诊id
chargeItem.setEncounterId(batchMedicationRequest.getEncounterId()); // 就诊id
chargeItem.setEntererId(practitionerId);// 开立人ID
chargeItem.setRequestingOrgId(orgId); // 开立科室
chargeItem.setEnteredDate(curDate); // 开立时间
chargeItem.setServiceTable(CommonConstants.TableName.MED_MEDICATION_REQUEST);// 医疗服务类型
chargeItem.setServiceId(longMedicationRequest.getId()); // 医疗服务ID
chargeItem.setServiceId(batchMedicationRequest.getId()); // 医疗服务ID
chargeItem.setProductTable(CommonConstants.TableName.MED_MEDICATION_DEFINITION);// 产品所在表
chargeItem.setProductId(longMedicationRequest.getMedicationId());// 收费项id
chargeItem.setProductId(batchMedicationRequest.getMedicationId());// 收费项id
chargeItem.setAccountId(medicationRequestUseExe.getAccountId());// 关联账户ID
chargeItem.setConditionId(longMedicationRequest.getConditionId()); // 诊断id
chargeItem.setEncounterDiagnosisId(longMedicationRequest.getEncounterDiagnosisId()); // 就诊诊断id
chargeItem.setConditionId(batchMedicationRequest.getConditionId()); // 诊断id
chargeItem.setEncounterDiagnosisId(batchMedicationRequest.getEncounterDiagnosisId()); // 就诊诊断id
chargeItem.setProcedureId(procedureId); // 执行id
chargeItem.setDispenseTable(CommonConstants.TableName.MED_MEDICATION_DISPENSE); // 发放表名
// chargeItem.setDispenseId(dispenseId); // 发放ID
// ------------------------------ 匹配定价信息
// 在 priceList 中查找匹配的定价信息
Optional<AdvicePriceDto> matchedPrice =
priceList.stream().filter(p -> lotNumber.equals(p.getConditionValue())).findFirst();
if (matchedPrice.isEmpty()) {
AdvicePriceDto priceDto = matchedPrice.orElseGet(() -> priceList.stream().findFirst().orElse(null));
if (priceDto == null) {
throw new RuntimeException(
"未找到匹配的定价信息lotNumber: " + finalLongMedicationRequest.getLotNumber());
"未找到匹配的定价信息lotNumber: " + lotNumber);
}
AdvicePriceDto priceDto = matchedPrice.get();
// 单价(大单位)
BigDecimal price = priceDto.getPrice();
// 单价(小单位)
@@ -919,7 +928,11 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
chargeItemService.saveOrUpdate(chargeItem);
}*/
}
if (remainingQuantity.compareTo(BigDecimal.ZERO) > 0) {
throw new RuntimeException("药品库存不足medicationId: "
+ finalLongMedicationRequest.getMedicationId());
}
}
}
}
@@ -932,11 +945,7 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
BeanUtils.copyProperties(medicationRequestUseExe, tempMedicationRequest);
// 生成执行记录
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 转换为 LocalDateTime
LocalDateTime localDateTime = LocalDateTime.parse(executeTime, formatter);
// 转换为 Date
Date expectedDate = Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
Date expectedDate = parseExecuteTime(executeTime);
// 执行记录id
Long procedureId = procedureService.addProcedureRecord(tempMedicationRequest.getEncounterId(),
tempMedicationRequest.getPatientId(), tempMedicationRequest.getId(),
@@ -978,16 +987,14 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
List<Long> activityDefinitionIdList
= actUseExeList.stream().map(ServiceRequestUseExe::getActivityId).collect(Collectors.toList());
// 医嘱详细信息
Long orgId = SecurityUtils.getLoginUser().getOrgId();
List<AdviceBaseDto> activityInfos = doctorStationAdviceAppService.getAdviceBaseInfo(null, null, null,
activityDefinitionIdList, 0L, 1, 500, Whether.NO.getValue(), List.of(3), null, null).getRecords();
activityDefinitionIdList, orgId, 1, 500, Whether.NO.getValue(), List.of(3), null, null).getRecords();
// 当前时间
Date curDate = new Date();
// 参与者id
Long practitionerId = SecurityUtils.getLoginUser().getPractitionerId();
// 当前登录账号的科室id
Long orgId = SecurityUtils.getLoginUser().getOrgId();
Date clickDate = new Date();
// 长期
@@ -1002,11 +1009,7 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
BeanUtils.copyProperties(serviceRequestUseExe, longServiceRequest);
// 生成执行记录
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 转换为 LocalDateTime
LocalDateTime localDateTime = LocalDateTime.parse(executeTime, formatter);
// 转换为 Date
Date expectedDate = Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
Date expectedDate = parseExecuteTime(executeTime);
// 执行记录id
Long procedureId = procedureService.addProcedureRecord(longServiceRequest.getEncounterId(),
longServiceRequest.getPatientId(), longServiceRequest.getId(),
@@ -1077,11 +1080,7 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
BeanUtils.copyProperties(serviceRequestUseExe, tempServiceRequest);
// 生成执行记录
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 转换为 LocalDateTime
LocalDateTime localDateTime = LocalDateTime.parse(executeTime, formatter);
// 转换为 Date
Date expectedDate = Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
Date expectedDate = parseExecuteTime(executeTime);
// 执行记录id
Long procedureId = procedureService.addProcedureRecord(tempServiceRequest.getEncounterId(),
tempServiceRequest.getPatientId(), tempServiceRequest.getId(),
@@ -1146,7 +1145,8 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
// 耗材医嘱详细信息
List<AdviceBaseDto> deviceInfos = doctorStationAdviceAppService
.getAdviceBaseInfo(null, null, null, deviceIds, 0L, 1, 500, Whether.NO.getValue(), List.of(2), null, null)
.getAdviceBaseInfo(null, null, null, deviceIds, orgId, 1, 500, Whether.NO.getValue(), List.of(2),
null, null)
.getRecords();
DeviceRequest deviceRequest;
@@ -1405,6 +1405,18 @@ public class AdviceProcessAppServiceImpl implements IAdviceProcessAppService {
return actUseExeList;
}
private Date parseExecuteTime(String executeTime) {
for (DateTimeFormatter formatter : EXECUTE_TIME_FORMATTERS) {
try {
LocalDateTime localDateTime = LocalDateTime.parse(executeTime, formatter);
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
} catch (DateTimeParseException ignored) {
// try next formatter
}
}
throw new RuntimeException("无法解析执行时间: " + executeTime);
}
/**
* 生成退药单
*

View File

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

View File

@@ -77,7 +77,6 @@ import com.openhis.yb.service.IClinicSettleService;
import com.openhis.yb.service.IInpatientSettleService;
import com.openhis.yb.service.IRegService;
import com.openhis.yb.service.YbManager;
import com.openhis.web.triageandqueuemanage.appservice.impl.TriageQueueAppServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.poi.util.StringUtil;
@@ -2150,7 +2149,7 @@ public class PaymentRecServiceImpl implements IPaymentRecService {
.setRoomNo(null)
.setPoolId(queuePoolId)
.setSlotId(queueSlotId)
.setStatus(TriageQueueAppServiceImpl.STATUS_WAITING) // 0=WAITING(等待中)
.setStatus(TriageQueueStatus.WAITING.getValue()) // 使用枚举替代硬编码常量,保持状态值一致
.setQueueOrder(maxOrder + 1)
.setDeleteFlag("0")
.setCreateTime(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS))

View File

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

View File

@@ -104,4 +104,12 @@ public interface IInfectiousCardAppService {
R<?> revokeAudit(String cardNo, String status);
R<?> getDeptTree();
/**
* 查询报卡审核记录(留痕追溯)
*
* @param cardNo 报卡编号
* @return 审核记录列表
*/
R<?> getAuditRecords(String cardNo);
}

View File

@@ -4,20 +4,25 @@ import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.core.common.core.domain.R;
import com.core.common.utils.SecurityUtils;
import com.openhis.administration.domain.Organization;
import com.openhis.administration.service.IOrganizationService;
import com.openhis.infectious.domain.InfectiousAudit;
import com.openhis.web.reportManagement.appservice.IInfectiousCardAppService;
import com.openhis.web.reportManagement.dto.InfectiousCardDto;
import com.openhis.web.reportManagement.dto.InfectiousCardParam;
import com.openhis.web.cardmanagement.mapper.InfectiousAuditMapper;
import com.openhis.web.reportManagement.mapper.ReportManageCardMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
import java.time.LocalDateTime;
/**
* 传染病报卡 AppService 实现
@@ -35,6 +40,41 @@ public class InfectiousCardAppServiceImpl implements IInfectiousCardAppService {
@Autowired
private IOrganizationService organizationService;
@Autowired
private InfectiousAuditMapper infectiousAuditMapper;
private void createAuditRecord(String cardNo, Integer statusFrom, Integer statusTo, String auditType,
String auditOpinion, String reasonForReturn, boolean isBatch, Integer batchSize) {
String safeCardNo = cardNo == null ? null : cardNo.trim();
Integer nextSeq = infectiousAuditMapper.getNextAuditSeq(safeCardNo);
InfectiousAudit audit = new InfectiousAudit();
// infectious_audit.card_id 设计上应存报卡 card_no字符串
audit.setCardId(safeCardNo);
audit.setAuditSeq(nextSeq);
audit.setAuditType(auditType);
audit.setAuditStatusFrom(statusFrom == null ? null : String.valueOf(statusFrom));
audit.setAuditStatusTo(statusTo == null ? null : String.valueOf(statusTo));
audit.setAuditTime(LocalDateTime.now());
audit.setAuditorId(SecurityUtils.getUserId() != null ? SecurityUtils.getUserId().toString() : null);
audit.setAuditorName(SecurityUtils.getUsername());
audit.setAuditOpinion(auditOpinion);
audit.setReasonForReturn(reasonForReturn);
audit.setIsBatch(isBatch);
audit.setBatchSize(batchSize);
// 通用审计字段(数据库字段建议为 create_time/update_time与 HisBaseEntity 保持一致)
audit.setCreateBy(SecurityUtils.getUsernameSafe());
audit.setUpdateBy(SecurityUtils.getUsernameSafe());
audit.setCreateTime(new java.util.Date());
audit.setUpdateTime(new java.util.Date());
audit.setTenantId(SecurityUtils.getLoginUser().getTenantId());
audit.setDeleteFlag("0");
int inserted = infectiousAuditMapper.insert(audit);
if (inserted <= 0) {
throw new RuntimeException("写入审核记录失败");
}
}
/**
* 分页查询传染病报卡列表
* @param param 查询参数
@@ -106,15 +146,21 @@ public class InfectiousCardAppServiceImpl implements IInfectiousCardAppService {
* @return 审核结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> audit(String cardNo, String auditOpinion, String status) {
try {
InfectiousCardDto dto = reportManageCardMapper.selectCardByCardNo(cardNo);
String safeCardNo = cardNo == null ? null : cardNo.trim();
InfectiousCardDto dto = reportManageCardMapper.selectCardByCardNo(safeCardNo);
if (dto == null) {
return R.fail("报卡不存在");
}
int rows = reportManageCardMapper.auditCard(cardNo, Integer.parseInt(status));
Integer fromStatus = dto.getStatus();
Integer toStatus = Integer.parseInt(status);
int rows = reportManageCardMapper.auditCard(safeCardNo, Integer.parseInt(status));
if (rows > 0) {
// 单审核通过auditType=2
createAuditRecord(safeCardNo, fromStatus, toStatus, "2", auditOpinion, null, false, 1);
return R.ok("审核成功");
} else {
return R.fail("审核失败:未更新任何记录");
@@ -133,15 +179,21 @@ public class InfectiousCardAppServiceImpl implements IInfectiousCardAppService {
* @return 退回结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> returnCard(String cardNo, String returnReason, String status) {
try {
InfectiousCardDto dto = reportManageCardMapper.selectCardByCardNo(cardNo);
String safeCardNo = cardNo == null ? null : cardNo.trim();
InfectiousCardDto dto = reportManageCardMapper.selectCardByCardNo(safeCardNo);
if (dto == null) {
return R.fail("报卡不存在");
}
int rows = reportManageCardMapper.returnCard(cardNo, Integer.parseInt(status), returnReason);
Integer fromStatus = dto.getStatus();
Integer toStatus = Integer.parseInt(status);
int rows = reportManageCardMapper.returnCard(safeCardNo, Integer.parseInt(status), returnReason);
if (rows > 0) {
// 单退回auditType=4
createAuditRecord(safeCardNo, fromStatus, toStatus, "4", null, returnReason, false, 1);
return R.ok("退回成功");
} else {
return R.fail("退回失败:未更新任何记录");
@@ -160,14 +212,27 @@ public class InfectiousCardAppServiceImpl implements IInfectiousCardAppService {
* @return 批量审核结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> batchAudit(List<String> cardNos, String auditOpinion, String status) {
try {
int successCount = 0;
int failCount = 0;
for (String cardNo : cardNos) {
try {
InfectiousCardDto dto = reportManageCardMapper.selectCardByCardNo(cardNo);
int rows = reportManageCardMapper.auditCard(cardNo, Integer.parseInt(status));
if (rows > 0) {
// 批量审核auditType=1
createAuditRecord(
cardNo,
dto != null ? dto.getStatus() : null,
Integer.parseInt(status),
"1",
auditOpinion,
null,
true,
cardNos.size()
);
successCount++;
} else {
failCount++;
@@ -192,14 +257,27 @@ public class InfectiousCardAppServiceImpl implements IInfectiousCardAppService {
* @return 批量退回结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> batchReturn(List<String> cardNos, String returnReason, String status) {
try {
int successCount = 0;
int failCount = 0;
for (String cardNo : cardNos) {
try {
InfectiousCardDto dto = reportManageCardMapper.selectCardByCardNo(cardNo);
int rows = reportManageCardMapper.returnCard(cardNo, Integer.parseInt(status), returnReason);
if (rows > 0) {
// 批量退回auditType=3
createAuditRecord(
cardNo,
dto != null ? dto.getStatus() : null,
Integer.parseInt(status),
"3",
null,
returnReason,
true,
cardNos.size()
);
successCount++;
} else {
failCount++;
@@ -284,20 +362,28 @@ public class InfectiousCardAppServiceImpl implements IInfectiousCardAppService {
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> revokeAudit(String cardNo, String status) {
try {
// 验证参数
if (cardNo == null || cardNo.trim().isEmpty()) {
return R.fail("报卡编号不能为空");
}
String safeCardNo = cardNo.trim();
if (status == null || status.trim().isEmpty()) {
return R.fail("撤销后的状态不能为空");
}
InfectiousCardDto dto = reportManageCardMapper.selectCardByCardNo(safeCardNo);
Integer fromStatus = dto != null ? dto.getStatus() : null;
Integer toStatus = Integer.parseInt(status);
// 执行撤销审核操作
int rows = reportManageCardMapper.revokeAuditCard(cardNo, Integer.parseInt(status));
int rows = reportManageCardMapper.revokeAuditCard(safeCardNo, Integer.parseInt(status));
if (rows > 0) {
// 撤销审核auditType=5(其他)
createAuditRecord(safeCardNo, fromStatus, toStatus, "5", "撤销审核", null, false, 1);
return R.ok("撤销审核成功");
} else {
return R.fail("撤销审核失败:未找到对应的报卡");
@@ -411,6 +497,20 @@ public class InfectiousCardAppServiceImpl implements IInfectiousCardAppService {
}
}
@Override
public R<?> getAuditRecords(String cardNo) {
try {
if (cardNo == null || cardNo.trim().isEmpty()) {
return R.fail("报卡编号不能为空");
}
List<InfectiousAudit> records = infectiousAuditMapper.selectByCardId(cardNo);
return R.ok(records);
} catch (Exception e) {
log.error("查询审核记录失败, cardNo={}", cardNo, e);
return R.fail("查询审核记录失败:" + e.getMessage());
}
}
/**
* 构建树形结构
*/

View File

@@ -55,6 +55,14 @@ public class reportManagementController {
return infectiousCardAppService.getByCardNo(cardNo);
}
/**
* 查询报卡审核记录(留痕追溯)
*/
@GetMapping("/auditRecords/{cardNo}")
public R<?> getAuditRecords(@PathVariable String cardNo) {
return infectiousCardAppService.getAuditRecords(cardNo);
}
/**
* 审核传染病报卡
*/

View File

@@ -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 {

View File

@@ -3,49 +3,67 @@ package com.openhis.web.triageandqueuemanage.appservice.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.core.common.core.domain.R;
import com.core.common.utils.SecurityUtils;
import com.openhis.administration.domain.Encounter;
import com.openhis.administration.mapper.EncounterMapper;
import com.openhis.common.enums.EncounterStatus;
import com.openhis.common.enums.EncounterSubjectStatus;
import com.openhis.common.enums.TriageQueueStatus;
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 {
/**
* 分诊队列状态常量(数字编码)
* 0=WAITING(等待中), 10=CALLING(呼叫中), 20=IN_CLINIC(诊中),
* 30=COMPLETED(已完成), 40=SKIPPED(已跳过), 50=REFUNDED(已退费), 60=FOLLOW(已随访)
*/
public static final Integer STATUS_WAITING = 0;
public static final Integer STATUS_CALLING = 10;
public static final Integer STATUS_IN_CLINIC = 20;
public static final Integer STATUS_COMPLETED = 30;
public static final Integer STATUS_SKIPPED = 40;
public static final Integer STATUS_REFUNDED = 50;
public static final Integer STATUS_FOLLOW = 60;
// 状态常量已迁移至 TriageQueueStatus 枚举,原硬编码 STATU_WAITING/STATU_CALLING 等已删除,
// 避免散落在多个 Service 类中的魔法数字造成不一致
@Resource
private TriageQueueItemService triageQueueItemService;
@Resource
private CallNumberSseManager callNumberSseManager;
@Resource
private TriageCandidateExclusionService triageCandidateExclusionService;
@Resource
private DivLogService divLogService;
@Resource
private CallRecordService callRecordService;
@Resource
private ScheduleSlotMapper scheduleSlotMapper;
@Resource
private EncounterMapper encounterMapper;
@Override
public R<?> list(Long organizationId, LocalDate date) {
Integer tenantId = SecurityUtils.getLoginUser().getTenantId();
@@ -56,7 +74,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
.eq(TriageQueueItem::getTenantId, tenantId)
.eq(TriageQueueItem::getQueueDate, qd)
.eq(TriageQueueItem::getDeleteFlag, "0")
.ne(TriageQueueItem::getStatus, STATUS_COMPLETED)
.ne(TriageQueueItem::getStatus, TriageQueueStatus.COMPLETED.getValue())
.orderByAsc(TriageQueueItem::getQueueOrder);
// 如果指定了科室,按科室过滤;否则查询所有科室(全科模式)
@@ -65,31 +83,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()))
.filter(item -> !TriageQueueStatus.COMPLETED.getValue().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) {
@@ -109,7 +129,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
.eq(TriageQueueItem::getOrganizationId, orgId)
.eq(TriageQueueItem::getQueueDate, qd)
.eq(TriageQueueItem::getDeleteFlag, "0")
.ne(TriageQueueItem::getStatus, STATUS_COMPLETED));
.ne(TriageQueueItem::getStatus, TriageQueueStatus.COMPLETED.getValue()));
int maxOrder = existing.stream().map(TriageQueueItem::getQueueOrder).filter(Objects::nonNull).max(Integer::compareTo).orElse(0);
@@ -133,14 +153,16 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
.setRoomNo(it.getRoomNo()) // ✅ 新增字段(可选)
.setPoolId(it.getPoolId()) // ✅ 号源池ID用于div_log审计
.setSlotId(it.getSlotId()) // ✅ 号源槽位ID用于div_log审计
.setStatus(STATUS_WAITING)
.setSeqNo(it.getSeqNo()) // ✅ 预约序号(用于叫号显示)
.setStatus(TriageQueueStatus.WAITING.getValue())
.setQueueOrder(++maxOrder)
.setDeleteFlag("0")
.setCreateTime(LocalDateTime.now())
.setUpdateTime(LocalDateTime.now());
triageQueueItemService.save(qi);
// 写入分诊日志
writeDivLog(it.getPoolId(), it.getSlotId(), "ADD_QUEUE");
// 记录到候选池排除列表(避免刷新后重新出现在候选池)
TriageCandidateExclusion exclusion = triageCandidateExclusionService.getOne(
new LambdaQueryWrapper<TriageCandidateExclusion>()
@@ -171,17 +193,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 +270,12 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
return R.ok(true);
}
/**
* 智能分诊选呼功能,在进行选呼操作时同时写入叫号记录和选呼操作日志
*
* @param req
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> call(TriageQueueActionReq req) {
@@ -247,15 +284,17 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
if (selected == null) return R.fail("队列项不存在");
// 只将"等待"状态的患者转为"叫号中",允许有多个"叫号中"的患者
if (STATUS_WAITING.equals(selected.getStatus())) {
selected.setStatus(STATUS_CALLING).setUpdateTime(LocalDateTime.now());
if (TriageQueueStatus.WAITING.getValue().equals(selected.getStatus())) {
selected.setStatus(TriageQueueStatus.CALLING.getValue()).setUpdateTime(LocalDateTime.now());
triageQueueItemService.updateById(selected);
// 叫号后推送 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())) {
} else if (TriageQueueStatus.CALLING.getValue().equals(selected.getStatus())) {
// 如果已经是"叫号中"状态,直接返回成功(不做任何操作)
return R.ok(true);
} else {
@@ -264,6 +303,13 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
}
}
/**
* 智能分诊完成操作
* 在进行完成操作后同时写入叫号记录和完成操作日志
*
* @param req
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> complete(TriageQueueActionReq req) {
@@ -277,18 +323,17 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
return R.fail("队列项不存在");
}
// 验证状态
if (!STATUS_CALLING.equals(calling.getStatus())) {
if (!TriageQueueStatus.CALLING.getValue().equals(calling.getStatus())) {
return R.fail("只能完成\"叫号中\"状态的患者,当前患者状态为:" + calling.getStatus());
}
} 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)
.eq(TriageQueueItem::getDeleteFlag, "0")
.eq(TriageQueueItem::getStatus, STATUS_CALLING)
.eq(TriageQueueItem::getStatus, TriageQueueStatus.CALLING.getValue())
.orderByAsc(TriageQueueItem::getQueueOrder)
.last("LIMIT 1");
@@ -297,8 +342,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("当前没有叫号中的患者");
@@ -309,16 +352,33 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
Long actualOrgId = calling.getOrganizationId();
// 1) 叫号中 -> 完成(移出列表)
calling.setStatus(STATUS_COMPLETED).setUpdateTime(LocalDateTime.now());
calling.setStatus(TriageQueueStatus.COMPLETED.getValue()).setUpdateTime(LocalDateTime.now());
triageQueueItemService.updateById(calling);
// 同步更新就诊状态为诊毕
// 分诊台完诊原只更新队列状态未同步encounter表导致与医生站完诊行为不一致
if (calling.getEncounterId() != null) {
try {
java.util.Date now = new java.util.Date();
encounterMapper.update(null,
new LambdaUpdateWrapper<Encounter>()
.eq(Encounter::getId, calling.getEncounterId())
.set(Encounter::getStatusEnum, EncounterStatus.DISCHARGED.getValue())
.set(Encounter::getSubjectStatusEnum, EncounterSubjectStatus.DEPARTED.getValue())
.set(Encounter::getEndTime, now)
.set(Encounter::getUpdateTime, now));
} catch (Exception e) {
log.error("更新就诊状态为诊毕失败, encounterId={}", calling.getEncounterId(), e);
}
}
// 2) 自动推进下一个等待为叫号中(同一科室,包含跳过状态,不限制日期)
LambdaQueryWrapper<TriageQueueItem> nextWrapper = new LambdaQueryWrapper<TriageQueueItem>()
.eq(TriageQueueItem::getTenantId, tenantId)
.eq(TriageQueueItem::getDeleteFlag, "0")
.and(w -> w.eq(TriageQueueItem::getStatus, STATUS_WAITING)
.and(w -> w.eq(TriageQueueItem::getStatus, TriageQueueStatus.WAITING.getValue())
.or()
.eq(TriageQueueItem::getStatus, STATUS_SKIPPED))
.eq(TriageQueueItem::getStatus, TriageQueueStatus.SKIPPED.getValue()))
.orderByAsc(TriageQueueItem::getQueueOrder)
.last("LIMIT 1");
@@ -328,11 +388,9 @@ 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());
next.setStatus(TriageQueueStatus.CALLING.getValue()).setUpdateTime(LocalDateTime.now());
triageQueueItemService.updateById(next);
}
@@ -340,16 +398,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());
@@ -357,8 +444,8 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
return R.fail("队列项不存在");
}
// 验证状态
if (!STATUS_CALLING.equals(calling.getStatus())) {
return R.fail("只能对\"叫号中\"状态的患者进行过号重排,当前患者状态为:" + calling.getStatus());
if (!TriageQueueStatus.CALLING.getValue().equals(calling.getStatus())) {
return R.fail("只能对\"叫号中\"状态的患者进行" + ("SKIP".equals(action) ? "跳过" : "过号重排") + ",当前患者状态为:" + calling.getStatus());
}
} else {
// 如果没有提供 id通过查询条件查找兼容旧逻辑
@@ -367,7 +454,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
LambdaQueryWrapper<TriageQueueItem> callingWrapper = new LambdaQueryWrapper<TriageQueueItem>()
.eq(TriageQueueItem::getTenantId, tenantId)
.eq(TriageQueueItem::getDeleteFlag, "0")
.eq(TriageQueueItem::getStatus, STATUS_CALLING)
.eq(TriageQueueItem::getStatus, TriageQueueStatus.CALLING.getValue())
.orderByAsc(TriageQueueItem::getQueueOrder)
.last("LIMIT 1");
@@ -379,36 +466,27 @@ 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")
.and(w -> w.eq(TriageQueueItem::getStatus, STATUS_WAITING)
.and(w -> w.eq(TriageQueueItem::getStatus, TriageQueueStatus.WAITING.getValue())
.or()
.eq(TriageQueueItem::getStatus, STATUS_SKIPPED))
.eq(TriageQueueItem::getStatus, TriageQueueStatus.SKIPPED.getValue()))
.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("当前没有等待中的患者");
}
@@ -417,7 +495,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
.eq(TriageQueueItem::getTenantId, tenantId)
.eq(TriageQueueItem::getOrganizationId, actualOrgId)
.eq(TriageQueueItem::getDeleteFlag, "0")
.ne(TriageQueueItem::getStatus, STATUS_COMPLETED))
.ne(TriageQueueItem::getStatus, TriageQueueStatus.COMPLETED.getValue()))
.stream()
.map(TriageQueueItem::getQueueOrder)
.filter(Objects::nonNull)
@@ -425,35 +503,28 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
.orElse(0);
// 1) 叫号中 -> 跳过,并移到末尾
calling.setStatus(STATUS_SKIPPED).setQueueOrder(maxOrder + 1).setUpdateTime(LocalDateTime.now());
calling.setStatus(TriageQueueStatus.SKIPPED.getValue()).setQueueOrder(maxOrder + 1).setUpdateTime(LocalDateTime.now());
triageQueueItemService.updateById(calling);
// 2) 自动推进下一个等待为叫号中
next.setStatus(STATUS_CALLING).setUpdateTime(LocalDateTime.now());
next.setStatus(TriageQueueStatus.CALLING.getValue()).setUpdateTime(LocalDateTime.now());
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) {
@@ -462,7 +533,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
return R.fail("队列项不存在");
}
// 验证状态:必须是"叫号中"状态
if (!STATUS_CALLING.equals(calling.getStatus())) {
if (!TriageQueueStatus.CALLING.getValue().equals(calling.getStatus())) {
return R.fail("只能对\"叫号中\"状态的患者执行\"下一患者\"操作,当前患者状态为:" + calling.getStatus());
}
} else {
@@ -472,7 +543,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
LambdaQueryWrapper<TriageQueueItem> callingWrapper = new LambdaQueryWrapper<TriageQueueItem>()
.eq(TriageQueueItem::getTenantId, tenantId)
.eq(TriageQueueItem::getDeleteFlag, "0")
.eq(TriageQueueItem::getStatus, STATUS_CALLING)
.eq(TriageQueueItem::getStatus, TriageQueueStatus.CALLING.getValue())
.orderByAsc(TriageQueueItem::getQueueOrder)
.last("LIMIT 1");
@@ -487,7 +558,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
// 当前叫号中 -> 完成(如果不存在,就当作从头找第一位等待)
if (calling != null) {
calling.setStatus(STATUS_COMPLETED).setUpdateTime(LocalDateTime.now());
calling.setStatus(TriageQueueStatus.COMPLETED.getValue()).setUpdateTime(LocalDateTime.now());
triageQueueItemService.updateById(calling);
actualOrgId = calling.getOrganizationId(); // 使用叫号中患者所在的科室
} else {
@@ -502,9 +573,9 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
LambdaQueryWrapper<TriageQueueItem> nextWrapper = new LambdaQueryWrapper<TriageQueueItem>()
.eq(TriageQueueItem::getTenantId, tenantId)
.eq(TriageQueueItem::getDeleteFlag, "0")
.and(w -> w.eq(TriageQueueItem::getStatus, STATUS_WAITING)
.and(w -> w.eq(TriageQueueItem::getStatus, TriageQueueStatus.WAITING.getValue())
.or()
.eq(TriageQueueItem::getStatus, STATUS_SKIPPED))
.eq(TriageQueueItem::getStatus, TriageQueueStatus.SKIPPED.getValue()))
.orderByAsc(TriageQueueItem::getQueueOrder)
.last("LIMIT 1");
@@ -513,14 +584,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) {
@@ -529,12 +592,19 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
return R.fail("当前没有等待的患者");
}
next.setStatus(STATUS_CALLING).setUpdateTime(LocalDateTime.now());
next.setStatus(TriageQueueStatus.CALLING.getValue()).setUpdateTime(LocalDateTime.now());
triageQueueItemService.updateById(next);
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);
}
@@ -544,7 +614,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
.eq(TriageQueueItem::getTenantId, tenantId)
.eq(TriageQueueItem::getOrganizationId, orgId)
.eq(TriageQueueItem::getDeleteFlag, "0")
.ne(TriageQueueItem::getStatus, STATUS_COMPLETED);
.ne(TriageQueueItem::getStatus, TriageQueueStatus.COMPLETED.getValue());
// 如果 qd 不为 null才添加日期限制
if (qd != null) {
wrapper.eq(TriageQueueItem::getQueueDate, qd);
@@ -559,7 +629,7 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
.eq(TriageQueueItem::getOrganizationId, orgId)
.eq(TriageQueueItem::getQueueDate, qd)
.eq(TriageQueueItem::getDeleteFlag, "0")
.eq(TriageQueueItem::getStatus, STATUS_CALLING)
.eq(TriageQueueItem::getStatus, TriageQueueStatus.CALLING.getValue())
.last("LIMIT 1"), false);
}
@@ -604,11 +674,14 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
.eq(TriageQueueItem::getQueueDate, qd)
.eq(TriageQueueItem::getOrganizationId, organizationId)
.eq(TriageQueueItem::getTenantId, tenantId)
.in(TriageQueueItem::getStatus, STATUS_WAITING, STATUS_CALLING)
.in(TriageQueueItem::getStatus, TriageQueueStatus.WAITING.getValue(), TriageQueueStatus.CALLING.getValue())
.eq(TriageQueueItem::getDeleteFlag, "0")
.orderByAsc(TriageQueueItem::getQueueOrder)
);
// 通过 slotId 批量查询 seqNo方案 B不修改 triage_queue_item 表结构)
Map<Long, Integer> slotSeqNoMap = buildSlotSeqNoMap(allItems);
CallNumberDisplayResp resp = new CallNumberDisplayResp();
// 1. 获取科室名称(从第一条数据中取)
@@ -620,13 +693,14 @@ public class TriageQueueAppServiceImpl implements TriageQueueAppService {
// 2. 查找当前叫号中的患者CALLING 状态)
TriageQueueItem callingItem = allItems.stream()
.filter(item -> STATUS_CALLING.equals(item.getStatus()))
.filter(item -> TriageQueueStatus.CALLING.getValue().equals(item.getStatus()))
.findFirst()
.orElse(null);
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,11 +754,11 @@ 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 状态)
if (STATUS_WAITING.equals(item.getStatus())) {
if (TriageQueueStatus.WAITING.getValue().equals(item.getStatus())) {
totalWaiting++;
}
}
@@ -748,6 +822,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();
}
}

View File

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

View File

@@ -3,7 +3,7 @@ core:
# 名称
name: HEALTHLINK-HIS
# 版本
version: 0.0.1
version: ${CORE_VERSION:0.0.1}
# 版权年份
copyrightYear: 2025
# 文件路径

View File

@@ -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,

View File

@@ -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

View File

@@ -331,11 +331,15 @@ 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')}
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').replace('encounter_id', 's.encounter_id')/* 补充encounter_id替换修复多表关联时字段歧义 */}
]]>
</if>
</where>

View File

@@ -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

View File

@@ -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>

View File

@@ -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=")">

View File

@@ -42,13 +42,13 @@
togpd.quantity,
togpd.unit_code,
sdd.dict_label AS unit_code_name,
togpd.dose,
togpd.rate_code,
togpd.method_code,
togpd.dose_quantity,
togpd.dose AS dose,
togpd.rate_code AS rate_code,
togpd.method_code AS method_code,
togpd.dose_quantity AS dose_quantity,
togpd.group_id,
togpd.dispense_per_duration,
togpd.therapy_enum,
togpd.dispense_per_duration AS dispense_per_duration,
togpd.therapy_enum AS therapy_enum,
CASE
WHEN togpd.order_definition_table = 'med_medication_definition' THEN
med.NAME

View File

@@ -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'

View File

@@ -150,6 +150,7 @@
WHERE t1.delete_flag = '0' AND t1.card_no = #{cardNo}
</select>
<!-- 审核传染病报卡 -->
<update id="auditCard">
UPDATE infectious_card

View File

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

View File

@@ -0,0 +1,35 @@
package com.openhis.common.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 智能分诊队列状态
*/
@Getter
@AllArgsConstructor
public enum TriageQueueStatus implements HisEnumInterface {
WAITING(0, "waiting", "等待"),
CALLING(10, "calling", "叫号中"),
IN_CLINIC(20, "in-clinic", "就诊中"),
COMPLETED(30, "completed", "完成"),
SKIPPED(40, "skipped", "跳过");
@EnumValue
private final Integer value;
private final String code;
private final String info;
public static TriageQueueStatus getByValue(Integer value) {
if (value == null) {
return null;
}
for (TriageQueueStatus val : values()) {
if (val.getValue().equals(value)) {
return val;
}
}
return null;
}
}

View File

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -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>

View File

@@ -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

View File

@@ -21,3 +21,8 @@ selenium-debug.log
package-lock.json
yarn.lock
# Playwright test results
test-results/
tests/e2e/report/
tests/tests/

View 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"],
},
},
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,11 @@
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
"test:ui": "vitest --ui",
"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",
@@ -67,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,4 +90,4 @@
"vitest": "^4.0.18",
"vue-tsc": "^3.1.8"
}
}
}

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

View File

@@ -20,7 +20,7 @@ export function advicePrint(data) {
}
// 获取全部科室列表
// 获取科室列表(树形结构
// 获取科室列表(别名
export function getDepartmentList(data) {
return request({
url: '/app-common/department-list',
@@ -69,11 +69,12 @@ export function getAdjustPriceSwitchState(params) {
/**
* 批次号匹配
*/
export function lotNumberMatch(params) {
export function lotNumberMatch(params, config = {}) {
return request({
url: '/app-common/lot-number-match',
method: 'get',
params: params,
...config
});
}

View File

@@ -0,0 +1,10 @@
import request from '@/utils/request'
export function getSystemVersion(options = {}) {
return request({
url: '/system/version',
method: 'get',
...options
})
}

View File

@@ -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',

View File

@@ -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 释放锁
});
}
});

View File

@@ -511,6 +511,90 @@
</template>
</el-drawer>
<InfectiousDiseaseReportDialog
ref="reportDialogRef"
:title="drawerMode === 'audit' ? '审核报卡' : '查看报卡'"
read-only
@close="handleDrawerClose"
>
<template #append>
<!-- 审核记录查看/审核都展示保证留痕可追溯 -->
<div
class="audit-records-section"
v-if="drawerMode === 'view' || (drawerMode === 'audit' && auditRecords.length > 0)"
>
<h3 class="section-title">审核记录</h3>
<el-timeline v-if="auditRecords.length > 0">
<el-timeline-item
v-for="(record, idx) in auditRecords"
:key="record.auditId || idx"
:timestamp="record.auditTime"
placement="top"
:type="getAuditType(record.auditStatusTo)"
>
<el-card>
<div class="record-content">
<div class="record-header">
<span class="auditor">{{ record.auditorName }}</span>
<el-tag size="small" :type="getAuditType(record.auditStatusTo)">
{{ getAuditTypeName(record.auditType) }}
</el-tag>
</div>
<div class="record-detail">
<div v-if="record.auditOpinion">审核意见{{ record.auditOpinion }}</div>
<div v-if="record.reasonForReturn">退回原因{{ record.reasonForReturn }}</div>
</div>
<div class="record-status">
{{ getStatusName(record.auditStatusFrom) }} {{ getStatusName(record.auditStatusTo) }}
</div>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无审核记录" />
</div>
<!-- 审核操作区域 -->
<div class="audit-action-section" v-if="drawerMode === 'audit'">
<h3 class="section-title">审核操作</h3>
<el-form :model="auditForm" label-width="100px">
<el-form-item label="审核意见" required>
<el-input
v-model="auditForm.auditOpinion"
type="textarea"
:rows="3"
placeholder="请填写审核意见"
/>
</el-form-item>
<el-form-item label="退回原因">
<el-input
v-model="auditForm.returnReason"
type="textarea"
:rows="3"
placeholder="如需退回,请填写退回原因"
maxlength="50"
show-word-limit
/>
</el-form-item>
</el-form>
</div>
</template>
<template #footer="{ close }">
<div class="drawer-footer">
<el-button @click="close">关闭</el-button>
<template v-if="drawerMode === 'audit'">
<el-button type="warning" @click="handleReturnCard" :disabled="!auditForm.returnReason">
退回修改
</el-button>
<el-button type="success" @click="handlePassCard" :disabled="!auditForm.auditOpinion">
审核通过
</el-button>
</template>
</div>
</template>
</InfectiousDiseaseReportDialog>
<!-- 批量审核弹窗 -->
<el-dialog
v-model="batchAuditDialogVisible"
@@ -568,9 +652,11 @@ import { ElMessage, ElMessageBox } from 'element-plus';
import { Clock, CircleClose, CircleCheck, Document, Search, Refresh, DocumentChecked, RefreshLeft, Download } from '@element-plus/icons-vue';
import { useDict } from '@/utils/dict';
import useUserStore from '@/store/modules/user';
import InfectiousDiseaseReportDialog from '@/views/doctorstation/components/diagnosis/infectiousDiseaseReportDialog.vue';
import {
listInfectiousCards,
getInfectiousCard,
getAuditRecords,
auditInfectiousCard,
returnInfectiousCard,
revokeAuditCard,
@@ -623,7 +709,10 @@ const selectedRows = ref([]);
const drawerVisible = ref(false);
const drawerMode = ref('view'); // view | audit
const drawerLoading = ref(false);
const reportDialogRef = ref(null);
const currentCard = ref({});
// 锁定当前打开的卡号,防止异步/串单导致写错审核记录
const activeCardNo = ref('');
const auditRecords = ref([]);
const auditForm = ref({
auditOpinion: '',
@@ -823,14 +912,12 @@ function handleSelectionChange(rows) {
// 审核
function handleAudit(row) {
drawerMode.value = 'audit';
drawerVisible.value = true;
loadCardDetail(row.cardNo);
}
// 查看
function handleView(row) {
drawerMode.value = 'view';
drawerVisible.value = true;
loadCardDetail(row.cardNo);
}
@@ -870,6 +957,8 @@ async function handleRevokeAudit(row) {
async function loadCardDetail(cardNo) {
drawerLoading.value = true;
try {
activeCardNo.value = String(cardNo || '').trim();
auditRecords.value = [];
// 使用详情 API 获取卡片详细信息
const res = await getInfectiousCard(cardNo);
if (res.code === 200 && res.data) {
@@ -882,8 +971,37 @@ async function loadCardDetail(cardNo) {
deptName: res.data.deptName || res.data.createDeptName,
diseaseName: res.data.diseaseName || getDiseaseName(res.data.diseaseCode),
};
// 如果没有单独的审核记录 API尝试从详情数据中获取
auditRecords.value = res.data.auditRecords || res.auditRecords || [];
const normalizeAuditRecords = (payload) => {
if (!payload) return [];
if (Array.isArray(payload)) return payload;
// 兼容data 里再包一层
if (Array.isArray(payload.data)) return payload.data;
// 兼容:分页/列表常见字段
if (Array.isArray(payload.records)) return payload.records;
if (Array.isArray(payload.rows)) return payload.rows;
if (Array.isArray(payload.list)) return payload.list;
return [];
};
// 优先使用审核记录 API确保能看到完整历史失败再回退到详情内嵌字段
try {
const recordRes = await getAuditRecords(String(cardNo || '').trim());
if (recordRes?.code === 200) {
auditRecords.value = normalizeAuditRecords(recordRes.data);
} else {
auditRecords.value = normalizeAuditRecords(res.data.auditRecords || res.auditRecords);
if (auditRecords.value.length === 0) {
ElMessage.warning(recordRes?.msg || '获取审核记录失败');
}
}
} catch (e) {
auditRecords.value = normalizeAuditRecords(res.data.auditRecords || res.auditRecords);
if (auditRecords.value.length === 0) {
ElMessage.warning('获取审核记录失败');
}
}
reportDialogRef.value?.showReport(currentCard.value);
} else {
ElMessage.error('获取卡片详情失败');
}
@@ -912,15 +1030,25 @@ async function handlePassCard() {
}
try {
const cardNo = String(activeCardNo.value || currentCard.value.cardNo || '').trim();
if (!cardNo) {
ElMessage.error('未获取到报卡编号,请重新打开后再试');
return;
}
// 防止串单:展示的 currentCard 与锁定卡号不一致时禁止提交
if (currentCard.value?.cardNo && String(currentCard.value.cardNo).trim() !== cardNo) {
ElMessage.error('报卡编号发生变化,请重新打开报卡后再审核');
return;
}
const res = await auditInfectiousCard({
cardNo: currentCard.value.cardNo,
cardNo,
auditOpinion: auditForm.value.auditOpinion,
status: '2'
});
if (res.code === 200) {
ElMessage.success('审核通过');
handleDrawerClose();
reportDialogRef.value?.close?.();
loadTableData();
loadStats();
} else {
@@ -946,15 +1074,24 @@ async function handleReturnCard() {
}
try {
const cardNo = String(activeCardNo.value || currentCard.value.cardNo || '').trim();
if (!cardNo) {
ElMessage.error('未获取到报卡编号,请重新打开后再试');
return;
}
if (currentCard.value?.cardNo && String(currentCard.value.cardNo).trim() !== cardNo) {
ElMessage.error('报卡编号发生变化,请重新打开报卡后再退回');
return;
}
const res = await returnInfectiousCard({
cardNo: currentCard.value.cardNo,
cardNo,
returnReason: auditForm.value.returnReason,
status: '5'
});
if (res.code === 200) {
ElMessage.success('已退回修改');
handleDrawerClose();
reportDialogRef.value?.close?.();
loadTableData();
loadStats();
} else {

View File

@@ -9,7 +9,7 @@
>
<template #header>
<el-card class="report-header" shadow="never">
<h1 class="report-title">中华人民共和国传染病报告卡</h1>
<h1 class="report-title">{{ title }}</h1>
<el-space align="center" class="card-number-row">
<span class="card-number-label">卡片编号</span>
<el-input
@@ -17,13 +17,14 @@
class="card-number-input"
placeholder="单位自编,与网络直报一致"
maxlength="12"
:disabled="readOnly"
/>
</el-space>
</el-card>
</template>
<el-card class="report-form" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="readOnly">
<!-- 患者姓名家长姓名身份证号 -->
<el-row :gutter="16" class="form-row">
<el-col :span="8" class="form-item">
@@ -503,14 +504,17 @@
</el-col>
</el-row>
</el-form>
<slot name="append" :form="form" />
</el-card>
<template #footer>
<el-space :size="16" justify="center" class="dialog-footer-space" style="display: flex; justify-content: center; width: 100%;">
<el-button type="primary" @click="handleSubmit" :loading="submitLoading" class="blue-button">保 存</el-button>
<el-button type="info" @click="handleClose">关 闭</el-button>
<el-button type="danger" @click="handleReset"> </el-button>
</el-space>
<slot name="footer" :close="handleClose" :submit-loading="submitLoading">
<el-space :size="16" justify="center" class="dialog-footer-space" style="display: flex; justify-content: center; width: 100%;">
<el-button v-if="!readOnly" type="primary" @click="handleSubmit" :loading="submitLoading" class="blue-button">保 存</el-button>
<el-button type="info" @click="handleClose">关 闭</el-button>
<el-button v-if="!readOnly" type="danger" @click="handleReset"> </el-button>
</el-space>
</slot>
</template>
</el-dialog>
</template>
@@ -545,6 +549,14 @@ const formRef = ref(null);
const submitLoading = ref(false);
const props = defineProps({
title: {
type: String,
default: '中华人民共和国传染病报告卡',
},
readOnly: {
type: Boolean,
default: false,
},
patientInfo: {
type: Object,
default: () => ({}),
@@ -1010,6 +1022,117 @@ function parseBirthDate(birthDate) {
};
}
function normalizeDate(value) {
if (!value) return '';
return String(value).split(/[T ]/)[0];
}
function normalizeSex(value) {
if (value === '1' || value === 1 || value === '男') return '男';
if (value === '2' || value === 2 || value === '女') return '女';
return '未知';
}
function normalizeAgeUnit(value) {
const ageUnitMap = {
1: '岁',
2: '月',
3: '天',
'1': '岁',
'2': '月',
'3': '天',
'岁': '岁',
'月': '月',
'天': '天',
};
return ageUnitMap[value] || '岁';
}
function getDiseaseSelection(diseaseCode) {
const code = diseaseCode ? String(diseaseCode) : '';
return {
selectedClassA: code.startsWith('01') ? code : '',
selectedClassB: code.startsWith('02') ? code : '',
selectedClassC: code.startsWith('03') ? code : '',
};
}
function resetAddressSelector() {
provinceCode.value = '';
cityCode.value = '';
countyCode.value = '';
townCode.value = '';
cityOptions.value = [];
countyOptions.value = [];
townOptions.value = [];
}
/**
* 以只读详情方式打开报卡弹窗,供报卡管理等页面复用医生站报卡样式。
* @param {Object} reportData - 报卡详情数据
*/
function showReport(reportData = {}) {
dialogVisible.value = true;
resetAddressSelector();
initProvinceOptions();
const birthInfo = parseBirthDate(reportData.birthday || reportData.birthDate);
const diseaseCode = reportData.diseaseCode ? String(reportData.diseaseCode) : '';
const diseaseSelection = getDiseaseSelection(diseaseCode);
form.value = {
cardNo: reportData.cardNo || '',
patName: reportData.patName || reportData.patientName || '',
parentName: reportData.parentName || '',
idNo: reportData.idNo || '',
sex: normalizeSex(reportData.sex),
birthYear: birthInfo.year,
birthMonth: birthInfo.month,
birthDay: birthInfo.day,
age: reportData.age != null ? String(reportData.age) : '',
ageUnit: normalizeAgeUnit(reportData.ageUnit),
workplace: reportData.workplace || '',
phone: reportData.phone || '',
contactPhone: reportData.contactPhone || '',
addressProv: reportData.addressProv || '',
addressCity: reportData.addressCity || '',
addressCounty: reportData.addressCounty || '',
addressTown: reportData.addressTown || '',
addressVillage: reportData.addressVillage || '',
addressHouse: reportData.addressHouse || '',
patientBelong: reportData.patientBelong || 1,
occupation: reportData.occupation || '',
caseClass: reportData.caseClass != null ? String(reportData.caseClass) : '',
onsetDate: normalizeDate(reportData.onsetDate),
diagDate: normalizeDate(reportData.diagDate),
deathDate: normalizeDate(reportData.deathDate),
selectedDiseases: diseaseCode && diseaseCode !== 'OTHER' ? [diseaseCode] : [],
selectedClassA: diseaseSelection.selectedClassA,
selectedClassB: diseaseSelection.selectedClassB,
selectedClassC: diseaseSelection.selectedClassC,
otherDisease: reportData.otherDisease || (diseaseCode === 'OTHER' ? reportData.diseaseName || '' : ''),
diseaseType: reportData.diseaseType || '',
reportOrg: reportData.reportOrg || '',
reportOrgPhone: reportData.reportOrgPhone || '',
reportDoc: reportData.reportDoc || '',
reportDate: normalizeDate(reportData.reportDate || reportData.createdAt),
correctName: reportData.correctName || '',
withdrawReason: reportData.withdrawReason || '',
remark: reportData.remark || '',
encounterId: reportData.encounterId || reportData.visitId || '',
patientId: reportData.patientId || reportData.patId || '',
diagnosisId: reportData.diagnosisId || reportData.diagId || '',
};
initAddressByName(
form.value.addressProv,
form.value.addressCity,
form.value.addressCounty,
form.value.addressTown
);
}
/**
* 从身份证号自动解析出生日期和性别
* @param {string} idNo - 身份证号码15位或18位
@@ -1118,13 +1241,7 @@ function show(diagnosisData) {
dialogVisible.value = true;
// 重置地址选择器状态
provinceCode.value = '';
cityCode.value = '';
countyCode.value = '';
townCode.value = '';
cityOptions.value = [];
countyOptions.value = [];
townOptions.value = [];
resetAddressSelector();
// 初始化省级地址选项
initProvinceOptions();
@@ -1502,7 +1619,7 @@ function handleClose() {
emit('close');
}
defineExpose({ show });
defineExpose({ show, showReport, close: handleClose });
</script>
<style scoped>

View File

@@ -301,12 +301,11 @@
<div v-if="filteredCategoryList.length === 0" class="empty-hint">
{{ dictLoading ? '' : '暂无检查项目请在"检查项目设置"中配置' }}
</div>
<el-collapse v-else v-model="activeNames">
<el-collapse v-else v-model="activeNames" @change="(activeNames) => handleCollapseChange(activeNames)">
<el-collapse-item
v-for="cat in filteredCategoryList"
:key="cat.typeId"
:name="cat.typeId"
@click="handleCategoryExpand(cat)"
>
<template #title>
<span class="cat-title">{{ cat.categoryName }}</span>
@@ -355,20 +354,23 @@
<el-icon><Close /></el-icon>
</el-button>
</div>
<!-- Bug #384修复: 展开后显示检查方法勾选框列表 -->
<div v-if="item.expanded && item.methods && item.methods.length > 0" class="method-list">
<div
v-for="method in item.methods"
:key="method.id"
class="method-option"
>
<el-checkbox
:model-value="item.selectedMethod?.id === method.id"
@change="(val) => selectMethodCheckbox(val, item, method)"
>
<span class="method-name">{{ method.name }}</span>
<span class="method-price">¥{{ method.packagePrice || item.price }}</span>
</el-checkbox>
<!-- Bug #428修复: 展开后显示套餐明细或检查方法 -->
<div v-if="item.expanded">
<!-- 显示套餐明细 -->
<div v-if="item.packageDetails && item.packageDetails.length > 0" class="package-details-list">
<div class="detail-row" v-for="detail in item.packageDetails" :key="detail.id">
<span class="detail-name">{{ detail.name }}</span>
<span class="detail-info">数量: {{ detail.quantity }} 单价: ¥{{ detail.price }}</span>
</div>
</div>
<!-- 显示检查方法 -->
<div v-else-if="item.methods && item.methods.length > 0" class="method-list">
<div v-for="method in item.methods" :key="method.id" class="method-option">
<el-checkbox :model-value="item.selectedMethod?.id === method.id" @change="(val) => selectMethodCheckbox(val, item, method)">
<span class="method-name">{{ method.name }}</span>
<span class="method-price">¥{{ method.packagePrice || item.price }}</span>
</el-checkbox>
</div>
</div>
</div>
</div>
@@ -433,6 +435,33 @@ async function loadPackageDetails(row, treeNode, resolve) {
resolve([]);
}
}
// #428: 为已选择项目加载套餐明细
async function loadPackageDetailsForItem(item) {
if (!item.isPackage || !item.packageId) {
return;
}
try {
const res = await request({
url: `/exam/package/${item.packageId}/details`,
method: 'get'
});
if (res.code === 200 && res.data) {
item.packageDetails = res.data.map(detail => ({
...detail,
name: detail.name || detail.itemName,
unit: detail.unit || '次',
price: detail.price || detail.itemPrice || 0,
quantity: detail.quantity || 1
}));
} else {
item.packageDetails = [];
}
} catch (err) {
console.error('加载套餐明细失败:', err);
item.packageDetails = [];
}
}
const detailTableRef = ref(null);
const formRef = ref(null);
@@ -623,6 +652,17 @@ async function handleCategoryExpand(cat) {
console.error('加载分类检查方法失败', err);
}
}
async function handleCollapseChange(activeNames) {
// 当折叠面板展开时,加载对应分类的检查方法
if (Array.isArray(activeNames) && activeNames.length > 0) {
for (const typeId of activeNames) {
const cat = filteredCategoryList.value.find(c => c.typeId == typeId);
if (cat && (!cat.methods || cat.methods.length === 0)) {
await handleCategoryExpand(cat);
}
}
}
}
watch(availableMethods, (newMethods) => {
if (form.inspectionMethod && !newMethods.find(m => m.name === form.inspectionMethod)) {
@@ -699,6 +739,7 @@ async function loadCategoryList() {
checkType: p.checkType || '',
nationalCode: p.nationalCode || '',
packageName: p.packageName || '',
packageId: p.packageId || null,
checked: false
};
@@ -1031,7 +1072,9 @@ async function handleItemSelect(checked, item, cat) {
checked: true,
methods: methods,
selectedMethod: null,
expanded: false // Bug #384修复: 新增展开状态,默认不展开
expanded: false, // Bug #384修复: 新增展开状态,默认不展开
isPackage: !!item.packageName, // Bug #428修复: 标记是否为套餐
packageId: item.packageId || null // Bug #428修复: 套餐ID
});
// 自动回填执行科室:按检查项目类型 → 检查类型管理里配置的执行科室
@@ -1060,8 +1103,12 @@ async function handleItemSelect(checked, item, cat) {
}
// Bug #384修复: 展开/收起项目卡片
function toggleItemExpand(item) {
async function toggleItemExpand(item) {
item.expanded = !item.expanded;
// 如果是展开且该项目是套餐,加载套餐明细
if (item.expanded && item.isPackage && (!item.packageDetails || item.packageDetails.length === 0)) {
await loadPackageDetailsForItem(item);
}
}
// Bug #384修复: 勾选框选择检查方法(单选逻辑)

View File

@@ -1845,7 +1845,7 @@ const handleCellClick = (row, column) => {
}
// 点击表格行时,将该申请单的数据加载到表单中
// 使用 applyNo 判断是否有效
if (row && row.applyNo) {
if (row && row.applyNo && !isDeleting.value) {
loadApplicationToForm(row);
}
}

View File

@@ -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) { // 西药

View File

@@ -371,7 +371,9 @@
<div style="display: flex; align-items: center; margin-bottom: 16px; gap: 16px">
<span style="font-size: 16px; font-weight: 600">
{{ row.adviceName }}
{{ row.unitPrice ? ' -' + Number(row.unitPrice).toFixed(2) + '元' : ' -元' }}
<template v-if="row.unitPrice != null && row.unitPrice !== ''">
{{ Number(row.unitPrice).toFixed(2) }}
</template>
</span>
<div class="form-group">
<el-form-item

View File

@@ -36,7 +36,7 @@
<el-dialog
v-model="applicationFormDialogVisible"
destroy-on-close
width="1000px"
width="1200px"
:close-on-click-modal="false"
:title="applicationFormTitle"
@close="closeDialog"

View File

@@ -111,20 +111,23 @@ const getList = () => {
}
loading.value = true;
getApplicationList({
pageSize: 10000,
pageSize: 500,
pageNum: 1,
categoryCode: '28',
organizationId: patientInfo.value.inHospitalOrgId,
adviceTypes: '3', //1 药品 2耗材 3诊疗
adviceTypes: [3], //1 药品 2耗材 3诊疗
})
.then((res) => {
if (res.code === 200) {
applicationListAll.value = res.data.records;
applicationList.value = res.data.records.map((item) => {
const priceInfo = item.priceList?.[0] || {};
const price = priceInfo.price != null ? Number(priceInfo.price).toFixed(2) : '0.00';
const unit = item.unitCode_dictText || item.unitCode || '';
return {
adviceDefinitionId: item.adviceDefinitionId,
orgId: item.orgId,
label: item.adviceName + item.adviceDefinitionId,
label: item.adviceName + ' (¥' + price + '/' + unit + ')',
key: item.adviceDefinitionId,
};
});
@@ -310,7 +313,7 @@ defineExpose({ state, submit, getLocationInfo, getDiagnosisList });
}
.el-transfer {
--el-transfer-panel-width: 400px !important;
--el-transfer-panel-width: 480px !important;
}
.bloodTransfusion-form {

View File

@@ -116,16 +116,19 @@ const getList = () => {
pageNum: 1,
categoryCode: '22',
organizationId: patientInfo.value.inHospitalOrgId,
adviceTypes: '3', //1 药品 2耗材 3诊疗
adviceTypes: [3], //1 药品 2耗材 3诊疗
})
.then((res) => {
if (res.code === 200) {
applicationListAll.value = res.data.records;
applicationList.value = res.data.records.map((item) => {
const priceInfo = item.priceList?.[0] || {};
const price = priceInfo.price != null ? Number(priceInfo.price).toFixed(2) : '0.00';
const unit = item.unitCode_dictText || item.unitCode || '';
return {
adviceDefinitionId: item.adviceDefinitionId,
orgId: item.orgId,
label: item.adviceName + item.adviceDefinitionId,
label: item.adviceName + ' (¥' + price + '/' + unit + ')',
key: item.adviceDefinitionId,
};
});
@@ -312,7 +315,7 @@ defineExpose({ state, submit, getLocationInfo, getDiagnosisList });
}
.el-transfer {
--el-transfer-panel-width: 400px !important;
--el-transfer-panel-width: 480px !important;
}
.bloodTransfusion-form {

View File

@@ -116,16 +116,19 @@ const getList = () => {
pageNum: 1,
categoryCode: '23',
organizationId: patientInfo.value.inHospitalOrgId,
adviceTypes: '3', //1 药品 2耗材 3诊疗
adviceTypes: [3], //1 药品 2耗材 3诊疗
})
.then((res) => {
if (res.code === 200) {
applicationListAll.value = res.data.records;
applicationList.value = res.data.records.map((item) => {
const priceInfo = item.priceList?.[0] || {};
const price = priceInfo.price != null ? Number(priceInfo.price).toFixed(2) : '0.00';
const unit = item.unitCode_dictText || item.unitCode || '';
return {
adviceDefinitionId: item.adviceDefinitionId,
orgId: item.orgId,
label: item.adviceName + item.adviceDefinitionId,
label: item.adviceName + ' (¥' + price + '/' + unit + ')',
key: item.adviceDefinitionId,
};
});
@@ -311,7 +314,7 @@ defineExpose({ state, submit, getLocationInfo, getDiagnosisList });
}
.el-transfer {
--el-transfer-panel-width: 400px !important;
--el-transfer-panel-width: 480px !important;
}
.bloodTransfusion-form {

View File

@@ -112,20 +112,23 @@ const getList = () => {
}
loading.value = true;
getApplicationList({
pageSize: 10000,
pageSize: 500,
pageNum: 1,
categoryCode: '24',
organizationId: patientInfo.value.inHospitalOrgId,
adviceTypes: '3', //1 药品 2耗材 3诊疗
adviceTypes: [3, 6], //1 药品 2耗材 3诊疗 6手术
})
.then((res) => {
if (res.code === 200) {
applicationListAll.value = res.data.records;
applicationList.value = res.data.records.map((item) => {
const priceInfo = item.priceList?.[0] || {};
const price = priceInfo.price != null ? Number(priceInfo.price).toFixed(2) : '0.00';
const unit = item.unitCode_dictText || item.unitCode || '';
return {
adviceDefinitionId: item.adviceDefinitionId,
orgId: item.orgId,
label: item.adviceName + item.adviceDefinitionId,
label: item.adviceName + ' (¥' + price + '/' + unit + ')',
key: item.adviceDefinitionId,
};
});
@@ -312,7 +315,7 @@ defineExpose({ state, submit, getLocationInfo, getDiagnosisList });
}
.el-transfer {
--el-transfer-panel-width: 400px !important;
--el-transfer-panel-width: 480px !important;
}
.bloodTransfusion-form {

View File

@@ -14,8 +14,8 @@
<el-date-picker
v-model="deadline"
type="datetime"
format="YYYY/MM/DD HH:mm:ss"
value-format="YYYY/MM/DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
:clearable="false"
@change="handleGetPrescription"
/>
@@ -33,8 +33,8 @@
<el-date-picker
v-model="exeDate"
type="datetime"
format="YYYY/MM/DD HH:mm:ss"
value-format="YYYY/MM/DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
:clearable="false"
@change="handleGetPrescription"
/>
@@ -254,6 +254,20 @@ function handleRadioChange() {
handleGetPrescription();
}
/** 频次时间规整为 HH:mm与 formatDateStr 得到的时分一致,避免 8:00 vs 08:00 对不上) */
function normalizeDayTimeHm(part) {
if (part == null || String(part).trim() === '') {
return '00:00';
}
const s = String(part).trim();
const [hRaw, mRaw = '0'] = s.split(':').map((x) => String(x).trim());
const hNum = Number.parseInt(hRaw || '0', 10);
const mNum = Number.parseInt(mRaw || '0', 10);
const h = Number.isNaN(hNum) ? '00' : String(hNum).padStart(2, '0');
const m = Number.isNaN(mNum) ? '00' : String(mNum).padStart(2, '0');
return `${h}:${m}`;
}
function handleGetPrescription() {
if (patientInfoList.value.length > 0) {
loading.value = true;
@@ -273,8 +287,12 @@ function handleGetPrescription() {
let rate;
let times;
if (prescription.therapyEnum == 1) {
// 长期医嘱 后台返回执行时间点字符串,直接拆分取时间点
rate = prescription.dayTimes?.split(',');
// 长期医嘱dayTimes 可能为「8:00,12:00」未补零需与后端 occurrenceTime 格式化后对齐
rate = prescription.dayTimes
?.split(',')
.map((x) => x.trim())
.filter((x) => x !== '')
.map((x) => normalizeDayTimeHm(x));
// 用截止时间和医嘱签发时间算出全部执行日期
times = getDateRange(prescription.requestTime, deadline.value);
} else {
@@ -390,6 +408,11 @@ function handleGetPrescription() {
}
});
}
// 「待执行」tab 下医嘱所有时间点都已执行/不执行时,跳过该医嘱
// (后端待执行查询未做该过滤,需要前端补充;其他 tab 后端已按记录列表过滤,不再处理)
if (props.exeStatus === 1 && (!prescription.times || prescription.times.length === 0)) {
return groups;
}
// 把相同encounterId的医嘱放在同一个数组中
const encounterId = prescription.encounterId;
if (!groups[encounterId]) {
@@ -435,8 +458,13 @@ function handleExecute() {
console.log(list, 'list');
adviceExecute({ exeDate: exeDate.value, adviceExecuteDetailList: list }).then((res) => {
if (res.code == 200) {
proxy.$modal.msgSuccess(res.msg || '医嘱执行成功');
handleGetPrescription();
lotNumberMatch({ encounterIdList: encounterIds });
lotNumberMatch({ encounterIdList: encounterIds }, { skipErrorMsg: true }).catch((error) => {
console.warn('lotNumberMatch failed after adviceExecute:', error);
});
} else {
proxy.$modal.msgError(res.msg || '医嘱执行失败');
}
});
}
@@ -456,7 +484,10 @@ function handleNoExecute() {
console.log(list, 'list');
adviceNoExecute({ adviceExecuteDetailList: list }).then((res) => {
if (res.code == 200) {
proxy.$modal.msgSuccess(res.msg || '操作成功');
handleGetPrescription();
} else {
proxy.$modal.msgError(res.msg || '操作失败');
}
});
}
@@ -477,7 +508,10 @@ function handleCancel() {
});
adviceCancel({ adviceExecuteDetailList: producerIds }).then((res) => {
if (res.code == 200) {
proxy.$modal.msgSuccess(res.msg || '取消执行成功');
handleGetPrescription();
} else {
proxy.$modal.msgError(res.msg || '取消执行失败');
}
});
}
@@ -761,4 +795,4 @@ defineExpose({
font-size: 15px;
font-weight: 500;
}
</style>
</style>

View File

@@ -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() {

View File

@@ -1494,7 +1494,7 @@ function handleMedicalAdvice(row) {
const jsonContent = item.contentJson || item.content_json;
const contentData = jsonContent ? JSON.parse(jsonContent) : {};
return {
medicineName: contentData.adviceName || contentData.advice_name || item.adviceName || item.advice_name || '',
medicineName: contentData.adviceName || contentData.advice_name || item.adviceName || item.advice_name || item.chargeName || item.charge_name || contentData.itemName || contentData.item_name || '未知药品',
specification: contentData.volume || contentData.specification || item.volume || item.specification || '',
quantity: contentData.quantity || item.quantity || 0,
batchNumber: contentData.lotNumber || contentData.lot_number || item.lotNumber || item.lot_number || '',
@@ -1670,10 +1670,15 @@ function handleTemporaryMedicalSubmit(data) {
temporaryBillingMedicines.value = []
}
// 关闭弹窗
showTemporaryMedical.value = false
}
// 显示成功提示
ElMessage.success('临时医嘱已生成,弹窗即将关闭')
// 延迟关闭弹窗,让用户看到成功提示
setTimeout(() => {
showTemporaryMedical.value = false
}, 1000)
}
// 处理临时医嘱取消
function handleTemporaryMedicalCancel() {
// 🔧 修复:用户点击取消时才清空数据,因为用户可能要放弃修改
@@ -1941,6 +1946,7 @@ function submitForm() {
// 新增手术安排
addSurgerySchedule(submitData).then((res) => {
proxy.$modal.msgSuccess('新增成功')
queryParams.pageNo = 1
open.value = false
getPageList()
}).catch(() => {

View File

@@ -37,7 +37,8 @@ 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(待诊),已挂号未接诊的患者
excludeFromCandidatePool: true // 显式传参过滤已入队患者,配合后端 opt-in 逻辑
},
skipErrorMsg: true // 跳过错误提示,由组件处理
})
@@ -169,4 +170,4 @@ export function getLocationTree(query) {
method: 'get',
params: query
})
}
}

View File

@@ -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"
>
<< 移出队列
&lt;&lt; 移出队列
</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="{&quot;age&quot;:&quot;>=60&quot;,&quot;regType&quot;:&quot;专家&quot;}"
/>
</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>
@@ -600,7 +840,8 @@ const syncCurrentCallFromQueue = () => {
return
}
currentCall.value = {
number: String(calling.queueOrder),
// 优先显示预约序号 seqNo让患者凭挂号单号识别无预约序号时回退到排队序号
number: String(calling.seqNo ?? calling.queueOrder),
name: calling.patientName,
room: calling.room
}
@@ -627,29 +868,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
}
}
// 从数据库加载队列
@@ -709,6 +961,8 @@ const loadQueueFromDb = async () => {
return {
id: it.id,
queueOrder: it.queueOrder,
// 预约序号优先于queueOrder用于叫号屏显示患者凭此识别
seqNo: it.seqNo,
patientName: it.patientName ?? '-',
// 队列数据已从入队时存储的 healthcareName 读取(包含"预约"或"挂号"后缀)
appointmentType: it.healthcareName ?? '普通号挂号',
@@ -1464,8 +1718,8 @@ const handleSelectCall = async () => {
const latest = originalQueueList.value.find((i) => i.id === selectedQueueRow.value.id)
if (latest) {
selectedQueueRow.value = latest
// 如果有多个"叫号中"的患者,显示当前选中的这个
currentCall.value = { number: String(latest.queueOrder), name: latest.patientName, room: latest.room }
// 如果有多个"叫号中"的患者,显示当前选中的这个seqNo优先于queueOrder
currentCall.value = { number: String(latest.seqNo ?? latest.queueOrder), name: latest.patientName, room: latest.room }
}
// 统计当前"叫号中"的患者数量

View 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';

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

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

View 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 });
}
}

View 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);
}
});
});

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

View 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.*/);
});
});

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

View 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 });
}
});
});

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

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

View File

@@ -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/。

21
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "jkhl",
"name": "his-repo",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -7,6 +7,9 @@
"dependencies": {
"axios": "^1.13.2",
"json-bigint": "^1.0.0"
},
"devDependencies": {
"husky": "^9.1.7"
}
},
"node_modules/asynckit": {
@@ -261,6 +264,22 @@
"node": ">= 0.4"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz",

View File

@@ -2,5 +2,11 @@
"dependencies": {
"axios": "^1.13.2",
"json-bigint": "^1.0.0"
},
"devDependencies": {
"husky": "^9.1.7"
},
"scripts": {
"prepare": "husky"
}
}
}

View File

@@ -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;

View File

@@ -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;