From 73082225f6dc80aa2aee5cd8de7ca85ef139152d Mon Sep 17 00:00:00 2001 From: EL <1175065040@qq.com> Date: Thu, 19 Mar 2026 20:42:17 +0800 Subject: [PATCH] =?UTF-8?q?"1.=20=E6=96=B0=E5=A2=9E=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=AD=97=E5=85=B8=E4=B8=8E=E5=85=A8=E5=B1=80=E6=A4=8D=E5=85=A5?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=9B=B8=E5=85=B3=E8=A1=A8=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8F=8A=E8=BF=81=E7=A7=BB=202.=20=E6=89=A9=E5=B1=95=E6=82=A3?= =?UTF-8?q?=E8=80=85=E6=89=8B=E6=9C=AF=E4=B8=8E=E6=9D=90=E6=96=99=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=A7=8D=E5=AD=90=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=203.=20=E6=96=B0=E5=A2=9E=E5=AD=97=E5=85=B8=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8C=E5=A2=9E=E5=BC=BA=E8=AE=BE=E5=A4=87=E6=A4=8D?= =?UTF-8?q?=E5=85=A5=E7=9B=AE=E5=BD=95=E7=AE=A1=E7=90=86=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=204.=20=E9=87=8D=E6=9E=84=E6=82=A3=E8=80=85=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E4=B8=8E=E8=A1=A8=E5=8D=95=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=EF=BC=8C=E7=BB=9F=E4=B8=80=E6=9D=83=E9=99=90=E4=B8=8E=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E6=A0=A1=E9=AA=8C=205.=20=E7=AE=A1=E7=90=86=E5=8F=B0?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AD=97=E5=85=B8=E9=A1=B5=E9=9D=A2=E5=B9=B6?= =?UTF-8?q?=E6=94=B9=E9=80=A0=E6=82=A3=E8=80=85/=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B8=8E=E8=B7=AF=E7=94=B1=E6=9D=83=E9=99=90?= =?UTF-8?q?=206.=20=E8=A1=A5=E5=85=85=E5=AD=97=E5=85=B8=E5=8F=8A=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E9=A2=86=E5=9F=9F=20e2e=20=E6=B5=8B=E8=AF=95=E5=B9=B6?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/devices.md | 64 +- docs/dictionaries.md | 42 + docs/patients.md | 112 +- .../migration.sql | 82 + .../migration.sql | 21 + .../migration.sql | 15 + prisma/schema.prisma | 125 +- prisma/seed.mjs | 409 ++++- src/app.module.ts | 2 + src/auth/current-actor.decorator.ts | 4 +- src/auth/roles.guard.ts | 4 +- src/common/http-exception.filter.ts | 5 +- src/common/messages.ts | 19 + src/common/transforms/to-boolean.transform.ts | 24 + src/departments/departments.controller.ts | 21 +- src/departments/departments.service.ts | 25 +- src/devices/b-devices/b-devices.controller.ts | 70 + src/devices/devices.service.ts | 327 +++- src/devices/dto/create-device.dto.ts | 6 - src/devices/dto/create-implant-catalog.dto.ts | 70 + src/devices/dto/update-implant-catalog.dto.ts | 9 + .../b-dictionaries.controller.ts | 112 ++ src/dictionaries/dictionaries.module.ts | 12 + src/dictionaries/dictionaries.service.ts | 156 ++ .../dto/create-dictionary-item.dto.ts | 55 + src/dictionaries/dto/dictionary-query.dto.ts | 30 + .../dto/update-dictionary-item.dto.ts | 9 + src/groups/groups.controller.ts | 26 +- src/groups/groups.module.ts | 7 +- src/hospitals/hospitals.controller.ts | 19 +- src/hospitals/hospitals.service.ts | 27 +- .../dto/organization-query.dto.ts | 11 +- .../organization-access.service.ts | 18 +- .../b-patients/b-patients.controller.ts | 27 +- src/patients/b-patients/b-patients.service.ts | 665 +++++++- src/patients/c-patients/c-patients.service.ts | 103 +- .../dto/create-patient-surgery.dto.ts | 116 ++ src/patients/dto/create-patient.dto.ts | 31 +- src/patients/dto/create-surgery-device.dto.ts | 95 ++ src/patients/dto/surgery-material.dto.ts | 32 + src/tasks/task.service.ts | 30 +- src/users/b-users/b-users.controller.ts | 7 +- src/users/dto/create-user.dto.ts | 5 +- src/users/dto/login.dto.ts | 5 +- test/e2e/specs/devices.e2e-spec.ts | 108 +- test/e2e/specs/dictionaries.e2e-spec.ts | 133 ++ test/e2e/specs/patients.e2e-spec.ts | 220 ++- test/e2e/specs/tasks.e2e-spec.ts | 28 +- tyt-admin/components.d.ts | 5 + tyt-admin/src/api/devices.js | 16 + tyt-admin/src/api/dictionaries.js | 17 + tyt-admin/src/api/patients.js | 4 + .../src/constants/medical-dictionaries.js | 73 + tyt-admin/src/constants/role-permissions.js | 3 +- tyt-admin/src/layouts/AdminLayout.vue | 14 +- tyt-admin/src/router/index.js | 10 + tyt-admin/src/views/devices/Devices.vue | 714 ++++---- .../src/views/dictionaries/Dictionaries.vue | 376 +++++ tyt-admin/src/views/patients/Patients.vue | 1468 ++++++++++++++--- .../components/SurgeryFormSection.vue | 657 ++++++++ .../views/patients/patient-form-options.js | 15 + 61 files changed, 6058 insertions(+), 857 deletions(-) create mode 100644 docs/dictionaries.md create mode 100644 prisma/migrations/20260319101507_patient_surgery_implant_architecture/migration.sql create mode 100644 prisma/migrations/20260319104958_system_dictionaries/migration.sql create mode 100644 prisma/migrations/20260319123000_global_implant_catalog_directory/migration.sql create mode 100644 src/common/transforms/to-boolean.transform.ts create mode 100644 src/devices/dto/create-implant-catalog.dto.ts create mode 100644 src/devices/dto/update-implant-catalog.dto.ts create mode 100644 src/dictionaries/b-dictionaries/b-dictionaries.controller.ts create mode 100644 src/dictionaries/dictionaries.module.ts create mode 100644 src/dictionaries/dictionaries.service.ts create mode 100644 src/dictionaries/dto/create-dictionary-item.dto.ts create mode 100644 src/dictionaries/dto/dictionary-query.dto.ts create mode 100644 src/dictionaries/dto/update-dictionary-item.dto.ts create mode 100644 src/patients/dto/create-patient-surgery.dto.ts create mode 100644 src/patients/dto/create-surgery-device.dto.ts create mode 100644 src/patients/dto/surgery-material.dto.ts create mode 100644 test/e2e/specs/dictionaries.e2e-spec.ts create mode 100644 tyt-admin/src/api/dictionaries.js create mode 100644 tyt-admin/src/constants/medical-dictionaries.js create mode 100644 tyt-admin/src/views/dictionaries/Dictionaries.vue create mode 100644 tyt-admin/src/views/patients/components/SurgeryFormSection.vue create mode 100644 tyt-admin/src/views/patients/patient-form-options.js diff --git a/docs/devices.md b/docs/devices.md index 0715fe4..53492c3 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -2,17 +2,52 @@ ## 1. 目标 -- 提供 B 端设备 CRUD。 -- 管理设备与患者的归属关系。 -- 支持管理员按医院、患者、状态和关键词分页查询设备。 +- 提供“全局植入物目录”管理,供患者手术表单选择。 +- 维护患者手术下的植入实例记录。 +- 支持为可调压器械配置挡位列表。 +- 支持管理员按医院、患者、状态和关键词分页查询患者植入实例。 -## 2. 权限 +## 2. 设备实例 -- `SYSTEM_ADMIN`:可跨院查询和维护设备。 -- `HOSPITAL_ADMIN`:仅可操作本院患者名下设备。 -- 其他角色:默认拒绝。 +`Device` 现在表示“患者某次手术下的植入设备实例”,不是独立库存主数据。 -## 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/:id`:查询设备详情 @@ -20,8 +55,19 @@ - `PATCH /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 在全库唯一,服务端会统一转成大写后再校验。 - 删除已被任务明细引用的设备会返回 `409`。 +- 删除已被患者手术引用的植入物目录会返回 `409`。 +- 可调压植入物若配置了 `pressureLevels`,患者手术录入和任务调压时的压力值必须命中该挡位列表。 +- 调压任务仅允许针对 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备发布。 +- `Device.currentPressure` 只允许由调压任务完成时更新,患者手术录入和设备实例编辑都不开放手工写入。 diff --git a/docs/dictionaries.md b/docs/dictionaries.md new file mode 100644 index 0000000..f1b6364 --- /dev/null +++ b/docs/dictionaries.md @@ -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` 仅系统管理员生效。 +- 患者手术表单现在从该接口动态读取选项,不再使用前端硬编码数组。 diff --git a/docs/patients.md b/docs/patients.md index 0c6001c..311e931 100644 --- a/docs/patients.md +++ b/docs/patients.md @@ -2,33 +2,99 @@ ## 1. 目标 -- B 端:按组织与角色范围查询患者(强依赖 `hospitalId`)。 -- C 端:按 `phone + idCard` 做跨院聚合查询。 -- 患者档案直接保存身份证号原文,不再做哈希转换。 -- 服务端只做轻量格式整理:去空格、统一末尾 `x/X` 为大写。 +- B 端:按组织与角色范围查询患者,并维护患者基础档案。 +- B 端:支持患者首术录入、二次手术追加、旧设备弃用标记。 +- 数据关系升级为 `Patient -> PatientSurgery -> Device -> TaskItem -> Task`。 +- 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`:仅可查自己名下患者 -- `LEADER`:可查本组医生名下患者(按医生当前 `groupId` 反查) -- `DIRECTOR`:可查本科室医生名下患者(按医生当前 `departmentId` 反查) +- `LEADER`:可查本组医生名下患者 +- `DIRECTOR`:可查本科室医生名下患者 - `HOSPITAL_ADMIN`:可查本院全部患者 - `SYSTEM_ADMIN`:需显式传入目标 `hospitalId` -## 2.1 B 端 CRUD +## 6. B 端接口 -- `GET /b/patients`:按角色查询可见患者 -- `GET /b/patients/doctors`:查询当前角色可见的归属人员候选(医生/主任/组长,用于患者表单) -- `POST /b/patients`:创建患者 +- `GET /b/patients`:按角色查询可见患者列表 +- `GET /b/patients/doctors`:查询当前角色可见的归属人员候选 +- `POST /b/patients`:创建患者,可选带 `initialSurgery` +- `POST /b/patients/:id/surgeries`:为患者新增手术 - `GET /b/patients/:id`:查询患者详情 -- `PATCH /b/patients/:id`:更新患者 -- `DELETE /b/patients/:id`:删除患者(若存在关联设备返回 409) +- `PATCH /b/patients/:id`:更新患者基础信息 +- `DELETE /b/patients/:id`:删除患者 -说明: -患者表只绑定 `doctorId + hospitalId`,不直接绑定小组/科室。医生调组或调科后, -可见范围会按医生当前组织归属自动变化,无需迁移患者数据。 +约束: -## 3. C 端生命周期聚合 +- `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。 +- 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`。 + +## 7. C 端生命周期聚合 接口:`GET /c/patients/lifecycle?phone=...&idCard=...` @@ -36,10 +102,16 @@ 1. 不做医院隔离(跨租户) 2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配 -3. 关联查询 `Patient -> Device -> TaskItem -> Task` -4. 返回扁平生命周期列表(按 `Task.createdAt DESC`) +3. 聚合 `Patient -> PatientSurgery -> Device` 的手术事件 +4. 聚合 `Patient -> Device -> TaskItem -> Task` 的调压事件 +5. 全部事件按 `occurredAt DESC` 返回 -## 4. 响应结构 +事件类型: + +- `SURGERY` +- `TASK_PRESSURE_ADJUSTMENT` + +## 8. 响应结构 全部接口统一返回: diff --git a/prisma/migrations/20260319101507_patient_surgery_implant_architecture/migration.sql b/prisma/migrations/20260319101507_patient_surgery_implant_architecture/migration.sql new file mode 100644 index 0000000..e1e1905 --- /dev/null +++ b/prisma/migrations/20260319101507_patient_surgery_implant_architecture/migration.sql @@ -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; diff --git a/prisma/migrations/20260319104958_system_dictionaries/migration.sql b/prisma/migrations/20260319104958_system_dictionaries/migration.sql new file mode 100644 index 0000000..d32b092 --- /dev/null +++ b/prisma/migrations/20260319104958_system_dictionaries/migration.sql @@ -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"); diff --git a/prisma/migrations/20260319123000_global_implant_catalog_directory/migration.sql b/prisma/migrations/20260319123000_global_implant_catalog_directory/migration.sql new file mode 100644 index 0000000..f4c33a3 --- /dev/null +++ b/prisma/migrations/20260319123000_global_implant_catalog_directory/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4f0a996..5233423 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,6 +36,16 @@ enum TaskStatus { CANCELLED } +// 医学字典类型:驱动患者手术表单中的单选/多选项。 +enum DictionaryType { + PRIMARY_DISEASE + HYDROCEPHALUS_TYPE + SHUNT_MODE + PROXIMAL_PUNCTURE_AREA + VALVE_PLACEMENT_SITE + DISTAL_SHUNT_DIRECTION +} + // 医院主表:多租户顶层实体。 model Hospital { id Int @id @default(autoincrement()) @@ -100,32 +110,113 @@ model User { // 患者表:院内患者档案,按医院隔离。 model Patient { - id Int @id @default(autoincrement()) - name String - phone String + id Int @id @default(autoincrement()) + name String + // 住院号:用于院内患者检索与病案关联。 + inpatientNo String? + // 项目名称:用于区分患者所属项目/课题。 + projectName String? + phone String // 患者身份证号,录入与查询都使用原始证件号。 - idCard String - hospitalId Int - doctorId Int - hospital Hospital @relation(fields: [hospitalId], references: [id]) - doctor User @relation("DoctorPatients", fields: [doctorId], references: [id]) - devices Device[] + idCard String + hospitalId Int + doctorId Int + hospital Hospital @relation(fields: [hospitalId], references: [id]) + doctor User @relation("DoctorPatients", fields: [doctorId], references: [id]) + surgeries PatientSurgery[] + devices Device[] @@index([phone, idCard]) @@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 { - id Int @id @default(autoincrement()) - snCode String @unique - currentPressure Int - status DeviceStatus @default(ACTIVE) - patientId Int - patient Patient @relation(fields: [patientId], references: [id]) - taskItems TaskItem[] + id Int @id @default(autoincrement()) + snCode String @unique + currentPressure Int + status DeviceStatus @default(ACTIVE) + 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]) + surgery PatientSurgery? @relation(fields: [surgeryId], references: [id], onDelete: SetNull) + implantCatalog ImplantCatalog? @relation(fields: [implantCatalogId], references: [id], onDelete: SetNull) + taskItems TaskItem[] @@index([patientId, status]) + @@index([surgeryId]) + @@index([implantCatalogId]) + @@index([patientId, isAbandoned]) } // 主任务表:记录调压任务主单。 diff --git a/prisma/seed.mjs b/prisma/seed.mjs index bde8b50..03b46b5 100644 --- a/prisma/seed.mjs +++ b/prisma/seed.mjs @@ -3,7 +3,8 @@ import { PrismaPg } from '@prisma/adapter-pg'; import { hash } from 'bcrypt'; import prismaClientPackage from '@prisma/client'; -const { DeviceStatus, PrismaClient, Role, TaskStatus } = prismaClientPackage; +const { DictionaryType, DeviceStatus, PrismaClient, Role, TaskStatus } = + prismaClientPackage; const connectionString = process.env.DATABASE_URL; 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({ where: { hospitalId, @@ -70,10 +79,15 @@ async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) { }); 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({ where: { id: existing.id }, - data: { doctorId, name }, + data: { doctorId, name, inpatientNo, projectName }, }); } return existing; @@ -84,12 +98,124 @@ async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) { hospitalId, doctorId, name, + inpatientNo, + projectName, phone, 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() { const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12); @@ -217,10 +343,61 @@ async function main() { 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({ hospitalId: hospitalA.id, doctorId: doctorA.id, name: 'Seed Patient A1', + inpatientNo: 'ZYH-A-0001', + projectName: '脑积水随访项目-A', phone: '13800002001', idCard: '110101199001010011', }); @@ -229,6 +406,8 @@ async function main() { hospitalId: hospitalA.id, doctorId: doctorA2.id, name: 'Seed Patient A2', + inpatientNo: 'ZYH-A-0002', + projectName: '脑积水随访项目-A', phone: '13800002002', idCard: '110101199002020022', }); @@ -237,6 +416,8 @@ async function main() { hospitalId: hospitalA.id, doctorId: doctorA3.id, name: 'Seed Patient A3', + inpatientNo: 'ZYH-A-0003', + projectName: '脑积水随访项目-A', phone: '13800002003', idCard: '110101199003030033', }); @@ -245,22 +426,130 @@ async function main() { hospitalId: hospitalB.id, doctorId: doctorB.id, name: 'Seed Patient B1', + inpatientNo: 'ZYH-B-0001', + projectName: '脑积水随访项目-B', phone: '13800002001', 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({ where: { snCode: 'SEED-SN-A-001' }, update: { patientId: patientA1.id, + surgeryId: surgeryA1New.id, + implantCatalogId: adjustableCatalog.id, currentPressure: 118, 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: { snCode: 'SEED-SN-A-001', patientId: patientA1.id, + surgeryId: surgeryA1New.id, + implantCatalogId: adjustableCatalog.id, currentPressure: 118, 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' }, update: { patientId: patientA2.id, + surgeryId: surgeryA2.id, + implantCatalogId: adjustableCatalog.id, currentPressure: 112, 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: { snCode: 'SEED-SN-A-002', patientId: patientA2.id, + surgeryId: surgeryA2.id, + implantCatalogId: adjustableCatalog.id, currentPressure: 112, 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' }, update: { patientId: patientA3.id, + surgeryId: surgeryA3.id, + implantCatalogId: adjustableCatalog.id, currentPressure: 109, 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: { snCode: 'SEED-SN-A-003', patientId: patientA3.id, + surgeryId: surgeryA3.id, + implantCatalogId: adjustableCatalog.id, currentPressure: 109, 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' }, update: { patientId: patientB1.id, + surgeryId: surgeryB1.id, + implantCatalogId: adjustableCatalog.id, currentPressure: 121, 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: { snCode: 'SEED-SN-B-001', patientId: patientB1.id, + surgeryId: surgeryB1.id, + implantCatalogId: adjustableCatalog.id, currentPressure: 121, 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' }, update: { patientId: patientA1.id, + surgeryId: surgeryA1Old.id, + implantCatalogId: adjustableCatalog.id, currentPressure: 130, 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: { snCode: 'SEED-SN-A-004', patientId: patientA1.id, + surgeryId: surgeryA1Old.id, + implantCatalogId: adjustableCatalog.id, currentPressure: 130, 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', }, }); diff --git a/src/app.module.ts b/src/app.module.ts index c356ecc..e25179b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { AuthModule } from './auth/auth.module.js'; import { OrganizationModule } from './organization/organization.module.js'; import { NotificationsModule } from './notifications/notifications.module.js'; import { DevicesModule } from './devices/devices.module.js'; +import { DictionariesModule } from './dictionaries/dictionaries.module.js'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { DevicesModule } from './devices/devices.module.js'; OrganizationModule, NotificationsModule, DevicesModule, + DictionariesModule, ], }) export class AppModule {} diff --git a/src/auth/current-actor.decorator.ts b/src/auth/current-actor.decorator.ts index ee36d85..1c47c1d 100644 --- a/src/auth/current-actor.decorator.ts +++ b/src/auth/current-actor.decorator.ts @@ -6,7 +6,9 @@ import type { ActorContext } from '../common/actor-context.js'; */ export const CurrentActor = createParamDecorator( (_data: unknown, context: ExecutionContext): ActorContext => { - const request = context.switchToHttp().getRequest<{ actor: ActorContext }>(); + const request = context + .switchToHttp() + .getRequest<{ actor: ActorContext }>(); return request.actor; }, ); diff --git a/src/auth/roles.guard.ts b/src/auth/roles.guard.ts index f558d70..411537c 100644 --- a/src/auth/roles.guard.ts +++ b/src/auth/roles.guard.ts @@ -29,7 +29,9 @@ export class RolesGuard implements CanActivate { return true; } - const request = context.switchToHttp().getRequest<{ actor?: { role?: Role } }>(); + const request = context + .switchToHttp() + .getRequest<{ actor?: { role?: Role } }>(); const actorRole = request.actor?.role; if (!actorRole || !requiredRoles.includes(actorRole)) { throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); diff --git a/src/common/http-exception.filter.ts b/src/common/http-exception.filter.ts index 16cc8c3..04d5833 100644 --- a/src/common/http-exception.filter.ts +++ b/src/common/http-exception.filter.ts @@ -24,10 +24,7 @@ export class HttpExceptionFilter implements ExceptionFilter { // 非 HttpException 统一记录堆栈,便于定位 500 根因。 if (!(exception instanceof HttpException)) { const error = exception as { message?: string; stack?: string }; - this.logger.error( - error?.message ?? 'Unhandled exception', - error?.stack, - ); + this.logger.error(error?.message ?? 'Unhandled exception', error?.stack); } const status = this.resolveStatus(exception); diff --git a/src/common/messages.ts b/src/common/messages.ts index 8bb9c50..e5d1d06 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -89,6 +89,12 @@ export const MESSAGES = { LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号', SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId', ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息', + SURGERY_ITEMS_REQUIRED: '手术下至少需要录入一个植入设备', + SURGERY_NOT_FOUND: '手术记录不存在或无权限访问', + IMPLANT_CATALOG_NOT_FOUND: '植入物型号不存在或不在当前医院可见范围内', + SURGERY_UPDATE_NOT_SUPPORTED: + '患者更新接口不支持直接修改手术,请使用新增手术接口', + ABANDON_DEVICE_SCOPE_FORBIDDEN: '仅可弃用当前患者名下设备', }, DEVICE: { @@ -102,6 +108,19 @@ export const MESSAGES = { PATIENT_SCOPE_FORBIDDEN: '仅可绑定当前权限范围内患者', DELETE_CONFLICT: '设备存在关联任务记录,无法删除', 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: { diff --git a/src/common/transforms/to-boolean.transform.ts b/src/common/transforms/to-boolean.transform.ts new file mode 100644 index 0000000..0268eba --- /dev/null +++ b/src/common/transforms/to-boolean.transform.ts @@ -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; + }); diff --git a/src/departments/departments.controller.ts b/src/departments/departments.controller.ts index 4e202e7..43d0149 100644 --- a/src/departments/departments.controller.ts +++ b/src/departments/departments.controller.ts @@ -55,12 +55,7 @@ export class DepartmentsController { * 查询科室列表。 */ @Get() - @Roles( - Role.SYSTEM_ADMIN, - Role.HOSPITAL_ADMIN, - Role.DIRECTOR, - Role.LEADER, - ) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) @ApiOperation({ summary: '查询科室列表' }) @ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' }) findAll( @@ -74,12 +69,7 @@ export class DepartmentsController { * 查询科室详情。 */ @Get(':id') - @Roles( - Role.SYSTEM_ADMIN, - Role.HOSPITAL_ADMIN, - Role.DIRECTOR, - Role.LEADER, - ) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) @ApiOperation({ summary: '查询科室详情' }) @ApiParam({ name: 'id', description: '科室 ID' }) findOne( @@ -93,12 +83,7 @@ export class DepartmentsController { * 更新科室。 */ @Patch(':id') - @Roles( - Role.SYSTEM_ADMIN, - Role.HOSPITAL_ADMIN, - Role.DIRECTOR, - Role.LEADER, - ) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) @ApiOperation({ summary: '更新科室' }) update( @CurrentActor() actor: ActorContext, diff --git a/src/departments/departments.service.ts b/src/departments/departments.service.ts index eb6d40f..3bc484d 100644 --- a/src/departments/departments.service.ts +++ b/src/departments/departments.service.ts @@ -29,13 +29,19 @@ export class DepartmentsService { */ async create(actor: ActorContext, dto: CreateDepartmentDto) { 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); this.access.assertHospitalScope(actor, hospitalId); return this.prisma.department.create({ data: { - name: this.access.normalizeName(dto.name, MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED), + name: this.access.normalizeName( + dto.name, + MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED, + ), hospitalId, }, }); @@ -73,7 +79,10 @@ export class DepartmentsService { this.prisma.department.count({ where }), this.prisma.department.findMany({ where, - include: { hospital: true, _count: { select: { users: true, groups: true } } }, + include: { + hospital: true, + _count: { select: { users: true, groups: true } }, + }, skip: paging.skip, take: paging.take, orderBy: { id: 'desc' }, @@ -93,7 +102,10 @@ export class DepartmentsService { Role.DIRECTOR, 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({ where: { id: departmentId }, include: { @@ -128,7 +140,10 @@ export class DepartmentsService { } 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({ diff --git a/src/devices/b-devices/b-devices.controller.ts b/src/devices/b-devices/b-devices.controller.ts index da04017..5a0458f 100644 --- a/src/devices/b-devices/b-devices.controller.ts +++ b/src/devices/b-devices/b-devices.controller.ts @@ -14,6 +14,7 @@ import { ApiBearerAuth, ApiOperation, ApiParam, + ApiQuery, ApiTags, } from '@nestjs/swagger'; 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 type { ActorContext } from '../../common/actor-context.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 { 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 { DevicesService } from '../devices.service.js'; @@ -37,6 +40,73 @@ import { DevicesService } from '../devices.service.js'; export class BDevicesController { 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); + } + /** * 查询设备列表。 */ diff --git a/src/devices/devices.service.ts b/src/devices/devices.service.ts index 4ccf598..80224b6 100644 --- a/src/devices/devices.service.ts +++ b/src/devices/devices.service.ts @@ -10,15 +10,30 @@ import { DeviceStatus, 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 { CreateImplantCatalogDto } from './dto/create-implant-catalog.dto.js'; import { CreateDeviceDto } from './dto/create-device.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'; +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 = { patient: { select: { id: true, name: true, + inpatientNo: true, phone: true, hospitalId: true, hospital: { @@ -36,6 +51,17 @@ const DEVICE_DETAIL_INCLUDE = { }, }, }, + surgery: { + select: { + id: true, + surgeryDate: true, + surgeryName: true, + surgeonName: true, + }, + }, + implantCatalog: { + select: CATALOG_SELECT, + }, _count: { select: { taskItems: true, @@ -44,7 +70,7 @@ const DEVICE_DETAIL_INCLUDE = { } as const; /** - * 设备服务:承载管理员设备 CRUD、租户隔离与分页筛选。 + * 设备服务:承载患者植入实例 CRUD 与全局植入物目录维护。 */ @Injectable() export class DevicesService { @@ -114,7 +140,8 @@ export class DevicesService { return this.prisma.device.create({ data: { snCode, - currentPressure: this.normalizePressure(dto.currentPressure), + // 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。 + currentPressure: 0, status: dto.status ?? DeviceStatus.ACTIVE, patientId: patient.id, }, @@ -123,7 +150,7 @@ export class DevicesService { } /** - * 更新设备:允许修改 SN、当前压力、状态和归属患者。 + * 更新设备:允许修改 SN、状态和归属患者;当前压力仅由任务完成时更新。 */ async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) { const current = await this.findOne(actor, id); @@ -134,9 +161,6 @@ export class DevicesService { await this.assertSnCodeUnique(snCode, current.id); data.snCode = snCode; } - if (dto.currentPressure !== undefined) { - data.currentPressure = this.normalizePressure(dto.currentPressure); - } if (dto.status !== undefined) { 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', }, }, + { + implantModel: { + contains: keyword, + mode: 'insensitive', + }, + }, + { + implantName: { + contains: keyword, + mode: 'insensitive', + }, + }, { patient: { is: { @@ -238,6 +410,47 @@ export class DevicesService { 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; } + /** + * 查询当前管理员可写的型号字典。 + */ + 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) { 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 标准化:统一去空白并转大写,避免大小写重复。 */ private normalizeSnCode(value: unknown) { + return this.normalizeRequiredString(value, 'snCode').toUpperCase(); + } + + private normalizeRequiredString(value: unknown, fieldName: string) { if (typeof value !== 'string') { - throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED); + throw new BadRequestException(`${fieldName} 必须是字符串`); } - const normalized = value.trim().toUpperCase(); + const normalized = value.trim(); if (!normalized) { - throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED); + throw new BadRequestException(`${fieldName} 不能为空`); } 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); + } + /** * 压力值必须是非负整数。 */ diff --git a/src/devices/dto/create-device.dto.ts b/src/devices/dto/create-device.dto.ts index c711931..9c1605a 100644 --- a/src/devices/dto/create-device.dto.ts +++ b/src/devices/dto/create-device.dto.ts @@ -11,12 +11,6 @@ export class CreateDeviceDto { @IsString({ message: 'snCode 必须是字符串' }) snCode!: string; - @ApiProperty({ description: '当前压力值', example: 120 }) - @Type(() => Number) - @IsInt({ message: 'currentPressure 必须是整数' }) - @Min(0, { message: 'currentPressure 必须大于等于 0' }) - currentPressure!: number; - @ApiPropertyOptional({ description: '设备状态,默认 ACTIVE', enum: DeviceStatus, diff --git a/src/devices/dto/create-implant-catalog.dto.ts b/src/devices/dto/create-implant-catalog.dto.ts new file mode 100644 index 0000000..c15578e --- /dev/null +++ b/src/devices/dto/create-implant-catalog.dto.ts @@ -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; +} diff --git a/src/devices/dto/update-implant-catalog.dto.ts b/src/devices/dto/update-implant-catalog.dto.ts new file mode 100644 index 0000000..e2d7b9f --- /dev/null +++ b/src/devices/dto/update-implant-catalog.dto.ts @@ -0,0 +1,9 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateImplantCatalogDto } from './create-implant-catalog.dto.js'; + +/** + * 植入物型号更新 DTO。 + */ +export class UpdateImplantCatalogDto extends PartialType( + CreateImplantCatalogDto, +) {} diff --git a/src/dictionaries/b-dictionaries/b-dictionaries.controller.ts b/src/dictionaries/b-dictionaries/b-dictionaries.controller.ts new file mode 100644 index 0000000..012742b --- /dev/null +++ b/src/dictionaries/b-dictionaries/b-dictionaries.controller.ts @@ -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); + } +} diff --git a/src/dictionaries/dictionaries.module.ts b/src/dictionaries/dictionaries.module.ts new file mode 100644 index 0000000..8c9b00d --- /dev/null +++ b/src/dictionaries/dictionaries.module.ts @@ -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 {} diff --git a/src/dictionaries/dictionaries.service.ts b/src/dictionaries/dictionaries.service.ts new file mode 100644 index 0000000..8bf47ae --- /dev/null +++ b/src/dictionaries/dictionaries.service.ts @@ -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); + } + } +} diff --git a/src/dictionaries/dto/create-dictionary-item.dto.ts b/src/dictionaries/dto/create-dictionary-item.dto.ts new file mode 100644 index 0000000..fa1b669 --- /dev/null +++ b/src/dictionaries/dto/create-dictionary-item.dto.ts @@ -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; +} diff --git a/src/dictionaries/dto/dictionary-query.dto.ts b/src/dictionaries/dto/dictionary-query.dto.ts new file mode 100644 index 0000000..d69e159 --- /dev/null +++ b/src/dictionaries/dto/dictionary-query.dto.ts @@ -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; +} diff --git a/src/dictionaries/dto/update-dictionary-item.dto.ts b/src/dictionaries/dto/update-dictionary-item.dto.ts new file mode 100644 index 0000000..788cbbd --- /dev/null +++ b/src/dictionaries/dto/update-dictionary-item.dto.ts @@ -0,0 +1,9 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateDictionaryItemDto } from './create-dictionary-item.dto.js'; + +/** + * 系统字典项更新 DTO。 + */ +export class UpdateDictionaryItemDto extends PartialType( + CreateDictionaryItemDto, +) {} diff --git a/src/groups/groups.controller.ts b/src/groups/groups.controller.ts index 551ef7b..4c77163 100644 --- a/src/groups/groups.controller.ts +++ b/src/groups/groups.controller.ts @@ -43,10 +43,7 @@ export class GroupsController { @Post() @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) @ApiOperation({ summary: '创建小组' }) - create( - @CurrentActor() actor: ActorContext, - @Body() dto: CreateGroupDto, - ) { + create(@CurrentActor() actor: ActorContext, @Body() dto: CreateGroupDto) { return this.groupsService.create(actor, dto); } @@ -54,12 +51,7 @@ export class GroupsController { * 查询小组列表。 */ @Get() - @Roles( - Role.SYSTEM_ADMIN, - Role.HOSPITAL_ADMIN, - Role.DIRECTOR, - Role.LEADER, - ) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) @ApiOperation({ summary: '查询小组列表' }) findAll( @CurrentActor() actor: ActorContext, @@ -72,12 +64,7 @@ export class GroupsController { * 查询小组详情。 */ @Get(':id') - @Roles( - Role.SYSTEM_ADMIN, - Role.HOSPITAL_ADMIN, - Role.DIRECTOR, - Role.LEADER, - ) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) @ApiOperation({ summary: '查询小组详情' }) @ApiParam({ name: 'id', description: '小组 ID' }) findOne( @@ -91,12 +78,7 @@ export class GroupsController { * 更新小组。 */ @Patch(':id') - @Roles( - Role.SYSTEM_ADMIN, - Role.HOSPITAL_ADMIN, - Role.DIRECTOR, - Role.LEADER, - ) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) @ApiOperation({ summary: '更新小组' }) update( @CurrentActor() actor: ActorContext, diff --git a/src/groups/groups.module.ts b/src/groups/groups.module.ts index c35889a..2b01306 100644 --- a/src/groups/groups.module.ts +++ b/src/groups/groups.module.ts @@ -10,7 +10,12 @@ import { OrganizationAccessService } from '../organization-common/organization-a */ @Module({ controllers: [GroupsController], - providers: [GroupsService, OrganizationAccessService, AccessTokenGuard, RolesGuard], + providers: [ + GroupsService, + OrganizationAccessService, + AccessTokenGuard, + RolesGuard, + ], exports: [GroupsService], }) export class GroupsModule {} diff --git a/src/hospitals/hospitals.controller.ts b/src/hospitals/hospitals.controller.ts index 7ba503d..d09ee6c 100644 --- a/src/hospitals/hospitals.controller.ts +++ b/src/hospitals/hospitals.controller.ts @@ -43,10 +43,7 @@ export class HospitalsController { @Post() @Roles(Role.SYSTEM_ADMIN) @ApiOperation({ summary: '创建医院(SYSTEM_ADMIN)' }) - create( - @CurrentActor() actor: ActorContext, - @Body() dto: CreateHospitalDto, - ) { + create(@CurrentActor() actor: ActorContext, @Body() dto: CreateHospitalDto) { return this.hospitalsService.create(actor, dto); } @@ -54,12 +51,7 @@ export class HospitalsController { * 查询医院列表(系统管理员全量,院管仅本院)。 */ @Get() - @Roles( - Role.SYSTEM_ADMIN, - Role.HOSPITAL_ADMIN, - Role.DIRECTOR, - Role.LEADER, - ) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) @ApiOperation({ summary: '查询医院列表' }) findAll( @CurrentActor() actor: ActorContext, @@ -72,12 +64,7 @@ export class HospitalsController { * 查询医院详情。 */ @Get(':id') - @Roles( - Role.SYSTEM_ADMIN, - Role.HOSPITAL_ADMIN, - Role.DIRECTOR, - Role.LEADER, - ) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) @ApiOperation({ summary: '查询医院详情' }) @ApiParam({ name: 'id', description: '医院 ID' }) findOne( diff --git a/src/hospitals/hospitals.service.ts b/src/hospitals/hospitals.service.ts index 7344e1c..f1011d8 100644 --- a/src/hospitals/hospitals.service.ts +++ b/src/hospitals/hospitals.service.ts @@ -23,10 +23,16 @@ export class HospitalsService { * 创建医院:仅系统管理员可调用。 */ 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({ 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 }, include: { _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 data: Prisma.HospitalUpdateInput = {}; 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({ where: { id: current.id }, @@ -109,7 +123,10 @@ export class HospitalsService { * 删除医院:仅系统管理员允许。 */ 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); await this.access.ensureHospitalExists(hospitalId); diff --git a/src/organization-common/dto/organization-query.dto.ts b/src/organization-common/dto/organization-query.dto.ts index 7dc3001..356065c 100644 --- a/src/organization-common/dto/organization-query.dto.ts +++ b/src/organization-common/dto/organization-query.dto.ts @@ -7,7 +7,10 @@ import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; * 组织查询 DTO:用于医院/科室/小组列表筛选与分页。 */ export class OrganizationQueryDto { - @ApiPropertyOptional({ description: '关键词(按名称模糊匹配)', example: '神经' }) + @ApiPropertyOptional({ + description: '关键词(按名称模糊匹配)', + example: '神经', + }) @IsOptional() @IsString({ message: 'keyword 必须是字符串' }) keyword?: string; @@ -28,7 +31,11 @@ export class OrganizationQueryDto { @Min(1, { message: 'departmentId 必须大于 0' }) departmentId?: number; - @ApiPropertyOptional({ description: '页码(默认 1)', example: 1, default: 1 }) + @ApiPropertyOptional({ + description: '页码(默认 1)', + example: 1, + default: 1, + }) @IsOptional() @EmptyStringToUndefined() @Type(() => Number) diff --git a/src/organization-common/organization-access.service.ts b/src/organization-common/organization-access.service.ts index d6b517b..42b06ac 100644 --- a/src/organization-common/organization-access.service.ts +++ b/src/organization-common/organization-access.service.ts @@ -66,9 +66,9 @@ export class OrganizationAccessService { */ requireActorHospitalId(actor: ActorContext): number { if ( - typeof actor.hospitalId !== 'number' - || !Number.isInteger(actor.hospitalId) - || actor.hospitalId <= 0 + typeof actor.hospitalId !== 'number' || + !Number.isInteger(actor.hospitalId) || + actor.hospitalId <= 0 ) { throw new BadRequestException(MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED); } @@ -80,9 +80,9 @@ export class OrganizationAccessService { */ requireActorDepartmentId(actor: ActorContext): number { if ( - typeof actor.departmentId !== 'number' - || !Number.isInteger(actor.departmentId) - || actor.departmentId <= 0 + typeof actor.departmentId !== 'number' || + !Number.isInteger(actor.departmentId) || + actor.departmentId <= 0 ) { throw new BadRequestException(MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED); } @@ -94,9 +94,9 @@ export class OrganizationAccessService { */ requireActorGroupId(actor: ActorContext): number { if ( - typeof actor.groupId !== 'number' - || !Number.isInteger(actor.groupId) - || actor.groupId <= 0 + typeof actor.groupId !== 'number' || + !Number.isInteger(actor.groupId) || + actor.groupId <= 0 ) { throw new BadRequestException(MESSAGES.ORG.ACTOR_GROUP_REQUIRED); } diff --git a/src/patients/b-patients/b-patients.controller.ts b/src/patients/b-patients/b-patients.controller.ts index 346788e..0e96af6 100644 --- a/src/patients/b-patients/b-patients.controller.ts +++ b/src/patients/b-patients/b-patients.controller.ts @@ -25,6 +25,7 @@ import { Roles } from '../../auth/roles.decorator.js'; import { Role } from '../../generated/prisma/enums.js'; import { BPatientsService } from './b-patients.service.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'; /** @@ -102,10 +103,34 @@ export class BPatientsController { Role.DOCTOR, ) @ApiOperation({ summary: '创建患者' }) - createPatient(@CurrentActor() actor: ActorContext, @Body() dto: CreatePatientDto) { + createPatient( + @CurrentActor() actor: ActorContext, + @Body() dto: CreatePatientDto, + ) { 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); + } + /** * 查询患者详情。 */ diff --git a/src/patients/b-patients/b-patients.service.ts b/src/patients/b-patients/b-patients.service.ts index b9b97cb..6997788 100644 --- a/src/patients/b-patients/b-patients.service.ts +++ b/src/patients/b-patients/b-patients.service.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { BadRequestException, ConflictException, @@ -6,18 +7,118 @@ import { NotFoundException, } from '@nestjs/common'; 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 type { ActorContext } from '../../common/actor-context.js'; import { MESSAGES } from '../../common/messages.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 { normalizePatientIdCard } from '../patient-id-card.util.js'; +type PrismaExecutor = Prisma.TransactionClient | PrismaService; + 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() export class BPatientsService { @@ -30,15 +131,27 @@ export class BPatientsService { const hospitalId = this.resolveHospitalId(actor, requestedHospitalId); const where = this.buildVisiblePatientWhere(actor, hospitalId); - return this.prisma.patient.findMany({ + const patients = await this.prisma.patient.findMany({ where, - include: { - hospital: { select: { id: true, name: true } }, - doctor: { select: { id: true, name: true, role: true } }, - devices: true, - }, + include: PATIENT_LIST_INCLUDE, 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) { const doctor = await this.resolveWritableDoctor(actor, dto.doctorId); - return this.prisma.patient.create({ - data: { - name: this.normalizeRequiredString(dto.name, 'name'), - phone: this.normalizePhone(dto.phone), - // 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。 - idCard: this.normalizeIdCard(dto.idCard), - hospitalId: doctor.hospitalId!, - doctorId: doctor.id, - }, - include: { - hospital: { select: { id: true, name: true } }, - doctor: { select: { id: true, name: true, role: true } }, - devices: true, - }, + return this.prisma.$transaction(async (tx) => { + const patient = await tx.patient.create({ + data: { + 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), + // 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。 + idCard: this.normalizeIdCard(dto.idCard), + hospitalId: doctor.hospitalId!, + doctorId: doctor.id, + }, + }); + + if (dto.initialSurgery) { + 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) { const patient = await this.findPatientWithScope(id); this.assertPatientScope(actor, patient); - return patient; + return this.decoratePatientDetail(patient); } /** - * 更新患者信息。 + * 更新患者基础信息。 */ async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) { const patient = await this.findPatientWithScope(id); this.assertPatientScope(actor, patient); + if (dto.initialSurgery !== undefined) { + throw new BadRequestException( + MESSAGES.PATIENT.SURGERY_UPDATE_NOT_SUPPORTED, + ); + } + const data: Prisma.PatientUpdateInput = {}; if (dto.name !== undefined) { 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) { data.phone = this.normalizePhone(dto.phone); } @@ -145,15 +324,13 @@ export class BPatientsService { data.hospital = { connect: { id: doctor.hospitalId! } }; } - return this.prisma.patient.update({ + const updated = await this.prisma.patient.update({ where: { id: patient.id }, 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); try { - return await this.prisma.patient.delete({ + const deleted = await this.prisma.patient.delete({ where: { id: patient.id }, - include: { - hospital: { select: { id: true, name: true } }, - doctor: { select: { id: true, name: true, role: true } }, - devices: true, - }, + include: PATIENT_DETAIL_INCLUDE, }); + return this.decoratePatientDetail(deleted); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && @@ -192,22 +366,16 @@ export class BPatientsService { 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 }, - include: { - hospital: { select: { id: true, name: true } }, - doctor: { - select: { - id: true, - name: true, - role: true, - hospitalId: true, - departmentId: true, - groupId: true, - }, - }, - devices: true, - }, + include: PATIENT_DETAIL_INCLUDE, }); if (!patient) { @@ -380,6 +548,304 @@ export class BPatientsService { 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> + >(); + } + + 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>, + ) { + 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) { if (typeof value !== 'string') { throw new BadRequestException(`${fieldName} 必须是字符串`); @@ -391,6 +857,14 @@ export class BPatientsService { 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) { const normalized = this.normalizeRequiredString(phone, 'phone'); if (!/^1\d{10}$/.test(normalized)) { @@ -406,4 +880,91 @@ export class BPatientsService { const normalized = this.normalizeRequiredString(value, 'idCard'); 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; + } } diff --git a/src/patients/c-patients/c-patients.service.ts b/src/patients/c-patients/c-patients.service.ts index f41d637..7f98ed7 100644 --- a/src/patients/c-patients/c-patients.service.ts +++ b/src/patients/c-patients/c-patients.service.ts @@ -32,8 +32,46 @@ export class CPatientsService { }, include: { hospital: { select: { id: true, name: true } }, + surgeries: { + include: { + devices: { + 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: { include: { task: true, @@ -48,8 +86,49 @@ export class CPatientsService { } const lifecycle = patients - .flatMap((patient) => - patient.devices.flatMap((device) => + .flatMap((patient) => { + 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) => { // 容错:若存在脏数据导致 task 为空,直接跳过该条明细,避免接口 500。 if (!taskItem.task) { @@ -65,13 +144,27 @@ export class CPatientsService { patient: { id: this.toJsonNumber(patient.id), name: patient.name, + inpatientNo: patient.inpatientNo, + projectName: patient.projectName, }, device: { id: this.toJsonNumber(device.id), snCode: device.snCode, status: device.status, + isAbandoned: device.isAbandoned, 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: { id: this.toJsonNumber(task.id), status: task.status, @@ -85,8 +178,10 @@ export class CPatientsService { }, ]; }), - ), - ) + ); + + return [...surgeryEvents, ...taskEvents]; + }) .sort( (a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), diff --git a/src/patients/dto/create-patient-surgery.dto.ts b/src/patients/dto/create-patient-surgery.dto.ts new file mode 100644 index 0000000..8983621 --- /dev/null +++ b/src/patients/dto/create-patient-surgery.dto.ts @@ -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[]; +} diff --git a/src/patients/dto/create-patient.dto.ts b/src/patients/dto/create-patient.dto.ts index ca4c2be..8b93273 100644 --- a/src/patients/dto/create-patient.dto.ts +++ b/src/patients/dto/create-patient.dto.ts @@ -1,6 +1,14 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 端新增患者使用。 @@ -10,6 +18,16 @@ export class CreatePatientDto { @IsString({ message: 'name 必须是字符串' }) 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' }) @IsString({ message: 'phone 必须是字符串' }) @Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' }) @@ -27,4 +45,13 @@ export class CreatePatientDto { @IsInt({ message: 'doctorId 必须是整数' }) @Min(1, { message: 'doctorId 必须大于 0' }) doctorId!: number; + + @ApiPropertyOptional({ + description: '首台手术信息,可在创建患者时一并录入', + type: CreatePatientSurgeryDto, + }) + @IsOptional() + @ValidateNested() + @Type(() => CreatePatientSurgeryDto) + initialSurgery?: CreatePatientSurgeryDto; } diff --git a/src/patients/dto/create-surgery-device.dto.ts b/src/patients/dto/create-surgery-device.dto.ts new file mode 100644 index 0000000..f1c48ed --- /dev/null +++ b/src/patients/dto/create-surgery-device.dto.ts @@ -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; +} diff --git a/src/patients/dto/surgery-material.dto.ts b/src/patients/dto/surgery-material.dto.ts new file mode 100644 index 0000000..738532e --- /dev/null +++ b/src/patients/dto/surgery-material.dto.ts @@ -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; +} diff --git a/src/tasks/task.service.ts b/src/tasks/task.service.ts index b59241b..70cc53b 100644 --- a/src/tasks/task.service.ts +++ b/src/tasks/task.service.ts @@ -56,9 +56,19 @@ export class TaskService { where: { id: { in: deviceIds }, status: DeviceStatus.ACTIVE, + isAbandoned: false, + isPressureAdjustable: true, patient: { hospitalId }, }, - select: { id: true, currentPressure: true }, + select: { + id: true, + currentPressure: true, + implantCatalog: { + select: { + pressureLevels: true, + }, + }, + }, }); if (devices.length !== deviceIds.length) { @@ -82,6 +92,24 @@ export class TaskService { const pressureByDeviceId = new Map( 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({ data: { diff --git a/src/users/b-users/b-users.controller.ts b/src/users/b-users/b-users.controller.ts index f1b8187..7c9753e 100644 --- a/src/users/b-users/b-users.controller.ts +++ b/src/users/b-users/b-users.controller.ts @@ -1,5 +1,10 @@ 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 { CurrentActor } from '../../auth/current-actor.decorator.js'; import { AccessTokenGuard } from '../../auth/access-token.guard.js'; diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts index 2b764a8..87cd83e 100644 --- a/src/users/dto/create-user.dto.ts +++ b/src/users/dto/create-user.dto.ts @@ -31,7 +31,10 @@ export class CreateUserDto { @MinLength(8, { message: 'password 长度至少 8 位' }) password?: string; - @ApiPropertyOptional({ description: '微信 openId', example: 'wx-open-id-demo' }) + @ApiPropertyOptional({ + description: '微信 openId', + example: 'wx-open-id-demo', + }) @IsOptional() @IsString({ message: 'openId 必须是字符串' }) openId?: string; diff --git a/src/users/dto/login.dto.ts b/src/users/dto/login.dto.ts index 093355e..9e0135c 100644 --- a/src/users/dto/login.dto.ts +++ b/src/users/dto/login.dto.ts @@ -30,7 +30,10 @@ export class LoginDto { @IsEnum(Role, { message: 'role 枚举值不合法' }) role!: Role; - @ApiPropertyOptional({ description: '医院 ID(多账号场景建议传入)', example: 1 }) + @ApiPropertyOptional({ + description: '医院 ID(多账号场景建议传入)', + example: 1, + }) @IsOptional() @EmptyStringToUndefined() @Type(() => Number) diff --git a/test/e2e/specs/devices.e2e-spec.ts b/test/e2e/specs/devices.e2e-spec.ts index baa7ace..991e921 100644 --- a/test/e2e/specs/devices.e2e-spec.ts +++ b/test/e2e/specs/devices.e2e-spec.ts @@ -29,7 +29,6 @@ describe('BDevicesController (e2e)', () => { .set('Authorization', `Bearer ${token}`) .send({ snCode: uniqueSeedValue('device-sn'), - currentPressure: 118, status: DeviceStatus.ACTIVE, patientId, }); @@ -38,6 +37,7 @@ describe('BDevicesController (e2e)', () => { return response.body.data as { id: number; snCode: string; + currentPressure: number; status: DeviceStatus; 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 流程', () => { it('成功:HOSPITAL_ADMIN 可创建设备', async () => { const created = await createDevice( @@ -103,6 +188,7 @@ describe('BDevicesController (e2e)', () => { ); expect(created.status).toBe(DeviceStatus.ACTIVE); + expect(created.currentPressure).toBe(0); expect(created.patient.id).toBe(ctx.fixtures.patients.patientA1Id); expect(created.snCode).toMatch(/^DEVICE-SN-/); }); @@ -113,7 +199,6 @@ describe('BDevicesController (e2e)', () => { .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`) .send({ snCode: uniqueSeedValue('cross-hospital-device'), - currentPressure: 120, status: DeviceStatus.ACTIVE, patientId: ctx.fixtures.patients.patientB1Id, }); @@ -133,7 +218,6 @@ describe('BDevicesController (e2e)', () => { .send({ status: DeviceStatus.INACTIVE, patientId: ctx.fixtures.patients.patientA2Id, - currentPressure: 99, }); expectSuccessEnvelope(response, 200); @@ -141,7 +225,23 @@ describe('BDevicesController (e2e)', () => { expect(response.body.data.patient.id).toBe( 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 () => { diff --git a/test/e2e/specs/dictionaries.e2e-spec.ts b/test/e2e/specs/dictionaries.e2e-spec.ts new file mode 100644 index 0000000..117f667 --- /dev/null +++ b/test/e2e/specs/dictionaries.e2e-spec.ts @@ -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('匿名字典项'), + }), + }); + }); + }); +}); diff --git a/test/e2e/specs/patients.e2e-spec.ts b/test/e2e/specs/patients.e2e-spec.ts index cd8eb8d..bc6fc2d 100644 --- a/test/e2e/specs/patients.e2e-spec.ts +++ b/test/e2e/specs/patients.e2e-spec.ts @@ -1,5 +1,9 @@ import request from 'supertest'; -import { Role } from '../../../src/generated/prisma/enums.js'; +import { + DeviceStatus, + Role, + TaskStatus, +} from '../../../src/generated/prisma/enums.js'; import { closeE2EContext, createE2EContext, @@ -9,8 +13,17 @@ import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js'; import { expectErrorEnvelope, expectSuccessEnvelope, + uniquePhone, + uniqueSeedValue, } 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)', () => { let ctx: E2EContext; @@ -185,4 +198,209 @@ describe('Patients Controllers (e2e)', () => { 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, '请求参数不合法'); + }); + }); }); diff --git a/test/e2e/specs/tasks.e2e-spec.ts b/test/e2e/specs/tasks.e2e-spec.ts index 9941314..1c1b8a2 100644 --- a/test/e2e/specs/tasks.e2e-spec.ts +++ b/test/e2e/specs/tasks.e2e-spec.ts @@ -49,7 +49,7 @@ describe('BTasksController (e2e)', () => { items: [ { deviceId: ctx.fixtures.devices.deviceA2Id, - targetPressure: 126, + targetPressure: 120, }, ], }); @@ -58,6 +58,22 @@ describe('BTasksController (e2e)', () => { 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 () => { const response = await request(ctx.app.getHttpServer()) .post('/b/tasks/publish') @@ -101,7 +117,7 @@ describe('BTasksController (e2e)', () => { it('成功:ENGINEER 可接收待处理任务', async () => { const task = await publishPendingTask( ctx.fixtures.devices.deviceA2Id, - 127, + 140, ); const response = await request(ctx.app.getHttpServer()) @@ -128,7 +144,7 @@ describe('BTasksController (e2e)', () => { it('状态机失败:重复接收返回 409', async () => { const task = await publishPendingTask( ctx.fixtures.devices.deviceA3Id, - 122, + 120, ); const firstAccept = await request(ctx.app.getHttpServer()) @@ -172,7 +188,7 @@ describe('BTasksController (e2e)', () => { describe('POST /b/tasks/complete', () => { it('成功:ENGINEER 完成已接收任务并同步设备压力', async () => { - const targetPressure = 135; + const targetPressure = 140; const task = await publishPendingTask( ctx.fixtures.devices.deviceA1Id, targetPressure, @@ -211,7 +227,7 @@ describe('BTasksController (e2e)', () => { it('状态机失败:未接收任务直接完成返回 409', async () => { const task = await publishPendingTask( ctx.fixtures.devices.deviceA2Id, - 124, + 100, ); const response = await request(ctx.app.getHttpServer()) @@ -275,7 +291,7 @@ describe('BTasksController (e2e)', () => { it('状态机失败:已完成任务不可取消返回 409', async () => { const task = await publishPendingTask( ctx.fixtures.devices.deviceA2Id, - 123, + 160, ); const acceptResponse = await request(ctx.app.getHttpServer()) diff --git a/tyt-admin/components.d.ts b/tyt-admin/components.d.ts index 5806b1b..b78f775 100644 --- a/tyt-admin/components.d.ts +++ b/tyt-admin/components.d.ts @@ -18,6 +18,7 @@ declare module 'vue' { ElCol: typeof import('element-plus/es')['ElCol'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElContainer: typeof import('element-plus/es')['ElContainer'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] @@ -31,6 +32,7 @@ declare module 'vue' { ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElLink: typeof import('element-plus/es')['ElLink'] ElMain: typeof import('element-plus/es')['ElMain'] ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] @@ -39,8 +41,11 @@ declare module 'vue' { ElRow: typeof import('element-plus/es')['ElRow'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] 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'] ElTimeline: typeof import('element-plus/es')['ElTimeline'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] diff --git a/tyt-admin/src/api/devices.js b/tyt-admin/src/api/devices.js index a763c50..1e43f9c 100644 --- a/tyt-admin/src/api/devices.js +++ b/tyt-admin/src/api/devices.js @@ -11,6 +11,22 @@ export const getDeviceById = (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) => { return request.post('/b/devices', data); }; diff --git a/tyt-admin/src/api/dictionaries.js b/tyt-admin/src/api/dictionaries.js new file mode 100644 index 0000000..2c68342 --- /dev/null +++ b/tyt-admin/src/api/dictionaries.js @@ -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}`); +}; diff --git a/tyt-admin/src/api/patients.js b/tyt-admin/src/api/patients.js index 952dd40..8ddbf45 100644 --- a/tyt-admin/src/api/patients.js +++ b/tyt-admin/src/api/patients.js @@ -20,6 +20,10 @@ export const updatePatient = (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) => { return request.delete(`/b/patients/${id}`); }; diff --git a/tyt-admin/src/constants/medical-dictionaries.js b/tyt-admin/src/constants/medical-dictionaries.js new file mode 100644 index 0000000..462a57a --- /dev/null +++ b/tyt-admin/src/constants/medical-dictionaries.js @@ -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; +} diff --git a/tyt-admin/src/constants/role-permissions.js b/tyt-admin/src/constants/role-permissions.js index 2617062..ea0f9e6 100644 --- a/tyt-admin/src/constants/role-permissions.js +++ b/tyt-admin/src/constants/role-permissions.js @@ -29,8 +29,9 @@ export const ROLE_PERMISSIONS = Object.freeze({ // 主任/组长仍可通过接口读取科室信息,但不再开放独立“科室管理”页面。 ORG_DEPARTMENTS: ADMIN_ROLES, ORG_GROUPS: ORG_MANAGER_ROLES, + DICTIONARIES: Object.freeze(['SYSTEM_ADMIN']), USERS: USER_MANAGER_ROLES, - DEVICES: ADMIN_ROLES, + DEVICES: Object.freeze(['SYSTEM_ADMIN']), TASKS: TASK_ROLES, PATIENTS: PATIENT_ROLES, }); diff --git a/tyt-admin/src/layouts/AdminLayout.vue b/tyt-admin/src/layouts/AdminLayout.vue index a015b92..204b3bd 100644 --- a/tyt-admin/src/layouts/AdminLayout.vue +++ b/tyt-admin/src/layouts/AdminLayout.vue @@ -36,7 +36,10 @@ 组织架构图 - + 科室管理 @@ -51,6 +54,11 @@ {{ usersMenuLabel }} + + + 字典管理 + + 设备管理 @@ -119,6 +127,7 @@ import { Connection, Share, Monitor, + CollectionTag, } from '@element-plus/icons-vue'; const route = useRoute(); @@ -136,6 +145,9 @@ const canAccessUsers = computed(() => const canAccessDevices = computed(() => hasRolePermission(userStore.role, ROLE_PERMISSIONS.DEVICES), ); +const canAccessDictionaries = computed(() => + hasRolePermission(userStore.role, ROLE_PERMISSIONS.DICTIONARIES), +); const canAccessOrgTree = computed(() => hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE), ); diff --git a/tyt-admin/src/router/index.js b/tyt-admin/src/router/index.js index e2af074..4ad24c6 100644 --- a/tyt-admin/src/router/index.js +++ b/tyt-admin/src/router/index.js @@ -77,6 +77,16 @@ const routes = [ allowedRoles: ROLE_PERMISSIONS.USERS, }, }, + { + path: 'dictionaries', + name: 'Dictionaries', + component: () => import('../views/dictionaries/Dictionaries.vue'), + meta: { + title: '字典管理', + requiresAuth: true, + allowedRoles: ROLE_PERMISSIONS.DICTIONARIES, + }, + }, { path: 'devices', name: 'Devices', diff --git a/tyt-admin/src/views/devices/Devices.vue b/tyt-admin/src/views/devices/Devices.vue index 570c348..de4cab6 100644 --- a/tyt-admin/src/views/devices/Devices.vue +++ b/tyt-admin/src/views/devices/Devices.vue @@ -1,76 +1,42 @@ - -
- -
- - - - - + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + 删除 + +
+ + + 新增挡位 + +
+ 每个挡位填一个整数值,保存时会自动去重并按从小到大排序 +
+
- - - - - - - + - - - - - - - - - -
@@ -241,26 +208,14 @@ @@ -519,13 +417,63 @@ onMounted(async () => { padding: 0; } -.header-actions { - margin-bottom: 20px; +.panel-card { + border-radius: 18px; } -.pagination-container { - margin-top: 20px; +.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; +} + +.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; justify-content: flex-end; + gap: 12px; } diff --git a/tyt-admin/src/views/dictionaries/Dictionaries.vue b/tyt-admin/src/views/dictionaries/Dictionaries.vue new file mode 100644 index 0000000..d305117 --- /dev/null +++ b/tyt-admin/src/views/dictionaries/Dictionaries.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/tyt-admin/src/views/patients/Patients.vue b/tyt-admin/src/views/patients/Patients.vue index 8a0a93a..0ccae3b 100644 --- a/tyt-admin/src/views/patients/Patients.vue +++ b/tyt-admin/src/views/patients/Patients.vue @@ -1,16 +1,14 @@ - + @@ -578,17 +1380,187 @@ onMounted(async () => { padding: 0; } +.panel-card { + border-radius: 20px; + border: none; + background: + radial-gradient( + circle at top right, + rgba(18, 99, 167, 0.09), + transparent 22% + ), + linear-gradient(180deg, #ffffff 0%, #f6f9fd 100%); +} + .header-actions { margin-bottom: 20px; } +.stat-stack { + display: grid; + gap: 4px; + line-height: 1.4; + color: #334155; +} + .pagination-container { margin-top: 20px; display: flex; justify-content: flex-end; } -.mb-16 { +.dialog-scroll { + max-height: 72vh; + overflow: auto; + padding-right: 4px; +} + +.section-card { + padding: 18px 18px 8px; + border: 1px solid #d8e5f3; + border-radius: 18px; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); +} + +.section-card + .section-card { + margin-top: 16px; +} + +.section-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; margin-bottom: 16px; } + +.section-card-title { + font-size: 16px; + font-weight: 600; + color: #16324f; +} + +.section-card-subtitle { + margin-top: 4px; + font-size: 12px; + color: #64748b; +} + +.dialog-footer { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.context-alert { + margin-bottom: 16px; +} + +.detail-profile { + padding-top: 6px; +} + +.surgery-card-list { + display: grid; + gap: 16px; +} + +.surgery-card { + border-radius: 18px; + border-color: #d7e6f5; + background: + radial-gradient( + circle at top right, + rgba(0, 109, 119, 0.08), + transparent 26% + ), + linear-gradient(180deg, #ffffff 0%, #f7fbff 100%); +} + +.surgery-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.surgery-card-title { + font-size: 16px; + font-weight: 600; + color: #16324f; +} + +.surgery-card-meta { + margin-top: 4px; + color: #64748b; + font-size: 13px; +} + +.surgery-card-tags { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.surgery-desc { + margin-bottom: 16px; +} + +.link-list { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.device-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 14px; +} + +.device-detail-card { + border-radius: 16px; + border-color: #d7e6f5; + background: #fff; +} + +.device-detail-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.device-detail-title { + font-size: 14px; + font-weight: 600; + color: #16324f; +} + +.device-detail-subtitle { + margin-top: 4px; + font-size: 12px; + color: #64748b; +} + +.device-detail-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +@media (max-width: 768px) { + .section-card-head, + .surgery-card-head, + .device-detail-head { + flex-direction: column; + align-items: flex-start; + } + + .surgery-card-tags { + justify-content: flex-start; + } +} diff --git a/tyt-admin/src/views/patients/components/SurgeryFormSection.vue b/tyt-admin/src/views/patients/components/SurgeryFormSection.vue new file mode 100644 index 0000000..38897fd --- /dev/null +++ b/tyt-admin/src/views/patients/components/SurgeryFormSection.vue @@ -0,0 +1,657 @@ + + + + + diff --git a/tyt-admin/src/views/patients/patient-form-options.js b/tyt-admin/src/views/patients/patient-form-options.js new file mode 100644 index 0000000..6281719 --- /dev/null +++ b/tyt-admin/src/views/patients/patient-form-options.js @@ -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', +};