Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5874f17b9 | ||
|
|
9395a461a2 | ||
|
|
d613af11c2 | ||
|
|
e0b4dd0b14 | ||
|
|
3e4397edaf | ||
|
|
4211d8bcc2 | ||
|
|
27933c4798 | ||
|
|
7b413cc8b3 | ||
|
|
042ecefe25 | ||
|
|
03dc11ecbb | ||
|
|
f2311af4af | ||
|
|
c643a78bfa | ||
|
|
57e198a37b | ||
|
|
662e61d869 | ||
|
|
816594a9d1 | ||
|
|
abf82767ce | ||
|
|
870bb5dae3 | ||
|
|
17dc4a02da | ||
|
|
49b4015f68 | ||
|
|
37f2cad1cf | ||
|
|
8b1d979858 | ||
|
|
a20a5cf106 | ||
|
|
a352d658e9 | ||
|
|
77f6b78397 | ||
|
|
a99ecaeef8 | ||
|
|
7c604e198b | ||
|
|
be0cd400d9 | ||
|
|
51e9921807 | ||
|
|
c6771e4e05 | ||
|
|
16e2c87ca7 | ||
|
|
273579480a | ||
|
|
7e78960f67 | ||
|
|
7ef162f3ea | ||
|
|
4f461f7af4 | ||
|
|
37de28f4df | ||
|
|
4045644df9 | ||
|
|
2fa7ab5ea4 | ||
|
|
3e2e7bd317 | ||
|
|
1d1af282a8 | ||
|
|
76816e98ce | ||
| 506f0f756e | |||
| 150383dbcd | |||
|
|
4ffe22599f | ||
|
|
7bb231a927 | ||
|
|
755a61b573 | ||
|
|
91b3a0a4ba | ||
|
|
8a68fd9209 | ||
|
|
433cf82eb4 | ||
| 532cd17c10 | |||
|
|
b4167c9994 |
@@ -1,61 +0,0 @@
|
||||
# Bug #516 深度分析报告
|
||||
|
||||
## Bug 描述
|
||||
[住院医生站-临床医嘱-检验申请] 检验申请单手动填写的"发往科室"与生成的医嘱执行科室不一致
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 前端 Bug(`laboratoryTests.vue`)
|
||||
|
||||
`projectWithDepartment` 函数(第167行)声明了1个参数,但内部使用了未声明的变量 `type`:
|
||||
|
||||
```javascript
|
||||
const projectWithDepartment = (selectProjectIds) => { // 只有1个参数
|
||||
const manualDept = type === 2 ? form.targetDepartment : ''; // type 未声明!
|
||||
...
|
||||
if (type === 2 && manualDept) { // type 未声明!
|
||||
```
|
||||
|
||||
调用处传了第2个参数但函数不接收:
|
||||
- 第221行(watch监听):`projectWithDepartment(newValue, 1)`
|
||||
- 第228行(提交):`if (!projectWithDepartment(transferValue.value, 2))`
|
||||
|
||||
**后果**:
|
||||
1. `type` 始终为 `undefined`,`type === 2` 永远为 false
|
||||
2. `manualDept` 永远为空字符串
|
||||
3. 用户手动选择的"发往科室"在提交时被清空
|
||||
4. 即使 `findItem` 未找到配置的科室,也无法用手动选择兜底
|
||||
|
||||
### 后端 Bug(`RequestFormManageAppServiceImpl.java`)
|
||||
|
||||
第165-171行:
|
||||
|
||||
```java
|
||||
Long positionId = activityOrganizationConfig.stream()
|
||||
.filter(dto -> activitySaveDto.getAdviceDefinitionId().equals(dto.getActivityDefinitionId()))
|
||||
.map(ActivityOrganizationConfigDto::getOrganizationId).findFirst().orElse(null);
|
||||
if (positionId == null) {
|
||||
throw new ServiceException(activitySaveDto.getAdviceDefinitionName() + "未配置当前时间段的执行科室");
|
||||
}
|
||||
serviceRequest.setOrgId(positionId); // 完全忽略前端传的 positionId!
|
||||
```
|
||||
|
||||
后端从配置表 `adm_organization_location` 查找执行科室,完全无视前端传来的 `activitySaveDto.positionId`(即用户手动选择的"发往科室")。
|
||||
|
||||
### 数据流
|
||||
|
||||
1. 用户在前端选择检验项目 → 触发watch → `projectWithDepartment` 尝试自动设置科室
|
||||
2. 用户手动切换"发往科室"下拉框 → `form.targetDepartment` = 肝胆科ID
|
||||
3. 用户点击提交 → `projectWithDepartment(transferValue.value, 2)` 调用
|
||||
4. 因 `type` 未声明,手动选择的科室被清空 → `form.targetDepartment` = ''
|
||||
5. 前端构建提交参数:`positionId: item.positionId || form.targetDepartment` → 空值
|
||||
6. 后端收到请求,从配置表查默认科室(检验科) → `serviceRequest.setOrgId(检验科)`
|
||||
7. 医嘱列表中"药房/科室"列显示检验科,而非用户选择的肝胆科
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 前端修复(1行改动)
|
||||
在 `projectWithDepartment` 函数签名中添加 `type` 参数。
|
||||
|
||||
### 后端修复(3行改动)
|
||||
优先使用前端传来的 `positionId`,配置表作为兜底值。
|
||||
@@ -1,76 +0,0 @@
|
||||
# Bug #461 分析报告
|
||||
|
||||
## Bug 描述
|
||||
[系统管理-执行科室配置] 保存项目配置后,项目名称回显为ID码,未显示正确名称
|
||||
|
||||
## 阶段1:深度分析
|
||||
|
||||
### 数据流追踪
|
||||
|
||||
1. **前端保存**: 用户选择项目 → 点击"保存" → POST `/base-data-manage/org-loc/org-loc`
|
||||
2. **后端处理**: `OrganizationLocationAppServiceImpl.addOrEditOrgLoc()` 保存记录
|
||||
3. **前端刷新**: 保存成功后调用 `getList()` → GET `/base-data-manage/org-loc/org-loc`
|
||||
4. **后端查询**: `OrganizationLocationAppServiceImpl.getOrgLocPage()` 查询分页数据
|
||||
5. **前端渲染**: `el-select` 根据 `v-model` 值匹配 `filteredOptions` 中的 label 显示
|
||||
|
||||
### 根因定位
|
||||
|
||||
**根因:`DictAspect` 覆盖了控制器方法中手动设置的 `activityDefinitionId_dictText`**
|
||||
|
||||
执行顺序:
|
||||
```
|
||||
1. 控制器方法 getOrgLocPage() 执行
|
||||
→ HisPageUtils.selectPage() 返回分页数据(_dictText 为空)
|
||||
→ 手动代码遍历记录,用 activityDefinitionMapper.selectById() 查询并设置 _dictText ✓
|
||||
→ 返回 R.ok(orgLocQueryDtoPage)
|
||||
|
||||
2. DictAspect.aroundController() 拦截返回结果(@Around 后置处理)
|
||||
→ 检查到 Page<OrgLocQueryDto> 中有 @Dict 注解字段
|
||||
→ 对 activityDefinitionId 执行 SQL:SELECT name FROM wor_activity_definition WHERE id::varchar = ?
|
||||
→ 如果 SQL 执行失败(任何原因),返回空字符串 ""
|
||||
→ 覆盖 _dictText 为 "" ❌
|
||||
```
|
||||
|
||||
**关键问题**:
|
||||
- `DictAspect.queryDictLabel()` 在 SQL 查询失败时返回 `""`(空字符串),而不是 `null`
|
||||
- `processDict()` 中 `if (dictLabel != null)` 条件对空字符串为 `true`,导致空字符串被写入 `_dictText`
|
||||
- 前端 fallback 代码中 `record.activityDefinitionId_dictText || record.activityDefinitionId` 遇到空字符串时 fallback 到 ID
|
||||
|
||||
### DictAspect 中 SQL 可能失败的原因
|
||||
- PostgreSQL `search_path` 不包含表所在 schema(虽然 JDBC URL 有 `currentSchema=hisdev`,但特定连接池配置可能不一致)
|
||||
- `JdbcTemplate` 连接未正确继承 `currentSchema` 设置
|
||||
- 数据库连接状态异常
|
||||
|
||||
### 已有修复尝试(均未完全解决)
|
||||
|
||||
| 提交 | 作者 | 修复内容 | 问题 |
|
||||
|------|------|---------|------|
|
||||
| 6cd48d84 | 华佗 | 前端保存回调中确保选中项在 filteredOptions | 只解决了保存瞬间的显示,getList 刷新后仍回显ID |
|
||||
| 60814120 | 关羽 | 前端 getList 中将 dictText 补充到 filteredOptions | 依赖后端正确返回 dictText,但被 DictAspect 覆盖 |
|
||||
| be0cd400 | 关羽 | 后端手动填充 dictText | 手动设置的值被 DictAspect 后置处理覆盖为空字符串 |
|
||||
|
||||
### 修复方案
|
||||
|
||||
**方案A(推荐)**:修改 `DictAspect`,在 `_dictText` 字段已非空时跳过 SQL 查询,避免覆盖手动设置的有效值
|
||||
|
||||
优点:不影响其他使用 @Dict 注解的地方,只避免覆盖已填充的值
|
||||
改动范围:1个文件,约10行代码
|
||||
|
||||
**方案B**:修改 `DictAspect` 的 SQL 查询,在 dictLabel 为空字符串时不覆盖 _dictText
|
||||
|
||||
优点:修复了 DictAspect 的 bug 本身
|
||||
缺点:如果 DictAspect 的 SQL 在某些情况下应该返回空,则可能掩盖问题
|
||||
|
||||
采用方案A + 方案B 双重保护。
|
||||
|
||||
## 修复结果
|
||||
|
||||
✅ 成功,16行改动(+16/-2)
|
||||
|
||||
修改文件:`openhis-server-new/openhis-common/src/main/java/com/openhis/common/aspectj/DictAspect.java`
|
||||
|
||||
修复策略:
|
||||
1. DictAspect 在 SQL 查询前检查 `_dictText` 字段是否已被手动填充,若已有值则跳过查询
|
||||
2. 增加空字符串防护:`dictLabel` 为空字符串时不设置 `_dictText`
|
||||
|
||||
提交:79d67b1f
|
||||
@@ -1,94 +0,0 @@
|
||||
# 分析报告 — Bug #497
|
||||
|
||||
## Bug 描述
|
||||
【住院医生工作站-检查申请】检查申请列表缺失"申请单状态"列及全流程闭环状态流转逻辑
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 前端层面
|
||||
`examineApplication.vue` **已有**"申请单状态"列(第96-102行),包含:
|
||||
- 筛选下拉框(待签发→已出报告,8个状态)
|
||||
- 表格列展示(el-tag + parseStatus)
|
||||
- `parseStatus` 方法正确映射 0-7 到对应状态文本
|
||||
- `getStatusTagType` 正确映射颜色
|
||||
- 操作按钮按状态分支显示
|
||||
|
||||
**前端没有问题。**
|
||||
|
||||
### 后端层面 — SQL CASE 语句不完整
|
||||
|
||||
`RequestFormManageAppMapper.xml` 中的 `getRequestForm` 查询通过 CASE 语句计算 `computed_status`,从 `wor_service_request.status_enum` 推导 RequestForm 状态:
|
||||
|
||||
**当前 SQL 映射:**
|
||||
```sql
|
||||
CASE
|
||||
WHEN status_enum = 8 THEN 6 -- 已出报告 ✓
|
||||
WHEN status_enum = 3 THEN 5 -- 已检查 ✓
|
||||
WHEN status_enum = 2 THEN 1 -- 已签发 ✓
|
||||
WHEN status_enum = 5 THEN 7 -- 已作废 ✓
|
||||
ELSE 0 -- 待签发 ✓
|
||||
END
|
||||
```
|
||||
|
||||
**缺失的状态映射(CASE 语句中没有 WHEN 子句生成这些值):**
|
||||
- **computed_status = 2(已校对)**:无 WHEN 生成此值
|
||||
- **computed_status = 3(待接收)**:无 WHEN 生成此值
|
||||
- **computed_status = 4(已接收)**:无 WHEN 生成此值
|
||||
|
||||
### 深层原因 — RequestStatus 枚举缺少中间状态值
|
||||
|
||||
`RequestStatus` 枚举当前只有:
|
||||
- DRAFT(1), ACTIVE(2), COMPLETED(3), ON_HOLD(4), CANCELLED(5), STOPPED(6), ENDED(7), COMPLETED_REPORT(8)
|
||||
|
||||
缺少三甲医院住院节点所需的中间状态:
|
||||
- **已校对**(护士校对通过)
|
||||
- **待接收**(医技科室可接单)
|
||||
- **已接收**(医技科室已接单)
|
||||
|
||||
护士校对时调用 `updateCompleteRequestStatus()` 将 status_enum 设为 3 (COMPLETED),SQL 直接将其映射为 5 (已检查),跳过了"已校对"→"待接收"→"已接收"→"已检查"的中间环节。
|
||||
|
||||
### 数据流总结
|
||||
|
||||
| 节点 | ServiceRequest.status_enum | SQL 当前映射 | RequestForm.status | 正确? |
|
||||
|------|---------------------------|-------------|-------------------|--------|
|
||||
| 医生录入 | 1 (DRAFT) | ELSE 0 | 0 待签发 | ✓ |
|
||||
| 医生签发 | 2 (ACTIVE) | WHEN 2 THEN 1 | 1 已签发 | ✓ |
|
||||
| 护士校对 | 3 (COMPLETED) | WHEN 3 THEN 5 | 5 已检查 | ✗ 跳过中间状态 |
|
||||
| 医技接收 | 无对应枚举值 | 无 | 无 | ✗ 缺失 |
|
||||
| 医技检查 | 3 (COMPLETED) | WHEN 3 THEN 5 | 5 已检查 | ✓ 但语义不对 |
|
||||
| 出报告 | 8 (COMPLETED_REPORT) | WHEN 8 THEN 6 | 6 已出报告 | ✓ |
|
||||
| 作废 | 5 (CANCELLED) | WHEN 5 THEN 7 | 7 已作废 | ✓ |
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 1. RequestStatus 枚举新增三个值
|
||||
- `PROOFREAD(10, "proofread", "已校对")`
|
||||
- `PENDING_RECEIVE(11, "pending_receive", "待接收")`
|
||||
- `RECEIVED(12, "received", "已接收")`
|
||||
|
||||
使用 10+ 值避免与现有 1-9 冲突。
|
||||
|
||||
### 2. 修改 SQL CASE 语句
|
||||
```sql
|
||||
CASE
|
||||
WHEN status_enum = 8 THEN 6 -- 已出报告
|
||||
WHEN status_enum = 12 THEN 4 -- 已接收(新增)
|
||||
WHEN status_enum = 11 THEN 3 -- 待接收(新增)
|
||||
WHEN status_enum = 10 THEN 2 -- 已校对(新增)
|
||||
WHEN status_enum = 3 THEN 5 -- 已检查(保留向后兼容旧数据)
|
||||
WHEN status_enum = 2 THEN 1 -- 已签发
|
||||
WHEN status_enum = 5 THEN 7 -- 已作废
|
||||
ELSE 0 -- 待签发
|
||||
END
|
||||
```
|
||||
|
||||
### 3. 修改护士校对方法
|
||||
`ServiceRequestServiceImpl.updateCompleteRequestStatus()` 中 status_enum 从 3 (COMPLETED) 改为 10 (PROOFREAD)。
|
||||
|
||||
### 4. 医技接收后状态流转
|
||||
医技接收时将 status_enum 从 10 (PROOFREAD) 更新为 11 (PENDING_RECEIVE)→12 (RECEIVED),检查后更新为 3 (COMPLETED)。
|
||||
|
||||
## 涉及文件
|
||||
1. `openhis-server-new/openhis-common/src/main/java/com/openhis/common/enums/RequestStatus.java` — 枚举新增
|
||||
2. `openhis-server-new/openhis-application/src/main/resources/mapper/regdoctorstation/RequestFormManageAppMapper.xml` — SQL CASE 修复
|
||||
3. `openhis-server-new/openhis-domain/src/main/java/com/openhis/workflow/service/impl/ServiceRequestServiceImpl.java` — 校对方法修改
|
||||
@@ -1,138 +0,0 @@
|
||||
# Bug #445 分析报告
|
||||
|
||||
## Bug 描述
|
||||
在"门诊手术临时医嘱"界面,生成医嘱成功后,已生成的计费项目仍然保留在"一、已引用计费药品(待生成医嘱)"列表中,导致上下两个列表数据完全一致,用户无法区分哪些已处理、哪些未处理。
|
||||
|
||||
## 根因定位
|
||||
|
||||
### 核心问题:`handleTemporaryMedicalSubmit` 中过滤逻辑匹配字段路径错误
|
||||
|
||||
**文件**: `openhis-ui-vue3/src/views/surgicalschedule/index.vue`
|
||||
**行号**: 第 1791-1793 行
|
||||
|
||||
```js
|
||||
// 第 1776-1788 行:构建已提交项目的匹配键(从 originalMedicine 中取字段)
|
||||
const submittedKeys = new Set(
|
||||
(data.temporaryAdvices || [])
|
||||
.map(a => {
|
||||
const om = a.originalMedicine || {}
|
||||
const name = om.medicineName || om.adviceName || om.advice_name || a.adviceName || ''
|
||||
const spec = om.specification || om.volume || ''
|
||||
const qty = om.quantity || 0
|
||||
return `${name}|||${spec}|||${qty}`
|
||||
})
|
||||
.filter(k => k !== '|||0')
|
||||
)
|
||||
|
||||
// 第 1791-1794 行:过滤待生成列表(错误:直接从顶层取字段)
|
||||
temporaryBillingMedicines.value = (temporaryBillingMedicines.value || []).filter(m => {
|
||||
const key = `${m.medicineName || ''}|||${m.specification || ''}|||${m.quantity || 0}` // ❌ BUG: 字段路径错误
|
||||
return !submittedKeys.has(key)
|
||||
})
|
||||
```
|
||||
|
||||
### 为什么匹配不上?
|
||||
|
||||
`temporaryBillingMedicines` 中的数据来自 `handleMedicalAdvice`(第 1605-1651 行),转换后的对象结构为:
|
||||
|
||||
```js
|
||||
{
|
||||
medicineName: 'xxx', // 顶层有(从原始 item 映射来的)
|
||||
specification: 'xxx', // 顶层有
|
||||
quantity: xxx, // 顶层有
|
||||
originalMedicine: { // 嵌套也有
|
||||
medicineName: 'xxx', // ...spread 复制了所有字段
|
||||
specification: 'xxx',
|
||||
quantity: xxx,
|
||||
encounterId: xxx
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
但问题在于 **`handleQuoteBilling`**(第 1878-1916 行)刷新数据时的结构不同:
|
||||
|
||||
```js
|
||||
// handleQuoteBilling 中的数据映射(第 1878-1897 行)
|
||||
{
|
||||
medicineName: 'xxx', // 顶层有
|
||||
specification: 'xxx', // 顶层有
|
||||
quantity: xxx, // 顶层有
|
||||
// ❌ 没有 originalMedicine 嵌套!
|
||||
}
|
||||
```
|
||||
|
||||
等等,让我再仔细看...实际上 `handleMedicalAdvice` 第一次加载时,数据是有顶层字段的(第 1611-1627 行直接映射了 `medicineName`、`specification`、`quantity` 到顶层)。所以匹配键应该能对上。
|
||||
|
||||
让我重新审视...
|
||||
|
||||
### 重新分析:真正的问题
|
||||
|
||||
再看 `handleMedicalAdvice` 第 1560-1562 行:
|
||||
|
||||
```js
|
||||
// 先清空旧数据
|
||||
temporaryBillingMedicines.value = []
|
||||
temporaryAdvices.value = []
|
||||
```
|
||||
|
||||
**这是问题的关键!** 当用户第二次打开同一个手术记录的医嘱界面时:
|
||||
|
||||
1. `isSameEncounter` 检查(第 1543-1556 行):
|
||||
- `temporaryAdvices.value.length > 0` → 此时已被清空为 0,所以 `isSameEncounter = false`
|
||||
- 不会走 early return
|
||||
|
||||
2. 第 1560-1562 行清空了 `temporaryAdvices`
|
||||
3. 调用 `getPrescriptionList` 从后端拉取最新数据
|
||||
4. 第 1587-1588 行过滤:`if (item.requestId) return false;`
|
||||
- **后端新创建的医嘱记录,返回的数据中 `requestId` 可能为空/null**
|
||||
- 因为这些记录刚被创建,后端的计费数据表(adm_charge_item)可能还没同步 requestId
|
||||
|
||||
5. 结果:已生成的医嘱项目因为 `requestId` 为空,没有被过滤掉,重新出现在"待生成"列表中
|
||||
|
||||
### 另一个问题:提交后的本地过滤也不可靠
|
||||
|
||||
`handleTemporaryMedicalSubmit`(第 1791 行)的本地过滤:
|
||||
```js
|
||||
const key = `${m.medicineName || ''}|||${m.specification || ''}|||${m.quantity || 0}`
|
||||
```
|
||||
|
||||
这里的 `m` 是 `temporaryBillingMedicines` 中的对象。在 `handleMedicalAdvice` 首次加载时(第 1605-1651 行),确实映射了顶层字段,所以这个匹配**应该能工作**。
|
||||
|
||||
但问题在于:提交成功后,如果用户点击了"刷新"按钮或"引用计费"按钮:
|
||||
- `handleQuoteBilling` 从后端重新拉取数据
|
||||
- 后端返回的数据中,新生成的医嘱项 `requestId` 仍然可能为空
|
||||
- 虽然 `handleQuoteBilling` 有第 1977-1999 行的过滤逻辑,但它依赖于 `temporaryAdvices` 中已有的 `requestId`/`chargeItemId`/`id`
|
||||
- 如果这些 ID 在后端返回的新数据中不存在,就匹配不上
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 方案:在 `handleMedicalAdvice` 中,提交后再次打开时保留 `temporaryAdvices` 数据
|
||||
|
||||
核心修复点:**不要在打开医嘱时清空 `temporaryAdvices`**,而是复用已提交的数据,避免从后端重复拉取已生成的项目。
|
||||
|
||||
具体修改 `handleMedicalAdvice` 函数:
|
||||
|
||||
1. 将清空数据的逻辑移到 `isSameEncounter` 检查**之后**,并且只在非同一 encounter 时才清空
|
||||
2. 或者,在从后端拉取数据后,用已提交的 `temporaryAdvices` 中的 requestId 来过滤后端返回的数据
|
||||
|
||||
最简洁的修复:在 `handleMedicalAdvice` 第 1559-1562 行,**不要无条件清空 `temporaryAdvices`**。改为:
|
||||
|
||||
```js
|
||||
// 修复前:
|
||||
temporaryBillingMedicines.value = []
|
||||
temporaryAdvices.value = []
|
||||
|
||||
// 修复后:
|
||||
temporaryBillingMedicines.value = []
|
||||
// 不清空 temporaryAdvices,保留已提交的医嘱数据
|
||||
// 但需要清空未提交的自动转换数据(避免重复)
|
||||
const submittedAdvices = temporaryAdvices.value.filter(a => a.originalMedicine?.requestId)
|
||||
temporaryAdvices.value = submittedAdvices
|
||||
```
|
||||
|
||||
同时,在 `getPrescriptionList` 回调中(第 1571 行之后),用已提交的 requestId 过滤后端返回的数据。
|
||||
|
||||
## 总结
|
||||
|
||||
- **根因**:`handleMedicalAdvice` 每次打开都清空 `temporaryAdvices`,然后从后端重新拉取数据。但后端返回的新创建医嘱项可能没有 `requestId`,导致无法过滤。
|
||||
- **修复**:保留已提交(有 requestId)的医嘱数据,不清空;同时用这些 requestId 过滤后端返回的新数据。
|
||||
@@ -1,33 +0,0 @@
|
||||
## Bug #470: 住院医生工作站-手术申请单加载手术项目耗时过长
|
||||
|
||||
### 根因分析
|
||||
|
||||
点击"手术"按钮后,前端调用 `GET /doctor-station/advice/surgery-page` 加载手术项目列表。
|
||||
|
||||
**性能瓶颈链路**:
|
||||
1. 后端 `DoctorStationAdviceAppServiceImpl.getSurgeryPage()` 使用 MyBatis Plus 分页查询
|
||||
2. MyBatis Plus `PaginationInnerInterceptor` 会**先执行一次 COUNT 查询**获取 total,再执行数据查询
|
||||
3. COUNT 查询需要扫描 `wor_activity_definition` 全表 10,102 条手术项目记录(~4ms)
|
||||
4. 数据查询(LIMIT 100)仅需 0.3ms,但 COUNT 查询是主要开销来源
|
||||
5. 虽然 Redis 缓存已配置(24小时过期),但首次调用/缓存失效时仍需执行完整查询
|
||||
|
||||
**关键问题**:前端 el-transfer 组件**不需要精确的 total 总数**(无分页控件),MyBatis Plus 的 COUNT 查询完全是多余开销。
|
||||
|
||||
### 修复方案
|
||||
|
||||
将手术项目查询从 MyBatis Plus 分页模式改为直接 LIMIT/OFFSET 查询:
|
||||
|
||||
1. **Mapper 接口**:`getSurgeryPage()` 返回值从 `IPage<SurgeryItemDto>` 改为 `List<SurgeryItemDto>`
|
||||
2. **XML SQL**:添加 `LIMIT #{page.size} OFFSET ${(page.current - 1) * page.size}`
|
||||
3. **Service 层**:手动构造 `Page` 对象,`total` 设为 `records.size()`(前端 el-transfer 只用作显示)
|
||||
4. **Controller 层**:无需修改,仍返回 `R.ok(IPage)`
|
||||
|
||||
**效果**:消除了 COUNT 查询开销,首次加载从 ~5ms 降至 ~0.3ms(数据库层面),叠加 Redis 缓存后后续调用几乎瞬时。
|
||||
|
||||
### 修改文件
|
||||
|
||||
- `openhis-application/src/main/java/com/openhis/web/doctorstation/mapper/DoctorStationAdviceAppMapper.java`
|
||||
- `openhis-application/src/main/resources/mapper/doctorstation/DoctorStationAdviceAppMapper.xml`
|
||||
- `openhis-application/src/main/java/com/openhis/web/doctorstation/appservice/impl/DoctorStationAdviceAppServiceImpl.java`
|
||||
|
||||
修复结果:✅ 成功,~15行改动
|
||||
@@ -2458,7 +2458,6 @@ public class DoctorStationAdviceAppServiceImpl implements IDoctorStationAdviceAp
|
||||
|
||||
/**
|
||||
* 手术项目专用分页查询(仅手术 + 定价,无库存/草稿库存/取药科室等无关逻辑)
|
||||
* 使用直接 LIMIT 查询替代 MyBatis Plus 分页,避免 COUNT 全表扫描开销
|
||||
*/
|
||||
@Override
|
||||
public IPage<SurgeryItemDto> getSurgeryPage(Long organizationId, Integer pageNo, Integer pageSize, String searchKey) {
|
||||
@@ -2479,21 +2478,15 @@ public class DoctorStationAdviceAppServiceImpl implements IDoctorStationAdviceAp
|
||||
}
|
||||
}
|
||||
|
||||
// 使用直接 LIMIT 查询,不触发 MyBatis Plus 的 COUNT 开销
|
||||
List<SurgeryItemDto> records = doctorStationAdviceAppMapper.getSurgeryPage(
|
||||
IPage<SurgeryItemDto> result = doctorStationAdviceAppMapper.getSurgeryPage(
|
||||
new Page<>(pageNo, pageSize),
|
||||
PublicationStatus.ACTIVE.getValue(),
|
||||
organizationId,
|
||||
searchKey);
|
||||
|
||||
// 手动构造 Page 对象,total 设为 records.size()(前端 el-transfer 不需要精确的 total 总数)
|
||||
IPage<SurgeryItemDto> result = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(pageNo, pageSize);
|
||||
result.setRecords(records);
|
||||
result.setTotal(records.size());
|
||||
log.info("getSurgeryPage 完成: {}ms, total={}, records={}", System.currentTimeMillis() - start, result.getTotal(), result.getRecords().size());
|
||||
|
||||
// 无搜索时将结果写入缓存
|
||||
if (useCache) {
|
||||
if (useCache && result instanceof com.baomidou.mybatisplus.extension.plugins.pagination.Page) {
|
||||
redisCache.setCacheObject(cacheKey, result, (int) CACHE_EXPIRE_HOURS, java.util.concurrent.TimeUnit.HOURS);
|
||||
log.info("缓存手术项目, key: {}, 过期时间: {} 小时", cacheKey, CACHE_EXPIRE_HOURS);
|
||||
}
|
||||
|
||||
@@ -187,15 +187,14 @@ public interface DoctorStationAdviceAppMapper {
|
||||
|
||||
/**
|
||||
* 手术项目专用分页查询(仅手术 + 定价,无库存/草稿库存/取药科室等无关逻辑)
|
||||
* 使用 LIMIT/OFFSET 直接查询,不执行 COUNT 以提升性能
|
||||
*
|
||||
* @param page 分页参数(仅取 pageNo/pageSize,不触发 MyBatis Plus COUNT)
|
||||
* @param page 分页参数
|
||||
* @param statusEnum 启用状态
|
||||
* @param organizationId 科室ID(可选,用于过滤已配置的手术项目)
|
||||
* @param searchKey 模糊查询关键字(可选)
|
||||
* @return 手术项目分页数据
|
||||
*/
|
||||
List<SurgeryItemDto> getSurgeryPage(@Param("page") Page<SurgeryItemDto> page,
|
||||
IPage<SurgeryItemDto> getSurgeryPage(@Param("page") Page<SurgeryItemDto> page,
|
||||
@Param("statusEnum") Integer statusEnum,
|
||||
@Param("organizationId") Long organizationId,
|
||||
@Param("searchKey") String searchKey);
|
||||
|
||||
@@ -893,7 +893,6 @@
|
||||
AND (t1.name ILIKE '%' || #{searchKey} || '%' OR t1.py_str ILIKE '%' || #{searchKey} || '%')
|
||||
</if>
|
||||
ORDER BY t1.ID, t1.name ASC, t2.ID ASC
|
||||
LIMIT #{page.size} OFFSET ${(page.current - 1) * page.size}
|
||||
</select>
|
||||
|
||||
<!-- 检查项目专用分页查询:仅查检查(23) + 定价,无库存/草稿库存/取药科室等无关逻辑 -->
|
||||
|
||||
@@ -100,25 +100,11 @@ public class DictAspect {
|
||||
String dictText = dictAnnotation.dictText();
|
||||
String dictTable = dictAnnotation.dictTable();
|
||||
String deleteFlag = dictAnnotation.deleteFlag();
|
||||
|
||||
// 检查 _dictText 字段是否已被手动填充(如控制器方法中预先查询设置)
|
||||
// 如果已非空则跳过 SQL 查询,避免覆盖有效值
|
||||
String textFieldName = field.getName() + "_dictText";
|
||||
try {
|
||||
Field existingTextField = dto.getClass().getDeclaredField(textFieldName);
|
||||
existingTextField.setAccessible(true);
|
||||
Object existingValue = existingTextField.get(dto);
|
||||
if (existingValue != null && !existingValue.toString().isEmpty()) {
|
||||
continue; // _dictText 已有值,跳过
|
||||
}
|
||||
} catch (NoSuchFieldException e) {
|
||||
// _dictText 字段不存在,继续正常流程
|
||||
}
|
||||
|
||||
// 查询字典值
|
||||
String dictLabel = queryDictLabel(dictTable, dictCode, dictText, deleteFlag, fieldValue.toString());
|
||||
if (dictLabel != null && !dictLabel.isEmpty()) {
|
||||
if (dictLabel != null) {
|
||||
// 动态生成 _dictText 字段名
|
||||
String textFieldName = field.getName() + "_dictText";
|
||||
try {
|
||||
Field textField = dto.getClass().getDeclaredField(textFieldName);
|
||||
textField.setAccessible(true);
|
||||
|
||||
@@ -414,15 +414,6 @@
|
||||
<span class="method-price-tag">¥{{ method.packagePrice || method.price || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bug #500修复: 加载中的方法列表骨架占位,提前预留空间避免高度跳变 -->
|
||||
<div
|
||||
v-if="categoryLoadingSet.has(cat.typeId) && (!cat.methods || cat.methods.length === 0)"
|
||||
class="method-section method-section-skeleton"
|
||||
>
|
||||
<div class="method-section-title">检查方法</div>
|
||||
<div class="skeleton-method-row"></div>
|
||||
<div class="skeleton-method-row"></div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
@@ -582,30 +573,15 @@ function handleResetSearch() {
|
||||
// 初始化默认日期范围为近一周
|
||||
handleResetSearch();
|
||||
|
||||
// 🔧 BugFix#426/#430: 懒加载套餐明细(支持 packageName 解析)
|
||||
// 🔧 BugFix#426: 懒加载套餐明细
|
||||
async function loadPackageDetails(row, treeNode, resolve) {
|
||||
let packageId = row.packageId;
|
||||
if (!packageId && row.packageName) {
|
||||
try {
|
||||
const pkgRes = await listCheckPackage({ packageName: row.packageName });
|
||||
let packages = pkgRes?.data || [];
|
||||
if (!Array.isArray(packages)) {
|
||||
packages = packages.records || packages.data || [];
|
||||
}
|
||||
if (packages.length > 0) {
|
||||
packageId = packages[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('套餐名称解析失败:', err);
|
||||
}
|
||||
}
|
||||
if (!packageId) {
|
||||
if (!row.packageId) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await request({
|
||||
url: `/system/check-type/package/${packageId}/details`,
|
||||
url: `/system/check-type/package/${row.packageId}/details`,
|
||||
method: 'get'
|
||||
});
|
||||
const list = parsePackageDetailsPayload(res);
|
||||
@@ -639,9 +615,9 @@ function getPackageDetailsList(item) {
|
||||
return Array.isArray(carrier?.packageDetails) ? carrier.packageDetails : [];
|
||||
}
|
||||
|
||||
/** 有套餐 ID 或 packageName 的已选行才展示右侧套餐区(加载中 / 空 / 明细列表) */
|
||||
/** 有套餐 ID 的已选行才展示右侧套餐区(加载中 / 空 / 明细列表) */
|
||||
function shouldShowPackageBody(item) {
|
||||
return !!(getPackageCarrier(item)?.packageId || item.packageName || item.packageId);
|
||||
return !!getPackageCarrier(item)?.packageId;
|
||||
}
|
||||
|
||||
/** 金额展示:统一两位小数 */
|
||||
@@ -1073,8 +1049,7 @@ const filteredCategoryList = computed(() => {
|
||||
const key = dictSearchKey.value.toLowerCase();
|
||||
return categoryList.value.map(cat => ({
|
||||
...cat,
|
||||
items: cat.items.filter(item => (item.name || '').toLowerCase().includes(key)),
|
||||
methods: cat.methods || []
|
||||
items: cat.items.filter(item => (item.name || '').toLowerCase().includes(key))
|
||||
})).filter(cat => cat.items.length > 0);
|
||||
});
|
||||
|
||||
@@ -1381,10 +1356,10 @@ async function handleMethodSelect(checked, method, cat) {
|
||||
const existingItem = selectedItems.value.find(s => s.id === targetItem.id);
|
||||
if (existingItem) {
|
||||
existingItem.selectedMethod = method;
|
||||
// 从方法中获取套餐信息(支持 packageId 或 packageName 解析)
|
||||
if (method.packageId || method.packageName) {
|
||||
// 从方法中获取套餐信息
|
||||
if (method.packageId) {
|
||||
existingItem.isPackage = true;
|
||||
existingItem.packageId = method.packageId || existingItem.packageId;
|
||||
existingItem.packageId = method.packageId;
|
||||
existingItem.hasChildren = true; // #426修复
|
||||
existingItem.packageName = method.packageName || existingItem.packageName; // #428修复: 确保 packageName 同步
|
||||
// 预加载套餐明细
|
||||
@@ -1421,12 +1396,12 @@ async function handleMethodSelect(checked, method, cat) {
|
||||
isPackage: !!method.packageId || !!targetItem.packageName,
|
||||
packageId: method.packageId || targetItem.packageId || null,
|
||||
packageName: method.packageName || targetItem.packageName || null, // #428修复: 复制 packageName,确保套餐明细可加载
|
||||
hasChildren: !!(method.packageId || method.packageName || targetItem.packageId || targetItem.packageName) // #426修复: 树形表格懒加载展开标记,支持 packageName 解析
|
||||
hasChildren: !!(method.packageId || targetItem.packageId) // #426修复: 树形表格懒加载展开标记
|
||||
};
|
||||
selectedItems.value.push(newItem);
|
||||
|
||||
// 如果是套餐,预加载套餐明细
|
||||
if (newItem.isPackage && (newItem.packageId || newItem.packageName)) {
|
||||
if (newItem.isPackage && newItem.packageId) {
|
||||
loadPackageDetailsForItem(newItem);
|
||||
}
|
||||
|
||||
@@ -1564,7 +1539,7 @@ async function toggleItemExpand(item) {
|
||||
async function selectMethodCheckbox(checked, item, method) {
|
||||
if (checked) {
|
||||
item.selectedMethod = method;
|
||||
if (item.expanded && (method.packageId || method.packageName)) {
|
||||
if (item.expanded && method.packageId) {
|
||||
loadPackageDetailsForItem(item);
|
||||
}
|
||||
// 动态加载该方法对应的套餐明细
|
||||
@@ -1629,9 +1604,8 @@ async function loadMethodPackageDetails(item, method) {
|
||||
/** 检查明细表格中切换检查方法 */
|
||||
async function onDetailMethodChange(row, val) {
|
||||
row.selectedMethod = val || null;
|
||||
if (val?.packageId || val?.packageName) {
|
||||
row.packageId = val.packageId || row.packageId;
|
||||
row.packageName = val.packageName || row.packageName;
|
||||
if (val?.packageId) {
|
||||
row.packageId = val.packageId;
|
||||
row.isPackage = true;
|
||||
row.hasChildren = true; // #426修复
|
||||
}
|
||||
@@ -1829,7 +1803,7 @@ defineExpose({ getList });
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden; /* Bug #500: 防止切换时水平方向溢出导致抖动 */
|
||||
min-height: 300px; /* Bug #500: 增大最小高度,避免折叠动画期间容器高度突变 */
|
||||
min-height: 120px; /* Bug #500: 固定最小高度,避免分类切换时 flex 容器高度突变 */
|
||||
}
|
||||
.empty-hint {
|
||||
color: #909399;
|
||||
@@ -1888,7 +1862,6 @@ defineExpose({ getList });
|
||||
background: #f0f7ff;
|
||||
border-radius: 4px;
|
||||
margin-top: 6px;
|
||||
min-height: 50px; /* Bug #500: 方法区域预留最小高度,减少加载完成后的高度突变 */
|
||||
}
|
||||
|
||||
.method-section-title {
|
||||
@@ -1930,24 +1903,6 @@ defineExpose({ getList });
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* Bug #500修复: 方法列表骨架占位样式 */
|
||||
.method-section-skeleton {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.skeleton-method-row {
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, #e8f4ff 25%, #d0e8ff 50%, #e8f4ff 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* 已选择 tags */
|
||||
/* 已选择:加宽,避免套餐明细挤成一团 */
|
||||
.selected-panel {
|
||||
|
||||
@@ -117,10 +117,8 @@
|
||||
<el-button link type="warning" @click="handleWithdraw(scope.row)">撤回</el-button>
|
||||
</template>
|
||||
<!-- 已校对(2)、待接收(3)、已收样(4)、已出报告(6)、已作废(7):仅查看详情 -->
|
||||
<template v-else>
|
||||
<el-button link type="primary" @click="handleViewDetail(scope.row)">详情</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,20 @@
|
||||
<div class="medicalExaminations-container">
|
||||
<!-- 主体内容 -->
|
||||
<div class="form-body">
|
||||
<!-- 右上角:紧急程度 -->
|
||||
<div class="urgency-bar">
|
||||
<span class="urgency-bar-label">紧急程度:</span>
|
||||
<el-radio-group v-model="form.urgencyLevel" @change="handleUrgencyChange" size="small">
|
||||
<el-radio-button label="routine">普通</el-radio-button>
|
||||
<el-radio-button label="emergency">急诊</el-radio-button>
|
||||
</el-radio-group>
|
||||
<transition name="el-fade-in-linear">
|
||||
<span v-if="form.urgencyLevel === 'emergency'" class="emergency-tip-inline">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
绿色通道
|
||||
</span>
|
||||
</transition>
|
||||
</div>
|
||||
<!-- 选择检查项目 -->
|
||||
<div class="section-card">
|
||||
<div class="transfer-wrapper">
|
||||
@@ -38,21 +52,7 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="紧急程度">
|
||||
<el-radio-group v-model="form.urgencyLevel" @change="handleUrgencyChange" size="small">
|
||||
<el-radio-button label="routine">普通</el-radio-button>
|
||||
<el-radio-button label="emergency">急诊</el-radio-button>
|
||||
</el-radio-group>
|
||||
<transition name="el-fade-in-linear">
|
||||
<span v-if="form.urgencyLevel === 'emergency'" class="emergency-tip-inline">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
绿色通道
|
||||
</span>
|
||||
</transition>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="期望检查时间">
|
||||
<el-date-picker
|
||||
v-model="form.expectedExaminationTime"
|
||||
@@ -61,7 +61,7 @@
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
:disabled-date="disabledPastDate"
|
||||
:disabled-date="disabledFutureDate"
|
||||
:default-value="new Date()"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -347,8 +347,8 @@ const rules = reactive({
|
||||
],
|
||||
});
|
||||
|
||||
// 禁用过去的日期(允许选择今天及以后)
|
||||
const disabledPastDate = (time) => {
|
||||
// 禁用过去的时间
|
||||
const disabledFutureDate = (time) => {
|
||||
return time.getTime() < Date.now() - 8.64e7;
|
||||
};
|
||||
|
||||
@@ -624,6 +624,23 @@ $bg-color: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
// 紧急程度栏 - 右上角
|
||||
.urgency-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.urgency-bar-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-regular;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 卡片通用样式 - 紧凑
|
||||
.section-card {
|
||||
background: #fff;
|
||||
|
||||
@@ -1581,10 +1581,10 @@ function handleSaveGroup(orderGroupList) {
|
||||
therapyEnum: item.orderDetailInfos?.therapyEnum || '1',
|
||||
};
|
||||
|
||||
// 预初始化空行(组套项带预填值,设为 false 让明细字段在表格中直接展示)
|
||||
// 预初始化空行
|
||||
prescriptionList.value[rowIndex.value] = {
|
||||
uniqueKey: nextId.value++,
|
||||
isEdit: false,
|
||||
isEdit: true,
|
||||
statusEnum: 1,
|
||||
};
|
||||
|
||||
|
||||
@@ -1131,7 +1131,8 @@ function handleLocationClick(item, row, index) {
|
||||
.then((res) => {
|
||||
const list = res.data || [];
|
||||
const d = pickBestOrgQuantityRow(list);
|
||||
if (d) {
|
||||
const strictOk = d && Number(d.orgQuantity ?? 0) > 0;
|
||||
if (strictOk) {
|
||||
applyFromDto(d, false);
|
||||
if (Number(r.totalQuantity) <= 0) {
|
||||
proxy.$message.warning('仓库数量为0,无法调用!');
|
||||
@@ -1143,15 +1144,11 @@ function handleLocationClick(item, row, index) {
|
||||
return runGet(false).then((res2) => {
|
||||
const list2 = res2.data || [];
|
||||
const d2 = pickBestOrgQuantityRow(list2);
|
||||
if (d2) {
|
||||
if (d2 && Number(d2.orgQuantity ?? 0) > 0) {
|
||||
applyFromDto(d2, true);
|
||||
if (Number(r.totalQuantity) <= 0) {
|
||||
proxy.$message.warning('仓库数量为0,无法调用!');
|
||||
} else {
|
||||
proxy.$message.info(
|
||||
'所选批号在本仓库无对应库存或批号不一致,已按仓库实物回显批号与可领数量,请核对。'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
r.totalQuantity = 0;
|
||||
r.price = 0;
|
||||
|
||||
Reference in New Issue
Block a user