"1. 新增系统字典与全局植入目录相关表结构及迁移

2. 扩展患者手术与材料模型,更新种子数据
3. 新增字典模块,增强设备植入目录管理能力
4. 重构患者后台服务与表单链路,统一权限与参数校验
5. 管理台新增字典页面并改造患者/设备页面与路由权限
6. 补充字典及相关领域 e2e 测试并更新文档"
This commit is contained in:
EL 2026-03-19 20:42:17 +08:00
parent 64d1ad7896
commit 73082225f6
61 changed files with 6058 additions and 857 deletions

View File

@ -2,17 +2,52 @@
## 1. 目标 ## 1. 目标
- 提供 B 端设备 CRUD。 - 提供“全局植入物目录”管理,供患者手术表单选择。
- 管理设备与患者的归属关系。 - 维护患者手术下的植入实例记录。
- 支持管理员按医院、患者、状态和关键词分页查询设备。 - 支持为可调压器械配置挡位列表。
- 支持管理员按医院、患者、状态和关键词分页查询患者植入实例。
## 2. 权限 ## 2. 设备实例
- `SYSTEM_ADMIN`:可跨院查询和维护设备。 `Device` 现在表示“患者某次手术下的植入设备实例”,不是独立库存主数据。
- `HOSPITAL_ADMIN`:仅可操作本院患者名下设备。
- 其他角色:默认拒绝。
## 3. 接口 核心字段:
- `snCode`:设备唯一标识
- `patientId`:归属患者
- `surgeryId`:归属手术,可为空
- `implantCatalogId`:型号字典 ID可为空
- `implantModel` / `implantManufacturer` / `implantName`:历史快照
- `isPressureAdjustable`:是否可调压
- `isAbandoned`:是否弃用
- `currentPressure`:当前压力
- `status`:设备状态
补充:
- `currentPressure` 不允许在创建/编辑设备实例时手工指定。
- 新植入设备默认以 `initialPressure`(或系统默认值)作为当前压力起点,后续只允许在调压任务完成时更新。
## 3. 植入物目录
新增 `ImplantCatalog`
- `modelCode`:型号编码,唯一
- `manufacturer`:厂商
- `name`:名称
- `pressureLevels`:可调压器械的挡位列表
- `isPressureAdjustable`:是否可调压
- `notes`:目录备注
可见性:
- 全部已登录 B 端角色都可读取,用于患者手术录入
- 仅 `SYSTEM_ADMIN` 可做目录 CRUD
- 目录是全局共享的,不按医院隔离
## 4. 接口
设备实例:
- `GET /b/devices`:分页查询设备列表 - `GET /b/devices`:分页查询设备列表
- `GET /b/devices/:id`:查询设备详情 - `GET /b/devices/:id`:查询设备详情
@ -20,8 +55,19 @@
- `PATCH /b/devices/:id`:更新设备 - `PATCH /b/devices/:id`:更新设备
- `DELETE /b/devices/:id`:删除设备 - `DELETE /b/devices/:id`:删除设备
## 4. 约束 型号字典:
- `GET /b/devices/catalogs`:查询植入物型号字典
- `POST /b/devices/catalogs`:新增植入物目录
- `PATCH /b/devices/catalogs/:id`:更新植入物目录
- `DELETE /b/devices/catalogs/:id`:删除植入物目录
## 5. 约束
- 设备必须绑定到一个患者。 - 设备必须绑定到一个患者。
- 设备 SN 在全库唯一,服务端会统一转成大写后再校验。 - 设备 SN 在全库唯一,服务端会统一转成大写后再校验。
- 删除已被任务明细引用的设备会返回 `409` - 删除已被任务明细引用的设备会返回 `409`
- 删除已被患者手术引用的植入物目录会返回 `409`
- 可调压植入物若配置了 `pressureLevels`,患者手术录入和任务调压时的压力值必须命中该挡位列表。
- 调压任务仅允许针对 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备发布。
- `Device.currentPressure` 只允许由调压任务完成时更新,患者手术录入和设备实例编辑都不开放手工写入。

42
docs/dictionaries.md Normal file
View File

@ -0,0 +1,42 @@
# 系统字典说明(`src/dictionaries`
## 1. 目标
- 将患者手术表单中的固定选项沉淀为系统级字典。
- 仅允许 `SYSTEM_ADMIN` 做 CRUD。
- 业务角色仅可读取启用中的字典项,用于患者录入表单。
## 2. 当前字典类型
- `PRIMARY_DISEASE`:原发病
- `HYDROCEPHALUS_TYPE`:脑积水类型
- `SHUNT_MODE`:分流方式
- `PROXIMAL_PUNCTURE_AREA`:近端穿刺区域
- `VALVE_PLACEMENT_SITE`:阀门植入部位
- `DISTAL_SHUNT_DIRECTION`:远端分流方向
## 3. 数据结构
新增 `DictionaryItem`
- `type`:字典类型枚举
- `label`:字典项显示值
- `sortOrder`:排序值,越小越靠前
- `enabled`:是否启用
约束:
- 同一 `type``label` 唯一。
- 非系统管理员读取时只返回 `enabled=true` 的字典项。
## 4. 接口
- `GET /b/dictionaries`:查询字典项
- `POST /b/dictionaries`:创建字典项(仅系统管理员)
- `PATCH /b/dictionaries/:id`:更新字典项(仅系统管理员)
- `DELETE /b/dictionaries/:id`:删除字典项(仅系统管理员)
说明:
- `GET /b/dictionaries?includeDisabled=true` 仅系统管理员生效。
- 患者手术表单现在从该接口动态读取选项,不再使用前端硬编码数组。

View File

