"1. 新增系统字典与全局植入目录相关表结构及迁移
2. 扩展患者手术与材料模型,更新种子数据 3. 新增字典模块,增强设备植入目录管理能力 4. 重构患者后台服务与表单链路,统一权限与参数校验 5. 管理台新增字典页面并改造患者/设备页面与路由权限 6. 补充字典及相关领域 e2e 测试并更新文档"
This commit is contained in:
parent
64d1ad7896
commit
73082225f6
@ -2,17 +2,52 @@
|
|||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
- 提供 B 端设备 CRUD。
|
- 提供“全局植入物目录”管理,供患者手术表单选择。
|
||||||
- 管理设备与患者的归属关系。
|
- 维护患者手术下的植入实例记录。
|
||||||
- 支持管理员按医院、患者、状态和关键词分页查询设备。
|
- 支持为可调压器械配置挡位列表。
|
||||||
|
- 支持管理员按医院、患者、状态和关键词分页查询患者植入实例。
|
||||||
|
|
||||||
## 2. 权限
|
## 2. 设备实例
|
||||||
|
|
||||||
- `SYSTEM_ADMIN`:可跨院查询和维护设备。
|
`Device` 现在表示“患者某次手术下的植入设备实例”,不是独立库存主数据。
|
||||||
- `HOSPITAL_ADMIN`:仅可操作本院患者名下设备。
|
|
||||||
- 其他角色:默认拒绝。
|
|
||||||
|
|
||||||
## 3. 接口
|
核心字段:
|
||||||
|
|
||||||
|
- `snCode`:设备唯一标识
|
||||||
|
- `patientId`:归属患者
|
||||||
|
- `surgeryId`:归属手术,可为空
|
||||||
|
- `implantCatalogId`:型号字典 ID,可为空
|
||||||
|
- `implantModel` / `implantManufacturer` / `implantName`:历史快照
|
||||||
|
- `isPressureAdjustable`:是否可调压
|
||||||
|
- `isAbandoned`:是否弃用
|
||||||
|
- `currentPressure`:当前压力
|
||||||
|
- `status`:设备状态
|
||||||
|
|
||||||
|
补充:
|
||||||
|
|
||||||
|
- `currentPressure` 不允许在创建/编辑设备实例时手工指定。
|
||||||
|
- 新植入设备默认以 `initialPressure`(或系统默认值)作为当前压力起点,后续只允许在调压任务完成时更新。
|
||||||
|
|
||||||
|
## 3. 植入物目录
|
||||||
|
|
||||||
|
新增 `ImplantCatalog`:
|
||||||
|
|
||||||
|
- `modelCode`:型号编码,唯一
|
||||||
|
- `manufacturer`:厂商
|
||||||
|
- `name`:名称
|
||||||
|
- `pressureLevels`:可调压器械的挡位列表
|
||||||
|
- `isPressureAdjustable`:是否可调压
|
||||||
|
- `notes`:目录备注
|
||||||
|
|
||||||
|
可见性:
|
||||||
|
|
||||||
|
- 全部已登录 B 端角色都可读取,用于患者手术录入
|
||||||
|
- 仅 `SYSTEM_ADMIN` 可做目录 CRUD
|
||||||
|
- 目录是全局共享的,不按医院隔离
|
||||||
|
|
||||||
|
## 4. 接口
|
||||||
|
|
||||||
|
设备实例:
|
||||||
|
|
||||||
- `GET /b/devices`:分页查询设备列表
|
- `GET /b/devices`:分页查询设备列表
|
||||||
- `GET /b/devices/:id`:查询设备详情
|
- `GET /b/devices/:id`:查询设备详情
|
||||||
@ -20,8 +55,19 @@
|
|||||||
- `PATCH /b/devices/:id`:更新设备
|
- `PATCH /b/devices/:id`:更新设备
|
||||||
- `DELETE /b/devices/:id`:删除设备
|
- `DELETE /b/devices/:id`:删除设备
|
||||||
|
|
||||||
## 4. 约束
|
型号字典:
|
||||||
|
|
||||||
|
- `GET /b/devices/catalogs`:查询植入物型号字典
|
||||||
|
- `POST /b/devices/catalogs`:新增植入物目录
|
||||||
|
- `PATCH /b/devices/catalogs/:id`:更新植入物目录
|
||||||
|
- `DELETE /b/devices/catalogs/:id`:删除植入物目录
|
||||||
|
|
||||||
|
## 5. 约束
|
||||||
|
|
||||||
- 设备必须绑定到一个患者。
|
- 设备必须绑定到一个患者。
|
||||||
- 设备 SN 在全库唯一,服务端会统一转成大写后再校验。
|
- 设备 SN 在全库唯一,服务端会统一转成大写后再校验。
|
||||||
- 删除已被任务明细引用的设备会返回 `409`。
|
- 删除已被任务明细引用的设备会返回 `409`。
|
||||||
|
- 删除已被患者手术引用的植入物目录会返回 `409`。
|
||||||
|
- 可调压植入物若配置了 `pressureLevels`,患者手术录入和任务调压时的压力值必须命中该挡位列表。
|
||||||
|
- 调压任务仅允许针对 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备发布。
|
||||||
|
- `Device.currentPressure` 只允许由调压任务完成时更新,患者手术录入和设备实例编辑都不开放手工写入。
|
||||||
|
|||||||
42
docs/dictionaries.md
Normal file
42
docs/dictionaries.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# 系统字典说明(`src/dictionaries`)
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
- 将患者手术表单中的固定选项沉淀为系统级字典。
|
||||||
|
- 仅允许 `SYSTEM_ADMIN` 做 CRUD。
|
||||||
|
- 业务角色仅可读取启用中的字典项,用于患者录入表单。
|
||||||
|
|
||||||
|
## 2. 当前字典类型
|
||||||
|
|
||||||
|
- `PRIMARY_DISEASE`:原发病
|
||||||
|
- `HYDROCEPHALUS_TYPE`:脑积水类型
|
||||||
|
- `SHUNT_MODE`:分流方式
|
||||||
|
- `PROXIMAL_PUNCTURE_AREA`:近端穿刺区域
|
||||||
|
- `VALVE_PLACEMENT_SITE`:阀门植入部位
|
||||||
|
- `DISTAL_SHUNT_DIRECTION`:远端分流方向
|
||||||
|
|
||||||
|
## 3. 数据结构
|
||||||
|
|
||||||
|
新增 `DictionaryItem`:
|
||||||
|
|
||||||
|
- `type`:字典类型枚举
|
||||||
|
- `label`:字典项显示值
|
||||||
|
- `sortOrder`:排序值,越小越靠前
|
||||||
|
- `enabled`:是否启用
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 同一 `type` 下 `label` 唯一。
|
||||||
|
- 非系统管理员读取时只返回 `enabled=true` 的字典项。
|
||||||
|
|
||||||
|
## 4. 接口
|
||||||
|
|
||||||
|
- `GET /b/dictionaries`:查询字典项
|
||||||
|
- `POST /b/dictionaries`:创建字典项(仅系统管理员)
|
||||||
|
- `PATCH /b/dictionaries/:id`:更新字典项(仅系统管理员)
|
||||||
|
- `DELETE /b/dictionaries/:id`:删除字典项(仅系统管理员)
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `GET /b/dictionaries?includeDisabled=true` 仅系统管理员生效。
|
||||||
|
- 患者手术表单现在从该接口动态读取选项,不再使用前端硬编码数组。
|
||||||
112
docs/patients.md
112
docs/patients.md
@ -2,33 +2,99 @@
|
|||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
- B 端:按组织与角色范围查询患者(强依赖 `hospitalId`)。
|
- B 端:按组织与角色范围查询患者,并维护患者基础档案。
|
||||||
- C 端:按 `phone + idCard` 做跨院聚合查询。
|
- B 端:支持患者首术录入、二次手术追加、旧设备弃用标记。
|
||||||
- 患者档案直接保存身份证号原文,不再做哈希转换。
|
- 数据关系升级为 `Patient -> PatientSurgery -> Device -> TaskItem -> Task`。
|
||||||
- 服务端只做轻量格式整理:去空格、统一末尾 `x/X` 为大写。
|
- C 端:按 `phone + idCard` 做跨院生命周期聚合,返回手术事件与调压事件。
|
||||||
|
|
||||||
## 2. B 端可见性
|
## 2. 患者基础档案
|
||||||
|
|
||||||
|
患者表新增以下字段:
|
||||||
|
|
||||||
|
- `inpatientNo`:住院号
|
||||||
|
- `projectName`:项目名称
|
||||||
|
- `phone`:联系电话
|
||||||
|
- `idCard`:身份证号原文
|
||||||
|
- `doctorId`:患者归属人员(医生/主任/组长)
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `name` 仍然保留为患者姓名必填字段。
|
||||||
|
- `doctorId + hospitalId` 仍然是患者的组织归属来源,不直接绑定小组/科室。
|
||||||
|
- 手术表单中的 `原发病 / 脑积水类型 / 分流方式 / 近端穿刺区域 / 阀门植入部位 / 远端分流方向` 改为读取系统字典,不再前端硬编码。
|
||||||
|
|
||||||
|
## 3. 手术档案
|
||||||
|
|
||||||
|
新增 `PatientSurgery` 表,每次手术保存:
|
||||||
|
|
||||||
|
- `surgeryDate`:手术日期
|
||||||
|
- `surgeryName`:手术名称
|
||||||
|
- `surgeonName`:主刀医生
|
||||||
|
- `preOpPressure`:术前测压,可为空
|
||||||
|
- `primaryDisease`:原发病
|
||||||
|
- `hydrocephalusTypes`:脑积水类型,多选
|
||||||
|
- `previousShuntSurgeryDate`:上次分流手术时间,可为空
|
||||||
|
- `preOpMaterials`:术前 CT 影像/资料,保存为附件元数据数组
|
||||||
|
- `notes`:手术备注,可为空
|
||||||
|
|
||||||
|
返回时会自动补充:
|
||||||
|
|
||||||
|
- `shuntSurgeryCount`:当前这台手术是该患者第几次分流手术
|
||||||
|
- `activeDeviceCount`:本次手术仍在用设备数
|
||||||
|
- `abandonedDeviceCount`:本次手术已弃用设备数
|
||||||
|
|
||||||
|
## 4. 植入设备
|
||||||
|
|
||||||
|
设备表仍沿用 `Device`,但语义改为“患者手术下植入的设备实例”。
|
||||||
|
|
||||||
|
新增/使用字段:
|
||||||
|
|
||||||
|
- `implantCatalogId`:植入物型号字典 ID
|
||||||
|
- `implantModel` / `implantManufacturer` / `implantName`:型号快照
|
||||||
|
- `isPressureAdjustable`:是否可调压
|
||||||
|
- `isAbandoned`:是否已弃用
|
||||||
|
- `shuntMode`:分流方式
|
||||||
|
- `proximalPunctureAreas`:近端穿刺区域,最多 2 个
|
||||||
|
- `valvePlacementSites`:阀门植入部位,最多 2 个
|
||||||
|
- `distalShuntDirection`:远端分流方向
|
||||||
|
- `initialPressure`:初始压力,可为空
|
||||||
|
- `implantNotes`:植入物备注,可为空
|
||||||
|
- `labelImageUrl`:植入物标签图片地址,可为空
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 患者手术里选择的是“全局植入物目录”,不是按医院单独维护的设备库。
|
||||||
|
- 同一个植入物目录可被多个患者手术重复绑定,患者侧保存的是目录快照。
|
||||||
|
- 旧设备弃用后,`TaskItem` 历史不会删除。
|
||||||
|
- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。
|
||||||
|
- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。
|
||||||
|
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的值。
|
||||||
|
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`,后续仅能由调压任务完成后更新。
|
||||||
|
|
||||||
|
## 5. B 端可见性
|
||||||
|
|
||||||
- `DOCTOR`:仅可查自己名下患者
|
- `DOCTOR`:仅可查自己名下患者
|
||||||
- `LEADER`:可查本组医生名下患者(按医生当前 `groupId` 反查)
|
- `LEADER`:可查本组医生名下患者
|
||||||
- `DIRECTOR`:可查本科室医生名下患者(按医生当前 `departmentId` 反查)
|
- `DIRECTOR`:可查本科室医生名下患者
|
||||||
- `HOSPITAL_ADMIN`:可查本院全部患者
|
- `HOSPITAL_ADMIN`:可查本院全部患者
|
||||||
- `SYSTEM_ADMIN`:需显式传入目标 `hospitalId`
|
- `SYSTEM_ADMIN`:需显式传入目标 `hospitalId`
|
||||||
|
|
||||||
## 2.1 B 端 CRUD
|
## 6. B 端接口
|
||||||
|
|
||||||
- `GET /b/patients`:按角色查询可见患者
|
- `GET /b/patients`:按角色查询可见患者列表
|
||||||
- `GET /b/patients/doctors`:查询当前角色可见的归属人员候选(医生/主任/组长,用于患者表单)
|
- `GET /b/patients/doctors`:查询当前角色可见的归属人员候选
|
||||||
- `POST /b/patients`:创建患者
|
- `POST /b/patients`:创建患者,可选带 `initialSurgery`
|
||||||
|
- `POST /b/patients/:id/surgeries`:为患者新增手术
|
||||||
- `GET /b/patients/:id`:查询患者详情
|
- `GET /b/patients/:id`:查询患者详情
|
||||||
- `PATCH /b/patients/:id`:更新患者
|
- `PATCH /b/patients/:id`:更新患者基础信息
|
||||||
- `DELETE /b/patients/:id`:删除患者(若存在关联设备返回 409)
|
- `DELETE /b/patients/:id`:删除患者
|
||||||
|
|
||||||
说明:
|
约束:
|
||||||
患者表只绑定 `doctorId + hospitalId`,不直接绑定小组/科室。医生调组或调科后,
|
|
||||||
可见范围会按医生当前组织归属自动变化,无需迁移患者数据。
|
|
||||||
|
|
||||||
## 3. C 端生命周期聚合
|
- `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。
|
||||||
|
- 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`。
|
||||||
|
|
||||||
|
## 7. C 端生命周期聚合
|
||||||
|
|
||||||
接口:`GET /c/patients/lifecycle?phone=...&idCard=...`
|
接口:`GET /c/patients/lifecycle?phone=...&idCard=...`
|
||||||
|
|
||||||
@ -36,10 +102,16 @@
|
|||||||
|
|
||||||
1. 不做医院隔离(跨租户)
|
1. 不做医院隔离(跨租户)
|
||||||
2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配
|
2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配
|
||||||
3. 关联查询 `Patient -> Device -> TaskItem -> Task`
|
3. 聚合 `Patient -> PatientSurgery -> Device` 的手术事件
|
||||||
4. 返回扁平生命周期列表(按 `Task.createdAt DESC`)
|
4. 聚合 `Patient -> Device -> TaskItem -> Task` 的调压事件
|
||||||
|
5. 全部事件按 `occurredAt DESC` 返回
|
||||||
|
|
||||||
## 4. 响应结构
|
事件类型:
|
||||||
|
|
||||||
|
- `SURGERY`
|
||||||
|
- `TASK_PRESSURE_ADJUSTMENT`
|
||||||
|
|
||||||
|
## 8. 响应结构
|
||||||
|
|
||||||
全部接口统一返回:
|
全部接口统一返回:
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,82 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Device" ADD COLUMN "distalShuntDirection" TEXT,
|
||||||
|
ADD COLUMN "implantCatalogId" INTEGER,
|
||||||
|
ADD COLUMN "implantManufacturer" TEXT,
|
||||||
|
ADD COLUMN "implantModel" TEXT,
|
||||||
|
ADD COLUMN "implantName" TEXT,
|
||||||
|
ADD COLUMN "implantNotes" TEXT,
|
||||||
|
ADD COLUMN "initialPressure" INTEGER,
|
||||||
|
ADD COLUMN "isAbandoned" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "isPressureAdjustable" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "labelImageUrl" TEXT,
|
||||||
|
ADD COLUMN "proximalPunctureAreas" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
ADD COLUMN "shuntMode" TEXT,
|
||||||
|
ADD COLUMN "surgeryId" INTEGER,
|
||||||
|
ADD COLUMN "valvePlacementSites" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Patient" ADD COLUMN "inpatientNo" TEXT,
|
||||||
|
ADD COLUMN "projectName" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PatientSurgery" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"patientId" INTEGER NOT NULL,
|
||||||
|
"surgeryDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"surgeryName" TEXT NOT NULL,
|
||||||
|
"surgeonName" TEXT NOT NULL,
|
||||||
|
"preOpPressure" INTEGER,
|
||||||
|
"primaryDisease" TEXT NOT NULL,
|
||||||
|
"hydrocephalusTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"previousShuntSurgeryDate" TIMESTAMP(3),
|
||||||
|
"preOpMaterials" JSONB,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "PatientSurgery_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ImplantCatalog" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"modelCode" TEXT NOT NULL,
|
||||||
|
"manufacturer" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"hospitalId" INTEGER,
|
||||||
|
"isPressureAdjustable" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "ImplantCatalog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PatientSurgery_patientId_surgeryDate_idx" ON "PatientSurgery"("patientId", "surgeryDate");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ImplantCatalog_modelCode_key" ON "ImplantCatalog"("modelCode");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ImplantCatalog_hospitalId_idx" ON "ImplantCatalog"("hospitalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Device_surgeryId_idx" ON "Device"("surgeryId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Device_implantCatalogId_idx" ON "Device"("implantCatalogId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Device_patientId_isAbandoned_idx" ON "Device"("patientId", "isAbandoned");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Patient_inpatientNo_idx" ON "Patient"("inpatientNo");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PatientSurgery" ADD CONSTRAINT "PatientSurgery_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ImplantCatalog" ADD CONSTRAINT "ImplantCatalog_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Device" ADD CONSTRAINT "Device_surgeryId_fkey" FOREIGN KEY ("surgeryId") REFERENCES "PatientSurgery"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Device" ADD CONSTRAINT "Device_implantCatalogId_fkey" FOREIGN KEY ("implantCatalogId") REFERENCES "ImplantCatalog"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DictionaryType" AS ENUM ('PRIMARY_DISEASE', 'HYDROCEPHALUS_TYPE', 'SHUNT_MODE', 'PROXIMAL_PUNCTURE_AREA', 'VALVE_PLACEMENT_SITE', 'DISTAL_SHUNT_DIRECTION');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DictionaryItem" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"type" "DictionaryType" NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DictionaryItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DictionaryItem_type_enabled_sortOrder_idx" ON "DictionaryItem"("type", "enabled", "sortOrder");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DictionaryItem_type_label_key" ON "DictionaryItem"("type", "label");
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ImplantCatalog"
|
||||||
|
ADD COLUMN "pressureLevels" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
|
||||||
|
ADD COLUMN "notes" TEXT,
|
||||||
|
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "ImplantCatalog" DROP CONSTRAINT IF EXISTS "ImplantCatalog_hospitalId_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "ImplantCatalog_hospitalId_idx";
|
||||||
|
|
||||||
|
-- DropColumn
|
||||||
|
ALTER TABLE "ImplantCatalog" DROP COLUMN IF EXISTS "hospitalId";
|
||||||
@ -36,6 +36,16 @@ enum TaskStatus {
|
|||||||
CANCELLED
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 医学字典类型:驱动患者手术表单中的单选/多选项。
|
||||||
|
enum DictionaryType {
|
||||||
|
PRIMARY_DISEASE
|
||||||
|
HYDROCEPHALUS_TYPE
|
||||||
|
SHUNT_MODE
|
||||||
|
PROXIMAL_PUNCTURE_AREA
|
||||||
|
VALVE_PLACEMENT_SITE
|
||||||
|
DISTAL_SHUNT_DIRECTION
|
||||||
|
}
|
||||||
|
|
||||||
// 医院主表:多租户顶层实体。
|
// 医院主表:多租户顶层实体。
|
||||||
model Hospital {
|
model Hospital {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
@ -102,6 +112,10 @@ model User {
|
|||||||
model Patient {
|
model Patient {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
|
// 住院号:用于院内患者检索与病案关联。
|
||||||
|
inpatientNo String?
|
||||||
|
// 项目名称:用于区分患者所属项目/课题。
|
||||||
|
projectName String?
|
||||||
phone String
|
phone String
|
||||||
// 患者身份证号,录入与查询都使用原始证件号。
|
// 患者身份证号,录入与查询都使用原始证件号。
|
||||||
idCard String
|
idCard String
|
||||||
@ -109,23 +123,100 @@ model Patient {
|
|||||||
doctorId Int
|
doctorId Int
|
||||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||||
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
|
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
|
||||||
|
surgeries PatientSurgery[]
|
||||||
devices Device[]
|
devices Device[]
|
||||||
|
|
||||||
@@index([phone, idCard])
|
@@index([phone, idCard])
|
||||||
@@index([hospitalId, doctorId])
|
@@index([hospitalId, doctorId])
|
||||||
|
@@index([inpatientNo])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设备表:患者可绑定多个分流设备。
|
// 患者手术表:保存每次分流/复手术档案。
|
||||||
|
model PatientSurgery {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
patientId Int
|
||||||
|
surgeryDate DateTime
|
||||||
|
surgeryName String
|
||||||
|
surgeonName String
|
||||||
|
// 术前测压:部分患者可为空。
|
||||||
|
preOpPressure Int?
|
||||||
|
// 原发病:前端单选,后端先按字符串存储,方便后续补字典。
|
||||||
|
primaryDisease String
|
||||||
|
// 脑积水类型:前端多选。
|
||||||
|
hydrocephalusTypes String[] @default([])
|
||||||
|
// 上次分流手术时间:无既往分流史时为空。
|
||||||
|
previousShuntSurgeryDate DateTime?
|
||||||
|
// 术前影像/资料:支持图片、视频等附件元数据。
|
||||||
|
preOpMaterials Json?
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||||
|
devices Device[]
|
||||||
|
|
||||||
|
@@index([patientId, surgeryDate])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 植入物型号字典:供前端单选型号后自动回填厂家与名称。
|
||||||
|
model ImplantCatalog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
modelCode String @unique
|
||||||
|
manufacturer String
|
||||||
|
name String
|
||||||
|
// 可调压器械的可选挡位,由系统管理员维护。
|
||||||
|
pressureLevels Int[] @default([])
|
||||||
|
isPressureAdjustable Boolean @default(true)
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
devices Device[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统级字典项:由系统管理员维护,供患者手术表单选择使用。
|
||||||
|
model DictionaryItem {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
type DictionaryType
|
||||||
|
label String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([type, label])
|
||||||
|
@@index([type, enabled, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备表:每次手术植入的设备实例,保留当前压力与历史调压记录。
|
||||||
model Device {
|
model Device {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
snCode String @unique
|
snCode String @unique
|
||||||
currentPressure Int
|
currentPressure Int
|
||||||
status DeviceStatus @default(ACTIVE)
|
status DeviceStatus @default(ACTIVE)
|
||||||
patientId Int
|
patientId Int
|
||||||
|
surgeryId Int?
|
||||||
|
implantCatalogId Int?
|
||||||
|
// 植入物快照:避免型号字典修改后影响历史病历。
|
||||||
|
implantModel String?
|
||||||
|
implantManufacturer String?
|
||||||
|
implantName String?
|
||||||
|
isPressureAdjustable Boolean @default(true)
|
||||||
|
// 二次手术后旧设备可标记弃用,但历史调压任务仍需保留。
|
||||||
|
isAbandoned Boolean @default(false)
|
||||||
|
shuntMode String?
|
||||||
|
proximalPunctureAreas String[] @default([])
|
||||||
|
valvePlacementSites String[] @default([])
|
||||||
|
distalShuntDirection String?
|
||||||
|
initialPressure Int?
|
||||||
|
implantNotes String?
|
||||||
|
labelImageUrl String?
|
||||||
patient Patient @relation(fields: [patientId], references: [id])
|
patient Patient @relation(fields: [patientId], references: [id])
|
||||||
|
surgery PatientSurgery? @relation(fields: [surgeryId], references: [id], onDelete: SetNull)
|
||||||
|
implantCatalog ImplantCatalog? @relation(fields: [implantCatalogId], references: [id], onDelete: SetNull)
|
||||||
taskItems TaskItem[]
|
taskItems TaskItem[]
|
||||||
|
|
||||||
@@index([patientId, status])
|
@@index([patientId, status])
|
||||||
|
@@index([surgeryId])
|
||||||
|
@@index([implantCatalogId])
|
||||||
|
@@index([patientId, isAbandoned])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主任务表:记录调压任务主单。
|
// 主任务表:记录调压任务主单。
|
||||||
|
|||||||
409
prisma/seed.mjs
409
prisma/seed.mjs
@ -3,7 +3,8 @@ import { PrismaPg } from '@prisma/adapter-pg';
|
|||||||
import { hash } from 'bcrypt';
|
import { hash } from 'bcrypt';
|
||||||
import prismaClientPackage from '@prisma/client';
|
import prismaClientPackage from '@prisma/client';
|
||||||
|
|
||||||
const { DeviceStatus, PrismaClient, Role, TaskStatus } = prismaClientPackage;
|
const { DictionaryType, DeviceStatus, PrismaClient, Role, TaskStatus } =
|
||||||
|
prismaClientPackage;
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL;
|
const connectionString = process.env.DATABASE_URL;
|
||||||
if (!connectionString) {
|
if (!connectionString) {
|
||||||
@ -60,7 +61,15 @@ async function upsertUserByOpenId(openId, data) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) {
|
async function ensurePatient({
|
||||||
|
hospitalId,
|
||||||
|
doctorId,
|
||||||
|
name,
|
||||||
|
inpatientNo = null,
|
||||||
|
projectName = null,
|
||||||
|
phone,
|
||||||
|
idCard,
|
||||||
|
}) {
|
||||||
const existing = await prisma.patient.findFirst({
|
const existing = await prisma.patient.findFirst({
|
||||||
where: {
|
where: {
|
||||||
hospitalId,
|
hospitalId,
|
||||||
@ -70,10 +79,15 @@ async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.doctorId !== doctorId || existing.name !== name) {
|
if (
|
||||||
|
existing.doctorId !== doctorId ||
|
||||||
|
existing.name !== name ||
|
||||||
|
existing.inpatientNo !== inpatientNo ||
|
||||||
|
existing.projectName !== projectName
|
||||||
|
) {
|
||||||
return prisma.patient.update({
|
return prisma.patient.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { doctorId, name },
|
data: { doctorId, name, inpatientNo, projectName },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return existing;
|
return existing;
|
||||||
@ -84,12 +98,124 @@ async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) {
|
|||||||
hospitalId,
|
hospitalId,
|
||||||
doctorId,
|
doctorId,
|
||||||
name,
|
name,
|
||||||
|
inpatientNo,
|
||||||
|
projectName,
|
||||||
phone,
|
phone,
|
||||||
idCard,
|
idCard,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureImplantCatalog({
|
||||||
|
modelCode,
|
||||||
|
manufacturer,
|
||||||
|
name,
|
||||||
|
pressureLevels = [],
|
||||||
|
isPressureAdjustable = true,
|
||||||
|
notes = null,
|
||||||
|
}) {
|
||||||
|
return prisma.implantCatalog.upsert({
|
||||||
|
where: { modelCode },
|
||||||
|
update: {
|
||||||
|
manufacturer,
|
||||||
|
name,
|
||||||
|
pressureLevels,
|
||||||
|
isPressureAdjustable,
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
modelCode,
|
||||||
|
manufacturer,
|
||||||
|
name,
|
||||||
|
pressureLevels,
|
||||||
|
isPressureAdjustable,
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDictionaryItem({
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
sortOrder = 0,
|
||||||
|
enabled = true,
|
||||||
|
}) {
|
||||||
|
return prisma.dictionaryItem.upsert({
|
||||||
|
where: {
|
||||||
|
type_label: {
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
sortOrder,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
sortOrder,
|
||||||
|
enabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePatientSurgery({
|
||||||
|
patientId,
|
||||||
|
surgeryDate,
|
||||||
|
surgeryName,
|
||||||
|
surgeonName,
|
||||||
|
preOpPressure = null,
|
||||||
|
primaryDisease,
|
||||||
|
hydrocephalusTypes,
|
||||||
|
previousShuntSurgeryDate = null,
|
||||||
|
preOpMaterials = null,
|
||||||
|
notes = null,
|
||||||
|
}) {
|
||||||
|
const normalizedSurgeryDate = new Date(surgeryDate);
|
||||||
|
const normalizedPreviousDate = previousShuntSurgeryDate
|
||||||
|
? new Date(previousShuntSurgeryDate)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const existing = await prisma.patientSurgery.findFirst({
|
||||||
|
where: {
|
||||||
|
patientId,
|
||||||
|
surgeryDate: normalizedSurgeryDate,
|
||||||
|
surgeryName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return prisma.patientSurgery.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
surgeonName,
|
||||||
|
preOpPressure,
|
||||||
|
primaryDisease,
|
||||||
|
hydrocephalusTypes,
|
||||||
|
previousShuntSurgeryDate: normalizedPreviousDate,
|
||||||
|
preOpMaterials,
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.patientSurgery.create({
|
||||||
|
data: {
|
||||||
|
patientId,
|
||||||
|
surgeryDate: normalizedSurgeryDate,
|
||||||
|
surgeryName,
|
||||||
|
surgeonName,
|
||||||
|
preOpPressure,
|
||||||
|
primaryDisease,
|
||||||
|
hydrocephalusTypes,
|
||||||
|
previousShuntSurgeryDate: normalizedPreviousDate,
|
||||||
|
preOpMaterials,
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12);
|
const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12);
|
||||||
|
|
||||||
@ -217,10 +343,61 @@ async function main() {
|
|||||||
groupId: null,
|
groupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dictionarySeeds = {
|
||||||
|
[DictionaryType.PRIMARY_DISEASE]: [
|
||||||
|
'先天性脑积水',
|
||||||
|
'梗阻性脑积水',
|
||||||
|
'交通性脑积水',
|
||||||
|
'出血后脑积水',
|
||||||
|
'肿瘤相关脑积水',
|
||||||
|
'外伤后脑积水',
|
||||||
|
'感染后脑积水',
|
||||||
|
'分流功能障碍',
|
||||||
|
],
|
||||||
|
[DictionaryType.HYDROCEPHALUS_TYPE]: [
|
||||||
|
'交通性',
|
||||||
|
'梗阻性',
|
||||||
|
'高压性',
|
||||||
|
'正常压力',
|
||||||
|
'先天性',
|
||||||
|
'继发性',
|
||||||
|
],
|
||||||
|
[DictionaryType.SHUNT_MODE]: ['VPS', 'VPLS', 'LPS', '脑室心房分流'],
|
||||||
|
[DictionaryType.PROXIMAL_PUNCTURE_AREA]: [
|
||||||
|
'额角',
|
||||||
|
'枕角',
|
||||||
|
'三角区',
|
||||||
|
'腰穿',
|
||||||
|
'后角',
|
||||||
|
],
|
||||||
|
[DictionaryType.VALVE_PLACEMENT_SITE]: [
|
||||||
|
'耳后',
|
||||||
|
'胸前',
|
||||||
|
'锁骨下',
|
||||||
|
'腹壁',
|
||||||
|
'腰背部',
|
||||||
|
],
|
||||||
|
[DictionaryType.DISTAL_SHUNT_DIRECTION]: ['腹腔', '胸腔', '心房', '腰大池'],
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(dictionarySeeds).flatMap(([type, labels]) =>
|
||||||
|
labels.map((label, index) =>
|
||||||
|
ensureDictionaryItem({
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
sortOrder: index * 10,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const patientA1 = await ensurePatient({
|
const patientA1 = await ensurePatient({
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
doctorId: doctorA.id,
|
doctorId: doctorA.id,
|
||||||
name: 'Seed Patient A1',
|
name: 'Seed Patient A1',
|
||||||
|
inpatientNo: 'ZYH-A-0001',
|
||||||
|
projectName: '脑积水随访项目-A',
|
||||||
phone: '13800002001',
|
phone: '13800002001',
|
||||||
idCard: '110101199001010011',
|
idCard: '110101199001010011',
|
||||||
});
|
});
|
||||||
@ -229,6 +406,8 @@ async function main() {
|
|||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
doctorId: doctorA2.id,
|
doctorId: doctorA2.id,
|
||||||
name: 'Seed Patient A2',
|
name: 'Seed Patient A2',
|
||||||
|
inpatientNo: 'ZYH-A-0002',
|
||||||
|
projectName: '脑积水随访项目-A',
|
||||||
phone: '13800002002',
|
phone: '13800002002',
|
||||||
idCard: '110101199002020022',
|
idCard: '110101199002020022',
|
||||||
});
|
});
|
||||||
@ -237,6 +416,8 @@ async function main() {
|
|||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
doctorId: doctorA3.id,
|
doctorId: doctorA3.id,
|
||||||
name: 'Seed Patient A3',
|
name: 'Seed Patient A3',
|
||||||
|
inpatientNo: 'ZYH-A-0003',
|
||||||
|
projectName: '脑积水随访项目-A',
|
||||||
phone: '13800002003',
|
phone: '13800002003',
|
||||||
idCard: '110101199003030033',
|
idCard: '110101199003030033',
|
||||||
});
|
});
|
||||||
@ -245,22 +426,130 @@ async function main() {
|
|||||||
hospitalId: hospitalB.id,
|
hospitalId: hospitalB.id,
|
||||||
doctorId: doctorB.id,
|
doctorId: doctorB.id,
|
||||||
name: 'Seed Patient B1',
|
name: 'Seed Patient B1',
|
||||||
|
inpatientNo: 'ZYH-B-0001',
|
||||||
|
projectName: '脑积水随访项目-B',
|
||||||
phone: '13800002001',
|
phone: '13800002001',
|
||||||
idCard: '110101199001010011',
|
idCard: '110101199001010011',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const adjustableCatalog = await ensureImplantCatalog({
|
||||||
|
modelCode: 'SEED-ADJUSTABLE-VALVE',
|
||||||
|
manufacturer: 'Seed MedTech',
|
||||||
|
name: 'Seed 可调压分流阀',
|
||||||
|
pressureLevels: [80, 100, 120, 140, 160],
|
||||||
|
isPressureAdjustable: true,
|
||||||
|
notes: 'Seed 全局可调压目录样例',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixedCatalog = await ensureImplantCatalog({
|
||||||
|
modelCode: 'SEED-FIXED-VALVE',
|
||||||
|
manufacturer: 'Seed MedTech',
|
||||||
|
name: 'Seed 固定压分流阀',
|
||||||
|
pressureLevels: [],
|
||||||
|
isPressureAdjustable: false,
|
||||||
|
notes: 'Seed 固定压目录样例',
|
||||||
|
});
|
||||||
|
|
||||||
|
const surgeryA1Old = await ensurePatientSurgery({
|
||||||
|
patientId: patientA1.id,
|
||||||
|
surgeryDate: '2024-06-01T08:00:00.000Z',
|
||||||
|
surgeryName: '首次脑室腹腔分流术',
|
||||||
|
surgeonName: 'Seed Director A',
|
||||||
|
preOpPressure: 24,
|
||||||
|
primaryDisease: '先天性脑积水',
|
||||||
|
hydrocephalusTypes: ['交通性'],
|
||||||
|
notes: '首台手术',
|
||||||
|
});
|
||||||
|
|
||||||
|
const surgeryA1New = await ensurePatientSurgery({
|
||||||
|
patientId: patientA1.id,
|
||||||
|
surgeryDate: '2025-09-10T08:00:00.000Z',
|
||||||
|
surgeryName: '分流系统翻修术',
|
||||||
|
surgeonName: 'Seed Director A',
|
||||||
|
preOpPressure: 18,
|
||||||
|
primaryDisease: '分流功能障碍',
|
||||||
|
hydrocephalusTypes: ['交通性', '高压性'],
|
||||||
|
previousShuntSurgeryDate: '2024-06-01T08:00:00.000Z',
|
||||||
|
preOpMaterials: [
|
||||||
|
{
|
||||||
|
type: 'IMAGE',
|
||||||
|
url: 'https://seed.example.com/a1-ct-preop.png',
|
||||||
|
name: 'Seed A1 术前 CT',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: '二次手术,保留原设备历史',
|
||||||
|
});
|
||||||
|
|
||||||
|
const surgeryA2 = await ensurePatientSurgery({
|
||||||
|
patientId: patientA2.id,
|
||||||
|
surgeryDate: '2025-12-15T08:00:00.000Z',
|
||||||
|
surgeryName: '脑室腹腔分流术',
|
||||||
|
surgeonName: 'Seed Doctor A2',
|
||||||
|
preOpPressure: 20,
|
||||||
|
primaryDisease: '肿瘤相关脑积水',
|
||||||
|
hydrocephalusTypes: ['梗阻性'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const surgeryA3 = await ensurePatientSurgery({
|
||||||
|
patientId: patientA3.id,
|
||||||
|
surgeryDate: '2025-11-20T08:00:00.000Z',
|
||||||
|
surgeryName: '脑室腹腔分流术',
|
||||||
|
surgeonName: 'Seed Doctor A3',
|
||||||
|
preOpPressure: 21,
|
||||||
|
primaryDisease: '外伤后脑积水',
|
||||||
|
hydrocephalusTypes: ['交通性'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const surgeryB1 = await ensurePatientSurgery({
|
||||||
|
patientId: patientB1.id,
|
||||||
|
surgeryDate: '2025-10-05T08:00:00.000Z',
|
||||||
|
surgeryName: '脑室腹腔分流术',
|
||||||
|
surgeonName: 'Seed Doctor B',
|
||||||
|
preOpPressure: 23,
|
||||||
|
primaryDisease: '出血后脑积水',
|
||||||
|
hydrocephalusTypes: ['高压性'],
|
||||||
|
});
|
||||||
|
|
||||||
const deviceA1 = await prisma.device.upsert({
|
const deviceA1 = await prisma.device.upsert({
|
||||||
where: { snCode: 'SEED-SN-A-001' },
|
where: { snCode: 'SEED-SN-A-001' },
|
||||||
update: {
|
update: {
|
||||||
patientId: patientA1.id,
|
patientId: patientA1.id,
|
||||||
|
surgeryId: surgeryA1New.id,
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
currentPressure: 118,
|
currentPressure: 118,
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
|
implantModel: adjustableCatalog.modelCode,
|
||||||
|
implantManufacturer: adjustableCatalog.manufacturer,
|
||||||
|
implantName: adjustableCatalog.name,
|
||||||
|
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||||
|
isAbandoned: false,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 118,
|
||||||
|
implantNotes: 'Seed A1 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
snCode: 'SEED-SN-A-001',
|
snCode: 'SEED-SN-A-001',
|
||||||
patientId: patientA1.id,
|
patientId: patientA1.id,
|
||||||
|
surgeryId: surgeryA1New.id,
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
currentPressure: 118,
|
currentPressure: 118,
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
|
implantModel: adjustableCatalog.modelCode,
|
||||||
|
implantManufacturer: adjustableCatalog.manufacturer,
|
||||||
|
implantName: adjustableCatalog.name,
|
||||||
|
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||||
|
isAbandoned: false,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 118,
|
||||||
|
implantNotes: 'Seed A1 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -268,14 +557,42 @@ async function main() {
|
|||||||
where: { snCode: 'SEED-SN-A-002' },
|
where: { snCode: 'SEED-SN-A-002' },
|
||||||
update: {
|
update: {
|
||||||
patientId: patientA2.id,
|
patientId: patientA2.id,
|
||||||
|
surgeryId: surgeryA2.id,
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
currentPressure: 112,
|
currentPressure: 112,
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
|
implantModel: adjustableCatalog.modelCode,
|
||||||
|
implantManufacturer: adjustableCatalog.manufacturer,
|
||||||
|
implantName: adjustableCatalog.name,
|
||||||
|
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||||
|
isAbandoned: false,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['枕角'],
|
||||||
|
valvePlacementSites: ['胸前'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 112,
|
||||||
|
implantNotes: 'Seed A2 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
snCode: 'SEED-SN-A-002',
|
snCode: 'SEED-SN-A-002',
|
||||||
patientId: patientA2.id,
|
patientId: patientA2.id,
|
||||||
|
surgeryId: surgeryA2.id,
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
currentPressure: 112,
|
currentPressure: 112,
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
|
implantModel: adjustableCatalog.modelCode,
|
||||||
|
implantManufacturer: adjustableCatalog.manufacturer,
|
||||||
|
implantName: adjustableCatalog.name,
|
||||||
|
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||||
|
isAbandoned: false,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['枕角'],
|
||||||
|
valvePlacementSites: ['胸前'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 112,
|
||||||
|
implantNotes: 'Seed A2 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -283,14 +600,42 @@ async function main() {
|
|||||||
where: { snCode: 'SEED-SN-A-003' },
|
where: { snCode: 'SEED-SN-A-003' },
|
||||||
update: {
|
update: {
|
||||||
patientId: patientA3.id,
|
patientId: patientA3.id,
|
||||||
|
surgeryId: surgeryA3.id,
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
currentPressure: 109,
|
currentPressure: 109,
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
|
implantModel: adjustableCatalog.modelCode,
|
||||||
|
implantManufacturer: adjustableCatalog.manufacturer,
|
||||||
|
implantName: adjustableCatalog.name,
|
||||||
|
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||||
|
isAbandoned: false,
|
||||||
|
shuntMode: 'LPS',
|
||||||
|
proximalPunctureAreas: ['腰穿'],
|
||||||
|
valvePlacementSites: ['腰背部'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 109,
|
||||||
|
implantNotes: 'Seed A3 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
snCode: 'SEED-SN-A-003',
|
snCode: 'SEED-SN-A-003',
|
||||||
patientId: patientA3.id,
|
patientId: patientA3.id,
|
||||||
|
surgeryId: surgeryA3.id,
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
currentPressure: 109,
|
currentPressure: 109,
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
|
implantModel: adjustableCatalog.modelCode,
|
||||||
|
implantManufacturer: adjustableCatalog.manufacturer,
|
||||||
|
implantName: adjustableCatalog.name,
|
||||||
|
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||||
|
isAbandoned: false,
|
||||||
|
shuntMode: 'LPS',
|
||||||
|
proximalPunctureAreas: ['腰穿'],
|
||||||
|
valvePlacementSites: ['腰背部'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 109,
|
||||||
|
implantNotes: 'Seed A3 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -298,14 +643,42 @@ async function main() {
|
|||||||
where: { snCode: 'SEED-SN-B-001' },
|
where: { snCode: 'SEED-SN-B-001' },
|
||||||
update: {
|
update: {
|
||||||
patientId: patientB1.id,
|
patientId: patientB1.id,
|
||||||
|
surgeryId: surgeryB1.id,
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
currentPressure: 121,
|
currentPressure: 121,
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
|
implantModel: adjustableCatalog.modelCode,
|
||||||
|
implantManufacturer: adjustableCatalog.manufacturer,
|
||||||
|
implantName: adjustableCatalog.name,
|
||||||
|
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||||
|
isAbandoned: false,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 121,
|
||||||
|
implantNotes: 'Seed B1 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
snCode: 'SEED-SN-B-001',
|
snCode: 'SEED-SN-B-001',
|
||||||
patientId: patientB1.id,
|
patientId: patientB1.id,
|
||||||
|
surgeryId: surgeryB1.id,
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
currentPressure: 121,
|
currentPressure: 121,
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
|
implantModel: adjustableCatalog.modelCode,
|
||||||
|
implantManufacturer: adjustableCatalog.manufacturer,
|
||||||
|
implantName: adjustableCatalog.name,
|
||||||
|
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||||
|
isAbandoned: false,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 121,
|
||||||
|
implantNotes: 'Seed B1 当前在用设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -313,14 +686,42 @@ async function main() {
|
|||||||
where: { snCode: 'SEED-SN-A-004' },
|
where: { snCode: 'SEED-SN-A-004' },
|
||||||
update: {
|
update: {
|
||||||
patientId: patientA1.id,
|
patientId: patientA1.id,
|
||||||
|
surgeryId: surgeryA1Old.id,
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
currentPressure: 130,
|
currentPressure: 130,
|
||||||
status: DeviceStatus.INACTIVE,
|
status: DeviceStatus.INACTIVE,
|
||||||
|
implantModel: adjustableCatalog.modelCode,
|
||||||
|
implantManufacturer: adjustableCatalog.manufacturer,
|
||||||
|
implantName: adjustableCatalog.name,
|
||||||
|
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||||
|
isAbandoned: true,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 130,
|
||||||
|
implantNotes: 'Seed A1 弃用历史设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
snCode: 'SEED-SN-A-004',
|
snCode: 'SEED-SN-A-004',
|
||||||
patientId: patientA1.id,
|
patientId: patientA1.id,
|
||||||
|
surgeryId: surgeryA1Old.id,
|
||||||
|
implantCatalogId: adjustableCatalog.id,
|
||||||
currentPressure: 130,
|
currentPressure: 130,
|
||||||
status: DeviceStatus.INACTIVE,
|
status: DeviceStatus.INACTIVE,
|
||||||
|
implantModel: adjustableCatalog.modelCode,
|
||||||
|
implantManufacturer: adjustableCatalog.manufacturer,
|
||||||
|
implantName: adjustableCatalog.name,
|
||||||
|
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
|
||||||
|
isAbandoned: true,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 130,
|
||||||
|
implantNotes: 'Seed A1 弃用历史设备',
|
||||||
|
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { AuthModule } from './auth/auth.module.js';
|
|||||||
import { OrganizationModule } from './organization/organization.module.js';
|
import { OrganizationModule } from './organization/organization.module.js';
|
||||||
import { NotificationsModule } from './notifications/notifications.module.js';
|
import { NotificationsModule } from './notifications/notifications.module.js';
|
||||||
import { DevicesModule } from './devices/devices.module.js';
|
import { DevicesModule } from './devices/devices.module.js';
|
||||||
|
import { DictionariesModule } from './dictionaries/dictionaries.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -20,6 +21,7 @@ import { DevicesModule } from './devices/devices.module.js';
|
|||||||
OrganizationModule,
|
OrganizationModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
DevicesModule,
|
DevicesModule,
|
||||||
|
DictionariesModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -6,7 +6,9 @@ import type { ActorContext } from '../common/actor-context.js';
|
|||||||
*/
|
*/
|
||||||
export const CurrentActor = createParamDecorator(
|
export const CurrentActor = createParamDecorator(
|
||||||
(_data: unknown, context: ExecutionContext): ActorContext => {
|
(_data: unknown, context: ExecutionContext): ActorContext => {
|
||||||
const request = context.switchToHttp().getRequest<{ actor: ActorContext }>();
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<{ actor: ActorContext }>();
|
||||||
return request.actor;
|
return request.actor;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -29,7 +29,9 @@ export class RolesGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest<{ actor?: { role?: Role } }>();
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<{ actor?: { role?: Role } }>();
|
||||||
const actorRole = request.actor?.role;
|
const actorRole = request.actor?.role;
|
||||||
if (!actorRole || !requiredRoles.includes(actorRole)) {
|
if (!actorRole || !requiredRoles.includes(actorRole)) {
|
||||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||||
|
|||||||
@ -24,10 +24,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
// 非 HttpException 统一记录堆栈,便于定位 500 根因。
|
// 非 HttpException 统一记录堆栈,便于定位 500 根因。
|
||||||
if (!(exception instanceof HttpException)) {
|
if (!(exception instanceof HttpException)) {
|
||||||
const error = exception as { message?: string; stack?: string };
|
const error = exception as { message?: string; stack?: string };
|
||||||
this.logger.error(
|
this.logger.error(error?.message ?? 'Unhandled exception', error?.stack);
|
||||||
error?.message ?? 'Unhandled exception',
|
|
||||||
error?.stack,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = this.resolveStatus(exception);
|
const status = this.resolveStatus(exception);
|
||||||
|
|||||||
@ -89,6 +89,12 @@ export const MESSAGES = {
|
|||||||
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号',
|
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号',
|
||||||
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
|
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
|
||||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||||
|
SURGERY_ITEMS_REQUIRED: '手术下至少需要录入一个植入设备',
|
||||||
|
SURGERY_NOT_FOUND: '手术记录不存在或无权限访问',
|
||||||
|
IMPLANT_CATALOG_NOT_FOUND: '植入物型号不存在或不在当前医院可见范围内',
|
||||||
|
SURGERY_UPDATE_NOT_SUPPORTED:
|
||||||
|
'患者更新接口不支持直接修改手术,请使用新增手术接口',
|
||||||
|
ABANDON_DEVICE_SCOPE_FORBIDDEN: '仅可弃用当前患者名下设备',
|
||||||
},
|
},
|
||||||
|
|
||||||
DEVICE: {
|
DEVICE: {
|
||||||
@ -102,6 +108,19 @@ export const MESSAGES = {
|
|||||||
PATIENT_SCOPE_FORBIDDEN: '仅可绑定当前权限范围内患者',
|
PATIENT_SCOPE_FORBIDDEN: '仅可绑定当前权限范围内患者',
|
||||||
DELETE_CONFLICT: '设备存在关联任务记录,无法删除',
|
DELETE_CONFLICT: '设备存在关联任务记录,无法删除',
|
||||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||||
|
CATALOG_NOT_FOUND: '植入物型号不存在',
|
||||||
|
CATALOG_MODEL_DUPLICATE: '植入物型号编码已存在',
|
||||||
|
CATALOG_SCOPE_FORBIDDEN: '当前角色无权限维护该植入物型号',
|
||||||
|
CATALOG_DELETE_CONFLICT: '植入物型号已被患者手术引用,无法删除',
|
||||||
|
PRESSURE_LEVEL_INVALID: '压力值不在该植入物配置的挡位范围内',
|
||||||
|
DEVICE_NOT_ADJUSTABLE: '仅可调压设备允许创建调压任务',
|
||||||
|
},
|
||||||
|
|
||||||
|
DICTIONARY: {
|
||||||
|
NOT_FOUND: '字典项不存在',
|
||||||
|
LABEL_REQUIRED: '字典项名称不能为空',
|
||||||
|
DUPLICATE: '同类型下字典项名称已存在',
|
||||||
|
SYSTEM_ADMIN_ONLY_MAINTAIN: '仅系统管理员可维护字典',
|
||||||
},
|
},
|
||||||
|
|
||||||
ORG: {
|
ORG: {
|
||||||
|
|||||||
24
src/common/transforms/to-boolean.transform.ts
Normal file
24
src/common/transforms/to-boolean.transform.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将常见布尔输入统一转换为 boolean。
|
||||||
|
*/
|
||||||
|
export const ToBoolean = () =>
|
||||||
|
Transform(({ value }) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === 'true' || normalized === '1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === 'false' || normalized === '0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
@ -55,12 +55,7 @@ export class DepartmentsController {
|
|||||||
* 查询科室列表。
|
* 查询科室列表。
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||||
Role.SYSTEM_ADMIN,
|
|
||||||
Role.HOSPITAL_ADMIN,
|
|
||||||
Role.DIRECTOR,
|
|
||||||
Role.LEADER,
|
|
||||||
)
|
|
||||||
@ApiOperation({ summary: '查询科室列表' })
|
@ApiOperation({ summary: '查询科室列表' })
|
||||||
@ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' })
|
@ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' })
|
||||||
findAll(
|
findAll(
|
||||||
@ -74,12 +69,7 @@ export class DepartmentsController {
|
|||||||
* 查询科室详情。
|
* 查询科室详情。
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||||
Role.SYSTEM_ADMIN,
|
|
||||||
Role.HOSPITAL_ADMIN,
|
|
||||||
Role.DIRECTOR,
|
|
||||||
Role.LEADER,
|
|
||||||
)
|
|
||||||
@ApiOperation({ summary: '查询科室详情' })
|
@ApiOperation({ summary: '查询科室详情' })
|
||||||
@ApiParam({ name: 'id', description: '科室 ID' })
|
@ApiParam({ name: 'id', description: '科室 ID' })
|
||||||
findOne(
|
findOne(
|
||||||
@ -93,12 +83,7 @@ export class DepartmentsController {
|
|||||||
* 更新科室。
|
* 更新科室。
|
||||||
*/
|
*/
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||||
Role.SYSTEM_ADMIN,
|
|
||||||
Role.HOSPITAL_ADMIN,
|
|
||||||
Role.DIRECTOR,
|
|
||||||
Role.LEADER,
|
|
||||||
)
|
|
||||||
@ApiOperation({ summary: '更新科室' })
|
@ApiOperation({ summary: '更新科室' })
|
||||||
update(
|
update(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
|
|||||||
@ -29,13 +29,19 @@ export class DepartmentsService {
|
|||||||
*/
|
*/
|
||||||
async create(actor: ActorContext, dto: CreateDepartmentDto) {
|
async create(actor: ActorContext, dto: CreateDepartmentDto) {
|
||||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||||
const hospitalId = this.access.toInt(dto.hospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
const hospitalId = this.access.toInt(
|
||||||
|
dto.hospitalId,
|
||||||
|
MESSAGES.ORG.HOSPITAL_ID_REQUIRED,
|
||||||
|
);
|
||||||
await this.access.ensureHospitalExists(hospitalId);
|
await this.access.ensureHospitalExists(hospitalId);
|
||||||
this.access.assertHospitalScope(actor, hospitalId);
|
this.access.assertHospitalScope(actor, hospitalId);
|
||||||
|
|
||||||
return this.prisma.department.create({
|
return this.prisma.department.create({
|
||||||
data: {
|
data: {
|
||||||
name: this.access.normalizeName(dto.name, MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED),
|
name: this.access.normalizeName(
|
||||||
|
dto.name,
|
||||||
|
MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED,
|
||||||
|
),
|
||||||
hospitalId,
|
hospitalId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -73,7 +79,10 @@ export class DepartmentsService {
|
|||||||
this.prisma.department.count({ where }),
|
this.prisma.department.count({ where }),
|
||||||
this.prisma.department.findMany({
|
this.prisma.department.findMany({
|
||||||
where,
|
where,
|
||||||
include: { hospital: true, _count: { select: { users: true, groups: true } } },
|
include: {
|
||||||
|
hospital: true,
|
||||||
|
_count: { select: { users: true, groups: true } },
|
||||||
|
},
|
||||||
skip: paging.skip,
|
skip: paging.skip,
|
||||||
take: paging.take,
|
take: paging.take,
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
@ -93,7 +102,10 @@ export class DepartmentsService {
|
|||||||
Role.DIRECTOR,
|
Role.DIRECTOR,
|
||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
]);
|
]);
|
||||||
const departmentId = this.access.toInt(id, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
|
const departmentId = this.access.toInt(
|
||||||
|
id,
|
||||||
|
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
|
||||||
|
);
|
||||||
const department = await this.prisma.department.findUnique({
|
const department = await this.prisma.department.findUnique({
|
||||||
where: { id: departmentId },
|
where: { id: departmentId },
|
||||||
include: {
|
include: {
|
||||||
@ -128,7 +140,10 @@ export class DepartmentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.name !== undefined) {
|
if (dto.name !== undefined) {
|
||||||
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED);
|
data.name = this.access.normalizeName(
|
||||||
|
dto.name,
|
||||||
|
MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.department.update({
|
return this.prisma.department.update({
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||||
@ -22,8 +23,10 @@ import { Roles } from '../../auth/roles.decorator.js';
|
|||||||
import { RolesGuard } from '../../auth/roles.guard.js';
|
import { RolesGuard } from '../../auth/roles.guard.js';
|
||||||
import type { ActorContext } from '../../common/actor-context.js';
|
import type { ActorContext } from '../../common/actor-context.js';
|
||||||
import { Role } from '../../generated/prisma/enums.js';
|
import { Role } from '../../generated/prisma/enums.js';
|
||||||
|
import { CreateImplantCatalogDto } from '../dto/create-implant-catalog.dto.js';
|
||||||
import { CreateDeviceDto } from '../dto/create-device.dto.js';
|
import { CreateDeviceDto } from '../dto/create-device.dto.js';
|
||||||
import { DeviceQueryDto } from '../dto/device-query.dto.js';
|
import { DeviceQueryDto } from '../dto/device-query.dto.js';
|
||||||
|
import { UpdateImplantCatalogDto } from '../dto/update-implant-catalog.dto.js';
|
||||||
import { UpdateDeviceDto } from '../dto/update-device.dto.js';
|
import { UpdateDeviceDto } from '../dto/update-device.dto.js';
|
||||||
import { DevicesService } from '../devices.service.js';
|
import { DevicesService } from '../devices.service.js';
|
||||||
|
|
||||||
@ -37,6 +40,73 @@ import { DevicesService } from '../devices.service.js';
|
|||||||
export class BDevicesController {
|
export class BDevicesController {
|
||||||
constructor(private readonly devicesService: DevicesService) {}
|
constructor(private readonly devicesService: DevicesService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询可见植入物型号字典。
|
||||||
|
*/
|
||||||
|
@Get('catalogs')
|
||||||
|
@Roles(
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
|
Role.ENGINEER,
|
||||||
|
)
|
||||||
|
@ApiOperation({ summary: '查询植入物型号字典' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'keyword',
|
||||||
|
required: false,
|
||||||
|
description: '支持按型号、厂家、名称模糊查询',
|
||||||
|
})
|
||||||
|
findCatalogs(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
) {
|
||||||
|
return this.devicesService.findCatalogs(actor, keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增植入物型号字典。
|
||||||
|
*/
|
||||||
|
@Post('catalogs')
|
||||||
|
@Roles(Role.SYSTEM_ADMIN)
|
||||||
|
@ApiOperation({ summary: '新增植入物目录(SYSTEM_ADMIN)' })
|
||||||
|
createCatalog(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Body() dto: CreateImplantCatalogDto,
|
||||||
|
) {
|
||||||
|
return this.devicesService.createCatalog(actor, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新植入物型号字典。
|
||||||
|
*/
|
||||||
|
@Patch('catalogs/:id')
|
||||||
|
@Roles(Role.SYSTEM_ADMIN)
|
||||||
|
@ApiOperation({ summary: '更新植入物目录(SYSTEM_ADMIN)' })
|
||||||
|
@ApiParam({ name: 'id', description: '型号字典 ID' })
|
||||||
|
updateCatalog(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateImplantCatalogDto,
|
||||||
|
) {
|
||||||
|
return this.devicesService.updateCatalog(actor, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除植入物目录。
|
||||||
|
*/
|
||||||
|
@Delete('catalogs/:id')
|
||||||
|
@Roles(Role.SYSTEM_ADMIN)
|
||||||
|
@ApiOperation({ summary: '删除植入物目录(SYSTEM_ADMIN)' })
|
||||||
|
@ApiParam({ name: 'id', description: '型号字典 ID' })
|
||||||
|
removeCatalog(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
) {
|
||||||
|
return this.devicesService.removeCatalog(actor, id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询设备列表。
|
* 查询设备列表。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -10,15 +10,30 @@ import { DeviceStatus, Role } from '../generated/prisma/enums.js';
|
|||||||
import type { ActorContext } from '../common/actor-context.js';
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
import { MESSAGES } from '../common/messages.js';
|
import { MESSAGES } from '../common/messages.js';
|
||||||
import { PrismaService } from '../prisma.service.js';
|
import { PrismaService } from '../prisma.service.js';
|
||||||
|
import { CreateImplantCatalogDto } from './dto/create-implant-catalog.dto.js';
|
||||||
import { CreateDeviceDto } from './dto/create-device.dto.js';
|
import { CreateDeviceDto } from './dto/create-device.dto.js';
|
||||||
import { DeviceQueryDto } from './dto/device-query.dto.js';
|
import { DeviceQueryDto } from './dto/device-query.dto.js';
|
||||||
|
import { UpdateImplantCatalogDto } from './dto/update-implant-catalog.dto.js';
|
||||||
import { UpdateDeviceDto } from './dto/update-device.dto.js';
|
import { UpdateDeviceDto } from './dto/update-device.dto.js';
|
||||||
|
|
||||||
|
const CATALOG_SELECT = {
|
||||||
|
id: true,
|
||||||
|
modelCode: true,
|
||||||
|
manufacturer: true,
|
||||||
|
name: true,
|
||||||
|
pressureLevels: true,
|
||||||
|
isPressureAdjustable: true,
|
||||||
|
notes: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
const DEVICE_DETAIL_INCLUDE = {
|
const DEVICE_DETAIL_INCLUDE = {
|
||||||
patient: {
|
patient: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
inpatientNo: true,
|
||||||
phone: true,
|
phone: true,
|
||||||
hospitalId: true,
|
hospitalId: true,
|
||||||
hospital: {
|
hospital: {
|
||||||
@ -36,6 +51,17 @@ const DEVICE_DETAIL_INCLUDE = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
surgery: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
surgeryDate: true,
|
||||||
|
surgeryName: true,
|
||||||
|
surgeonName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
implantCatalog: {
|
||||||
|
select: CATALOG_SELECT,
|
||||||
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
taskItems: true,
|
taskItems: true,
|
||||||
@ -44,7 +70,7 @@ const DEVICE_DETAIL_INCLUDE = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设备服务:承载管理员设备 CRUD、租户隔离与分页筛选。
|
* 设备服务:承载患者植入实例 CRUD 与全局植入物目录维护。
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DevicesService {
|
export class DevicesService {
|
||||||
@ -114,7 +140,8 @@ export class DevicesService {
|
|||||||
return this.prisma.device.create({
|
return this.prisma.device.create({
|
||||||
data: {
|
data: {
|
||||||
snCode,
|
snCode,
|
||||||
currentPressure: this.normalizePressure(dto.currentPressure),
|
// 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。
|
||||||
|
currentPressure: 0,
|
||||||
status: dto.status ?? DeviceStatus.ACTIVE,
|
status: dto.status ?? DeviceStatus.ACTIVE,
|
||||||
patientId: patient.id,
|
patientId: patient.id,
|
||||||
},
|
},
|
||||||
@ -123,7 +150,7 @@ export class DevicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新设备:允许修改 SN、当前压力、状态和归属患者。
|
* 更新设备:允许修改 SN、状态和归属患者;当前压力仅由任务完成时更新。
|
||||||
*/
|
*/
|
||||||
async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) {
|
async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) {
|
||||||
const current = await this.findOne(actor, id);
|
const current = await this.findOne(actor, id);
|
||||||
@ -134,9 +161,6 @@ export class DevicesService {
|
|||||||
await this.assertSnCodeUnique(snCode, current.id);
|
await this.assertSnCodeUnique(snCode, current.id);
|
||||||
data.snCode = snCode;
|
data.snCode = snCode;
|
||||||
}
|
}
|
||||||
if (dto.currentPressure !== undefined) {
|
|
||||||
data.currentPressure = this.normalizePressure(dto.currentPressure);
|
|
||||||
}
|
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
data.status = this.normalizeStatus(dto.status);
|
data.status = this.normalizeStatus(dto.status);
|
||||||
}
|
}
|
||||||
@ -174,6 +198,142 @@ export class DevicesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前角色可见的植入物型号字典。
|
||||||
|
*/
|
||||||
|
async findCatalogs(actor: ActorContext, keyword?: string) {
|
||||||
|
this.assertCatalogReadable(actor);
|
||||||
|
|
||||||
|
const where = this.buildCatalogWhere(keyword);
|
||||||
|
|
||||||
|
return this.prisma.implantCatalog.findMany({
|
||||||
|
where,
|
||||||
|
select: CATALOG_SELECT,
|
||||||
|
orderBy: [{ manufacturer: 'asc' }, { name: 'asc' }, { modelCode: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增植入物型号字典。
|
||||||
|
*/
|
||||||
|
async createCatalog(actor: ActorContext, dto: CreateImplantCatalogDto) {
|
||||||
|
this.assertSystemAdmin(actor);
|
||||||
|
const isPressureAdjustable = dto.isPressureAdjustable ?? true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.implantCatalog.create({
|
||||||
|
data: {
|
||||||
|
modelCode: this.normalizeModelCode(dto.modelCode),
|
||||||
|
manufacturer: this.normalizeRequiredString(
|
||||||
|
dto.manufacturer,
|
||||||
|
'manufacturer',
|
||||||
|
),
|
||||||
|
name: this.normalizeRequiredString(dto.name, 'name'),
|
||||||
|
pressureLevels: this.normalizePressureLevels(
|
||||||
|
dto.pressureLevels,
|
||||||
|
isPressureAdjustable,
|
||||||
|
),
|
||||||
|
isPressureAdjustable,
|
||||||
|
notes:
|
||||||
|
dto.notes === undefined
|
||||||
|
? undefined
|
||||||
|
: this.normalizeNullableString(dto.notes, 'notes'),
|
||||||
|
},
|
||||||
|
select: CATALOG_SELECT,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新植入物型号字典。
|
||||||
|
*/
|
||||||
|
async updateCatalog(
|
||||||
|
actor: ActorContext,
|
||||||
|
id: number,
|
||||||
|
dto: UpdateImplantCatalogDto,
|
||||||
|
) {
|
||||||
|
this.assertSystemAdmin(actor);
|
||||||
|
const current = await this.findWritableCatalog(id);
|
||||||
|
const nextIsPressureAdjustable =
|
||||||
|
dto.isPressureAdjustable ?? current.isPressureAdjustable;
|
||||||
|
|
||||||
|
const data: Prisma.ImplantCatalogUpdateInput = {};
|
||||||
|
if (dto.modelCode !== undefined) {
|
||||||
|
data.modelCode = this.normalizeModelCode(dto.modelCode);
|
||||||
|
}
|
||||||
|
if (dto.manufacturer !== undefined) {
|
||||||
|
data.manufacturer = this.normalizeRequiredString(
|
||||||
|
dto.manufacturer,
|
||||||
|
'manufacturer',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
data.name = this.normalizeRequiredString(dto.name, 'name');
|
||||||
|
}
|
||||||
|
if (dto.isPressureAdjustable !== undefined) {
|
||||||
|
data.isPressureAdjustable = dto.isPressureAdjustable;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
dto.pressureLevels !== undefined ||
|
||||||
|
dto.isPressureAdjustable !== undefined
|
||||||
|
) {
|
||||||
|
data.pressureLevels = this.normalizePressureLevels(
|
||||||
|
dto.pressureLevels ?? current.pressureLevels,
|
||||||
|
nextIsPressureAdjustable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
data.notes = this.normalizeNullableString(dto.notes, 'notes');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.implantCatalog.update({
|
||||||
|
where: { id: current.id },
|
||||||
|
data,
|
||||||
|
select: CATALOG_SELECT,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除植入物目录:若已被患者手术引用,则返回 409。
|
||||||
|
*/
|
||||||
|
async removeCatalog(actor: ActorContext, id: number) {
|
||||||
|
this.assertSystemAdmin(actor);
|
||||||
|
const current = await this.findWritableCatalog(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.implantCatalog.delete({
|
||||||
|
where: { id: current.id },
|
||||||
|
select: CATALOG_SELECT,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
(error.code === 'P2003' || error.code === 'P2014')
|
||||||
|
) {
|
||||||
|
throw new ConflictException(MESSAGES.DEVICE.CATALOG_DELETE_CONFLICT);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构造列表筛选:支持按医院、患者、状态和关键词组合查询。
|
* 构造列表筛选:支持按医院、患者、状态和关键词组合查询。
|
||||||
*/
|
*/
|
||||||
@ -212,6 +372,18 @@ export class DevicesService {
|
|||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
implantModel: {
|
||||||
|
contains: keyword,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
implantName: {
|
||||||
|
contains: keyword,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
patient: {
|
patient: {
|
||||||
is: {
|
is: {
|
||||||
@ -238,6 +410,47 @@ export class DevicesService {
|
|||||||
return andConditions.length > 0 ? { AND: andConditions } : {};
|
return andConditions.length > 0 ? { AND: andConditions } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造型号字典查询条件。
|
||||||
|
*/
|
||||||
|
private buildCatalogWhere(keyword?: string): Prisma.ImplantCatalogWhereInput {
|
||||||
|
const andConditions: Prisma.ImplantCatalogWhereInput[] = [];
|
||||||
|
const normalizedKeyword = keyword?.trim();
|
||||||
|
|
||||||
|
if (normalizedKeyword) {
|
||||||
|
andConditions.push({
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
modelCode: {
|
||||||
|
contains: normalizedKeyword,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
manufacturer: {
|
||||||
|
contains: normalizedKeyword,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
contains: normalizedKeyword,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notes: {
|
||||||
|
contains: normalizedKeyword,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return andConditions.length > 0 ? { AND: andConditions } : {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析列表分页。
|
* 解析列表分页。
|
||||||
*/
|
*/
|
||||||
@ -301,6 +514,23 @@ export class DevicesService {
|
|||||||
return patient;
|
return patient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前管理员可写的型号字典。
|
||||||
|
*/
|
||||||
|
private async findWritableCatalog(id: number) {
|
||||||
|
const catalogId = this.toInt(id, 'id');
|
||||||
|
const catalog = await this.prisma.implantCatalog.findUnique({
|
||||||
|
where: { id: catalogId },
|
||||||
|
select: CATALOG_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!catalog) {
|
||||||
|
throw new NotFoundException(MESSAGES.DEVICE.CATALOG_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验当前用户是否可读/写该设备。
|
* 校验当前用户是否可读/写该设备。
|
||||||
*/
|
*/
|
||||||
@ -315,7 +545,7 @@ export class DevicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理员角色校验:仅系统管理员与院管可操作设备。
|
* 管理员角色校验:仅系统管理员与院管可操作患者植入实例。
|
||||||
*/
|
*/
|
||||||
private assertAdmin(actor: ActorContext) {
|
private assertAdmin(actor: ActorContext) {
|
||||||
if (
|
if (
|
||||||
@ -326,21 +556,98 @@ export class DevicesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 型号字典读权限:B 端全部已登录角色可访问。
|
||||||
|
*/
|
||||||
|
private assertCatalogReadable(actor: ActorContext) {
|
||||||
|
if (
|
||||||
|
actor.role === Role.SYSTEM_ADMIN ||
|
||||||
|
actor.role === Role.HOSPITAL_ADMIN ||
|
||||||
|
actor.role === Role.DIRECTOR ||
|
||||||
|
actor.role === Role.LEADER ||
|
||||||
|
actor.role === Role.DOCTOR ||
|
||||||
|
actor.role === Role.ENGINEER
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局植入物目录仅系统管理员可维护。
|
||||||
|
*/
|
||||||
|
private assertSystemAdmin(actor: ActorContext) {
|
||||||
|
if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||||
|
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 型号编码标准化:统一去空白并转大写。
|
||||||
|
*/
|
||||||
|
private normalizeModelCode(value: unknown) {
|
||||||
|
return this.normalizeRequiredString(value, 'modelCode').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设备 SN 标准化:统一去空白并转大写,避免大小写重复。
|
* 设备 SN 标准化:统一去空白并转大写,避免大小写重复。
|
||||||
*/
|
*/
|
||||||
private normalizeSnCode(value: unknown) {
|
private normalizeSnCode(value: unknown) {
|
||||||
if (typeof value !== 'string') {
|
return this.normalizeRequiredString(value, 'snCode').toUpperCase();
|
||||||
throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = value.trim().toUpperCase();
|
private normalizeRequiredString(value: unknown, fieldName: string) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new BadRequestException(`${fieldName} 必须是字符串`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED);
|
throw new BadRequestException(`${fieldName} 不能为空`);
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeNullableString(value: unknown, fieldName: string) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new BadRequestException(`${fieldName} 必须是字符串`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挡位列表标准化:去重、排序,并在非可调压目录下自动清空。
|
||||||
|
*/
|
||||||
|
private normalizePressureLevels(
|
||||||
|
pressureLevels: number[] | undefined,
|
||||||
|
isPressureAdjustable: boolean,
|
||||||
|
) {
|
||||||
|
if (!isPressureAdjustable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(pressureLevels) || pressureLevels.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
pressureLevels.map((level) => {
|
||||||
|
const normalized = Number(level);
|
||||||
|
if (!Number.isInteger(normalized) || normalized < 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'pressureLevels 必须为大于等于 0 的整数数组',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).sort((left, right) => left - right);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 压力值必须是非负整数。
|
* 压力值必须是非负整数。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -11,12 +11,6 @@ export class CreateDeviceDto {
|
|||||||
@IsString({ message: 'snCode 必须是字符串' })
|
@IsString({ message: 'snCode 必须是字符串' })
|
||||||
snCode!: string;
|
snCode!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '当前压力值', example: 120 })
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt({ message: 'currentPressure 必须是整数' })
|
|
||||||
@Min(0, { message: 'currentPressure 必须大于等于 0' })
|
|
||||||
currentPressure!: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '设备状态,默认 ACTIVE',
|
description: '设备状态,默认 ACTIVE',
|
||||||
enum: DeviceStatus,
|
enum: DeviceStatus,
|
||||||
|
|||||||
70
src/devices/dto/create-implant-catalog.dto.ts
Normal file
70
src/devices/dto/create-implant-catalog.dto.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 植入物目录创建 DTO。
|
||||||
|
*/
|
||||||
|
export class CreateImplantCatalogDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '型号编码',
|
||||||
|
example: 'CODMAN-HAKIM-120',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'modelCode 必须是字符串' })
|
||||||
|
modelCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '厂家',
|
||||||
|
example: 'Codman',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'manufacturer 必须是字符串' })
|
||||||
|
manufacturer!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '名称',
|
||||||
|
example: 'Hakim 可调压阀',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'name 必须是字符串' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '可调压器械的挡位列表,按整数录入',
|
||||||
|
type: [Number],
|
||||||
|
example: [80, 100, 120, 140],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: 'pressureLevels 必须是数组' })
|
||||||
|
@ArrayMaxSize(30, { message: 'pressureLevels 最多 30 项' })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ each: true, message: 'pressureLevels 必须为整数数组' })
|
||||||
|
@Min(0, { each: true, message: 'pressureLevels 必须大于等于 0' })
|
||||||
|
pressureLevels?: number[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '是否支持调压,默认 true',
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@ToBoolean()
|
||||||
|
@IsBoolean({ message: 'isPressureAdjustable 必须是布尔值' })
|
||||||
|
isPressureAdjustable?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '植入物备注',
|
||||||
|
example: '适用于儿童脑积水病例',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'notes 必须是字符串' })
|
||||||
|
@MaxLength(200, { message: 'notes 最长 200 个字符' })
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
9
src/devices/dto/update-implant-catalog.dto.ts
Normal file
9
src/devices/dto/update-implant-catalog.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateImplantCatalogDto } from './create-implant-catalog.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 植入物型号更新 DTO。
|
||||||
|
*/
|
||||||
|
export class UpdateImplantCatalogDto extends PartialType(
|
||||||
|
CreateImplantCatalogDto,
|
||||||
|
) {}
|
||||||
112
src/dictionaries/b-dictionaries/b-dictionaries.controller.ts
Normal file
112
src/dictionaries/b-dictionaries/b-dictionaries.controller.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||||
|
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||||
|
import { Roles } from '../../auth/roles.decorator.js';
|
||||||
|
import { RolesGuard } from '../../auth/roles.guard.js';
|
||||||
|
import type { ActorContext } from '../../common/actor-context.js';
|
||||||
|
import { Role } from '../../generated/prisma/enums.js';
|
||||||
|
import { CreateDictionaryItemDto } from '../dto/create-dictionary-item.dto.js';
|
||||||
|
import { DictionaryQueryDto } from '../dto/dictionary-query.dto.js';
|
||||||
|
import { UpdateDictionaryItemDto } from '../dto/update-dictionary-item.dto.js';
|
||||||
|
import { DictionariesService } from '../dictionaries.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B 端字典控制器:供患者表单读取和系统管理员维护选项字典。
|
||||||
|
*/
|
||||||
|
@ApiTags('字典管理(B端)')
|
||||||
|
@ApiBearerAuth('bearer')
|
||||||
|
@Controller('b/dictionaries')
|
||||||
|
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||||
|
export class BDictionariesController {
|
||||||
|
constructor(private readonly dictionariesService: DictionariesService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询字典项列表。
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@Roles(
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
|
Role.ENGINEER,
|
||||||
|
)
|
||||||
|
@ApiOperation({ summary: '查询系统字典' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'type',
|
||||||
|
required: false,
|
||||||
|
description: '字典类型,不传返回全部类型',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'includeDisabled',
|
||||||
|
required: false,
|
||||||
|
description: '是否包含停用项,仅系统管理员生效',
|
||||||
|
})
|
||||||
|
findAll(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Query() query: DictionaryQueryDto,
|
||||||
|
) {
|
||||||
|
return this.dictionariesService.findAll(actor, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建字典项。
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@Roles(Role.SYSTEM_ADMIN)
|
||||||
|
@ApiOperation({ summary: '创建系统字典项(SYSTEM_ADMIN)' })
|
||||||
|
create(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Body() dto: CreateDictionaryItemDto,
|
||||||
|
) {
|
||||||
|
return this.dictionariesService.create(actor, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新字典项。
|
||||||
|
*/
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles(Role.SYSTEM_ADMIN)
|
||||||
|
@ApiOperation({ summary: '更新系统字典项(SYSTEM_ADMIN)' })
|
||||||
|
@ApiParam({ name: 'id', description: '字典项 ID' })
|
||||||
|
update(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateDictionaryItemDto,
|
||||||
|
) {
|
||||||
|
return this.dictionariesService.update(actor, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除字典项。
|
||||||
|
*/
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles(Role.SYSTEM_ADMIN)
|
||||||
|
@ApiOperation({ summary: '删除系统字典项(SYSTEM_ADMIN)' })
|
||||||
|
@ApiParam({ name: 'id', description: '字典项 ID' })
|
||||||
|
remove(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
) {
|
||||||
|
return this.dictionariesService.remove(actor, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/dictionaries/dictionaries.module.ts
Normal file
12
src/dictionaries/dictionaries.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard.js';
|
||||||
|
import { BDictionariesController } from './b-dictionaries/b-dictionaries.controller.js';
|
||||||
|
import { DictionariesService } from './dictionaries.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BDictionariesController],
|
||||||
|
providers: [DictionariesService, AccessTokenGuard, RolesGuard],
|
||||||
|
exports: [DictionariesService],
|
||||||
|
})
|
||||||
|
export class DictionariesModule {}
|
||||||
156
src/dictionaries/dictionaries.service.ts
Normal file
156
src/dictionaries/dictionaries.service.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '../generated/prisma/client.js';
|
||||||
|
import { Role } from '../generated/prisma/enums.js';
|
||||||
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
|
import { MESSAGES } from '../common/messages.js';
|
||||||
|
import { PrismaService } from '../prisma.service.js';
|
||||||
|
import { CreateDictionaryItemDto } from './dto/create-dictionary-item.dto.js';
|
||||||
|
import { DictionaryQueryDto } from './dto/dictionary-query.dto.js';
|
||||||
|
import { UpdateDictionaryItemDto } from './dto/update-dictionary-item.dto.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DictionariesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询字典项列表。
|
||||||
|
*/
|
||||||
|
async findAll(actor: ActorContext, query: DictionaryQueryDto) {
|
||||||
|
const where: Prisma.DictionaryItemWhereInput = {};
|
||||||
|
|
||||||
|
if (query.type) {
|
||||||
|
where.type = query.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非系统管理员一律只看启用项,避免业务页面误拿到停用值。
|
||||||
|
if (actor.role !== Role.SYSTEM_ADMIN || !query.includeDisabled) {
|
||||||
|
where.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.dictionaryItem.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建字典项:仅系统管理员可维护。
|
||||||
|
*/
|
||||||
|
async create(actor: ActorContext, dto: CreateDictionaryItemDto) {
|
||||||
|
this.assertSystemAdmin(actor);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.dictionaryItem.create({
|
||||||
|
data: {
|
||||||
|
type: dto.type,
|
||||||
|
label: this.normalizeLabel(dto.label),
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
enabled: dto.enabled ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.handleDuplicate(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新字典项:支持调整分类、排序和启停状态。
|
||||||
|
*/
|
||||||
|
async update(actor: ActorContext, id: number, dto: UpdateDictionaryItemDto) {
|
||||||
|
this.assertSystemAdmin(actor);
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
const data: Prisma.DictionaryItemUpdateInput = {};
|
||||||
|
if (dto.type !== undefined) {
|
||||||
|
data.type = dto.type;
|
||||||
|
}
|
||||||
|
if (dto.label !== undefined) {
|
||||||
|
data.label = this.normalizeLabel(dto.label);
|
||||||
|
}
|
||||||
|
if (dto.sortOrder !== undefined) {
|
||||||
|
data.sortOrder = dto.sortOrder;
|
||||||
|
}
|
||||||
|
if (dto.enabled !== undefined) {
|
||||||
|
data.enabled = dto.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.dictionaryItem.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.handleDuplicate(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除字典项。
|
||||||
|
*/
|
||||||
|
async remove(actor: ActorContext, id: number) {
|
||||||
|
this.assertSystemAdmin(actor);
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
return this.prisma.dictionaryItem.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化字典名称并确保非空。
|
||||||
|
*/
|
||||||
|
private normalizeLabel(value: string) {
|
||||||
|
const label = value?.trim();
|
||||||
|
if (!label) {
|
||||||
|
throw new BadRequestException(MESSAGES.DICTIONARY.LABEL_REQUIRED);
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验系统管理员权限。
|
||||||
|
*/
|
||||||
|
private assertSystemAdmin(actor: ActorContext) {
|
||||||
|
if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
MESSAGES.DICTIONARY.SYSTEM_ADMIN_ONLY_MAINTAIN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认字典项存在。
|
||||||
|
*/
|
||||||
|
private async ensureExists(id: number) {
|
||||||
|
const current = await this.prisma.dictionaryItem.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
throw new NotFoundException(MESSAGES.DICTIONARY.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一处理唯一键冲突。
|
||||||
|
*/
|
||||||
|
private handleDuplicate(error: unknown) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException(MESSAGES.DICTIONARY.DUPLICATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/dictionaries/dto/create-dictionary-item.dto.ts
Normal file
55
src/dictionaries/dto/create-dictionary-item.dto.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
|
||||||
|
import { DictionaryType } from '../../generated/prisma/enums.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统字典项创建 DTO。
|
||||||
|
*/
|
||||||
|
export class CreateDictionaryItemDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '字典类型',
|
||||||
|
enum: DictionaryType,
|
||||||
|
example: DictionaryType.PRIMARY_DISEASE,
|
||||||
|
})
|
||||||
|
@IsEnum(DictionaryType, { message: 'type 枚举值不合法' })
|
||||||
|
type!: DictionaryType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '字典项名称',
|
||||||
|
example: '先天性脑积水',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'label 必须是字符串' })
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '排序值,越小越靠前,默认 0',
|
||||||
|
example: 10,
|
||||||
|
default: 0,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'sortOrder 必须是整数' })
|
||||||
|
@Min(-9999, { message: 'sortOrder 不能小于 -9999' })
|
||||||
|
@Max(9999, { message: 'sortOrder 不能大于 9999' })
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '是否启用,默认 true',
|
||||||
|
example: true,
|
||||||
|
default: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@ToBoolean()
|
||||||
|
@IsBoolean({ message: 'enabled 必须是布尔值' })
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
30
src/dictionaries/dto/dictionary-query.dto.ts
Normal file
30
src/dictionaries/dto/dictionary-query.dto.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsEnum, IsOptional } from 'class-validator';
|
||||||
|
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||||
|
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
|
||||||
|
import { DictionaryType } from '../../generated/prisma/enums.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字典查询 DTO:支持按类型筛选,并允许系统管理员查看停用项。
|
||||||
|
*/
|
||||||
|
export class DictionaryQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '字典类型,不传返回全部类型',
|
||||||
|
enum: DictionaryType,
|
||||||
|
example: DictionaryType.PRIMARY_DISEASE,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@IsEnum(DictionaryType, { message: 'type 枚举值不合法' })
|
||||||
|
type?: DictionaryType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '是否包含停用项,仅系统管理员生效',
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@ToBoolean()
|
||||||
|
@IsBoolean({ message: 'includeDisabled 必须是布尔值' })
|
||||||
|
includeDisabled?: boolean;
|
||||||
|
}
|
||||||
9
src/dictionaries/dto/update-dictionary-item.dto.ts
Normal file
9
src/dictionaries/dto/update-dictionary-item.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateDictionaryItemDto } from './create-dictionary-item.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统字典项更新 DTO。
|
||||||
|
*/
|
||||||
|
export class UpdateDictionaryItemDto extends PartialType(
|
||||||
|
CreateDictionaryItemDto,
|
||||||
|
) {}
|
||||||
@ -43,10 +43,7 @@ export class GroupsController {
|
|||||||
@Post()
|
@Post()
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||||
@ApiOperation({ summary: '创建小组' })
|
@ApiOperation({ summary: '创建小组' })
|
||||||
create(
|
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateGroupDto) {
|
||||||
@CurrentActor() actor: ActorContext,
|
|
||||||
@Body() dto: CreateGroupDto,
|
|
||||||
) {
|
|
||||||
return this.groupsService.create(actor, dto);
|
return this.groupsService.create(actor, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,12 +51,7 @@ export class GroupsController {
|
|||||||
* 查询小组列表。
|
* 查询小组列表。
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||||
Role.SYSTEM_ADMIN,
|
|
||||||
Role.HOSPITAL_ADMIN,
|
|
||||||
Role.DIRECTOR,
|
|
||||||
Role.LEADER,
|
|
||||||
)
|
|
||||||
@ApiOperation({ summary: '查询小组列表' })
|
@ApiOperation({ summary: '查询小组列表' })
|
||||||
findAll(
|
findAll(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@ -72,12 +64,7 @@ export class GroupsController {
|
|||||||
* 查询小组详情。
|
* 查询小组详情。
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||||
Role.SYSTEM_ADMIN,
|
|
||||||
Role.HOSPITAL_ADMIN,
|
|
||||||
Role.DIRECTOR,
|
|
||||||
Role.LEADER,
|
|
||||||
)
|
|
||||||
@ApiOperation({ summary: '查询小组详情' })
|
@ApiOperation({ summary: '查询小组详情' })
|
||||||
@ApiParam({ name: 'id', description: '小组 ID' })
|
@ApiParam({ name: 'id', description: '小组 ID' })
|
||||||
findOne(
|
findOne(
|
||||||
@ -91,12 +78,7 @@ export class GroupsController {
|
|||||||
* 更新小组。
|
* 更新小组。
|
||||||
*/
|
*/
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||||
Role.SYSTEM_ADMIN,
|
|
||||||
Role.HOSPITAL_ADMIN,
|
|
||||||
Role.DIRECTOR,
|
|
||||||
Role.LEADER,
|
|
||||||
)
|
|
||||||
@ApiOperation({ summary: '更新小组' })
|
@ApiOperation({ summary: '更新小组' })
|
||||||
update(
|
update(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
|
|||||||
@ -10,7 +10,12 @@ import { OrganizationAccessService } from '../organization-common/organization-a
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [GroupsController],
|
controllers: [GroupsController],
|
||||||
providers: [GroupsService, OrganizationAccessService, AccessTokenGuard, RolesGuard],
|
providers: [
|
||||||
|
GroupsService,
|
||||||
|
OrganizationAccessService,
|
||||||
|
AccessTokenGuard,
|
||||||
|
RolesGuard,
|
||||||
|
],
|
||||||
exports: [GroupsService],
|
exports: [GroupsService],
|
||||||
})
|
})
|
||||||
export class GroupsModule {}
|
export class GroupsModule {}
|
||||||
|
|||||||
@ -43,10 +43,7 @@ export class HospitalsController {
|
|||||||
@Post()
|
@Post()
|
||||||
@Roles(Role.SYSTEM_ADMIN)
|
@Roles(Role.SYSTEM_ADMIN)
|
||||||
@ApiOperation({ summary: '创建医院(SYSTEM_ADMIN)' })
|
@ApiOperation({ summary: '创建医院(SYSTEM_ADMIN)' })
|
||||||
create(
|
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateHospitalDto) {
|
||||||
@CurrentActor() actor: ActorContext,
|
|
||||||
@Body() dto: CreateHospitalDto,
|
|
||||||
) {
|
|
||||||
return this.hospitalsService.create(actor, dto);
|
return this.hospitalsService.create(actor, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,12 +51,7 @@ export class HospitalsController {
|
|||||||
* 查询医院列表(系统管理员全量,院管仅本院)。
|
* 查询医院列表(系统管理员全量,院管仅本院)。
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||||
Role.SYSTEM_ADMIN,
|
|
||||||
Role.HOSPITAL_ADMIN,
|
|
||||||
Role.DIRECTOR,
|
|
||||||
Role.LEADER,
|
|
||||||
)
|
|
||||||
@ApiOperation({ summary: '查询医院列表' })
|
@ApiOperation({ summary: '查询医院列表' })
|
||||||
findAll(
|
findAll(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@ -72,12 +64,7 @@ export class HospitalsController {
|
|||||||
* 查询医院详情。
|
* 查询医院详情。
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
|
||||||
Role.SYSTEM_ADMIN,
|
|
||||||
Role.HOSPITAL_ADMIN,
|
|
||||||
Role.DIRECTOR,
|
|
||||||
Role.LEADER,
|
|
||||||
)
|
|
||||||
@ApiOperation({ summary: '查询医院详情' })
|
@ApiOperation({ summary: '查询医院详情' })
|
||||||
@ApiParam({ name: 'id', description: '医院 ID' })
|
@ApiParam({ name: 'id', description: '医院 ID' })
|
||||||
findOne(
|
findOne(
|
||||||
|
|||||||
@ -23,10 +23,16 @@ export class HospitalsService {
|
|||||||
* 创建医院:仅系统管理员可调用。
|
* 创建医院:仅系统管理员可调用。
|
||||||
*/
|
*/
|
||||||
async create(actor: ActorContext, dto: CreateHospitalDto) {
|
async create(actor: ActorContext, dto: CreateHospitalDto) {
|
||||||
this.access.assertSystemAdmin(actor, MESSAGES.ORG.SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL);
|
this.access.assertSystemAdmin(
|
||||||
|
actor,
|
||||||
|
MESSAGES.ORG.SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL,
|
||||||
|
);
|
||||||
return this.prisma.hospital.create({
|
return this.prisma.hospital.create({
|
||||||
data: {
|
data: {
|
||||||
name: this.access.normalizeName(dto.name, MESSAGES.ORG.HOSPITAL_NAME_REQUIRED),
|
name: this.access.normalizeName(
|
||||||
|
dto.name,
|
||||||
|
MESSAGES.ORG.HOSPITAL_NAME_REQUIRED,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -79,7 +85,12 @@ export class HospitalsService {
|
|||||||
where: { id: hospitalId },
|
where: { id: hospitalId },
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { departments: true, users: true, patients: true, tasks: true },
|
select: {
|
||||||
|
departments: true,
|
||||||
|
users: true,
|
||||||
|
patients: true,
|
||||||
|
tasks: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -97,7 +108,10 @@ export class HospitalsService {
|
|||||||
const current = await this.findOne(actor, id);
|
const current = await this.findOne(actor, id);
|
||||||
const data: Prisma.HospitalUpdateInput = {};
|
const data: Prisma.HospitalUpdateInput = {};
|
||||||
if (dto.name !== undefined) {
|
if (dto.name !== undefined) {
|
||||||
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.HOSPITAL_NAME_REQUIRED);
|
data.name = this.access.normalizeName(
|
||||||
|
dto.name,
|
||||||
|
MESSAGES.ORG.HOSPITAL_NAME_REQUIRED,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return this.prisma.hospital.update({
|
return this.prisma.hospital.update({
|
||||||
where: { id: current.id },
|
where: { id: current.id },
|
||||||
@ -109,7 +123,10 @@ export class HospitalsService {
|
|||||||
* 删除医院:仅系统管理员允许。
|
* 删除医院:仅系统管理员允许。
|
||||||
*/
|
*/
|
||||||
async remove(actor: ActorContext, id: number) {
|
async remove(actor: ActorContext, id: number) {
|
||||||
this.access.assertSystemAdmin(actor, MESSAGES.ORG.SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL);
|
this.access.assertSystemAdmin(
|
||||||
|
actor,
|
||||||
|
MESSAGES.ORG.SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL,
|
||||||
|
);
|
||||||
const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||||
await this.access.ensureHospitalExists(hospitalId);
|
await this.access.ensureHospitalExists(hospitalId);
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
|||||||
* 组织查询 DTO:用于医院/科室/小组列表筛选与分页。
|
* 组织查询 DTO:用于医院/科室/小组列表筛选与分页。
|
||||||
*/
|
*/
|
||||||
export class OrganizationQueryDto {
|
export class OrganizationQueryDto {
|
||||||
@ApiPropertyOptional({ description: '关键词(按名称模糊匹配)', example: '神经' })
|
@ApiPropertyOptional({
|
||||||
|
description: '关键词(按名称模糊匹配)',
|
||||||
|
example: '神经',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: 'keyword 必须是字符串' })
|
@IsString({ message: 'keyword 必须是字符串' })
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
@ -28,7 +31,11 @@ export class OrganizationQueryDto {
|
|||||||
@Min(1, { message: 'departmentId 必须大于 0' })
|
@Min(1, { message: 'departmentId 必须大于 0' })
|
||||||
departmentId?: number;
|
departmentId?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '页码(默认 1)', example: 1, default: 1 })
|
@ApiPropertyOptional({
|
||||||
|
description: '页码(默认 1)',
|
||||||
|
example: 1,
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@EmptyStringToUndefined()
|
@EmptyStringToUndefined()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
@ -66,9 +66,9 @@ export class OrganizationAccessService {
|
|||||||
*/
|
*/
|
||||||
requireActorHospitalId(actor: ActorContext): number {
|
requireActorHospitalId(actor: ActorContext): number {
|
||||||
if (
|
if (
|
||||||
typeof actor.hospitalId !== 'number'
|
typeof actor.hospitalId !== 'number' ||
|
||||||
|| !Number.isInteger(actor.hospitalId)
|
!Number.isInteger(actor.hospitalId) ||
|
||||||
|| actor.hospitalId <= 0
|
actor.hospitalId <= 0
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED);
|
throw new BadRequestException(MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED);
|
||||||
}
|
}
|
||||||
@ -80,9 +80,9 @@ export class OrganizationAccessService {
|
|||||||
*/
|
*/
|
||||||
requireActorDepartmentId(actor: ActorContext): number {
|
requireActorDepartmentId(actor: ActorContext): number {
|
||||||
if (
|
if (
|
||||||
typeof actor.departmentId !== 'number'
|
typeof actor.departmentId !== 'number' ||
|
||||||
|| !Number.isInteger(actor.departmentId)
|
!Number.isInteger(actor.departmentId) ||
|
||||||
|| actor.departmentId <= 0
|
actor.departmentId <= 0
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED);
|
throw new BadRequestException(MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED);
|
||||||
}
|
}
|
||||||
@ -94,9 +94,9 @@ export class OrganizationAccessService {
|
|||||||
*/
|
*/
|
||||||
requireActorGroupId(actor: ActorContext): number {
|
requireActorGroupId(actor: ActorContext): number {
|
||||||
if (
|
if (
|
||||||
typeof actor.groupId !== 'number'
|
typeof actor.groupId !== 'number' ||
|
||||||
|| !Number.isInteger(actor.groupId)
|
!Number.isInteger(actor.groupId) ||
|
||||||
|| actor.groupId <= 0
|
actor.groupId <= 0
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(MESSAGES.ORG.ACTOR_GROUP_REQUIRED);
|
throw new BadRequestException(MESSAGES.ORG.ACTOR_GROUP_REQUIRED);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { Roles } from '../../auth/roles.decorator.js';
|
|||||||
import { Role } from '../../generated/prisma/enums.js';
|
import { Role } from '../../generated/prisma/enums.js';
|
||||||
import { BPatientsService } from './b-patients.service.js';
|
import { BPatientsService } from './b-patients.service.js';
|
||||||
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
||||||
|
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
|
||||||
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -102,10 +103,34 @@ export class BPatientsController {
|
|||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
)
|
)
|
||||||
@ApiOperation({ summary: '创建患者' })
|
@ApiOperation({ summary: '创建患者' })
|
||||||
createPatient(@CurrentActor() actor: ActorContext, @Body() dto: CreatePatientDto) {
|
createPatient(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Body() dto: CreatePatientDto,
|
||||||
|
) {
|
||||||
return this.patientsService.createPatient(actor, dto);
|
return this.patientsService.createPatient(actor, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为患者新增手术记录。
|
||||||
|
*/
|
||||||
|
@Post(':id/surgeries')
|
||||||
|
@Roles(
|
||||||
|
Role.SYSTEM_ADMIN,
|
||||||
|
Role.HOSPITAL_ADMIN,
|
||||||
|
Role.DIRECTOR,
|
||||||
|
Role.LEADER,
|
||||||
|
Role.DOCTOR,
|
||||||
|
)
|
||||||
|
@ApiOperation({ summary: '为患者新增手术记录' })
|
||||||
|
@ApiParam({ name: 'id', description: '患者 ID' })
|
||||||
|
createPatientSurgery(
|
||||||
|
@CurrentActor() actor: ActorContext,
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: CreatePatientSurgeryDto,
|
||||||
|
) {
|
||||||
|
return this.patientsService.createPatientSurgery(actor, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询患者详情。
|
* 查询患者详情。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
@ -6,18 +7,118 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma } from '../../generated/prisma/client.js';
|
import { Prisma } from '../../generated/prisma/client.js';
|
||||||
import { Role } from '../../generated/prisma/enums.js';
|
import { DeviceStatus, Role } from '../../generated/prisma/enums.js';
|
||||||
import { PrismaService } from '../../prisma.service.js';
|
import { PrismaService } from '../../prisma.service.js';
|
||||||
import type { ActorContext } from '../../common/actor-context.js';
|
import type { ActorContext } from '../../common/actor-context.js';
|
||||||
import { MESSAGES } from '../../common/messages.js';
|
import { MESSAGES } from '../../common/messages.js';
|
||||||
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
||||||
|
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
|
||||||
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
||||||
import { normalizePatientIdCard } from '../patient-id-card.util.js';
|
import { normalizePatientIdCard } from '../patient-id-card.util.js';
|
||||||
|
|
||||||
|
type PrismaExecutor = Prisma.TransactionClient | PrismaService;
|
||||||
|
|
||||||
const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER];
|
const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER];
|
||||||
|
|
||||||
|
const IMPLANT_CATALOG_SELECT = {
|
||||||
|
id: true,
|
||||||
|
modelCode: true,
|
||||||
|
manufacturer: true,
|
||||||
|
name: true,
|
||||||
|
pressureLevels: true,
|
||||||
|
isPressureAdjustable: true,
|
||||||
|
notes: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PATIENT_LIST_INCLUDE = {
|
||||||
|
hospital: { select: { id: true, name: true } },
|
||||||
|
doctor: { select: { id: true, name: true, role: true } },
|
||||||
|
devices: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
snCode: true,
|
||||||
|
status: true,
|
||||||
|
currentPressure: true,
|
||||||
|
isAbandoned: true,
|
||||||
|
implantModel: true,
|
||||||
|
implantManufacturer: true,
|
||||||
|
implantName: true,
|
||||||
|
isPressureAdjustable: true,
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
},
|
||||||
|
surgeries: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
surgeryDate: true,
|
||||||
|
surgeryName: true,
|
||||||
|
surgeonName: true,
|
||||||
|
},
|
||||||
|
orderBy: { surgeryDate: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
surgeries: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PATIENT_DETAIL_INCLUDE = {
|
||||||
|
hospital: { select: { id: true, name: true } },
|
||||||
|
doctor: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
hospitalId: true,
|
||||||
|
departmentId: true,
|
||||||
|
groupId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
devices: {
|
||||||
|
include: {
|
||||||
|
implantCatalog: {
|
||||||
|
select: IMPLANT_CATALOG_SELECT,
|
||||||
|
},
|
||||||
|
surgery: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
surgeryDate: true,
|
||||||
|
surgeryName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
},
|
||||||
|
surgeries: {
|
||||||
|
include: {
|
||||||
|
devices: {
|
||||||
|
include: {
|
||||||
|
implantCatalog: {
|
||||||
|
select: IMPLANT_CATALOG_SELECT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { surgeryDate: 'desc' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PATIENT_SURGERY_DETAIL_INCLUDE = {
|
||||||
|
devices: {
|
||||||
|
include: {
|
||||||
|
implantCatalog: {
|
||||||
|
select: IMPLANT_CATALOG_SELECT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* B 端患者服务:承载院内可见性隔离与患者 CRUD。
|
* B 端患者服务:承载院内可见性隔离、患者 CRUD 与手术档案录入。
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BPatientsService {
|
export class BPatientsService {
|
||||||
@ -30,15 +131,27 @@ export class BPatientsService {
|
|||||||
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
|
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
|
||||||
const where = this.buildVisiblePatientWhere(actor, hospitalId);
|
const where = this.buildVisiblePatientWhere(actor, hospitalId);
|
||||||
|
|
||||||
return this.prisma.patient.findMany({
|
const patients = await this.prisma.patient.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: PATIENT_LIST_INCLUDE,
|
||||||
hospital: { select: { id: true, name: true } },
|
|
||||||
doctor: { select: { id: true, name: true, role: true } },
|
|
||||||
devices: true,
|
|
||||||
},
|
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return patients.map((patient) => {
|
||||||
|
const { _count, surgeries, ...rest } = patient;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
shuntSurgeryCount: _count.surgeries,
|
||||||
|
latestSurgery: surgeries[0] ?? null,
|
||||||
|
activeDeviceCount: patient.devices.filter(
|
||||||
|
(device) =>
|
||||||
|
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
|
||||||
|
).length,
|
||||||
|
abandonedDeviceCount: patient.devices.filter(
|
||||||
|
(device) => device.isAbandoned,
|
||||||
|
).length,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -90,25 +203,73 @@ export class BPatientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建患者。
|
* 创建患者,可选一并创建首台手术及植入设备。
|
||||||
*/
|
*/
|
||||||
async createPatient(actor: ActorContext, dto: CreatePatientDto) {
|
async createPatient(actor: ActorContext, dto: CreatePatientDto) {
|
||||||
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
|
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
|
||||||
|
|
||||||
return this.prisma.patient.create({
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const patient = await tx.patient.create({
|
||||||
data: {
|
data: {
|
||||||
name: this.normalizeRequiredString(dto.name, 'name'),
|
name: this.normalizeRequiredString(dto.name, 'name'),
|
||||||
|
inpatientNo:
|
||||||
|
dto.inpatientNo === undefined
|
||||||
|
? undefined
|
||||||
|
: this.normalizeNullableString(dto.inpatientNo, 'inpatientNo'),
|
||||||
|
projectName:
|
||||||
|
dto.projectName === undefined
|
||||||
|
? undefined
|
||||||
|
: this.normalizeNullableString(dto.projectName, 'projectName'),
|
||||||
phone: this.normalizePhone(dto.phone),
|
phone: this.normalizePhone(dto.phone),
|
||||||
// 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。
|
// 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。
|
||||||
idCard: this.normalizeIdCard(dto.idCard),
|
idCard: this.normalizeIdCard(dto.idCard),
|
||||||
hospitalId: doctor.hospitalId!,
|
hospitalId: doctor.hospitalId!,
|
||||||
doctorId: doctor.id,
|
doctorId: doctor.id,
|
||||||
},
|
},
|
||||||
include: {
|
});
|
||||||
hospital: { select: { id: true, name: true } },
|
|
||||||
doctor: { select: { id: true, name: true, role: true } },
|
if (dto.initialSurgery) {
|
||||||
devices: true,
|
await this.createPatientSurgeryRecord(
|
||||||
},
|
tx,
|
||||||
|
patient.id,
|
||||||
|
dto.initialSurgery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = await this.loadPatientDetail(tx, patient.id);
|
||||||
|
return this.decoratePatientDetail(detail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为患者新增一台手术,并支持弃用旧设备。
|
||||||
|
*/
|
||||||
|
async createPatientSurgery(
|
||||||
|
actor: ActorContext,
|
||||||
|
patientId: number,
|
||||||
|
dto: CreatePatientSurgeryDto,
|
||||||
|
) {
|
||||||
|
const patient = await this.findPatientWithScope(patientId);
|
||||||
|
this.assertPatientScope(actor, patient);
|
||||||
|
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const createdSurgery = await this.createPatientSurgeryRecord(
|
||||||
|
tx,
|
||||||
|
patient.id,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
|
||||||
|
const detail = await this.loadPatientDetail(tx, patient.id);
|
||||||
|
const decoratedPatient = this.decoratePatientDetail(detail);
|
||||||
|
const created = decoratedPatient.surgeries.find(
|
||||||
|
(surgery) => surgery.id === createdSurgery.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!created) {
|
||||||
|
throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,20 +279,38 @@ export class BPatientsService {
|
|||||||
async findPatientById(actor: ActorContext, id: number) {
|
async findPatientById(actor: ActorContext, id: number) {
|
||||||
const patient = await this.findPatientWithScope(id);
|
const patient = await this.findPatientWithScope(id);
|
||||||
this.assertPatientScope(actor, patient);
|
this.assertPatientScope(actor, patient);
|
||||||
return patient;
|
return this.decoratePatientDetail(patient);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新患者信息。
|
* 更新患者基础信息。
|
||||||
*/
|
*/
|
||||||
async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) {
|
async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) {
|
||||||
const patient = await this.findPatientWithScope(id);
|
const patient = await this.findPatientWithScope(id);
|
||||||
this.assertPatientScope(actor, patient);
|
this.assertPatientScope(actor, patient);
|
||||||
|
|
||||||
|
if (dto.initialSurgery !== undefined) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
MESSAGES.PATIENT.SURGERY_UPDATE_NOT_SUPPORTED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const data: Prisma.PatientUpdateInput = {};
|
const data: Prisma.PatientUpdateInput = {};
|
||||||
if (dto.name !== undefined) {
|
if (dto.name !== undefined) {
|
||||||
data.name = this.normalizeRequiredString(dto.name, 'name');
|
data.name = this.normalizeRequiredString(dto.name, 'name');
|
||||||
}
|
}
|
||||||
|
if (dto.inpatientNo !== undefined) {
|
||||||
|
data.inpatientNo = this.normalizeNullableString(
|
||||||
|
dto.inpatientNo,
|
||||||
|
'inpatientNo',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dto.projectName !== undefined) {
|
||||||
|
data.projectName = this.normalizeNullableString(
|
||||||
|
dto.projectName,
|
||||||
|
'projectName',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (dto.phone !== undefined) {
|
if (dto.phone !== undefined) {
|
||||||
data.phone = this.normalizePhone(dto.phone);
|
data.phone = this.normalizePhone(dto.phone);
|
||||||
}
|
}
|
||||||
@ -145,15 +324,13 @@ export class BPatientsService {
|
|||||||
data.hospital = { connect: { id: doctor.hospitalId! } };
|
data.hospital = { connect: { id: doctor.hospitalId! } };
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.patient.update({
|
const updated = await this.prisma.patient.update({
|
||||||
where: { id: patient.id },
|
where: { id: patient.id },
|
||||||
data,
|
data,
|
||||||
include: {
|
|
||||||
hospital: { select: { id: true, name: true } },
|
|
||||||
doctor: { select: { id: true, name: true, role: true } },
|
|
||||||
devices: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const detail = await this.loadPatientDetail(this.prisma, updated.id);
|
||||||
|
return this.decoratePatientDetail(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -164,14 +341,11 @@ export class BPatientsService {
|
|||||||
this.assertPatientScope(actor, patient);
|
this.assertPatientScope(actor, patient);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.prisma.patient.delete({
|
const deleted = await this.prisma.patient.delete({
|
||||||
where: { id: patient.id },
|
where: { id: patient.id },
|
||||||
include: {
|
include: PATIENT_DETAIL_INCLUDE,
|
||||||
hospital: { select: { id: true, name: true } },
|
|
||||||
doctor: { select: { id: true, name: true, role: true } },
|
|
||||||
devices: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
return this.decoratePatientDetail(deleted);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@ -192,22 +366,16 @@ export class BPatientsService {
|
|||||||
throw new BadRequestException('id 必须为整数');
|
throw new BadRequestException('id 必须为整数');
|
||||||
}
|
}
|
||||||
|
|
||||||
const patient = await this.prisma.patient.findUnique({
|
return this.loadPatientDetail(this.prisma, patientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一加载患者详情。
|
||||||
|
*/
|
||||||
|
private async loadPatientDetail(prisma: PrismaExecutor, patientId: number) {
|
||||||
|
const patient = await prisma.patient.findUnique({
|
||||||
where: { id: patientId },
|
where: { id: patientId },
|
||||||
include: {
|
include: PATIENT_DETAIL_INCLUDE,
|
||||||
hospital: { select: { id: true, name: true } },
|
|
||||||
doctor: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
role: true,
|
|
||||||
hospitalId: true,
|
|
||||||
departmentId: true,
|
|
||||||
groupId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
devices: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!patient) {
|
if (!patient) {
|
||||||
@ -380,6 +548,304 @@ export class BPatientsService {
|
|||||||
return actor.hospitalId;
|
return actor.hospitalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建患者手术记录并写入设备快照。
|
||||||
|
*/
|
||||||
|
private async createPatientSurgeryRecord(
|
||||||
|
prisma: PrismaExecutor,
|
||||||
|
patientId: number,
|
||||||
|
dto: CreatePatientSurgeryDto,
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(dto.devices) || dto.devices.length === 0) {
|
||||||
|
throw new BadRequestException(MESSAGES.PATIENT.SURGERY_ITEMS_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
dto.devices.map((device) =>
|
||||||
|
this.toInt(device.implantCatalogId, 'implantCatalogId'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const abandonedDeviceIds = Array.from(
|
||||||
|
new Set(dto.abandonedDeviceIds ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [catalogMap, latestSurgery] = await Promise.all([
|
||||||
|
this.resolveImplantCatalogMap(prisma, catalogIds),
|
||||||
|
prisma.patientSurgery.findFirst({
|
||||||
|
where: { patientId },
|
||||||
|
orderBy: { surgeryDate: 'desc' },
|
||||||
|
select: { surgeryDate: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (abandonedDeviceIds.length > 0) {
|
||||||
|
const devices = await prisma.device.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: abandonedDeviceIds },
|
||||||
|
patientId,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (devices.length !== abandonedDeviceIds.length) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
MESSAGES.PATIENT.ABANDON_DEVICE_SCOPE_FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceDrafts = dto.devices.map((device, index) => {
|
||||||
|
const catalog = catalogMap.get(device.implantCatalogId);
|
||||||
|
if (!catalog) {
|
||||||
|
throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialPressure =
|
||||||
|
device.initialPressure == null
|
||||||
|
? null
|
||||||
|
: this.assertPressureLevelAllowed(
|
||||||
|
catalog,
|
||||||
|
this.normalizeNonNegativeInteger(
|
||||||
|
device.initialPressure,
|
||||||
|
'initialPressure',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const fallbackPressureLevel =
|
||||||
|
catalog.isPressureAdjustable && catalog.pressureLevels.length > 0
|
||||||
|
? catalog.pressureLevels[0]
|
||||||
|
: 0;
|
||||||
|
const currentPressure = catalog.isPressureAdjustable
|
||||||
|
? this.assertPressureLevelAllowed(
|
||||||
|
catalog,
|
||||||
|
initialPressure ?? fallbackPressureLevel,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
patient: { connect: { id: patientId } },
|
||||||
|
implantCatalog: { connect: { id: catalog.id } },
|
||||||
|
snCode: this.resolveDeviceSnCode(device.snCode, patientId, index),
|
||||||
|
currentPressure,
|
||||||
|
status: DeviceStatus.ACTIVE,
|
||||||
|
implantModel: catalog.modelCode,
|
||||||
|
implantManufacturer: catalog.manufacturer,
|
||||||
|
implantName: catalog.name,
|
||||||
|
isPressureAdjustable: catalog.isPressureAdjustable,
|
||||||
|
isAbandoned: false,
|
||||||
|
shuntMode: this.normalizeRequiredString(device.shuntMode, 'shuntMode'),
|
||||||
|
proximalPunctureAreas: this.normalizeStringArray(
|
||||||
|
device.proximalPunctureAreas,
|
||||||
|
'proximalPunctureAreas',
|
||||||
|
),
|
||||||
|
valvePlacementSites: this.normalizeStringArray(
|
||||||
|
device.valvePlacementSites,
|
||||||
|
'valvePlacementSites',
|
||||||
|
),
|
||||||
|
distalShuntDirection: this.normalizeRequiredString(
|
||||||
|
device.distalShuntDirection,
|
||||||
|
'distalShuntDirection',
|
||||||
|
),
|
||||||
|
initialPressure,
|
||||||
|
implantNotes:
|
||||||
|
device.implantNotes === undefined
|
||||||
|
? undefined
|
||||||
|
: this.normalizeNullableString(device.implantNotes, 'implantNotes'),
|
||||||
|
labelImageUrl:
|
||||||
|
device.labelImageUrl === undefined
|
||||||
|
? undefined
|
||||||
|
: this.normalizeNullableString(
|
||||||
|
device.labelImageUrl,
|
||||||
|
'labelImageUrl',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.assertSnCodesUnique(
|
||||||
|
prisma,
|
||||||
|
deviceDrafts.map((device) => device.snCode),
|
||||||
|
);
|
||||||
|
|
||||||
|
const surgery = await prisma.patientSurgery.create({
|
||||||
|
data: {
|
||||||
|
patientId,
|
||||||
|
surgeryDate: this.normalizeIsoDate(dto.surgeryDate, 'surgeryDate'),
|
||||||
|
surgeryName: this.normalizeRequiredString(
|
||||||
|
dto.surgeryName,
|
||||||
|
'surgeryName',
|
||||||
|
),
|
||||||
|
surgeonName: this.normalizeRequiredString(
|
||||||
|
dto.surgeonName,
|
||||||
|
'surgeonName',
|
||||||
|
),
|
||||||
|
preOpPressure:
|
||||||
|
dto.preOpPressure == null
|
||||||
|
? null
|
||||||
|
: this.normalizeNonNegativeInteger(
|
||||||
|
dto.preOpPressure,
|
||||||
|
'preOpPressure',
|
||||||
|
),
|
||||||
|
primaryDisease: this.normalizeRequiredString(
|
||||||
|
dto.primaryDisease,
|
||||||
|
'primaryDisease',
|
||||||
|
),
|
||||||
|
hydrocephalusTypes: this.normalizeStringArray(
|
||||||
|
dto.hydrocephalusTypes,
|
||||||
|
'hydrocephalusTypes',
|
||||||
|
),
|
||||||
|
previousShuntSurgeryDate: dto.previousShuntSurgeryDate
|
||||||
|
? this.normalizeIsoDate(
|
||||||
|
dto.previousShuntSurgeryDate,
|
||||||
|
'previousShuntSurgeryDate',
|
||||||
|
)
|
||||||
|
: (latestSurgery?.surgeryDate ?? null),
|
||||||
|
preOpMaterials:
|
||||||
|
dto.preOpMaterials == null
|
||||||
|
? undefined
|
||||||
|
: this.normalizePreOpMaterials(dto.preOpMaterials),
|
||||||
|
notes:
|
||||||
|
dto.notes === undefined
|
||||||
|
? undefined
|
||||||
|
: this.normalizeNullableString(dto.notes, 'notes'),
|
||||||
|
devices: {
|
||||||
|
create: deviceDrafts,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: PATIENT_SURGERY_DETAIL_INCLUDE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (abandonedDeviceIds.length > 0) {
|
||||||
|
await prisma.device.updateMany({
|
||||||
|
where: {
|
||||||
|
id: { in: abandonedDeviceIds },
|
||||||
|
patientId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
isAbandoned: true,
|
||||||
|
status: DeviceStatus.INACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return surgery;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并校验植入物型号字典。
|
||||||
|
*/
|
||||||
|
private async resolveImplantCatalogMap(
|
||||||
|
prisma: PrismaExecutor,
|
||||||
|
implantCatalogIds: number[],
|
||||||
|
) {
|
||||||
|
if (implantCatalogIds.length === 0) {
|
||||||
|
return new Map<
|
||||||
|
number,
|
||||||
|
Awaited<ReturnType<typeof prisma.implantCatalog.findFirst>>
|
||||||
|
>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogs = await prisma.implantCatalog.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: implantCatalogIds },
|
||||||
|
},
|
||||||
|
select: IMPLANT_CATALOG_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (catalogs.length !== implantCatalogIds.length) {
|
||||||
|
throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Map(catalogs.map((catalog) => [catalog.id, catalog]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可调压植入物若配置了挡位,录入压力时必须命中其中一项。
|
||||||
|
*/
|
||||||
|
private assertPressureLevelAllowed(
|
||||||
|
catalog: {
|
||||||
|
isPressureAdjustable: boolean;
|
||||||
|
pressureLevels: number[];
|
||||||
|
},
|
||||||
|
pressure: number,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
catalog.isPressureAdjustable &&
|
||||||
|
Array.isArray(catalog.pressureLevels) &&
|
||||||
|
catalog.pressureLevels.length > 0 &&
|
||||||
|
!catalog.pressureLevels.includes(pressure)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将患者详情补全为前端可直接消费的结构。
|
||||||
|
*/
|
||||||
|
private decoratePatientDetail(
|
||||||
|
patient: Awaited<ReturnType<BPatientsService['findPatientWithScope']>>,
|
||||||
|
) {
|
||||||
|
const surgeries = this.decorateSurgeries(patient.surgeries);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...patient,
|
||||||
|
surgeries,
|
||||||
|
shuntSurgeryCount: surgeries.length,
|
||||||
|
latestSurgery: surgeries[0] ?? null,
|
||||||
|
activeDeviceCount: patient.devices.filter(
|
||||||
|
(device) =>
|
||||||
|
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
|
||||||
|
).length,
|
||||||
|
abandonedDeviceCount: patient.devices.filter(
|
||||||
|
(device) => device.isAbandoned,
|
||||||
|
).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算每次手术的自动分流手术次数。
|
||||||
|
*/
|
||||||
|
private decorateSurgeries<
|
||||||
|
TSurgery extends {
|
||||||
|
id: number;
|
||||||
|
surgeryDate: Date;
|
||||||
|
devices: Array<{
|
||||||
|
id: number;
|
||||||
|
status: DeviceStatus;
|
||||||
|
isAbandoned: boolean;
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
>(surgeries: TSurgery[]) {
|
||||||
|
const sortedAsc = [...surgeries].sort(
|
||||||
|
(left, right) =>
|
||||||
|
new Date(left.surgeryDate).getTime() -
|
||||||
|
new Date(right.surgeryDate).getTime(),
|
||||||
|
);
|
||||||
|
const sequenceById = new Map(
|
||||||
|
sortedAsc.map((surgery, index) => [surgery.id, index + 1] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...surgeries]
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
new Date(right.surgeryDate).getTime() -
|
||||||
|
new Date(left.surgeryDate).getTime(),
|
||||||
|
)
|
||||||
|
.map((surgery) => ({
|
||||||
|
...surgery,
|
||||||
|
shuntSurgeryCount: sequenceById.get(surgery.id) ?? surgeries.length,
|
||||||
|
activeDeviceCount: surgery.devices.filter(
|
||||||
|
(device) =>
|
||||||
|
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
|
||||||
|
).length,
|
||||||
|
abandonedDeviceCount: surgery.devices.filter(
|
||||||
|
(device) => device.isAbandoned,
|
||||||
|
).length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeRequiredString(value: unknown, fieldName: string) {
|
private normalizeRequiredString(value: unknown, fieldName: string) {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new BadRequestException(`${fieldName} 必须是字符串`);
|
throw new BadRequestException(`${fieldName} 必须是字符串`);
|
||||||
@ -391,6 +857,14 @@ export class BPatientsService {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeNullableString(value: unknown, fieldName: string) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new BadRequestException(`${fieldName} 必须是字符串`);
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
private normalizePhone(phone: unknown) {
|
private normalizePhone(phone: unknown) {
|
||||||
const normalized = this.normalizeRequiredString(phone, 'phone');
|
const normalized = this.normalizeRequiredString(phone, 'phone');
|
||||||
if (!/^1\d{10}$/.test(normalized)) {
|
if (!/^1\d{10}$/.test(normalized)) {
|
||||||
@ -406,4 +880,91 @@ export class BPatientsService {
|
|||||||
const normalized = this.normalizeRequiredString(value, 'idCard');
|
const normalized = this.normalizeRequiredString(value, 'idCard');
|
||||||
return normalizePatientIdCard(normalized);
|
return normalizePatientIdCard(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeIsoDate(value: unknown, fieldName: string) {
|
||||||
|
const normalized = this.normalizeRequiredString(value, fieldName);
|
||||||
|
const parsed = new Date(normalized);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw new BadRequestException(`${fieldName} 必须是合法日期`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeNonNegativeInteger(value: unknown, fieldName: string) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||||
|
throw new BadRequestException(`${fieldName} 必须是大于等于 0 的整数`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeStringArray(value: unknown, fieldName: string) {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
|
throw new BadRequestException(`${fieldName} 必须为非空数组`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
value.map((item) => this.normalizeRequiredString(item, fieldName)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePreOpMaterials(
|
||||||
|
materials: CreatePatientSurgeryDto['preOpMaterials'],
|
||||||
|
): Prisma.InputJsonArray {
|
||||||
|
if (!Array.isArray(materials)) {
|
||||||
|
throw new BadRequestException('preOpMaterials 必须是数组');
|
||||||
|
}
|
||||||
|
|
||||||
|
return materials.map((material) => ({
|
||||||
|
type: this.normalizeRequiredString(material.type, 'type'),
|
||||||
|
url: this.normalizeRequiredString(material.url, 'url'),
|
||||||
|
name:
|
||||||
|
material.name === undefined
|
||||||
|
? null
|
||||||
|
: this.normalizeNullableString(material.name, 'name'),
|
||||||
|
})) as Prisma.InputJsonArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveDeviceSnCode(
|
||||||
|
snCode: string | undefined,
|
||||||
|
patientId: number,
|
||||||
|
index: number,
|
||||||
|
) {
|
||||||
|
if (snCode) {
|
||||||
|
return this.normalizeRequiredString(snCode, 'snCode').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `SURG-${patientId}-${Date.now()}-${index + 1}-${randomUUID()
|
||||||
|
.slice(0, 8)
|
||||||
|
.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertSnCodesUnique(prisma: PrismaExecutor, snCodes: string[]) {
|
||||||
|
const uniqueSnCodes = Array.from(new Set(snCodes));
|
||||||
|
if (uniqueSnCodes.length !== snCodes.length) {
|
||||||
|
throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.device.findMany({
|
||||||
|
where: {
|
||||||
|
snCode: { in: uniqueSnCodes },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toInt(value: unknown, fieldName: string) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new BadRequestException(`${fieldName} 必须为正整数`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,8 +32,46 @@ export class CPatientsService {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
hospital: { select: { id: true, name: true } },
|
hospital: { select: { id: true, name: true } },
|
||||||
|
surgeries: {
|
||||||
|
include: {
|
||||||
devices: {
|
devices: {
|
||||||
include: {
|
include: {
|
||||||
|
implantCatalog: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
modelCode: true,
|
||||||
|
manufacturer: true,
|
||||||
|
name: true,
|
||||||
|
pressureLevels: true,
|
||||||
|
isPressureAdjustable: true,
|
||||||
|
notes: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { surgeryDate: 'desc' },
|
||||||
|
},
|
||||||
|
devices: {
|
||||||
|
include: {
|
||||||
|
surgery: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
surgeryDate: true,
|
||||||
|
surgeryName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
implantCatalog: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
modelCode: true,
|
||||||
|
manufacturer: true,
|
||||||
|
name: true,
|
||||||
|
pressureLevels: true,
|
||||||
|
isPressureAdjustable: true,
|
||||||
|
notes: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
taskItems: {
|
taskItems: {
|
||||||
include: {
|
include: {
|
||||||
task: true,
|
task: true,
|
||||||
@ -48,8 +86,49 @@ export class CPatientsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lifecycle = patients
|
const lifecycle = patients
|
||||||
.flatMap((patient) =>
|
.flatMap((patient) => {
|
||||||
patient.devices.flatMap((device) =>
|
const surgeryEvents = patient.surgeries.map(
|
||||||
|
(surgery, index, surgeries) => ({
|
||||||
|
eventType: 'SURGERY',
|
||||||
|
occurredAt: surgery.surgeryDate,
|
||||||
|
hospital: patient.hospital,
|
||||||
|
patient: {
|
||||||
|
id: this.toJsonNumber(patient.id),
|
||||||
|
name: patient.name,
|
||||||
|
inpatientNo: patient.inpatientNo,
|
||||||
|
projectName: patient.projectName,
|
||||||
|
},
|
||||||
|
surgery: {
|
||||||
|
id: this.toJsonNumber(surgery.id),
|
||||||
|
surgeryDate: surgery.surgeryDate,
|
||||||
|
surgeryName: surgery.surgeryName,
|
||||||
|
surgeonName: surgery.surgeonName,
|
||||||
|
primaryDisease: surgery.primaryDisease,
|
||||||
|
hydrocephalusTypes: surgery.hydrocephalusTypes,
|
||||||
|
previousShuntSurgeryDate: surgery.previousShuntSurgeryDate,
|
||||||
|
shuntSurgeryCount: surgeries.length - index,
|
||||||
|
},
|
||||||
|
devices: surgery.devices.map((device) => ({
|
||||||
|
id: this.toJsonNumber(device.id),
|
||||||
|
snCode: device.snCode,
|
||||||
|
status: device.status,
|
||||||
|
isAbandoned: device.isAbandoned,
|
||||||
|
currentPressure: this.toJsonNumber(device.currentPressure),
|
||||||
|
initialPressure: this.toJsonNumber(device.initialPressure),
|
||||||
|
implantModel: device.implantModel,
|
||||||
|
implantManufacturer: device.implantManufacturer,
|
||||||
|
implantName: device.implantName,
|
||||||
|
isPressureAdjustable: device.isPressureAdjustable,
|
||||||
|
shuntMode: device.shuntMode,
|
||||||
|
distalShuntDirection: device.distalShuntDirection,
|
||||||
|
proximalPunctureAreas: device.proximalPunctureAreas,
|
||||||
|
valvePlacementSites: device.valvePlacementSites,
|
||||||
|
implantCatalog: device.implantCatalog,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const taskEvents = patient.devices.flatMap((device) =>
|
||||||
device.taskItems.flatMap((taskItem) => {
|
device.taskItems.flatMap((taskItem) => {
|
||||||
// 容错:若存在脏数据导致 task 为空,直接跳过该条明细,避免接口 500。
|
// 容错:若存在脏数据导致 task 为空,直接跳过该条明细,避免接口 500。
|
||||||
if (!taskItem.task) {
|
if (!taskItem.task) {
|
||||||
@ -65,13 +144,27 @@ export class CPatientsService {
|
|||||||
patient: {
|
patient: {
|
||||||
id: this.toJsonNumber(patient.id),
|
id: this.toJsonNumber(patient.id),
|
||||||
name: patient.name,
|
name: patient.name,
|
||||||
|
inpatientNo: patient.inpatientNo,
|
||||||
|
projectName: patient.projectName,
|
||||||
},
|
},
|
||||||
device: {
|
device: {
|
||||||
id: this.toJsonNumber(device.id),
|
id: this.toJsonNumber(device.id),
|
||||||
snCode: device.snCode,
|
snCode: device.snCode,
|
||||||
status: device.status,
|
status: device.status,
|
||||||
|
isAbandoned: device.isAbandoned,
|
||||||
currentPressure: this.toJsonNumber(device.currentPressure),
|
currentPressure: this.toJsonNumber(device.currentPressure),
|
||||||
|
implantModel: device.implantModel,
|
||||||
|
implantManufacturer: device.implantManufacturer,
|
||||||
|
implantName: device.implantName,
|
||||||
|
isPressureAdjustable: device.isPressureAdjustable,
|
||||||
},
|
},
|
||||||
|
surgery: device.surgery
|
||||||
|
? {
|
||||||
|
id: this.toJsonNumber(device.surgery.id),
|
||||||
|
surgeryDate: device.surgery.surgeryDate,
|
||||||
|
surgeryName: device.surgery.surgeryName,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
task: {
|
task: {
|
||||||
id: this.toJsonNumber(task.id),
|
id: this.toJsonNumber(task.id),
|
||||||
status: task.status,
|
status: task.status,
|
||||||
@ -85,8 +178,10 @@ export class CPatientsService {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
)
|
|
||||||
|
return [...surgeryEvents, ...taskEvents];
|
||||||
|
})
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||||||
|
|||||||
116
src/patients/dto/create-patient-surgery.dto.ts
Normal file
116
src/patients/dto/create-patient-surgery.dto.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsISO8601,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { CreateSurgeryDeviceDto } from './create-surgery-device.dto.js';
|
||||||
|
import { SurgeryMaterialDto } from './surgery-material.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 患者手术 DTO:支持首术与二次手术录入。
|
||||||
|
*/
|
||||||
|
export class CreatePatientSurgeryDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '手术日期',
|
||||||
|
example: '2026-03-19T08:00:00.000Z',
|
||||||
|
})
|
||||||
|
@IsISO8601({}, { message: 'surgeryDate 必须是合法 ISO 日期' })
|
||||||
|
surgeryDate!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '手术名称',
|
||||||
|
example: '脑室腹腔分流术',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'surgeryName 必须是字符串' })
|
||||||
|
surgeryName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '主刀医生',
|
||||||
|
example: '张主任',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'surgeonName 必须是字符串' })
|
||||||
|
surgeonName!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '术前测压,可为空',
|
||||||
|
example: 22,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'preOpPressure 必须是整数' })
|
||||||
|
preOpPressure?: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '原发病',
|
||||||
|
example: '梗阻性脑积水',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'primaryDisease 必须是字符串' })
|
||||||
|
primaryDisease!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '脑积水类型,多选',
|
||||||
|
type: [String],
|
||||||
|
example: ['交通性', '高压性'],
|
||||||
|
})
|
||||||
|
@IsArray({ message: 'hydrocephalusTypes 必须是数组' })
|
||||||
|
@ArrayMinSize(1, { message: 'hydrocephalusTypes 至少选择 1 项' })
|
||||||
|
@IsString({ each: true, message: 'hydrocephalusTypes 必须为字符串数组' })
|
||||||
|
hydrocephalusTypes!: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '上次分流手术时间,可为空',
|
||||||
|
example: '2024-08-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({}, { message: 'previousShuntSurgeryDate 必须是合法 ISO 日期' })
|
||||||
|
previousShuntSurgeryDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '手术备注',
|
||||||
|
example: '二次手术,弃用原右侧分流装置',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'notes 必须是字符串' })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '术前 CT 影像/资料',
|
||||||
|
type: [SurgeryMaterialDto],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: 'preOpMaterials 必须是数组' })
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SurgeryMaterialDto)
|
||||||
|
preOpMaterials?: SurgeryMaterialDto[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '本次手术植入设备列表',
|
||||||
|
type: [CreateSurgeryDeviceDto],
|
||||||
|
})
|
||||||
|
@IsArray({ message: 'devices 必须是数组' })
|
||||||
|
@ArrayMinSize(1, { message: 'devices 至少录入 1 个设备' })
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateSurgeryDeviceDto)
|
||||||
|
devices!: CreateSurgeryDeviceDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '本次手术后需弃用的历史设备 ID 列表',
|
||||||
|
type: [Number],
|
||||||
|
example: [1],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: 'abandonedDeviceIds 必须是数组' })
|
||||||
|
@ArrayUnique({ message: 'abandonedDeviceIds 不能重复' })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ each: true, message: 'abandonedDeviceIds 必须为整数数组' })
|
||||||
|
@Min(1, { each: true, message: 'abandonedDeviceIds 必须大于 0' })
|
||||||
|
abandonedDeviceIds?: number[];
|
||||||
|
}
|
||||||
@ -1,6 +1,14 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsInt, IsString, Matches, Min } from 'class-validator';
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { CreatePatientSurgeryDto } from './create-patient-surgery.dto.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 患者创建 DTO:B 端新增患者使用。
|
* 患者创建 DTO:B 端新增患者使用。
|
||||||
@ -10,6 +18,16 @@ export class CreatePatientDto {
|
|||||||
@IsString({ message: 'name 必须是字符串' })
|
@IsString({ message: 'name 必须是字符串' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '住院号', example: 'ZYH-20260319001' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'inpatientNo 必须是字符串' })
|
||||||
|
inpatientNo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '项目名称', example: '脑积水随访项目' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'projectName 必须是字符串' })
|
||||||
|
projectName?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '手机号', example: '13800002001' })
|
@ApiProperty({ description: '手机号', example: '13800002001' })
|
||||||
@IsString({ message: 'phone 必须是字符串' })
|
@IsString({ message: 'phone 必须是字符串' })
|
||||||
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||||
@ -27,4 +45,13 @@ export class CreatePatientDto {
|
|||||||
@IsInt({ message: 'doctorId 必须是整数' })
|
@IsInt({ message: 'doctorId 必须是整数' })
|
||||||
@Min(1, { message: 'doctorId 必须大于 0' })
|
@Min(1, { message: 'doctorId 必须大于 0' })
|
||||||
doctorId!: number;
|
doctorId!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '首台手术信息,可在创建患者时一并录入',
|
||||||
|
type: CreatePatientSurgeryDto,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => CreatePatientSurgeryDto)
|
||||||
|
initialSurgery?: CreatePatientSurgeryDto;
|
||||||
}
|
}
|
||||||
|
|||||||
95
src/patients/dto/create-surgery-device.dto.ts
Normal file
95
src/patients/dto/create-surgery-device.dto.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手术下设备 DTO:描述一次手术中植入的设备实例。
|
||||||
|
*/
|
||||||
|
export class CreateSurgeryDeviceDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '植入物型号 ID,选中后自动回填厂家与名称',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'implantCatalogId 必须是整数' })
|
||||||
|
@Min(1, { message: 'implantCatalogId 必须大于 0' })
|
||||||
|
implantCatalogId!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '设备 SN,可不传;不传时系统自动生成',
|
||||||
|
example: 'TYT-SHUNT-001',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'snCode 必须是字符串' })
|
||||||
|
snCode?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '分流方式',
|
||||||
|
example: 'VPS',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'shuntMode 必须是字符串' })
|
||||||
|
shuntMode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '近端穿刺区域,最多 2 个',
|
||||||
|
type: [String],
|
||||||
|
example: ['额角', '枕角'],
|
||||||
|
})
|
||||||
|
@IsArray({ message: 'proximalPunctureAreas 必须是数组' })
|
||||||
|
@ArrayMinSize(1, { message: 'proximalPunctureAreas 至少选择 1 项' })
|
||||||
|
@ArrayMaxSize(2, { message: 'proximalPunctureAreas 最多选择 2 项' })
|
||||||
|
@IsString({ each: true, message: 'proximalPunctureAreas 必须为字符串数组' })
|
||||||
|
proximalPunctureAreas!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '阀门植入部位,最多 2 个',
|
||||||
|
type: [String],
|
||||||
|
example: ['耳后', '胸前'],
|
||||||
|
})
|
||||||
|
@IsArray({ message: 'valvePlacementSites 必须是数组' })
|
||||||
|
@ArrayMinSize(1, { message: 'valvePlacementSites 至少选择 1 项' })
|
||||||
|
@ArrayMaxSize(2, { message: 'valvePlacementSites 最多选择 2 项' })
|
||||||
|
@IsString({ each: true, message: 'valvePlacementSites 必须为字符串数组' })
|
||||||
|
valvePlacementSites!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '远端分流方向',
|
||||||
|
example: '腹腔',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'distalShuntDirection 必须是字符串' })
|
||||||
|
distalShuntDirection!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '初始压力,可为空',
|
||||||
|
example: 120,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'initialPressure 必须是整数' })
|
||||||
|
@Min(0, { message: 'initialPressure 必须大于等于 0' })
|
||||||
|
initialPressure?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '植入物备注',
|
||||||
|
example: '术中顺利,通畅良好',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'implantNotes 必须是字符串' })
|
||||||
|
implantNotes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '植入物标签图片地址',
|
||||||
|
example: 'https://cdn.example.com/patients/device-label-001.jpg',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'labelImageUrl 必须是字符串' })
|
||||||
|
labelImageUrl?: string;
|
||||||
|
}
|
||||||
32
src/patients/dto/surgery-material.dto.ts
Normal file
32
src/patients/dto/surgery-material.dto.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手术资料 DTO:用于术前 CT 影像/视频等附件元数据。
|
||||||
|
*/
|
||||||
|
export class SurgeryMaterialDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '资料类型',
|
||||||
|
enum: ['IMAGE', 'VIDEO', 'FILE'],
|
||||||
|
example: 'IMAGE',
|
||||||
|
})
|
||||||
|
@IsIn(['IMAGE', 'VIDEO', 'FILE'], {
|
||||||
|
message: 'type 必须是 IMAGE、VIDEO 或 FILE',
|
||||||
|
})
|
||||||
|
type!: 'IMAGE' | 'VIDEO' | 'FILE';
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '资料访问地址',
|
||||||
|
example: 'https://cdn.example.com/patients/ct-001.png',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'url 必须是字符串' })
|
||||||
|
url!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '资料名称',
|
||||||
|
example: '术前 CT 第 1 张',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'name 必须是字符串' })
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
@ -56,9 +56,19 @@ export class TaskService {
|
|||||||
where: {
|
where: {
|
||||||
id: { in: deviceIds },
|
id: { in: deviceIds },
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
|
isAbandoned: false,
|
||||||
|
isPressureAdjustable: true,
|
||||||
patient: { hospitalId },
|
patient: { hospitalId },
|
||||||
},
|
},
|
||||||
select: { id: true, currentPressure: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
currentPressure: true,
|
||||||
|
implantCatalog: {
|
||||||
|
select: {
|
||||||
|
pressureLevels: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (devices.length !== deviceIds.length) {
|
if (devices.length !== deviceIds.length) {
|
||||||
@ -82,6 +92,24 @@ export class TaskService {
|
|||||||
const pressureByDeviceId = new Map(
|
const pressureByDeviceId = new Map(
|
||||||
devices.map((device) => [device.id, device.currentPressure] as const),
|
devices.map((device) => [device.id, device.currentPressure] as const),
|
||||||
);
|
);
|
||||||
|
const pressureLevelsByDeviceId = new Map(
|
||||||
|
devices.map((device) => [
|
||||||
|
device.id,
|
||||||
|
Array.isArray(device.implantCatalog?.pressureLevels)
|
||||||
|
? device.implantCatalog.pressureLevels
|
||||||
|
: [],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
dto.items.forEach((item) => {
|
||||||
|
const pressureLevels = pressureLevelsByDeviceId.get(item.deviceId) ?? [];
|
||||||
|
if (
|
||||||
|
pressureLevels.length > 0 &&
|
||||||
|
!pressureLevels.includes(item.targetPressure)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const task = await this.prisma.task.create({
|
const task = await this.prisma.task.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { Body, Controller, Param, Patch, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Param, Patch, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import type { ActorContext } from '../../common/actor-context.js';
|
import type { ActorContext } from '../../common/actor-context.js';
|
||||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||||
|
|||||||
@ -31,7 +31,10 @@ export class CreateUserDto {
|
|||||||
@MinLength(8, { message: 'password 长度至少 8 位' })
|
@MinLength(8, { message: 'password 长度至少 8 位' })
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '微信 openId', example: 'wx-open-id-demo' })
|
@ApiPropertyOptional({
|
||||||
|
description: '微信 openId',
|
||||||
|
example: 'wx-open-id-demo',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: 'openId 必须是字符串' })
|
@IsString({ message: 'openId 必须是字符串' })
|
||||||
openId?: string;
|
openId?: string;
|
||||||
|
|||||||
@ -30,7 +30,10 @@ export class LoginDto {
|
|||||||
@IsEnum(Role, { message: 'role 枚举值不合法' })
|
@IsEnum(Role, { message: 'role 枚举值不合法' })
|
||||||
role!: Role;
|
role!: Role;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '医院 ID(多账号场景建议传入)', example: 1 })
|
@ApiPropertyOptional({
|
||||||
|
description: '医院 ID(多账号场景建议传入)',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@EmptyStringToUndefined()
|
@EmptyStringToUndefined()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
@ -29,7 +29,6 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
.set('Authorization', `Bearer ${token}`)
|
.set('Authorization', `Bearer ${token}`)
|
||||||
.send({
|
.send({
|
||||||
snCode: uniqueSeedValue('device-sn'),
|
snCode: uniqueSeedValue('device-sn'),
|
||||||
currentPressure: 118,
|
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
patientId,
|
patientId,
|
||||||
});
|
});
|
||||||
@ -38,6 +37,7 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
return response.body.data as {
|
return response.body.data as {
|
||||||
id: number;
|
id: number;
|
||||||
snCode: string;
|
snCode: string;
|
||||||
|
currentPressure: number;
|
||||||
status: DeviceStatus;
|
status: DeviceStatus;
|
||||||
patient: { id: number };
|
patient: { id: number };
|
||||||
};
|
};
|
||||||
@ -95,6 +95,91 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('植入物型号字典', () => {
|
||||||
|
it('成功:DOCTOR 可查询可见型号字典', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/devices/catalogs')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(
|
||||||
|
(response.body.data as Array<{ modelCode: string }>).some(
|
||||||
|
(item) => item.modelCode === 'SEED-ADJUSTABLE-VALVE',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功:SYSTEM_ADMIN 可新增、更新并删除全局植入物目录', async () => {
|
||||||
|
const createResponse = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/devices/catalogs')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
|
.send({
|
||||||
|
modelCode: uniqueSeedValue('catalog').toUpperCase(),
|
||||||
|
manufacturer: 'Global Vendor',
|
||||||
|
name: '全局可调压阀',
|
||||||
|
isPressureAdjustable: true,
|
||||||
|
pressureLevels: [70, 90, 110],
|
||||||
|
notes: '测试全局目录',
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessEnvelope(createResponse, 201);
|
||||||
|
expect(createResponse.body.data.pressureLevels).toEqual([70, 90, 110]);
|
||||||
|
|
||||||
|
const updateResponse = await request(ctx.app.getHttpServer())
|
||||||
|
.patch(`/b/devices/catalogs/${createResponse.body.data.id}`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
|
.send({
|
||||||
|
name: '全局可调压阀-更新版',
|
||||||
|
pressureLevels: [80, 100, 120],
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessEnvelope(updateResponse, 200);
|
||||||
|
expect(updateResponse.body.data.name).toBe('全局可调压阀-更新版');
|
||||||
|
expect(updateResponse.body.data.pressureLevels).toEqual([80, 100, 120]);
|
||||||
|
|
||||||
|
const deleteResponse = await request(ctx.app.getHttpServer())
|
||||||
|
.delete(`/b/devices/catalogs/${createResponse.body.data.id}`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(deleteResponse, 200);
|
||||||
|
expect(deleteResponse.body.data.id).toBe(createResponse.body.data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('角色矩阵:仅 SYSTEM_ADMIN 可维护全局植入物目录', async () => {
|
||||||
|
await assertRoleMatrix({
|
||||||
|
name: 'POST /b/devices/catalogs role matrix',
|
||||||
|
tokens: ctx.tokens,
|
||||||
|
expectedStatusByRole: {
|
||||||
|
[Role.SYSTEM_ADMIN]: 201,
|
||||||
|
[Role.HOSPITAL_ADMIN]: 403,
|
||||||
|
[Role.DIRECTOR]: 403,
|
||||||
|
[Role.LEADER]: 403,
|
||||||
|
[Role.DOCTOR]: 403,
|
||||||
|
[Role.ENGINEER]: 403,
|
||||||
|
},
|
||||||
|
sendAsRole: async (_role, token) =>
|
||||||
|
request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/devices/catalogs')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({
|
||||||
|
modelCode: uniqueSeedValue('catalog-role').toUpperCase(),
|
||||||
|
manufacturer: 'Role Matrix Vendor',
|
||||||
|
name: '角色矩阵目录',
|
||||||
|
isPressureAdjustable: true,
|
||||||
|
pressureLevels: [50, 80],
|
||||||
|
}),
|
||||||
|
sendWithoutToken: async () =>
|
||||||
|
request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/devices/catalogs')
|
||||||
|
.send({
|
||||||
|
modelCode: uniqueSeedValue('catalog-anon').toUpperCase(),
|
||||||
|
manufacturer: 'Anon Vendor',
|
||||||
|
name: '匿名目录',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('设备 CRUD 流程', () => {
|
describe('设备 CRUD 流程', () => {
|
||||||
it('成功:HOSPITAL_ADMIN 可创建设备', async () => {
|
it('成功:HOSPITAL_ADMIN 可创建设备', async () => {
|
||||||
const created = await createDevice(
|
const created = await createDevice(
|
||||||
@ -103,6 +188,7 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(created.status).toBe(DeviceStatus.ACTIVE);
|
expect(created.status).toBe(DeviceStatus.ACTIVE);
|
||||||
|
expect(created.currentPressure).toBe(0);
|
||||||
expect(created.patient.id).toBe(ctx.fixtures.patients.patientA1Id);
|
expect(created.patient.id).toBe(ctx.fixtures.patients.patientA1Id);
|
||||||
expect(created.snCode).toMatch(/^DEVICE-SN-/);
|
expect(created.snCode).toMatch(/^DEVICE-SN-/);
|
||||||
});
|
});
|
||||||
@ -113,7 +199,6 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||||
.send({
|
.send({
|
||||||
snCode: uniqueSeedValue('cross-hospital-device'),
|
snCode: uniqueSeedValue('cross-hospital-device'),
|
||||||
currentPressure: 120,
|
|
||||||
status: DeviceStatus.ACTIVE,
|
status: DeviceStatus.ACTIVE,
|
||||||
patientId: ctx.fixtures.patients.patientB1Id,
|
patientId: ctx.fixtures.patients.patientB1Id,
|
||||||
});
|
});
|
||||||
@ -133,7 +218,6 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
.send({
|
.send({
|
||||||
status: DeviceStatus.INACTIVE,
|
status: DeviceStatus.INACTIVE,
|
||||||
patientId: ctx.fixtures.patients.patientA2Id,
|
patientId: ctx.fixtures.patients.patientA2Id,
|
||||||
currentPressure: 99,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
expectSuccessEnvelope(response, 200);
|
||||||
@ -141,7 +225,23 @@ describe('BDevicesController (e2e)', () => {
|
|||||||
expect(response.body.data.patient.id).toBe(
|
expect(response.body.data.patient.id).toBe(
|
||||||
ctx.fixtures.patients.patientA2Id,
|
ctx.fixtures.patients.patientA2Id,
|
||||||
);
|
);
|
||||||
expect(response.body.data.currentPressure).toBe(99);
|
expect(response.body.data.currentPressure).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败:设备实例接口不允许手工更新 currentPressure', async () => {
|
||||||
|
const created = await createDevice(
|
||||||
|
ctx.tokens[Role.SYSTEM_ADMIN],
|
||||||
|
ctx.fixtures.patients.patientA1Id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.patch(`/b/devices/${created.id}`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
|
.send({
|
||||||
|
currentPressure: 99,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectErrorEnvelope(response, 400, 'currentPressure should not exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('成功:SYSTEM_ADMIN 可删除未被任务引用的设备', async () => {
|
it('成功:SYSTEM_ADMIN 可删除未被任务引用的设备', async () => {
|
||||||
|
|||||||
133
test/e2e/specs/dictionaries.e2e-spec.ts
Normal file
133
test/e2e/specs/dictionaries.e2e-spec.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import { Role } from '../../../src/generated/prisma/enums.js';
|
||||||
|
import {
|
||||||
|
closeE2EContext,
|
||||||
|
createE2EContext,
|
||||||
|
type E2EContext,
|
||||||
|
} from '../helpers/e2e-context.helper.js';
|
||||||
|
import {
|
||||||
|
expectSuccessEnvelope,
|
||||||
|
uniqueSeedValue,
|
||||||
|
} from '../helpers/e2e-http.helper.js';
|
||||||
|
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
|
||||||
|
|
||||||
|
describe('BDictionariesController (e2e)', () => {
|
||||||
|
let ctx: E2EContext;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createE2EContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await closeE2EContext(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /b/dictionaries', () => {
|
||||||
|
it('成功:DOCTOR 可查询启用中的系统字典', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/dictionaries')
|
||||||
|
.query({ type: 'PRIMARY_DISEASE' })
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(
|
||||||
|
(response.body.data as Array<{ label: string; enabled: boolean }>).some(
|
||||||
|
(item) => item.label === '先天性脑积水' && item.enabled === true,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('字典维护流程', () => {
|
||||||
|
it('成功:SYSTEM_ADMIN 可新增、更新、删除字典项,非管理员读取不到停用项', async () => {
|
||||||
|
const createLabel = uniqueSeedValue('字典项');
|
||||||
|
const updateLabel = `${createLabel}-启用`;
|
||||||
|
|
||||||
|
const createResponse = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/dictionaries')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
|
.send({
|
||||||
|
type: 'SHUNT_MODE',
|
||||||
|
label: createLabel,
|
||||||
|
sortOrder: 999,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessEnvelope(createResponse, 201);
|
||||||
|
const createdId = createResponse.body.data.id as number;
|
||||||
|
|
||||||
|
const doctorReadResponse = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/dictionaries')
|
||||||
|
.query({ type: 'SHUNT_MODE' })
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(doctorReadResponse, 200);
|
||||||
|
expect(
|
||||||
|
(doctorReadResponse.body.data as Array<{ label: string }>).some(
|
||||||
|
(item) => item.label === createLabel,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
const adminReadResponse = await request(ctx.app.getHttpServer())
|
||||||
|
.get('/b/dictionaries')
|
||||||
|
.query({ type: 'SHUNT_MODE', includeDisabled: true })
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(adminReadResponse, 200);
|
||||||
|
expect(
|
||||||
|
(adminReadResponse.body.data as Array<{ label: string }>).some(
|
||||||
|
(item) => item.label === createLabel,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const updateResponse = await request(ctx.app.getHttpServer())
|
||||||
|
.patch(`/b/dictionaries/${createdId}`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||||
|
.send({
|
||||||
|
label: updateLabel,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessEnvelope(updateResponse, 200);
|
||||||
|
expect(updateResponse.body.data.label).toBe(updateLabel);
|
||||||
|
expect(updateResponse.body.data.enabled).toBe(true);
|
||||||
|
|
||||||
|
const deleteResponse = await request(ctx.app.getHttpServer())
|
||||||
|
.delete(`/b/dictionaries/${createdId}`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||||
|
|
||||||
|
expectSuccessEnvelope(deleteResponse, 200);
|
||||||
|
expect(deleteResponse.body.data.id).toBe(createdId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('角色矩阵:仅 SYSTEM_ADMIN 可维护字典,其他角色 403,未登录 401', async () => {
|
||||||
|
await assertRoleMatrix({
|
||||||
|
name: 'POST /b/dictionaries role matrix',
|
||||||
|
tokens: ctx.tokens,
|
||||||
|
expectedStatusByRole: {
|
||||||
|
[Role.SYSTEM_ADMIN]: 201,
|
||||||
|
[Role.HOSPITAL_ADMIN]: 403,
|
||||||
|
[Role.DIRECTOR]: 403,
|
||||||
|
[Role.LEADER]: 403,
|
||||||
|
[Role.DOCTOR]: 403,
|
||||||
|
[Role.ENGINEER]: 403,
|
||||||
|
},
|
||||||
|
sendAsRole: async (_role, token) =>
|
||||||
|
request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/dictionaries')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({
|
||||||
|
type: 'DISTAL_SHUNT_DIRECTION',
|
||||||
|
label: uniqueSeedValue('矩阵字典项'),
|
||||||
|
}),
|
||||||
|
sendWithoutToken: async () =>
|
||||||
|
request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/dictionaries')
|
||||||
|
.send({
|
||||||
|
type: 'DISTAL_SHUNT_DIRECTION',
|
||||||
|
label: uniqueSeedValue('匿名字典项'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,9 @@
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { Role } from '../../../src/generated/prisma/enums.js';
|
import {
|
||||||
|
DeviceStatus,
|
||||||
|
Role,
|
||||||
|
TaskStatus,
|
||||||
|
} from '../../../src/generated/prisma/enums.js';
|
||||||
import {
|
import {
|
||||||
closeE2EContext,
|
closeE2EContext,
|
||||||
createE2EContext,
|
createE2EContext,
|
||||||
@ -9,8 +13,17 @@ import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
|
|||||||
import {
|
import {
|
||||||
expectErrorEnvelope,
|
expectErrorEnvelope,
|
||||||
expectSuccessEnvelope,
|
expectSuccessEnvelope,
|
||||||
|
uniquePhone,
|
||||||
|
uniqueSeedValue,
|
||||||
} from '../helpers/e2e-http.helper.js';
|
} from '../helpers/e2e-http.helper.js';
|
||||||
|
|
||||||
|
function uniqueIdCard() {
|
||||||
|
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
|
||||||
|
.replace(/\D/g, '')
|
||||||
|
.slice(-4);
|
||||||
|
return `11010119990101${suffix.padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
describe('Patients Controllers (e2e)', () => {
|
describe('Patients Controllers (e2e)', () => {
|
||||||
let ctx: E2EContext;
|
let ctx: E2EContext;
|
||||||
|
|
||||||
@ -185,4 +198,209 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
|
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('患者手术录入', () => {
|
||||||
|
it('成功:DOCTOR 可创建患者并附带首台手术和植入设备', async () => {
|
||||||
|
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
|
||||||
|
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
expect(adjustableCatalog).toBeTruthy();
|
||||||
|
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/patients')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
|
.send({
|
||||||
|
name: '首术患者',
|
||||||
|
inpatientNo: uniqueSeedValue('zyh'),
|
||||||
|
projectName: '脑积水手术项目',
|
||||||
|
phone: uniquePhone(),
|
||||||
|
idCard: uniqueIdCard(),
|
||||||
|
doctorId: ctx.fixtures.users.doctorAId,
|
||||||
|
initialSurgery: {
|
||||||
|
surgeryDate: '2026-03-19T08:00:00.000Z',
|
||||||
|
surgeryName: '脑室腹腔分流术',
|
||||||
|
surgeonName: 'Seed Doctor A',
|
||||||
|
preOpPressure: 20,
|
||||||
|
primaryDisease: '梗阻性脑积水',
|
||||||
|
hydrocephalusTypes: ['交通性'],
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
implantCatalogId: adjustableCatalog!.id,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 120,
|
||||||
|
implantNotes: '首术植入',
|
||||||
|
labelImageUrl:
|
||||||
|
'https://seed.example.com/tests/first-surgery.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessEnvelope(response, 201);
|
||||||
|
expect(response.body.data.shuntSurgeryCount).toBe(1);
|
||||||
|
expect(response.body.data.surgeries).toHaveLength(1);
|
||||||
|
expect(response.body.data.surgeries[0].devices).toHaveLength(1);
|
||||||
|
expect(response.body.data.surgeries[0].devices[0].implantModel).toBe(
|
||||||
|
'SEED-ADJUSTABLE-VALVE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功:新增二次手术时可弃用旧设备且保留旧设备调压历史', async () => {
|
||||||
|
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
|
||||||
|
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
expect(adjustableCatalog).toBeTruthy();
|
||||||
|
|
||||||
|
const createPatientResponse = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/patients')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
|
.send({
|
||||||
|
name: '二次手术患者',
|
||||||
|
inpatientNo: uniqueSeedValue('zyh'),
|
||||||
|
projectName: '二次手术项目',
|
||||||
|
phone: uniquePhone(),
|
||||||
|
idCard: uniqueIdCard(),
|
||||||
|
doctorId: ctx.fixtures.users.doctorAId,
|
||||||
|
initialSurgery: {
|
||||||
|
surgeryDate: '2026-03-01T08:00:00.000Z',
|
||||||
|
surgeryName: '首次分流术',
|
||||||
|
surgeonName: 'Seed Doctor A',
|
||||||
|
preOpPressure: 18,
|
||||||
|
primaryDisease: '出血后脑积水',
|
||||||
|
hydrocephalusTypes: ['高压性'],
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
implantCatalogId: adjustableCatalog!.id,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 100,
|
||||||
|
implantNotes: '首术设备',
|
||||||
|
labelImageUrl:
|
||||||
|
'https://seed.example.com/tests/initial-device.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expectSuccessEnvelope(createPatientResponse, 201);
|
||||||
|
|
||||||
|
const patient = createPatientResponse.body.data as {
|
||||||
|
id: number;
|
||||||
|
devices: Array<{ id: number }>;
|
||||||
|
};
|
||||||
|
const oldDeviceId = patient.devices[0].id;
|
||||||
|
|
||||||
|
await ctx.prisma.task.create({
|
||||||
|
data: {
|
||||||
|
status: TaskStatus.COMPLETED,
|
||||||
|
creatorId: ctx.fixtures.users.doctorAId,
|
||||||
|
engineerId: ctx.fixtures.users.engineerAId,
|
||||||
|
hospitalId: ctx.fixtures.hospitalAId,
|
||||||
|
items: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
deviceId: oldDeviceId,
|
||||||
|
oldPressure: 100,
|
||||||
|
targetPressure: 120,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const surgeryResponse = await request(ctx.app.getHttpServer())
|
||||||
|
.post(`/b/patients/${patient.id}/surgeries`)
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
|
.send({
|
||||||
|
surgeryDate: '2026-03-18T08:00:00.000Z',
|
||||||
|
surgeryName: '二次翻修术',
|
||||||
|
surgeonName: 'Seed Doctor A',
|
||||||
|
preOpPressure: 16,
|
||||||
|
primaryDisease: '分流功能障碍',
|
||||||
|
hydrocephalusTypes: ['交通性', '高压性'],
|
||||||
|
abandonedDeviceIds: [oldDeviceId],
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
implantCatalogId: adjustableCatalog!.id,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['枕角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 120,
|
||||||
|
implantNotes: '二次手术新设备-1',
|
||||||
|
labelImageUrl: 'https://seed.example.com/tests/revision-1.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
implantCatalogId: adjustableCatalog!.id,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['胸前'],
|
||||||
|
distalShuntDirection: '胸腔',
|
||||||
|
initialPressure: 140,
|
||||||
|
implantNotes: '二次手术新设备-2',
|
||||||
|
labelImageUrl: 'https://seed.example.com/tests/revision-2.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expectSuccessEnvelope(surgeryResponse, 201);
|
||||||
|
expect(surgeryResponse.body.data.devices).toHaveLength(2);
|
||||||
|
expect(surgeryResponse.body.data.shuntSurgeryCount).toBe(2);
|
||||||
|
|
||||||
|
const oldDevice = await ctx.prisma.device.findUnique({
|
||||||
|
where: { id: oldDeviceId },
|
||||||
|
include: { taskItems: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(oldDevice?.isAbandoned).toBe(true);
|
||||||
|
expect(oldDevice?.status).toBe(DeviceStatus.INACTIVE);
|
||||||
|
expect(oldDevice?.taskItems).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败:手术录入设备不允许手工传 currentPressure', async () => {
|
||||||
|
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
|
||||||
|
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
expect(adjustableCatalog).toBeTruthy();
|
||||||
|
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/patients')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
|
.send({
|
||||||
|
name: '非法当前压力患者',
|
||||||
|
inpatientNo: uniqueSeedValue('zyh'),
|
||||||
|
projectName: '非法字段校验',
|
||||||
|
phone: uniquePhone(),
|
||||||
|
idCard: uniqueIdCard(),
|
||||||
|
doctorId: ctx.fixtures.users.doctorAId,
|
||||||
|
initialSurgery: {
|
||||||
|
surgeryDate: '2026-03-19T08:00:00.000Z',
|
||||||
|
surgeryName: '脑室腹腔分流术',
|
||||||
|
surgeonName: 'Seed Doctor A',
|
||||||
|
primaryDisease: '梗阻性脑积水',
|
||||||
|
hydrocephalusTypes: ['交通性'],
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
implantCatalogId: adjustableCatalog!.id,
|
||||||
|
shuntMode: 'VPS',
|
||||||
|
proximalPunctureAreas: ['额角'],
|
||||||
|
valvePlacementSites: ['耳后'],
|
||||||
|
distalShuntDirection: '腹腔',
|
||||||
|
initialPressure: 120,
|
||||||
|
currentPressure: 120,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expectErrorEnvelope(response, 400, '请求参数不合法');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -49,7 +49,7 @@ describe('BTasksController (e2e)', () => {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
deviceId: ctx.fixtures.devices.deviceA2Id,
|
deviceId: ctx.fixtures.devices.deviceA2Id,
|
||||||
targetPressure: 126,
|
targetPressure: 120,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -58,6 +58,22 @@ describe('BTasksController (e2e)', () => {
|
|||||||
expect(response.body.data.status).toBe(TaskStatus.PENDING);
|
expect(response.body.data.status).toBe(TaskStatus.PENDING);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('失败:可调压设备使用非法挡位返回 400', async () => {
|
||||||
|
const response = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/b/tasks/publish')
|
||||||
|
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||||
|
.send({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
deviceId: ctx.fixtures.devices.deviceA2Id,
|
||||||
|
targetPressure: 126,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expectErrorEnvelope(response, 400, '压力值不在该植入物配置的挡位范围内');
|
||||||
|
});
|
||||||
|
|
||||||
it('失败:发布跨院设备任务返回 404', async () => {
|
it('失败:发布跨院设备任务返回 404', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/b/tasks/publish')
|
.post('/b/tasks/publish')
|
||||||
@ -101,7 +117,7 @@ describe('BTasksController (e2e)', () => {
|
|||||||
it('成功:ENGINEER 可接收待处理任务', async () => {
|
it('成功:ENGINEER 可接收待处理任务', async () => {
|
||||||
const task = await publishPendingTask(
|
const task = await publishPendingTask(
|
||||||
ctx.fixtures.devices.deviceA2Id,
|
ctx.fixtures.devices.deviceA2Id,
|
||||||
127,
|
140,
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
@ -128,7 +144,7 @@ describe('BTasksController (e2e)', () => {
|
|||||||
it('状态机失败:重复接收返回 409', async () => {
|
it('状态机失败:重复接收返回 409', async () => {
|
||||||
const task = await publishPendingTask(
|
const task = await publishPendingTask(
|
||||||
ctx.fixtures.devices.deviceA3Id,
|
ctx.fixtures.devices.deviceA3Id,
|
||||||
122,
|
120,
|
||||||
);
|
);
|
||||||
|
|
||||||
const firstAccept = await request(ctx.app.getHttpServer())
|
const firstAccept = await request(ctx.app.getHttpServer())
|
||||||
@ -172,7 +188,7 @@ describe('BTasksController (e2e)', () => {
|
|||||||
|
|
||||||
describe('POST /b/tasks/complete', () => {
|
describe('POST /b/tasks/complete', () => {
|
||||||
it('成功:ENGINEER 完成已接收任务并同步设备压力', async () => {
|
it('成功:ENGINEER 完成已接收任务并同步设备压力', async () => {
|
||||||
const targetPressure = 135;
|
const targetPressure = 140;
|
||||||
const task = await publishPendingTask(
|
const task = await publishPendingTask(
|
||||||
ctx.fixtures.devices.deviceA1Id,
|
ctx.fixtures.devices.deviceA1Id,
|
||||||
targetPressure,
|
targetPressure,
|
||||||
@ -211,7 +227,7 @@ describe('BTasksController (e2e)', () => {
|
|||||||
it('状态机失败:未接收任务直接完成返回 409', async () => {
|
it('状态机失败:未接收任务直接完成返回 409', async () => {
|
||||||
const task = await publishPendingTask(
|
const task = await publishPendingTask(
|
||||||
ctx.fixtures.devices.deviceA2Id,
|
ctx.fixtures.devices.deviceA2Id,
|
||||||
124,
|
100,
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
@ -275,7 +291,7 @@ describe('BTasksController (e2e)', () => {
|
|||||||
it('状态机失败:已完成任务不可取消返回 409', async () => {
|
it('状态机失败:已完成任务不可取消返回 409', async () => {
|
||||||
const task = await publishPendingTask(
|
const task = await publishPendingTask(
|
||||||
ctx.fixtures.devices.deviceA2Id,
|
ctx.fixtures.devices.deviceA2Id,
|
||||||
123,
|
160,
|
||||||
);
|
);
|
||||||
|
|
||||||
const acceptResponse = await request(ctx.app.getHttpServer())
|
const acceptResponse = await request(ctx.app.getHttpServer())
|
||||||
|
|||||||
5
tyt-admin/components.d.ts
vendored
5
tyt-admin/components.d.ts
vendored
@ -18,6 +18,7 @@ declare module 'vue' {
|
|||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
@ -31,6 +32,7 @@ declare module 'vue' {
|
|||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
|
ElLink: typeof import('element-plus/es')['ElLink']
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
@ -39,8 +41,11 @@ declare module 'vue' {
|
|||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||||
|
|||||||
@ -11,6 +11,22 @@ export const getDeviceById = (id) => {
|
|||||||
return request.get(`/b/devices/${id}`);
|
return request.get(`/b/devices/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getImplantCatalogs = (params) => {
|
||||||
|
return request.get('/b/devices/catalogs', { params });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createImplantCatalog = (data) => {
|
||||||
|
return request.post('/b/devices/catalogs', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateImplantCatalog = (id, data) => {
|
||||||
|
return request.patch(`/b/devices/catalogs/${id}`, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteImplantCatalog = (id) => {
|
||||||
|
return request.delete(`/b/devices/catalogs/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const createDevice = (data) => {
|
export const createDevice = (data) => {
|
||||||
return request.post('/b/devices', data);
|
return request.post('/b/devices', data);
|
||||||
};
|
};
|
||||||
|
|||||||
17
tyt-admin/src/api/dictionaries.js
Normal file
17
tyt-admin/src/api/dictionaries.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export const getDictionaries = (params) => {
|
||||||
|
return request.get('/b/dictionaries', { params });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDictionaryItem = (data) => {
|
||||||
|
return request.post('/b/dictionaries', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDictionaryItem = (id, data) => {
|
||||||
|
return request.patch(`/b/dictionaries/${id}`, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDictionaryItem = (id) => {
|
||||||
|
return request.delete(`/b/dictionaries/${id}`);
|
||||||
|
};
|
||||||
@ -20,6 +20,10 @@ export const updatePatient = (id, data) => {
|
|||||||
return request.patch(`/b/patients/${id}`, data);
|
return request.patch(`/b/patients/${id}`, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createPatientSurgery = (id, data) => {
|
||||||
|
return request.post(`/b/patients/${id}/surgeries`, data);
|
||||||
|
};
|
||||||
|
|
||||||
export const deletePatient = (id) => {
|
export const deletePatient = (id) => {
|
||||||
return request.delete(`/b/patients/${id}`);
|
return request.delete(`/b/patients/${id}`);
|
||||||
};
|
};
|
||||||
|
|||||||
73
tyt-admin/src/constants/medical-dictionaries.js
Normal file
73
tyt-admin/src/constants/medical-dictionaries.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
export const MEDICAL_DICTIONARY_TYPES = Object.freeze({
|
||||||
|
PRIMARY_DISEASE: 'PRIMARY_DISEASE',
|
||||||
|
HYDROCEPHALUS_TYPE: 'HYDROCEPHALUS_TYPE',
|
||||||
|
SHUNT_MODE: 'SHUNT_MODE',
|
||||||
|
PROXIMAL_PUNCTURE_AREA: 'PROXIMAL_PUNCTURE_AREA',
|
||||||
|
VALVE_PLACEMENT_SITE: 'VALVE_PLACEMENT_SITE',
|
||||||
|
DISTAL_SHUNT_DIRECTION: 'DISTAL_SHUNT_DIRECTION',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MEDICAL_DICTIONARY_TYPE_OPTIONS = Object.freeze([
|
||||||
|
{
|
||||||
|
label: '原发病',
|
||||||
|
value: MEDICAL_DICTIONARY_TYPES.PRIMARY_DISEASE,
|
||||||
|
optionKey: 'primaryDiseaseOptions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '脑积水类型',
|
||||||
|
value: MEDICAL_DICTIONARY_TYPES.HYDROCEPHALUS_TYPE,
|
||||||
|
optionKey: 'hydrocephalusTypeOptions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '分流方式',
|
||||||
|
value: MEDICAL_DICTIONARY_TYPES.SHUNT_MODE,
|
||||||
|
optionKey: 'shuntModeOptions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '近端穿刺区域',
|
||||||
|
value: MEDICAL_DICTIONARY_TYPES.PROXIMAL_PUNCTURE_AREA,
|
||||||
|
optionKey: 'proximalPunctureOptions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '阀门植入部位',
|
||||||
|
value: MEDICAL_DICTIONARY_TYPES.VALVE_PLACEMENT_SITE,
|
||||||
|
optionKey: 'valvePlacementOptions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '远端分流方向',
|
||||||
|
value: MEDICAL_DICTIONARY_TYPES.DISTAL_SHUNT_DIRECTION,
|
||||||
|
optionKey: 'distalShuntDirectionOptions',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function createEmptyMedicalDictionaryOptions() {
|
||||||
|
return {
|
||||||
|
primaryDiseaseOptions: [],
|
||||||
|
hydrocephalusTypeOptions: [],
|
||||||
|
shuntModeOptions: [],
|
||||||
|
proximalPunctureOptions: [],
|
||||||
|
valvePlacementOptions: [],
|
||||||
|
distalShuntDirectionOptions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupMedicalDictionaryItems(items) {
|
||||||
|
const grouped = createEmptyMedicalDictionaryOptions();
|
||||||
|
|
||||||
|
MEDICAL_DICTIONARY_TYPE_OPTIONS.forEach((typeOption) => {
|
||||||
|
grouped[typeOption.optionKey] = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
(Array.isArray(items) ? items : []).forEach((item) => {
|
||||||
|
const typeOption = MEDICAL_DICTIONARY_TYPE_OPTIONS.find(
|
||||||
|
(option) => option.value === item.type,
|
||||||
|
);
|
||||||
|
if (!typeOption || !item.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped[typeOption.optionKey].push(item.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
@ -29,8 +29,9 @@ export const ROLE_PERMISSIONS = Object.freeze({
|
|||||||
// 主任/组长仍可通过接口读取科室信息,但不再开放独立“科室管理”页面。
|
// 主任/组长仍可通过接口读取科室信息,但不再开放独立“科室管理”页面。
|
||||||
ORG_DEPARTMENTS: ADMIN_ROLES,
|
ORG_DEPARTMENTS: ADMIN_ROLES,
|
||||||
ORG_GROUPS: ORG_MANAGER_ROLES,
|
ORG_GROUPS: ORG_MANAGER_ROLES,
|
||||||
|
DICTIONARIES: Object.freeze(['SYSTEM_ADMIN']),
|
||||||
USERS: USER_MANAGER_ROLES,
|
USERS: USER_MANAGER_ROLES,
|
||||||
DEVICES: ADMIN_ROLES,
|
DEVICES: Object.freeze(['SYSTEM_ADMIN']),
|
||||||
TASKS: TASK_ROLES,
|
TASKS: TASK_ROLES,
|
||||||
PATIENTS: PATIENT_ROLES,
|
PATIENTS: PATIENT_ROLES,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,7 +36,10 @@
|
|||||||
<el-icon><Share /></el-icon>
|
<el-icon><Share /></el-icon>
|
||||||
<span>组织架构图</span>
|
<span>组织架构图</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item v-if="canAccessDepartments" index="/organization/departments">
|
<el-menu-item
|
||||||
|
v-if="canAccessDepartments"
|
||||||
|
index="/organization/departments"
|
||||||
|
>
|
||||||
<el-icon><OfficeBuilding /></el-icon>
|
<el-icon><OfficeBuilding /></el-icon>
|
||||||
<span>科室管理</span>
|
<span>科室管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
@ -51,6 +54,11 @@
|
|||||||
<span>{{ usersMenuLabel }}</span>
|
<span>{{ usersMenuLabel }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-menu-item v-if="canAccessDictionaries" index="/dictionaries">
|
||||||
|
<el-icon><CollectionTag /></el-icon>
|
||||||
|
<span>字典管理</span>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
<el-menu-item v-if="canAccessDevices" index="/devices">
|
<el-menu-item v-if="canAccessDevices" index="/devices">
|
||||||
<el-icon><Monitor /></el-icon>
|
<el-icon><Monitor /></el-icon>
|
||||||
<span>设备管理</span>
|
<span>设备管理</span>
|
||||||
@ -119,6 +127,7 @@ import {
|
|||||||
Connection,
|
Connection,
|
||||||
Share,
|
Share,
|
||||||
Monitor,
|
Monitor,
|
||||||
|
CollectionTag,
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -136,6 +145,9 @@ const canAccessUsers = computed(() =>
|
|||||||
const canAccessDevices = computed(() =>
|
const canAccessDevices = computed(() =>
|
||||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.DEVICES),
|
hasRolePermission(userStore.role, ROLE_PERMISSIONS.DEVICES),
|
||||||
);
|
);
|
||||||
|
const canAccessDictionaries = computed(() =>
|
||||||
|
hasRolePermission(userStore.role, ROLE_PERMISSIONS.DICTIONARIES),
|
||||||
|
);
|
||||||
const canAccessOrgTree = computed(() =>
|
const canAccessOrgTree = computed(() =>
|
||||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE),
|
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -77,6 +77,16 @@ const routes = [
|
|||||||
allowedRoles: ROLE_PERMISSIONS.USERS,
|
allowedRoles: ROLE_PERMISSIONS.USERS,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'dictionaries',
|
||||||
|
name: 'Dictionaries',
|
||||||
|
component: () => import('../views/dictionaries/Dictionaries.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '字典管理',
|
||||||
|
requiresAuth: true,
|
||||||
|
allowedRoles: ROLE_PERMISSIONS.DICTIONARIES,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'devices',
|
path: 'devices',
|
||||||
name: 'Devices',
|
name: 'Devices',
|
||||||
|
|||||||
@ -1,76 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="devices-container">
|
<div class="devices-container">
|
||||||
<el-card>
|
<el-card class="panel-card">
|
||||||
<div class="header-actions">
|
<template #header>
|
||||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
<div class="panel-head">
|
||||||
<el-form-item label="所属医院" v-if="isSystemAdmin">
|
<div>
|
||||||
<el-select
|
<div class="panel-title">植入物目录</div>
|
||||||
v-model="searchForm.hospitalId"
|
<div class="panel-subtitle">
|
||||||
clearable
|
这里维护患者手术里可选的全局植入物目录,不再按医院或患者单独建档
|
||||||
filterable
|
</div>
|
||||||
placeholder="全部医院"
|
</div>
|
||||||
style="width: 220px"
|
</div>
|
||||||
@change="handleSearchHospitalChange"
|
</template>
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="hospital in hospitals"
|
|
||||||
:key="hospital.id"
|
|
||||||
:label="hospital.name"
|
|
||||||
:value="hospital.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="归属患者">
|
<el-alert
|
||||||
<el-select
|
type="info"
|
||||||
v-model="searchForm.patientId"
|
:closable="false"
|
||||||
clearable
|
class="page-alert"
|
||||||
filterable
|
title="一个目录项可被多个患者手术重复绑定;患者手术里形成的是患者自己的植入记录,不会占用或锁定目录。"
|
||||||
placeholder="全部患者"
|
|
||||||
style="width: 260px"
|
|
||||||
:disabled="isSystemAdmin && !searchForm.hospitalId"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="patient in searchPatients"
|
|
||||||
:key="patient.id"
|
|
||||||
:label="formatPatientLabel(patient)"
|
|
||||||
:value="patient.id"
|
|
||||||
/>
|
/>
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="设备状态">
|
|
||||||
<el-select
|
|
||||||
v-model="searchForm.status"
|
|
||||||
clearable
|
|
||||||
placeholder="全部状态"
|
|
||||||
style="width: 160px"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="item in DEVICE_STATUS_OPTIONS"
|
|
||||||
:key="item.value"
|
|
||||||
:label="item.label"
|
|
||||||
:value="item.value"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-form :inline="true" :model="searchForm">
|
||||||
<el-form-item label="关键词">
|
<el-form-item label="关键词">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="searchForm.keyword"
|
v-model="searchForm.keyword"
|
||||||
clearable
|
clearable
|
||||||
placeholder="设备 SN / 患者姓名 / 手机号"
|
placeholder="型号编码 / 厂家 / 名称"
|
||||||
style="width: 260px"
|
style="width: 280px"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="handleSearch" icon="Search">
|
<el-button type="primary" @click="fetchData" icon="Search">
|
||||||
查询
|
查询
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
||||||
<el-button type="success" @click="openCreateDialog" icon="Plus">
|
<el-button type="success" @click="openCreateDialog" icon="Plus">
|
||||||
新增设备
|
新增植入物
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@ -83,44 +49,40 @@
|
|||||||
stripe
|
stripe
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
<el-table-column prop="id" label="ID" width="90" align="center" />
|
||||||
<el-table-column prop="snCode" label="设备 SN" min-width="180" />
|
<el-table-column prop="modelCode" label="型号编码" min-width="180" />
|
||||||
<el-table-column
|
<el-table-column prop="manufacturer" label="厂家" min-width="180" />
|
||||||
prop="currentPressure"
|
<el-table-column prop="name" label="名称" min-width="180" />
|
||||||
label="当前压力"
|
<el-table-column label="器械类型" width="120" align="center">
|
||||||
width="120"
|
|
||||||
align="center"
|
|
||||||
/>
|
|
||||||
<el-table-column label="设备状态" width="120" align="center">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getStatusTagType(row.status)">
|
<el-tag :type="row.isPressureAdjustable ? 'success' : 'info'">
|
||||||
{{ getStatusName(row.status) }}
|
{{ row.isPressureAdjustable ? '可调压' : '非调压' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="归属患者" min-width="140">
|
<el-table-column label="挡位" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.patient?.name || '-' }}
|
<div v-if="row.pressureLevels?.length" class="pressure-tag-list">
|
||||||
|
<el-tag
|
||||||
|
v-for="level in row.pressureLevels"
|
||||||
|
:key="`${row.id}-${level}`"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
>
|
||||||
|
{{ level }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="患者手机号" min-width="150">
|
<el-table-column label="备注" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.patient?.phone || '-' }}
|
{{ row.notes || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="所属医院" min-width="160">
|
<el-table-column label="更新时间" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.patient?.hospital?.name || '-' }}
|
{{ formatDateTime(row.updatedAt) }}
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="归属医生" min-width="140">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ row.patient?.doctor?.name || '-' }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="关联任务数" width="120" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ row._count?.taskItems ?? 0 }}
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||||
@ -134,93 +96,98 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<div class="pagination-container">
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="page"
|
|
||||||
v-model:page-size="pageSize"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
:total="total"
|
|
||||||
background
|
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
|
||||||
@size-change="fetchData"
|
|
||||||
@current-change="fetchData"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
:title="isEdit ? '编辑设备' : '新增设备'"
|
:title="isEdit ? '编辑植入物目录' : '新增植入物目录'"
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
width="560px"
|
width="760px"
|
||||||
|
destroy-on-close
|
||||||
@close="resetForm"
|
@close="resetForm"
|
||||||
>
|
>
|
||||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="110px">
|
||||||
<el-form-item label="所属医院" prop="hospitalId" v-if="isSystemAdmin">
|
<el-row :gutter="16">
|
||||||
<el-select
|
<el-col :xs="24" :md="12">
|
||||||
v-model="form.hospitalId"
|
<el-form-item label="型号编码" prop="modelCode">
|
||||||
filterable
|
|
||||||
placeholder="请选择医院"
|
|
||||||
style="width: 100%"
|
|
||||||
@change="handleFormHospitalChange"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="hospital in hospitals"
|
|
||||||
:key="hospital.id"
|
|
||||||
:label="hospital.name"
|
|
||||||
:value="hospital.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="归属患者" prop="patientId">
|
|
||||||
<el-select
|
|
||||||
v-model="form.patientId"
|
|
||||||
filterable
|
|
||||||
placeholder="请选择患者"
|
|
||||||
style="width: 100%"
|
|
||||||
:disabled="isSystemAdmin && !form.hospitalId"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="patient in formPatients"
|
|
||||||
:key="patient.id"
|
|
||||||
:label="formatPatientLabel(patient)"
|
|
||||||
:value="patient.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-form-item label="设备 SN" prop="snCode">
|
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.snCode"
|
v-model="form.modelCode"
|
||||||
placeholder="请输入设备 SN"
|
|
||||||
maxlength="64"
|
maxlength="64"
|
||||||
|
placeholder="请输入型号编码"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
<el-form-item label="当前压力" prop="currentPressure">
|
<el-col :xs="24" :md="12">
|
||||||
<el-input-number
|
<el-form-item label="厂家" prop="manufacturer">
|
||||||
v-model="form.currentPressure"
|
<el-input
|
||||||
:min="0"
|
v-model="form.manufacturer"
|
||||||
:step="1"
|
maxlength="100"
|
||||||
:controls="false"
|
placeholder="请输入厂家"
|
||||||
style="width: 100%"
|
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input
|
||||||
|
v-model="form.name"
|
||||||
|
maxlength="100"
|
||||||
|
placeholder="请输入植入物名称"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="可调压">
|
||||||
|
<el-switch
|
||||||
|
v-model="form.isPressureAdjustable"
|
||||||
|
active-text="是"
|
||||||
|
inactive-text="否"
|
||||||
|
@change="handleAdjustableChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
<el-form-item label="设备状态" prop="status">
|
<el-form-item label="压力挡位" v-if="form.isPressureAdjustable">
|
||||||
<el-select
|
<div class="pressure-level-panel">
|
||||||
v-model="form.status"
|
<div
|
||||||
placeholder="请选择状态"
|
v-for="(level, index) in form.pressureLevels"
|
||||||
style="width: 100%"
|
:key="`pressure-level-${index}`"
|
||||||
|
class="pressure-level-row"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-input-number
|
||||||
v-for="item in DEVICE_STATUS_OPTIONS"
|
v-model="form.pressureLevels[index]"
|
||||||
:key="item.value"
|
:min="0"
|
||||||
:label="item.label"
|
:controls="false"
|
||||||
:value="item.value"
|
placeholder="请输入挡位值"
|
||||||
|
style="width: 180px"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="removePressureLevel(index)"
|
||||||
|
:disabled="form.pressureLevels.length === 1"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-button type="primary" plain @click="addPressureLevel">
|
||||||
|
新增挡位
|
||||||
|
</el-button>
|
||||||
|
<div class="field-hint">
|
||||||
|
每个挡位填一个整数值,保存时会自动去重并按从小到大排序
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input
|
||||||
|
v-model="form.notes"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="可填写适用说明、材质、适配场景等"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
@ -232,7 +199,7 @@
|
|||||||
:loading="submitLoading"
|
:loading="submitLoading"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
确定
|
{{ isEdit ? '保存修改' : '创建目录' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -241,26 +208,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
getDevices,
|
createImplantCatalog,
|
||||||
createDevice,
|
deleteImplantCatalog,
|
||||||
updateDevice,
|
getImplantCatalogs,
|
||||||
deleteDevice,
|
updateImplantCatalog,
|
||||||
} from '../../api/devices';
|
} from '../../api/devices';
|
||||||
import { getHospitals } from '../../api/organization';
|
|
||||||
import { getPatients } from '../../api/patients';
|
|
||||||
import { useUserStore } from '../../store/user';
|
|
||||||
|
|
||||||
const userStore = useUserStore();
|
|
||||||
|
|
||||||
const DEVICE_STATUS_OPTIONS = [
|
|
||||||
{ label: '启用', value: 'ACTIVE' },
|
|
||||||
{ label: '停用', value: 'INACTIVE' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const submitLoading = ref(false);
|
const submitLoading = ref(false);
|
||||||
@ -268,221 +223,165 @@ const dialogVisible = ref(false);
|
|||||||
const isEdit = ref(false);
|
const isEdit = ref(false);
|
||||||
const formRef = ref(null);
|
const formRef = ref(null);
|
||||||
const currentId = ref(null);
|
const currentId = ref(null);
|
||||||
|
|
||||||
const hospitals = ref([]);
|
|
||||||
const searchPatients = ref([]);
|
|
||||||
const formPatients = ref([]);
|
|
||||||
|
|
||||||
const tableData = ref([]);
|
const tableData = ref([]);
|
||||||
const total = ref(0);
|
|
||||||
const page = ref(1);
|
|
||||||
const pageSize = ref(10);
|
|
||||||
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
hospitalId: null,
|
|
||||||
patientId: null,
|
|
||||||
status: '',
|
|
||||||
keyword: '',
|
keyword: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive(createDefaultForm());
|
||||||
hospitalId: null,
|
|
||||||
patientId: null,
|
|
||||||
snCode: '',
|
|
||||||
currentPressure: 0,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
});
|
|
||||||
|
|
||||||
const rules = computed(() => ({
|
const rules = {
|
||||||
hospitalId: isSystemAdmin.value
|
modelCode: [{ required: true, message: '请输入型号编码', trigger: 'blur' }],
|
||||||
? [{ required: true, message: '请选择所属医院', trigger: 'change' }]
|
manufacturer: [{ required: true, message: '请输入厂家', trigger: 'blur' }],
|
||||||
: [],
|
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||||
patientId: [{ required: true, message: '请选择归属患者', trigger: 'change' }],
|
|
||||||
snCode: [{ required: true, message: '请输入设备 SN', trigger: 'blur' }],
|
|
||||||
currentPressure: [
|
|
||||||
{ required: true, message: '请输入当前压力', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
status: [{ required: true, message: '请选择设备状态', trigger: 'change' }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const getStatusName = (status) => {
|
|
||||||
return (
|
|
||||||
DEVICE_STATUS_OPTIONS.find((item) => item.value === status)?.label || status
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusTagType = (status) => {
|
function createDefaultForm() {
|
||||||
return status === 'ACTIVE' ? 'success' : 'info';
|
return {
|
||||||
};
|
modelCode: '',
|
||||||
|
manufacturer: '',
|
||||||
|
name: '',
|
||||||
|
isPressureAdjustable: true,
|
||||||
|
pressureLevels: [null],
|
||||||
|
notes: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const formatPatientLabel = (patient) => {
|
function formatDateTime(value) {
|
||||||
const hospitalName = patient.hospital?.name
|
if (!value) {
|
||||||
? ` / ${patient.hospital.name}`
|
return '-';
|
||||||
: '';
|
|
||||||
return `${patient.name}(${patient.phone}${hospitalName})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchHospitals = async () => {
|
|
||||||
if (!isSystemAdmin.value) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getHospitals({ page: 1, pageSize: 100 });
|
const date = new Date(value);
|
||||||
hospitals.value = res.list || [];
|
if (Number.isNaN(date.getTime())) {
|
||||||
};
|
return '-';
|
||||||
|
|
||||||
// 搜索区患者下拉只跟筛选条件联动,避免和弹窗下拉状态互相干扰。
|
|
||||||
const fetchSearchPatients = async () => {
|
|
||||||
if (isSystemAdmin.value && !searchForm.hospitalId) {
|
|
||||||
searchPatients.value = [];
|
|
||||||
searchForm.patientId = null;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = {};
|
return date.toLocaleString('zh-CN', { hour12: false });
|
||||||
if (isSystemAdmin.value) {
|
}
|
||||||
params.hospitalId = searchForm.hospitalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getPatients(params);
|
function normalizePressureLevels(levels) {
|
||||||
searchPatients.value = Array.isArray(res) ? res : [];
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
(Array.isArray(levels) ? levels : [])
|
||||||
|
.filter(
|
||||||
|
(level) => level !== null && level !== undefined && level !== '',
|
||||||
|
)
|
||||||
|
.map((level) => Number(level))
|
||||||
|
.filter((level) => Number.isInteger(level) && level >= 0),
|
||||||
|
),
|
||||||
|
).sort((left, right) => left - right);
|
||||||
|
}
|
||||||
|
|
||||||
if (!searchPatients.value.some((item) => item.id === searchForm.patientId)) {
|
async function fetchData() {
|
||||||
searchForm.patientId = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 表单患者下拉按当前选择的医院动态刷新,确保 patientId 可写且合法。
|
|
||||||
const fetchFormPatients = async (hospitalId = form.hospitalId) => {
|
|
||||||
if (isSystemAdmin.value && !hospitalId) {
|
|
||||||
formPatients.value = [];
|
|
||||||
form.patientId = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = {};
|
|
||||||
if (isSystemAdmin.value) {
|
|
||||||
params.hospitalId = hospitalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getPatients(params);
|
|
||||||
formPatients.value = Array.isArray(res) ? res : [];
|
|
||||||
|
|
||||||
if (!formPatients.value.some((item) => item.id === form.patientId)) {
|
|
||||||
form.patientId = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const params = {
|
const res = await getImplantCatalogs({
|
||||||
page: page.value,
|
|
||||||
pageSize: pageSize.value,
|
|
||||||
keyword: searchForm.keyword || undefined,
|
keyword: searchForm.keyword || undefined,
|
||||||
status: searchForm.status || undefined,
|
});
|
||||||
patientId: searchForm.patientId || undefined,
|
tableData.value = Array.isArray(res) ? res : [];
|
||||||
};
|
|
||||||
|
|
||||||
if (isSystemAdmin.value && searchForm.hospitalId) {
|
|
||||||
params.hospitalId = searchForm.hospitalId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getDevices(params);
|
|
||||||
tableData.value = res.list || [];
|
|
||||||
total.value = res.total || 0;
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSearchHospitalChange = async () => {
|
async function resetSearch() {
|
||||||
page.value = 1;
|
|
||||||
await fetchSearchPatients();
|
|
||||||
await fetchData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormHospitalChange = async (hospitalId) => {
|
|
||||||
form.patientId = null;
|
|
||||||
await fetchFormPatients(hospitalId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
|
||||||
page.value = 1;
|
|
||||||
fetchData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetSearch = async () => {
|
|
||||||
searchForm.hospitalId = null;
|
|
||||||
searchForm.patientId = null;
|
|
||||||
searchForm.status = '';
|
|
||||||
searchForm.keyword = '';
|
searchForm.keyword = '';
|
||||||
page.value = 1;
|
|
||||||
await fetchSearchPatients();
|
|
||||||
await fetchData();
|
await fetchData();
|
||||||
};
|
}
|
||||||
|
|
||||||
const resetForm = () => {
|
function resetForm() {
|
||||||
formRef.value?.resetFields();
|
formRef.value?.clearValidate?.();
|
||||||
form.hospitalId = null;
|
const next = createDefaultForm();
|
||||||
form.patientId = null;
|
form.modelCode = next.modelCode;
|
||||||
form.snCode = '';
|
form.manufacturer = next.manufacturer;
|
||||||
form.currentPressure = 0;
|
form.name = next.name;
|
||||||
form.status = 'ACTIVE';
|
form.isPressureAdjustable = next.isPressureAdjustable;
|
||||||
|
form.pressureLevels = next.pressureLevels;
|
||||||
|
form.notes = next.notes;
|
||||||
currentId.value = null;
|
currentId.value = null;
|
||||||
formPatients.value = [];
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const openCreateDialog = async () => {
|
function handleAdjustableChange(enabled) {
|
||||||
isEdit.value = false;
|
if (!enabled) {
|
||||||
resetForm();
|
form.pressureLevels = [null];
|
||||||
|
return;
|
||||||
// 系统管理员可沿用当前筛选医院,院管则固定为本人医院。
|
|
||||||
if (isSystemAdmin.value) {
|
|
||||||
form.hospitalId = searchForm.hospitalId || null;
|
|
||||||
} else {
|
|
||||||
form.hospitalId = userStore.userInfo?.hospitalId || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchFormPatients(form.hospitalId);
|
if (!Array.isArray(form.pressureLevels) || form.pressureLevels.length === 0) {
|
||||||
dialogVisible.value = true;
|
form.pressureLevels = [null];
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openEditDialog = async (row) => {
|
function addPressureLevel() {
|
||||||
|
form.pressureLevels.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePressureLevel(index) {
|
||||||
|
if (form.pressureLevels.length === 1) {
|
||||||
|
form.pressureLevels.splice(index, 1, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.pressureLevels.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
isEdit.value = false;
|
||||||
|
resetForm();
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(row) {
|
||||||
isEdit.value = true;
|
isEdit.value = true;
|
||||||
|
formRef.value?.clearValidate?.();
|
||||||
currentId.value = row.id;
|
currentId.value = row.id;
|
||||||
form.snCode = row.snCode;
|
form.modelCode = row.modelCode || '';
|
||||||
form.currentPressure = row.currentPressure;
|
form.manufacturer = row.manufacturer || '';
|
||||||
form.status = row.status;
|
form.name = row.name || '';
|
||||||
form.hospitalId =
|
form.isPressureAdjustable = Boolean(row.isPressureAdjustable);
|
||||||
row.patient?.hospital?.id || row.patient?.hospitalId || null;
|
form.pressureLevels =
|
||||||
|
Array.isArray(row.pressureLevels) && row.pressureLevels.length > 0
|
||||||
await fetchFormPatients(form.hospitalId);
|
? [...row.pressureLevels]
|
||||||
form.patientId = row.patient?.id || null;
|
: [null];
|
||||||
|
form.notes = row.notes || '';
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
async function handleSubmit() {
|
||||||
if (!formRef.value) return;
|
if (!formRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await formRef.value.validate(async (valid) => {
|
try {
|
||||||
if (!valid) return;
|
await formRef.value.validate();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLevels = normalizePressureLevels(form.pressureLevels);
|
||||||
|
if (form.isPressureAdjustable && normalizedLevels.length === 0) {
|
||||||
|
ElMessage.warning('可调压植入物至少需要录入一个挡位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
snCode: form.snCode,
|
modelCode: form.modelCode,
|
||||||
currentPressure: Number(form.currentPressure),
|
manufacturer: form.manufacturer,
|
||||||
status: form.status,
|
name: form.name,
|
||||||
patientId: form.patientId,
|
isPressureAdjustable: form.isPressureAdjustable,
|
||||||
|
pressureLevels: form.isPressureAdjustable ? normalizedLevels : [],
|
||||||
|
notes: form.notes || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await updateDevice(currentId.value, payload);
|
await updateImplantCatalog(currentId.value, payload);
|
||||||
ElMessage.success('更新成功');
|
ElMessage.success('植入物目录已更新');
|
||||||
} else {
|
} else {
|
||||||
await createDevice(payload);
|
await createImplantCatalog(payload);
|
||||||
ElMessage.success('创建成功');
|
ElMessage.success('植入物目录已创建');
|
||||||
}
|
}
|
||||||
|
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
@ -490,27 +389,26 @@ const handleSubmit = async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
submitLoading.value = false;
|
submitLoading.value = false;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (row) => {
|
async function handleDelete(row) {
|
||||||
ElMessageBox.confirm(`确定要删除设备 "${row.snCode}" 吗?`, '警告', {
|
await ElMessageBox.confirm(
|
||||||
confirmButtonText: '确定',
|
`确认删除植入物目录「${row.name}」吗?已绑定到患者手术的目录项将无法删除。`,
|
||||||
cancelButtonText: '取消',
|
'删除确认',
|
||||||
|
{
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
confirmButtonText: '删除',
|
||||||
.then(async () => {
|
cancelButtonText: '取消',
|
||||||
await deleteDevice(row.id);
|
},
|
||||||
ElMessage.success('删除成功');
|
);
|
||||||
await fetchData();
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
await deleteImplantCatalog(row.id);
|
||||||
await fetchHospitals();
|
ElMessage.success('植入物目录已删除');
|
||||||
await fetchSearchPatients();
|
|
||||||
await fetchData();
|
await fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -519,13 +417,63 @@ onMounted(async () => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.panel-card {
|
||||||
margin-bottom: 20px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-container {
|
.panel-head {
|
||||||
margin-top: 20px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pressure-tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pressure-level-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pressure-level-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
376
tyt-admin/src/views/dictionaries/Dictionaries.vue
Normal file
376
tyt-admin/src/views/dictionaries/Dictionaries.vue
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dictionaries-container">
|
||||||
|
<el-card class="panel-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<div class="panel-title">医学选项字典</div>
|
||||||
|
<div class="panel-subtitle">
|
||||||
|
系统管理员维护患者手术表单中的标准选项,停用后不会再出现在录入页
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
class="page-alert"
|
||||||
|
title="当前字典为系统级公共配置,所有医院患者录入表单共享同一套选项。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-form :inline="true" :model="searchForm">
|
||||||
|
<el-form-item label="字典类型">
|
||||||
|
<el-select
|
||||||
|
v-model="searchForm.type"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="全部类型"
|
||||||
|
style="width: 220px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in MEDICAL_DICTIONARY_TYPE_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="searchForm.keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="按字典项名称筛选"
|
||||||
|
style="width: 260px"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="fetchData" icon="Search">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
||||||
|
<el-button type="success" @click="openCreateDialog" icon="Plus">
|
||||||
|
新增字典项
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="filteredTableData"
|
||||||
|
v-loading="loading"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" label="ID" width="90" align="center" />
|
||||||
|
<el-table-column label="字典类型" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getTypeLabel(row.type) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="label" label="字典项名称" min-width="240" />
|
||||||
|
<el-table-column
|
||||||
|
prop="sortOrder"
|
||||||
|
label="排序"
|
||||||
|
width="110"
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<el-table-column label="状态" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.enabled ? 'success' : 'info'">
|
||||||
|
{{ row.enabled ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="更新时间" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.updatedAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="primary" @click="openEditDialog(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="handleDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
:title="isEdit ? '编辑字典项' : '新增字典项'"
|
||||||
|
v-model="dialogVisible"
|
||||||
|
width="560px"
|
||||||
|
destroy-on-close
|
||||||
|
@close="resetForm"
|
||||||
|
>
|
||||||
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="110px">
|
||||||
|
<el-form-item label="字典类型" prop="type">
|
||||||
|
<el-select
|
||||||
|
v-model="form.type"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择字典类型"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in MEDICAL_DICTIONARY_TYPE_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="字典项名称" prop="label">
|
||||||
|
<el-input
|
||||||
|
v-model="form.label"
|
||||||
|
maxlength="50"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入字典项名称"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="排序值" prop="sortOrder">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.sortOrder"
|
||||||
|
:controls="false"
|
||||||
|
:step="10"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="是否启用" prop="enabled">
|
||||||
|
<el-switch
|
||||||
|
v-model="form.enabled"
|
||||||
|
active-text="启用"
|
||||||
|
inactive-text="停用"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
{{ isEdit ? '保存修改' : '创建字典项' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import {
|
||||||
|
createDictionaryItem,
|
||||||
|
deleteDictionaryItem,
|
||||||
|
getDictionaries,
|
||||||
|
updateDictionaryItem,
|
||||||
|
} from '../../api/dictionaries';
|
||||||
|
import { MEDICAL_DICTIONARY_TYPE_OPTIONS } from '../../constants/medical-dictionaries';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const isEdit = ref(false);
|
||||||
|
const formRef = ref(null);
|
||||||
|
const currentId = ref(null);
|
||||||
|
const tableData = ref([]);
|
||||||
|
|
||||||
|
const searchForm = reactive({
|
||||||
|
type: '',
|
||||||
|
keyword: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
type: MEDICAL_DICTIONARY_TYPE_OPTIONS[0]?.value || '',
|
||||||
|
label: '',
|
||||||
|
sortOrder: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
type: [{ required: true, message: '请选择字典类型', trigger: 'change' }],
|
||||||
|
label: [{ required: true, message: '请输入字典项名称', trigger: 'blur' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTableData = computed(() => {
|
||||||
|
const keyword = String(searchForm.keyword || '').trim();
|
||||||
|
if (!keyword) {
|
||||||
|
return tableData.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tableData.value.filter((item) =>
|
||||||
|
String(item.label || '').includes(keyword),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTypeLabel = (type) => {
|
||||||
|
return (
|
||||||
|
MEDICAL_DICTIONARY_TYPE_OPTIONS.find((option) => option.value === type)
|
||||||
|
?.label || type
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString('zh-CN', { hour12: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
includeDisabled: true,
|
||||||
|
type: searchForm.type || undefined,
|
||||||
|
};
|
||||||
|
const res = await getDictionaries(params);
|
||||||
|
tableData.value = Array.isArray(res) ? res : [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSearch = async () => {
|
||||||
|
searchForm.type = '';
|
||||||
|
searchForm.keyword = '';
|
||||||
|
await fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
formRef.value?.clearValidate?.();
|
||||||
|
form.type = MEDICAL_DICTIONARY_TYPE_OPTIONS[0]?.value || '';
|
||||||
|
form.label = '';
|
||||||
|
form.sortOrder = 0;
|
||||||
|
form.enabled = true;
|
||||||
|
currentId.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
isEdit.value = false;
|
||||||
|
resetForm();
|
||||||
|
if (searchForm.type) {
|
||||||
|
form.type = searchForm.type;
|
||||||
|
}
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (row) => {
|
||||||
|
isEdit.value = true;
|
||||||
|
formRef.value?.clearValidate?.();
|
||||||
|
currentId.value = row.id;
|
||||||
|
form.type = row.type;
|
||||||
|
form.label = row.label;
|
||||||
|
form.sortOrder = row.sortOrder ?? 0;
|
||||||
|
form.enabled = Boolean(row.enabled);
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await formRef.value.validate();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitLoading.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
type: form.type,
|
||||||
|
label: form.label,
|
||||||
|
sortOrder: form.sortOrder,
|
||||||
|
enabled: form.enabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateDictionaryItem(currentId.value, payload);
|
||||||
|
ElMessage.success('字典项已更新');
|
||||||
|
} else {
|
||||||
|
await createDictionaryItem(payload);
|
||||||
|
ElMessage.success('字典项已创建');
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogVisible.value = false;
|
||||||
|
await fetchData();
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (row) => {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认删除字典项「${row.label}」吗?删除后将无法继续在表单中选择该值。`,
|
||||||
|
'删除确认',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await deleteDictionaryItem(row.id);
|
||||||
|
ElMessage.success('字典项已删除');
|
||||||
|
await fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel-card {
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
657
tyt-admin/src/views/patients/components/SurgeryFormSection.vue
Normal file
657
tyt-admin/src/views/patients/components/SurgeryFormSection.vue
Normal file
@ -0,0 +1,657 @@
|
|||||||
|
<template>
|
||||||
|
<div class="surgery-section">
|
||||||
|
<div class="section-head" v-if="title || description">
|
||||||
|
<div>
|
||||||
|
<div class="section-title" v-if="title">{{ title }}</div>
|
||||||
|
<div class="section-description" v-if="description">
|
||||||
|
{{ description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="手术日期">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.surgeryDate"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
placeholder="请选择手术日期"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="手术名称">
|
||||||
|
<el-input
|
||||||
|
v-model="form.surgeryName"
|
||||||
|
placeholder="例如:脑室腹腔分流术"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="主刀医生">
|
||||||
|
<el-input v-model="form.surgeonName" placeholder="请输入主刀医生" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="术前测压">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.preOpPressure"
|
||||||
|
:min="0"
|
||||||
|
:controls="false"
|
||||||
|
placeholder="可为空"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="原发病">
|
||||||
|
<el-select
|
||||||
|
v-model="form.primaryDisease"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择原发病"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in dictionaryOptions.primaryDiseaseOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="脑积水类型">
|
||||||
|
<el-select
|
||||||
|
v-model="form.hydrocephalusTypes"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="请选择脑积水类型"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in dictionaryOptions.hydrocephalusTypeOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="上次分流时间">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.previousShuntSurgeryDate"
|
||||||
|
type="datetime"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
placeholder="可为空"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="手术备注">
|
||||||
|
<el-input
|
||||||
|
v-model="form.notes"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="二次手术原因、术中特殊情况等"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="24" v-if="showAbandonSelector">
|
||||||
|
<el-form-item label="弃用旧设备">
|
||||||
|
<el-select
|
||||||
|
v-model="form.abandonedDeviceIds"
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
placeholder="可选择本次手术后弃用的旧设备"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="device in abandonableDevices"
|
||||||
|
:key="device.id"
|
||||||
|
:label="formatAbandonDeviceLabel(device)"
|
||||||
|
:value="device.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="block-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="block-head">
|
||||||
|
<div>
|
||||||
|
<div class="block-title">术前 CT 影像/资料</div>
|
||||||
|
<div class="block-subtitle">支持图片、视频、文件链接</div>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" plain @click="addMaterial"
|
||||||
|
>新增资料</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="form.preOpMaterials.length === 0" class="empty-hint">
|
||||||
|
暂未录入术前资料
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(material, index) in form.preOpMaterials"
|
||||||
|
:key="`material-${index}`"
|
||||||
|
class="material-row"
|
||||||
|
>
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :xs="24" :md="5">
|
||||||
|
<el-select
|
||||||
|
v-model="material.type"
|
||||||
|
placeholder="类型"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in MATERIAL_TYPE_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="5">
|
||||||
|
<el-input v-model="material.name" placeholder="资料名称" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-input v-model="material.url" placeholder="资料地址 URL" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="2" class="material-remove">
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="removeMaterial(index)"
|
||||||
|
:disabled="
|
||||||
|
form.preOpMaterials.length === 1 &&
|
||||||
|
!material.url &&
|
||||||
|
!material.name
|
||||||
|
"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="block-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="block-head">
|
||||||
|
<div>
|
||||||
|
<div class="block-title">植入设备</div>
|
||||||
|
<div class="block-subtitle">
|
||||||
|
选型号后自动联动厂家和名称;支持一次手术录入多台设备
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="addDevice">新增设备</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(device, index) in form.devices"
|
||||||
|
:key="`device-${index}`"
|
||||||
|
class="device-card"
|
||||||
|
>
|
||||||
|
<div class="device-card-head">
|
||||||
|
<div class="device-card-title">设备 {{ index + 1 }}</div>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="removeDevice(index)"
|
||||||
|
:disabled="form.devices.length <= 1"
|
||||||
|
>
|
||||||
|
删除设备
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="植入物型号">
|
||||||
|
<el-select
|
||||||
|
v-model="device.implantCatalogId"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择植入物型号"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleCatalogChange(device)"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in catalogOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="formatCatalogLabel(item)"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="设备 SN">
|
||||||
|
<el-input
|
||||||
|
v-model="device.snCode"
|
||||||
|
placeholder="可不填,系统自动生成"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="植入物厂商">
|
||||||
|
<el-input
|
||||||
|
:model-value="
|
||||||
|
resolveCatalog(device.implantCatalogId)?.manufacturer || '-'
|
||||||
|
"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="植入物名称">
|
||||||
|
<div class="catalog-name-box">
|
||||||
|
<el-input
|
||||||
|
:model-value="
|
||||||
|
resolveCatalog(device.implantCatalogId)?.name || '-'
|
||||||
|
"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<el-tag
|
||||||
|
v-if="resolveCatalog(device.implantCatalogId)"
|
||||||
|
:type="
|
||||||
|
resolveCatalog(device.implantCatalogId)
|
||||||
|
?.isPressureAdjustable
|
||||||
|
? 'success'
|
||||||
|
: 'info'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
resolveCatalog(device.implantCatalogId)
|
||||||
|
?.isPressureAdjustable
|
||||||
|
? '可调压'
|
||||||
|
: '固定压'
|
||||||
|
}}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="resolvePressureLevels(device.implantCatalogId).length"
|
||||||
|
class="pressure-level-hint"
|
||||||
|
>
|
||||||
|
挡位:
|
||||||
|
{{ resolvePressureLevels(device.implantCatalogId).join(' / ') }}
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="分流方式">
|
||||||
|
<el-select
|
||||||
|
v-model="device.shuntMode"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择分流方式"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in dictionaryOptions.shuntModeOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="远端分流方向">
|
||||||
|
<el-select
|
||||||
|
v-model="device.distalShuntDirection"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择远端方向"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in dictionaryOptions.distalShuntDirectionOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="近端穿刺区域">
|
||||||
|
<el-select
|
||||||
|
v-model="device.proximalPunctureAreas"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
:multiple-limit="2"
|
||||||
|
placeholder="最多 2 项"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in dictionaryOptions.proximalPunctureOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="阀门植入部位">
|
||||||
|
<el-select
|
||||||
|
v-model="device.valvePlacementSites"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
:multiple-limit="2"
|
||||||
|
placeholder="最多 2 项"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in dictionaryOptions.valvePlacementOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<el-form-item label="初始压力">
|
||||||
|
<el-select
|
||||||
|
v-if="resolvePressureLevels(device.implantCatalogId).length"
|
||||||
|
v-model="device.initialPressure"
|
||||||
|
clearable
|
||||||
|
placeholder="请选择初始挡位"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="level in resolvePressureLevels(
|
||||||
|
device.implantCatalogId,
|
||||||
|
)"
|
||||||
|
:key="`${device.implantCatalogId}-initial-${level}`"
|
||||||
|
:label="String(level)"
|
||||||
|
:value="level"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input-number
|
||||||
|
v-else
|
||||||
|
v-model="device.initialPressure"
|
||||||
|
:min="0"
|
||||||
|
:controls="false"
|
||||||
|
:disabled="
|
||||||
|
!resolveCatalog(device.implantCatalogId)?.isPressureAdjustable
|
||||||
|
"
|
||||||
|
placeholder="可为空"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<div class="field-hint">
|
||||||
|
当前压力创建后默认继承初始压力,后续以调压任务完成结果为准
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="植入物备注">
|
||||||
|
<el-input
|
||||||
|
v-model="device.implantNotes"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="记录术中情况、位置说明等"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="植入物标签">
|
||||||
|
<el-input
|
||||||
|
v-model="device.labelImageUrl"
|
||||||
|
placeholder="请输入图片地址 URL"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { MATERIAL_TYPE_OPTIONS } from '../patient-form-options';
|
||||||
|
import { createEmptyMedicalDictionaryOptions } from '../../../constants/medical-dictionaries';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
form: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
catalogOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
dictionaryOptions: {
|
||||||
|
type: Object,
|
||||||
|
default: () => createEmptyMedicalDictionaryOptions(),
|
||||||
|
},
|
||||||
|
abandonableDevices: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
showAbandonSelector: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMaterial = () => ({
|
||||||
|
type: 'IMAGE',
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDevice = () => ({
|
||||||
|
implantCatalogId: null,
|
||||||
|
snCode: '',
|
||||||
|
shuntMode: '',
|
||||||
|
proximalPunctureAreas: [],
|
||||||
|
valvePlacementSites: [],
|
||||||
|
distalShuntDirection: '',
|
||||||
|
initialPressure: null,
|
||||||
|
implantNotes: '',
|
||||||
|
labelImageUrl: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMaterial = () => {
|
||||||
|
props.form.preOpMaterials.push(createMaterial());
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMaterial = (index) => {
|
||||||
|
if (props.form.preOpMaterials.length === 1) {
|
||||||
|
props.form.preOpMaterials.splice(index, 1, createMaterial());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.form.preOpMaterials.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDevice = () => {
|
||||||
|
props.form.devices.push(createDevice());
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDevice = (index) => {
|
||||||
|
if (props.form.devices.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.form.devices.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCatalog = (catalogId) => {
|
||||||
|
return props.catalogOptions.find((item) => item.id === catalogId) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvePressureLevels = (catalogId) => {
|
||||||
|
const pressureLevels = resolveCatalog(catalogId)?.pressureLevels;
|
||||||
|
return Array.isArray(pressureLevels) ? pressureLevels : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCatalogLabel = (catalog) => {
|
||||||
|
return `${catalog.modelCode} | ${catalog.manufacturer} | ${catalog.name}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCatalogChange = (device) => {
|
||||||
|
const catalog = resolveCatalog(device.implantCatalogId);
|
||||||
|
if (!catalog?.isPressureAdjustable) {
|
||||||
|
device.initialPressure = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pressureLevels = resolvePressureLevels(device.implantCatalogId);
|
||||||
|
if (pressureLevels.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pressureLevels.includes(device.initialPressure)) {
|
||||||
|
device.initialPressure = pressureLevels[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAbandonDeviceLabel = (device) => {
|
||||||
|
const implantLabel =
|
||||||
|
device.implantModel || device.implantName || '未命名设备';
|
||||||
|
return `${implantLabel} | ${device.snCode || '无 SN'} | 当前压力 ${
|
||||||
|
device.currentPressure ?? '-'
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section-head {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2a37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border-color: #d9e4f3;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2a37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
padding: 12px 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-row + .material-row {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-remove {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #dbe7f5;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card + .device-card {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2a37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-name-box {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pressure-level-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.block-head,
|
||||||
|
.device-card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-remove {
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
tyt-admin/src/views/patients/patient-form-options.js
Normal file
15
tyt-admin/src/views/patients/patient-form-options.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export const MATERIAL_TYPE_OPTIONS = [
|
||||||
|
{ label: '图片', value: 'IMAGE' },
|
||||||
|
{ label: '视频', value: 'VIDEO' },
|
||||||
|
{ label: '文件', value: 'FILE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LIFECYCLE_EVENT_LABELS = {
|
||||||
|
SURGERY: '手术',
|
||||||
|
TASK_PRESSURE_ADJUSTMENT: '调压',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIFECYCLE_EVENT_TAG_TYPES = {
|
||||||
|
SURGERY: 'success',
|
||||||
|
TASK_PRESSURE_ADJUSTMENT: 'warning',
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user