@ -2,33 +2,99 @@
## 1. 目标 ## 1. 目标
- B 端:按组织与角色范围查询患者(强依赖 `hospitalId` - B 端:按组织与角色范围查询患者,并维护患者基础档案
- C 端:按 `phone + idCard` 做跨院聚合查询 - B 端:支持患者首术录入、二次手术追加、旧设备弃用标记
- 患者档案直接保存身份证号原文,不再做哈希转换 - 数据关系升级为 `Patient -> PatientSurgery -> Device -> TaskItem -> Task`
- 服务端只做轻量格式整理:去空格、统一末尾 `x/X` 为大写 - C 端:按 `phone + idCard` 做跨院生命周期聚合,返回手术事件与调压事件
## 2. B 端可见性 ## 2. 患者基础档案
患者表新增以下字段:
- `inpatientNo`:住院号
- `projectName`:项目名称
- `phone`:联系电话
- `idCard`:身份证号原文
- `doctorId`:患者归属人员(医生/主任/组长)
说明:
- `name` 仍然保留为患者姓名必填字段。
- `doctorId + hospitalId` 仍然是患者的组织归属来源,不直接绑定小组/科室。
- 手术表单中的 `原发病 / 脑积水类型 / 分流方式 / 近端穿刺区域 / 阀门植入部位 / 远端分流方向` 改为读取系统字典,不再前端硬编码。
## 3. 手术档案
新增 `PatientSurgery` 表,每次手术保存:
- `surgeryDate`:手术日期
- `surgeryName`:手术名称
- `surgeonName`:主刀医生
- `preOpPressure`:术前测压,可为空
- `primaryDisease`:原发病
- `hydrocephalusTypes`:脑积水类型,多选
- `previousShuntSurgeryDate`:上次分流手术时间,可为空
- `preOpMaterials`:术前 CT 影像/资料,保存为附件元数据数组
- `notes`:手术备注,可为空
返回时会自动补充:
- `shuntSurgeryCount`:当前这台手术是该患者第几次分流手术
- `activeDeviceCount`:本次手术仍在用设备数
- `abandonedDeviceCount`:本次手术已弃用设备数
## 4. 植入设备
设备表仍沿用 `Device`,但语义改为“患者手术下植入的设备实例”。
新增/使用字段:
- `implantCatalogId`:植入物型号字典 ID
- `implantModel` / `implantManufacturer` / `implantName`:型号快照
- `isPressureAdjustable`:是否可调压
- `isAbandoned`:是否已弃用
- `shuntMode`:分流方式
- `proximalPunctureAreas`:近端穿刺区域,最多 2 个
- `valvePlacementSites`:阀门植入部位,最多 2 个
- `distalShuntDirection`:远端分流方向
- `initialPressure`:初始压力,可为空
- `implantNotes`:植入物备注,可为空
- `labelImageUrl`:植入物标签图片地址,可为空
说明:
- 患者手术里选择的是“全局植入物目录”,不是按医院单独维护的设备库。
- 同一个植入物目录可被多个患者手术重复绑定,患者侧保存的是目录快照。
- 旧设备弃用后,`TaskItem` 历史不会删除。
- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。
- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的值。
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`,后续仅能由调压任务完成后更新。
## 5. B 端可见性
- `DOCTOR`:仅可查自己名下患者 - `DOCTOR`:仅可查自己名下患者
- `LEADER`:可查本组医生名下患者(按医生当前 `groupId` 反查) - `LEADER`:可查本组医生名下患者
- `DIRECTOR`:可查本科室医生名下患者(按医生当前 `departmentId` 反查) - `DIRECTOR`:可查本科室医生名下患者
- `HOSPITAL_ADMIN`:可查本院全部患者 - `HOSPITAL_ADMIN`:可查本院全部患者
- `SYSTEM_ADMIN`:需显式传入目标 `hospitalId` - `SYSTEM_ADMIN`:需显式传入目标 `hospitalId`
## 2.1 B 端 CRUD ## 6. B 端接口
- `GET /b/patients`:按角色查询可见患者 - `GET /b/patients`:按角色查询可见患者列表
- `GET /b/patients/doctors`:查询当前角色可见的归属人员候选(医生/主任/组长,用于患者表单) - `GET /b/patients/doctors`:查询当前角色可见的归属人员候选
- `POST /b/patients`:创建患者 - `POST /b/patients`:创建患者,可选带 `initialSurgery`
- `POST /b/patients/:id/surgeries`:为患者新增手术
- `GET /b/patients/:id`:查询患者详情 - `GET /b/patients/:id`:查询患者详情
- `PATCH /b/patients/:id`:更新患者 - `PATCH /b/patients/:id`:更新患者基础信息
- `DELETE /b/patients/:id`:删除患者(若存在关联设备返回 409 - `DELETE /b/patients/:id`:删除患者
说明: 约束:
患者表只绑定 `doctorId + hospitalId`,不直接绑定小组/科室。医生调组或调科后,
可见范围会按医生当前组织归属自动变化,无需迁移患者数据。
## 3. C 端生命周期聚合 - `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。
- 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`
## 7. C 端生命周期聚合
接口:`GET /c/patients/lifecycle?phone=...&idCard=...` 接口:`GET /c/patients/lifecycle?phone=...&idCard=...`
@ -36,10 +102,16 @@
1. 不做医院隔离(跨租户) 1. 不做医院隔离(跨租户)
2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配 2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配
3. 关联查询 `Patient -> Device -> TaskItem -> Task` 3. 聚合 `Patient -> PatientSurgery -> Device` 的手术事件
4. 返回扁平生命周期列表(按 `Task.createdAt DESC` 4. 聚合 `Patient -> Device -> TaskItem -> Task` 的调压事件
5. 全部事件按 `occurredAt DESC` 返回
## 4. 响应结构 事件类型:
- `SURGERY`
- `TASK_PRESSURE_ADJUSTMENT`
## 8. 响应结构
全部接口统一返回: 全部接口统一返回:

View File

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

View File

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

View File

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

View File

@ -36,6 +36,16 @@ enum TaskStatus {
CANCELLED CANCELLED
} }
// 医学字典类型:驱动患者手术表单中的单选/多选项。
enum DictionaryType {
PRIMARY_DISEASE
HYDROCEPHALUS_TYPE
SHUNT_MODE
PROXIMAL_PUNCTURE_AREA
VALVE_PLACEMENT_SITE
DISTAL_SHUNT_DIRECTION
}
// 医院主表:多租户顶层实体。 // 医院主表:多租户顶层实体。
model Hospital { model Hospital {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
@ -102,6 +112,10 @@ model User {
model Patient { model Patient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
// 住院号:用于院内患者检索与病案关联。
inpatientNo String?
// 项目名称:用于区分患者所属项目/课题。
projectName String?
phone String phone String
// 患者身份证号,录入与查询都使用原始证件号。 // 患者身份证号,录入与查询都使用原始证件号。
idCard String idCard String
@ -109,23 +123,100 @@ model Patient {
doctorId Int doctorId Int
hospital Hospital @relation(fields: [hospitalId], references: [id]) hospital Hospital @relation(fields: [hospitalId], references: [id])
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id]) doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
surgeries PatientSurgery[]
devices Device[] devices Device[]
@@index([phone, idCard]) @@index([phone, idCard])
@@index([hospitalId, doctorId]) @@index([hospitalId, doctorId])
@@index([inpatientNo])
} }
// 设备表:患者可绑定多个分流设备。 // 患者手术表:保存每次分流/复手术档案。
model PatientSurgery {
id Int @id @default(autoincrement())
patientId Int
surgeryDate DateTime
surgeryName String
surgeonName String
// 术前测压:部分患者可为空。
preOpPressure Int?
// 原发病:前端单选,后端先按字符串存储,方便后续补字典。
primaryDisease String
// 脑积水类型:前端多选。
hydrocephalusTypes String[] @default([])
// 上次分流手术时间:无既往分流史时为空。
previousShuntSurgeryDate DateTime?
// 术前影像/资料:支持图片、视频等附件元数据。
preOpMaterials Json?
notes String?
createdAt DateTime @default(now())
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
devices Device[]
@@index([patientId, surgeryDate])
}
// 植入物型号字典:供前端单选型号后自动回填厂家与名称。
model ImplantCatalog {
id Int @id @default(autoincrement())
modelCode String @unique
manufacturer String
name String
// 可调压器械的可选挡位,由系统管理员维护。
pressureLevels Int[] @default([])
isPressureAdjustable Boolean @default(true)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
devices Device[]
}
// 系统级字典项:由系统管理员维护,供患者手术表单选择使用。
model DictionaryItem {
id Int @id @default(autoincrement())
type DictionaryType
label String
sortOrder Int @default(0)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([type, label])
@@index([type, enabled, sortOrder])
}
// 设备表:每次手术植入的设备实例,保留当前压力与历史调压记录。
model Device { model Device {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
snCode String @unique snCode String @unique
currentPressure Int currentPressure Int
status DeviceStatus @default(ACTIVE) status DeviceStatus @default(ACTIVE)
patientId Int patientId Int
surgeryId Int?
implantCatalogId Int?
// 植入物快照:避免型号字典修改后影响历史病历。
implantModel String?
implantManufacturer String?
implantName String?
isPressureAdjustable Boolean @default(true)
// 二次手术后旧设备可标记弃用,但历史调压任务仍需保留。
isAbandoned Boolean @default(false)
shuntMode String?
proximalPunctureAreas String[] @default([])
valvePlacementSites String[] @default([])
distalShuntDirection String?
initialPressure Int?
implantNotes String?
labelImageUrl String?
patient Patient @relation(fields: [patientId], references: [id]) patient Patient @relation(fields: [patientId], references: [id])
surgery PatientSurgery? @relation(fields: [surgeryId], references: [id], onDelete: SetNull)
implantCatalog ImplantCatalog? @relation(fields: [implantCatalogId], references: [id], onDelete: SetNull)
taskItems TaskItem[] taskItems TaskItem[]
@@index([patientId, status]) @@index([patientId, status])
@@index([surgeryId])
@@index([implantCatalogId])
@@index([patientId, isAbandoned])
} }
// 主任务表:记录调压任务主单。 // 主任务表:记录调压任务主单。

View File

@ -3,7 +3,8 @@ import { PrismaPg } from '@prisma/adapter-pg';
import { hash } from 'bcrypt'; import { hash } from 'bcrypt';
import prismaClientPackage from '@prisma/client'; import prismaClientPackage from '@prisma/client';
const { DeviceStatus, PrismaClient, Role, TaskStatus } = prismaClientPackage; const { DictionaryType, DeviceStatus, PrismaClient, Role, TaskStatus } =
prismaClientPackage;
const connectionString = process.env.DATABASE_URL; const connectionString = process.env.DATABASE_URL;
if (!connectionString) { if (!connectionString) {
@ -60,7 +61,15 @@ async function upsertUserByOpenId(openId, data) {
}); });
} }
async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) { async function ensurePatient({
hospitalId,
doctorId,
name,
inpatientNo = null,
projectName = null,
phone,
idCard,
}) {
const existing = await prisma.patient.findFirst({ const existing = await prisma.patient.findFirst({
where: { where: {
hospitalId, hospitalId,
@ -70,10 +79,15 @@ async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) {
}); });
if (existing) { if (existing) {
if (existing.doctorId !== doctorId || existing.name !== name) { if (
existing.doctorId !== doctorId ||
existing.name !== name ||
existing.inpatientNo !== inpatientNo ||
existing.projectName !== projectName
) {
return prisma.patient.update({ return prisma.patient.update({
where: { id: existing.id }, where: { id: existing.id },
data: { doctorId, name }, data: { doctorId, name, inpatientNo, projectName },
}); });
} }
return existing; return existing;
@ -84,12 +98,124 @@ async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) {
hospitalId, hospitalId,
doctorId, doctorId,
name, name,
inpatientNo,
projectName,
phone, phone,
idCard, idCard,
}, },
}); });
} }
async function ensureImplantCatalog({
modelCode,
manufacturer,
name,
pressureLevels = [],
isPressureAdjustable = true,
notes = null,
}) {
return prisma.implantCatalog.upsert({
where: { modelCode },
update: {
manufacturer,
name,
pressureLevels,
isPressureAdjustable,
notes,
},
create: {
modelCode,
manufacturer,
name,
pressureLevels,
isPressureAdjustable,
notes,
},
});
}
async function ensureDictionaryItem({
type,
label,
sortOrder = 0,
enabled = true,
}) {
return prisma.dictionaryItem.upsert({
where: {
type_label: {
type,
label,
},
},
update: {
sortOrder,
enabled,
},
create: {
type,
label,
sortOrder,
enabled,
},
});
}
async function ensurePatientSurgery({
patientId,
surgeryDate,
surgeryName,
surgeonName,
preOpPressure = null,
primaryDisease,
hydrocephalusTypes,
previousShuntSurgeryDate = null,
preOpMaterials = null,
notes = null,
}) {
const normalizedSurgeryDate = new Date(surgeryDate);
const normalizedPreviousDate = previousShuntSurgeryDate
? new Date(previousShuntSurgeryDate)
: null;
const existing = await prisma.patientSurgery.findFirst({
where: {
patientId,
surgeryDate: normalizedSurgeryDate,
surgeryName,
},
});
if (existing) {
return prisma.patientSurgery.update({
where: { id: existing.id },
data: {
surgeonName,
preOpPressure,
primaryDisease,
hydrocephalusTypes,
previousShuntSurgeryDate: normalizedPreviousDate,
preOpMaterials,
notes,
},
});
}
return prisma.patientSurgery.create({
data: {
patientId,
surgeryDate: normalizedSurgeryDate,
surgeryName,
surgeonName,
preOpPressure,
primaryDisease,
hydrocephalusTypes,
previousShuntSurgeryDate: normalizedPreviousDate,
preOpMaterials,
notes,
},
});
}
async function main() { async function main() {
const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12); const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12);
@ -217,10 +343,61 @@ async function main() {
groupId: null, groupId: null,
}); });
const dictionarySeeds = {
[DictionaryType.PRIMARY_DISEASE]: [
'先天性脑积水',
'梗阻性脑积水',
'交通性脑积水',
'出血后脑积水',
'肿瘤相关脑积水',
'外伤后脑积水',
'感染后脑积水',
'分流功能障碍',
],
[DictionaryType.HYDROCEPHALUS_TYPE]: [
'交通性',
'梗阻性',
'高压性',
'正常压力',
'先天性',
'继发性',
],
[DictionaryType.SHUNT_MODE]: ['VPS', 'VPLS', 'LPS', '脑室心房分流'],
[DictionaryType.PROXIMAL_PUNCTURE_AREA]: [
'额角',
'枕角',
'三角区',
'腰穿',
'后角',
],
[DictionaryType.VALVE_PLACEMENT_SITE]: [
'耳后',
'胸前',
'锁骨下',
'腹壁',
'腰背部',
],
[DictionaryType.DISTAL_SHUNT_DIRECTION]: ['腹腔', '胸腔', '心房', '腰大池'],
};
await Promise.all(
Object.entries(dictionarySeeds).flatMap(([type, labels]) =>
labels.map((label, index) =>
ensureDictionaryItem({
type,
label,
sortOrder: index * 10,
}),
),
),
);
const patientA1 = await ensurePatient({ const patientA1 = await ensurePatient({
hospitalId: hospitalA.id, hospitalId: hospitalA.id,
doctorId: doctorA.id, doctorId: doctorA.id,
name: 'Seed Patient A1', name: 'Seed Patient A1',
inpatientNo: 'ZYH-A-0001',
projectName: '脑积水随访项目-A',
phone: '13800002001', phone: '13800002001',
idCard: '110101199001010011', idCard: '110101199001010011',
}); });
@ -229,6 +406,8 @@ async function main() {
hospitalId: hospitalA.id, hospitalId: hospitalA.id,
doctorId: doctorA2.id, doctorId: doctorA2.id,
name: 'Seed Patient A2', name: 'Seed Patient A2',
inpatientNo: 'ZYH-A-0002',
projectName: '脑积水随访项目-A',
phone: '13800002002', phone: '13800002002',
idCard: '110101199002020022', idCard: '110101199002020022',
}); });
@ -237,6 +416,8 @@ async function main() {
hospitalId: hospitalA.id, hospitalId: hospitalA.id,
doctorId: doctorA3.id, doctorId: doctorA3.id,
name: 'Seed Patient A3', name: 'Seed Patient A3',
inpatientNo: 'ZYH-A-0003',
projectName: '脑积水随访项目-A',
phone: '13800002003', phone: '13800002003',
idCard: '110101199003030033', idCard: '110101199003030033',
}); });
@ -245,22 +426,130 @@ async function main() {
hospitalId: hospitalB.id, hospitalId: hospitalB.id,
doctorId: doctorB.id, doctorId: doctorB.id,
name: 'Seed Patient B1', name: 'Seed Patient B1',
inpatientNo: 'ZYH-B-0001',
projectName: '脑积水随访项目-B',
phone: '13800002001', phone: '13800002001',
idCard: '110101199001010011', idCard: '110101199001010011',
}); });
const adjustableCatalog = await ensureImplantCatalog({
modelCode: 'SEED-ADJUSTABLE-VALVE',
manufacturer: 'Seed MedTech',
name: 'Seed 可调压分流阀',
pressureLevels: [80, 100, 120, 140, 160],
isPressureAdjustable: true,
notes: 'Seed 全局可调压目录样例',
});
const fixedCatalog = await ensureImplantCatalog({
modelCode: 'SEED-FIXED-VALVE',
manufacturer: 'Seed MedTech',
name: 'Seed 固定压分流阀',
pressureLevels: [],
isPressureAdjustable: false,
notes: 'Seed 固定压目录样例',
});
const surgeryA1Old = await ensurePatientSurgery({
patientId: patientA1.id,
surgeryDate: '2024-06-01T08:00:00.000Z',
surgeryName: '首次脑室腹腔分流术',
surgeonName: 'Seed Director A',
preOpPressure: 24,
primaryDisease: '先天性脑积水',
hydrocephalusTypes: ['交通性'],
notes: '首台手术',
});
const surgeryA1New = await ensurePatientSurgery({
patientId: patientA1.id,
surgeryDate: '2025-09-10T08:00:00.000Z',
surgeryName: '分流系统翻修术',
surgeonName: 'Seed Director A',
preOpPressure: 18,
primaryDisease: '分流功能障碍',
hydrocephalusTypes: ['交通性', '高压性'],
previousShuntSurgeryDate: '2024-06-01T08:00:00.000Z',
preOpMaterials: [
{
type: 'IMAGE',
url: 'https://seed.example.com/a1-ct-preop.png',
name: 'Seed A1 术前 CT',
},
],
notes: '二次手术,保留原设备历史',
});
const surgeryA2 = await ensurePatientSurgery({
patientId: patientA2.id,
surgeryDate: '2025-12-15T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
surgeonName: 'Seed Doctor A2',
preOpPressure: 20,
primaryDisease: '肿瘤相关脑积水',
hydrocephalusTypes: ['梗阻性'],
});
const surgeryA3 = await ensurePatientSurgery({
patientId: patientA3.id,
surgeryDate: '2025-11-20T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
surgeonName: 'Seed Doctor A3',
preOpPressure: 21,
primaryDisease: '外伤后脑积水',
hydrocephalusTypes: ['交通性'],
});
const surgeryB1 = await ensurePatientSurgery({
patientId: patientB1.id,
surgeryDate: '2025-10-05T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
surgeonName: 'Seed Doctor B',
preOpPressure: 23,
primaryDisease: '出血后脑积水',
hydrocephalusTypes: ['高压性'],
});
const deviceA1 = await prisma.device.upsert({ const deviceA1 = await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-001' }, where: { snCode: 'SEED-SN-A-001' },
update: { update: {
patientId: patientA1.id, patientId: patientA1.id,
surgeryId: surgeryA1New.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 118, currentPressure: 118,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 118,
implantNotes: 'Seed A1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
}, },
create: { create: {
snCode: 'SEED-SN-A-001', snCode: 'SEED-SN-A-001',
patientId: patientA1.id, patientId: patientA1.id,
surgeryId: surgeryA1New.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 118, currentPressure: 118,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 118,
implantNotes: 'Seed A1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
}, },
}); });
@ -268,14 +557,42 @@ async function main() {
where: { snCode: 'SEED-SN-A-002' }, where: { snCode: 'SEED-SN-A-002' },
update: { update: {
patientId: patientA2.id, patientId: patientA2.id,
surgeryId: surgeryA2.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 112, currentPressure: 112,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'VPS',
proximalPunctureAreas: ['枕角'],
valvePlacementSites: ['胸前'],
distalShuntDirection: '腹腔',
initialPressure: 112,
implantNotes: 'Seed A2 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
}, },
create: { create: {
snCode: 'SEED-SN-A-002', snCode: 'SEED-SN-A-002',
patientId: patientA2.id, patientId: patientA2.id,
surgeryId: surgeryA2.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 112, currentPressure: 112,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'VPS',
proximalPunctureAreas: ['枕角'],
valvePlacementSites: ['胸前'],
distalShuntDirection: '腹腔',
initialPressure: 112,
implantNotes: 'Seed A2 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
}, },
}); });
@ -283,14 +600,42 @@ async function main() {
where: { snCode: 'SEED-SN-A-003' }, where: { snCode: 'SEED-SN-A-003' },
update: { update: {
patientId: patientA3.id, patientId: patientA3.id,
surgeryId: surgeryA3.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 109, currentPressure: 109,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'LPS',
proximalPunctureAreas: ['腰穿'],
valvePlacementSites: ['腰背部'],
distalShuntDirection: '腹腔',
initialPressure: 109,
implantNotes: 'Seed A3 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
}, },
create: { create: {
snCode: 'SEED-SN-A-003', snCode: 'SEED-SN-A-003',
patientId: patientA3.id, patientId: patientA3.id,
surgeryId: surgeryA3.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 109, currentPressure: 109,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'LPS',
proximalPunctureAreas: ['腰穿'],
valvePlacementSites: ['腰背部'],
distalShuntDirection: '腹腔',
initialPressure: 109,
implantNotes: 'Seed A3 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
}, },
}); });
@ -298,14 +643,42 @@ async function main() {
where: { snCode: 'SEED-SN-B-001' }, where: { snCode: 'SEED-SN-B-001' },
update: { update: {
patientId: patientB1.id, patientId: patientB1.id,
surgeryId: surgeryB1.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 121, currentPressure: 121,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 121,
implantNotes: 'Seed B1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
}, },
create: { create: {
snCode: 'SEED-SN-B-001', snCode: 'SEED-SN-B-001',
patientId: patientB1.id, patientId: patientB1.id,
surgeryId: surgeryB1.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 121, currentPressure: 121,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 121,
implantNotes: 'Seed B1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
}, },
}); });
@ -313,14 +686,42 @@ async function main() {
where: { snCode: 'SEED-SN-A-004' }, where: { snCode: 'SEED-SN-A-004' },
update: { update: {
patientId: patientA1.id, patientId: patientA1.id,
surgeryId: surgeryA1Old.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 130, currentPressure: 130,
status: DeviceStatus.INACTIVE, status: DeviceStatus.INACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: true,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 130,
implantNotes: 'Seed A1 弃用历史设备',
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
}, },
create: { create: {
snCode: 'SEED-SN-A-004', snCode: 'SEED-SN-A-004',
patientId: patientA1.id, patientId: patientA1.id,
surgeryId: surgeryA1Old.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 130, currentPressure: 130,
status: DeviceStatus.INACTIVE, status: DeviceStatus.INACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: true,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 130,
implantNotes: 'Seed A1 弃用历史设备',
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
}, },
}); });

View File

@ -8,6 +8,7 @@ import { AuthModule } from './auth/auth.module.js';
import { OrganizationModule } from './organization/organization.module.js'; import { OrganizationModule } from './organization/organization.module.js';
import { NotificationsModule } from './notifications/notifications.module.js'; import { NotificationsModule } from './notifications/notifications.module.js';
import { DevicesModule } from './devices/devices.module.js'; import { DevicesModule } from './devices/devices.module.js';
import { DictionariesModule } from './dictionaries/dictionaries.module.js';
@Module({ @Module({
imports: [ imports: [
@ -20,6 +21,7 @@ import { DevicesModule } from './devices/devices.module.js';
OrganizationModule, OrganizationModule,
NotificationsModule, NotificationsModule,
DevicesModule, DevicesModule,
DictionariesModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -6,7 +6,9 @@ import type { ActorContext } from '../common/actor-context.js';
*/ */
export const CurrentActor = createParamDecorator( export const CurrentActor = createParamDecorator(
(_data: unknown, context: ExecutionContext): ActorContext => { (_data: unknown, context: ExecutionContext): ActorContext => {
const request = context.switchToHttp().getRequest<{ actor: ActorContext }>(); const request = context
.switchToHttp()
.getRequest<{ actor: ActorContext }>();
return request.actor; return request.actor;
}, },
); );

View File

@ -29,7 +29,9 @@ export class RolesGuard implements CanActivate {
return true; return true;
} }
const request = context.switchToHttp().getRequest<{ actor?: { role?: Role } }>(); const request = context
.switchToHttp()
.getRequest<{ actor?: { role?: Role } }>();
const actorRole = request.actor?.role; const actorRole = request.actor?.role;
if (!actorRole || !requiredRoles.includes(actorRole)) { if (!actorRole || !requiredRoles.includes(actorRole)) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);

View File

@ -24,10 +24,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
// 非 HttpException 统一记录堆栈,便于定位 500 根因。 // 非 HttpException 统一记录堆栈,便于定位 500 根因。
if (!(exception instanceof HttpException)) { if (!(exception instanceof HttpException)) {
const error = exception as { message?: string; stack?: string }; const error = exception as { message?: string; stack?: string };
this.logger.error( this.logger.error(error?.message ?? 'Unhandled exception', error?.stack);
error?.message ?? 'Unhandled exception',
error?.stack,
);
} }
const status = this.resolveStatus(exception); const status = this.resolveStatus(exception);

View File

@ -89,6 +89,12 @@ export const MESSAGES = {
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号', LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号',
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId', SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息', ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
SURGERY_ITEMS_REQUIRED: '手术下至少需要录入一个植入设备',
SURGERY_NOT_FOUND: '手术记录不存在或无权限访问',
IMPLANT_CATALOG_NOT_FOUND: '植入物型号不存在或不在当前医院可见范围内',
SURGERY_UPDATE_NOT_SUPPORTED:
'患者更新接口不支持直接修改手术,请使用新增手术接口',
ABANDON_DEVICE_SCOPE_FORBIDDEN: '仅可弃用当前患者名下设备',
}, },
DEVICE: { DEVICE: {
@ -102,6 +108,19 @@ export const MESSAGES = {
PATIENT_SCOPE_FORBIDDEN: '仅可绑定当前权限范围内患者', PATIENT_SCOPE_FORBIDDEN: '仅可绑定当前权限范围内患者',
DELETE_CONFLICT: '设备存在关联任务记录,无法删除', DELETE_CONFLICT: '设备存在关联任务记录,无法删除',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息', ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
CATALOG_NOT_FOUND: '植入物型号不存在',
CATALOG_MODEL_DUPLICATE: '植入物型号编码已存在',
CATALOG_SCOPE_FORBIDDEN: '当前角色无权限维护该植入物型号',
CATALOG_DELETE_CONFLICT: '植入物型号已被患者手术引用,无法删除',
PRESSURE_LEVEL_INVALID: '压力值不在该植入物配置的挡位范围内',
DEVICE_NOT_ADJUSTABLE: '仅可调压设备允许创建调压任务',
},
DICTIONARY: {
NOT_FOUND: '字典项不存在',
LABEL_REQUIRED: '字典项名称不能为空',
DUPLICATE: '同类型下字典项名称已存在',
SYSTEM_ADMIN_ONLY_MAINTAIN: '仅系统管理员可维护字典',
}, },
ORG: { ORG: {

View File

@ -0,0 +1,24 @@
import { Transform } from 'class-transformer';
/**
* boolean
*/
export const ToBoolean = () =>
Transform(({ value }) => {
if (value === undefined || value === null || value === '') {
return value;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1') {
return true;
}
if (normalized === 'false' || normalized === '0') {
return false;
}
}
return value;
});

View File

@ -55,12 +55,7 @@ export class DepartmentsController {
* *
*/ */
@Get() @Get()
@Roles( @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '查询科室列表' }) @ApiOperation({ summary: '查询科室列表' })
@ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' }) @ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' })
findAll( findAll(
@ -74,12 +69,7 @@ export class DepartmentsController {
* *
*/ */
@Get(':id') @Get(':id')
@Roles( @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '查询科室详情' }) @ApiOperation({ summary: '查询科室详情' })
@ApiParam({ name: 'id', description: '科室 ID' }) @ApiParam({ name: 'id', description: '科室 ID' })
findOne( findOne(
@ -93,12 +83,7 @@ export class DepartmentsController {
* *
*/ */
@Patch(':id') @Patch(':id')
@Roles( @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '更新科室' }) @ApiOperation({ summary: '更新科室' })
update( update(
@CurrentActor() actor: ActorContext, @CurrentActor() actor: ActorContext,

View File

@ -29,13 +29,19 @@ export class DepartmentsService {
*/ */
async create(actor: ActorContext, dto: CreateDepartmentDto) { async create(actor: ActorContext, dto: CreateDepartmentDto) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]); this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const hospitalId = this.access.toInt(dto.hospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED); const hospitalId = this.access.toInt(
dto.hospitalId,
MESSAGES.ORG.HOSPITAL_ID_REQUIRED,
);
await this.access.ensureHospitalExists(hospitalId); await this.access.ensureHospitalExists(hospitalId);
this.access.assertHospitalScope(actor, hospitalId); this.access.assertHospitalScope(actor, hospitalId);
return this.prisma.department.create({ return this.prisma.department.create({
data: { data: {
name: this.access.normalizeName(dto.name, MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED), name: this.access.normalizeName(
dto.name,
MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED,
),
hospitalId, hospitalId,
}, },
}); });
@ -73,7 +79,10 @@ export class DepartmentsService {
this.prisma.department.count({ where }), this.prisma.department.count({ where }),
this.prisma.department.findMany({ this.prisma.department.findMany({
where, where,
include: { hospital: true, _count: { select: { users: true, groups: true } } }, include: {
hospital: true,
_count: { select: { users: true, groups: true } },
},
skip: paging.skip, skip: paging.skip,
take: paging.take, take: paging.take,
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
@ -93,7 +102,10 @@ export class DepartmentsService {
Role.DIRECTOR, Role.DIRECTOR,
Role.LEADER, Role.LEADER,
]); ]);
const departmentId = this.access.toInt(id, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED); const departmentId = this.access.toInt(
id,
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
);
const department = await this.prisma.department.findUnique({ const department = await this.prisma.department.findUnique({
where: { id: departmentId }, where: { id: departmentId },
include: { include: {
@ -128,7 +140,10 @@ export class DepartmentsService {
} }
if (dto.name !== undefined) { if (dto.name !== undefined) {
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED); data.name = this.access.normalizeName(
dto.name,
MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED,
);
} }
return this.prisma.department.update({ return this.prisma.department.update({

View File

@ -14,6 +14,7 @@ import {
ApiBearerAuth, ApiBearerAuth,
ApiOperation, ApiOperation,
ApiParam, ApiParam,
ApiQuery,
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { AccessTokenGuard } from '../../auth/access-token.guard.js'; import { AccessTokenGuard } from '../../auth/access-token.guard.js';
@ -22,8 +23,10 @@ import { Roles } from '../../auth/roles.decorator.js';
import { RolesGuard } from '../../auth/roles.guard.js'; import { RolesGuard } from '../../auth/roles.guard.js';
import type { ActorContext } from '../../common/actor-context.js'; import type { ActorContext } from '../../common/actor-context.js';
import { Role } from '../../generated/prisma/enums.js'; import { Role } from '../../generated/prisma/enums.js';
import { CreateImplantCatalogDto } from '../dto/create-implant-catalog.dto.js';
import { CreateDeviceDto } from '../dto/create-device.dto.js'; import { CreateDeviceDto } from '../dto/create-device.dto.js';
import { DeviceQueryDto } from '../dto/device-query.dto.js'; import { DeviceQueryDto } from '../dto/device-query.dto.js';
import { UpdateImplantCatalogDto } from '../dto/update-implant-catalog.dto.js';
import { UpdateDeviceDto } from '../dto/update-device.dto.js'; import { UpdateDeviceDto } from '../dto/update-device.dto.js';
import { DevicesService } from '../devices.service.js'; import { DevicesService } from '../devices.service.js';
@ -37,6 +40,73 @@ import { DevicesService } from '../devices.service.js';
export class BDevicesController { export class BDevicesController {
constructor(private readonly devicesService: DevicesService) {} constructor(private readonly devicesService: DevicesService) {}
/**
*
*/
@Get('catalogs')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
Role.ENGINEER,
)
@ApiOperation({ summary: '查询植入物型号字典' })
@ApiQuery({
name: 'keyword',
required: false,
description: '支持按型号、厂家、名称模糊查询',
})
findCatalogs(
@CurrentActor() actor: ActorContext,
@Query('keyword') keyword?: string,
) {
return this.devicesService.findCatalogs(actor, keyword);
}
/**
*
*/
@Post('catalogs')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '新增植入物目录SYSTEM_ADMIN' })
createCatalog(
@CurrentActor() actor: ActorContext,
@Body() dto: CreateImplantCatalogDto,
) {
return this.devicesService.createCatalog(actor, dto);
}
/**
*
*/
@Patch('catalogs/:id')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '更新植入物目录SYSTEM_ADMIN' })
@ApiParam({ name: 'id', description: '型号字典 ID' })
updateCatalog(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateImplantCatalogDto,
) {
return this.devicesService.updateCatalog(actor, id, dto);
}
/**
*
*/
@Delete('catalogs/:id')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '删除植入物目录SYSTEM_ADMIN' })
@ApiParam({ name: 'id', description: '型号字典 ID' })
removeCatalog(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.devicesService.removeCatalog(actor, id);
}
/** /**
* *
*/ */

View File

@ -10,15 +10,30 @@ import { DeviceStatus, Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js'; import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js'; import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js'; import { PrismaService } from '../prisma.service.js';
import { CreateImplantCatalogDto } from './dto/create-implant-catalog.dto.js';
import { CreateDeviceDto } from './dto/create-device.dto.js'; import { CreateDeviceDto } from './dto/create-device.dto.js';
import { DeviceQueryDto } from './dto/device-query.dto.js'; import { DeviceQueryDto } from './dto/device-query.dto.js';
import { UpdateImplantCatalogDto } from './dto/update-implant-catalog.dto.js';
import { UpdateDeviceDto } from './dto/update-device.dto.js'; import { UpdateDeviceDto } from './dto/update-device.dto.js';
const CATALOG_SELECT = {
id: true,
modelCode: true,
manufacturer: true,
name: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
createdAt: true,
updatedAt: true,
} as const;
const DEVICE_DETAIL_INCLUDE = { const DEVICE_DETAIL_INCLUDE = {
patient: { patient: {
select: { select: {
id: true, id: true,
name: true, name: true,
inpatientNo: true,
phone: true, phone: true,
hospitalId: true, hospitalId: true,
hospital: { hospital: {
@ -36,6 +51,17 @@ const DEVICE_DETAIL_INCLUDE = {
}, },
}, },
}, },
surgery: {
select: {
id: true,
surgeryDate: true,
surgeryName: true,
surgeonName: true,
},
},
implantCatalog: {
select: CATALOG_SELECT,
},
_count: { _count: {
select: { select: {
taskItems: true, taskItems: true,
@ -44,7 +70,7 @@ const DEVICE_DETAIL_INCLUDE = {
} as const; } as const;
/** /**
* CRUD * CRUD
*/ */
@Injectable() @Injectable()
export class DevicesService { export class DevicesService {
@ -114,7 +140,8 @@ export class DevicesService {
return this.prisma.device.create({ return this.prisma.device.create({
data: { data: {
snCode, snCode,
currentPressure: this.normalizePressure(dto.currentPressure), // 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。
currentPressure: 0,
status: dto.status ?? DeviceStatus.ACTIVE, status: dto.status ?? DeviceStatus.ACTIVE,
patientId: patient.id, patientId: patient.id,
}, },
@ -123,7 +150,7 @@ export class DevicesService {
} }
/** /**
* SN * SN
*/ */
async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) { async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) {
const current = await this.findOne(actor, id); const current = await this.findOne(actor, id);
@ -134,9 +161,6 @@ export class DevicesService {
await this.assertSnCodeUnique(snCode, current.id); await this.assertSnCodeUnique(snCode, current.id);
data.snCode = snCode; data.snCode = snCode;
} }
if (dto.currentPressure !== undefined) {
data.currentPressure = this.normalizePressure(dto.currentPressure);
}
if (dto.status !== undefined) { if (dto.status !== undefined) {
data.status = this.normalizeStatus(dto.status); data.status = this.normalizeStatus(dto.status);
} }
@ -174,6 +198,142 @@ export class DevicesService {
} }
} }
/**
*
*/
async findCatalogs(actor: ActorContext, keyword?: string) {
this.assertCatalogReadable(actor);
const where = this.buildCatalogWhere(keyword);
return this.prisma.implantCatalog.findMany({
where,
select: CATALOG_SELECT,
orderBy: [{ manufacturer: 'asc' }, { name: 'asc' }, { modelCode: 'asc' }],
});
}
/**
*
*/
async createCatalog(actor: ActorContext, dto: CreateImplantCatalogDto) {
this.assertSystemAdmin(actor);
const isPressureAdjustable = dto.isPressureAdjustable ?? true;
try {
return await this.prisma.implantCatalog.create({
data: {
modelCode: this.normalizeModelCode(dto.modelCode),
manufacturer: this.normalizeRequiredString(
dto.manufacturer,
'manufacturer',
),
name: this.normalizeRequiredString(dto.name, 'name'),
pressureLevels: this.normalizePressureLevels(
dto.pressureLevels,
isPressureAdjustable,
),
isPressureAdjustable,
notes:
dto.notes === undefined
? undefined
: this.normalizeNullableString(dto.notes, 'notes'),
},
select: CATALOG_SELECT,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE);
}
throw error;
}
}
/**
*
*/
async updateCatalog(
actor: ActorContext,
id: number,
dto: UpdateImplantCatalogDto,
) {
this.assertSystemAdmin(actor);
const current = await this.findWritableCatalog(id);
const nextIsPressureAdjustable =
dto.isPressureAdjustable ?? current.isPressureAdjustable;
const data: Prisma.ImplantCatalogUpdateInput = {};
if (dto.modelCode !== undefined) {
data.modelCode = this.normalizeModelCode(dto.modelCode);
}
if (dto.manufacturer !== undefined) {
data.manufacturer = this.normalizeRequiredString(
dto.manufacturer,
'manufacturer',
);
}
if (dto.name !== undefined) {
data.name = this.normalizeRequiredString(dto.name, 'name');
}
if (dto.isPressureAdjustable !== undefined) {
data.isPressureAdjustable = dto.isPressureAdjustable;
}
if (
dto.pressureLevels !== undefined ||
dto.isPressureAdjustable !== undefined
) {
data.pressureLevels = this.normalizePressureLevels(
dto.pressureLevels ?? current.pressureLevels,
nextIsPressureAdjustable,
);
}
if (dto.notes !== undefined) {
data.notes = this.normalizeNullableString(dto.notes, 'notes');
}
try {
return await this.prisma.implantCatalog.update({
where: { id: current.id },
data,
select: CATALOG_SELECT,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE);
}
throw error;
}
}
/**
* 409
*/
async removeCatalog(actor: ActorContext, id: number) {
this.assertSystemAdmin(actor);
const current = await this.findWritableCatalog(id);
try {
return await this.prisma.implantCatalog.delete({
where: { id: current.id },
select: CATALOG_SELECT,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === 'P2003' || error.code === 'P2014')
) {
throw new ConflictException(MESSAGES.DEVICE.CATALOG_DELETE_CONFLICT);
}
throw error;
}
}
/** /**
* *
*/ */
@ -212,6 +372,18 @@ export class DevicesService {
mode: 'insensitive', mode: 'insensitive',
}, },
}, },
{
implantModel: {
contains: keyword,
mode: 'insensitive',
},
},
{
implantName: {
contains: keyword,
mode: 'insensitive',
},
},
{ {
patient: { patient: {
is: { is: {
@ -238,6 +410,47 @@ export class DevicesService {
return andConditions.length > 0 ? { AND: andConditions } : {}; return andConditions.length > 0 ? { AND: andConditions } : {};
} }
/**
*
*/
private buildCatalogWhere(keyword?: string): Prisma.ImplantCatalogWhereInput {
const andConditions: Prisma.ImplantCatalogWhereInput[] = [];
const normalizedKeyword = keyword?.trim();
if (normalizedKeyword) {
andConditions.push({
OR: [
{
modelCode: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
{
manufacturer: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
{
name: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
{
notes: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
],
});
}
return andConditions.length > 0 ? { AND: andConditions } : {};
}
/** /**
* *
*/ */
@ -301,6 +514,23 @@ export class DevicesService {
return patient; return patient;
} }
/**
*
*/
private async findWritableCatalog(id: number) {
const catalogId = this.toInt(id, 'id');
const catalog = await this.prisma.implantCatalog.findUnique({
where: { id: catalogId },
select: CATALOG_SELECT,
});
if (!catalog) {
throw new NotFoundException(MESSAGES.DEVICE.CATALOG_NOT_FOUND);
}
return catalog;
}
/** /**
* / * /
*/ */
@ -315,7 +545,7 @@ export class DevicesService {
} }
/** /**
* *
*/ */
private assertAdmin(actor: ActorContext) { private assertAdmin(actor: ActorContext) {
if ( if (
@ -326,21 +556,98 @@ export class DevicesService {
} }
} }
/**
* B 访
*/
private assertCatalogReadable(actor: ActorContext) {
if (
actor.role === Role.SYSTEM_ADMIN ||
actor.role === Role.HOSPITAL_ADMIN ||
actor.role === Role.DIRECTOR ||
actor.role === Role.LEADER ||
actor.role === Role.DOCTOR ||
actor.role === Role.ENGINEER
) {
return;
}
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
/**
*
*/
private assertSystemAdmin(actor: ActorContext) {
if (actor.role !== Role.SYSTEM_ADMIN) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
}
/**
*
*/
private normalizeModelCode(value: unknown) {
return this.normalizeRequiredString(value, 'modelCode').toUpperCase();
}
/** /**
* SN * SN
*/ */
private normalizeSnCode(value: unknown) { private normalizeSnCode(value: unknown) {
if (typeof value !== 'string') { return this.normalizeRequiredString(value, 'snCode').toUpperCase();
throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED);
} }
const normalized = value.trim().toUpperCase(); private normalizeRequiredString(value: unknown, fieldName: string) {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
}
const normalized = value.trim();
if (!normalized) { if (!normalized) {
throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED); throw new BadRequestException(`${fieldName} 不能为空`);
} }
return normalized; return normalized;
} }
private normalizeNullableString(value: unknown, fieldName: string) {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
}
const normalized = value.trim();
return normalized || null;
}
/**
*
*/
private normalizePressureLevels(
pressureLevels: number[] | undefined,
isPressureAdjustable: boolean,
) {
if (!isPressureAdjustable) {
return [];
}
if (!Array.isArray(pressureLevels) || pressureLevels.length === 0) {
return [];
}
return Array.from(
new Set(
pressureLevels.map((level) => {
const normalized = Number(level);
if (!Number.isInteger(normalized) || normalized < 0) {
throw new BadRequestException(
'pressureLevels 必须为大于等于 0 的整数数组',
);
}
return normalized;
}),
),
).sort((left, right) => left - right);
}
/** /**
* *
*/ */

View File

@ -11,12 +11,6 @@ export class CreateDeviceDto {
@IsString({ message: 'snCode 必须是字符串' }) @IsString({ message: 'snCode 必须是字符串' })
snCode!: string; snCode!: string;
@ApiProperty({ description: '当前压力值', example: 120 })
@Type(() => Number)
@IsInt({ message: 'currentPressure 必须是整数' })
@Min(0, { message: 'currentPressure 必须大于等于 0' })
currentPressure!: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: '设备状态,默认 ACTIVE', description: '设备状态,默认 ACTIVE',
enum: DeviceStatus, enum: DeviceStatus,

View File

@ -0,0 +1,70 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
/**
* DTO
*/
export class CreateImplantCatalogDto {
@ApiProperty({
description: '型号编码',
example: 'CODMAN-HAKIM-120',
})
@IsString({ message: 'modelCode 必须是字符串' })
modelCode!: string;
@ApiProperty({
description: '厂家',
example: 'Codman',
})
@IsString({ message: 'manufacturer 必须是字符串' })
manufacturer!: string;
@ApiProperty({
description: '名称',
example: 'Hakim 可调压阀',
})
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiPropertyOptional({
description: '可调压器械的挡位列表,按整数录入',
type: [Number],
example: [80, 100, 120, 140],
})
@IsOptional()
@IsArray({ message: 'pressureLevels 必须是数组' })
@ArrayMaxSize(30, { message: 'pressureLevels 最多 30 项' })
@Type(() => Number)
@IsInt({ each: true, message: 'pressureLevels 必须为整数数组' })
@Min(0, { each: true, message: 'pressureLevels 必须大于等于 0' })
pressureLevels?: number[];
@ApiPropertyOptional({
description: '是否支持调压,默认 true',
example: true,
})
@IsOptional()
@ToBoolean()
@IsBoolean({ message: 'isPressureAdjustable 必须是布尔值' })
isPressureAdjustable?: boolean;
@ApiPropertyOptional({
description: '植入物备注',
example: '适用于儿童脑积水病例',
})
@IsOptional()
@IsString({ message: 'notes 必须是字符串' })
@MaxLength(200, { message: 'notes 最长 200 个字符' })
notes?: string;
}

View File

@ -0,0 +1,9 @@
import { PartialType } from '@nestjs/swagger';
import { CreateImplantCatalogDto } from './create-implant-catalog.dto.js';
/**
* DTO
*/
export class UpdateImplantCatalogDto extends PartialType(
CreateImplantCatalogDto,
) {}

View File

@ -0,0 +1,112 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { Roles } from '../../auth/roles.decorator.js';
import { RolesGuard } from '../../auth/roles.guard.js';
import type { ActorContext } from '../../common/actor-context.js';
import { Role } from '../../generated/prisma/enums.js';
import { CreateDictionaryItemDto } from '../dto/create-dictionary-item.dto.js';
import { DictionaryQueryDto } from '../dto/dictionary-query.dto.js';
import { UpdateDictionaryItemDto } from '../dto/update-dictionary-item.dto.js';
import { DictionariesService } from '../dictionaries.service.js';
/**
* B
*/
@ApiTags('字典管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/dictionaries')
@UseGuards(AccessTokenGuard, RolesGuard)
export class BDictionariesController {
constructor(private readonly dictionariesService: DictionariesService) {}
/**
*
*/
@Get()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
Role.ENGINEER,
)
@ApiOperation({ summary: '查询系统字典' })
@ApiQuery({
name: 'type',
required: false,
description: '字典类型,不传返回全部类型',
})
@ApiQuery({
name: 'includeDisabled',
required: false,
description: '是否包含停用项,仅系统管理员生效',
})
findAll(
@CurrentActor() actor: ActorContext,
@Query() query: DictionaryQueryDto,
) {
return this.dictionariesService.findAll(actor, query);
}
/**
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '创建系统字典项SYSTEM_ADMIN' })
create(
@CurrentActor() actor: ActorContext,
@Body() dto: CreateDictionaryItemDto,
) {
return this.dictionariesService.create(actor, dto);
}
/**
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '更新系统字典项SYSTEM_ADMIN' })
@ApiParam({ name: 'id', description: '字典项 ID' })
update(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateDictionaryItemDto,
) {
return this.dictionariesService.update(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '删除系统字典项SYSTEM_ADMIN' })
@ApiParam({ name: 'id', description: '字典项 ID' })
remove(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.dictionariesService.remove(actor, id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { BDictionariesController } from './b-dictionaries/b-dictionaries.controller.js';
import { DictionariesService } from './dictionaries.service.js';
@Module({
controllers: [BDictionariesController],
providers: [DictionariesService, AccessTokenGuard, RolesGuard],
exports: [DictionariesService],
})
export class DictionariesModule {}

View File

@ -0,0 +1,156 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../generated/prisma/client.js';
import { Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import { CreateDictionaryItemDto } from './dto/create-dictionary-item.dto.js';
import { DictionaryQueryDto } from './dto/dictionary-query.dto.js';
import { UpdateDictionaryItemDto } from './dto/update-dictionary-item.dto.js';
@Injectable()
export class DictionariesService {
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
async findAll(actor: ActorContext, query: DictionaryQueryDto) {
const where: Prisma.DictionaryItemWhereInput = {};
if (query.type) {
where.type = query.type;
}
// 非系统管理员一律只看启用项,避免业务页面误拿到停用值。
if (actor.role !== Role.SYSTEM_ADMIN || !query.includeDisabled) {
where.enabled = true;
}
return this.prisma.dictionaryItem.findMany({
where,
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
});
}
/**
*
*/
async create(actor: ActorContext, dto: CreateDictionaryItemDto) {
this.assertSystemAdmin(actor);
try {
return await this.prisma.dictionaryItem.create({
data: {
type: dto.type,
label: this.normalizeLabel(dto.label),
sortOrder: dto.sortOrder ?? 0,
enabled: dto.enabled ?? true,
},
});
} catch (error) {
this.handleDuplicate(error);
throw error;
}
}
/**
*
*/
async update(actor: ActorContext, id: number, dto: UpdateDictionaryItemDto) {
this.assertSystemAdmin(actor);
await this.ensureExists(id);
const data: Prisma.DictionaryItemUpdateInput = {};
if (dto.type !== undefined) {
data.type = dto.type;
}
if (dto.label !== undefined) {
data.label = this.normalizeLabel(dto.label);
}
if (dto.sortOrder !== undefined) {
data.sortOrder = dto.sortOrder;
}
if (dto.enabled !== undefined) {
data.enabled = dto.enabled;
}
try {
return await this.prisma.dictionaryItem.update({
where: { id },
data,
});
} catch (error) {
this.handleDuplicate(error);
throw error;
}
}
/**
*
*/
async remove(actor: ActorContext, id: number) {
this.assertSystemAdmin(actor);
await this.ensureExists(id);
return this.prisma.dictionaryItem.delete({
where: { id },
});
}
/**
*
*/
private normalizeLabel(value: string) {
const label = value?.trim();
if (!label) {
throw new BadRequestException(MESSAGES.DICTIONARY.LABEL_REQUIRED);
}
return label;
}
/**
*
*/
private assertSystemAdmin(actor: ActorContext) {
if (actor.role !== Role.SYSTEM_ADMIN) {
throw new ForbiddenException(
MESSAGES.DICTIONARY.SYSTEM_ADMIN_ONLY_MAINTAIN,
);
}
}
/**
*
*/
private async ensureExists(id: number) {
const current = await this.prisma.dictionaryItem.findUnique({
where: { id },
select: { id: true },
});
if (!current) {
throw new NotFoundException(MESSAGES.DICTIONARY.NOT_FOUND);
}
return current;
}
/**
*
*/
private handleDuplicate(error: unknown) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(MESSAGES.DICTIONARY.DUPLICATE);
}
}
}

View File

@ -0,0 +1,55 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
import { DictionaryType } from '../../generated/prisma/enums.js';
/**
* DTO
*/
export class CreateDictionaryItemDto {
@ApiProperty({
description: '字典类型',
enum: DictionaryType,
example: DictionaryType.PRIMARY_DISEASE,
})
@IsEnum(DictionaryType, { message: 'type 枚举值不合法' })
type!: DictionaryType;
@ApiProperty({
description: '字典项名称',
example: '先天性脑积水',
})
@IsString({ message: 'label 必须是字符串' })
label!: string;
@ApiPropertyOptional({
description: '排序值,越小越靠前,默认 0',
example: 10,
default: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'sortOrder 必须是整数' })
@Min(-9999, { message: 'sortOrder 不能小于 -9999' })
@Max(9999, { message: 'sortOrder 不能大于 9999' })
sortOrder?: number;
@ApiPropertyOptional({
description: '是否启用,默认 true',
example: true,
default: true,
})
@IsOptional()
@ToBoolean()
@IsBoolean({ message: 'enabled 必须是布尔值' })
enabled?: boolean;
}

View File

@ -0,0 +1,30 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsOptional } from 'class-validator';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
import { DictionaryType } from '../../generated/prisma/enums.js';
/**
* DTO
*/
export class DictionaryQueryDto {
@ApiPropertyOptional({
description: '字典类型,不传返回全部类型',
enum: DictionaryType,
example: DictionaryType.PRIMARY_DISEASE,
})
@IsOptional()
@EmptyStringToUndefined()
@IsEnum(DictionaryType, { message: 'type 枚举值不合法' })
type?: DictionaryType;
@ApiPropertyOptional({
description: '是否包含停用项,仅系统管理员生效',
example: true,
})
@IsOptional()
@EmptyStringToUndefined()
@ToBoolean()
@IsBoolean({ message: 'includeDisabled 必须是布尔值' })
includeDisabled?: boolean;
}

View File

@ -0,0 +1,9 @@
import { PartialType } from '@nestjs/swagger';
import { CreateDictionaryItemDto } from './create-dictionary-item.dto.js';
/**
* DTO
*/
export class UpdateDictionaryItemDto extends PartialType(
CreateDictionaryItemDto,
) {}

View File

@ -43,10 +43,7 @@ export class GroupsController {
@Post() @Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
@ApiOperation({ summary: '创建小组' }) @ApiOperation({ summary: '创建小组' })
create( create(@CurrentActor() actor: ActorContext, @Body() dto: CreateGroupDto) {
@CurrentActor() actor: ActorContext,
@Body() dto: CreateGroupDto,
) {
return this.groupsService.create(actor, dto); return this.groupsService.create(actor, dto);
} }
@ -54,12 +51,7 @@ export class GroupsController {
* *
*/ */
@Get() @Get()
@Roles( @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '查询小组列表' }) @ApiOperation({ summary: '查询小组列表' })
findAll( findAll(
@CurrentActor() actor: ActorContext, @CurrentActor() actor: ActorContext,
@ -72,12 +64,7 @@ export class GroupsController {
* *
*/ */
@Get(':id') @Get(':id')
@Roles( @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '查询小组详情' }) @ApiOperation({ summary: '查询小组详情' })
@ApiParam({ name: 'id', description: '小组 ID' }) @ApiParam({ name: 'id', description: '小组 ID' })
findOne( findOne(
@ -91,12 +78,7 @@ export class GroupsController {
* *
*/ */
@Patch(':id') @Patch(':id')
@Roles( @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '更新小组' }) @ApiOperation({ summary: '更新小组' })
update( update(
@CurrentActor() actor: ActorContext, @CurrentActor() actor: ActorContext,

View File

@ -10,7 +10,12 @@ import { OrganizationAccessService } from '../organization-common/organization-a
*/ */
@Module({ @Module({
controllers: [GroupsController], controllers: [GroupsController],
providers: [GroupsService, OrganizationAccessService, AccessTokenGuard, RolesGuard], providers: [
GroupsService,
OrganizationAccessService,
AccessTokenGuard,
RolesGuard,
],
exports: [GroupsService], exports: [GroupsService],
}) })
export class GroupsModule {} export class GroupsModule {}

View File

@ -43,10 +43,7 @@ export class HospitalsController {
@Post() @Post()
@Roles(Role.SYSTEM_ADMIN) @Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '创建医院SYSTEM_ADMIN' }) @ApiOperation({ summary: '创建医院SYSTEM_ADMIN' })
create( create(@CurrentActor() actor: ActorContext, @Body() dto: CreateHospitalDto) {
@CurrentActor() actor: ActorContext,
@Body() dto: CreateHospitalDto,
) {
return this.hospitalsService.create(actor, dto); return this.hospitalsService.create(actor, dto);
} }
@ -54,12 +51,7 @@ export class HospitalsController {
* *
*/ */
@Get() @Get()
@Roles( @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '查询医院列表' }) @ApiOperation({ summary: '查询医院列表' })
findAll( findAll(
@CurrentActor() actor: ActorContext, @CurrentActor() actor: ActorContext,
@ -72,12 +64,7 @@ export class HospitalsController {
* *
*/ */
@Get(':id') @Get(':id')
@Roles( @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '查询医院详情' }) @ApiOperation({ summary: '查询医院详情' })
@ApiParam({ name: 'id', description: '医院 ID' }) @ApiParam({ name: 'id', description: '医院 ID' })
findOne( findOne(

View File

@ -23,10 +23,16 @@ export class HospitalsService {
* *
*/ */
async create(actor: ActorContext, dto: CreateHospitalDto) { async create(actor: ActorContext, dto: CreateHospitalDto) {
this.access.assertSystemAdmin(actor, MESSAGES.ORG.SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL); this.access.assertSystemAdmin(
actor,
MESSAGES.ORG.SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL,
);
return this.prisma.hospital.create({ return this.prisma.hospital.create({
data: { data: {
name: this.access.normalizeName(dto.name, MESSAGES.ORG.HOSPITAL_NAME_REQUIRED), name: this.access.normalizeName(
dto.name,
MESSAGES.ORG.HOSPITAL_NAME_REQUIRED,
),
}, },
}); });
} }
@ -79,7 +85,12 @@ export class HospitalsService {
where: { id: hospitalId }, where: { id: hospitalId },
include: { include: {
_count: { _count: {
select: { departments: true, users: true, patients: true, tasks: true }, select: {
departments: true,
users: true,
patients: true,
tasks: true,
},
}, },
}, },
}); });
@ -97,7 +108,10 @@ export class HospitalsService {
const current = await this.findOne(actor, id); const current = await this.findOne(actor, id);
const data: Prisma.HospitalUpdateInput = {}; const data: Prisma.HospitalUpdateInput = {};
if (dto.name !== undefined) { if (dto.name !== undefined) {
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.HOSPITAL_NAME_REQUIRED); data.name = this.access.normalizeName(
dto.name,
MESSAGES.ORG.HOSPITAL_NAME_REQUIRED,
);
} }
return this.prisma.hospital.update({ return this.prisma.hospital.update({
where: { id: current.id }, where: { id: current.id },
@ -109,7 +123,10 @@ export class HospitalsService {
* *
*/ */
async remove(actor: ActorContext, id: number) { async remove(actor: ActorContext, id: number) {
this.access.assertSystemAdmin(actor, MESSAGES.ORG.SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL); this.access.assertSystemAdmin(
actor,
MESSAGES.ORG.SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL,
);
const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED); const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
await this.access.ensureHospitalExists(hospitalId); await this.access.ensureHospitalExists(hospitalId);

View File

@ -7,7 +7,10 @@ import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
* DTO// * DTO//
*/ */
export class OrganizationQueryDto { export class OrganizationQueryDto {
@ApiPropertyOptional({ description: '关键词(按名称模糊匹配)', example: '神经' }) @ApiPropertyOptional({
description: '关键词(按名称模糊匹配)',
example: '神经',
})
@IsOptional() @IsOptional()
@IsString({ message: 'keyword 必须是字符串' }) @IsString({ message: 'keyword 必须是字符串' })
keyword?: string; keyword?: string;
@ -28,7 +31,11 @@ export class OrganizationQueryDto {
@Min(1, { message: 'departmentId 必须大于 0' }) @Min(1, { message: 'departmentId 必须大于 0' })
departmentId?: number; departmentId?: number;
@ApiPropertyOptional({ description: '页码(默认 1', example: 1, default: 1 }) @ApiPropertyOptional({
description: '页码(默认 1',
example: 1,
default: 1,
})
@IsOptional() @IsOptional()
@EmptyStringToUndefined() @EmptyStringToUndefined()
@Type(() => Number) @Type(() => Number)

View File

@ -66,9 +66,9 @@ export class OrganizationAccessService {
*/ */
requireActorHospitalId(actor: ActorContext): number { requireActorHospitalId(actor: ActorContext): number {
if ( if (
typeof actor.hospitalId !== 'number' typeof actor.hospitalId !== 'number' ||
|| !Number.isInteger(actor.hospitalId) !Number.isInteger(actor.hospitalId) ||
|| actor.hospitalId <= 0 actor.hospitalId <= 0
) { ) {
throw new BadRequestException(MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED); throw new BadRequestException(MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED);
} }
@ -80,9 +80,9 @@ export class OrganizationAccessService {
*/ */
requireActorDepartmentId(actor: ActorContext): number { requireActorDepartmentId(actor: ActorContext): number {
if ( if (
typeof actor.departmentId !== 'number' typeof actor.departmentId !== 'number' ||
|| !Number.isInteger(actor.departmentId) !Number.isInteger(actor.departmentId) ||
|| actor.departmentId <= 0 actor.departmentId <= 0
) { ) {
throw new BadRequestException(MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED); throw new BadRequestException(MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED);
} }
@ -94,9 +94,9 @@ export class OrganizationAccessService {
*/ */
requireActorGroupId(actor: ActorContext): number { requireActorGroupId(actor: ActorContext): number {
if ( if (
typeof actor.groupId !== 'number' typeof actor.groupId !== 'number' ||
|| !Number.isInteger(actor.groupId) !Number.isInteger(actor.groupId) ||
|| actor.groupId <= 0 actor.groupId <= 0
) { ) {
throw new BadRequestException(MESSAGES.ORG.ACTOR_GROUP_REQUIRED); throw new BadRequestException(MESSAGES.ORG.ACTOR_GROUP_REQUIRED);
} }

View File

@ -25,6 +25,7 @@ import { Roles } from '../../auth/roles.decorator.js';
import { Role } from '../../generated/prisma/enums.js'; import { Role } from '../../generated/prisma/enums.js';
import { BPatientsService } from './b-patients.service.js'; import { BPatientsService } from './b-patients.service.js';
import { CreatePatientDto } from '../dto/create-patient.dto.js'; import { CreatePatientDto } from '../dto/create-patient.dto.js';
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
import { UpdatePatientDto } from '../dto/update-patient.dto.js'; import { UpdatePatientDto } from '../dto/update-patient.dto.js';
/** /**
@ -102,10 +103,34 @@ export class BPatientsController {
Role.DOCTOR, Role.DOCTOR,
) )
@ApiOperation({ summary: '创建患者' }) @ApiOperation({ summary: '创建患者' })
createPatient(@CurrentActor() actor: ActorContext, @Body() dto: CreatePatientDto) { createPatient(
@CurrentActor() actor: ActorContext,
@Body() dto: CreatePatientDto,
) {
return this.patientsService.createPatient(actor, dto); return this.patientsService.createPatient(actor, dto);
} }
/**
*
*/
@Post(':id/surgeries')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '为患者新增手术记录' })
@ApiParam({ name: 'id', description: '患者 ID' })
createPatientSurgery(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: CreatePatientSurgeryDto,
) {
return this.patientsService.createPatientSurgery(actor, id, dto);
}
/** /**
* *
*/ */

View File

@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
@ -6,18 +7,118 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '../../generated/prisma/client.js'; import { Prisma } from '../../generated/prisma/client.js';
import { Role } from '../../generated/prisma/enums.js'; import { DeviceStatus, Role } from '../../generated/prisma/enums.js';
import { PrismaService } from '../../prisma.service.js'; import { PrismaService } from '../../prisma.service.js';
import type { ActorContext } from '../../common/actor-context.js'; import type { ActorContext } from '../../common/actor-context.js';
import { MESSAGES } from '../../common/messages.js'; import { MESSAGES } from '../../common/messages.js';
import { CreatePatientDto } from '../dto/create-patient.dto.js'; import { CreatePatientDto } from '../dto/create-patient.dto.js';
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
import { UpdatePatientDto } from '../dto/update-patient.dto.js'; import { UpdatePatientDto } from '../dto/update-patient.dto.js';
import { normalizePatientIdCard } from '../patient-id-card.util.js'; import { normalizePatientIdCard } from '../patient-id-card.util.js';
type PrismaExecutor = Prisma.TransactionClient | PrismaService;
const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]; const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER];
const IMPLANT_CATALOG_SELECT = {
id: true,
modelCode: true,
manufacturer: true,
name: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
} as const;
const PATIENT_LIST_INCLUDE = {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: {
select: {
id: true,
snCode: true,
status: true,
currentPressure: true,
isAbandoned: true,
implantModel: true,
implantManufacturer: true,
implantName: true,
isPressureAdjustable: true,
},
orderBy: { id: 'desc' },
},
surgeries: {
select: {
id: true,
surgeryDate: true,
surgeryName: true,
surgeonName: true,
},
orderBy: { surgeryDate: 'desc' },
take: 1,
},
_count: {
select: {
surgeries: true,
},
},
} as const;
const PATIENT_DETAIL_INCLUDE = {
hospital: { select: { id: true, name: true } },
doctor: {
select: {
id: true,
name: true,
role: true,
hospitalId: true,
departmentId: true,
groupId: true,
},
},
devices: {
include: {
implantCatalog: {
select: IMPLANT_CATALOG_SELECT,
},
surgery: {
select: {
id: true,
surgeryDate: true,
surgeryName: true,
},
},
},
orderBy: { id: 'desc' },
},
surgeries: {
include: {
devices: {
include: {
implantCatalog: {
select: IMPLANT_CATALOG_SELECT,
},
},
orderBy: { id: 'desc' },
},
},
orderBy: { surgeryDate: 'desc' },
},
} as const;
const PATIENT_SURGERY_DETAIL_INCLUDE = {
devices: {
include: {
implantCatalog: {
select: IMPLANT_CATALOG_SELECT,
},
},
orderBy: { id: 'desc' },
},
} as const;
/** /**
* B CRUD * B CRUD
*/ */
@Injectable() @Injectable()
export class BPatientsService { export class BPatientsService {
@ -30,15 +131,27 @@ export class BPatientsService {
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId); const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
const where = this.buildVisiblePatientWhere(actor, hospitalId); const where = this.buildVisiblePatientWhere(actor, hospitalId);
return this.prisma.patient.findMany({ const patients = await this.prisma.patient.findMany({
where, where,
include: { include: PATIENT_LIST_INCLUDE,
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
}); });
return patients.map((patient) => {
const { _count, surgeries, ...rest } = patient;
return {
...rest,
shuntSurgeryCount: _count.surgeries,
latestSurgery: surgeries[0] ?? null,
activeDeviceCount: patient.devices.filter(
(device) =>
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
).length,
abandonedDeviceCount: patient.devices.filter(
(device) => device.isAbandoned,
).length,
};
});
} }
/** /**
@ -90,25 +203,73 @@ export class BPatientsService {
} }
/** /**
* *
*/ */
async createPatient(actor: ActorContext, dto: CreatePatientDto) { async createPatient(actor: ActorContext, dto: CreatePatientDto) {
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId); const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
return this.prisma.patient.create({ return this.prisma.$transaction(async (tx) => {
const patient = await tx.patient.create({
data: { data: {
name: this.normalizeRequiredString(dto.name, 'name'), name: this.normalizeRequiredString(dto.name, 'name'),
inpatientNo:
dto.inpatientNo === undefined
? undefined
: this.normalizeNullableString(dto.inpatientNo, 'inpatientNo'),
projectName:
dto.projectName === undefined
? undefined
: this.normalizeNullableString(dto.projectName, 'projectName'),
phone: this.normalizePhone(dto.phone), phone: this.normalizePhone(dto.phone),
// 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。 // 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。
idCard: this.normalizeIdCard(dto.idCard), idCard: this.normalizeIdCard(dto.idCard),
hospitalId: doctor.hospitalId!, hospitalId: doctor.hospitalId!,
doctorId: doctor.id, doctorId: doctor.id,
}, },
include: { });
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } }, if (dto.initialSurgery) {
devices: true, await this.createPatientSurgeryRecord(
}, tx,
patient.id,
dto.initialSurgery,
);
}
const detail = await this.loadPatientDetail(tx, patient.id);
return this.decoratePatientDetail(detail);
});
}
/**
*
*/
async createPatientSurgery(
actor: ActorContext,
patientId: number,
dto: CreatePatientSurgeryDto,
) {
const patient = await this.findPatientWithScope(patientId);
this.assertPatientScope(actor, patient);
return this.prisma.$transaction(async (tx) => {
const createdSurgery = await this.createPatientSurgeryRecord(
tx,
patient.id,
dto,
);
const detail = await this.loadPatientDetail(tx, patient.id);
const decoratedPatient = this.decoratePatientDetail(detail);
const created = decoratedPatient.surgeries.find(
(surgery) => surgery.id === createdSurgery.id,
);
if (!created) {
throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND);
}
return created;
}); });
} }
@ -118,20 +279,38 @@ export class BPatientsService {
async findPatientById(actor: ActorContext, id: number) { async findPatientById(actor: ActorContext, id: number) {
const patient = await this.findPatientWithScope(id); const patient = await this.findPatientWithScope(id);
this.assertPatientScope(actor, patient); this.assertPatientScope(actor, patient);
return patient; return this.decoratePatientDetail(patient);
} }
/** /**
* *
*/ */
async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) { async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) {
const patient = await this.findPatientWithScope(id); const patient = await this.findPatientWithScope(id);
this.assertPatientScope(actor, patient); this.assertPatientScope(actor, patient);
if (dto.initialSurgery !== undefined) {
throw new BadRequestException(
MESSAGES.PATIENT.SURGERY_UPDATE_NOT_SUPPORTED,
);
}
const data: Prisma.PatientUpdateInput = {}; const data: Prisma.PatientUpdateInput = {};
if (dto.name !== undefined) { if (dto.name !== undefined) {
data.name = this.normalizeRequiredString(dto.name, 'name'); data.name = this.normalizeRequiredString(dto.name, 'name');
} }
if (dto.inpatientNo !== undefined) {
data.inpatientNo = this.normalizeNullableString(
dto.inpatientNo,
'inpatientNo',
);
}
if (dto.projectName !== undefined) {
data.projectName = this.normalizeNullableString(
dto.projectName,
'projectName',
);
}
if (dto.phone !== undefined) { if (dto.phone !== undefined) {
data.phone = this.normalizePhone(dto.phone); data.phone = this.normalizePhone(dto.phone);
} }
@ -145,15 +324,13 @@ export class BPatientsService {
data.hospital = { connect: { id: doctor.hospitalId! } }; data.hospital = { connect: { id: doctor.hospitalId! } };
} }
return this.prisma.patient.update({ const updated = await this.prisma.patient.update({
where: { id: patient.id }, where: { id: patient.id },
data, data,
include: {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
}); });
const detail = await this.loadPatientDetail(this.prisma, updated.id);
return this.decoratePatientDetail(detail);
} }
/** /**
@ -164,14 +341,11 @@ export class BPatientsService {
this.assertPatientScope(actor, patient); this.assertPatientScope(actor, patient);
try { try {
return await this.prisma.patient.delete({ const deleted = await this.prisma.patient.delete({
where: { id: patient.id }, where: { id: patient.id },
include: { include: PATIENT_DETAIL_INCLUDE,
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
}); });
return this.decoratePatientDetail(deleted);
} catch (error) { } catch (error) {
if ( if (
error instanceof Prisma.PrismaClientKnownRequestError && error instanceof Prisma.PrismaClientKnownRequestError &&
@ -192,22 +366,16 @@ export class BPatientsService {
throw new BadRequestException('id 必须为整数'); throw new BadRequestException('id 必须为整数');
} }
const patient = await this.prisma.patient.findUnique({ return this.loadPatientDetail(this.prisma, patientId);
}
/**
*
*/
private async loadPatientDetail(prisma: PrismaExecutor, patientId: number) {
const patient = await prisma.patient.findUnique({
where: { id: patientId }, where: { id: patientId },
include: { include: PATIENT_DETAIL_INCLUDE,
hospital: { select: { id: true, name: true } },
doctor: {
select: {
id: true,
name: true,
role: true,
hospitalId: true,
departmentId: true,
groupId: true,
},
},
devices: true,
},
}); });
if (!patient) { if (!patient) {
@ -380,6 +548,304 @@ export class BPatientsService {
return actor.hospitalId; return actor.hospitalId;
} }
/**
*
*/
private async createPatientSurgeryRecord(
prisma: PrismaExecutor,
patientId: number,
dto: CreatePatientSurgeryDto,
) {
if (!Array.isArray(dto.devices) || dto.devices.length === 0) {
throw new BadRequestException(MESSAGES.PATIENT.SURGERY_ITEMS_REQUIRED);
}
const catalogIds = Array.from(
new Set(
dto.devices.map((device) =>
this.toInt(device.implantCatalogId, 'implantCatalogId'),
),
),
);
const abandonedDeviceIds = Array.from(
new Set(dto.abandonedDeviceIds ?? []),
);
const [catalogMap, latestSurgery] = await Promise.all([
this.resolveImplantCatalogMap(prisma, catalogIds),
prisma.patientSurgery.findFirst({
where: { patientId },
orderBy: { surgeryDate: 'desc' },
select: { surgeryDate: true },
}),
]);
if (abandonedDeviceIds.length > 0) {
const devices = await prisma.device.findMany({
where: {
id: { in: abandonedDeviceIds },
patientId,
},
select: { id: true },
});
if (devices.length !== abandonedDeviceIds.length) {
throw new ForbiddenException(
MESSAGES.PATIENT.ABANDON_DEVICE_SCOPE_FORBIDDEN,
);
}
}
const deviceDrafts = dto.devices.map((device, index) => {
const catalog = catalogMap.get(device.implantCatalogId);
if (!catalog) {
throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND);
}
const initialPressure =
device.initialPressure == null
? null
: this.assertPressureLevelAllowed(
catalog,
this.normalizeNonNegativeInteger(
device.initialPressure,
'initialPressure',
),
);
const fallbackPressureLevel =
catalog.isPressureAdjustable && catalog.pressureLevels.length > 0
? catalog.pressureLevels[0]
: 0;
const currentPressure = catalog.isPressureAdjustable
? this.assertPressureLevelAllowed(
catalog,
initialPressure ?? fallbackPressureLevel,
)
: 0;
return {
patient: { connect: { id: patientId } },
implantCatalog: { connect: { id: catalog.id } },
snCode: this.resolveDeviceSnCode(device.snCode, patientId, index),
currentPressure,
status: DeviceStatus.ACTIVE,
implantModel: catalog.modelCode,
implantManufacturer: catalog.manufacturer,
implantName: catalog.name,
isPressureAdjustable: catalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: this.normalizeRequiredString(device.shuntMode, 'shuntMode'),
proximalPunctureAreas: this.normalizeStringArray(
device.proximalPunctureAreas,
'proximalPunctureAreas',
),
valvePlacementSites: this.normalizeStringArray(
device.valvePlacementSites,
'valvePlacementSites',
),
distalShuntDirection: this.normalizeRequiredString(
device.distalShuntDirection,
'distalShuntDirection',
),
initialPressure,
implantNotes:
device.implantNotes === undefined
? undefined
: this.normalizeNullableString(device.implantNotes, 'implantNotes'),
labelImageUrl:
device.labelImageUrl === undefined
? undefined
: this.normalizeNullableString(
device.labelImageUrl,
'labelImageUrl',
),
};
});
await this.assertSnCodesUnique(
prisma,
deviceDrafts.map((device) => device.snCode),
);
const surgery = await prisma.patientSurgery.create({
data: {
patientId,
surgeryDate: this.normalizeIsoDate(dto.surgeryDate, 'surgeryDate'),
surgeryName: this.normalizeRequiredString(
dto.surgeryName,
'surgeryName',
),
surgeonName: this.normalizeRequiredString(
dto.surgeonName,
'surgeonName',
),
preOpPressure:
dto.preOpPressure == null
? null
: this.normalizeNonNegativeInteger(
dto.preOpPressure,
'preOpPressure',
),
primaryDisease: this.normalizeRequiredString(
dto.primaryDisease,
'primaryDisease',
),
hydrocephalusTypes: this.normalizeStringArray(
dto.hydrocephalusTypes,
'hydrocephalusTypes',
),
previousShuntSurgeryDate: dto.previousShuntSurgeryDate
? this.normalizeIsoDate(
dto.previousShuntSurgeryDate,
'previousShuntSurgeryDate',
)
: (latestSurgery?.surgeryDate ?? null),
preOpMaterials:
dto.preOpMaterials == null
? undefined
: this.normalizePreOpMaterials(dto.preOpMaterials),
notes:
dto.notes === undefined
? undefined
: this.normalizeNullableString(dto.notes, 'notes'),
devices: {
create: deviceDrafts,
},
},
include: PATIENT_SURGERY_DETAIL_INCLUDE,
});
if (abandonedDeviceIds.length > 0) {
await prisma.device.updateMany({
where: {
id: { in: abandonedDeviceIds },
patientId,
},
data: {
isAbandoned: true,
status: DeviceStatus.INACTIVE,
},
});
}
return surgery;
}
/**
*
*/
private async resolveImplantCatalogMap(
prisma: PrismaExecutor,
implantCatalogIds: number[],
) {
if (implantCatalogIds.length === 0) {
return new Map<
number,
Awaited<ReturnType<typeof prisma.implantCatalog.findFirst>>
>();
}
const catalogs = await prisma.implantCatalog.findMany({
where: {
id: { in: implantCatalogIds },
},
select: IMPLANT_CATALOG_SELECT,
});
if (catalogs.length !== implantCatalogIds.length) {
throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND);
}
return new Map(catalogs.map((catalog) => [catalog.id, catalog]));
}
/**
*
*/
private assertPressureLevelAllowed(
catalog: {
isPressureAdjustable: boolean;
pressureLevels: number[];
},
pressure: number,
) {
if (
catalog.isPressureAdjustable &&
Array.isArray(catalog.pressureLevels) &&
catalog.pressureLevels.length > 0 &&
!catalog.pressureLevels.includes(pressure)
) {
throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID);
}
return pressure;
}
/**
*
*/
private decoratePatientDetail(
patient: Awaited<ReturnType<BPatientsService['findPatientWithScope']>>,
) {
const surgeries = this.decorateSurgeries(patient.surgeries);
return {
...patient,
surgeries,
shuntSurgeryCount: surgeries.length,
latestSurgery: surgeries[0] ?? null,
activeDeviceCount: patient.devices.filter(
(device) =>
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
).length,
abandonedDeviceCount: patient.devices.filter(
(device) => device.isAbandoned,
).length,
};
}
/**
*
*/
private decorateSurgeries<
TSurgery extends {
id: number;
surgeryDate: Date;
devices: Array<{
id: number;
status: DeviceStatus;
isAbandoned: boolean;
}>;
},
>(surgeries: TSurgery[]) {
const sortedAsc = [...surgeries].sort(
(left, right) =>
new Date(left.surgeryDate).getTime() -
new Date(right.surgeryDate).getTime(),
);
const sequenceById = new Map(
sortedAsc.map((surgery, index) => [surgery.id, index + 1] as const),
);
return [...surgeries]
.sort(
(left, right) =>
new Date(right.surgeryDate).getTime() -
new Date(left.surgeryDate).getTime(),
)
.map((surgery) => ({
...surgery,
shuntSurgeryCount: sequenceById.get(surgery.id) ?? surgeries.length,
activeDeviceCount: surgery.devices.filter(
(device) =>
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
).length,
abandonedDeviceCount: surgery.devices.filter(
(device) => device.isAbandoned,
).length,
}));
}
private normalizeRequiredString(value: unknown, fieldName: string) { private normalizeRequiredString(value: unknown, fieldName: string) {
if (typeof value !== 'string') { if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`); throw new BadRequestException(`${fieldName} 必须是字符串`);
@ -391,6 +857,14 @@ export class BPatientsService {
return trimmed; return trimmed;
} }
private normalizeNullableString(value: unknown, fieldName: string) {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
}
const trimmed = value.trim();
return trimmed || null;
}
private normalizePhone(phone: unknown) { private normalizePhone(phone: unknown) {
const normalized = this.normalizeRequiredString(phone, 'phone'); const normalized = this.normalizeRequiredString(phone, 'phone');
if (!/^1\d{10}$/.test(normalized)) { if (!/^1\d{10}$/.test(normalized)) {
@ -406,4 +880,91 @@ export class BPatientsService {
const normalized = this.normalizeRequiredString(value, 'idCard'); const normalized = this.normalizeRequiredString(value, 'idCard');
return normalizePatientIdCard(normalized); return normalizePatientIdCard(normalized);
} }
private normalizeIsoDate(value: unknown, fieldName: string) {
const normalized = this.normalizeRequiredString(value, fieldName);
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException(`${fieldName} 必须是合法日期`);
}
return parsed;
}
private normalizeNonNegativeInteger(value: unknown, fieldName: string) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new BadRequestException(`${fieldName} 必须是大于等于 0 的整数`);
}
return parsed;
}
private normalizeStringArray(value: unknown, fieldName: string) {
if (!Array.isArray(value) || value.length === 0) {
throw new BadRequestException(`${fieldName} 必须为非空数组`);
}
return Array.from(
new Set(
value.map((item) => this.normalizeRequiredString(item, fieldName)),
),
);
}
private normalizePreOpMaterials(
materials: CreatePatientSurgeryDto['preOpMaterials'],
): Prisma.InputJsonArray {
if (!Array.isArray(materials)) {
throw new BadRequestException('preOpMaterials 必须是数组');
}
return materials.map((material) => ({
type: this.normalizeRequiredString(material.type, 'type'),
url: this.normalizeRequiredString(material.url, 'url'),
name:
material.name === undefined
? null
: this.normalizeNullableString(material.name, 'name'),
})) as Prisma.InputJsonArray;
}
private resolveDeviceSnCode(
snCode: string | undefined,
patientId: number,
index: number,
) {
if (snCode) {
return this.normalizeRequiredString(snCode, 'snCode').toUpperCase();
}
return `SURG-${patientId}-${Date.now()}-${index + 1}-${randomUUID()
.slice(0, 8)
.toUpperCase()}`;
}
private async assertSnCodesUnique(prisma: PrismaExecutor, snCodes: string[]) {
const uniqueSnCodes = Array.from(new Set(snCodes));
if (uniqueSnCodes.length !== snCodes.length) {
throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE);
}
const existing = await prisma.device.findMany({
where: {
snCode: { in: uniqueSnCodes },
},
select: { id: true },
take: 1,
});
if (existing.length > 0) {
throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE);
}
}
private toInt(value: unknown, fieldName: string) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new BadRequestException(`${fieldName} 必须为正整数`);
}
return parsed;
}
} }

View File

@ -32,8 +32,46 @@ export class CPatientsService {
}, },
include: { include: {
hospital: { select: { id: true, name: true } }, hospital: { select: { id: true, name: true } },
surgeries: {
include: {
devices: { devices: {
include: { include: {
implantCatalog: {
select: {
id: true,
modelCode: true,
manufacturer: true,
name: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
},
},
},
},
},
orderBy: { surgeryDate: 'desc' },
},
devices: {
include: {
surgery: {
select: {
id: true,
surgeryDate: true,
surgeryName: true,
},
},
implantCatalog: {
select: {
id: true,
modelCode: true,
manufacturer: true,
name: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
},
},
taskItems: { taskItems: {
include: { include: {
task: true, task: true,
@ -48,8 +86,49 @@ export class CPatientsService {
} }
const lifecycle = patients const lifecycle = patients
.flatMap((patient) => .flatMap((patient) => {
patient.devices.flatMap((device) => const surgeryEvents = patient.surgeries.map(
(surgery, index, surgeries) => ({
eventType: 'SURGERY',
occurredAt: surgery.surgeryDate,
hospital: patient.hospital,
patient: {
id: this.toJsonNumber(patient.id),
name: patient.name,
inpatientNo: patient.inpatientNo,
projectName: patient.projectName,
},
surgery: {
id: this.toJsonNumber(surgery.id),
surgeryDate: surgery.surgeryDate,
surgeryName: surgery.surgeryName,
surgeonName: surgery.surgeonName,
primaryDisease: surgery.primaryDisease,
hydrocephalusTypes: surgery.hydrocephalusTypes,
previousShuntSurgeryDate: surgery.previousShuntSurgeryDate,
shuntSurgeryCount: surgeries.length - index,
},
devices: surgery.devices.map((device) => ({
id: this.toJsonNumber(device.id),
snCode: device.snCode,
status: device.status,
isAbandoned: device.isAbandoned,
currentPressure: this.toJsonNumber(device.currentPressure),
initialPressure: this.toJsonNumber(device.initialPressure),
implantModel: device.implantModel,
implantManufacturer: device.implantManufacturer,
implantName: device.implantName,
isPressureAdjustable: device.isPressureAdjustable,
shuntMode: device.shuntMode,
distalShuntDirection: device.distalShuntDirection,
proximalPunctureAreas: device.proximalPunctureAreas,
valvePlacementSites: device.valvePlacementSites,
implantCatalog: device.implantCatalog,
})),
}),
);
const taskEvents = patient.devices.flatMap((device) =>
device.taskItems.flatMap((taskItem) => { device.taskItems.flatMap((taskItem) => {
// 容错:若存在脏数据导致 task 为空,直接跳过该条明细,避免接口 500。 // 容错:若存在脏数据导致 task 为空,直接跳过该条明细,避免接口 500。
if (!taskItem.task) { if (!taskItem.task) {
@ -65,13 +144,27 @@ export class CPatientsService {
patient: { patient: {
id: this.toJsonNumber(patient.id), id: this.toJsonNumber(patient.id),
name: patient.name, name: patient.name,
inpatientNo: patient.inpatientNo,
projectName: patient.projectName,
}, },
device: { device: {
id: this.toJsonNumber(device.id), id: this.toJsonNumber(device.id),
snCode: device.snCode, snCode: device.snCode,
status: device.status, status: device.status,
isAbandoned: device.isAbandoned,
currentPressure: this.toJsonNumber(device.currentPressure), currentPressure: this.toJsonNumber(device.currentPressure),
implantModel: device.implantModel,
implantManufacturer: device.implantManufacturer,
implantName: device.implantName,
isPressureAdjustable: device.isPressureAdjustable,
}, },
surgery: device.surgery
? {
id: this.toJsonNumber(device.surgery.id),
surgeryDate: device.surgery.surgeryDate,
surgeryName: device.surgery.surgeryName,
}
: null,
task: { task: {
id: this.toJsonNumber(task.id), id: this.toJsonNumber(task.id),
status: task.status, status: task.status,
@ -85,8 +178,10 @@ export class CPatientsService {
}, },
]; ];
}), }),
), );
)
return [...surgeryEvents, ...taskEvents];
})
.sort( .sort(
(a, b) => (a, b) =>
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),

View File

@ -0,0 +1,116 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
ArrayUnique,
IsArray,
IsISO8601,
IsInt,
IsOptional,
IsString,
Min,
ValidateNested,
} from 'class-validator';
import { CreateSurgeryDeviceDto } from './create-surgery-device.dto.js';
import { SurgeryMaterialDto } from './surgery-material.dto.js';
/**
* DTO
*/
export class CreatePatientSurgeryDto {
@ApiProperty({
description: '手术日期',
example: '2026-03-19T08:00:00.000Z',
})
@IsISO8601({}, { message: 'surgeryDate 必须是合法 ISO 日期' })
surgeryDate!: string;
@ApiProperty({
description: '手术名称',
example: '脑室腹腔分流术',
})
@IsString({ message: 'surgeryName 必须是字符串' })
surgeryName!: string;
@ApiProperty({
description: '主刀医生',
example: '张主任',
})
@IsString({ message: 'surgeonName 必须是字符串' })
surgeonName!: string;
@ApiPropertyOptional({
description: '术前测压,可为空',
example: 22,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'preOpPressure 必须是整数' })
preOpPressure?: number;
@ApiProperty({
description: '原发病',
example: '梗阻性脑积水',
})
@IsString({ message: 'primaryDisease 必须是字符串' })
primaryDisease!: string;
@ApiProperty({
description: '脑积水类型,多选',
type: [String],
example: ['交通性', '高压性'],
})
@IsArray({ message: 'hydrocephalusTypes 必须是数组' })
@ArrayMinSize(1, { message: 'hydrocephalusTypes 至少选择 1 项' })
@IsString({ each: true, message: 'hydrocephalusTypes 必须为字符串数组' })
hydrocephalusTypes!: string[];
@ApiPropertyOptional({
description: '上次分流手术时间,可为空',
example: '2024-08-01T00:00:00.000Z',
})
@IsOptional()
@IsISO8601({}, { message: 'previousShuntSurgeryDate 必须是合法 ISO 日期' })
previousShuntSurgeryDate?: string;
@ApiPropertyOptional({
description: '手术备注',
example: '二次手术,弃用原右侧分流装置',
})
@IsOptional()
@IsString({ message: 'notes 必须是字符串' })
notes?: string;
@ApiPropertyOptional({
description: '术前 CT 影像/资料',
type: [SurgeryMaterialDto],
})
@IsOptional()
@IsArray({ message: 'preOpMaterials 必须是数组' })
@ValidateNested({ each: true })
@Type(() => SurgeryMaterialDto)
preOpMaterials?: SurgeryMaterialDto[];
@ApiProperty({
description: '本次手术植入设备列表',
type: [CreateSurgeryDeviceDto],
})
@IsArray({ message: 'devices 必须是数组' })
@ArrayMinSize(1, { message: 'devices 至少录入 1 个设备' })
@ValidateNested({ each: true })
@Type(() => CreateSurgeryDeviceDto)
devices!: CreateSurgeryDeviceDto[];
@ApiPropertyOptional({
description: '本次手术后需弃用的历史设备 ID 列表',
type: [Number],
example: [1],
})
@IsOptional()
@IsArray({ message: 'abandonedDeviceIds 必须是数组' })
@ArrayUnique({ message: 'abandonedDeviceIds 不能重复' })
@Type(() => Number)
@IsInt({ each: true, message: 'abandonedDeviceIds 必须为整数数组' })
@Min(1, { each: true, message: 'abandonedDeviceIds 必须大于 0' })
abandonedDeviceIds?: number[];
}

View File

@ -1,6 +1,14 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsInt, IsString, Matches, Min } from 'class-validator'; import {
IsInt,
IsOptional,
IsString,
Matches,
Min,
ValidateNested,
} from 'class-validator';
import { CreatePatientSurgeryDto } from './create-patient-surgery.dto.js';
/** /**
* DTOB 使 * DTOB 使
@ -10,6 +18,16 @@ export class CreatePatientDto {
@IsString({ message: 'name 必须是字符串' }) @IsString({ message: 'name 必须是字符串' })
name!: string; name!: string;
@ApiPropertyOptional({ description: '住院号', example: 'ZYH-20260319001' })
@IsOptional()
@IsString({ message: 'inpatientNo 必须是字符串' })
inpatientNo?: string;
@ApiPropertyOptional({ description: '项目名称', example: '脑积水随访项目' })
@IsOptional()
@IsString({ message: 'projectName 必须是字符串' })
projectName?: string;
@ApiProperty({ description: '手机号', example: '13800002001' }) @ApiProperty({ description: '手机号', example: '13800002001' })
@IsString({ message: 'phone 必须是字符串' }) @IsString({ message: 'phone 必须是字符串' })
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' }) @Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
@ -27,4 +45,13 @@ export class CreatePatientDto {
@IsInt({ message: 'doctorId 必须是整数' }) @IsInt({ message: 'doctorId 必须是整数' })
@Min(1, { message: 'doctorId 必须大于 0' }) @Min(1, { message: 'doctorId 必须大于 0' })
doctorId!: number; doctorId!: number;
@ApiPropertyOptional({
description: '首台手术信息,可在创建患者时一并录入',
type: CreatePatientSurgeryDto,
})
@IsOptional()
@ValidateNested()
@Type(() => CreatePatientSurgeryDto)
initialSurgery?: CreatePatientSurgeryDto;
} }

View File

@ -0,0 +1,95 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
/**
* DTO
*/
export class CreateSurgeryDeviceDto {
@ApiProperty({
description: '植入物型号 ID选中后自动回填厂家与名称',
example: 1,
})
@Type(() => Number)
@IsInt({ message: 'implantCatalogId 必须是整数' })
@Min(1, { message: 'implantCatalogId 必须大于 0' })
implantCatalogId!: number;
@ApiPropertyOptional({
description: '设备 SN可不传不传时系统自动生成',
example: 'TYT-SHUNT-001',
})
@IsOptional()
@IsString({ message: 'snCode 必须是字符串' })
snCode?: string;
@ApiProperty({
description: '分流方式',
example: 'VPS',
})
@IsString({ message: 'shuntMode 必须是字符串' })
shuntMode!: string;
@ApiProperty({
description: '近端穿刺区域,最多 2 个',
type: [String],
example: ['额角', '枕角'],
})
@IsArray({ message: 'proximalPunctureAreas 必须是数组' })
@ArrayMinSize(1, { message: 'proximalPunctureAreas 至少选择 1 项' })
@ArrayMaxSize(2, { message: 'proximalPunctureAreas 最多选择 2 项' })
@IsString({ each: true, message: 'proximalPunctureAreas 必须为字符串数组' })
proximalPunctureAreas!: string[];
@ApiProperty({
description: '阀门植入部位,最多 2 个',
type: [String],
example: ['耳后', '胸前'],
})
@IsArray({ message: 'valvePlacementSites 必须是数组' })
@ArrayMinSize(1, { message: 'valvePlacementSites 至少选择 1 项' })
@ArrayMaxSize(2, { message: 'valvePlacementSites 最多选择 2 项' })
@IsString({ each: true, message: 'valvePlacementSites 必须为字符串数组' })
valvePlacementSites!: string[];
@ApiProperty({
description: '远端分流方向',
example: '腹腔',
})
@IsString({ message: 'distalShuntDirection 必须是字符串' })
distalShuntDirection!: string;
@ApiPropertyOptional({
description: '初始压力,可为空',
example: 120,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'initialPressure 必须是整数' })
@Min(0, { message: 'initialPressure 必须大于等于 0' })
initialPressure?: number;
@ApiPropertyOptional({
description: '植入物备注',
example: '术中顺利,通畅良好',
})
@IsOptional()
@IsString({ message: 'implantNotes 必须是字符串' })
implantNotes?: string;
@ApiPropertyOptional({
description: '植入物标签图片地址',
example: 'https://cdn.example.com/patients/device-label-001.jpg',
})
@IsOptional()
@IsString({ message: 'labelImageUrl 必须是字符串' })
labelImageUrl?: string;
}

View File

@ -0,0 +1,32 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator';
/**
* DTO CT /
*/
export class SurgeryMaterialDto {
@ApiProperty({
description: '资料类型',
enum: ['IMAGE', 'VIDEO', 'FILE'],
example: 'IMAGE',
})
@IsIn(['IMAGE', 'VIDEO', 'FILE'], {
message: 'type 必须是 IMAGE、VIDEO 或 FILE',
})
type!: 'IMAGE' | 'VIDEO' | 'FILE';
@ApiProperty({
description: '资料访问地址',
example: 'https://cdn.example.com/patients/ct-001.png',
})
@IsString({ message: 'url 必须是字符串' })
url!: string;
@ApiPropertyOptional({
description: '资料名称',
example: '术前 CT 第 1 张',
})
@IsOptional()
@IsString({ message: 'name 必须是字符串' })
name?: string;
}

View File

@ -56,9 +56,19 @@ export class TaskService {
where: { where: {
id: { in: deviceIds }, id: { in: deviceIds },
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
isAbandoned: false,
isPressureAdjustable: true,
patient: { hospitalId }, patient: { hospitalId },
}, },
select: { id: true, currentPressure: true }, select: {
id: true,
currentPressure: true,
implantCatalog: {
select: {
pressureLevels: true,
},
},
},
}); });
if (devices.length !== deviceIds.length) { if (devices.length !== deviceIds.length) {
@ -82,6 +92,24 @@ export class TaskService {
const pressureByDeviceId = new Map( const pressureByDeviceId = new Map(
devices.map((device) => [device.id, device.currentPressure] as const), devices.map((device) => [device.id, device.currentPressure] as const),
); );
const pressureLevelsByDeviceId = new Map(
devices.map((device) => [
device.id,
Array.isArray(device.implantCatalog?.pressureLevels)
? device.implantCatalog.pressureLevels
: [],
]),
);
dto.items.forEach((item) => {
const pressureLevels = pressureLevelsByDeviceId.get(item.deviceId) ?? [];
if (
pressureLevels.length > 0 &&
!pressureLevels.includes(item.targetPressure)
) {
throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID);
}
});
const task = await this.prisma.task.create({ const task = await this.prisma.task.create({
data: { data: {

View File

@ -1,5 +1,10 @@
import { Body, Controller, Param, Patch, UseGuards } from '@nestjs/common'; import { Body, Controller, Param, Patch, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../../common/actor-context.js'; import type { ActorContext } from '../../common/actor-context.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js'; import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { AccessTokenGuard } from '../../auth/access-token.guard.js'; import { AccessTokenGuard } from '../../auth/access-token.guard.js';

View File

@ -31,7 +31,10 @@ export class CreateUserDto {
@MinLength(8, { message: 'password 长度至少 8 位' }) @MinLength(8, { message: 'password 长度至少 8 位' })
password?: string; password?: string;
@ApiPropertyOptional({ description: '微信 openId', example: 'wx-open-id-demo' }) @ApiPropertyOptional({
description: '微信 openId',
example: 'wx-open-id-demo',
})
@IsOptional() @IsOptional()
@IsString({ message: 'openId 必须是字符串' }) @IsString({ message: 'openId 必须是字符串' })
openId?: string; openId?: string;

View File

@ -30,7 +30,10 @@ export class LoginDto {
@IsEnum(Role, { message: 'role 枚举值不合法' }) @IsEnum(Role, { message: 'role 枚举值不合法' })
role!: Role; role!: Role;
@ApiPropertyOptional({ description: '医院 ID多账号场景建议传入', example: 1 }) @ApiPropertyOptional({
description: '医院 ID多账号场景建议传入',
example: 1,
})
@IsOptional() @IsOptional()
@EmptyStringToUndefined() @EmptyStringToUndefined()
@Type(() => Number) @Type(() => Number)

View File

@ -29,7 +29,6 @@ describe('BDevicesController (e2e)', () => {
.set('Authorization', `Bearer ${token}`) .set('Authorization', `Bearer ${token}`)
.send({ .send({
snCode: uniqueSeedValue('device-sn'), snCode: uniqueSeedValue('device-sn'),
currentPressure: 118,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
patientId, patientId,
}); });
@ -38,6 +37,7 @@ describe('BDevicesController (e2e)', () => {
return response.body.data as { return response.body.data as {
id: number; id: number;
snCode: string; snCode: string;
currentPressure: number;
status: DeviceStatus; status: DeviceStatus;
patient: { id: number }; patient: { id: number };
}; };
@ -95,6 +95,91 @@ describe('BDevicesController (e2e)', () => {
}); });
}); });
describe('植入物型号字典', () => {
it('成功DOCTOR 可查询可见型号字典', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/devices/catalogs')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(response, 200);
expect(
(response.body.data as Array<{ modelCode: string }>).some(
(item) => item.modelCode === 'SEED-ADJUSTABLE-VALVE',
),
).toBe(true);
});
it('成功SYSTEM_ADMIN 可新增、更新并删除全局植入物目录', async () => {
const createResponse = await request(ctx.app.getHttpServer())
.post('/b/devices/catalogs')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
modelCode: uniqueSeedValue('catalog').toUpperCase(),
manufacturer: 'Global Vendor',
name: '全局可调压阀',
isPressureAdjustable: true,
pressureLevels: [70, 90, 110],
notes: '测试全局目录',
});
expectSuccessEnvelope(createResponse, 201);
expect(createResponse.body.data.pressureLevels).toEqual([70, 90, 110]);
const updateResponse = await request(ctx.app.getHttpServer())
.patch(`/b/devices/catalogs/${createResponse.body.data.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: '全局可调压阀-更新版',
pressureLevels: [80, 100, 120],
});
expectSuccessEnvelope(updateResponse, 200);
expect(updateResponse.body.data.name).toBe('全局可调压阀-更新版');
expect(updateResponse.body.data.pressureLevels).toEqual([80, 100, 120]);
const deleteResponse = await request(ctx.app.getHttpServer())
.delete(`/b/devices/catalogs/${createResponse.body.data.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(deleteResponse, 200);
expect(deleteResponse.body.data.id).toBe(createResponse.body.data.id);
});
it('角色矩阵:仅 SYSTEM_ADMIN 可维护全局植入物目录', async () => {
await assertRoleMatrix({
name: 'POST /b/devices/catalogs role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 201,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/devices/catalogs')
.set('Authorization', `Bearer ${token}`)
.send({
modelCode: uniqueSeedValue('catalog-role').toUpperCase(),
manufacturer: 'Role Matrix Vendor',
name: '角色矩阵目录',
isPressureAdjustable: true,
pressureLevels: [50, 80],
}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/devices/catalogs')
.send({
modelCode: uniqueSeedValue('catalog-anon').toUpperCase(),
manufacturer: 'Anon Vendor',
name: '匿名目录',
}),
});
});
});
describe('设备 CRUD 流程', () => { describe('设备 CRUD 流程', () => {
it('成功HOSPITAL_ADMIN 可创建设备', async () => { it('成功HOSPITAL_ADMIN 可创建设备', async () => {
const created = await createDevice( const created = await createDevice(
@ -103,6 +188,7 @@ describe('BDevicesController (e2e)', () => {
); );
expect(created.status).toBe(DeviceStatus.ACTIVE); expect(created.status).toBe(DeviceStatus.ACTIVE);
expect(created.currentPressure).toBe(0);
expect(created.patient.id).toBe(ctx.fixtures.patients.patientA1Id); expect(created.patient.id).toBe(ctx.fixtures.patients.patientA1Id);
expect(created.snCode).toMatch(/^DEVICE-SN-/); expect(created.snCode).toMatch(/^DEVICE-SN-/);
}); });
@ -113,7 +199,6 @@ describe('BDevicesController (e2e)', () => {
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`) .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ .send({
snCode: uniqueSeedValue('cross-hospital-device'), snCode: uniqueSeedValue('cross-hospital-device'),
currentPressure: 120,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
patientId: ctx.fixtures.patients.patientB1Id, patientId: ctx.fixtures.patients.patientB1Id,
}); });
@ -133,7 +218,6 @@ describe('BDevicesController (e2e)', () => {
.send({ .send({
status: DeviceStatus.INACTIVE, status: DeviceStatus.INACTIVE,
patientId: ctx.fixtures.patients.patientA2Id, patientId: ctx.fixtures.patients.patientA2Id,
currentPressure: 99,
}); });
expectSuccessEnvelope(response, 200); expectSuccessEnvelope(response, 200);
@ -141,7 +225,23 @@ describe('BDevicesController (e2e)', () => {
expect(response.body.data.patient.id).toBe( expect(response.body.data.patient.id).toBe(
ctx.fixtures.patients.patientA2Id, ctx.fixtures.patients.patientA2Id,
); );
expect(response.body.data.currentPressure).toBe(99); expect(response.body.data.currentPressure).toBe(0);
});
it('失败:设备实例接口不允许手工更新 currentPressure', async () => {
const created = await createDevice(
ctx.tokens[Role.SYSTEM_ADMIN],
ctx.fixtures.patients.patientA1Id,
);
const response = await request(ctx.app.getHttpServer())
.patch(`/b/devices/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
currentPressure: 99,
});
expectErrorEnvelope(response, 400, 'currentPressure should not exist');
}); });
it('成功SYSTEM_ADMIN 可删除未被任务引用的设备', async () => { it('成功SYSTEM_ADMIN 可删除未被任务引用的设备', async () => {

View File

@ -0,0 +1,133 @@
import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
type E2EContext,
} from '../helpers/e2e-context.helper.js';
import {
expectSuccessEnvelope,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
describe('BDictionariesController (e2e)', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
describe('GET /b/dictionaries', () => {
it('成功DOCTOR 可查询启用中的系统字典', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/dictionaries')
.query({ type: 'PRIMARY_DISEASE' })
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(response, 200);
expect(
(response.body.data as Array<{ label: string; enabled: boolean }>).some(
(item) => item.label === '先天性脑积水' && item.enabled === true,
),
).toBe(true);
});
});
describe('字典维护流程', () => {
it('成功SYSTEM_ADMIN 可新增、更新、删除字典项,非管理员读取不到停用项', async () => {
const createLabel = uniqueSeedValue('字典项');
const updateLabel = `${createLabel}-启用`;
const createResponse = await request(ctx.app.getHttpServer())
.post('/b/dictionaries')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
type: 'SHUNT_MODE',
label: createLabel,
sortOrder: 999,
enabled: false,
});
expectSuccessEnvelope(createResponse, 201);
const createdId = createResponse.body.data.id as number;
const doctorReadResponse = await request(ctx.app.getHttpServer())
.get('/b/dictionaries')
.query({ type: 'SHUNT_MODE' })
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(doctorReadResponse, 200);
expect(
(doctorReadResponse.body.data as Array<{ label: string }>).some(
(item) => item.label === createLabel,
),
).toBe(false);
const adminReadResponse = await request(ctx.app.getHttpServer())
.get('/b/dictionaries')
.query({ type: 'SHUNT_MODE', includeDisabled: true })
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(adminReadResponse, 200);
expect(
(adminReadResponse.body.data as Array<{ label: string }>).some(
(item) => item.label === createLabel,
),
).toBe(true);
const updateResponse = await request(ctx.app.getHttpServer())
.patch(`/b/dictionaries/${createdId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
label: updateLabel,
enabled: true,
});
expectSuccessEnvelope(updateResponse, 200);
expect(updateResponse.body.data.label).toBe(updateLabel);
expect(updateResponse.body.data.enabled).toBe(true);
const deleteResponse = await request(ctx.app.getHttpServer())
.delete(`/b/dictionaries/${createdId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(deleteResponse, 200);
expect(deleteResponse.body.data.id).toBe(createdId);
});
it('角色矩阵:仅 SYSTEM_ADMIN 可维护字典,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/dictionaries role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 201,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/dictionaries')
.set('Authorization', `Bearer ${token}`)
.send({
type: 'DISTAL_SHUNT_DIRECTION',
label: uniqueSeedValue('矩阵字典项'),
}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/dictionaries')
.send({
type: 'DISTAL_SHUNT_DIRECTION',
label: uniqueSeedValue('匿名字典项'),
}),
});
});
});
});

View File

@ -1,5 +1,9 @@
import request from 'supertest'; import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js'; import {
DeviceStatus,
Role,
TaskStatus,
} from '../../../src/generated/prisma/enums.js';
import { import {
closeE2EContext, closeE2EContext,
createE2EContext, createE2EContext,
@ -9,8 +13,17 @@ import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import { import {
expectErrorEnvelope, expectErrorEnvelope,
expectSuccessEnvelope, expectSuccessEnvelope,
uniquePhone,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js'; } from '../helpers/e2e-http.helper.js';
function uniqueIdCard() {
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
.replace(/\D/g, '')
.slice(-4);
return `11010119990101${suffix.padStart(4, '0')}`;
}
describe('Patients Controllers (e2e)', () => { describe('Patients Controllers (e2e)', () => {
let ctx: E2EContext; let ctx: E2EContext;
@ -185,4 +198,209 @@ describe('Patients Controllers (e2e)', () => {
expectErrorEnvelope(response, 404, '未找到匹配的患者档案'); expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
}); });
}); });
describe('患者手术录入', () => {
it('成功DOCTOR 可创建患者并附带首台手术和植入设备', async () => {
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
select: { id: true },
});
expect(adjustableCatalog).toBeTruthy();
const response = await request(ctx.app.getHttpServer())
.post('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
name: '首术患者',
inpatientNo: uniqueSeedValue('zyh'),
projectName: '脑积水手术项目',
phone: uniquePhone(),
idCard: uniqueIdCard(),
doctorId: ctx.fixtures.users.doctorAId,
initialSurgery: {
surgeryDate: '2026-03-19T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
surgeonName: 'Seed Doctor A',
preOpPressure: 20,
primaryDisease: '梗阻性脑积水',
hydrocephalusTypes: ['交通性'],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 120,
implantNotes: '首术植入',
labelImageUrl:
'https://seed.example.com/tests/first-surgery.jpg',
},
],
},
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.shuntSurgeryCount).toBe(1);
expect(response.body.data.surgeries).toHaveLength(1);
expect(response.body.data.surgeries[0].devices).toHaveLength(1);
expect(response.body.data.surgeries[0].devices[0].implantModel).toBe(
'SEED-ADJUSTABLE-VALVE',
);
});
it('成功:新增二次手术时可弃用旧设备且保留旧设备调压历史', async () => {
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
select: { id: true },
});
expect(adjustableCatalog).toBeTruthy();
const createPatientResponse = await request(ctx.app.getHttpServer())
.post('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
name: '二次手术患者',
inpatientNo: uniqueSeedValue('zyh'),
projectName: '二次手术项目',
phone: uniquePhone(),
idCard: uniqueIdCard(),
doctorId: ctx.fixtures.users.doctorAId,
initialSurgery: {
surgeryDate: '2026-03-01T08:00:00.000Z',
surgeryName: '首次分流术',
surgeonName: 'Seed Doctor A',
preOpPressure: 18,
primaryDisease: '出血后脑积水',
hydrocephalusTypes: ['高压性'],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 100,
implantNotes: '首术设备',
labelImageUrl:
'https://seed.example.com/tests/initial-device.jpg',
},
],
},
});
expectSuccessEnvelope(createPatientResponse, 201);
const patient = createPatientResponse.body.data as {
id: number;
devices: Array<{ id: number }>;
};
const oldDeviceId = patient.devices[0].id;
await ctx.prisma.task.create({
data: {
status: TaskStatus.COMPLETED,
creatorId: ctx.fixtures.users.doctorAId,
engineerId: ctx.fixtures.users.engineerAId,
hospitalId: ctx.fixtures.hospitalAId,
items: {
create: [
{
deviceId: oldDeviceId,
oldPressure: 100,
targetPressure: 120,
},
],
},
},
});
const surgeryResponse = await request(ctx.app.getHttpServer())
.post(`/b/patients/${patient.id}/surgeries`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
surgeryDate: '2026-03-18T08:00:00.000Z',
surgeryName: '二次翻修术',
surgeonName: 'Seed Doctor A',
preOpPressure: 16,
primaryDisease: '分流功能障碍',
hydrocephalusTypes: ['交通性', '高压性'],
abandonedDeviceIds: [oldDeviceId],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['枕角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 120,
implantNotes: '二次手术新设备-1',
labelImageUrl: 'https://seed.example.com/tests/revision-1.jpg',
},
{
implantCatalogId: adjustableCatalog!.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['胸前'],
distalShuntDirection: '胸腔',
initialPressure: 140,
implantNotes: '二次手术新设备-2',
labelImageUrl: 'https://seed.example.com/tests/revision-2.jpg',
},
],
});
expectSuccessEnvelope(surgeryResponse, 201);
expect(surgeryResponse.body.data.devices).toHaveLength(2);
expect(surgeryResponse.body.data.shuntSurgeryCount).toBe(2);
const oldDevice = await ctx.prisma.device.findUnique({
where: { id: oldDeviceId },
include: { taskItems: true },
});
expect(oldDevice?.isAbandoned).toBe(true);
expect(oldDevice?.status).toBe(DeviceStatus.INACTIVE);
expect(oldDevice?.taskItems).toHaveLength(1);
});
it('失败:手术录入设备不允许手工传 currentPressure', async () => {
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
select: { id: true },
});
expect(adjustableCatalog).toBeTruthy();
const response = await request(ctx.app.getHttpServer())
.post('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
name: '非法当前压力患者',
inpatientNo: uniqueSeedValue('zyh'),
projectName: '非法字段校验',
phone: uniquePhone(),
idCard: uniqueIdCard(),
doctorId: ctx.fixtures.users.doctorAId,
initialSurgery: {
surgeryDate: '2026-03-19T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
surgeonName: 'Seed Doctor A',
primaryDisease: '梗阻性脑积水',
hydrocephalusTypes: ['交通性'],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 120,
currentPressure: 120,
},
],
},
});
expectErrorEnvelope(response, 400, '请求参数不合法');
});
});
}); });

View File

@ -49,7 +49,7 @@ describe('BTasksController (e2e)', () => {
items: [ items: [
{ {
deviceId: ctx.fixtures.devices.deviceA2Id, deviceId: ctx.fixtures.devices.deviceA2Id,
targetPressure: 126, targetPressure: 120,
}, },
], ],
}); });
@ -58,6 +58,22 @@ describe('BTasksController (e2e)', () => {
expect(response.body.data.status).toBe(TaskStatus.PENDING); expect(response.body.data.status).toBe(TaskStatus.PENDING);
}); });
it('失败:可调压设备使用非法挡位返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId: ctx.fixtures.devices.deviceA2Id,
targetPressure: 126,
},
],
});
expectErrorEnvelope(response, 400, '压力值不在该植入物配置的挡位范围内');
});
it('失败:发布跨院设备任务返回 404', async () => { it('失败:发布跨院设备任务返回 404', async () => {
const response = await request(ctx.app.getHttpServer()) const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish') .post('/b/tasks/publish')
@ -101,7 +117,7 @@ describe('BTasksController (e2e)', () => {
it('成功ENGINEER 可接收待处理任务', async () => { it('成功ENGINEER 可接收待处理任务', async () => {
const task = await publishPendingTask( const task = await publishPendingTask(
ctx.fixtures.devices.deviceA2Id, ctx.fixtures.devices.deviceA2Id,
127, 140,
); );
const response = await request(ctx.app.getHttpServer()) const response = await request(ctx.app.getHttpServer())
@ -128,7 +144,7 @@ describe('BTasksController (e2e)', () => {
it('状态机失败:重复接收返回 409', async () => { it('状态机失败:重复接收返回 409', async () => {
const task = await publishPendingTask( const task = await publishPendingTask(
ctx.fixtures.devices.deviceA3Id, ctx.fixtures.devices.deviceA3Id,
122, 120,
); );
const firstAccept = await request(ctx.app.getHttpServer()) const firstAccept = await request(ctx.app.getHttpServer())
@ -172,7 +188,7 @@ describe('BTasksController (e2e)', () => {
describe('POST /b/tasks/complete', () => { describe('POST /b/tasks/complete', () => {
it('成功ENGINEER 完成已接收任务并同步设备压力', async () => { it('成功ENGINEER 完成已接收任务并同步设备压力', async () => {
const targetPressure = 135; const targetPressure = 140;
const task = await publishPendingTask( const task = await publishPendingTask(
ctx.fixtures.devices.deviceA1Id, ctx.fixtures.devices.deviceA1Id,
targetPressure, targetPressure,
@ -211,7 +227,7 @@ describe('BTasksController (e2e)', () => {
it('状态机失败:未接收任务直接完成返回 409', async () => { it('状态机失败:未接收任务直接完成返回 409', async () => {
const task = await publishPendingTask( const task = await publishPendingTask(
ctx.fixtures.devices.deviceA2Id, ctx.fixtures.devices.deviceA2Id,
124, 100,
); );
const response = await request(ctx.app.getHttpServer()) const response = await request(ctx.app.getHttpServer())
@ -275,7 +291,7 @@ describe('BTasksController (e2e)', () => {
it('状态机失败:已完成任务不可取消返回 409', async () => { it('状态机失败:已完成任务不可取消返回 409', async () => {
const task = await publishPendingTask( const task = await publishPendingTask(
ctx.fixtures.devices.deviceA2Id, ctx.fixtures.devices.deviceA2Id,
123, 160,
); );
const acceptResponse = await request(ctx.app.getHttpServer()) const acceptResponse = await request(ctx.app.getHttpServer())

View File

@ -18,6 +18,7 @@ declare module 'vue' {
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
@ -31,6 +32,7 @@ declare module 'vue' {
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMain: typeof import('element-plus/es')['ElMain'] ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
@ -39,8 +41,11 @@ declare module 'vue' {
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline'] ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']

View File

@ -11,6 +11,22 @@ export const getDeviceById = (id) => {
return request.get(`/b/devices/${id}`); return request.get(`/b/devices/${id}`);
}; };
export const getImplantCatalogs = (params) => {
return request.get('/b/devices/catalogs', { params });
};
export const createImplantCatalog = (data) => {
return request.post('/b/devices/catalogs', data);
};
export const updateImplantCatalog = (id, data) => {
return request.patch(`/b/devices/catalogs/${id}`, data);
};
export const deleteImplantCatalog = (id) => {
return request.delete(`/b/devices/catalogs/${id}`);
};
export const createDevice = (data) => { export const createDevice = (data) => {
return request.post('/b/devices', data); return request.post('/b/devices', data);
}; };

View File

@ -0,0 +1,17 @@
import request from './request';
export const getDictionaries = (params) => {
return request.get('/b/dictionaries', { params });
};
export const createDictionaryItem = (data) => {
return request.post('/b/dictionaries', data);
};
export const updateDictionaryItem = (id, data) => {
return request.patch(`/b/dictionaries/${id}`, data);
};
export const deleteDictionaryItem = (id) => {
return request.delete(`/b/dictionaries/${id}`);
};

View File

@ -20,6 +20,10 @@ export const updatePatient = (id, data) => {
return request.patch(`/b/patients/${id}`, data); return request.patch(`/b/patients/${id}`, data);
}; };
export const createPatientSurgery = (id, data) => {
return request.post(`/b/patients/${id}/surgeries`, data);
};
export const deletePatient = (id) => { export const deletePatient = (id) => {
return request.delete(`/b/patients/${id}`); return request.delete(`/b/patients/${id}`);
}; };

View File

@ -0,0 +1,73 @@
export const MEDICAL_DICTIONARY_TYPES = Object.freeze({
PRIMARY_DISEASE: 'PRIMARY_DISEASE',
HYDROCEPHALUS_TYPE: 'HYDROCEPHALUS_TYPE',
SHUNT_MODE: 'SHUNT_MODE',
PROXIMAL_PUNCTURE_AREA: 'PROXIMAL_PUNCTURE_AREA',
VALVE_PLACEMENT_SITE: 'VALVE_PLACEMENT_SITE',
DISTAL_SHUNT_DIRECTION: 'DISTAL_SHUNT_DIRECTION',
});
export const MEDICAL_DICTIONARY_TYPE_OPTIONS = Object.freeze([
{
label: '原发病',
value: MEDICAL_DICTIONARY_TYPES.PRIMARY_DISEASE,
optionKey: 'primaryDiseaseOptions',
},
{
label: '脑积水类型',
value: MEDICAL_DICTIONARY_TYPES.HYDROCEPHALUS_TYPE,
optionKey: 'hydrocephalusTypeOptions',
},
{
label: '分流方式',
value: MEDICAL_DICTIONARY_TYPES.SHUNT_MODE,
optionKey: 'shuntModeOptions',
},
{
label: '近端穿刺区域',
value: MEDICAL_DICTIONARY_TYPES.PROXIMAL_PUNCTURE_AREA,
optionKey: 'proximalPunctureOptions',
},
{
label: '阀门植入部位',
value: MEDICAL_DICTIONARY_TYPES.VALVE_PLACEMENT_SITE,
optionKey: 'valvePlacementOptions',
},
{
label: '远端分流方向',
value: MEDICAL_DICTIONARY_TYPES.DISTAL_SHUNT_DIRECTION,
optionKey: 'distalShuntDirectionOptions',
},
]);
export function createEmptyMedicalDictionaryOptions() {
return {
primaryDiseaseOptions: [],
hydrocephalusTypeOptions: [],
shuntModeOptions: [],
proximalPunctureOptions: [],
valvePlacementOptions: [],
distalShuntDirectionOptions: [],
};
}
export function groupMedicalDictionaryItems(items) {
const grouped = createEmptyMedicalDictionaryOptions();
MEDICAL_DICTIONARY_TYPE_OPTIONS.forEach((typeOption) => {
grouped[typeOption.optionKey] = [];
});
(Array.isArray(items) ? items : []).forEach((item) => {
const typeOption = MEDICAL_DICTIONARY_TYPE_OPTIONS.find(
(option) => option.value === item.type,
);
if (!typeOption || !item.enabled) {
return;
}
grouped[typeOption.optionKey].push(item.label);
});
return grouped;
}

View File

@ -29,8 +29,9 @@ export const ROLE_PERMISSIONS = Object.freeze({
// 主任/组长仍可通过接口读取科室信息,但不再开放独立“科室管理”页面。 // 主任/组长仍可通过接口读取科室信息,但不再开放独立“科室管理”页面。
ORG_DEPARTMENTS: ADMIN_ROLES, ORG_DEPARTMENTS: ADMIN_ROLES,
ORG_GROUPS: ORG_MANAGER_ROLES, ORG_GROUPS: ORG_MANAGER_ROLES,
DICTIONARIES: Object.freeze(['SYSTEM_ADMIN']),
USERS: USER_MANAGER_ROLES, USERS: USER_MANAGER_ROLES,
DEVICES: ADMIN_ROLES, DEVICES: Object.freeze(['SYSTEM_ADMIN']),
TASKS: TASK_ROLES, TASKS: TASK_ROLES,
PATIENTS: PATIENT_ROLES, PATIENTS: PATIENT_ROLES,
}); });

View File

@ -36,7 +36,10 @@
<el-icon><Share /></el-icon> <el-icon><Share /></el-icon>
<span>组织架构图</span> <span>组织架构图</span>
</el-menu-item> </el-menu-item>
<el-menu-item v-if="canAccessDepartments" index="/organization/departments"> <el-menu-item
v-if="canAccessDepartments"
index="/organization/departments"
>
<el-icon><OfficeBuilding /></el-icon> <el-icon><OfficeBuilding /></el-icon>
<span>科室管理</span> <span>科室管理</span>
</el-menu-item> </el-menu-item>
@ -51,6 +54,11 @@
<span>{{ usersMenuLabel }}</span> <span>{{ usersMenuLabel }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item v-if="canAccessDictionaries" index="/dictionaries">
<el-icon><CollectionTag /></el-icon>
<span>字典管理</span>
</el-menu-item>
<el-menu-item v-if="canAccessDevices" index="/devices"> <el-menu-item v-if="canAccessDevices" index="/devices">
<el-icon><Monitor /></el-icon> <el-icon><Monitor /></el-icon>
<span>设备管理</span> <span>设备管理</span>
@ -119,6 +127,7 @@ import {
Connection, Connection,
Share, Share,
Monitor, Monitor,
CollectionTag,
} from '@element-plus/icons-vue'; } from '@element-plus/icons-vue';
const route = useRoute(); const route = useRoute();
@ -136,6 +145,9 @@ const canAccessUsers = computed(() =>
const canAccessDevices = computed(() => const canAccessDevices = computed(() =>
hasRolePermission(userStore.role, ROLE_PERMISSIONS.DEVICES), hasRolePermission(userStore.role, ROLE_PERMISSIONS.DEVICES),
); );
const canAccessDictionaries = computed(() =>
hasRolePermission(userStore.role, ROLE_PERMISSIONS.DICTIONARIES),
);
const canAccessOrgTree = computed(() => const canAccessOrgTree = computed(() =>
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE), hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE),
); );

View File

@ -77,6 +77,16 @@ const routes = [
allowedRoles: ROLE_PERMISSIONS.USERS, allowedRoles: ROLE_PERMISSIONS.USERS,
}, },
}, },
{
path: 'dictionaries',
name: 'Dictionaries',
component: () => import('../views/dictionaries/Dictionaries.vue'),
meta: {
title: '字典管理',
requiresAuth: true,
allowedRoles: ROLE_PERMISSIONS.DICTIONARIES,
},
},
{ {
path: 'devices', path: 'devices',
name: 'Devices', name: 'Devices',

View File

@ -1,76 +1,42 @@
<template> <template>
<div class="devices-container"> <div class="devices-container">
<el-card> <el-card class="panel-card">
<div class="header-actions"> <template #header>
<el-form :inline="true" :model="searchForm" class="search-form"> <div class="panel-head">
<el-form-item label="所属医院" v-if="isSystemAdmin"> <div>
<el-select <div class="panel-title">植入物目录</div>
v-model="searchForm.hospitalId" <div class="panel-subtitle">
clearable 这里维护患者手术里可选的全局植入物目录不再按医院或患者单独建档
filterable </div>
placeholder="全部医院" </div>
style="width: 220px" </div>
@change="handleSearchHospitalChange" </template>
>
<el-option
v-for="hospital in hospitals"
:key="hospital.id"
:label="hospital.name"
:value="hospital.id"
/>
</el-select>
</el-form-item>
<el-form-item label="归属患者"> <el-alert
<el-select type="info"
v-model="searchForm.patientId" :closable="false"
clearable class="page-alert"
filterable title="一个目录项可被多个患者手术重复绑定;患者手术里形成的是患者自己的植入记录,不会占用或锁定目录。"
placeholder="全部患者"
style="width: 260px"
:disabled="isSystemAdmin && !searchForm.hospitalId"
>
<el-option
v-for="patient in searchPatients"
:key="patient.id"
:label="formatPatientLabel(patient)"
:value="patient.id"
/> />
</el-select>
</el-form-item>
<el-form-item label="设备状态">
<el-select
v-model="searchForm.status"
clearable
placeholder="全部状态"
style="width: 160px"
>
<el-option
v-for="item in DEVICE_STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="toolbar">
<el-form :inline="true" :model="searchForm">
<el-form-item label="关键词"> <el-form-item label="关键词">
<el-input <el-input
v-model="searchForm.keyword" v-model="searchForm.keyword"
clearable clearable
placeholder="设备 SN / 患者姓名 / 手机号" placeholder="型号编码 / 厂家 / 名称"
style="width: 260px" style="width: 280px"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleSearch" icon="Search"> <el-button type="primary" @click="fetchData" icon="Search">
查询 查询
</el-button> </el-button>
<el-button @click="resetSearch" icon="Refresh">重置</el-button> <el-button @click="resetSearch" icon="Refresh">重置</el-button>
<el-button type="success" @click="openCreateDialog" icon="Plus"> <el-button type="success" @click="openCreateDialog" icon="Plus">
新增设备 新增植入物
</el-button> </el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -83,44 +49,40 @@
stripe stripe
style="width: 100%" style="width: 100%"
> >
<el-table-column prop="id" label="ID" width="80" align="center" /> <el-table-column prop="id" label="ID" width="90" align="center" />
<el-table-column prop="snCode" label="设备 SN" min-width="180" /> <el-table-column prop="modelCode" label="型号编码" min-width="180" />
<el-table-column <el-table-column prop="manufacturer" label="厂家" min-width="180" />
prop="currentPressure" <el-table-column prop="name" label="名称" min-width="180" />
label="当前压力" <el-table-column label="器械类型" width="120" align="center">
width="120"
align="center"
/>
<el-table-column label="设备状态" width="120" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)"> <el-tag :type="row.isPressureAdjustable ? 'success' : 'info'">
{{ getStatusName(row.status) }} {{ row.isPressureAdjustable ? '可调压' : '非调压' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="归属患者" min-width="140"> <el-table-column label="挡位" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
{{ row.patient?.name || '-' }} <div v-if="row.pressureLevels?.length" class="pressure-tag-list">
<el-tag
v-for="level in row.pressureLevels"
:key="`${row.id}-${level}`"
size="small"
type="warning"
>
{{ level }}
</el-tag>
</div>
<span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="患者手机号" min-width="150"> <el-table-column label="备注" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
{{ row.patient?.phone || '-' }} {{ row.notes || '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="所属医院" min-width="160"> <el-table-column label="更新时间" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
{{ row.patient?.hospital?.name || '-' }} {{ formatDateTime(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="归属医生" min-width="140">
<template #default="{ row }">
{{ row.patient?.doctor?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="关联任务数" width="120" align="center">
<template #default="{ row }">
{{ row._count?.taskItems ?? 0 }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center"> <el-table-column label="操作" width="180" fixed="right" align="center">
@ -134,93 +96,98 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
background
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</el-card> </el-card>
<el-dialog <el-dialog
:title="isEdit ? '编辑设备' : '新增设备'" :title="isEdit ? '编辑植入物目录' : '新增植入物目录'"
v-model="dialogVisible" v-model="dialogVisible"
width="560px" width="760px"
destroy-on-close
@close="resetForm" @close="resetForm"
> >
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px"> <el-form :model="form" :rules="rules" ref="formRef" label-width="110px">
<el-form-item label="所属医院" prop="hospitalId" v-if="isSystemAdmin"> <el-row :gutter="16">
<el-select <el-col :xs="24" :md="12">
v-model="form.hospitalId" <el-form-item label="型号编码" prop="modelCode">
filterable
placeholder="请选择医院"
style="width: 100%"
@change="handleFormHospitalChange"
>
<el-option
v-for="hospital in hospitals"
:key="hospital.id"
:label="hospital.name"
:value="hospital.id"
/>
</el-select>
</el-form-item>
<el-form-item label="归属患者" prop="patientId">
<el-select
v-model="form.patientId"
filterable
placeholder="请选择患者"
style="width: 100%"
:disabled="isSystemAdmin && !form.hospitalId"
>
<el-option
v-for="patient in formPatients"
:key="patient.id"
:label="formatPatientLabel(patient)"
:value="patient.id"
/>
</el-select>
</el-form-item>
<el-form-item label="设备 SN" prop="snCode">
<el-input <el-input
v-model="form.snCode" v-model="form.modelCode"
placeholder="请输入设备 SN"
maxlength="64" maxlength="64"
placeholder="请输入型号编码"
/> />
</el-form-item> </el-form-item>
</el-col>
<el-form-item label="当前压力" prop="currentPressure"> <el-col :xs="24" :md="12">
<el-input-number <el-form-item label="厂家" prop="manufacturer">
v-model="form.currentPressure" <el-input
:min="0" v-model="form.manufacturer"
:step="1" maxlength="100"
:controls="false" placeholder="请输入厂家"
style="width: 100%"
/> />
</el-form-item> </el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="名称" prop="name">
<el-input
v-model="form.name"
maxlength="100"
placeholder="请输入植入物名称"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="可调压">
<el-switch
v-model="form.isPressureAdjustable"
active-text="是"
inactive-text="否"
@change="handleAdjustableChange"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="设备状态" prop="status"> <el-form-item label="压力挡位" v-if="form.isPressureAdjustable">
<el-select <div class="pressure-level-panel">
v-model="form.status" <div
placeholder="请选择状态" v-for="(level, index) in form.pressureLevels"
style="width: 100%" :key="`pressure-level-${index}`"
class="pressure-level-row"
> >
<el-option <el-input-number
v-for="item in DEVICE_STATUS_OPTIONS" v-model="form.pressureLevels[index]"
:key="item.value" :min="0"
:label="item.label" :controls="false"
:value="item.value" placeholder="请输入挡位值"
style="width: 180px"
/>
<el-button
type="danger"
plain
@click="removePressureLevel(index)"
:disabled="form.pressureLevels.length === 1"
>
删除
</el-button>
</div>
<el-button type="primary" plain @click="addPressureLevel">
新增挡位
</el-button>
<div class="field-hint">
每个挡位填一个整数值保存时会自动去重并按从小到大排序
</div>
</div>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="form.notes"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="可填写适用说明、材质、适配场景等"
/> />
</el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -232,7 +199,7 @@
:loading="submitLoading" :loading="submitLoading"
@click="handleSubmit" @click="handleSubmit"
> >
确定 {{ isEdit ? '保存修改' : '创建目录' }}
</el-button> </el-button>
</div> </div>
</template> </template>
@ -241,26 +208,14 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { import {
getDevices, createImplantCatalog,
createDevice, deleteImplantCatalog,
updateDevice, getImplantCatalogs,
deleteDevice, updateImplantCatalog,
} from '../../api/devices'; } from '../../api/devices';
import { getHospitals } from '../../api/organization';
import { getPatients } from '../../api/patients';
import { useUserStore } from '../../store/user';
const userStore = useUserStore();
const DEVICE_STATUS_OPTIONS = [
{ label: '启用', value: 'ACTIVE' },
{ label: '停用', value: 'INACTIVE' },
];
const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
const loading = ref(false); const loading = ref(false);
const submitLoading = ref(false); const submitLoading = ref(false);
@ -268,221 +223,165 @@ const dialogVisible = ref(false);
const isEdit = ref(false); const isEdit = ref(false);
const formRef = ref(null); const formRef = ref(null);
const currentId = ref(null); const currentId = ref(null);
const hospitals = ref([]);
const searchPatients = ref([]);
const formPatients = ref([]);
const tableData = ref([]); const tableData = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const searchForm = reactive({ const searchForm = reactive({
hospitalId: null,
patientId: null,
status: '',
keyword: '', keyword: '',
}); });
const form = reactive({ const form = reactive(createDefaultForm());
hospitalId: null,
patientId: null,
snCode: '',
currentPressure: 0,
status: 'ACTIVE',
});
const rules = computed(() => ({ const rules = {
hospitalId: isSystemAdmin.value modelCode: [{ required: true, message: '请输入型号编码', trigger: 'blur' }],
? [{ required: true, message: '请选择所属医院', trigger: 'change' }] manufacturer: [{ required: true, message: '请输入厂家', trigger: 'blur' }],
: [], name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
patientId: [{ required: true, message: '请选择归属患者', trigger: 'change' }],
snCode: [{ required: true, message: '请输入设备 SN', trigger: 'blur' }],
currentPressure: [
{ required: true, message: '请输入当前压力', trigger: 'blur' },
],
status: [{ required: true, message: '请选择设备状态', trigger: 'change' }],
}));
const getStatusName = (status) => {
return (
DEVICE_STATUS_OPTIONS.find((item) => item.value === status)?.label || status
);
}; };
const getStatusTagType = (status) => { function createDefaultForm() {
return status === 'ACTIVE' ? 'success' : 'info'; return {
modelCode: '',
manufacturer: '',
name: '',
isPressureAdjustable: true,
pressureLevels: [null],
notes: '',
}; };
const formatPatientLabel = (patient) => {
const hospitalName = patient.hospital?.name
? ` / ${patient.hospital.name}`
: '';
return `${patient.name}${patient.phone}${hospitalName}`;
};
const fetchHospitals = async () => {
if (!isSystemAdmin.value) {
return;
} }
const res = await getHospitals({ page: 1, pageSize: 100 }); function formatDateTime(value) {
hospitals.value = res.list || []; if (!value) {
}; return '-';
//
const fetchSearchPatients = async () => {
if (isSystemAdmin.value && !searchForm.hospitalId) {
searchPatients.value = [];
searchForm.patientId = null;
return;
} }
const params = {}; const date = new Date(value);
if (isSystemAdmin.value) { if (Number.isNaN(date.getTime())) {
params.hospitalId = searchForm.hospitalId; return '-';
} }
const res = await getPatients(params); return date.toLocaleString('zh-CN', { hour12: false });
searchPatients.value = Array.isArray(res) ? res : [];
if (!searchPatients.value.some((item) => item.id === searchForm.patientId)) {
searchForm.patientId = null;
}
};
// patientId
const fetchFormPatients = async (hospitalId = form.hospitalId) => {
if (isSystemAdmin.value && !hospitalId) {
formPatients.value = [];
form.patientId = null;
return;
} }
const params = {}; function normalizePressureLevels(levels) {
if (isSystemAdmin.value) { return Array.from(
params.hospitalId = hospitalId; new Set(
(Array.isArray(levels) ? levels : [])
.filter(
(level) => level !== null && level !== undefined && level !== '',
)
.map((level) => Number(level))
.filter((level) => Number.isInteger(level) && level >= 0),
),
).sort((left, right) => left - right);
} }
const res = await getPatients(params); async function fetchData() {
formPatients.value = Array.isArray(res) ? res : [];
if (!formPatients.value.some((item) => item.id === form.patientId)) {
form.patientId = null;
}
};
const fetchData = async () => {
loading.value = true; loading.value = true;
try { try {
const params = { const res = await getImplantCatalogs({
page: page.value,
pageSize: pageSize.value,
keyword: searchForm.keyword || undefined, keyword: searchForm.keyword || undefined,
status: searchForm.status || undefined, });
patientId: searchForm.patientId || undefined, tableData.value = Array.isArray(res) ? res : [];
};
if (isSystemAdmin.value && searchForm.hospitalId) {
params.hospitalId = searchForm.hospitalId;
}
const res = await getDevices(params);
tableData.value = res.list || [];
total.value = res.total || 0;
} finally { } finally {
loading.value = false; loading.value = false;
} }
};
const handleSearchHospitalChange = async () => {
page.value = 1;
await fetchSearchPatients();
await fetchData();
};
const handleFormHospitalChange = async (hospitalId) => {
form.patientId = null;
await fetchFormPatients(hospitalId);
};
const handleSearch = () => {
page.value = 1;
fetchData();
};
const resetSearch = async () => {
searchForm.hospitalId = null;
searchForm.patientId = null;
searchForm.status = '';
searchForm.keyword = '';
page.value = 1;
await fetchSearchPatients();
await fetchData();
};
const resetForm = () => {
formRef.value?.resetFields();
form.hospitalId = null;
form.patientId = null;
form.snCode = '';
form.currentPressure = 0;
form.status = 'ACTIVE';
currentId.value = null;
formPatients.value = [];
};
const openCreateDialog = async () => {
isEdit.value = false;
resetForm();
// 沿
if (isSystemAdmin.value) {
form.hospitalId = searchForm.hospitalId || null;
} else {
form.hospitalId = userStore.userInfo?.hospitalId || null;
} }
await fetchFormPatients(form.hospitalId); async function resetSearch() {
dialogVisible.value = true; searchForm.keyword = '';
}; await fetchData();
}
const openEditDialog = async (row) => { function resetForm() {
formRef.value?.clearValidate?.();
const next = createDefaultForm();
form.modelCode = next.modelCode;
form.manufacturer = next.manufacturer;
form.name = next.name;
form.isPressureAdjustable = next.isPressureAdjustable;
form.pressureLevels = next.pressureLevels;
form.notes = next.notes;
currentId.value = null;
}
function handleAdjustableChange(enabled) {
if (!enabled) {
form.pressureLevels = [null];
return;
}
if (!Array.isArray(form.pressureLevels) || form.pressureLevels.length === 0) {
form.pressureLevels = [null];
}
}
function addPressureLevel() {
form.pressureLevels.push(null);
}
function removePressureLevel(index) {
if (form.pressureLevels.length === 1) {
form.pressureLevels.splice(index, 1, null);
return;
}
form.pressureLevels.splice(index, 1);
}
function openCreateDialog() {
isEdit.value = false;
resetForm();
dialogVisible.value = true;
}
function openEditDialog(row) {
isEdit.value = true; isEdit.value = true;
formRef.value?.clearValidate?.();
currentId.value = row.id; currentId.value = row.id;
form.snCode = row.snCode; form.modelCode = row.modelCode || '';
form.currentPressure = row.currentPressure; form.manufacturer = row.manufacturer || '';
form.status = row.status; form.name = row.name || '';
form.hospitalId = form.isPressureAdjustable = Boolean(row.isPressureAdjustable);
row.patient?.hospital?.id || row.patient?.hospitalId || null; form.pressureLevels =
Array.isArray(row.pressureLevels) && row.pressureLevels.length > 0
await fetchFormPatients(form.hospitalId); ? [...row.pressureLevels]
form.patientId = row.patient?.id || null; : [null];
form.notes = row.notes || '';
dialogVisible.value = true; dialogVisible.value = true;
}; }
const handleSubmit = async () => { async function handleSubmit() {
if (!formRef.value) return; if (!formRef.value) {
return;
}
await formRef.value.validate(async (valid) => { try {
if (!valid) return; await formRef.value.validate();
} catch {
return;
}
const normalizedLevels = normalizePressureLevels(form.pressureLevels);
if (form.isPressureAdjustable && normalizedLevels.length === 0) {
ElMessage.warning('可调压植入物至少需要录入一个挡位');
return;
}
submitLoading.value = true; submitLoading.value = true;
try { try {
const payload = { const payload = {
snCode: form.snCode, modelCode: form.modelCode,
currentPressure: Number(form.currentPressure), manufacturer: form.manufacturer,
status: form.status, name: form.name,
patientId: form.patientId, isPressureAdjustable: form.isPressureAdjustable,
pressureLevels: form.isPressureAdjustable ? normalizedLevels : [],
notes: form.notes || undefined,
}; };
if (isEdit.value) { if (isEdit.value) {
await updateDevice(currentId.value, payload); await updateImplantCatalog(currentId.value, payload);
ElMessage.success('更新成功'); ElMessage.success('植入物目录已更新');
} else { } else {
await createDevice(payload); await createImplantCatalog(payload);
ElMessage.success('创建成功'); ElMessage.success('植入物目录已创建');
} }
dialogVisible.value = false; dialogVisible.value = false;
@ -490,27 +389,26 @@ const handleSubmit = async () => {
} finally { } finally {
submitLoading.value = false; submitLoading.value = false;
} }
}); }
};
const handleDelete = (row) => { async function handleDelete(row) {
ElMessageBox.confirm(`确定要删除设备 "${row.snCode}" 吗?`, '警告', { await ElMessageBox.confirm(
confirmButtonText: '确定', `确认删除植入物目录「${row.name}」吗?已绑定到患者手术的目录项将无法删除。`,
cancelButtonText: '取消', '删除确认',
{
type: 'warning', type: 'warning',
}) confirmButtonText: '删除',
.then(async () => { cancelButtonText: '取消',
await deleteDevice(row.id); },
ElMessage.success('删除成功'); );
await fetchData();
})
.catch(() => {});
};
onMounted(async () => { await deleteImplantCatalog(row.id);
await fetchHospitals(); ElMessage.success('植入物目录已删除');
await fetchSearchPatients();
await fetchData(); await fetchData();
}
onMounted(() => {
fetchData();
}); });
</script> </script>
@ -519,13 +417,63 @@ onMounted(async () => {
padding: 0; padding: 0;
} }
.header-actions { .panel-card {
margin-bottom: 20px; border-radius: 18px;
} }
.pagination-container { .panel-head {
margin-top: 20px; display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.panel-subtitle {
margin-top: 6px;
font-size: 13px;
color: #6b7280;
}
.page-alert {
margin-bottom: 16px;
}
.toolbar {
margin-bottom: 16px;
}
.pressure-tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pressure-level-panel {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.pressure-level-row {
display: flex;
align-items: center;
gap: 12px;
}
.field-hint {
font-size: 12px;
color: #6b7280;
}
.dialog-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 12px;
} }
</style> </style>

View File

@ -0,0 +1,376 @@
<template>
<div class="dictionaries-container">
<el-card class="panel-card">
<template #header>
<div class="panel-head">
<div>
<div class="panel-title">医学选项字典</div>
<div class="panel-subtitle">
系统管理员维护患者手术表单中的标准选项停用后不会再出现在录入页
</div>
</div>
</div>
</template>
<el-alert
type="info"
:closable="false"
class="page-alert"
title="当前字典为系统级公共配置,所有医院患者录入表单共享同一套选项。"
/>
<div class="toolbar">
<el-form :inline="true" :model="searchForm">
<el-form-item label="字典类型">
<el-select
v-model="searchForm.type"
clearable
filterable
placeholder="全部类型"
style="width: 220px"
>
<el-option
v-for="option in MEDICAL_DICTIONARY_TYPE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
clearable
placeholder="按字典项名称筛选"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData" icon="Search">
查询
</el-button>
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
<el-button type="success" @click="openCreateDialog" icon="Plus">
新增字典项
</el-button>
</el-form-item>
</el-form>
</div>
<el-table
:data="filteredTableData"
v-loading="loading"
border
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="90" align="center" />
<el-table-column label="字典类型" min-width="180">
<template #default="{ row }">
{{ getTypeLabel(row.type) }}
</template>
</el-table-column>
<el-table-column prop="label" label="字典项名称" min-width="240" />
<el-table-column
prop="sortOrder"
label="排序"
width="110"
align="center"
/>
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'">
{{ row.enabled ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="更新时间" min-width="180">
<template #default="{ row }">
{{ formatDateTime(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openEditDialog(row)">
编辑
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
:title="isEdit ? '编辑字典项' : '新增字典项'"
v-model="dialogVisible"
width="560px"
destroy-on-close
@close="resetForm"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="110px">
<el-form-item label="字典类型" prop="type">
<el-select
v-model="form.type"
filterable
placeholder="请选择字典类型"
style="width: 100%"
>
<el-option
v-for="option in MEDICAL_DICTIONARY_TYPE_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="字典项名称" prop="label">
<el-input
v-model="form.label"
maxlength="50"
show-word-limit
placeholder="请输入字典项名称"
/>
</el-form-item>
<el-form-item label="排序值" prop="sortOrder">
<el-input-number
v-model="form.sortOrder"
:controls="false"
:step="10"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="是否启用" prop="enabled">
<el-switch
v-model="form.enabled"
active-text="启用"
inactive-text="停用"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="submitLoading"
@click="handleSubmit"
>
{{ isEdit ? '保存修改' : '创建字典项' }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
createDictionaryItem,
deleteDictionaryItem,
getDictionaries,
updateDictionaryItem,
} from '../../api/dictionaries';
import { MEDICAL_DICTIONARY_TYPE_OPTIONS } from '../../constants/medical-dictionaries';
const loading = ref(false);
const submitLoading = ref(false);
const dialogVisible = ref(false);
const isEdit = ref(false);
const formRef = ref(null);
const currentId = ref(null);
const tableData = ref([]);
const searchForm = reactive({
type: '',
keyword: '',
});
const form = reactive({
type: MEDICAL_DICTIONARY_TYPE_OPTIONS[0]?.value || '',
label: '',
sortOrder: 0,
enabled: true,
});
const rules = {
type: [{ required: true, message: '请选择字典类型', trigger: 'change' }],
label: [{ required: true, message: '请输入字典项名称', trigger: 'blur' }],
};
const filteredTableData = computed(() => {
const keyword = String(searchForm.keyword || '').trim();
if (!keyword) {
return tableData.value;
}
return tableData.value.filter((item) =>
String(item.label || '').includes(keyword),
);
});
const getTypeLabel = (type) => {
return (
MEDICAL_DICTIONARY_TYPE_OPTIONS.find((option) => option.value === type)
?.label || type
);
};
const formatDateTime = (value) => {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '-';
}
return date.toLocaleString('zh-CN', { hour12: false });
};
const fetchData = async () => {
loading.value = true;
try {
const params = {
includeDisabled: true,
type: searchForm.type || undefined,
};
const res = await getDictionaries(params);
tableData.value = Array.isArray(res) ? res : [];
} finally {
loading.value = false;
}
};
const resetSearch = async () => {
searchForm.type = '';
searchForm.keyword = '';
await fetchData();
};
const resetForm = () => {
formRef.value?.clearValidate?.();
form.type = MEDICAL_DICTIONARY_TYPE_OPTIONS[0]?.value || '';
form.label = '';
form.sortOrder = 0;
form.enabled = true;
currentId.value = null;
};
const openCreateDialog = () => {
isEdit.value = false;
resetForm();
if (searchForm.type) {
form.type = searchForm.type;
}
dialogVisible.value = true;
};
const openEditDialog = (row) => {
isEdit.value = true;
formRef.value?.clearValidate?.();
currentId.value = row.id;
form.type = row.type;
form.label = row.label;
form.sortOrder = row.sortOrder ?? 0;
form.enabled = Boolean(row.enabled);
dialogVisible.value = true;
};
const handleSubmit = async () => {
if (!formRef.value) {
return;
}
try {
await formRef.value.validate();
} catch {
return;
}
submitLoading.value = true;
try {
const payload = {
type: form.type,
label: form.label,
sortOrder: form.sortOrder,
enabled: form.enabled,
};
if (isEdit.value) {
await updateDictionaryItem(currentId.value, payload);
ElMessage.success('字典项已更新');
} else {
await createDictionaryItem(payload);
ElMessage.success('字典项已创建');
}
dialogVisible.value = false;
await fetchData();
} finally {
submitLoading.value = false;
}
};
const handleDelete = async (row) => {
await ElMessageBox.confirm(
`确认删除字典项「${row.label}」吗?删除后将无法继续在表单中选择该值。`,
'删除确认',
{
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
},
);
await deleteDictionaryItem(row.id);
ElMessage.success('字典项已删除');
await fetchData();
};
onMounted(() => {
fetchData();
});
</script>
<style scoped>
.panel-card {
border-radius: 18px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.panel-subtitle {
margin-top: 6px;
font-size: 13px;
color: #6b7280;
}
.page-alert {
margin-bottom: 16px;
}
.toolbar {
margin-bottom: 16px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,657 @@
<template>
<div class="surgery-section">
<div class="section-head" v-if="title || description">
<div>
<div class="section-title" v-if="title">{{ title }}</div>
<div class="section-description" v-if="description">
{{ description }}
</div>
</div>
</div>
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<el-form-item label="手术日期">
<el-date-picker
v-model="form.surgeryDate"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
format="YYYY-MM-DD HH:mm"
placeholder="请选择手术日期"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="手术名称">
<el-input
v-model="form.surgeryName"
placeholder="例如:脑室腹腔分流术"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="主刀医生">
<el-input v-model="form.surgeonName" placeholder="请输入主刀医生" />
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="术前测压">
<el-input-number
v-model="form.preOpPressure"
:min="0"
:controls="false"
placeholder="可为空"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="原发病">
<el-select
v-model="form.primaryDisease"
filterable
placeholder="请选择原发病"
style="width: 100%"
>
<el-option
v-for="item in dictionaryOptions.primaryDiseaseOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="脑积水类型">
<el-select
v-model="form.hydrocephalusTypes"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="请选择脑积水类型"
style="width: 100%"
>
<el-option
v-for="item in dictionaryOptions.hydrocephalusTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="上次分流时间">
<el-date-picker
v-model="form.previousShuntSurgeryDate"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
format="YYYY-MM-DD HH:mm"
placeholder="可为空"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="手术备注">
<el-input
v-model="form.notes"
maxlength="200"
show-word-limit
placeholder="二次手术原因、术中特殊情况等"
/>
</el-form-item>
</el-col>
<el-col :span="24" v-if="showAbandonSelector">
<el-form-item label="弃用旧设备">
<el-select
v-model="form.abandonedDeviceIds"
multiple
clearable
collapse-tags
collapse-tags-tooltip
placeholder="可选择本次手术后弃用的旧设备"
style="width: 100%"
>
<el-option
v-for="device in abandonableDevices"
:key="device.id"
:label="formatAbandonDeviceLabel(device)"
:value="device.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-card shadow="never" class="block-card">
<template #header>
<div class="block-head">
<div>
<div class="block-title">术前 CT 影像/资料</div>
<div class="block-subtitle">支持图片视频文件链接</div>
</div>
<el-button type="primary" plain @click="addMaterial"
>新增资料</el-button
>
</div>
</template>
<div v-if="form.preOpMaterials.length === 0" class="empty-hint">
暂未录入术前资料
</div>
<div
v-for="(material, index) in form.preOpMaterials"
:key="`material-${index}`"
class="material-row"
>
<el-row :gutter="12">
<el-col :xs="24" :md="5">
<el-select
v-model="material.type"
placeholder="类型"
style="width: 100%"
>
<el-option
v-for="item in MATERIAL_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-col>
<el-col :xs="24" :md="5">
<el-input v-model="material.name" placeholder="资料名称" />
</el-col>
<el-col :xs="24" :md="12">
<el-input v-model="material.url" placeholder="资料地址 URL" />
</el-col>
<el-col :xs="24" :md="2" class="material-remove">
<el-button
type="danger"
plain
@click="removeMaterial(index)"
:disabled="
form.preOpMaterials.length === 1 &&
!material.url &&
!material.name
"
>
删除
</el-button>
</el-col>
</el-row>
</div>
</el-card>
<el-card shadow="never" class="block-card">
<template #header>
<div class="block-head">
<div>
<div class="block-title">植入设备</div>
<div class="block-subtitle">
选型号后自动联动厂家和名称支持一次手术录入多台设备
</div>
</div>
<el-button type="primary" @click="addDevice">新增设备</el-button>
</div>
</template>
<div
v-for="(device, index) in form.devices"
:key="`device-${index}`"
class="device-card"
>
<div class="device-card-head">
<div class="device-card-title">设备 {{ index + 1 }}</div>
<el-button
type="danger"
plain
@click="removeDevice(index)"
:disabled="form.devices.length <= 1"
>
删除设备
</el-button>
</div>
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<el-form-item label="植入物型号">
<el-select
v-model="device.implantCatalogId"
filterable
placeholder="请选择植入物型号"
style="width: 100%"
@change="handleCatalogChange(device)"
>
<el-option
v-for="item in catalogOptions"
:key="item.id"
:label="formatCatalogLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="设备 SN">
<el-input
v-model="device.snCode"
placeholder="可不填,系统自动生成"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="植入物厂商">
<el-input
:model-value="
resolveCatalog(device.implantCatalogId)?.manufacturer || '-'
"
disabled
/>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="植入物名称">
<div class="catalog-name-box">
<el-input
:model-value="
resolveCatalog(device.implantCatalogId)?.name || '-'
"
disabled
/>
<el-tag
v-if="resolveCatalog(device.implantCatalogId)"
:type="
resolveCatalog(device.implantCatalogId)
?.isPressureAdjustable
? 'success'
: 'info'
"
>
{{
resolveCatalog(device.implantCatalogId)
?.isPressureAdjustable
? '可调压'
: '固定压'
}}
</el-tag>
</div>
<div
v-if="resolvePressureLevels(device.implantCatalogId).length"
class="pressure-level-hint"
>
挡位
{{ resolvePressureLevels(device.implantCatalogId).join(' / ') }}
</div>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="分流方式">
<el-select
v-model="device.shuntMode"
filterable
placeholder="请选择分流方式"
style="width: 100%"
>
<el-option
v-for="item in dictionaryOptions.shuntModeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="远端分流方向">
<el-select
v-model="device.distalShuntDirection"
filterable
placeholder="请选择远端方向"
style="width: 100%"
>
<el-option
v-for="item in dictionaryOptions.distalShuntDirectionOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="近端穿刺区域">
<el-select
v-model="device.proximalPunctureAreas"
multiple
filterable
collapse-tags
collapse-tags-tooltip
:multiple-limit="2"
placeholder="最多 2 项"
style="width: 100%"
>
<el-option
v-for="item in dictionaryOptions.proximalPunctureOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="阀门植入部位">
<el-select
v-model="device.valvePlacementSites"
multiple
filterable
collapse-tags
collapse-tags-tooltip
:multiple-limit="2"
placeholder="最多 2 项"
style="width: 100%"
>
<el-option
v-for="item in dictionaryOptions.valvePlacementOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="初始压力">
<el-select
v-if="resolvePressureLevels(device.implantCatalogId).length"
v-model="device.initialPressure"
clearable
placeholder="请选择初始挡位"
style="width: 100%"
>
<el-option
v-for="level in resolvePressureLevels(
device.implantCatalogId,
)"
:key="`${device.implantCatalogId}-initial-${level}`"
:label="String(level)"
:value="level"
/>
</el-select>
<el-input-number
v-else
v-model="device.initialPressure"
:min="0"
:controls="false"
:disabled="
!resolveCatalog(device.implantCatalogId)?.isPressureAdjustable
"
placeholder="可为空"
style="width: 100%"
/>
<div class="field-hint">
当前压力创建后默认继承初始压力后续以调压任务完成结果为准
</div>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="植入物备注">
<el-input
v-model="device.implantNotes"
maxlength="200"
show-word-limit
placeholder="记录术中情况、位置说明等"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="植入物标签">
<el-input
v-model="device.labelImageUrl"
placeholder="请输入图片地址 URL"
/>
</el-form-item>
</el-col>
</el-row>
</div>
</el-card>
</div>
</template>
<script setup>
import { MATERIAL_TYPE_OPTIONS } from '../patient-form-options';
import { createEmptyMedicalDictionaryOptions } from '../../../constants/medical-dictionaries';
const props = defineProps({
form: {
type: Object,
required: true,
},
catalogOptions: {
type: Array,
default: () => [],
},
dictionaryOptions: {
type: Object,
default: () => createEmptyMedicalDictionaryOptions(),
},
abandonableDevices: {
type: Array,
default: () => [],
},
showAbandonSelector: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
const createMaterial = () => ({
type: 'IMAGE',
name: '',
url: '',
});
const createDevice = () => ({
implantCatalogId: null,
snCode: '',
shuntMode: '',
proximalPunctureAreas: [],
valvePlacementSites: [],
distalShuntDirection: '',
initialPressure: null,
implantNotes: '',
labelImageUrl: '',
});
const addMaterial = () => {
props.form.preOpMaterials.push(createMaterial());
};
const removeMaterial = (index) => {
if (props.form.preOpMaterials.length === 1) {
props.form.preOpMaterials.splice(index, 1, createMaterial());
return;
}
props.form.preOpMaterials.splice(index, 1);
};
const addDevice = () => {
props.form.devices.push(createDevice());
};
const removeDevice = (index) => {
if (props.form.devices.length <= 1) {
return;
}
props.form.devices.splice(index, 1);
};
const resolveCatalog = (catalogId) => {
return props.catalogOptions.find((item) => item.id === catalogId) || null;
};
const resolvePressureLevels = (catalogId) => {
const pressureLevels = resolveCatalog(catalogId)?.pressureLevels;
return Array.isArray(pressureLevels) ? pressureLevels : [];
};
const formatCatalogLabel = (catalog) => {
return `${catalog.modelCode} ${catalog.manufacturer} ${catalog.name}`;
};
const handleCatalogChange = (device) => {
const catalog = resolveCatalog(device.implantCatalogId);
if (!catalog?.isPressureAdjustable) {
device.initialPressure = null;
return;
}
const pressureLevels = resolvePressureLevels(device.implantCatalogId);
if (pressureLevels.length === 0) {
return;
}
if (!pressureLevels.includes(device.initialPressure)) {
device.initialPressure = pressureLevels[0];
}
};
const formatAbandonDeviceLabel = (device) => {
const implantLabel =
device.implantModel || device.implantName || '未命名设备';
return `${implantLabel} ${device.snCode || '无 SN'} 当前压力 ${
device.currentPressure ?? '-'
}`;
};
</script>
<style scoped>
.section-head {
margin-bottom: 16px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #1f2a37;
}
.section-description {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.block-card {
margin-top: 12px;
border-radius: 14px;
border-color: #d9e4f3;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.block-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.block-title {
font-size: 14px;
font-weight: 600;
color: #1f2a37;
}
.block-subtitle {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.field-hint {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
}
.empty-hint {
padding: 12px 0;
color: #6b7280;
font-size: 13px;
}
.material-row + .material-row {
margin-top: 12px;
}
.material-remove {
display: flex;
justify-content: flex-end;
}
.device-card {
padding: 16px;
border: 1px solid #dbe7f5;
border-radius: 14px;
background: #fff;
}
.device-card + .device-card {
margin-top: 16px;
}
.device-card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.device-card-title {
font-size: 14px;
font-weight: 600;
color: #1f2a37;
}
.catalog-name-box {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
width: 100%;
}
.pressure-level-hint {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
line-height: 1.5;
}
@media (max-width: 768px) {
.block-head,
.device-card-head {
flex-direction: column;
align-items: flex-start;
}
.material-remove {
justify-content: flex-start;
margin-top: 8px;
}
}
</style>

View File

@ -0,0 +1,15 @@
export const MATERIAL_TYPE_OPTIONS = [
{ label: '图片', value: 'IMAGE' },
{ label: '视频', value: 'VIDEO' },
{ label: '文件', value: 'FILE' },
];
export const LIFECYCLE_EVENT_LABELS = {
SURGERY: '手术',
TASK_PRESSURE_ADJUSTMENT: '调压',
};
export const LIFECYCLE_EVENT_TAG_TYPES = {
SURGERY: 'success',
TASK_PRESSURE_ADJUSTMENT: 'warning',
};